自作x86エミュレータ (Rust) のベンチマークが嘘をついていた — 固定3.7億命令のはずが200億命令走っていた話

Rust

自作のx86エミュレータには、開発初期から実行速度ベンチマークがある。命令数が固定のワークロードを流し、環境が変わってもMIPSを比較できるようにした自慢の仕組みだった。

そのベンチが、ずっと別のものを測っていたことが今日わかった。UIの配色を直すついでに何気なく回した結果がこれである。

回  命令数              実行時間   MIPS
1   20,000,000,000     103.31秒   193.6

200億命令。固定ワークロードは約3.7億命令のはずだ。54倍走っている。

ワークロードの設計 (だったもの)

ベンチ用ワークロード bench.asm はリアルモードの16bitコードで、設計方針をコメントにこう書いてある。

; - **HLTで必ず止まる**。上限命令数で打ち切られると測定値がぶれるため、
;   ワークロード側が終端を持つ
; - 命令数は固定 (OUTER × INNER × 本体長)。実行時間ではなく
;   命令数が定数なので、環境が変わっても MIPS の比較ができる

    mov  bp, OUTER          ; 512回
.outer:
    mov  cx, INNER          ; 65535回
.inner:
    ; ALU・メモリ・シフト・スタック・比較・分岐を混ぜた本体10命令
    ...
    loop .inner
    dec  bp
    jnz  .outer
    hlt                     ; ← ここで必ず止まる約束

512 × 65535 × 本体 ≒ 3.7億命令で hlt。実行側は「HLTするか上限まで」走らせ、上限は保険として大きめの200億に設定してある。設計どおりなら保険を使うことはない。

その保険が、毎回、満額使われていた。

謎解き: HLTの向こう側

なぜHLTで止まらないのか。ヒントは、このエミュレータの成長過程にあった。

ベンチを作ったのはCPU単体の時代である。その後、OSを起動するために装置が生えた。8259 PIC (割り込みコントローラ)、そして 8254 PIT (タイマ) — BIOS相当の初期化がPITのチャンネル0を回し、18.2Hzで割り込みを上げ続ける。

すると何が起きるか。

1. ワークロードが hlt を実行 → CPUは割り込み待ちで停止
2. 約55ms後、PITがIRQ0を上げる
3. CPUが目を覚まし、タイマ割り込みハンドラへ飛ぶ
4. ハンドラがIRETで復帰する。復帰先は……hltの「次の番地」

実機のx86の仕様どおりである。HLTは「割り込みが来るまで停止」であって「永久停止」ではない。割り込みから復帰したCPUは、hltの次から実行を続ける。

hltの次には何があるか。データ領域と、ブートセクタの残りを埋めるゼロである。

    hlt
scratch: dw 0          ; ← ここから「実行」される
times 510-($-$$) db 0  ; ゼロの海 (00 00 = add [bx+si], al)

ゼロは add [bx+si], al としてデコードできてしまう。CPUは律儀にゼロの海を実行し続け、IPは64KBセグメントを一周して0x7C00に戻り、ワークロードを最初からやり直す。2周目もhltで寝て、タイマに起こされ、また周回する。上限の200億命令まで。

つまりベンチは「固定3.7億命令のワークロード」ではなく、「3.7億命令+ゼロの海+周回、を上限まで詰めた別のなにか」を測っていた。歴代の測定値は、比較可能どころか対象すら定義できていなかったことになる。

いつから嘘だったのか

「今日の変更 (CPU最適化) で壊したのでは」をまず疑った。切り分けは簡単で、mainブランチでも動かしてみればいい。

main:      20,000,000,000 命令 / 71.38秒
最適化後:  20,000,000,000 命令 / 55.51秒

mainでも200億。今日の回帰ではない。PITが挙手するようになった日から、つまり装置を実装したTier 2の途中から、この嘘は始まっていた。

皮肉なのは、READMEに残っている当時の測定記録である。

369095176 命令 / 4.10秒 = 90.1 MIPS   ← Tier 2完了時点の「基準線」

この時点では正しく3.7億で止まっていた (実行ループの終了条件が今と違った)。その後のどこかの変更で保険側に倒れ、誰も気づかなかった。ベンチは「回すと数字が出る」ので、数字が出続ける限り壊れて見えないのだ。

修理: 眠り続けるための2つの手当て

ワークロード側で「HLTで必ず止まる」の約束を守り直す。手当ては2つ。

    ; 制御語だけ書いてカウントを積まなければ 8254 は止まる
    mov  al, 0x30               ; ch0, lo/hi, mode 0 — カウント再装填まで停止
    out  0x43, al
.halt:
    hlt
    jmp  .halt                  ; 起こされても寝直す

1つめはタイマを止める。8254は制御語を書いた時点でそのチャンネルのカウントを停止し、新しいカウント値が装填されるまで動かない。起こしに来る者を消す。

2つめは起こされても寝直すhlt; jmp .halt のループなら、万一何かの割り込みが残っていてもゼロの海には流れ出ない。OSのアイドルループと同じ、定石の形である。

修理後の結果。

ネイティブ:  369,095,178 命令で停止
ブラウザ:    369,095,178 命令で停止   ← 1命令まで一致 (決定性の確認)

3.7億で止まり、しかもネイティブとブラウザ (WASM) で命令数が完全一致する。エミュレータ本体の決定性は無事だった。壊れていたのは測定対象の定義の方である。

ちなみに修理後の正直な数字は16bit経路で45.6 MIPSだった。昔の90 MIPSから半減しているが、これは劣化の放置ではなく32bit対応の税金である (ディスパッチが太り、デコードキャッシュは32bit専用)。正直な数字が出て初めて、こういう議論ができる。

教訓

  • 「命令数が決定的だから比較できる」は、終端が守られていて初めて成り立つ。 決定性は一度作れば終わりの仕組みではなく、守り続ける約束だった
  • ベンチは黙って壊れる。 テストは失敗すれば赤くなるが、ベンチは壊れても数字を出し続ける。「3.7億のはずが200億」のような自明な矛盾を表示に含める (今回なら命令数の表示があったから気づけた) のが防波堤になる
  • HLTは止まらない。 割り込みが生きている世界では、HLTの次の番地に何を置くかまでが設計である。実機のブートセクタが cli; hltjmp $ で終わるのには理由がある
  • 疑うときはまず切り分け。 「今日の変更が原因か」はmainで再現させれば5分で白黒つく。犯人探しは時系列の絞り込みから

なお、このベンチのブラウザUI自体は畳むことにした。速度の見張りはCIが毎回Linuxブートの実測MIPSを記録する仕組みに集約済みで、タブのスロットリングに汚染されるブラウザ手動計測より、同条件で回り続けるCIの方が定点観測に向いている。役目を終えた道具は、感謝して片付ける。

コメント

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