2023年12月25日、Ruby 3.
RubyはJust-In-Time--yjit
と--rjit
で有効化できます。
この回では、Ruby 3.
YJIT
YJITは現在様々な企業の本番環境で利用されており、最大40%の高速化が報告されています。RubyはJITコンパイラなしでもWebアプリケーションなどの実装では十分な性能の出る言語ですが、アプリケーションの実装を書き換えずにYJITを有効化するだけで高速になるわけですから、これを活用しない手はありません。現在開発中のRails 7.
JITコンパイラは実行速度を改善する一方で、機械語やその管理のためにより多くのメモリを消費するというトレードオフがあります。これはJavaやJavaScriptといった他の言語の処理系にも一般に当てはまる問題です。しかしRubyは並列化のために複数プロセスが使われることの多い言語ですから、YJITが消費するメモリの量は並列化に使用するプロセスの数分倍になってしまいます。そのため、Ruby 3.
Ruby 3.
より多くのコードを生成
Ruby 3.
- VMのスタック操作に新たにレジスタ割付が実装された
- 本番環境やベンチマークでよく使われる様々なCメソッドに特別な最適化が実装された
- 簡単な定義のRubyメソッドがインライン化できるようになった
これにより、より多くのタイプのメソッド呼び出しやインスタンス変数アクセスが高速化されるようになりました。
よく多くの種類のコードがコンパイルできるようになった副作用として、様々なアプリケーションにおいて、Ruby 3.
これによるYJITが使用するメモリサイズへのインパクトはアプリケーションによります。たとえばRailsのベンチマークでは、Ruby 3.RubyVM::
の変化を監視しておくと良いかもしれません。
メタデータの省メモリ化
YJITが消費するメモリには、code_
でカウントされるyjit_
でカウントされるyjit_
は一般にcode_
に比例する傾向にあります。
Ruby 3.code_
が増えるような変更が行われ、また新たなレジスタ割付のためのメタデータが新たに管理されるようになったため、yjit_
も増えてしまうことが考えられます。しかし、Ruby 3.
そのため、ベンチマークによっては、code_
とyjit_
の合計はRuby 3.RubyVM::
はRuby 3.code_
とyjit_
の両方を監視しておくと、YJITのメモリ使用量のインパクトが理解しやすくなります。
Code GCの無効化と--yjit-exec-mem-size
YJITが生成するコード量は--yjit-exec-mem-size
でコントロールできます--yjit-exec-mem-size
に達すると、YJITはデフォルトで以下のような動きをします。
- Ruby 3.
2:すべての生成コードを破棄し、以降呼ばれたメソッドをコンパイルし直す。 - Ruby 3.
3:新たにメソッドをコンパイルしなくなる。未コンパイルのメソッドはインタプリタ実行される。
Ruby 3.--yjit-exec-mem-size
が小さすぎると、頻繁にコンパイルし直すコストによってアプリがむしろ遅くなる場合があります。
こういった問題にヒットしにくくなるよう、Code GCはデフォルトで無効になりました。これの最大の利点は気軽に--yjit-exec-mem-size
が下げられるようになった点で、code_
の変化を参考にしつつ、--yjit-exec-mem-size=32
のような設定を使うのが現実的な選択肢になりました。
参考までに、筆者が所属しているShopify社内最大のアプリケーションであるモノリスでは、デフォルトである--yjit-exec-mem-size=64
でそれより大きな値とほぼ同じ性能が出ました。--yjit-exec-mem-size=32
では性能は多少劣化しましたが、そこまで大きな差ではありませんでした。このモノリスは大変大きなアプリケーションですから、読者の環境ではこれより小さな設定で十分な性能が出るかもしれません。
RubyVM::YJIT.enableを使った省メモリ化
YJITは、Ruby 3.--yjit
や環境変数RUBY_
で有効化するしかありませんでした。しかしRuby 3.RubyVM::
を呼び出すだけでYJITが有効化できるようになりました。
これを使ってYJITの起動を遅延させることで、アプリ初期化後は使われないコードのコンパイルを避けメモリ消費量を削減できます。Railsのイニシャライザから使うだけでも効果はありますが、理想的にはUnicornのafter_
やPumaのafter_
から呼び出すのが望ましいです。
なお、--yjit-exec-mem-size
などのチューニングオプションを指定するだけでも起動時にYJITが有効化されるため、その場合も遅延起動するには--yjit-disable
を明示する必要があります。
RJIT
RJITはRubyのJITコンパイラの最適化実験を行なう環境を提供することを目的としたJITコンパイラ基盤で、Rubyで書かれたJITコンパイラを提供しています。Ruby 3.
RJITがMJITを置き換えた理由
MJITはRuby 2.
YJITが追加されてからも、Ruby 3.
- Rubyで書かれているため、Rustで書かれているYJITに比べ最適化アイデアの実験が容易である。
- MJITをモンキーパッチすることで、MJITではない独自のJITコンパイラを実装する基盤にできる。
Ruby 3.
一方、MJITは実行時にCコンパイラを起動する非常にユニークなデザインであったことから、それを可能にするための特殊な実装が随所で必要で、時にはYJITの改善も困難にするような保守性の問題がありました。RJITは、その問題を解決しつつ上記のメリットを残すべく作られました。RJITはYJITをRustからRubyに書き直したようなデザインになっており、そのためRuby VMに必要な基盤がYJITと共通化されることでシンプルかつ高速になり、またYJITに活かす最適化アイデアの実験により適した実装になりました。
RJITとYJITの使い分け
RJITはYJITの開発を助けることを主目的とした実験的なプロジェクトであり、本番環境ではRJITではなくYJITを使うことが推奨されます。RJITにYJITにはない最適化が実装されることもありますが、それがベンチマークに有意差をもたらす場合は基本的にその最適化はRubyのリリース前にYJITに移植されています。そのため、リリースされるRuby上では常にYJITがRJITより速いことが期待されます。
RJITは現在x86_
RJITで独自のJITコンパイラを実装する方法
RJITはそれ自体がJITコンパイラを提供しており、これはRJITの基盤が十分な機能を維持していることのテストに役立ちますが、基本的にはそれとは全く異なるJITコンパイラに差し替えて使うことが想定されています。RJIT基盤上に実装されたJITコンパイラとしては、たとえばtenderjitやhawthjitなどがあります。
ここでは、RJITの基盤を使って独自のJITコンパイラを作って動かす方法を紹介します。Rubyを--rjit
つきで起動すると、あるメソッドの呼び出し回数が10回に達した時、そのメソッドを引数に取りRubyVM::
が呼ばれることによってJITコンパイルが行なわれます。そのため、このメソッドをモンキーパッチで上書きすることによってJITコンパイラを全く別のものに差し替えることができます。
具体的には、以下のようにします。
module RubyVM::RJIT
Compiler.prepend(Module.new {
def compile(iseq, cfp)
cb = CodeBlock.new(mem_block: C.mmap(4 * 1024), mem_size: 4 * 1024)
asm = Assembler.new
# Pop cfp: ec->cfp = cfp + 1 (rdi is EC, rsi is CFP)
asm.lea(:rax, [:rsi, C.rb_control_frame_t.size])
asm.mov([:rdi, C.rb_execution_context_t.offsetof(:cfp)], :rax)
# return true
asm.mov(:rax, Qtrue)
asm.ret
iseq.body.jit_entry = cb.write(asm)
end
})
enable
end if RubyVM::RJIT.enabled?
def compiled = false
p compiled #=> true
JITコンパイラを差し替える最中にRJITが有効になっていると、差し替えの最中にRJITが少しコンパイルを行なってしまうリスクがあります。Rubyを--rjit-disable
つきで起動することでRJIT無効の状態でRubyを立ち上げ、JITコンパイラを差し替えた後に上記コードのようにRubyVM::
を呼び出すことでそれを防ぐことができます。
上記のコードを--rjit-disable --rjit-call-threshold=1
で実行すると、#compiled
の初回の呼び出しで自前のJITコンパイラが起動されます。これは本来false
を出力するスクリプトですが、このJITコンパイラは何をコンパイルしてもtrue
を返すように実装されているため、true
を出力します。
ここで使われているRubyVM::
の使い方や具体的なJITコンパイラの記述方法はruby-jit-challengeというリポジトリで解説しています。興味のある方はこちらをご覧ください。
まとめ
Ruby 3.