JITを削除したら1日で64→98 MIPSになった — Rust製x86エミュレータ、Linuxブート9秒台へ

AI

前回の記事「Rust製x86エミュレータ高速化の全戦績 (13→79 MIPS)」の結論はこうだった — 仕事を減らす手は勝ち、仕事を並べ替える手は負ける

この記事はその続編である。数ヶ月かけて作ったJITコンパイラを丸ごと削除するという決断をした日の記録で、結果から言うと、Linuxフルブートが1日で15.1秒から9.9秒 (64→98 MIPS) になった。削除こそが今年最大の単発最適化だった。

タイムがそのまま100m走になる物差し

土俵は「bzImage (自己解凍つきLinuxカーネル) のフルブート、busyboxシェル到達まで」。この経路の実行命令数は決定的にちょうど970M命令なので、目標の100 MIPSは9.70秒になる。

つまりブート時間がそのまま100m走のタイムとして読める。朝の時点の15.1秒は市民ランナー。目標の9.70秒はガトリン (9.74) のすぐ先。世界記録はボルトの9.58 = 101.3 MIPSである。この物差しは進捗が直感的に見えるので、本稿でもこのまま使う。

朝: JITが逆ザヤだと確定した

前回記事の時点でJIT (Cranelift製、カバレッジ46%) は「同一バイナリのon/off比較で-9〜13%」と、確かに効いていた。ところが絶対値の物差しを1本化して冷間で測り直すと、景色が変わった。

バイナリ bzImageフル 970M MIPS
素のcore (craneliftリンクなし) 15.1s 64
JITオフ (craneliftを同居させただけ) 18.9s 51
JIT全開 18.0s 54

craneliftをリンクしただけでインタプリタが25%遅くなる。 コードは1行も実行していないのにである。正体はコードレイアウト — 巨大な依存をリンクするとホットループの機械語の並びとiキャッシュの挙動が変わる (Mytkowicz “Producing Wrong Data Without Doing Anything Obviously Wrong”)。JITの取り分-9%はこのリンク税+25%を埋められず、JITを全部効かせても素のcoreより19%遅いという逆ザヤが確定した。

JITの収支は3項で読む必要があった:

JITの収支 = カバレッジ × 単価の改善 − リンク税
          =   46%     ×  (伸びしろ僅か)  −  25%   < 0

相対A/B (on/off) だけ見ているとリンク税は見えない。「別バイナリの絶対値は比べるな」という自分の教訓の裏返しで、物差しの一本化こそが逆ザヤを可視化した。

決断: 凍結ではなく削除

PGOを寝かせたとき (ADR-0009) と同じ作法で、賛否と復帰条件をADRに書き、実装をタグ f1c-final に保全して、mainからコードを消した。凍結 (コードを残して無効化) にしなかった理由は後述の実測が示す通りで、これが本稿の山場である。

削除しただけで-22%速くなった

JITは削除しても「受け口」がcoreに残る。デコードキャッシュのエントリに焼いたブロック番号 (+8バイト)、ブロック頭の熱カウンタ、連結ループ毎周の入口判定。コメントには「NoneならJIT無効時のコストはゼロ (退路の無コストは譲れない)」と書いてあった。

嘘だった。受け口ごと削除した前後の交互A/B:

round 受け口あり 削除後
1 16.3s 13.4s
2 17.1s 11.9s
3 14.9s 13.1s
4 15.4s 12.3s
5 16.0s 12.2s
6 14.7s 11.7s

6周全勝、-22%。 「呼ばれない機能は無料」は嘘で、ホットパスに置いた受け口は (a) 構造体を太らせてキャッシュ密度を下げ、(b) 毎周の帳簿でクリティカルパスを伸ばして、しっかり課金していた。

// before: 32Kスロット全部が8バイトずつ太る
struct Entry {
    tag: u32,
    gen: u32,
    len: u8,
    heat: u16,      // ← JITの焼き候補選び
    jit_n: u16,     // ← 焼けたブロックの命令数
    jit_slot: u32,  // ← 関数テーブルの添字
    uop: Uop,
}
// after
struct Entry { tag: u32, gen: u32, len: u8, uop: Uop }

