RustとWASMで作るx86エミュレータ、18日間・226PRの完走記 — ブラウザでLinuxとDOOMが動くまで(ポストモーテム)

Rust

Rust製のx86エミュレータ rustx86 を完結させた。2026年8月7日の Initial commit から8月24日の最終マージまで18日間、PR 226本。ブートセクタのHello, Worldから始めて、最後はブラウザのタブの中で32bit Linuxが起動し、Xデスクトップが上がり、DOS版DOOMが音付きで遊べるところまで来た。

これはそのポストモーテムである。何がうまくいったか、何を作って捨てたか、どんな事故を踏んだか、そしてなぜここで終えるのかを記録する。

数字で見る18日間

項目
期間 2026-08-07 〜 08-24 (18日)
PR 226本 (マージ206)
コード Rust 約38,000行 + docs 約9,000行
判断の記録 ADR 28本、罠の型 14型
速度 13 MIPS → 133 MIPS (約10倍)
互換 test386.asm 完全合格 (386SX実機の期待値と44,926行ビット一致)

到達点を「動くもの」で言うとこうなる。

  1. ブートセクタの Hello, World
  2. 16bit UNIX (ELKS) がブラウザでログイン→シェル→テトリス
  3. FreeDOS 1.4 が A:\> まで起動
  4. 32bit Linux (Alpine 6.18) がブラウザで起動し、busyboxシェルが操作できる ← 当初の完成ライン
  5. そのLinuxから本物のホストに ping、さらに wget https:// でTLSまで通る
  6. VGA mode 13h → フレームバッファ → Xorg + icewm がブラウザに上がる
  7. DOS版DOOMがメニュー→E1M1→移動→扉まで遊べて、OPL2のFM音源でBGMが鳴る

DOOMを動かすには、DOS/16M(DPMIホスト)が踏む16bitプロテクトモードの獣道、BIOS INT 13hのハードディスク、VGAのMode Y(チェーン4解除+4プレーン)、そしてFM音源の自前合成まで要った。「DOOMが動く」は総合試験として実によくできている。

速度の推移 — 13 MIPSから133 MIPSへ

実効MIPSの推移

