自作のx86エミュレータ rustx86 は、インタプリタのまま100 MIPSに到達したところで最適化を一区切りにしていた。本稿はその第2ラウンドの記録である。結論を先に書くと、勝ったのは1件 (-19%)、負けとワッシュが4件。そして勝った1件は、機構を何も足していない — コードを動かしただけである。
外部レビューを台帳で裁く
発端はChatGPTにソースを読ませた壁打ちだった。返ってきた本命案は「デコード済みキャッシュの世代照合をやめて、書き込み時に物理的に無効化する」— 理屈は通っている。ところがこれ、3日前に実装して8周の交互A/Bで2勝4敗2分のワッシュを食らい、タグに保全してクローズした案そのものだった。
うちのリポジトリには負けた実験も理由つきで台帳 (perf.md) に残る。だから外部レビューには台帳ごと突き合わせて返せる。「その案はPR #111で実測済み。tagロードとpage_genロードは独立で、M1のOoOが並列発行していた」— ここまで返すと、レビューの側も精度が上がる。実際ChatGPTは初回5案のうち3案を自己撤回して、判別則を踏まえた新しい案を出し直してきた。
この往復で確立した判定基準が今回の主役である:
判別則(4): 削って効く「直列」とはループ搬送依存のことである。 命令内で閉じる仕事は、たとえ毎命令必ず通る形でも、OoOが隣のゲスト命令と重ねてしまう。予測が当たり続ける分岐は鎖の辺ではない。

