9月12日、Lalit Maganti氏が「I made a build visualizer to understand Bun's compile times」と題した記事を公開した。JavaScriptランタイムBunのZig製ビルドがRust製ビルドより5倍以上遅い理由を、自作のビルド可視化ツール「buildprof」で掘り下げた調査だ。犯人は「Full LTO」と「並列化できない単一モジュール構成」の組み合わせだった。
発端:Bunのアーキテクトが公開した衝撃の数字
Bunはv1.xで実装言語をZigからRustへと段階的に移行しており、パフォーマンスと開発者体験の両面での改善を進めている。そのBunのチーフアーキテクト、Jarred Sumner氏がX(旧Twitter)に投稿した数字は以下の通りだ。
- Bun 1.3.14(Zig製)のLinuxビルド:中央値 30分06秒
- Bun 1.4.0(Rust製)のLinuxビルド:中央値 5分37秒
5倍以上の開きがある。しかしMaganti氏はこの数字に直感的な違和感を覚えた。一般的にZigはRustよりコンパイルが速いとされているからだ。投稿には重要な注釈もあった。ZigビルドはFull LTO、RustビルドはThinLTOを使っているという点だ。
LTO(Link-Time Optimization)とは、コンパイル単位をまたいで最適化を行う仕組みのこと。Full LTOはすべてのコードを一つの巨大な最適化ジョブにまとめるため最大限の最適化が得られる一方、処理が直列になりビルド時間が大幅に伸びやすい。ThinLTOはある程度の分離を保ちながら並列処理できるため、ビルド速度と最適化のバランスが取れている(LLVMのドキュメント参照)。この違いがどこまで数字に影響しているのかを確かめようと、Maganti氏は独自調査を始めた。
ビルド可視化ツール「buildprof」を自作した
調査の前提として、「どこに時間がかかっているか」を可視化する必要があった。既存ツールではビルドシステムをまたいだプロセス全体の可視化が難しかったため、Maganti氏はゼロからbuildprofを開発した。
発想はシンプルだ。cargo buildやzig buildを実行すると、裏では大量のサブプロセスが起動する。コンパイラ、リンカ、スクリプト……それらがまたサブプロセスを生む。OSから見れば、ビルドシステムの違いを問わず「プロセスが子プロセスを生む」構造は共通だ。開始・終了時刻を記録してタイムラインに並べれば、「何がいつ、どのくらい動いていたか」が一目でわかる。
使い方は既存ビルドコマンドの前にbuildprof --を付けるだけで、CargoでもNinjaでもMakeでも動く。
buildprof -- make -j16
buildprof -- cargo build
buildprof -- ninja -C out/target
--compiler-tracesオプションを使えば、LLDなどコンパイラ内部のタイミング情報まで取り込める。
Bunのビルドに向けてみた
Maganti氏は6コア12スレッドのLinux VM上で、Bun 1.3.14(Zig)と1.4.0(Rust)のCI再現ビルドを実施した。
| ビルド | Zig時代 | Rust時代 |
|---|---|---|
| BunのCI中央値(公式) | 30m06s | 5m37s |
| 手元での再現 | 24m24s | 5m40s |
差は再現できた。問題は「なぜか」だ。
リンカが16分間走っていた
buildprofでZig時代のビルドを可視化すると、すぐに異常が見えた。ld.lld(LLVMのリンカ)がビルドの最後に単独で16分以上走っていた。全体の約3分の2だ。--compiler-tracesでリンカの内部を掘り下げると、Full LTOのコンパイルパスが大半を占めており、OptModule(機械語生成フェーズ)だけで10分以上かかっていた。
ThinLTOに変えると?
ZigビルドをFull LTOからThinLTOに切り替えると、リンク時間は3分40秒短縮された。しかしまだ約13分かかっていた。
理由はWebKitだった。BunはJSエンジンとしてJavaScriptCore(WebKitに含まれるJSエンジン)を使っており、そのライブラリのビルド済みバイナリをダウンロードして利用している。ところがそのWebKitライブラリ自体がFull LTOフラグでビルドされていたため、リンカはWebKit側のLTO処理もこなさなければならなかった。Rustビルドでは、より新しいWebKitリビジョンがThinLTOで構成されていた。
WebKitを含めてThinLTO設定で再ビルドした結果がこうだ。
| Zig時代のビルド | 全体 | リンカ単体 |
|---|---|---|
| 元のFull LTO | 24m24s | 16m35s |
| BunのみThinLTO(旧WebKit) | 20m20s | 12m55s |
| BunもWebKitもThinLTO | 15m11s | 7m22s |
リンカは7分22秒まで縮まった。しかし全体ではまだ15分かかる。
本質的な差:Zigは「1つの巨大モジュール」だった
残りの約8分は、リンカが起動する前の時間だ。ここでbuildprofがもう一つの決定的な違いを浮かび上がらせた。
Rustビルドでは90以上のcrateに分割されており、rustcが並列に走る。一方、ZigビルドはBunのコード全体を単一のZigモジュールとしてコンパイルしていた。Rustのcrateのような明示的なモジュール分割が行われていないため、並列化の余地がゼロだ。さらに、単一の巨大なThinLTOビットコードモジュールをリンカが処理するため、リンク段階でも遅くなるとMaganti氏は推測している。
buildprofなしでは、このどちらの問題も「なんとなく遅い」という印象のまま終わっていた可能性が高い。
まとめ
Zigビルドが遅かった主な要因は2つだ。
- Full LTOによるリンク時間:ZigもWebKitもFull LTOで構成されており、リンカが16分以上かかっていた
- Zigコードが単一モジュール:Rustのcrateのような分割がなく、コンパイルが並列化できない
「ZigはRustより遅い」という話ではなく、LTOの設定とモジュール分割の選択が積み重なった結果だ。buildprofはLinux上のあらゆるビルドシステムに対してprefixを追加するだけで動き、元記事内でソースコードも公開されている。
詳細はI made a build visualizer to understand Bun's compile timesを参照していただきたい。