Rust製x86エミュレータ高速化の全戦績 — なぜ効いたか、なぜ効かなかったか (13→79 MIPS)

AI

ブラウザで32bit Linuxを起動するx86エミュレータ rustx86 (Rust + WebAssembly) を、13 MIPSから79 MIPSまで約5倍にした。この記事はその全戦績 — 効いた手・効かなかった手・効いたのに採用しなかった手 — を、なぜそうなったのかの理屈まで含めて棚卸しする。

先に結論を言ってしまうと、勝敗の分水嶺は一つだった。

仕事そのものを減らす手は勝ち、仕事を並べ替える手は負ける。

「明らかに無駄な処理を削ったのに1msも速くならない」を4連敗して初めて、この分水嶺が見えた。理屈は後半で図解する。

前提: 測定の規律

数字を並べる前に、このプロジェクトの測定ルールを2つだけ。ここを外すと以降の数字は全部読めない。

  • 交互A/Bだけを信じる。M1ラップトップは数分のベンチで熱ダレし、同じバイナリが12.5〜14.2sぶれる。新旧を別々の時刻に測った比較は熱を測っている。新旧バイナリを交互に走らせただけが判定
  • 命令数は決定的。同じカーネルイメージなら、シェル到達までの実行命令数は毎回ビット同一 (ネイティブvmlinuxで580M)。命令数の増加は「速度の変化」ではなく意味の後退の印なので、最適化が意味を変えていないことの証明にも使う

土俵はネイティブ (M1) のvmlinux直接ロード起動。数字は断りがない限りこの経路の交互A/B実測である。

全戦績

最適化の全戦績 — 交互A/B実測の横棒チャート

16の手を試して、採用が9、寝かせ/凍結が2、ワッシュ (差なし) が3、悪化が3。「期待」の欄と「実測」の欄が一致した手はほとんどない — 期待10〜20%のB4が-43%叩き出す一方、期待10〜30%のB3は+24%悪化した。理屈を信じて実装し、判定は壁時計に任せる、を16回繰り返した記録である。

なぜ効いたのか — 勝った手は全部「仕事を減らして」いた

実行しない (E1/E2: 命令数-40%)

最速の命令は、実行しない命令である。bzImageの自己解凍スタブはゲスト内で971M命令中391Mを費やしていた。非圧縮vmlinuxをホスト側で展開して直接ロードすれば、この391Mは実行されない(E1)。さらにスナップショット起動 (E2) なら起動処理そのものを実行しない — 復元~1sで、これが最速である。

デコードしない (B1/B2: -25%、wasmは約2倍)

x86のデコード (プレフィクス解析・ModRM・即値長の解決) は毎命令の直列仕事の中で最も高い。デコード結果を物理アドレスをキーにキャッシュし、2回目からはデコードをしない (dcache)。自己書き換えはページ世代で失効させる。

wasmで効きが跳ね上がる (約2倍) のには理由があって、wasmはネイティブより間接分岐とメモリアクセスの単価が高い — デコードのような「分岐だらけの仕事」の削減は、単価が高い環境ほど大きく効く。

ループに戻らない (B4: 期待10〜20%に対し実測-43%、全戦績の最大打点)

1命令実行するたびに外側のループへ戻ると、lin計算・ページ変換・「はしご」(デバッガ判定・HLT判定・BIOS入口判定…) を毎回払い直す。B4は次の命令が同じ物理ページに居るかぎり、キャッシュ実行ループの中に居座る。分岐でも途切れない — 行き先が同じページなら物理番地を差し替えて続行するので、ホットループがまるごと連結される。

ここで重要なのは、意味を変えないための約束を実装コメントに明文化したことだ:

