書いた人: ささだ
2005-07-20(Wed) 14:16:58 +0900 プログラムのミスなどを修正しました。
YARV Maniacs の 3 回目です。今回は前回簡単に作ってみた RubiMaVM を拡張して Ruby の VM に化けさせていこう……かとも思ったんですが、大変そうなので、インタプリタを作る上で気にしなければいけない速度的な話をしてみようかと思います。つまり、いよいよ YARV が頑張っている高速化の話になるわけです。
具体的には、結構有名な話なんですけれど、命令ディスパッチの高速化のための最適化です。スレッデッドコードとか言うんですが、トピックとして書きやすいんですね。やることは少ないし難しくないから。この単語を知っている人は、今回は読む必要ないかも。でも、日本語でまとめてある文献としては、もっともわかりやすいものになればと思っていますので、プログラムの最適化、とくに多数ある処理の中から、どこかに分岐する処理の高速化に興味のある人は見ていただけると幸いです。
YARV は去年度、つまり2004年度 IPA(情報処理推進機構)未踏ソフトウェア創造事業未踏ユースに採択(プロジェクト・マネージャ:筧捷彦 早稲田大学教授)され、支援を受けていました。その成果として、一つ前のバージョン 0.2.0 が開発されました (IPA:2004年度「未踏ユース」開発成果:98-02笹田)。
今年度の2005年度未踏ソフトウェア創造事業前期にも応募して二匹目のドジョウを狙ってみたんですが、幸いなことにまた採択していただきました(採択テーマ:「オブジェクト指向スクリプト言語Rubyの処理系の刷新」。プロジェクトマネージャ:千葉滋 東京工業大学助教授。IPA:2005年度上期「未踏ソフト」採択概要:10-17笹田)。去年度に引き続き、YARV の開発を支援していただくことになります。
最大限の努力をする所存ですので、皆様のご指導ご鞭撻のほど、よろしくお願いいたします。
今年度立てた目標は次のとおりです。
実際、この短期間で上記すべての項目を完璧に行うことは困難であるかと予想されますが、上から順にできるだけ完成度の高いものを用意しようと思っています。
ところで、今回のインタビューでは田中さんが私を「外部資金を調達してきて云々」とか言ってますが、外部も何も、収入がそもそもないのでやっとこれで食べていけます。この歳(現在大学院博士課程 2 年生)で親のすねをかじり続けているのも心苦しいものがありまして……。
0.2.1 をリリースしました。外面はぜんぜん変わってないんですが、コンパイラの構造を変えました。あと、細かい修正が沢山入ってて、いつか紹介したい命令統合のアルゴリズムをいろいろと整理しました。
コンパイラの構造は、今までは Ruby のオブジェクトである Array を大量に生成する(たとえば、YARV 1 命令ごとに 1 Array 作っていた)ような構造だったものを、GC が起こったとときに大変遅いことがわかったので(大体100倍くらい遅くなった。数万行のスクリプトを食わせたときの最悪値だけど)、GC が起こらないように自分でメモリ管理するように書き換えました。
Array を使ってたのは楽チンだったから、去年の段階では間違ってなかったと思いますけど、もうちょっと先を考えて独自メモリ管理するような構造に移行しやすいようにしておけばよかったとちょっと後悔しています。
前置きが長くなりましたが今回の主題である命令ディスパッチの高速化について解説します。
そもそも、命令ディスパッチという言葉は耳慣れない方も多いかと思います。似たような言葉でいうと、メソッドディスパッチなんて言葉はよく聞くでしょうか。
メソッドディスパッチはオブジェクト指向でいうメソッドを実際に起動する、レシーバオブジェクトにメッセージを送るとか、いろいろ言いますが、まぁそんな機能です。この辺のモデルも言語によっていろいろ違うので説明しづらいのですが、Ruby だとレシーバオブジェクトがあって、メソッド名があったとき、実際にメソッドを起動するときに正しい挙動をさせないといけませんが、それをさせるのがディスパッチ処理ということになります。receiver_object.send(:method_name, args) なんていう、Object#send の機能は、まさに処理系が行うメソッドディスパッチの機能を如実に表現しているんですね。
メソッドディスパッチはメソッドに応じて処理をさせることでしたが、ということは命令ディスパッチは、命令によってそれに応じた処理をさせることです。簡単ですね。
前回の RubiMaVM では、命令数が少なかったですが、それを case/when で処理を振り分けていましたね。まさに、アレです。
Ruby で高速化の説明をするのはしんどいので(というか無理)、とりあえず C 言語で考えてみます。
ちなみに、実際のプロセッサ内部でも命令ディスパッチの処理は行われていて、これを高速化するためにどうしたこうしたという話もあるんですが、それは横道。フェッチ・デコード・ディスパッチ・エグゼキュート・リタイアなんてパイプラインステージがあって、VM は基本的にはこんなのを模倣しますが、まぁ興味のある人は調べてみてください。今回説明する方法は、もちろん CPU の実際の実装の話とは全然関係ありません。だけど、CPU エミュレータを作る人には関係しますね。ソフトウェアで上記処理をさせるわけですから。
説明の都合上、いろいろと定義してみます。
こういう前提でやってみますね。
ところで、PC を増加させる処理は端折ります。これは、処理の実態、つまり Ia_body() とかに PC を増加させる処理を入れておく、ということですね。なんでこうするかというと、たとえば PC を不規則に変化、たとえばジャンプ命令とかは、その処理の実態の中で PC を変化させるからです。まぁ、PC を変化する処理はあんまり本質的じゃないというのもあるんですが。
さて、何も考えずにディスパッチ処理部分を作ってみましょう。
こうやるのが自然ですよね。C の switch/case で分岐させます。何も疑問の余地がありません。もっとも移植性の高い、素直な方法です。高速化を求める必要がない場合はこれが一番いいです。
もうひとつ、関数ポインタを使った例を考えて見ます。この場合、Ia_body() などはすべて本当に関数として定義してある必要があります。あと、Ia などは 0 から続く定数として定義してあるとします。
ずいぶん処理がシンプルになりましたね。
Ruby で書くとこんな感じ。
この辺は Ruby では常套手段ですよね。
C で関数ポインタを作ると、Ruby で send を利用するよりも、ちょっとわかりづらくなるかもしれませんが、まぁディスパッチ部分がコンパクトになるので、いいかもしれません。
ただ、関数ポインタを用いてディスパッチする方法は、少し柔軟性に欠けるところがあり、たとえば関数間では goto などを用いてジャンプすることはできません。いや、あんまりやっちゃいけない気もするんですが。
あと、今回の主題の高速化という観点からは、C言語の関数呼び出しのオーバヘッドは一般に switch/case での条件分岐よりも負荷が大きいです1。
ところで、C++ のコンパイラは実装によっては上記みたいなことをメソッドディスパッチとしてやりますね。そういえば、Ruby のメソッドディスパッチもまぁ似たようなものかも。
とりあえず今回は C 言語の switch/case をいかにして高速化するか、ということを考えてみます。
switch/case による分岐は、分岐先が少ない場合、実際の機械語としては if 文の並びのようになります。case で指定している値が密に詰まっている(たとえば、0 〜 15 で分岐する)場合には、2分岐の構造をとればそんなに負荷になりません。ちょっと例。
こんな C 言語プログラム(さっきの命令ディスパッチの例ではないです、念のため)は、
こんなふうにコンパイルすると、最悪 15 回 if 文を実行_しない_で済みます。
でも、やっぱりこの場合、 4 回ほど分岐をしていまいますね。なんとかしたい。
そこで、C 言語のコンパイラは、飛び先をテーブルにして、ちょうど関数ポインタによる命令ディスパッチの例のように、飛び先をテーブルに格納してジャンプできるようにします。今度はちゃんと iseq[PC] 命令に応じた処理を行うための命令ディスパッチの処理として考えてみますね。
機械語に直した場合、ジャンプ命令はプログラムカウンタの操作、つまり数値の設定ですから、具体的な飛び先でなくても、実際に指定すべき飛び先の値を知ることができ、実際にジャンプする命令を生成すれば、いちいち条件分岐をする必要がなくなります。
で、それをあらわしているのが「飛び先(Ia)」で、ラベル Ia の命令番地をあらわすことにして、table という表にその飛び先を格納しており、goto_by_ptr という式でその値へジャンプすることを表しています。これらは今定義した擬似コードです。
で、C 言語のコンパイラはだいたいこんな機械語を吐くんですね。つまり、これは何も考えずに switch/case をした場合で、ここまでは何も考えずに高速化してくれます(多分。コンパイラによるかもしれないけれど)。
高速化してみると言いながら、何もしてませんねぇ。何もしなくても、C コンパイラは賢いからこれくらいやってくれる、という教訓なのかもしれない。
では、上記プログラムをどうやってもうちょっと高速化するか、考えてみます。
C コンパイラがやってくれる最適化のどの辺に無駄があるのか考えてみると、毎回 goto loop_start とやっているのが無駄な気がします。無駄なんです。無駄だと思ってください。ということで、こんなふうにしてみます。
毎回、goto loop_start という、最初の位置に戻る処理を省いてみました。これはジャンプ先の式をコピーする最適化の一つなんですが、これによって、goto loop_start としなくて済む分、ちょっと速くなります。
ただし、一般に goto_by_ptr で示すような間接分岐、つまりジャンプ先を数値で示すような処理は機械語命令が大きくなるので、生成される機械語のコード量は増えます。組み込み機器などのリソースが制限されている環境ではちょっと嫌ですね。でも、Ruby はとりあえず最近の PC 上で動かすことを目標としているので、コード量なんて気にしない。
いままで擬似コードで書いてきましたが、上記プログラムは GCC 上(2以上)では C 言語プログラム拡張として記述することができます。具体的にはこんなふうになります。
&&ラベル名、でラベルを値として扱います。その値としてのラベルにジャンプするには goto *value と記述します。
まぁ、めったに使うことはないんですが、インタプリタみたいなプログラムを作るときに便利な機能です。というか、そのために入れたんだろうなぁ。
で、こういう命令ディスパッチをするプログラムを_スレッデッドコード_、もしくは次に述べるものと比較してトークンスレッデッドコードと言います(用語はThreaded Code による)。
えーと、実はもうちょっと頑張れるような気がしませんか。お前は頑張ればできる子だってお父さん信じてたんだよ、って感じで。
ジャンプするとき、毎回テーブルをひいてますよね。goto の部分を分解して書くと、次のようになります。
もっと頑張れば、
までいけると思いませんか。つまり、iseq という配列の中身を、命令のインデックスじゃなくて、命令の飛び先番地に置き換えてあげれば毎回 table をひく手間はなくなります。
置き換え自体は簡単にできますね。
で、iseq_ptr という配列に実際にジャンプする先の番地が格納されることになるので、事前にこの変換をやっておけば、
こういうプログラムで済むことになります。これを_ダイレクトスレッデッドコード_ といいます。
お疲れ様でした。本稿で紹介したいのはここまでです。YARV では、コンパイラが GCC でこの機能を持っている場合、ダイレクトスレッデッドコードで VM を作ります。
さきほど_事前に変換しておけばいい_と言ったんですが、それはそんなに簡単なことでしょうか。実際に GCC 用にプログラムを書けばわかるんですが、そのラベルが定義してある関数の中でしか、ラベルの参照ができません。
命令を実行する関数と命令を変換する関数は別々にする場合が多いでしょうから(そうじゃないのも考えられるけど……)、なんとかして table を変換用の関数に渡さないといけません。
YARV では、
こんな感じで対処しています。つまり、VM の処理を実行するパス以外に一個、テーブルを得るためだけのパスを用意するということですね。
GCC 3.3.5 for cygwin (x86) でコンパイルすると、上記プログラムはこんな感じでコンパイルされます。
わずか2命令でディスパッチができるわけです。
%esi についてちょっと補足しておくと、YARV では、命令アクセスに iseq[PC] という表引きをするのであなくて、pc = &iseq[0] のようにしておいて、*pc で現在の命令をひくことができるようにしてるんですね。
で、その pc の値を、これも GCC 拡張の変数のレジスタ割り当て機能を利用して %esi (ESIレジスタ)に割り当てているんですね。
見た目だけで判断すると、スレッデッドコードの利点は goto が一個減っただけで、なーんだ1命令少なくしただけかよ、と思われるかもしれません。が、この最適化はもうちょっとうれしいことがあります。
ちょっと関係ないようなことを書きますが、我慢してついてきてください。きちんと関係あることですから。
近年のプロセッサには_分岐予測_という機能があります。ある程度予測をしながら実行しないと遅くてしょうがないからこういう機能をつけてるんですが、だいたい 90% 以上その予測は当たっているらしいです。また、それをもっともっと上げるために、分岐予測器を研究してる人たちがいます。この予測成功確率を 1% 上げると、結構性能は上がるらしいです。近年のプロセッサ(要するに Pentium の速い奴とか)は、パイプラインがどうの、とか I-Cache がどうの、とか色々理由があって、まぁ分岐予測が外れると結構遅くなります。どれくらい遅くなるかは専門書を読んでください。
で、分岐予測ですが、その方法は単純で、「前ここであそこにジャンプしたから、きっと次もあそこにジャンプするに違い無い」というものです。命令番地 x のジャンプでは a にジャンプしたから、きっと次の x でのジャンプは a にジャンプするだろう、と予測するわけです。(条件分岐の場合は、もうちょっと違いますが)。
キャッシュメモリと同じですね。専門用語っぽく言うと、プログラムの挙動は一般的に時間的局所性が高いので、それを利用してるんですね。
で、この方法はたいていの場合うまくいきます。だからこその 90% 以上のヒット率になるわけです。だけど、ここで switch/case が生成する擬似コードを思い出してください。
これでした。間接ジャンプをするのは先頭の一箇所(loop_start の後の goto_by_ptr)だけですから毎回分岐が外れます。たとえば、Ia、Ib と続いていたとすると、まず Ia にジャンプしたので次も Ia に行くんだろう、と予測したら、実は Ib に行く、という感じです。まぁ、そんなわけで分岐予測が外れるので、最近のプロセッサには結構ダメージがでかいです。
スレッデッドコードは、間接ジャンプする部分は複数にばら撒いてますから、ある程度分岐予測が効く可能性があります。というか、Pentium M 相手だと結構効きました。
また、これをさらに推し進め、同じ命令でも Ia_1、Ia_2、…と複数処理部分をわけておけば(それぞれ同じことをする)、分岐予測が効くようなインタプリタを生成することは可能です。YARV はそこまではやってませんが、今後紹介していくだろうスタックキャッシングは似たような効果を生みます。
まぁ、icc (Intel C Compiler)なんかは、switch/case をきちんとスレッデッドコードくらいには落とすのかもしれませんねえ。試してないから知りませんが。
GCC 3.3.x for x86 では、上で見たきちんと効率のいいコードが生成されるんですが、GCC 3.4.x ではなぜかどう頑張っても goto loop_start 相当のコードが生成されてしまします。何の為のダイレクトスレッデッドコードやねん! という感じです。
YARV ではしょうがないから、x86 限定で間接ジャンプ命令を asm 文で挿入することにしました。ポータビリティは悪くなりますが、まぁよく使われそうな環境に特化した最適化ということにしておいてください。変更箇所が小さくまとまってるので、ぞっとするほどひどいコードにはなってないと思います。
この辺のコードは YARV の vm.h というファイルに記述されています。興味がある人は見てください。
YARV でやっている命令ディスパッチの最適化はダイレクトスレッデッドコードまでですが、もっと頑張ろうと思えば頑張れます。
たとえば、JIT コンパイルはこの議論を根底から覆します。具体的には、VM命令の命令ディスパッチを必要としなくなります。でも、これやるの大変だから、当分は YARV ではパス。
実行時に各命令処理部分をくっつける dynamic superinstruction という最適化は、各命令の実際の処理部分 Ia_body()、… を実行時にコピーして繋ぎ合わせることで命令ディスパッチ部分をなくそうとしています。作るのが楽そうなので、これは YARV でもやりたいなぁと考えています。というか、ちょっと簡単な JIT コンパイラとして作ったんですが、あんまりうまくいっていません。これについては機会があればご紹介したいと思います。
命令ディスパッチが嫌なら事前に機械語コードに落とせばいいじゃん、ということで YARV では Ruby プログラム -> YARV 命令列 -> C と変換するコンパイラを用意しています。こうすれば、命令ディスパッチ自体のオーバヘッドは皆無です。
本稿では命令ディスパッチの高速化として、スレッデッドコードを紹介しました。このテクニックは YARV では利用できるなら利用します。そもそも、switch/case を利用した命令ディスパッチとスレッデッドコードを利用したプログラムは、あんまり見かけは変わらない(ように書ける)のでちょっとマクロで書いておけばその切り替えは簡単にできます。だから、これをやってる VM や処理系は結構多いです。
あんまりカリカリに高速化する必要が無いプログラムでは、ここまでやる必要はないでしょうね。途中で見たとおり、C コンパイラも結構頑張ってくれますから。
さて、次回は何しようかなぁ。まだ考えていない。具体的な命令の説明をしていないので、その辺からやろうかなぁ。まぁ、お楽しみに。
今回えらそうに書いたことは、すべて広く知られています。
スレッデッドコードについては Anton Ertl 氏の Threaded Code(和訳:Threaded Code)に詳しくまとめられています。この Anton Ertl 氏というのがインタプリタの高速化の凄い人で、いろんな論文を発表しています。彼は本稿でも紹介した「同じことをする命令を複数用意して分岐予測ミスを減らす」というテクニックを詳細に検証しています(Optimizing Indirect Branch Prediction Accuracy in Virtual Machine Interpreters)。また、この論文には分岐自体を無くす dynamic replication、dynamic superinstruction (Combining Stack Caching with Dynamic Superinstructions)手法も提案しています。要するに、命令実行部分をコピーすることですが。この手法は selective inlining としても知られています(Optimizing direct threaded code by selective inlining)。
知られています、ってのはなんかかっこつけすぎですね。論文として提案されています。
ささだこういち。学生。
前号のるびま人気投票でなぜか一番で、投票の無意味さを感じる最近。もしくは、投票の設置場所(私の日記)の特殊性を噛みしめたと言うところか。なんか今月になって文章をかいたりとか、プレゼンを作ったりとかばかりな気がする。そろそろウンザリ。まぁいいか。
もちろん、環境ややりたいことにもよります。無条件にこの方法はまずい、とは思わないでください。 ↩