RubyがJITコードを実行するメカニズムを読み解く(翻訳)
YJITが導入されて以来、私はRubyのJITについて親近感と気後れを同時に感じていました。自分のRubyプログラムでJITを有効にする方法も、Rubyプログラムの一部を機械語にコンパイルすることで高速化できることもわかっていますが、YJITやRubyのJITコンパイラ全般についての私の理解はそこ止まりだったようです。
数か月前に同僚のMax Bernsteinが、RubyにZJITがマージされたという記事を公開しました。この記事では、ZJITがRubyのバイトコードをHIR(high-level intermediate representation: 高水準中間表現)やLIR(low-level intermediate representation: 低水準中間表現)にコンパイルしてからネイティブコードにコンパイルする方法について解説しており、JITコンパイラがプログラムをコンパイルする方法が多少なりとも明らかにされています。私が7月からZJITに貢献するようになったのはこれがきっかけでした。しかし、JITのソースコードを深堀りして私の身近にいるMaxやKokubunやAlanというJIT専門家たちに質問するまでの私は、JITに関して多くの点がまだまだ不明でした。
そこで本記事では、RubyのJITコンパイラについて皆さんも抱いているであろう各種の疑問点やメンタルギャップにお答えしたいと思います。
- JITコンパイルされたコードは実際にはどこに保存されるのか
- RubyはJITコードを実際どのように実行するのか
- Rubyは、どのコードをコンパイルするかをどのように決定しているのか
- JITコンパイルされたコードがインタプリタにフォールバックするのはなぜか
ここでは、Rubyの次世代JITであるZJITをリファレンスとしますが、これらの概念はYJITでもそのまま通用します。
🔗 1: JITコンパイルされたコードは実際にはどこに保存されるのか
🔗 RubyのISEQとYARVのバイトコード
Rubyがコードを読み込むと、各メソッドがISEQ(Instruction SEQuence)にコンパイルされます。ISEQは、YARVというCRuby VM(仮想機械)のバイトコードインストラクションを含むデータ構造です。
(YARVのインストラクションがよくわからない場合や、もっと詳しく知りたい場合は、Kevin Newtonによる詳しいブログ記事シリーズを参照してください)
最初は簡単な例を見ていきましょう。
# example.rb
def foo
bar
end
def bar
42
end
上のコードをruby --dump=insn example.rbで実行すると、以下のようなバイトコードが出力されます(訳注: Rubyのバージョンなどによって若干異なる可能性があります)。
== disasm: #<ISeq:foo@example.rb:1 (1,0)-(3,3)>
0000 putself ( 2)[LiCa]
0001 opt_send_without_block <calldata!mid:bar, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 leave [Re]
== disasm: #<ISeq:bar@example.rb:5 (5,0)-(7,3)>
0000 putobject 42 ( 6)[LiCa]
0002 leave [Re]
🔗 JITコンパイルされたコードもISEQ上に存在する
当初のわたしは、JITコンパイルされたネイティブコードによってバイトコードが置き換えられるのだろうぐらいに想像していました。つまるところネイティブコードが最速なのですから。しかしRubyは、バイトコードもネイティブコードも両方保持します。そして、それにはれっきとした理由があります。
初期状態のISEQは以下のような感じになります。
ISEQ (fooメソッド)
├── body
│ ├── bytecode: [putself, opt_send_without_block, leave]
│ ├── jit_entry: NULL // (JITコードなし)
│ ├── jit_entry_calls: 0 // 呼び出しカウンタ
このfooメソッドが何度か呼び出されると、JITコンパイルされます。
ISEQ (fooメソッド)
├── body
│ ├── bytecode: [putself, opt_send_without_block, leave] // バイトコードはなくなっていない!
│ ├── jit_entry: 0x7f8b2c001000 // ネイティブ機械語へのポインタ
│ ├── jit_entry_calls: 35 // コンパイルを開始する閾値に達した
jit_entryフィールドはネイティブコードへの入口です。このフィールドがNULLの場合、Rubyはバイトコードをインタプリタとして実行します。このフィールドがコンパイル済みネイティブコードへのポインタである場合、Rubyはそのマシンコードに直接ジャンプします。しかしどちらの場合も、バイトコードそのものは決してなくなりません。Rubyは、最適化解除(deoptimization)に備えるためにバイトコードを必要としています(最適化解除については後述)。
🔗 2: バイトコードからネイティブコードへの実行切り替え
これについては思ったほど難しくありませんでした。各ISEQは、JITコンパイル済みコードが存在する場合はそのコードへのポインタを保持するので、Rubyは、実行されるすべてのISEQについてjit_entryフィールドをチェックするだけで済みます。
JITコードが存在しない場合、つまりjit_entryがNULLの場合は、引き続きインタプリタとしてバイトコードを実行します。JITコードが存在する場合は、JITコンパイル済みのネイティブコードを実行します。
🔗 Rubyがどのコードをコンパイルするか決定する方法
Rubyは、メソッドをランダムにコンパイルしたり、一括コンパイルしたりすることはありません。メソッドが一定の回数実行されるとコンパイルを開始します。
ZJITの場合は、2つのフェーズに分けて行われます。
if (body->jit_entry == NULL && rb_zjit_enabled_p) {
body->jit_entry_calls++;
// フェーズ1: メソッドをプロファイリングする
if (body->jit_entry_calls == rb_zjit_profile_threshold) {
rb_zjit_profile_enable(iseq);
}
// フェーズ2: ネイティブコードにコンパイルする
if (body->jit_entry_calls == rb_zjit_call_threshold) {
rb_zjit_compile_iseq(iseq, false);
// 以後jit_entryはマシンコードを指すようになる
}
}
ZJITでは、現時点のプロファイルしきい値は25、コンパイルしきい値は30に設定されています(これらの値は今後変更される可能性があります)。
つまり、メソッドのライフサイクルは以下のような感じになります。
呼び出し回数: 0 ───────────── 25 ──────────── 30 ─────────────────►
│ │ │
モード: └─ インタプリタ ──┴── プロファイル ──┴─ ネイティブコード(JITコンパイル済み)
このため、JITでパフォーマンスを最大限に高めるには、プログラムを「ウォームアップ」する必要があります。
🔗 JITはいつコンパイルを諦めるか: 最適化解除を理解する
JITコードは、高速実行のためにいくつかの前提を立てています。これらの前提が崩れると、Rubyは「最適化解除(deoptimize)」、すなわちインタプリタ実行に制御を戻さなければなりません。これは、コードから常に正しい結果を得られるようにするための安全機構です。
以下のメソッドを考えてみましょう。
def add(a, b)
a + b
end
上のコードから以下のようなインストラクションが生成されます。
== disasm: #<ISeq:add@test.rb:1 (1,0)-(3,3)>
0000 getlocal_WC_0 a@0 ( 2)[LiCa]
0002 getlocal_WC_0 b@1
0004 opt_plus <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0006 leave ( 3)[Re]
Rubyは、opt_plusがどこで呼び出されるかを事前には知り得ないため、背後のC関数vm_opt_plusは、+に応答する可能性があるさまざまなクラス(String、Array、Float、Integerなど)を処理しなければならなくなります。
しかし、プロファイリングによってaddメソッドが常に整数(Fixnum)で呼び出されることが判明すれば、JITコンパイラは、整数の加算「だけ」を扱うよう最適化されたコードを生成できます。
ただし、この前提が成り立っているかどうかをチェックするための「ガード文」も含まれます。
この前提が崩れた場合は(例: add(1.5, 2))、以下のように処理が進みます。
- チェックのガード文が失敗する
- JITコードは"side exit"にジャンプする
- "side exit"ではインタプリタ実行ステート(スタックやインストラクションポインタなど)を復元する
- 制御をインタプリタに返す
- インタプリタは
opt_plusを実行してvm_opt_plus関数を呼び出す
この他に、以下によってもフォールバックがトリガーされます。
- TracePointが有効な場合: TracePointが正しくイベントを発行するにはバイトコード実行を必要とする(詳しくは後述)
- コアメソッドが再定義されている場合: 誰かが
Integerの+メソッドの意味を変更した場合など - Ractorを使っている場合: Ractorが複数存在すると一部のYARVインストラクションの振る舞いが変更されるため、コンパイル済みコードの振る舞いがインタプリタの場合と異なる可能性がある
これらの前提チェックはZJITではパッチポイント(patch points)と呼ばれています。これによって、前提のいずれかが変更された場合でもプログラムが正しく実行されるようになります。
🔗 追加の質問に答える
- Q1: TracePointを有効にするとすべてが遅くなる理由は?
(TracePointは、特定のRuby実行イベントにコールバックを登録するためのRubyクラスであり、デバッグやデバッグツールでよく使われます)
TracePointイベントは、ほとんどの場合、対応するYARVバイトコードによってトリガーされます。TracePointが有効になっていると、ISEQ内の対応するインストラクションが、対応するtrace_*インストラクションに置き換えられます(例: opt_plusはtrace_opt_plusに置き換えられる)。
Rubyがコンパイル済みマシンコードだけを実行すると、これらのイベントが適切にトリガーされなくなってしまいます。そのため、ZJITやYJITのコンパイラはTracePointが有効になっていることを検出すると、最適化済みコードをただちに破棄して、Rubyで強制的にYARVインストラクションをインタプリタ実行させます。
- Rubyがすべてのコードをコンパイルしない理由は?
多くのメソッドは、めったに呼び出されません。そういうメソッドまでコンパイルすると、メモリもコンパイル時間も無駄に消費するばかりでパフォーマンス上のメリットを得られません。また、メソッドをプロファイリングなしでコンパイルすると、JITが誤った前提を立ててコンパイルをすぐ無効にしてしまったり、具体化の不十分な前提を立ててさらなる最適化の機会を逃してしまう可能性があります。
🔗 最後に
本記事が、今やRubyにとって不可欠な要素となったJITコンパイラに関する皆さんの理解を少しでも深めるのに役立ちますように。
Rubyの新しいJITコンパイラであるZJITについて詳しく知りたい方には、「RubyにマージされたZJITの概要を理解する」を一読することを強くおすすめします。
また、RubyのYARVインストラクションについて詳しく知りたい方には、Kevin NewtonによるYARVアドベント記事シリーズが最適な資料です。
概要
CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。