高速な実行が可能なRuby処理系「monoruby」

monochrome氏(以下、monochrome):monochromeといいます。今日は「機械語で書くRuby処理系のその後」ということで、前回の発表のその後を報告いたします。

自己紹介です。monochromeといいます。(スライドを示して)Twitterはこれです。最近、Twitterは治安が悪いので、Mastodonにアカウントを作りました。RustでRubyの処理系を作っています。プログラミング処理系、言語処理系が好きな人の集まりのSlackとか、最近はこの「Zulip」というアプリに移動して、こちらのコミュニティの中で楽しくやっています。

今日紹介するのは、monorubyという前回ご紹介したものと同じものです。ですが、これはRustで書いているRuby処理系で、インタプリタとJITコンパイラを実装して速く動くというものです。

特徴は、インタプリタをアセンブリで記述していることで、なんでそうしているかというと、それはあとで説明します。現時点では、x86-64のLinuxにのみ対応しています。(スライドを示して)repositoryはこのアドレスです。

実装状況としては、まだ完全ではありませんが、Rubyのオブジェクト指向機能の大半の部分は実装できています。最近実装した部分は、制御構文、クロージャ、ガベージコレクタなどです。

monorubyの基本構造

基本的な構成を最初にご紹介します。通常の処理系と同じように、プログラムを抽象構文木にパースして、バイトコードにコンパイルします。バイトコードは、仮想マシンの仮想機械語みたいなものですね。(スライドを示して)これ(バイトコード)をこのインタプリタが実行します。インタプリタの構造は非常に単純で、バイトコードを1個フェッチしてきて、それに応じたコードを実行して、それをループするというものです。

そこにJITコンパイラがつくと、JITコンパイラという新しいモジュールが(スライドを示して)ここに登場して、必要に応じてインタプリタの指示で機械語へのコンパイルを行います。

この際に、インタプリタが記録したプロファイル情報、つまり実行時に収集したいろいろな情報を参考にして、速い機械語コードを生成します。インタプリタで実行したり、JITコードに移ったり、また戻ったりを繰り返して、実行を進めていきます。

特徴として、インタプリタとJITコードでバイナリインターフェイスを共有しています。それが何かというと、スタックの構造とか、CPUのレジスタの使い方とか、あとは関数の呼び出し規約、コーリングコンベンションですね。Rubyレベルでのメソッドを呼び出す時のバイナリインターフェイスを同じにしているため、インタプリタとJITコードの相互の状態遷移が非常に容易になっています。

スタックフレームの構造

あまり詳しくは説明しませんが、これはスタックフレームです。(スライドを示して)これはCPUのスタック、マシンスタックと考えてください。普通はアセンブリでcall命令で関数を呼ぶと、CPUはリターンアドレスをスタックにプッシュします。そして、呼ばれたこの関数がベースポインタを保存したりスタックポインタを押し下げたりして、ローカル変数の領域を確保して、と実行が進んでいくわけです。

monorubyでも同じように、Rubyレベルでいろいろ必要なメタ情報を全部スタックに格納して実行を進めていきます。Rubyのレベルでメソッドを1個呼ぶたびに、このスタックがプッシュされて、フレームが1個プッシュされるというかたちで実行が進んでいきます。

(スライドを示して)赤い部分が今のメソッドの実行に必要な情報で、青い部分がローカル変数領域です。これは通常スタック上にあるのですが、クロージャを作ったりするとこれがheapにコピーされます。

スクリプト実行後の挙動

具体的にこういうスクリプトを実行するとどうなるか、という話をします。(スライドを示して)aというメソッドがあって、その中でこの5.times。timesというのはdo以下のブロック、つまりプログラムを5回実行するメソッドです。

まずaというメソッドを呼ぶと、aのコントロールフレームがマシンスタック上にプッシュされて、aのローカル変数領域を確保します。

その中でtimesというメソッドが呼ばれると、今度はtimesのフレームをプッシュして、timesの中からこのdo以下のdo~endの間のプログラムを5回実行するわけです。そして、この中身がさらにプッシュされて実行されます。この中から外側のaというメソッドの定義のトップレベルのローカル変数も参照できます。なので、このリンクをつけることで、aのこのレベルのローカル変数も参照できるという仕組みになっています。

ここでクロージャを生成すると、このローカル変数領域が全部heapにコピーされます。このスタック上のコントロールフレームが指されているローカル変数領域のポインタも全部つなぎ変えられることになります。

このメソッドを抜けると、スタックは全部ポップされてなくなってしまいますが、そうなっても$pというグローバル変数に返ってきたクロージャオブジェクトが格納されるわけです。そこからheap上のローカル変数を参照できるので、メソッドを抜けてもこの中のローカル変数にあとからアクセスできるという具合になっています。

