LLVMのPGOでRust製x86エミュレータが25%速くなった。それでも採用を見送った理由

Rust

自作x86エミュレータ rustx86 で、LLVMのPGO (Profile-Guided Optimization) を試したらネイティブ実行が約25%速くなった。交互A/B 5周全勝、意味論の後退なし。

そして、採用を見送った

「効いた最適化を捨てる」判断は珍しい型だと思うので、実測から議論、見送りの決め手、そして「どう捨てたか」までを記録しておく。

PGOとは — コンパイラに実測を渡してから焼き直す

PGOはソースを1行も変えない最適化である。3段で回る:

  1. 計測ビルド: 分岐カウンタを埋め込んだバイナリを作る (-Cprofile-generate)
  2. 訓練: それで本番ワークロード (ここではLinuxブート) を1回走らせる。「このifは99%こちらに倒れる」「この関数はここから1億回呼ばれる」という実測の帳簿が残る
  3. 焼き直し: 帳簿を渡して再コンパイル (-Cprofile-use)。LLVMは普段この情報を持たずヒューリスティックで分岐確率を推測しているが、それが実測に置き換わる

PGOの3段

# tools/build-pgo.sh の骨子
RUSTFLAGS="-Cprofile-generate=$PROF" cargo build --release --example bootphase
./bootphase && ./bootphase images/vmlinux-lts      # 訓練 (Linuxブート2種)
llvm-profdata merge -o merged.profdata "$PROF"
RUSTFLAGS="-Cprofile-use=merged.profdata" cargo build --release

実測: 交互A/B 5周全勝で約-25%

Apple M1 (熱あり) 通常ビルド PGO
Linux起動 (vmlinux 5.8億命令) 9.6〜10.0s 7.2〜7.8s
フルブート (bzImage 9.7億命令) 13.0s 11.0s

ゲストの実行命令数はビット不変 (580M/970M) — 意味は1bitも変わっていない。速くなったのはホスト機械語の並びだけである。

なぜこんなに効くのか。エミュレータのインタプリタは「巨大なディスパッチmatch+ヘルパ呼び出しの束」で、性能がコード配置と分岐予測の当たり外れに強く依存する。CPythonの公式バイナリがPGOで10〜30%取るのと同じ構図だ。ちなみにVirtualBoxやVMwareがPGOの話をしないのは、彼らがVT-xでゲスト命令を実CPUに直接走らせるハイパーバイザで、削る配管が最初から無いからである。エミュレータの同類はQEMU TCGやCPython側で、そこではPGOは普通の道具だ。

議論: 「汎用性は犠牲になるか」

見送りの前に、まず擁護側の議論を尽くした。

Q. 訓練データ (Linuxブート) 以外のワークロード — CDからのOSインストールや別のカーネル — に耐えられるか?

耐える。PGOは意味論に触れないので、「壊れる」方向のリスクはゼロ。最悪ケースは常に速度だけで、訓練に無いワークロードでは効き幅が薄まる (通常ビルド並みに近づく) だけだ。しかもプロファイルが教えているのは「ゲストが何をしたか」ではなく「インタプリタのどの配管が熱いか」で、配管の熱さはOSが変わってもあまり変わらない。

Q. 対応OSの幅 (v86が並べているようなリスト) を狭めないか?

狭めない。あのリストに対応できるかを決めるのは装置エミュレーションとCPU意味論の実装 (ソースコード) であって、ビルド技法は対応リストを増やしも減らしもしない。

ここまでは満点である。では何が問題だったのか。

見送りの決め手: 「遅い→再訓練か?」という判断が半永久に付いて回る

PGOの固有の悪さは、劣化が無音なことだ。

コードを大きく変えれば、古いプロファイルは静かに実態とずれていく。スナップショットのような成果物なら、古くなれば音を立てて壊れる (間違ったカーネルが起動する) から検知できる。PGOは違う。黙って効きが薄れ、「遅いのはプロファイルが古いからでは? 再訓練すべきでは?」という運用判断だけが残る。

この判断は一度導入すると消えない。ワークロードが増えるたび、コードが変わるたびに発生する。さらに、このプロジェクトの測定文化 (最適化は新旧バイナリの交互A/Bで判定する) とも相性が悪い — 公平な比較には毎回再訓練が要り、プロファイルの差分が効果測定のノイズ源になる。台帳の数字の土台が揺れる。

つまり争点は「効くか」ではなかった (効く)。「開発プロセスに常設の運用判断を1個増やしてよいか」であり、答えはNoにした。速さは設計 — 実CPUがdTLBを持つようにuopに変換結果を控える、JITの語彙を広げる、Craneliftでネイティブコードを吐く — つまり一度入れたら終わりの構造改善で取る。

層の整理: プロファイル系はFirecracker段の道具

ただし「永久に使わない」ではない。この判断には層の整理がある。

「一度動かして、その結果を最適化に使い回す」系の技法は、それが製品の動作モデルそのものになる層に置く。

このプロジェクトの長期目標にはFirecracker的なmicroVM運用がある。Firecrackerの本体機能 (スナップショット復元・プリウォーム) はまさに「一度動かして結果を使い回す」クラスで、あの層では訓練済み・温め済みは隠れた依存ではなく仕様だ。microVMのイメージ焼きパイプラインにPGOを組み込むなら、訓練セットは「そのmicroVMが実際に走らせるもの」であり、ワークロード依存は欠点ではなく狙いになる。

逆に汎用エミュレータ層に置くと、任意のアプリに対して「なぜかLinuxブートだけ速い」という暗黙のワークロード依存が生まれる。これがアプリ層への悪さの正体である。

どう捨てたか — 「いきなり取り下げない」

もう1つ、今回学んだのは捨て方だ。

効いた実験が黙って消えると、後から見た人 (プロジェクトに詳しくない人、そして未来の自分) が判断を信頼できなくなる。だから:

  • 実験一式 (スクリプト+実測) はgitタグ exp/pgo-build に保存 — 消していない
  • 賛成論・反対論・決め手・復帰条件をADR (Architecture Decision Record) に明文化
  • 復帰条件は2つ: (1) Firecracker段でイメージ焼きの道具として (2) 設計側の最適化を出し切っても目標に届かないときの最後の-25%として。後者の場合は「訓練セット=起動回帰スイートと同一に固定」というルールとセットで入れ、運用判断の発生自体を殺す

「計画で選択肢を棄却しない。外すのは実測後、それも台帳に寝かせる」— 効いた場合も同じで、寝かせた理由と起こす条件さえ書いてあれば、見送りは損失ではなく整理になる。

まとめ

  • LLVMのPGOはインタプリタ/エミュレータに劇的に効く (実測-25%)。CPython・ブラウザ・QEMU類では定番
  • それでも「無音で劣化し、運用判断を半永久に持ち込む」性質は、開発の測定文化と衝突し得る
  • 最適化の評価軸に「効き幅」だけでなく「一度入れたら終わりか、判断が付いて回るか」を加えると、こういう判断が言語化できる
  • 捨てるときは議論と復帰条件をADRに残す。黙って取り下げない

リポジトリ

github.com/yoshiharu-ishii/rustx86 — 判断の全文は ADR-0009 にある。

コメント

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