自作の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; hltやjmp $で終わるのには理由がある - 疑うときはまず切り分け。 「今日の変更が原因か」はmainで再現させれば5分で白黒つく。犯人探しは時系列の絞り込みから
なお、このベンチのブラウザUI自体は畳むことにした。速度の見張りはCIが毎回Linuxブートの実測MIPSを記録する仕組みに集約済みで、タブのスロットリングに汚染されるブラウザ手動計測より、同条件で回り続けるCIの方が定点観測に向いている。役目を終えた道具は、感謝して片付ける。


コメント