Rust自作x86エミュレータ最適化戦記 — QEMU式lazy flagsより「控えの薄切り」が効いた夜 (66→79 MIPS)

AI

Rust製のx86エミュレータ rustx86 で、ブラウザ (WASM) でも動く32bit Linuxを起動している。前回までにフルブート10秒 (ネイティブ66 MIPS) まで来ていて、目標は100 MIPS。「エミュレータの速さは以後すべての検証の単価」— 速くすればするほど、この先の全部の実験が短くなる。

今回はその続き。期待した最適化が効かず、ノーマークだった最適化が効き、そして4連敗して構造の上限に到達した一晩の記録である。数字は全部、交互A/B (新旧バイナリを交互に走らせて差だけを見る) での実測。

C1: lazy flags — 期待10〜25%、実測3%

x86のALU命令は毎回6個のフラグ (CF PF AF ZF SF OF) を書く。だがフラグは書かれる回数 >> 読まれる回数で、大半は次のALU命令に上書きされて誰にも読まれずに死ぬ。そこでQEMUと同じ cc_op 方式にした — 演算のたびにフラグを合成するのをやめ、材料 (op, a, b, carry, result) だけ控えて、読まれた瞬間に必要な1ビットだけ計算する。

// ALU本体 — フラグはここでは計算しない。材料を控えるだけ
fn alu_lazy(c: &mut Cpu, op: u8, a: u32, b: u32, w: u8) -> u32 {
    let r = match op { /* 演算そのものだけ */ };
    c.set_cc(op, w, a, b, cin, r);   // ← 6フラグ分の合成が消える
    if op == 7 { a } else { r }      // CMPは書き戻さない
}

// 読む側: Jccが欲しいのは大抵1ビットだけ
pub fn flag(&self, mask: u32) -> bool {
    if self.cc_op == CC_NONE || mask & CC_MASK == 0 {
        return self.flags & mask != 0;   // IF/DF等は今まで通り
    }
    match mask {
        ZF => self.cc_r == 0,            // ← cmp+je はこれ1個で済む
        CF => self.cc_cf(),
        // ...
    }
}

実装で効いた設計判断が1つ。flags フィールドを private化して、読み書きを flag()/set_flag()/eflags()/set_eflags() の4つの口に集約した。シフトやMULのような「6フラグの一部だけ書く」少数派は set_flag() が先に具現化してから書くので、意味論は1ビットも変わらない。直アクセスしていた14箇所 (PUSHF・割り込み配送・スナップショット・Unicorn照合) はコンパイラが全部見つけてくれる — 規律を人間の注意力ではなく型システムに守らせる。

検証はいつもの関門: Unicornとのランダム照合 (cosim) 全緑、実カーネル1命令ずつ突き合わせ (kernel_lockstep) 完走、そして起動命令数がビット同一 (580M)。

で、結果は交互A/B 6周で -3〜4% (8.9→8.6s)。期待の10〜25%には遠く届かなかった。

なぜか。期待値はブロック連結 (B4) を入れる前の前提だったからだ。B4が毎命令の固定費を先に食い尽くしていて、残ったプロファイルの中でフラグ合成はもう小さな費目だった。前回のB3 (ペア融合) とまったく同じ構図 — 最適化の取り分は積み木ではない。先行する最適化が、後続の前提を書き換える。

C4b: プロファイルのmemmove 11%の正体

lazy flagsで空振りした後、プロファイルを取り直したら memmove がまだ11%残っていた。正体を追うと、#PF巻き戻し用のCPU丸ごとcloneだった。

このエミュレータは、命令の途中でページフォールトが起きたら「その命令は何も起きなかったことにして」巻き戻し、#PFを配送して再実行する。そのための控え (命令前のCPU状態の複写) を、メモリに触る命令の前に毎回取っている。この複写がCpu構造体まるごと約400バイト — XMMレジスタ128B、セグメント隠しレジスタ72B、デバッグレジスタ32B、全部入り。

だがキャッシュ済みuop (mov/ALU/push/pop/シフト…) の語彙に、セグメントレジスタやXMMを書く命令は存在しない。書き得るのは汎用レジスタ・IP・フラグだけ。なら控えもそれだけでいい:

