Rustでx86エミュレータ rustx86 を書いている。前回(JIT編#1)は、手書きAArch64のテンプレートJITがインタプリタに初めて勝つまでを書いた。ブートは-14%の黒字、しかしgccコンパイル窓には-2%の赤字が残った。
今回はその-2%を追いかけた話である。先に結末を書く: QEMU直伝の主砲をフル実装して、不発に終わり、掘り返したら真犯人は別に居た。最終的にブートは133 MIPS、gcc窓でもJITが勝ち、JIT編はここで完結する。効かなかった実装の話を長めに書くのは、そこにいちばん学びがあったからだ。
まず測る: 時間はどこへ消えているのか
推測で最適化しない。macOSのsampleでJIT実行中のプロセスを突き刺して、自己時間の行き先を出した:
| 行き先 | ブート | gcc窓 |
|---|---|---|
| インタプリタ(未カバー命令の実行) | 48% | 37% |
| 生成コード | ~14% | ~20% |
| 入場機構(ブロック検索+TLS) | 8% | 8% |
| ヘルパ群(シフト・フラグ・push/pop…) | ~13% | ~10% |
| コード生成(mmap/mprotect込み) | ~4% | ~5% |
生成コードの雛形サイズを静的に数え合わせると、ブロック1回の入場に約90〜100ホスト命令の固定費を払っていた。平均8命令のブロックを実行するのに、である。ゲスト1命令あたり約12命令が「入場料」だ。
並行して、成熟したバイナリ変換器の技法を調査した。QEMU TCG・Rosetta 2・FEX-Emu・Box64・v86。決定性(rustx86の命令数指紋)を壊さずに効きそうな順に並べると、筆頭はQEMU TCGの主砲、TB chainingだった — ブロック末尾の分岐命令を実行時にパッチして、次のブロックへ直接飛ぶ。dispatch(入場)の往復そのものを消す技法で、QEMUには決定的実行(icountモード)と同居させている実績もある。
その前に安い掃除もした。入場路の固定費のうち、定数番地の焼き込み(movz+movk×3)をレジスタ引数渡しに、ヘルパ関数の番地をテーブル参照(ldr1命令)に、macOSのTLS参照(tlv_get_addr呼び、実測2%)をポインタ持ち込みに変えた。期待値は合計0.2〜0.3秒 — そして交互A/Bのノイズ床(±3%)に沈んで測定不能だった。この規模の削減は単体では裁けない。構造根拠とメモリ削減(スロット表128→32MiB)で採用し、教訓だけ台帳に書いた。
TB chainingをフル実装する
決定性を壊さないための契約を先にADRへ固めた。核心は3つ:
1. tick境界は不変。 rustx86は64命令ごとにデバイスtick(タイマ等)を刻む。ブロック入場時の予算 budget = min(鎖残り, tick残り) をレジスタw23で持ち回し、連結先へ飛ぶ前に引き算する:
stub: ; ブロックAの出口に差し込まれる連結スタブ
ldr w11, [x19, #ip] ... ; ip += delta (従来の出口と同じ)
add w24, w24, w15 ; 実行命令数を積算 (清算は退出時に一括)
ldr w11, [B世代の番地] ; 連結先の世代照合 (自己書き換え検出)
cmp w11, #焼いた時の世代
b.ne ->exit_done ; 腐っていたら退出 (可視状態は従来出口と同じ)
subs w23, w23, #Bの命令数 ; 予算検査 — チェーン全体がtick窓に収まる保証
b.mi ->exit_done
br x16 ; Bの本体へ (プロローグは飛ばしてフレーム共有)
2. スタブはプレースホルダで焼いておき、リンク時は命令のビット畑だけ書き換える。 movz/movkのimm16とsubsのimm12を差し替えるだけで、実行時に命令を組み立てない。

3. 剥がしの台帳。 世代照合はゲストの自己書き換えしか守らない。ホスト側の都合でブロックが捨てられる(スロット衝突・損益降格)とき、そこへ飛び込むリンクを書き戻さないと、飛び元が解放済みメモリへ飛ぶ。全ブロックが「誰が自分に飛んでくるか」の台帳を持ち、捨てる前に歩いて分岐を元に戻す(mprotect→書換→icache無効化)。
実装して、決定性の門番(ブート970M命令+gcc窓518M命令の指紋、JIT on/offでビット同一)は一発で緑。連結は実際に張られ(ブートで8,322本)、剥がしも実地で回った(gcc課程で1,763回 — スロット退去の嵐の中で1件もダングリングしない)。入場回数は53.1M→37.6M回(-29%)に減った。
…なのに、1msも速くならない
壁時計は同着〜-1%。入場が3割減って、なぜ時間にならないのか。
答えは予算の式の中にあった。budget = min(鎖残り, tick残り) — そしてtick粒度は64命令である。チェーンは64命令ぶんで必ず予算が尽き、表口(入場機構)へ戻る。平均ブロック8命令なら、どんなに連結しても数ホップで終わる。しかも入場路は前段の掃除で既に痩せていた。64命令ごとの往復1回は、もう消せるほど高い税ではなかったのだ。
QEMUでTB chainingが主砲なのは、icountの刻みが桁違いに大きいからである。教訓: 他所の主砲は、自分の税表で答え合わせしてから撃つ。

では、と診断実験をした。tick粒度を256にしてみる(装置の刻みが粗くなる=ゲスト可視の変更なので、あくまで診断)。すると:
- gcc窓: JITがインタプリタに勝った
- そしてchainingの有無は同着のまま
利得は粒度そのものだった。chainingは実装をブランチに寝かせ(マージせず)、ADRに「実装検証の上で不採用」と追記した。効かなかった理由ごと台帳に残す — 前提が変われば再訪するためである。
tick=256を正式に採用する — 時間の換算とまとめ幅は別の量
粒度の変更はゲストから見える(割り込みの刻み・ネットワークRTT・指紋の基準値)。性能最適化ではなく意味論の基線変更なので、ここはユーザー判断で「tick256でRTT再計測して締める」となった。
正式適用の初手で、いきなりベンチ用カーネルが起動途中で永眠した。Freeing initrd memory: を最後に沈黙、デッドハルト。JITを切っても同じ — JITのバグではない。
犯人は1行の定数だった:
/// 1回の tick で装置に渡すクロック数。
/// 実測でおよそ1億命令/秒 → 1命令≒10ns → 64命令 ≒ PITの1クロック(838ns)
pub const PIT_CLOCKS_PER_TICK: u32 = 1; // ← tick=64向けの校正だった
tick粒度だけ4倍にすると、PIT(ゲストのタイマ)は64命令に1クロックのまま256命令に1クロックしか進まなくなる。つまりゲストの時計が命令比で1/4に遅くなる。カーネルの時間系が狂って眠った。
修正は換算の不変化である。粒度が変えるのは「配送のまとめ幅」だけで、「1命令≒10ns」の時間換算は校正の原点のまま:
pub const INSTRUCTIONS_PER_TICK: u32 = 256;
pub const PIT_CLOCKS_PER_TICK: u32 = INSTRUCTIONS_PER_TICK / 64; // = 4
これが正しいことは、美しい形で証明された。凍結してあるベンチ用イメージのブートが、tick=64のときと寸分違わず970,000,000命令ちょうどで着地したのである。ゲスト時間の換算が不変なら、決定的な起動の道のりも不変 — 定規は無傷だった。(シリアル出力のFNVだけは変わる。dmesgのµsタイムスタンプが4クロック単位の配送で微妙に揺れるためで、門番はon/off比較なので張り替え不要である)
RTT再計測 — 刻みを保てば揺らがない
ネットワーク編で「RTTはスライスの刻みで決まる」と実測済みだったので、tick粒度がRTTを壊さないかが最後の関門だった。wsslirpd(自作のユーザーモードNAT)の折り返しで、ゲストのpingを測る:
| スライス刻み | tick=64 | tick=256 |
|---|---|---|
| 10M命令 | 88.3 ms | 88.6 ms(同) |
| 1M命令 | 37.4 ms | 28.6 ms(改善) |
| 100k命令 | 16.1 ms | 10.2 ms(改善) |
| 16bit(FreeDOS+mTCP) | 1.27 ms | 1.27 ms(同一) |
悪化なし。RTTの主因はスライスの刻み(壁時計)であって、時間換算を保てばtick粒度には揺らがない — 仮説どおりの結果である。改善分は実行そのものが速くなった(tick処理の頻度が1/4になった)おつりだ。
結果 — JIT編、完結
交互A/B 3周、全定規:
| 定規 | インタプリタ | JIT | |
|---|---|---|---|
| Linuxブート 970M命令 | 8.8s | 7.3s = 133 MIPS | -17% |
| gccコンパイル窓 518M命令 | 77.7〜79.9 MIPS | 82.2〜83.4 MIPS | 窓でも全勝 |
| ブート+コンパイル総合 | 24.6〜25.2s | 20.4〜20.6s | -17% |
決定性はビット単位で無傷(指紋2コース+条件写像の全数照合+自己書き換えテストが毎PRの関所)。#1の時点で119 MIPSだったブートは133 MIPSまで来て、最後まで赤字だったgccコンパイルもJITが上回った。
JIT編の全行程を並べると — 骨格 → メモリ語彙 → TLBインライン → 定規と正しさの穴 → 8bit語彙と損益再審 → 条件判定のインライン化 → 入場路の痩身 → chaining(不採用の学び) → tick粒度(真犯人)。振り返ると、後半の主役はコードよりも測定だった。プロファイルが的を教え、交互A/Bが嘘を弾き、ノイズ床が「単体では裁けない」と言い、診断実験が真犯人を指した。
残りの玉(ゲストレジスタのホストレジスタ常駐化・間接分岐キャッシュ・ストアTLBインライン)は台帳に寝かせてある。実測が呼んだら取りに行く。
(rustx86のコードは GitHub にある。前回: JIT編#1、ネットワーク編・ディスク編もどうぞ)


コメント