バイトコードはどういう形をしているか?

もうちょっと具体的な話で、バイトコードとかがどういう形をしているかという説明をします。(スライドを示して)これは1つの加算命令で、左辺(%lhs)と右辺(%rhs)を足してデスティネーションレジスタに入れるというバイトコードの命令です。全部で16byteの構造体になっていて、それぞれにレジスタの番号がオペランドとして入っています。

あとはインタプリタが記録する型情報ですね。このインタプリタを1回実行すると、左辺と右辺がどういう型だったか、どういうクラスだったかを確認します。そして、バイトコードの中にインタプリタが書き込むためのスペースがここに入っています。

インタプリタはこういうコードを実行します。左辺と右辺の値を取ってきて、両方のクラス、型を調べて、その型をこのバイトコードの該当領域に書き込んで、ヘルパ関数を呼んで、足し算を実行して戻ります。

その後これがJITコンパイルされると、バイトコードに左辺がIntegerでしたよという情報が入っているので、(スライドを示して)こういうコードを吐きます。左辺と右辺を取ってきて、最初に左辺と右辺両方ともIntegerかどうかを確認をします。Integerじゃなかった場合は、インタプリタに戻るというコードを吐きます。左辺、右辺が無事Integerだった場合には、単に足し算をして、それがオーバーフローしたら、またインタプリタに戻ります。

RubyのIntegerは63bit整数なんですが、それを足してオーバーフローした場合は多倍長整数のオブジェクトになるので、もしそうなった場合には、インタプリタで戻って処理を続ける、となっています。

ループがあった場合にどうコンパイルされるか?

ここで1つ具体的な例として、ループがあった場合にそれがどうコンパイルされるかを説明します。ループにはLOOP_START、LOOP_ENDという独自のバイトコード命令があって、ここで囲まれるかたちになっています。

インタプリタはこれを実行するわけです。バイトコードにカウンタが仕組まれていて、実行して1回回るごとに、LOOP_STARTのカウンタが1個進んでいきます。これがある程度進むと……ここだと5回回ると、JITコンパイラが起動します。

インタプリタがコンパイラを呼んで、ここからここまでをコンパイルしなさいという指示を出して、コンパイラが機械語コードをheap上に生成します。

ヘルパ関数を呼ぶと、このコンパイルが行われるんですが、その関数はこのコードの先頭のアドレスを返り値として返してくるので、そこにジャンプします。

ジャンプすると、JITコンパイルされた機械語コードに制御が移って、ここでまたループが始まります。

ループが終了すると、またインタプリタに戻って実行を継続するという具合です。

しかし、必ずしも最後までこの機械語コードの中で実行が進められるわけではありません。場合によっては先ほど説明したような、例えば、足し算でInteger+Integerのはずが、突然String+Stringになったりすることが、動的な言語ではあり得ます。

その場合はJITコードの実行は不可能になるので、インタプリタに戻ります。deoptimization、つまり脱最適化ですかね。それはいろいろな箇所で発生する可能性があるので、それぞれこのdeoptimizeが発生し得る場所ごとに、インタプリタに戻るコードを全部生成しておきます。

最初に説明したとおり、スタックの構造などが全部インタプリタとJITコードで同一なので、戻るのは比較的簡単です。単にインタプリタにジャンプすればそれでおしまいです。バイトコード上のプログラムカウンタをきちんと正しくセットする必要がありますが、非常に簡単に元に戻れます。

例えば、インタプリタをRustで書いていると、行ったり来たりが非常に大変です。JITコードとバイナリインターフェイスが同じだと、非常に恩恵があると考えています。

再コンパイルの時の挙動

monochrome:もう1つ、再コンパイルという話をしたいと思います。JITコンパイルを行う時に、プロファイル情報を参照して速い機械語を吐くという話をしました。例えばインタプリタがループを実行している最中に、実行されていないパスが存在する可能性があるんですね。(スライドを示して)条件分岐で毎回こちらを通るため、通過しないパスがあるのは当然あり得ることです。そうした場合、このプロファイル情報はないということになります。

(スライドを示して)具体例として、フィボナッチ関数があります。これを実行すると、最初のうちはフィボナッチ関数が再帰的に自分自身を呼んでるので、しばらくこの上をぐるぐる回って、後半のほうのメソッドは実行されないんですね。型情報を見てみると、このへんには全部その型がついています。つまり、インタプリタが型情報を収集してくれているのですが、後半の部分は実行されていないので、型情報がまったくない状態です。この状態でコンパイルしろと言われても、困るわけですね。