/// キャッシュ済みuopが書き得る部分だけの薄い控え (~76B)
pub(crate) struct SlimSave {
    regs: [u32; 8],
    ip: u32,
    flags: u32,
    cc_op: u8, cc_w: u8,          // lazy flagsの材料も控える
    cc_a: u32, cc_b: u32, cc_cin: u32, cc_r: u32,
}

何でも起こせるフォールバック経路 (未実装命令・16bitコード) は従来どおり丸ごとcloneのまま。どちらの控えが有効かは enum が覚える。

これが -17% (8.9→7.4s)。期待していなかった側が、この夜最大の取り分だった。プロファイルの上位を「知っているつもり」で放置せず、正体を毎回追い直すのが正しい。

そして4連敗 — 構造の上限に触る

79 MIPSまで来て残り1.27倍。ここから台帳の候補を順に測った結果が壮観だった:

実験 仮説 実測
TLBスロット数 (1K〜16K) サイズ調整の余地 差なし (単発で速く見えたのは熱の運)
tickの一括払い 毎命令の時計RMWが無駄 +10%悪化
ブロックのトレース化 1MBの表へのランダムloadがストール源 +8%悪化 (6戦全敗)
表の固定長配列化 境界チェック除去 差なし

トレース化 (B5) は特に綺麗な反証になった。命令キャッシュを「バイト番地ごとの表」から「ブロック=連続uop配列」に組み替え、照合はブロック頭で1回・中身は連続読み、という教科書どおりの改善。命令数は不変、再デコードは15M→5Mに減り、構造は狙いどおりに動いて、それでも壁時計は遅くなった。

学びはこうまとめられる: M1のアウトオブオーダ実行・分岐予測・ストアバッファは、インタプリタの毎命令の照合も帳簿も既に隠蔽している。 その中をどう並べ替えても、1命令≒40サイクルの壁は動かない。コードが悪いのではなく、スイッチ型インタプリタという構造の上限である。

「M1が特殊なだけでは?」という疑いも実測で潰した。2012年のIvy Bridge実機 (後述) で同じA/Bを回したら、やはりトレース化は+4%悪化。ホストによらず割に合わない。 実験はタグ (exp/speed-b5-blocks) に保存して台帳に理由ごと記録した — 前提が変われば再訪できる。

おまけの収穫: 3ホスト×12構成で命令数がビット同一

Windows機とその上のUbuntu VMにもベンチ環境を作り (この構築記はコラムに分けた)、全組み合わせを測った:

ホスト native bzImage (970M) native vmlinux (580M) wasm bzImage (1000M) wasm vmlinux (600M)
M1 Air (2020) 10.7s / 90.7 MIPS 7.3s / 79.5 16.2s / 61.7 10.0s / 60.3
Ivy Bridge素 (2012) 23.9s / 40.6 15.2s / 38.2 36.9s / 27.1 21.6s / 27.8
同PCのVirtualBox VM 26.1s / 37.2 16.0s / 36.3 36.0s / 27.8 21.3s / 28.2

読みどころが3つある。

仮想化の税は5〜9%しかない (wasmではほぼゼロ)。エミュレータは純粋なユーザー空間演算で、VM exitをほぼ起こさないからだ。M1との2.4倍差の正体は、仮想化ではなくCPUの世代差 (2020 vs 2012) がほぼ全てだった — 「VMだから遅い」と言う前に、同一ハードのネイティブと並べて測るべきである。

wasm/ネイティブ比は67〜68%で、ISAによらず安定。 V8のwasm JITはarm64でもx86_64でも同じくらい仕事をする。

そして一番大事なのがこれ — 12マス全部で起動命令数がビット同一 (970M / 580M / 1000M / 600M)。このエミュレータは「決定性 (ビット同一再実行)」を売りにしていて、それがOS・ISA・ランタイム横断で成立していることが、ただのベンチのつもりだった表から証明された。

次: テンプレートJIT

インタプリタ内の再配置が全滅した以上、次は実行そのものを変える番 — デコード済みuop列をwasmコードに落とすテンプレートJITを本命に昇格した。設計方針はADR-0008に起こしてある。lazy flagsのcc_op方式は、実はこのJITのフラグモデルの土台でもある (生成コードはフラグを計算せず、ブロック出口で材料を書き戻すだけ)。空振りに見えたC1は、ここで回収される予定だ。

100 MIPSに届いたら店じまいして、次のマイルストーンへ — ANSI端末でDOOMを動かす

コメント

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