/// 意味を変えないための約束:
/// - **時計は1命令粒度のまま**。連結中も毎命令 tsc+=1 と装置tickを、外側の
///   [`Machine::step_inner`] と同じ順序で回す。命令数の決定性は崩れない
/// - **割り込みの受付点も1命令粒度のまま**。毎命令の境界で保留を見て、
///   受けられるなら連結を打ち切って外へ返す
/// - **タグ+世代の照合は毎命令やる**。連結は照合を飛ばさない — 自己書き換えの
///   検出は非連結時と同一。連結が省くのは変換とはしごだけ
pub(crate) fn step_cached(m: &mut Machine, chain_extra: u64) {

つまりB4が削ったのは帳簿でも照合でもなく、アドレス変換とはしごという実仕事だけ。これが-43%になり、後述の「帳簿を削る」系が全滅したのと好対照になる。

コピーしない (C4b: -17%)

プロファイラにmemmoveが11%出ていた。正体は#PF巻き戻し用の控え — メモリに触るuopの前に、失敗したら巻き戻せるようCpu構造体を丸ごと (~400B、xmm 128B + セグメント隠しレジスタ72B込み) cloneしていた。

だがキャッシュ済みuopが書き得るのは汎用レジスタ・IP・フラグだけだ。sregsもCRもxmmも、uopの語彙に存在しない。なら控えるのは書き得る~76Bだけでいい:

/// #PF巻き戻し用の**薄い控え**。
/// キャッシュ済みuop (dcache) が書き得るのは 汎用レジスタ・IP・フラグ
/// (遅延材料含む) だけ — sregs/hidden/CR/xmm/dr/gdtr はuopの語彙に無い。
/// Cpu丸ごと (xmm 128B + hidden 72B + …) を毎回複写するのをやめ、
/// 書き得る ~76B だけ控える。
#[derive(Default)]
pub(crate) struct SlimSave {
    regs: [u32; 8],
    ip: u32,
    flags: u32,
    cc_op: u8,
    cc_w: u8,
    cc_a: u32,
    cc_b: u32,
    cc_cin: u32,
    cc_r: u32,
}

これは実メモリトラフィックの削減であり、後述のOoOにも隠せない種類のコストだったから効いた。なお同じプロファイルに出ていたBTreeSet::insert「27%」は削っても壁時計が1msも動かなかった — プロファイルの数字は容疑者リストであって判決ではない、という教訓もここで得た。

待たされない (タブ税: ブラウザ21→15s)

ブラウザだけheadless実行より×1.5遅い「タブ税」の正体は、Rustでもwasmでもなく、ワーカーのJavaScript 2文字だった:

// NG: 「0ms後」のつもりが、タイマのネスト5段目からは
// HTML仕様のスロットリングで毎回最低4ms待たされる
function loop() {
  runSlice();           // ~8ms の計算
  setTimeout(loop, 0);  // ← 実質 setTimeout(loop, 4)
}

// OK: MessageChannel の自分宛メッセージはクランプ対象外のマクロタスク
const wake = new MessageChannel();
wake.port1.onmessage = () => loop();
function loop() {
  runSlice();
  wake.port2.postMessage(0);  // 隙間なく次へ。キー入力も同じFIFOで割り込める
}

8ms働いて4ms強制休憩、デューティ比67% = ×1.5。実測と完全に一致した。エラーも警告も出ず、プロファイラにはただの待機として写る、完全に無音の税である。これも「実行環境が挟んでいた待ち時間」という本物の仕事 (をしていない時間) の削減だった。

なぜ効かなかったのか — 負けた手は全部「仕事を並べ替えて」いた

ここからが本題である。以下の4手は、理屈の上では確実に仕事が減るはずだった:

  • B3 頻出ペア融合 (cmp+jccを1 uopに): +20〜28%悪化 (4変異体全部)
  • B5 トレース化 (ホットループを配列に直列化、表引き排除): +8%悪化
  • C5 tick一括払い (毎命令のtick帳簿をブロック単位でまとめ払い): +10%悪化
  • C7 dTLB最終結果キャッシュ (TLBヒット経路の照合を短絡): 差なし

ディスパッチが1回になる。表引きが消える。帳簿が1/Nになる。照合が飛ばせる。どれも「実行される処理」は確かに減っている。なのに壁時計は動かないか、悪化した。

犯人はM1のアウトオブオーダ実行

足し算のモデルとOoOの現実 — 帳簿はクリティカルパスの影で並列実行されている

頭の中のモデルでは、1命令のコストは「帳簿+照合+ディスパッチ+実仕事」の足し算だった。だから帳簿を削れば合計が縮むはずだった。

現実のM1はアウトオブオーダ (OoO) 実行機で、数百命令の窓の中から依存の無い仕事を並列に拾って実行する。毎命令のtick帳簿 (tsc += 1) はストアバッファが吸い込む。世代照合の分岐は分岐予測が当たり続けてパイプラインを止めない。ディスパッチの表引きは次の実仕事と並列に走る。つまり帳簿・照合・ディスパッチは、とっくに実仕事の陰で「タダで」実行されていた

壁時計を決めているのは実仕事の依存連鎖 — ゲストメモリのロード→演算→ストア→次命令のアドレス計算 — の長さだけで、実測~40サイクル/命令。影に居る仕事をいくら削ってもこの連鎖は1サイクルも縮まない。それどころか、削るために足した機構 (融合テーブル・トレース配列・まとめ払いのカウンタ) がレジスタ圧とコードの嵩という実コストになって顔を出す。B3の+24%は「削った帳簿の分だけ損した」のではなく「足した機構の分だけ払った」のである。

念のためB5はx86ホストでも走らせた — +4%で悪化が再現した。M1固有ではなく、現代のOoO機に共通の構造である。

この理屈は逆向きにも読める。B4 (-43%) が削ったアドレス変換は、なぜ隠れていなかったのか? 変換の結果は次のフェッチが依存する — つまりクリティカルパス上の直列仕事だったからだ。同じ「削る」でも、影を削るか連鎖を削るかで結果が180度変わる。

期待外れの中間例: C1 lazy flags (-3〜4%)

x86はほぼ全命令がフラグを書くので、フラグ計算の遅延 (QEMUと同じcc_op方式 — 演算の材料だけ控えて、フラグが読まれた瞬間に計算する) は10〜25%効くはずだった。実測は-3〜4%。

pub fn flag(&self, mask: u32) -> bool {
    // 遅延中でも、対象外のフラグ (IF/DF等) は flags を直接見る。
    // このifは分岐予測が当たり続けるので、eager時代のコストとほぼ同じ
    if self.cc_op == CC_NONE || mask & CC_MASK == 0 {
        return self.flags & mask != 0;
    }
    match mask {
        CF => self.cc_cf(),
        ZF => self.cc_r == 0,
        SF => self.cc_r & self.cc_sign() != 0,
        // ...
    }
}

期待値の出典が古かった。10〜25%という見積もりはB4導入の毎命令ディスパッチ時代の相場で、B4後の世界ではフラグの即時計算も既にOoOの影に半分沈んでいた。それでも採用したのは、cc_op方式が後のJITのフラグモデルの土台になるからで、この投資は回収済みである。

効いたのに採用しなかった手: PGO (-25%)

プロファイル誘導最適化 (PGO) は交互A/B 5周全勝の-25%、命令数不変。数字だけ見れば文句なしの採用である。これを寝かせた

理由は速度ではなく運用にある。PGOは「訓練プロファイルに依存する」技法で、一度入れると以後すべての性能議論に「遅いのはプロファイルが古いからでは? 再訓練すべきでは?」という運用判断が半永久に付随する。交互A/Bの測定文化とも相性が悪い (公平な比較には毎回再訓練が要る)。速さは設計 (アルゴリズム・JIT) で取る、という方針でADR-0009に賛否と復帰条件を書いて、実験はタグ exp/pgo-build で保全した。

負けた実験も同様に、ブランチではなくタグ (exp/speed-b3exp/speed-b5-blocksexp/c7-dtlb) で残している。「その時点の前提でこうだった」は情報であって、前提が変われば再訪する — 実際B3は「JITの生成コード内では前提が変わる」ので台帳上は寝かせ扱いである。

そしてJITへ — 収支は「カバレッジ×単価」で分解する

再配置系の全滅で、インタプリタの~40サイクル/命令は構造の壁だと確定した。壁を動かすには依存連鎖そのものを消す — つまり複数のゲスト命令を1つの生成コードに畳み込むJITしかない。ここでも measured-first の流儀は変わらなかった。

wasmテンプレートJIT (F1a〜F1b-3) は、骨格→メモリロード→ストア→スタック形と語彙を広げてもA/Bが微赤字のままだった。カウンタを仕込んで分解すると、原因は単価ではなくカバレッジ1.2% — JITで実行されている命令が600M中7Mしかなかった。犯人は入口テーブルの追い出し (32Kスロットにfill 15M回) と、ブロック頭が分岐の着地点でしか育たない構造で、対策後は18.1%まで伸びた。それでも平均ブロック長4.4ではヘルパ呼びの税とディスパッチ除去益が相殺してワッシュ。「wasmバックエンドの磨き込みは終点 (ネイティブ) に載らない」と判断してここで凍結し、フロントエンド資産 (フォールト脱出モデル・語彙・カバレッジ機構・決定性の審判) を持ってネイティブへ移った。

Cranelift版 (F1c、進行中) は同じ資産の上で冷却A/B 6周全勝の-9〜13%、カバレッジ46.5%まで来ている。JITの収支は常に「カバレッジ×単価」の積であり、どちらが赤字の原因かをカウンタで確定させてから次の手を打つ — この分解の習慣がwasm凍結の判断とF1cの設計順序を両方決めた。

まとめ — 次の最適化を出す前のチェックリスト

  1. その手は仕事を減らすか、並べ替えるか。 減らす対象がクリティカルパス上 (変換・コピー・デコード・実行そのもの) なら勝ち筋。帳簿・照合・ディスパッチなら、OoOが既に隠している可能性を疑う
  2. 単発の速さは熱の運。 交互A/Bの差だけを信じる
  3. 命令数の決定性を意味不変の証明に使う。 増えたら速度の話ではない
  4. プロファイルは容疑者リスト。 判決は壁時計 (27%が幻で11%が本物だった)
  5. 別バイナリの絶対値は比べない。 コードレイアウトだけで20%以上動く (実測: 同一ロジックで9.7s vs 12.7s)
  6. 効いた手を採用しないなら、賛否と復帰条件をADRに残す。 負けた実験はタグで保全する

理屈で予想して、実装して、壁時計に裁いてもらう。16回裁かれた結果がこの台帳である。全記録は docs/reference/perf.md にある。

コメント

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