これで12.3秒 (79 MIPS)。JIT全開 (18.0秒) より、JITを消した素のインタプリタの方が33%速い。

外部レビューを台帳で裁く

ここでChatGPTとGeminiにコードレビューを頼んでみた。結果が面白かったので方法ごと記録しておく。

  • Gemini (ドキュメントを読んだ): 提案の大半が実装済みか実測棄却済み — 台帳の後追いになった
  • ChatGPT (コードを読んだ): 台帳に無い実物の欠陥を見つけてきた

ChatGPTが見つけたのは「デコード済みなのに毎命令再分類している」という点である。ホットループはこうなっていた:

// dcacheからデコード済みuopを拾った直後なのに…
if exec::may_touch_memory(&uop) {  // ← uop全種の大きなmatch (1回目)
    m.guard_save_slim();
}
m.cpu.advance_ip(len as u32);      // ← 中でCSが32bitかを毎回判定
exec::exec(m, uop);                 // ← uopのmatch (2回目)

分類はデコード時に一度やれば済む。命令長は最大15バイトで len: u8 の上位4bitが空いているから、エントリのサイズを増やさず属性を焼き込める:

const LEN_MASK: u8 = 0x0F;
const F_MEM: u8 = 0x10;  // メモリに触るuop
const F_CTL: u8 = 0x20;  // IPを動かし得るuop

// 実行時はビットを見るだけ
if lf & F_MEM != 0 {
    m.guard_save_slim();
}
m.cpu.advance_ip32(len);  // 32bit確定経路専用 (セグメント幅判定なし)

さらにF_CTLが0なら (約6分の5の命令)、実行後の「IPは直線のままか」比較すら省ける。この一式で7勝1敗の-8%。二重ディスパッチとip更新の幅判定は、OoOの影ではなく「次の値を作る直列仕事」だった。

translate-first: フォールトに備えた複写をやめる

メモリに触る命令は、#PFで巻き戻すために毎回76バイトの控え (レジスタ・IP・フラグ) を取っていた。だが平坦セグメントで、ページ内で、アドレス変換が通った後の実行にフォールトは無い。なら控える前に成功を確定させればいい:

Rm::Mem(mr) => {
    let off = off_of(m, &mr);
    match m.fast_read32(mr.seg as usize, off) {
        Some(v) => v,  // 成功確定 — 控えゼロで実行
        None => {      // 跨ぎ・非平坦・フォールトしそう → 控えて従来経路
            m.guard_save_slim_at(prev_ip);
            m.read32(addr_of(m, &mr, 4, false))
        }
    }
}

フォールトの配送は常に従来経路なので、巻き戻し機構の不変条件は無傷である。mov・スタック系 (push/pop/call/ret)・load系へ展開して8勝0敗の-5%

途中で本物のバグを1つ買った。低速路の控えを実行部の中 (IP更新後) に移したら、巻き戻し先IPが1命令先になり、#PF後の再実行が飛んだ。Linuxのinitが起動7秒後にSIGSEGVで死ぬ、という形で3OS起動回帰が即座に検出してくれた。「フォールトした命令は何も起きなかったことになる」という約束はIPも含む — 当たり前のことを、門番に教えられた。

+17%の大負けを#[cold]で反転する

RMW命令 (add [mem], reg) への展開は最初、0勝8敗の+17%悪化という大事故になった。速い道と遅い道を両方matchのarmに埋め込んだ結果、実行部のコードが肥大して命令キャッシュとインラインの均衡が崩れたのである。前回記事のB3 (+24%) と同じ「足した機構の嵩で払う」だ。

ならば嵩だけ分離すればいい。低速路を8本の関数に追い出した:

#[cold]
#[inline(never)]
fn slow_rmw32(m: &mut Machine, mr: &MemRef, kind: u8, b: u32, prev_ip: u32) {
    m.guard_save_slim_at(prev_ip);
    let addr = addr_of(m, mr, 4, true);
    let a = m.read32(addr);
    let v = alu_w(&mut m.cpu, kind, a, b, true);
    m.write32(addr, v);
}

armには速い道数行+呼び出し1行だけが残る。これだけで+17%が5勝2敗の微勝ちに反転した。「嵩で払う」理論の、きれいな対偶実証である。