では、どうするかという話です。先ほどのようなループをコンパイルする時に、プロファイル情報がない枝があった場合、そこでは単にインタプリタに戻るというコードになっています。

実際にこのコードはJITコンパイルを作って、そこにジャンプするわけですけれども、そこをループしている最中に、今度は情報のない枝に実行が進む可能性が当然あるわけです。その場合は、そのままインタプリタに戻って、インタプリタが実行を進めます。

ループの先頭に戻って、またこのJITコードに戻るというのを繰り返します。ここには例によってカウンタが仕込んであるので、JITコードからインタプリタに戻ることが何回か発生すると、再コンパイルが起こります。再コンパイルする時点では、前回通っていなかった枝もインタプリタが実行して情報ができているので、もうちょっと速いコードになるということです。

ベンチマークを紹介

(スライドを示して)最後にベンチマークを紹介します。前半はこのクリックソートとフィボナッチ数列とたらい関数(竹内関数)という、どれも再帰的にメソッドを実行するものです。黄色いのが今回のmonorubyで、左の青はCRubyの3.2.0で一番新しいリリースのもの、真ん中の赤はRubyのJITですね。RubyのJITは標準ではオフになっていて、オプションをつけることで有効になりますが、JITを実行するオプションを有効にしたものが赤です。

CRubyに比べてまあまあの速さになっています。あと、浮動小数点を使うようなマンデルブロート集合の演算とか、nbodyとか、aobenchというレイトレーシングのベンチマークとかも、JITを有効にしたRubyと比べてかなり速くなりました。

という結果になりました。ちょっと早いですが以上です。ありがとうございました。

実行時の情報がない枝をどう扱うか?

司会者:ありがとうございます。すみません。私がここの領域をさっぱりわかっていないため勘どころがなく、いまいち理解できなかったところが多かったです。先ほどのパフォーマンスのものの縦軸はなんですか? あれはたぶん時間なのかな?

monochrome:ごめんなさい、書いていないですね。縦軸は単位時間あたりのイテレート回数です。単位時間あたりの実行回数ですね。だから、高いほど速いということです。

司会者:質問が来た来た。「メソッドを全部再コンパイルするのか、これとdeopt(脱最適化)する時に関数ジャンプにして、この部分だけ再コンパイルするのと、どっちがいいだろうか」。

monochrome:実行時の情報がない枝をどう扱うかという話で、全体を再コンパイルしましょうというのが今回の話でした。この枝の部分だけコンパイルしてつなげばいいじゃない? という話ですよね?

司会者:おそらく。

monochrome:これには善し悪しがあります。コンパイルする時に分岐の情報を使っているので、(スライドを示して)あとからこの枝だけをつけ足すのが非常に難しい。これはコンパイルの仕方によるので難しいんです。全体を見て最適なコンパイルをしようとすると、後付けでここで1個だけ枝を足すのが非常に難しくなります。なので、monorubyでは全体をまるごと再コンパイルするようにしています。

(スライドを示して)コンパイラの作り方によっては、あとからここの枝だけコンパイルして、つなぎ直すということは当然可能なので、それはアーキテクチャによって善し悪しがあるという話ですね。

司会者:ありがとうございます。

マンデルブロートのベンチマークがずば抜けて速い理由

Kernel:ページ29のパフォーマンスのところで、マンデルブロートのベンチマークだけ異常に速くない? ぴょんって飛び出ている。

monochrome:そうですね。浮動小数点の演算の最適化をしているので、それが含まれるものに関しては、かなり速くなっています。

司会者:得意技だったって感じですかね。

monochrome:得意技、そうですね、はい。

司会者:これは確かに異常値に見える。

monochrome:マンデルブロート、ループ、いろいろ話すと難しいんですけど。マンデルブロートは、浮動小数点演算のウェイトが非常に大きいので、かなり速くなっています。

(スライドを示して)その右隣のnbodyとか、その右隣のaobenchも浮動小数点演算です。これは浮動小数点演算だけじゃなく、メソッド呼び出し、インスタンス変数的なRubyのいろいろな機能を使っています。なので、高速化がその分薄まっているということです。

真ん中のマンデルブロートだけは、単にループをしてひたすら浮動小数点演算を繰り返すベンチマークなので、それだけ高速化の恩恵を受けたということですね。ちょっとわかりにくい説明で申し訳ないです。

司会者:いや、今のはかなりわかりやすかった。Rubyで実行する意味があるのか、というのはさておき。

monochrome:そういうことは大人の事情。

司会者:でも、こういうベンチマークを見る時、どういう性能のプログラムを走らせていくかがわからないと、グラフを正しく読むのはなかなか難しいから解説は助かります。

