Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

JIT の不変条件 — 何を前提に投機し、破れたらどう戻るか

monoruby の JIT は、コンパイル時点でまだ真である事実をコードに焼き込む。 「この呼び出しは FuncId(42) へ行く」「この定数は 3 である」「この足し算は 組み込みの Integer#+ である」「このスロットは常に Float である」—— どれも Ruby が実行中に覆せる。覆されたまま走り続ければ JIT コードは インタプリタとの等価性を失う。

したがって投機ごとに次の 3 点が必要になる:

  1. 前提(不変条件)を明示する — 何を真だと仮定したか。
  2. 破れを検知する — ガード、あるいは外部イベントからの通知。
  3. 整合性を保って戻る — 生成コードが持っていた状態(レジスタ内の値、 アンボックスされた float、遅延したオブジェクト確保)をすべてフレームへ 書き戻し、正しい pc からインタプリタを再開する。実行中の呼び出し元 フレームも同時に救出する必要がある。

本書は monoruby がいま持っている不変条件を一覧し、それぞれの検知と復帰を 実装に即して記述する。個別の機構には専用ドキュメントがあるので、詳細は そちらへ譲る:


1. 三つの道具

不変条件を守る手段は 3 つあり、どれを使うかは「破れがどこから来るか」で 決まる。

道具いつ使うか
ガード(guard)生成コード自身が実行時に確かめられるクラスガード、型ガード、バージョンワード比較、キャプチャガード
無効化(invalidation)外部イベントが起きた時に Rust 側からコードを差し替える/捨てるBOP 再定義によるエビクション
投機の放棄(bail / 前提の成立)そもそも守れない、または安く成立させられるeval を呼ぶ callee は specialize しない/プロローグで ivar テーブルを拡張する

3 つ目には二種類ある。放棄は「投機しない」であり、 前提の成立は「ガードする代わりに、入口でその条件を真にしてしまう」 である(§3.7 の ivar テーブル)。後者はガードより安いことが多い。


2. 不変条件の一覧