効かなかった手たち (同日5連敗の記録)

勝った手だけ書くのはフェアではない。同じ日に、理屈は立派だったのに動かなかった手が5本ある:

理屈 結果
ページ跨ぎ連結 連結ループの退出理由を数えたら100%がページ跨ぎ (14M回) — 居座れば変換とはしごを14M回省ける ワッシュ。カウンタは回数を教えるが単価を教えない — 69命令に1回の変換は既にOoOの影の中だった
Entry構造体のSoA分割 照合部を8B/スロットに詰めてキャッシュ密度向上 ワッシュ。ヒット時に2ライン触る税と相殺
dcacheスロット4倍 衝突による再デコード10M回が消える ワッシュ。再デコードも影の中
境界チェック省略 (unsafe) Rustの配列境界検査を証明つきで省く ワッシュ。分岐予測が当たり続ける検査は既にタダ
16bit mov語彙追加 従来経路落ち19Mの74%が0x66プレフィクスと判明 → 最頻のmovを救済 ワッシュ。従来経路1回の単価は見積もりの1/4しかなかった

5連敗から出てきた計測方法論が1つある。意味が完全に同一のバイナリ (死んだフィールド16バイトの差だけ) が、レイアウトと熱で±4%動くことを対照実験で確認した。以後このプロジェクトでは、±4%以内の差で勝敗を宣言しない。小さい改善はバッチで積んで合算で判定し、勝ったら二分して寄与を割る。

1日の結果

1日のタイム推移と100m走の物差し

節目 タイム MIPS 100m走でいうと
朝 (JITあり時代のcore) 15.1s 64 市民ランナー
JIT削除 (-22%) 12.3s 79 県大会
二重ディスパッチ解消 (-8%) 11.4s 85 インターハイ
translate-first (-5%) 10.9s 89 大学トップ
公式ベスト 9.9s 98.0 桐生 (9.98)・山縣 (9.95) 超え

ガトリン (9.74) まであと1.6%、目標の100 MIPS (9.70) まであと2%。ボルト (9.58) はまだ遠い。

まとめ — 貫通原理と、JITの墓碑銘

16の手を試した前回と合わせ、この2日で分かった原理は1行に圧縮できる:

OoOが隠すのは独立な帳簿だけ。次の値 (IP・分岐先・ガード判定・アドレス変換) を作る仕事は、名前が「付帯処理」でも直列であり、削れば効く。

勝った手 (受け口の税-22%、二重ディスパッチ-8%、translate-first-5%) は全部「次の値を作る直列仕事」を削り、負けた手とワッシュは全部「影の中の帳簿」を削るか「機構の嵩」を足していた。

JITの墓碑銘も書いておく。JITが負けたのは技術が悪かったからではない。インタプリタを先に磨き切った (デコードキャッシュ+ブロック連結) せいで、テンプレートJITの旨味が先食いされていたところに、リンク税25%を払わされたからである。復活条件はADRに書いた — 土俵がブートではない定常ワークロードに変わったとき、そしてホストフラグ流用やレジスタ常駐といった「依存連鎖そのものを削る」深さまで一気に行く決断をしたとき。中間深度に長居しない。

明日の朝、完全に冷えた機体で9.7秒に挑戦する。実装は全部 GitHub にある — 消したJITも、タグの中で眠っている。

追記 (公開30分後): 目標達成、ついでにボルトも抜いた

「明日の朝」と書いたが、待てなかった。機体を15分だけ完全アイドルで冷やして、公式トライ8本:

9.8  9.8  9.9  9.8  9.8  9.7  9.7  9.5

9.70秒 (=100 MIPS) を2本、そしてラストの9.5秒 = 102.1 MIPS。 ガトリン (9.74) を抜き、目標を通過し、ボルト (9.58) まで抜いてしまった。JITなし・外部クレートなしの素のインタプリタで、である。

朝の時点で15.1秒だったことを思えば、今年最大の最適化が「数ヶ月かけて作ったJITの削除」だったという事実は、当分の間、自分への戒めとして効き続けると思う。測ったものだけを信じる。消す勇気を持つ。

コメント

タイトルとURLをコピーしました