JIT の不変条件 — 何を前提に投機し、破れたらどう戻るか
monoruby の JIT は、コンパイル時点でまだ真である事実をコードに焼き込む。
「この呼び出しは FuncId(42) へ行く」「この定数は 3 である」「この足し算は
組み込みの Integer#+ である」「このスロットは常に Float である」——
どれも Ruby が実行中に覆せる。覆されたまま走り続ければ JIT コードは
インタプリタとの等価性を失う。
したがって投機ごとに次の 3 点が必要になる:
- 前提(不変条件)を明示する — 何を真だと仮定したか。
- 破れを検知する — ガード、あるいは外部イベントからの通知。
- 整合性を保って戻る — 生成コードが持っていた状態(レジスタ内の値、 アンボックスされた float、遅延したオブジェクト確保)をすべてフレームへ 書き戻し、正しい pc からインタプリタを再開する。実行中の呼び出し元 フレームも同時に救出する必要がある。
本書は monoruby がいま持っている不変条件を一覧し、それぞれの検知と復帰を 実装に即して記述する。個別の機構には専用ドキュメントがあるので、詳細は そちらへ譲る:
jit_invalidation.md— バージョンカウンタと salvagebop_redefinition.md— 基本演算の再定義chain_deopt.md— 呼び出し元チェーンの一括変換deopt_log.md—--features deoptの読み方refinements.md— refinements とメソッド解決safepoint.md— GC/preempt ポーリング(同じ write-back を共有)jit.md— スタブとクラスガードチェーンの形
1. 三つの道具
不変条件を守る手段は 3 つあり、どれを使うかは「破れがどこから来るか」で 決まる。
| 道具 | いつ使うか | 例 |
|---|---|---|
| ガード(guard) | 生成コード自身が実行時に確かめられる | クラスガード、型ガード、バージョンワード比較、キャプチャガード |
| 無効化(invalidation) | 外部イベントが起きた時に Rust 側からコードを差し替える/捨てる | BOP 再定義によるエビクション |
| 投機の放棄(bail / 前提の成立) | そもそも守れない、または安く成立させられる | eval を呼ぶ callee は specialize しない/プロローグで ivar テーブルを拡張する |
3 つ目には二種類ある。放棄は「投機しない」であり、 前提の成立は「ガードする代わりに、入口でその条件を真にしてしまう」 である(§3.7 の ivar テーブル)。後者はガードより安いことが多い。
2. 不変条件の一覧
| # | 不変条件 | 破るイベント | 検知 | 復帰 |
|---|---|---|---|---|
| I1 | 呼び出し先の解決((recv_class, name, refinements) → FuncId) | def / define_method / attr_* / alias / undef / 可視性変更 / include / prepend / refine / using | クラスバージョンワードの比較 | salvage → 成功なら継続、失敗なら再コンパイル |
| I2 | 畳み込んだ定数の値 | 定数代入・remove_const・include/prepend | 定数バージョンワードの比較 | 2 段 salvage(名前エポック → 値比較) |
| I3 | 基本演算が組み込みのまま | Integer#+ 等の再定義 | CheckBOP(バージョン即値比較)+ 依存 iseq のエビクション | コード破棄 + 実行中フレームの chain 変換 |
| I4 | レシーバ/スロットの型・クラス | 別クラスの値が流れてくる、Fixnum のオーバーフロー、freeze | クラスガード/型ガード/jo | deopt |
| I5 | フレームが未キャプチャ | ブロック・Proc.new・binding・eval によるヒープ昇格 | GuardCapture(meta のビット検査) | deopt |
| I6 | eval 族がフレームを覗かない | eval / instance_eval / binding の呼び出し | コンパイル時に拒否(Effect::EVAL/BINDING) | そもそも投機しない |
| I7 | TracePoint が存在しない | (未サポート) | 検知機構なし | — |
| I8 | 呼び出しが単相 | 2 つ目のレシーバクラスの到来 | クラスガードのミス | 一度だけ再コンパイル(多相形へ)/以後は素の deopt |
| I9 | 遅延した実体(float・転送 rest/kwrest・定数)はフレームに無い | あらゆる側方退出・セーフポイント | — (破れではなく義務) | write-back で実体化してから戻る |
| I10 | GC がオブジェクトを動かさない | (現状の GC は非移動) | 検知機構なし | — |
I1〜I3 が「グローバルな再定義」、I4〜I5 が「そのフレーム固有の投機」、 I6〜I7・I10 が「そもそも投機しない/前提を置いている」もの、I9 は復帰の 手続きそのものである。
3. 個別の不変条件
3.1 I1: メソッド解決(クラスバージョン)
コンパイル時に (recv_class, name, refinements) → FuncId を解決してコードへ
焼き込む。この答えを変えうる全イベントが Globals::class_version_inc() で
グローバルなクラスバージョンを進める(def、define_method、attr_*、
alias_method、remove_method、undef_method、可視性変更、include /
prepend、refine / using)。
検知: コンパイル単位(ルート本体 + そこへインライン展開された全 specialize 済み callee)につきひとつのバージョンワードを持ち、単位内の 全ガードがグローバルカウンタとそのワードを比較する。ワードは即値ではなく メモリでなければならない(即値にすると salvage が無言で効かなくなる。 aarch64 で実際に起きた)。
復帰: カウンタはグローバルなので、無関係な def ひとつでも動く。そこで
ミスは即再コンパイルではなく salvage(修復)を試みる。単位は自分が
解決したメソッドを inline_cache_map に記録しており、全件を現在の
バージョンで再解決して答えが変わっていなければワードに現在値を書き込んで
コードを生かす。ruby/spec core で ~107,000 回の再コンパイルが ~320 回に
なった。詳細は jit_invalidation.md §1〜§3。
復帰の粒度: x86-64 のメソッド単位クラスガードだけは salvage 成功時に
その場で実行を再開する(jit_recompile_method_with_recovery が 1 を返し、
スタブの jnz recover が戻る)。それ以外は salvage が成功しても当該呼び出しは
一度 deopt し、次回から修復済みコードが走る。
3.2 I2: 定数の畳み込み(定数バージョン)
定数を畳み込んだ単位は、クラスバージョンとは別に定数バージョンワードを持つ。 ミス時の salvage は 2 段:
- 名前エポック — 各イベントは触った名前のエポックを進める。畳み込んだ 名前のエポックが一つも動いていなければ(ワイルドカードも動いていなければ) ルックアップ 0 回で確定。
- 値の再確認 — 触られた名前は VM 側インラインキャッシュと突き合わせる。
同じ値ならパッチして継続、違えば再コンパイル、キャッシュが古ければ
Defer(今回は deopt させて VM にキャッシュを温めさせ、次回再試行。stale_defers= 3 回で打ち切り)。
畳み込んだ Value は ConstSalvageMap::mark から GC ルートとして生かされる。
詳細は jit_invalidation.md §4。
3.3 I3: 基本演算(BOP)の再定義
1 + 2 を機械語の加算にインライン化してよいのは Integer#+ が組み込みの
ままだからである。この前提だけはバージョンガードでは修復されない。
検知: 二段構え。
- コンパイル時に
bop_deps: Vec<(ClassId, IdentId)>を記録する (「この本体はInteger#+をガード無しでインライン化した」)。 - 生成コードには
AsmInst::CheckBOPを置く。比較対象は コンパイル時に観測した BOP バージョンであって 0 ではない。ゼロ比較に すると、無関係な再定義以降すべてのdefで deopt し続けてしまう。
復帰: 再定義が起きると Store::evict_jit_code_for_bop(class, name) が
depends_on_bop(class, name) な iseq だけのコードを捨てる
(evict_jit_code)。Integer#+ をインライン化した本体は Integer#~ の
再定義では無傷であり、実メソッド呼び出しとして出た演算子は I1 の
クラスバージョンガードが既にカバーしている。
実行中フレームの救出: 捨てるだけでは、いまスタックで動いている JIT
フレームが古いコードを実行し続ける。Codegen::check_bop_redefine が
メソッド定義のたびに「今の定義が実際に BOP を潰したか」を確認し、潰していれば
chain_deopt_into(cfp) でスタック上の全 JIT フレームをインタプリタ
フレームへ変換する(§4.4)。BOP エビクションと側方退出のチェーン変換は
同一の walk を通る。詳細は bop_redefinition.md。
3.4 I4: 型・クラスの投機
インラインキャッシュから作った TraceIR は「このスロットは Integer」
「このレシーバは Foo」という型注釈を持つ。JIT はそれを信じてアンボックス
表現(LinkMode::F の生 float、タグ付き Fixnum の直接演算)や
クラス特化コードを出し、代わりにガードを置く。
| ガード | 破れ方 | 退出 |
|---|---|---|
GuardClass | 別クラスのレシーバ | deopt(呼び出しサイトでは §3.8 の再コンパイル付き) |
float アンボックス(GuardFloat 系) | Float でない値 | deopt |
Fixnum 演算のオーバーフロー(jo) | i63 を超える | deopt(インタプリタが Bignum へ昇格) |
GuardArrayTy | Array でない | deopt |
guard_frozen | 凍結オブジェクトへの ivar 代入 | deopt(インタプリタが FrozenError を上げる) |
| 引数形状ガード(転送の長さガード等) | 個数不一致 | deopt ではなく汎用ランタイムへフォールバック |
最後の行は重要な設計上の区別である。ガードがフレームを書き始める前に
すべて置かれていれば、ミスは deopt ではなく「同じことを汎用パスでやり直す」
で済み、ロールバックが要らない。引数設定の高速路(SetArgumentsForwarded
など)はこの形をとっている。
unfrozen_slots のような「直線コード内でだけ有効な証明」は基本ブロックの
合流で捨てられる(合流点では両側の事実が保証されないため)。投機的事実の
生存範囲を状態機械に持たせる、という一般則の一例。
3.5 I5: フレームのキャプチャ(クロージャ)
JIT はローカル変数をスタックフレーム上のスロットとして rbp 相対で読み書き
する。ところが Ruby はフレームをヒープへ昇格させられる:
ブロックや Proc が外側フレームを掴む、binding を取る、eval がフレームに
対してコンパイルする、など。
機構: Lfp::move_frame_to_heap がフレームの内容をヒープへコピーし、
cfp.set_lfp(heap_lfp) で正本を差し替え、元のスタックスロットに
tombstone(invalidated ビット)を立てる。以後スタック側を読む者は
cfp.lfp() 経由でヒープコピーへ転送される。外側フレームも再帰的に昇格する。
検知: AsmInst::GuardCapture が meta のビットを見て、昇格済み
(on_heap / invalidated)なら deopt する。tombstone ビットのおかげで
この 1 つの検査がレキシカルな祖先の昇格までカバーするので、ガードを
1 回通せば自フレームとその祖先すべてについて「未キャプチャ」を再証明できる
(set_lexical_no_capture_guard)。
抽象状態側: no_capture_guard は各フレームの invariant として持たれ、
ブロックリテラルを渡す呼び出しのたびに保守的に落とされ、ガード通過で
再び立つ。落ちている間、呼び出し結果の格納は rbp 相対ではなく
LFP(r14、呼び出し後に cfp.lfp から再ロード)経由になる
(def_return_store_guarded / def_rax2acc_capturing)。昇格が起きても
生きているフレームへ書き込むためである。
specialize の拒否: &block を転送する(BlockArg を持つ)callee は
インライン展開しない。specialize すると pop_frame が無いため、昇格後に
r14 がヒープコピーへ更新されず、以後のローカル読みが tombstone と
ヒープコピーに分裂する。
3.6 I6: eval 族
eval / instance_eval / class_eval / module_eval / Binding#eval は
Effect::EVAL、Kernel#binding は Effect::CAPTURE | Effect::BINDING を
持つ。possibly_capture_without_block() が真の callee を呼ぶサイトは
specialize を拒否する(CompileError → その呼び出しは通常ディスパッチ)。
これらはブロックを介さずに任意のフレームを覗ける=ローカル変数の配置に
関するあらゆる投機を無効化しうるので、ガードではなく投機の放棄で対処する。
加えて eval/binding は遅延実体化の強制解決も引き起こす:
文字列 eval(globals.rs)と Kernel#binding(builtins/kernel.rs)は
materialize_lazy_forwarding を呼び、cfp チェーン上の
lazy (...)-転送マーカーをすべて実 Array にしてから進む
(arg_forwarding_jit.md §3.6)。eval されたコードは
生のパラメータスロットに到達できるため、マーカーを残せない。
3.7 前提を成立させる例: ivar テーブルと ivar id
self のインスタンス変数は「クラスごとの IvarId スロット」で表される。
JIT が LoadIVarInline / StoreIVarHeap をガード無しで出せるのは、
その id までテーブルが確保済みだからである。
これはガードで守られていない。メソッドのプロローグ(AsmInst::Preparation)
が、self のクラスが持つ ivar 数からヒープテーブルの必要長を計算し、
足りなければ extend_ivar でその場で拡張する。以後の本体は境界検査
なしで書ける。frozen なクラスや inline 領域だけで済む場合は no-op。
コンパイル時に ivar id が引けなかった場合(まだ一度も代入されていない)は
RecompileReason::IvarIdNotFound で、VM のキャッシュが温まってからの
再コンパイルに委ねる。
3.8 I8: 単相性
単相前提でコンパイルしたサイトに 2 つ目のクラスが来ると、クラスガードが
外れる。ここは素の deopt ではなく SideExit::RecompileDeoptimize で、
ミスカウンタが尽きた時に多相形(クラス集合ガードまたは PIC)へ
一度だけ作り直す(RecompileReason::BecamePolymorphic)。作り直した
サイトにはレシーバクラスガードが無いので再発しない(単調・一回限り)。
対になるのがメガモルフィックゲート: クラスガードチェーンを全部外した
レシーバはインタプリタで実行され、スタブのウォームアップサンプラが 2 回
観測して初めてそのクラス用のコードを作る。この脱出先ラベルが未バインドで
分岐が no-op になっていた時、ミスしたクラス全部にコードを作ってしまい
ruby/spec が ~15,000 本 / ~300MB を吐いてタイムアウトした
(jit_invalidation.md §7)。
3.9 I7: TracePoint
monoruby には行・呼び出しイベントのフックが無い。Kernel#set_trace_func
は CRuby と同じ形(private instance method かつ module function)で存在
するが、ハンドラを受け取って返すだけの no-op スタブ
(monoruby/builtins/kernel.rb)であり、TracePoint クラスも無い。
これは単なる未実装ではなく、現在の最適化が寄りかかっている前提である。 JIT は次のように「呼び出しが起きた事実」そのものを消している:
- trivial method fold —
ISeqHint::ConstReturn/SelfReturnな callee の 呼び出しを丸ごと消す。 - frameless 展開 —
def initialize(a,b) = (@a=a; @b=b)をフレームを作らずに 呼び出し元の命令列へ展開する。 - specialize — callee をコンパイル単位へ取り込む(フレーム自体は作るので バックトレースは保たれるが、呼び出し規約は消える)。
イベントフックが入れば、これらは :call / :return / :line イベントを
落とすことになり等価性を失う。将来 TracePoint を実装する場合は、
「フックが有効か」を新しいグローバル不変条件として立て、有効化時に
全 JIT コードをエビクトする(I3 と同じ形)のが素直である。フックが無効な
限りコストがゼロという点でも BOP と同型になる。
3.10 I10: GC がオブジェクトを動かさないこと
JIT コードは畳み込んだ Value(定数、シンボル、ISeqHint::ConstReturn の
戻り値)を即値としてコードに埋め込む。現在の GC は非移動
(gc.md:非移動・単一スレッド・stop-the-world の世代別
mark & sweep)なので、これは安全である。埋め込んだ Value が回収されない
ことは、salvage レコードを GC ルートとして走査することで保証している。
移動 GC(コンパクション)を導入する場合、この暗黙の前提が破れる。焼き込んだ ポインタの一覧を持って更新するか、間接参照に変えるか、いずれにせよ本書の 表に新しい行が要る。
4. 復帰の機構
破れの検知はここまでで、以降はどう戻るかである。復帰は 4 つの部品から なる。
4.1 側方退出(side exit)の 4 形態
SideExit(jitgen/asmir.rs):
| 形態 | 使われ方 |
|---|---|
Deoptimize(pc, wb, chain) | 素の deopt。インタプリタで pc から再開 |
RecompileDeoptimize(pc, wb, reason, target, chain) | deopt に加え、カウンタが尽きたら target を再コンパイル(§3.8) |
Error(pc, wb, chain) | 例外送出。write-back 後に entry_raise へ |
Evict(..) | 歴史的な即時エビクション用。現在どこからも入らないが、AsmEvict が chain 変換のための返りアドレス登録キーなのでスロットは残る |
退出ハンドラはホットパスの外に置かれる(x86-64 はコールドページ page 1、 aarch64 は本体の後ろへ島として outline する)ので、ホットパスが払うのは 分岐命令 1 つだけである。
4.2 write-back — 何を書き戻すか
WriteBack(jitgen.rs)がその地点で「フレームに無い状態」を列挙する:
| フィールド | 内容 |
|---|---|
fpr | XMM/D レジスタにアンボックスで載っている float(f64_to_val でボックス化) |
literal | コンパイル時定数として持っていたスロット |
void | 未定義スロット(nil で埋める) |
gp | GP プール(x86-64 の r8–r11)に載っているスロット |
forward_rest | D1: 未実体化の ... rest Array(呼び出し元のスロット窓から create_array) |
forward_kwrest | K1: 未実体化の **kwrest Hash(correct_rest_kw) |
順序に意味がある。literal を先に書いてから forward_rest /
forward_kwrest を実体化する — 後者は確保を伴う呼び出しであり、その時点で
フレームが GC 整合でなければならないからである(未書き込みの遅延スロットは
呼び出し元が入れた物理的な nil を保持している)。
Loop JIT の rsp: write-back は Loop JIT のスピル領域を rsp が跨いだ状態で
先に行う。先に rsp を戻すと、ボックス化のための call が積む戻りアドレスが
スピルスロットを踏み、生きた float をコードポインタで上書きする。
4.3 pc の復元とインタプリタ再開
write-back 後、r13(プログラムカウンタ)に退出地点の pc を書き、
VM のフェッチループ(vm_fetch)へ jmp する。フレームのレイアウトは
VM と JIT で共通なので、フレームの作り直しは不要である。Loop JIT の場合は
ここでスピル領域分だけ rsp を戻し、VM の init_method が想定する深さに
合わせる。
4.4 chain deopt — 呼び出し元フレームの救出
自フレームだけ戻しても足りない。呼び出し元にも JIT フレームが積まれており、
それらもアンボックス float を抱えている。インタプリタになった内側フレームは
Load/StoreDynVar・binding・キャプチャ経由で外側のスロットに触れるので、
古いままでは壊れた値を読む。
runtime::chain_deopt は cfp チェーンを歩いて、各 JIT フレームについて
- スピルされたレジスタをスロットへ書き戻し(Rust 側で replay)、
- スタック上の返りアドレススロットを VM の継続スタブへ書き換える。
これで制御は二度と JIT コードへ戻らず、各 ret がインタプリタに着地する。
コードには一切触らないので、他のフレームや他スレッドに影響しない。
エスケープは個々の emitter が選ぶのではない: escalate_side_exits が
AsmIr に一度スタンプされ、すべての側方退出コンストラクタがそれを読む。
現在は無条件に真(実測で 1 回 ~160ns、最悪ケースでも 1.6% 以内)。
Error 退出も対象である — 例外はフレーム内で rescue されるかもしれず、
それは deopt と同じ「インタプリタでの再開」だからである。
一度変換済みのフレームは(返りアドレスが既に VM のものなので)スキップされる。
二重 replay は、その後インタプリタが更新した値を古い float で上書きしてしまう。
BOP エビクション(§3.3)もこの同じ walk を通る。変換機構は 1 つだけである。
4.5 コンパイルそのものの放棄
フロントエンドがコンパイルを諦めた場合(CompileError):
- メソッド単位の bail は iseq に
jit_invalidatedを立て、以後二度と コンパイルしない。 - ループ単位の bail はその地点をインタプリタのままにするだけ。
これは不変条件の破れではなく「投機を始めない」判断だが、同じ表に載せておく
価値がある。eval を呼ぶ callee(§3.6)や &block 転送メソッド(§3.5)が
ここに来る。
5. 実行中フレームがある場合の一般則
コードを捨てる・差し替える操作は、そのコードを今実行しているフレームが あるかどうかで難度が変わる。monoruby の答えは一貫している:
| 操作 | 実行中フレームへの対処 |
|---|---|
| salvage(I1/I2) | 不要。コードは生き続け、ワードを書き換えるだけ |
| 再コンパイル | 走行中のフレームは、再コンパイルを起動したその退出でインタプリタへ戻る(write-back 済み)。エントリのパッチポイントを新本体へ向け直すので、次回の呼び出し/次回のループ入口から新本体が使われる |
| BOP エビクション | 必要。走行中の本体は誤った演算をインライン化しているので chain_deopt_into で全 JIT フレームを変換する |
| 側方退出 | 必要。chain_deopt で呼び出し元チェーンを変換する |
MAP_JIT ページ(Apple Silicon)へ書き込む salvage は、パッチの直前に ページを書き込み可へ倒す必要がある。
6. 新しい投機を足すときのチェックリスト
- 前提を一文で書けるか。 書けないなら、それはまだ設計されていない。
- 破れうるイベントを列挙したか。 メソッド解決を変えうるなら クラスバージョンを、定数解決を変えうるなら定数バージョンと適切な エポック(名前、または名前が特定できないならワイルドカード)を進める。
- 検知はガードか無効化か。 ガードなら「フレームを書き始める前」に 置けるか(置けるならミスはフォールバックで済み、deopt が要らない)。
- salvage レコードを登録したか。 登録しないとガードミスが永久に 再コンパイルを呼ぶ。既存のマップに形の違う不変条件を相乗りさせない。
- 遅延した実体があるなら
WriteBackに載せたか。 側方退出だけでなく GC セーフポイントとフレームキャプチャも、その実体を要求する。 - バージョン比較の相手はワードか。 即値にすると salvage が無言で死ぬ。
- ガードのミス先ラベルを
cfgブロックの中でバインドしていないか。 未バインドの分岐は no-op になり、投機が全件通ってしまう。 - 両アーキで lowering したか。 x86-64 と aarch64 は全
AsmInstを ロワリングする(arch_difference.md)。
7. 観測のしかた
| 目的 | 方法 |
|---|---|
| どのガードが外れたか | --features deopt → deopt_log.md。guard: 欄が実際に分岐した箇所、cause: がその時の被検査値 |
| salvage と再コンパイルの収支 | --features jit-log → 終了時に jit_stats::dump() |
| deopt / 再コンパイルの統計 | --features profile |
| 生成コードそのもの | --features emit-asm |
fail: value changed が spec 一周で 0 のまま、というのが salvage 機構の
経験的な正当化になっている(定数イベントは実在したが、畳み込んだ値を
変えたものは一つも無かった)。
8. まとめ
- 不変条件は 3 つの道具で守る: ガード(コードが自分で確かめる)、 無効化(外部イベントがコードを捨てる)、放棄/前提の成立 (投機しない、または入口で条件を真にする)。
- グローバルなバージョンカウンタの破れは、まず修復を試みる。 カウンタが粗い(グローバル)以上、ミス=変更ではないからである。
- 復帰は「write-back → pc 復元 → チェーン変換 → VM フェッチ」の 4 段で、 どの退出形態も同じ道を通る。
- 実行中フレームを持つ無効化(BOP、側方退出)は、コードではなくフレームを 書き換えることで解決する。返りアドレスの差し替えが chain deopt の核心。
- TracePoint の不在は暗黙の不変条件であり、実装するなら BOP と同型の グローバル無効化として設計するのが自然。
- 非移動 GC も暗黙の不変条件である。