#不変条件破るイベント検知復帰
I1呼び出し先の解決((recv_class, name, refinements) → FuncIddef / define_method / attr_* / alias / undef / 可視性変更 / include / prepend / refine / usingクラスバージョンワードの比較salvage → 成功なら継続、失敗なら再コンパイル
I2畳み込んだ定数の値定数代入・remove_constinclude/prepend定数バージョンワードの比較2 段 salvage(名前エポック → 値比較)
I3基本演算が組み込みのままInteger#+ 等の再定義CheckBOP(バージョン即値比較)+ 依存 iseq のエビクションコード破棄 + 実行中フレームの chain 変換
I4レシーバ/スロットの型・クラス別クラスの値が流れてくる、Fixnum のオーバーフロー、freezeクラスガード/型ガード/jodeopt
I5フレームが未キャプチャブロック・Proc.newbindingeval によるヒープ昇格GuardCapture(meta のビット検査)deopt
I6eval 族がフレームを覗かないeval / instance_eval / binding の呼び出しコンパイル時に拒否Effect::EVAL/BINDINGそもそも投機しない
I7TracePoint が存在しない(未サポート)検知機構なし
I8呼び出しが単相2 つ目のレシーバクラスの到来クラスガードのミス一度だけ再コンパイル(多相形へ)/以後は素の deopt
I9遅延した実体(float・転送 rest/kwrest・定数)はフレームに無いあらゆる側方退出・セーフポイント— (破れではなく義務write-back で実体化してから戻る
I10GC がオブジェクトを動かさない(現状の GC は非移動)検知機構なし

I1〜I3 が「グローバルな再定義」、I4〜I5 が「そのフレーム固有の投機」、 I6〜I7・I10 が「そもそも投機しない/前提を置いている」もの、I9 は復帰の 手続きそのものである。


3. 個別の不変条件

3.1 I1: メソッド解決(クラスバージョン)

コンパイル時に (recv_class, name, refinements) → FuncId を解決してコードへ 焼き込む。この答えを変えうる全イベントが Globals::class_version_inc() で グローバルなクラスバージョンを進める(defdefine_methodattr_*alias_methodremove_methodundef_method、可視性変更、include / prependrefine / 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 段:

  1. 名前エポック — 各イベントは触った名前のエポックを進める。畳み込んだ 名前のエポックが一つも動いていなければ(ワイルドカードも動いていなければ) ルックアップ 0 回で確定。
  2. 値の再確認 — 触られた名前は VM 側インラインキャッシュと突き合わせる。 同じ値ならパッチして継続、違えば再コンパイル、キャッシュが古ければ Defer(今回は deopt させて VM にキャッシュを温めさせ、次回再試行。 stale_defers = 3 回で打ち切り)。

畳み込んだ ValueConstSalvageMap::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 演算のオーバーフロー(joi63 を超えるdeopt(インタプリタが Bignum へ昇格)
GuardArrayTyArray でない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#evalEffect::EVALKernel#bindingEffect::CAPTURE | Effect::BINDING を 持つ。possibly_capture_without_block() が真の callee を呼ぶサイトは specialize を拒否する(CompileError → その呼び出しは通常ディスパッチ)。 これらはブロックを介さずに任意のフレームを覗ける=ローカル変数の配置に 関するあらゆる投機を無効化しうるので、ガードではなく投機の放棄で対処する。

加えて eval/binding は遅延実体化の強制解決も引き起こす: 文字列 evalglobals.rs)と Kernel#bindingbuiltins/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 形態

SideExitjitgen/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 — 何を書き戻すか

WriteBackjitgen.rs)がその地点で「フレームに無い状態」を列挙する:

フィールド内容
fprXMM/D レジスタにアンボックスで載っている float(f64_to_val でボックス化)
literalコンパイル時定数として持っていたスロット
void未定義スロット(nil で埋める)
gpGP プール(x86-64 の r8r11)に載っているスロット
forward_restD1: 未実体化の ... rest Array(呼び出し元のスロット窓から create_array
forward_kwrestK1: 未実体化の **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/StoreDynVarbinding・キャプチャ経由で外側のスロットに触れるので、 古いままでは壊れた値を読む。

runtime::chain_deopt は cfp チェーンを歩いて、各 JIT フレームについて

  1. スピルされたレジスタをスロットへ書き戻し(Rust 側で replay)、
  2. スタック上の返りアドレススロットを VM の継続スタブへ書き換える。

これで制御は二度と JIT コードへ戻らず、各 ret がインタプリタに着地する。 コードには一切触らないので、他のフレームや他スレッドに影響しない。

エスケープは個々の emitter が選ぶのではない: escalate_side_exitsAsmIr に一度スタンプされ、すべての側方退出コンストラクタがそれを読む。 現在は無条件に真(実測で 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. 新しい投機を足すときのチェックリスト

  1. 前提を一文で書けるか。 書けないなら、それはまだ設計されていない。
  2. 破れうるイベントを列挙したか。 メソッド解決を変えうるなら クラスバージョンを、定数解決を変えうるなら定数バージョンと適切な エポック(名前、または名前が特定できないならワイルドカード)を進める。
  3. 検知はガードか無効化か。 ガードなら「フレームを書き始める前」に 置けるか(置けるならミスはフォールバックで済み、deopt が要らない)。
  4. salvage レコードを登録したか。 登録しないとガードミスが永久に 再コンパイルを呼ぶ。既存のマップに形の違う不変条件を相乗りさせない
  5. 遅延した実体があるなら WriteBack に載せたか。 側方退出だけでなく GC セーフポイントとフレームキャプチャも、その実体を要求する。
  6. バージョン比較の相手はワードか。 即値にすると salvage が無言で死ぬ。
  7. ガードのミス先ラベルを cfg ブロックの中でバインドしていないか。 未バインドの分岐は no-op になり、投機が全件通ってしまう。
  8. 両アーキで lowering したか。 x86-64 と aarch64 は全 AsmInst を ロワリングする(arch_difference.md)。

7. 観測のしかた

目的方法
どのガードが外れたか--features deoptdeopt_log.mdguard: 欄が実際に分岐した箇所、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 も暗黙の不変条件である。