正しさの借金を先に返す — 自己書き換え検出の穴
レビューの中で唯一「当たり」だったのが正しさの指摘だった。デコード済み命令キャッシュ (uopキャッシュ) は自己書き換えコード (SMC) をページ単位の世代で検出するのだが、その通知網に素の線形ストアが入っていなかった。note_write を呼ぶのは REP文字列とDMAだけで、ゲストの普通の mov [mem], imm はキャッシュを腐らせ放題。Linuxが無事だったのは、カーネルのコード書き換え (text_poke) がたまたま memcpy = REP MOVS 経由で網に入る、という偶然に依存していた。
テストを先に書いた。控えに載った inc eax を、ゲスト自身のストアで inc ecx に書き換えて戻る:
0x2000: 40 inc eax ← 1周目に控えへ載る
...
0x200E: C6 05 00 20 00 00 41 mov byte [0x2000], 0x41 ← 自分を書き換える
0x2015: EB E9 jmp 0x2000 ← 戻ると…?
修正前は3経路 (movストア / RMW直書き / dwordストア) とも古い写しが実行されて赤。eax=2, ecx=0 — 書き換えたはずの命令が生きている。配線を塞いで緑にした。
面白いのはここからで、修正の税を測ると、ストア密度の高いコース (cpio展開) で+2.0%残った。書き込みのたびに引く page_has_code: Vec<bool> が128MB RAMで32KB — L1に収まりきらない。1bit詰めにして4KBにしたら:
// Vec<bool> (1ページ1バイト = 32KB) → 1bit詰め (4KB) でL1常駐
if let Some(w) = self.page_has_code.get_mut(p >> 6) {
let bit = 1u64 << (p & 63);
if *w & bit != 0 { /* コードページへの書き込みだけ世代を進める */ }
}
5勝0敗・-1.4%で逆転。正しさの修正が代金ゼロどころか、僅かに速くなって着地した。
定規が漂流していた
ベンチを回して気づいたのだが、性能の定規にしていたbzImageブートのコースが970M命令から1370M命令に伸びていた。initramfsにTLS一式やディスクドライバを積んだからだ。荷物が変わると命令数ごと変わり、過去の「9.5秒」と比較できない。
対策は単純で、定規はレシピではなく現物で凍結する。イメージ取得スクリプトはAlpineの「常に最新」を指すので、スクリプトから作り直したイメージは定規にならない。プライベートのイメージ置き場に bzImage-bench と initramfs-bench (更新禁止) をコミットし、ベンチハーネスの既定コースにした。復元検証でぴったり970M命令 — 決定性のある機械は、こういうとき気持ちがいい。
本命: ホットループから冷たいコードを追い出すだけで-19%
広範囲にコードを精査し直して、2つの事実が出てきた。
ひとつ。uopキャッシュのEntry構造体は、コメントに「約24B・表768KB」と書いてあったが、レイアウト計算すると32B・表4MiBだった。enumの判別子パディングで8Bが捨て札。コメントの数字は腐る。
ふたつ。ホットループの中に、冷たいコードが大量に同居していた:
- 最頻命令 (mov/alu/test) のarmに、#[cold]化したはずの低速路と逐語同一のインラインコードが12箇所残存
- キャッシュミス時のfill経路 — 4MiBのVec確保コードを含む — がループ本体に展開
- 出現率1%以下の稀な命令のarm群が、ホットなmatchを太らせている
translate_for(全メモリアクセスが通る) に、TLBミス時のページウォークとA/Dビット帳簿が同居
全部まとめて #[cold] #[inline(never)] の外へ出した。意味論は1bitも変えていない。移動だけ。
結果: 交互A/B 8周で8勝0敗、11.4秒 → 9.2秒 = -19%。 温間で105 MIPSに乗り、朝の冷間ベストを温間で超えた。
教訓がひとつ精密になった。以前「境界検査を省いてもワッシュ」という実測から「検査はOoOの影」と結論していたが、あれは分岐の話であって、コードの嵩 (I-cache・フロントエンド) は別勘定だった。過去に特殊化でコードを3倍に膨らませて+17%悪化した実験があり、今回の-19%はちょうどその逆演算になっている。
そして4連敗 — 全部OoOの影
勢いに乗って残りの案も全部измった。全滅である。ここが今日いちばん面白いところで、負け方が全部同じだった。
| 案 | 結果 | 敗因 |
|---|---|---|
| 鎖の直短縮 (set_ip32・ipのレジスタ返し・ページ不変量) | 1勝6敗1分 (+2%、床内) | 削ったのは予測分岐と独立ロード = OoOの影 |
| Entry痩身 32→24B (表4MiB→3MiB) | 2勝5敗1分 (床内) | -19%の正体はI-cacheで、Entry表の熱い部分は元からキャッシュ在住 |
| victim TLB (QEMUはSPECINT+10.7%の実績) | census死亡 | 実測ミス率0.2238% — 直接マップ4096スロットで既に足りていた |
| dead-flags解析 (Box64/QEMU恒久採用) | 机上監査で凍結 | 毎命令が割り込み受付点=EFLAGSの観測点。QEMUが省けるのはブロック粒度実行の特権で、毎命令cosimと両立しない |
3つ目と4つ目は実装すらしていない。victim TLBは計測カウンタを1周回しただけで死に、dead-flagsは正しさの監査だけで箱に入った。測る前に死ぬ案・測るまでもなく凍結される案を、実装せずに裁けるのは、決定性とcosimという門番を先に建てたおかげである。
おまけ: ブラウザのgccとv86
v86 (ブラウザx86エミュレータの完成形) のArch Linuxデモで time gcc hello.c を打つと、実測で1分7.9秒かかった。rustx86は同じことが冷間4.4秒・温間2.25秒で終わる。
ただしこれはCPUの勝負ではない。v86の数字は user 1.6s + sys 0.9s — つまりゲストCPUは2.4秒しか働いておらず、残り65秒はネットワーク越しのファイルシステム待ちである。うちが速いのはsquashfsのディスクイメージを先にローカルへ運ぶ設計 (圧縮は輸送路の仕事、という先週の判断) が効いているからで、CPU単体ではJITを積むv86がまだ1.5倍以上速い。
それでも温間の2.25秒は、v86のゲストCPU時間 (2.4秒) より短い。インタプリタがJITのCPU時間に壁時計で勝つ日が、設計の置き所ひとつで来る — この非対称が今回いちばん気に入っている数字だ。
まとめ
- 外部レビューは負けた実測の履歴を渡すと質が上がる。台帳は防壁であり通訳である
- 正しさの修正はテスト先行で。税が出たらフットプリントを疑う (32KB→4KBで代金ゼロになった)
- 性能の定規は現物で凍結する。レシピは漂流する
- 現代のOoOの上でインタプリタを速くするのは「仕事を減らす」ことではない。コードを痩せさせる (I-cache) か、ループ搬送依存を切るかの二択で、後者はもうほぼ残っていない
- ここから先の大物は観測粒度 (ブロック実行) かJITの再訪 — つまり次の戦場は「毎命令の正確さ」という看板との交渉になる
計測は全部、凍結した970M命令コースでの交互A/B。単発の速さは熱の運なので信じない。


コメント