書いた人:ささだ
YARV: Yet Another RubyVM を解説するこの連載。今回は (まだまだ) 命令セットの紹介の続きで、分岐を解説します。if 文とか、お馴染みのああいうやつです。ご想像通り、今回の内容はとっても簡単です。気楽に読んでみてください。
さて、本編に入る前に恒例の与太話を。
一部では YARV が 1.9 にマージされるのは今年のバレンタインデーだ、という話もありましたが、実際には 1.9 へのマージはもうちょっと先になるかと思います。積み残している作業は、リファクタリング関連で、特に API 名やファイル名などが統一がなされていない、ということで、この辺をしっかりと見直してからということになりそうです。まさに、名前重要、と言う感じです。CVS だと、ファイル名の整理とかしづらいので面倒ですね。いっそ、Ruby 開発で利用するバージョン管理システムを Subversion に移行してくれないかな思っているんですが。
さて、移行の具体的な方策として、とりあえずまつもとさんに YARV のコミッターになってもらいました (YARV Merged Matz)。まつもとさんに実際に手をつけてもらうことで、現実味が増したんではないかと思います。Ruby 2.0 も大分見えてきましたね (そういうことにしておきたいのです)。
さて、sheepman さんからのバグ報告 (と、その fix)などにより、大分 YARV も安定してきました。今 test-all をすると
こんな感じでした。まだちょこっと通らないテストも残ってますが (大半は Ruby の不思議な不思議なブロックパラメータ)、動かない機能が大分減ったという印象です。あなたも YARV を使ってみませんか?
ちなみに、バグ報告は yarv-dev へどうぞ。積んでるバグの一覧などは YARV Bugs で確認できます。
ところで、RedHanded でも記事になっていましたが (Mauricio Skips Recursion With YARV’s Hidden Levels!)、秘密の VM コンパイルオプションを使うと、YARV ではアッカーマン関数が 2桁以上高速に動くんだそうです。凄いですね。これを見つけた Mauricio が凄い。
どんなに難しい、計算オーダの大きな問題も定数時間で答えちゃう、という話です。でも、答えが 42 だってわかっていないとダメなんですけどね。
まず、分岐について説明していく上で必要なことをまとめておきます。簡単にいうと「どうやって分岐するのか」ということと、「どこへ分岐するのか」ということです。
ちなみに、ジャンプとか分岐とか言っている操作は VM のプログラムカウンタ (PC) を操作して命令列中の任意の地点から実行を開始する操作をいいます。
YARV には条件ジャンプ命令 if 1 と unless、無条件ジャンプ命令の jump があります。
条件ジャンプ命令は、読んで字のごとく条件によってジャンプしたりしなかったり、という命令です。条件によってジャンプ先を変えるわけではありません2。条件分岐命令 if はスタックからひとつ値をポップして、もしそれが真ならオペランドで指定された分岐先へジャンプし、そうでなければ何もしません。unless 命令はその逆です。
Ruby における真偽の判断は、値が nil か false なら偽、それ以外なら真ということになります。C 言語レベルでは RTEST(val) というマクロが用意されています。
YARV には否定の値を得るための not 命令があります。not 命令はスタックトップの値が真なら false を、偽なら true をスタックトップに置く命令です。not 命令があるならば、if / unless 命令はどちらか一方あればよいことがわかります。つまり、if 命令は not と unless という命令の並び、unless 命令は not と if 命令の並びで実現できるからです。
ただ、性能的な観点からと、コンパイラを作るのが楽だったという理由で (どちらもたいした事じゃないんだけど) 両方の命令を設けました。
無条件ジャンプ命令 jump は、問答無用で分岐先へジャンプします。いわゆる分岐ではありませんが、ここにまとめて書いておきます。
YARV におけるローカルな分岐命令、ジャンプ命令はこの 3 つだけです。何も難しいことはないですね3。
さて、分岐先ですが、どのように指定するかというと、現在の PC からの増分値として指定します。たとえば、10 番地にある無条件ジャンプ命令 jump が 20 番地へジャンプしたければ、
という命令列 (内部表現) になります。ここで +10 したいのに 8 を指定しているのは、「jump 8」という命令表現自体が 2 番地分消費するので、8 + 2 で 10 番地分進める、という意味にするためです。ちなみに、PC が減る方向にジャンプするときはこの増分値が負の値になります。
さて、上記で「内部表現」という言葉を使いました。なんでこんなことを言うかというと、YARV の逆アセンブラはこの場合、「jump 20」という命令を表示します。逆アセンブルした結果を見るとき、いちいち PC と増分値を足すのは面倒ですよね。
しかし、分岐先を数字で示しても、わかりづらいことこの上ありません。そこで、これからの擬似コードでは分岐先をラベルで表現します。C 言語などでお馴染みで、アセンブラでもお馴染みです。たとえば、
という命令列は、「…」で示している命令列をすっ飛ばして LABEL の位置へジャンプする、という意味です。分岐先のラベルは「(LABEL名):」と表記し、インデントをひとつ下げて表現するようにします。
本連載でも以前からこんな表記を使っていたような気がしますが、一応ここで定義しておきました。まぁ、見てわかるよね。
さて、これで基本はおしまいです。簡単ですね。
では、説明した命令を利用して Ruby の if 文などをどうやって表現するか見てみましょう。
とっても簡単な if 文の例である
という命令を見てみましょう。ところで、COND とか THEN_BODY とか、全部大文字で書いてありますが、全部大文字の部分は任意の Ruby 式がまとまっていると思ってください。なんでもいいです。たとえば、COND に if 文が入っていても問題ありません。
これを YARV 命令列で表現すると、次のようになります。
ここで <COND> と <THEN_BODY> は COND と THEN_BODY をコンパイルした結果ということを示すことにしておきます。
処理を見てみると、<COND> 式はスタックにひとつ値を積み、unless 命令で、もしその値が偽なら ELSE ラベルへジャンプする、ということになります。馬鹿にするな、というほど簡単ですね。
では、ELSE 節があるときにどうなるか見てみましょう。次のような Ruby プログラム
は、以下のような YARV 命令列にコンパイルされます。
今度は jump 文が増えました。これも、説明するよりも見てもらったほうが早いですね。<THEN_BODY> を実行してから、後に続く <ELSE_BODY> をスキップするために END へジャンプしています。簡単ですね。
次に、Ruby 玄人も打ち間違える elsif を含んだプログラムを見てみましょう。
このプログラムは、以下のプログラムと同じです。
これを、先に説明したものと同じようにコンパイルするだけです。
最後にラベルが複数重なっていますが、結局同じ番地を指していることになります。ちょっと込み入っていますが、やっていることは単純ですね。
では、具体的な Ruby プログラムで実験してみましょう。
このプログラムを実際にコンパイルすると次のようになります。わかりやすいようにコメントを入れておきました。
まぁ、長いですけどそのまんまだし、読めばわかりますよね。
if 文の次は unless 文を説明します。いや、説明しようと思ったんですが、if 文で出てきた unless 命令を if 命令に変更するだけなので省略します。
if 文で unless 命令、unless 文で if 命令、というのがちょっと面白いですね。
Ruby は後置の if 文、unless 文が記述できます。たとえば、
の代わりに
と書けます4。
後置の式は、そのまま普通の if 文に置き換えられるため、YARV のコンパイラは普通の if 文として扱います。というか、パーサが普通の if 文と変わらないように構文木を作るため、違いがわからないということになります。
ついでにいうと、三項演算子 (a ? b c : d みたいなの) も同様に、普通の if 文と同じようにコンパイルされます。
Ruby には and もしくは &&、同様に or もしくは | という演算子がありますが、これも条件分岐と言えるでしょう。 |
は、
とほぼ同じ意味 (仮に作った変数 tmp が余計) になります5。
これを YARV 命令列にしてみると次のようになります。
tmp のような一時的な変数の代わりにスタックトップをこの目的に使っています。そのために dup 命令によって値を複製してスタックに積んでいます。
or もちょっと変わっただけです。a or b or c を if 文で表すと次のようになります。
これを YARV 命令列で表すと次のようになります。
まぁ、ゆっくり考えれば簡単ですね。
さて、繰り返しは条件分岐と無条件分岐を組み合わせれば作れるのは誰でも知ってますよね。たとえば、C 言語では、
という繰り返しは
と表現できます。釈迦に説法ですみません。
さて、我らが Ruby では while と until 文を使って繰り返しを表現できますが、while と until は if と unless のように条件文を逆にすればいいだけなので while 文だけ説明します。
早速ですが Ruby プログラム
は、YARV 命令列では次のようにコンパイルします。
後置の while では、必ず一回は LOOP_BODY を実行するので最初の jump 命令がなくなります。
さて、LOOP_COND の処理を、LOOP_BODY の前に持ってきてもいいんですが (初期の YARV はそうしていました)、そうすると次のようになります。
繰り返しのたびに「jump LOOP_START」を余計に行うことになります (先の例だと最初の一回だけ)。まぁ、たった 1 命令なんですが、されど 1 命令ということで、今のようにしています。
具体的な例を示しましょう。
このような Ruby プログラムでは次のような YARV 命令列にコンパイルされます。
さて、分岐における条件式は、ちょっとの工夫で最適化が可能です。その方法を少し紹介してみます。
たとえば、if 1 という式があった場合 (あまり用途が考えられませんが)、その条件式は必ず真になります。while true という表現で無限ループを作るという例なら見かけるかな? さて、そういう場合、if 命令などを利用するのは無意味なので、無条件ジャンプ命令になります。nil、false なら分岐しないので、そもそも if 命令自体が消えます。
本当は、if false; BODY; end の場合、BODY をスキップするための jump 命令も省略できるのですが (不到達ブロックの除去)、面倒なのでやっていません。
たとえば if !expr という条件があった場合、これは unless expr と同じ意味になります。
具体的な例としては、
という Ruby プログラムは
となります。
and 式、or 式は値を返す式ですが、条件文として使われる場合、その値は重要ではなくその結果の真偽値が問題になります。
たとえば、素直に実装すると、
は
となりますが、この命令列はちょっと冗長なので、
とすることが出来ます。
具体的な例を出してみましょう。
は、次のようにコンパイルされます。
条件式に or や and はよく出てくるので、この最適化はしっかりとしたいところです。ちょっと面倒そうですが、少し問題を一般化すればこの最適化を簡単に行うことが出来ます。
A and B という式が条件式だった場合、真なら then_label、偽なら else_label へ飛ぶということにすると、条件式を
というプログラムを生成すればいいことになります。
or の場合は
というプログラムを生成します。
さて、これを
という式に適用してみると、
となります。(*) で示したところは次の命令に無条件ジャンプしろ、という命令なので、無駄ですね。というわけで、こいつは削ります。
求める結果が得られました。
ここでは and と or を引き合いに出していますが、not もこれで一般化できます。if not A というプログラムは
というようになります。
これを一般化したものは compile.c の compile_branch_condition() という関数にまとめられています。上述した「リテラルだけの条件式」は無条件ジャンプにする、という最適化もこの中にまとめています。
compile_branch_condition() は引数として、条件文としてコンパイルしたいノード (node) と、もし条件が成立したときに飛ぶジャンプ先 (then_label)、そうでない場合に飛ぶジャンプ先 (else_label) を取ります。たとえば、if !expr というプログラムがあった場合、条件文としてコンパイルする式 (node) には !expr が指定されます。if !expr は unless expr と同じですから、この結果はcompile_branch_condition() を then_label、else_label 逆にして、node を expr として実行したものでいいということになります。not 命令を見事に省けましたね。まぁ、詳しくはソースを読んでください。
上述したように、「次の命令へジャンプする無条件ジャンプ命令」は、削ってもまったく問題ありません。というわけで、こういうのは削ることにします。こういう、コンパイル結果をちょこっとだけ見てちょこっとだけ命令列を変更する手法をピープホール (のぞき穴) 最適化といいます。
他にも、分岐関連では次のようなピープホール最適化をしています。
言葉だけだとちょっとわかりづらいですが、コードを見ると、ああ、なるほど、とわかるかと思います。
ちなみに、これらの最適化は自分で考えたものもあるし、人から教えてもらったものもあるんですが、全てよく知られているものばかりです。調べるより自分で考えたほうが早い、というのもあるんで、あまり真面目に調べていないんですが。何か、これはいいぞ、という方法があったら教えてください。
今回は YARV における分岐について説明しました。
このあたり、分岐のあたりは考えることがなくとても簡単で、特に最適化について考えやすい部分なので、ついつい色々と考えてしまうのですが、分岐命令を一つ減らしてもあんまり速くなんないんですよね。でも、楽しいので考えてしまう。
さて次回はまだまだ命令セットの説明ですが、まだネタが思いつかないので未定です。えーと、お楽しみに。
ささだこういち。学生。
先日紙の雑誌に記事を書かせてもらったんだけど、るびまの記事執筆がいかに楽かを痛感しました。るびまでは識者のチェックが入るし、リンクが貼れるし、何より分量を気にしないでいい。紙の雑誌のほう、色々ご迷惑をおかけいたしました。まぁいいか (いや、よくないよ)。
if a if b if tmp = c tmp end end end ではありません。返り値を考えてみてください。 [^6]: 先ほどのプログラムで最後に p :ok と書いていたのは、end の置き換えを回避するためでした。