出発点はネイティブで13 MIPS(Linuxシェル到達まで77秒)。そこから、

  • B4 ブロック連結(デコードキャッシュ内で同一ページの実行列を連結): -43%
  • C4b 控えの薄切り(#PF巻き戻し用のCPU控えを、丸ごとcloneから触るレジスタだけの76Bに): -17%
  • ホットパスの付帯処理の一掃で 2026-08-13 に100 MIPS達成 — JITではなくインタプリタで届いた
  • F1d テンプレートJIT(AArch64手書き、Craneliftなし)で 2026-08-18 に133 MIPS

一方で、「効くはず」の定番が軒並み効かなかった。TLBの結果キャッシュ、tick一括払い、固定長配列化 — 交互A/Bで測ると全部ワッシュか悪化である。理由は一貫していた: Apple M1のアウトオブオーダー実行と分岐予測が、毎命令の照合や帳簿つけのコストを既に隠している。隠れているコストを削っても速くならず、レジスタ圧でむしろ悪化する。約40サイクル/命令という壁は、プロファイラの数字ではなく実測の消去法で「構造」だと確定した。

教訓: 最適化は在庫表(効いた・効かなかった・理由)を資産にする。効かなかった案も理由つきで台帳に寝かせた。前提が変われば再訪できる — 実際、wasm JITで凍結した脱出モデルと語彙の資産は、後のネイティブJIT F1dでそのまま使った。

作って、捨てたもの

このプロジェクトで一番高くついた判断は、Cranelift JIT (F1c) を実装しきってから丸ごと削除したことだ。

コンパイル自体は動いた。しかし毎ブロックのリンク税(シンボル解決と再配置)が単価を食い潰し、交互A/Bでインタプリタに勝てない。2026-08-13に凍結・削除を決めた(ADR-0013)。その同じ日に、インタプリタ側のホットパス清掃で100 MIPSに届いた。そして5日後、Craneliftを経由しないテンプレートJIT(uopごとにAArch64の断片を手書きで貼る方式)が133 MIPSを出した。

負けた実装を消すのは痛いが、フロントエンド資産(フォールト脱出モデル・語彙カバレッジの計測機構・決定性の審判)は全部F1dに引き継がれた。捨てたのはバックエンドだけで、学びは捨てていない

同型の判断があと2つある。

  • fastmem(ホストMMUでゲストメモリを写像): 段2+ページ属性の保持まで実装して検証し、正しさは全緑 — それでも不採用。macOSの16KiBページ制約でユーザー空間が張れず、検査の前置きコスト×全ロードがヒットの利得を上回った。「動いた」と「採る」は別の判定である(ADR-0026)
  • PGO: 実測-25%、交互A/B 5周全勝 — それでも常設しなかった。「遅くなったら再訓練するのか?」という運用判断を開発ループに持ち込まないためだ(ADR-0009)。復帰条件だけ書いて寝かせ、後日、条件を満たした形(訓練ワークロード固定)で解放した

事故簿 — デバッグの謎解きから3つ

ベンチマークが20G命令走っていた

歴代のブラウザベンチが実は別物を測っていた。ベンチ末尾の HLTPITタイマ割り込みに起こされ、IRETで hlt の次(ゼロ領域)から再開し、64KB空間を一周して20G命令まで走り続けていた。修理は8254の制御語でch0を止めてから hlt; jmp で寝直すこと。以後、速度の見張りは「決定的な命令数」を持つCI回帰に集約した。

タブの中だけ×1.5遅い

ヘッドレスでは14秒なのにブラウザだと21秒。犯人はWorkerの setTimeout(loop, 0) 再予約がHTML仕様の「ネスト5段超は最低4ms」クランプに毎スライス当たっていたこと。8ms働いて4ms休む=デューティ67%で、実測の×1.5と完全に一致した。MessageChannelpostMessage に替えて2倍になった。

決定性ゲートをすり抜けた過払い

JIT実行後の一括清算が「次命令の前払い」をしてからページ跨ぎ判定でreturnし、跨ぎ着地のたびTSCが1だけ先行する — このバグはJIT on/offでシリアル出力と命令数がビット同一という決定性ゲートをすり抜けた。printkのµs解像度に届かない誤差だったからだ。捕まえたのは、より強い審判 — インタプリタとJITを並走させて save_state をバイト比較する二分探索。ゲートの解像度を過信しない、は罠の型3として台帳に入った。

このほか、GitHubのpublicリポジトリにディスク像を混入させて git filter-repo で履歴から抜く事故もやった。これは単独の記事にしてある(緊急手順のrunbook)。

うまくいったこと — 検証の多層防御

38,000行を18日で書いて壊れなかったのは、書く前に審判を立てたからだ。層になっている。

 L1  cosim        — ランダム単発命令を Unicorn Engine と毎命令照合 (フラグ意味論)
 L2  lockstep     — 実カーネルを Unicorn と1命令ずつ突き合わせ (幅バグを数十命令で特定)
 L3  test386.asm  — 386SX実機の期待値と出力44,926行のビット一致 (CI常設)
 L4  OS自身       — WP自己試験・FPU検出列・rdmsr_safe の #GP fixup が毎回走る
 L5  決定的命令数 — 同じイメージなら起動命令数はビット同一。増加 = 意味の後退の印
 L6  JIT決定性    — JIT on/off で命令数もシリアル出力もビット同一 (毎PR)
 L7  3OS起動回帰  — ELKS・FreeDOS・Linux がプロンプト到達 + スクショ証跡 (毎PR)

特に効いたのはL5の「命令数の決定性」だ。エミュレータは同じイメージを食えば同じ命令数で起動する。この数字をCIで固定すると、最適化の「意味不変」が証明になり、荷物が増えた(TLS同梱で580M→770M)のか意味が後退したのかを区別できる。速度の議論から「気のせい」が消えた。

もうひとつは二重実装の禁止。JITもデコードキャッシュも、フラグ計算や演算は従来経路と同じヘルパを呼ぶ。速い写しはあっても意味論の原本は1つ — だから写しが増えても審判のコストが増えない。

ドキュメントは「地図・物語・罠・判断」に分ける

docsは9,000行あるが、素朴に書き足したのではなくDiátaxisの4棚(理解する/手を動かす/引く/判断の記録)を物理ディレクトリにした。中でも運用して良かった分け方は、性能情報の3分冊である。

  • 地図 (perf.md): 現在地の数字と全量カタログだけ
  • 物語 (perf-log.md): 日付つきの実験と謎解き。追記専用
  • 罠の型 (pitfalls.md): 横断の教訓14型。「一括清算は原本の支払い順序と同型にする」「単発の速さは熱の運 — 交互A/Bだけを信じる」

ADRは28本。「PCIバスは作らない」と書いた6日後に「作った」と上書きする羽目になったが、判断を上書きした事実ごと残るのがADRの価値で、恥ではない。

なぜここで終えるのか

READMEの最初のバージョンからこう書いてあった。

16bitと32bitの地層を登りきることを完成と定義し、終わりを持たせる。

当初の完成ラインは「Linuxがブラウザで起動する」だった。着いてみたら先が見えて、ネットワーク、JIT、グラフィックス、DOOMまで登った。ロードマップにはまだTier 7〜9(ネイティブ化・マイクロVM・x86_64)が残っているが、これは未完ではなく、最初から別の山として定義してあったものだ。x86_64をやるならこのリポジトリをforkして別の名前を付ける — そう決めてあったから、終わりを宣言できる。

残った台帳(ATA HDDのprimary、VBE、Xへの入力、OPL2の音色精度、間欠panicの正体)は、棄却ではなく再開の判断材料ごと凍結した。効かなかった最適化を理由つきで寝かせたのと同じ流儀で、プロジェクトそのものを寝かせる。

教訓のまとめ

  1. 審判を先に立てる。 オラクル(Unicorn・実機期待値・決定的命令数)があれば、38,000行を18日で書いても足元は崩れない
  2. 動くものを常に置く。 「CPUは完成したが動かすものがない」を作らない。ELKSのテトリスは時計の受け入れテストであり、DOOMはCPU・BIOS・VGA・音源の総合試験だった
  3. 測ったものだけ信じる。 単発の速さは熱の運。交互A/Bと決定的命令数だけが勝敗を裁く
  4. 「動いた」と「採る」は別。 fastmemもPGOもCraneliftも、動いた上で捨てた。捨てる判断ができるのは、復帰条件を書いて寝かせる台帳があるからだ
  5. 終わりの定義を最初に書く。 完成ラインとやらないことをREADMEの初版に書いたから、スコープが膨らんでも「完結」と言える日が来た

リポジトリは公開してある: github.com/yoshiharu-ishii/rustx86。開発記の連載(ネットワーク編・JIT編・ディスク編・グラフィックス編)も本ブログにある。

コメント

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