プログラミング
Bunのコンパイル時間を理解するためにビルドビジュアライザーを作成しました
I made a build visualizer to understand Bun's compile times (lalitm.com)
要約
著者は、ソフトウェアのコンパイル時間を可視化するオープンソースツール「buildprof」を作成しました。このツールは、Bun JavaScriptランタイムがZigからRustに移行した際のコンパイル時間の大幅な短縮の理由を調査するために使用されました。分析の結果、ビルド時間の大部分はリンク時最適化(LTO)、特にフルLTOによるものだと判明しました。
全文翻訳
私はbuildprof(Github)を構築しました。これはLinuxでソフトウェアをコンパイルする際に時間がどこに費やされているかを示す、オープンソースのトレーシングツールです。以下は、ripgrepのクリーンビルドをプロファイリングするリアルタイムビデオです。
buildprofデモを見る
ビルドが遅いのは、単にコンパイルするコードが大量にあるためである場合があります。しかし、多くの場合、修正可能な問題があります。並列処理の不足、重複作業、依存関係のダウンロード、あるいは巨大なコンパイラ/リンカの呼び出しなどです。buildprofはこれらすべてを明確に可視化するため、調査や最適化に値するものがわかります。
使い方は、普段使用しているビルドコマンドの前にbuildprof --を付けるだけです。
buildprof -- make -j16
buildprof -- cargo build
buildprof -- ninja -C out/target
buildprof -- just build
buildprof -- ./dev/custom-build-script.sh
buildprofは、ビルドコマンドが起動するすべてのプロセス、それらのサブプロセス(さらにそのサブプロセス…)を記録し、1つのタイムライン上に配置します。時間は左から右へ進み、バーの幅は期間を示し、子プロセスはそれを起動したものの下に表示されます。
私はこのツールを、Bun JavaScriptランタイムのチーフアーキテクトであるJarred Sumner氏のこのツイートが頭から離れなかったために作りました。
特に、Bunの新しいRustビルドがLinuxで古いZigビルドよりも5倍以上高速だったという主張は、私を悩ませました。私の経験では、Zigプロジェクトは通常、同等の複雑さのRustプロジェクトよりもはるかに高速にコンパイルされていました。その直感だけで、解決すべき謎があると感じるには十分でした。
さらに、ツイートにあるもう一つの重要だが簡単に見落とされる詳細によって、これは増幅されました。ZigビルドはフルLTOを使用していたのに対し、RustビルドはシンLTOを使用していました。
コンパイラは通常、個別のコンパイルユニットをほぼ独立して最適化します。1リンク時最適化(LTO)により、それらの境界を越えて最適化できます。フルLTOはそれらのユニットを1つの大きな最適化ジョブにまとめますが、シンLTOはより多くの分離を維持するため、作業の多くを並列で実行できます。
過去の経験から、この違いはビルド時間に計り知れない影響を与える可能性があります。ツイートでは通りすがりに言及されていましたが、ヘッドラインの改善のどれだけをそれが説明しているのか疑問に思いました。
私はまず、数値を再現しようとしました。
数値は再現されました。しかし、今はどうすれば?
私はBun 1.3.14とBun 1.4.0をチェックアウトし、それらのLinux x64 CIビルドを6コア、12スレッドのLinux VMで再現するためのスクリプトを作成しました。スクリプトはビルドステップとその依存関係を維持し、すべてを1台のマシンで実行しました。2私のタイミングはJarredのものと同じような範囲でした。
Linux x64ビルド
Zig時代
Rust時代
Bunの報告されたCI中央値
30m06s
5m37s
私のシングルマシンCIプロファイル再現
24m24s
5m40s
OK、私のマシンでもギャップが現れました。しかし、2つの測定の間には言語以外にも多くの変化がありました。実際には何が原因だったのでしょうか?すべての追加時間を費やしていたのはZigコンパイラだったのでしょうか?それともフルLTOリンクだったのでしょうか?あるいは、私がまだ考えていなかったBunのビルドに何か他のものがあったのでしょうか?
ここで私のプロファイリングと開発者ツールの知識が役立ちました。通常、何かが遅い理由を理解しようとするとき、私はトレースを欲します。何が起こったのか、いつ起こったのか、そしてどれくらいの時間がかかったのか。これらをタイムラインに配置し、実際にはどこに時間が費やされたのかを見ることができれば、非常にクールでしょう。
しかし、ビルドには多くの異なるツールが関わっており、それぞれが何が起こっているかについての独自の考えを持っています。それらすべてを横断して見ることができるように、何を記録できるでしょうか?
ビルドはプロセスツリーです
cargo buildやzig buildと入力すると、1つのプログラムを実行しているように感じます。ビルドシステムは、再構築する必要があるもの、それらのピース間の順序、および並列で実行できるものを計算します。しかし、一般的に、それはそれ自体のすべての作業を実行するわけではありません。コンパイラ、コードジェネレータ、アーカイバ、リンカ、および任意のスクリプトを起動します。それはさらにプログラムを起動し、さらにプログラムを起動します…
異なるビルドシステムは、その作業を異なる方法で記述します。Cargoはクレートを見、Ninjaはビルドエッジを見、CMakeは別のビルドシステムのための指示を生成します。しかし、オペレーティングシステムの観点からは、それらは(ほとんどの場合)プロセスが他のプロセスを起動するように見えます。3
例えば、Rustのビルドには次のようなチェーンが含まれる場合があります。
cargo
└── rustc
└── cc
└── collect2
└── ld.lld
各サブプロセスがいつ開始し、終了したかを記録すれば、それらをタイムライン上に配置できます。buildprofでのそのチェーンの外観は次のとおりです。
このレイヤーでビルドを視覚化することには、いくつかの良い特性があります。
ビルドシステムに依存しません。Cargo、Ninja、Zig、Make、およびほとんどの他のビルドシステムは、プロセスの起動によって作業の多くを行うため、それぞれに特別な統合を記述する必要はありません。
カスタムスクリプトを自然に含めます。これには、ビルドシステムの上にあるスクリプト(リポジトリセットアップ、依存関係のフェッチ)と、その下にあるスクリプト(コードジェネレータ、アセットプロセッサ)の両方が含まれます。
ビルドステップ間でファイルを追跡できます。各プロセスが読み書きするファイルを記録することで、どのステップが他のステップの入力を作成したかを確認できます。これはビルドシステム間でも機能します!
これがbuildprofの出発点となりました。プロセスツリーを記録し、それを探索できるタイムラインに変換することです。さらに詳しく説明すべき詳細はたくさんありますが、それは後で行います。しかし、それが機能するようになると、最初の質問に戻ることができました。24分間、Bunは何をしていたのでしょうか?
Bunにそれを向ける
Zig CIビルドはなぜそれほど遅かったのか?
私は以前と同じスクリプトを使用して、buildprofでZig時代のCIビルドを記録することから始めました。
buildprofで探索する
すぐに巨大な問題が見えました。ld.lldリンカの呼び出しがビルド時間を支配していました。それは16分以上、ビルド全体の約3分の2をかけて、最後に単独で実行されました。一体全体、その間ずっと何をしていたのでしょうか?
リンカをクリックすると、buildprofが自動的にキャプチャするコマンドラインが表示されます。
そこには、Jarredが言ったようにフルLTOがあります。リンクにそれほど時間がかかっていたことを考えると、それは今や私の主な容疑者でした。
しかし、プロセスツリーだけでは、LTOが実際にそれらの16分間を担当していたかどうかはわかりませんでした。幸いなことに、LLDは独自の内部タイミングイベントを記録しており、--compiler-tracesを使用するとbuildprofがそれらを含めることができます。
--compiler-tracesを有効にして、最終リンクを再度記録しました。
buildprofで探索する
今、LTOがほとんどすべての時間を使っていることがわかります。リンカは、単にコンパイル済みのファイルを結合するだけでなく、プログラム全体に対してコンパイラパスを実行しています。OptModuleバーだけで10分以上かかり、マシンコードを生成するパスが含まれています。4
Rust CIビルドはどのように異なっていたか?
Zigビルドの大部分がLTOに費やされていたため、Rustビルドがリンクにどれだけの時間を費やしたかを確認したかったのです。そのビルドも記録しました。
buildprofで探索する
わずか2m24sでした。そして今回、予想通り、リンカコマンドには-plugin-opt=thinlto:が含まれていました。
両方のビルドがLTOを実行していましたが、設定が異なり、リンク時間が大きく異なりました。BunのZigコードを維持し、フルLTOをシンLTOに変更したらどうなるでしょうか?そのギャップのどれだけを埋めることができるでしょうか?
シンLTOを試す
Zig BunのビルドフラグをシンLTOに変更し、比較のために別のクリーンビルドと、新しいフルLTOビルドを記録しました。
buildprofで探索する: フルLTO · 部分的なシンLTO
この録音のペアでは、リンクは3m40s速くなりましたが、それでも約13分かかっていました。なぜリンクはまだそれほど高価だったのでしょうか?
コンパイラトレースに戻ると、作業の多くはJSCという名前の関数にありました。それはJavaScriptCore、BunがJavaScriptを実行するために使用するエンジンです。リンカはJavaScriptエンジンのコンパイルにも時間を費やしていました。5
リンカ呼び出しをクリックすると、入力の中にWebKitライブラリ、libJavaScriptCore.aを含むものが見つかりました。
それらの入力をビルドで追跡していくと、Bunがこれらのライブラリを自分でコンパイルしていないことがわかりました。それは別のWebKitビルドからダウンロードしていました。そして、そのビルドのフラグを確認すると、そこにも-flto=fullがありました。Rustビルドは、ビルドレシピがシンLTOを選択した新しいWebKitリビジョンを使用していました。
Bun自身のコードをどのようにコンパイルするかを変更しましたが、それらのダウンロードされたライブラリにはまだフルLTOの入力が含まれており、リンカはまだ