「スタックの形を合わせることで、どれくらい高速になっているのだろう。スタックコードばかり走る場合、インタプリタに返らないから、節約になりにくいのでは」というコメントが来ました。

monochrome:そうですね。Rubyだと、Rubyが使うスタックと、CPUのスタックが別々になっているので、両方ともきちんとハンドリングするのは難しいんですよね。なので、今回の処理系では、全部マシンスタックに統一化してそこでやるという話です。

JITのキャッシュは実装しているか?

司会者:YouTubeのコメントから。「JITのキャッシュとかは実装されているのでしょうか? 聞き逃しだとしたらすみません」。

monochrome:JITのキャッシュ、JITされたコードのハンドリングはまだぜんぜん作りっぱなしです。ひたすらコンパイルしたり、再コンパイルしたものはそのまま作りっぱなしになっています。そこを例えば……。

司会者:全部キャッシュされていますかね(笑)。

monochrome:キャッシュというか、永続化されています。本当はコードを無効にして消したりそこをまた新しく使ったりするのが、きちんとした処理系なんですが、今回はそこまでやっていなくて、作ったJITコード、機械語は作りっぱなしになっていますね。

YJITは高速化の余地がある

司会者:「Ruby版のaobenchは、ほとんどVectorオブジェクトのアロケーションコストですね」。ベンチマークの中身についてコメントされている方がいます。

monochrome:これはそのとおりですね。xyz軸のいわゆるベクトルを格納するオブジェクトを大量に作って捨てて、大量に作って捨ててという感じなので、アロケーションのコストはけっこうかかっています。単に浮動小数点演算だけではなく、そういうオブジェクトを作ったり、捨てたりというところでけっこうコストがかかるという話で、それはそのとおりだと思います。

司会者:JITコードの永続化、無限に太りそう。今は無限に太っていくということですね。

monochrome:そうなんですよ(笑)。このプログラムを実行するにつれて、どんどん太っていくのがちょっと弱点なんです。

司会者:おもしろいな。ちゃんとしたものという言い方はおかしいですが、普通にキャッシュみたいなものを作って、参照カウンタが少ないものとか、あとから普通に押し出されて消えていくみたいなものが、本物版は必要なんですね。

monochrome:そうですね。動的言語だと、実行するにつれていらなくなるJITコードがあるので、どんどんJITコードを作っていくのですが、必要ないものを消していかないといけないので、本当はそのへんをちゃんと作らないといけない、という話ですね。

司会者:逆に言えば、YJITはまだFloat周りで高速化の余地があるということなのかな。

monochrome:そのとおりですね。YJITは何かというと、Rubyの新しいJITコンパイラです。まだ開発が始まって1年とか、2年経たないぐらいだと思うので、これからバンバン最適化していく感じだと思います。

司会者:おもしろいですね。他の実装と実装感を比較することで、これはまだ伸ばせる余地があるんじゃないかみたいな見方ができるのは、いいところなんですね。

monochrome:そうですね。こちらからするとカンニングできるので、こんなことをやっているのかみたいな感じで見られます。あと、比較できるとここがダメなんだなとか、そういうことがわかるので、楽しいですね。

JITの脆弱性とは?

司会者:(コメントを見て)JITキャッシュの話から。「実はうまく管理しないと、スペクタメルトダウン的な脆弱性がある」。確かに理屈上はそういうことになる……なるほどな。

monochrome:そうですね。JITの脆弱性は、きちんとした処理系では問題になることがけっこう多いらしいです。JITの仕組みそのものが悪用されて、なにかの脆弱性につながるという話はネットで見たことがありますね。

司会者:そうなんですね。(コメントを見て)「コードのGCができる気がしない。それを1年くらいで作ったYJITがすごすぎ」。ちょっとすみません、私にはこれはわからないな。

monochrome:例えば、JITで作った機械語コード自体、いらないものを捨てていかないといけません。サーバーとかはずっと動いてると、たまにJITして機械語を作ってということが果てしなく伸びていくので、いずれオーバーフローしてメモリがなくなります。適切な時期に適切なタイミングでJITコードの取捨選択をして、またその領域を再利用したりしないといけません。

司会者:めっちゃ出てくるやんけ、質問(笑)。あれ、なんだろう、これ。YouTubeのコメント、JIT spraying。

monochrome:JIT spraying。わからないですね。これは。

司会者:汚染させるみたいな意味なのかな。

monochrome:脆弱性の文脈かもしれないですね。

司会者:脆弱性の文脈っぽい。JIT sprayingは普通にWikpediaに項目がありますね。では、時間もちょうどいいところになりましたので、終わりにしたいと思います。ありがとうございました。

monochrome:ありがとうございました。