プロと読み解く Ruby 3.0 NEWS のJITパート向けに、Ruby 3.0.0のNEWS.mdの僕が書いた部分をゆるくコメントしていきます。
Performance improvements of JIT-ed code
Microarchitectural optimizations
- Native functions shared by multiple methods are deduplicated on JIT compaction.
2.6や2.7のJITではメソッドごとに作る.oファイルを /tmp に残し、一定期間後に.soファイルにまとめてロードし直す (コードの局所性が上がり速くなります) ということをしていたんですが、.soファイルを眺めたところ共通のメソッドが何度もコード生成されていることに気付きました。
これを、.oファイルからリンクするのではなく、各メソッドの.cファイル達を1つの.cファイルに #include してコンパイルするようにしたところ、VMの実装に使われている関数などが重複しなくなり、コードサイズが例えば1.3MB→260KBほど減るようになりました。実行されるコードは何も変えてませんが、Sinatraのベンチが速くなりました。
- Decrease code size of hot paths by some optimizations and partitioning cold paths.
__attribute__((__cold__))
と書いてある関数同士はまとめられてコード生成され、つまりこれが書いてないコード同士もまとめられることになるのですが、例えばスタックオーバーフローになるケースをcoldとすることで、スタックオーバーフローしないようなコードはより速く走る可能性が高くなったりしています。
Instance variables
- Eliminate some redundant checks.
- Skip checking a class and a object multiple times in a method when possible.
インスタンス変数にアクセスするためにはクラスがどれであるかみたいなチェックがVMでは毎回発生するのですが、同じメソッド内でselfのクラスが変わることは ほとんど ないため、メソッドの先頭で一回だけチェックされ、各アクセスの際に再度チェックされなくなりました。ivarをsetする時にfrozenのチェックをする時も、一つその先頭でチェックしているフラグがあったので、それもチェックしないようになりました。Optcarrotはインスタンス変数のアクセスが重いため、ここの変更がRuby 3x3に割と効いています。
- Optimize accesses in some core classes like Hash and their subclasses.
Hashなどのいくつかのクラスはオブジェクトの構造が普通のオブジェクトとは異なり、そういうクラスではオブジェクトの構造体の中にインスタンス変数が埋め込まれたりしません。これはHashなどのクラスだけでなくそれらのサブクラスにも適用されるのですが、RailsなどでHashのサブクラスはよく使われていて、そこでインスタンス変数のアクセスがあったりしたので、そういうクラスも最適化されるようにしました。実際には普通のオブジェクトほど最適化の余地が大きくないのでそれほど速くなってないんですが。
Method inlining support for some C methods
Kernel
:#class
,#frozen?
Integer
:#-@
,#~
,#abs
,#bit_length
,#even?
,#integer?
,#magnitude
,#odd?
,#ord
,#to_i
,#to_int
,#zero?
Struct
: reader methods for 10th or later members
Ruby 3.0ではCで定義されたメソッドにアノテーションをつけることが本体の機能限定でできるようになりました。具体的には Primitive.attr! 'inline'
という感じで使えます。上記のメソッドは、このアノテーションをつけるか、単純な機能は単にRuby化することでインライン化が可能になりました。
対応すべきメソッドはもっとたくさんあるのですが、例外が発生するメソッドを (オーバーヘッドを最小にしながら) インライン化するにはOn-Stack Replacementというテクニックが実装されていないと難しく、これをRuby 3.1で実現することでインライン化可能なメソッドの範囲を広げていきたいと考えています。
- Constant references are inlined.
これまで定数の参照は何も最適化してなかったのですが、Ruby 3.0のJITでは定数の値がソースコードに直接埋め込まれるようになりました。これにより、定数が更新されるまでは、インラインキャッシュの値をメモリから読みにいかずに値がわかり、かつコンパイラがその値と周りのコードにに対して最適化を適用するチャンスが増えています。
- Always generate appropriate code for
==
,nil?
, and!
calls depending on a receiver class.
これらのメソッドは、専用の特化命令内で処理されるクラスと、opt_send_without_blockという命令を使って処理されるクラスがあり、これの判定が2.7ではメソッドキャシュにC関数が入っているかで判定されていたものの、実際にはどちらのケースもCで実装されているので、正しく判定されるようになりました。これらのメソッドで変に遅くなることがなくなります。
- Reduce the number of PC accesses on branches and method returns.
RubyはCtrl-CでSIGINTを送るとなるべく速く終了できるよう、分岐やメソッドからのリターンなど随所に割り込みチェックが入っています。割り込みを処理するためには現在のコンテキストを保存しておく必要があるのですが、割り込みを処理する命令では実装の都合で常にコンテキスト(プログラムカウンタ)を保存するようになっていたのが、割り込みが発生している時だけ保存するようにしました。分岐するコードのほとんどのケースでメモリアクセスが少し減って速くなっています。
- Optimize C method calls a little.
Cメソッドを呼び出す場所ではCメソッドを処理するためのコードだけ生成するようになりました。これはコードサイズは増えてしまっているのですが、それが悪影響を及ぼさない範囲ではCメソッドの呼び出しが高速になっています。
Compilation process improvements
- It does not keep temporary files in /tmp anymore.
最初の.cからコンパイルし直す変更をいれた当時は.cファイルを残すようにしていたんですが、ここで再度コード改変をするとお互いをインライン化するのに便利なのでは、というアイデアがあり、JITされたメソッド全てを1つの.soファイルにまとめる際に.cファイルの生成からやり直すようにしました。実際にはインライン化するとおそらくコードサイズ増大などで実際には遅くなったので結局いれなかったんですが、まあ最新のVMの状態からコードを生成し直した方が良いケースの方が多そうなので、そのままにしました。Rubyが停止されるまで /tmp 汚したままなのも微妙ですし。
- Throttle GC and compaction of JIT-ed code.
不要なJIT-edコードのunloadや、上記の.soをまとめるプロセスは、これが必要と判断される度にトリガーされるのではなく、10回 (--jit-max-cacheの10%) ヒットする度に1回実行されるようになりました。これらはGCなどのロックが必要でパフォーマンスに悪影響のある処理なので、あまり実行されないようにしました。これがOptcarrot 3000 framesベンチマークに効いてたりします。
- Avoid GC-ing JIT-ed code when not necessary.
2.7までは生成コードがunloadされる時呼び出し回数下位10%が容赦なくunloadされるようになっていたのですが、それらよりも呼び出し回数が多いメソッドが実際に存在するわけでもなければ (つまり、どの道再度コンパイルされるようなメソッドなら) unloadされないようになりました。
- GC-ing JIT-ed code is executed in a background thread.
これは書いてある通り。race conditionの修正のために導入したんですが、まあなんか生成コードのGCが走る時にRubyスレッド側は遅くならなくなってるかもしれません。
- Reduce the number of locks between Ruby and JIT threads.
.cファイルを全部作り直すのがあまりにも遅くなったため、全生成コードを1つの.soにまとめるためのコンパイル処理がロックの外になるように構造の見直しが行なわれました。