- Rustで作ったWebサイトをDockerで繰り返しビルドする際に、ビルド時間の問題に直面
- 標準的なDocker設定では、毎回 依存関係全体の再ビルド が発生し、4分以上かかる
cargo-chef やキャッシュツールを使っても、最終バイナリのビルドには 依然として多くの時間 がかかる
- プロファイリングの結果、LTO(リンク時最適化)とLLVMモジュール最適化 に大半の時間が費やされていることが判明
- 最適化オプション、デバッグ情報、LTO設定を調整することで一部改善できるものの、最終バイナリのコンパイルには最低でも50秒かかることを確認
問題提起と背景
- Rustで作った個人Webサイトを修正するたびに、静的リンクバイナリ をビルドしてサーバーにコピーし、再起動する面倒な作業を繰り返していた
- DockerやKubernetesなどのコンテナベースのデプロイへ移行しようとしたが、Rustの Dockerビルド速度 が大きな問題として浮上
- Docker内では小さなコード変更でも全体を 最初から再ビルド しなければならず、非効率な状況が発生
DockerでのRustビルド – 基本アプローチ
- 一般的なDockerfileのアプローチは、すべての依存関係とソースコードをコピーしてから cargo build を実行する方式
- この場合、キャッシュの利点がなく、フルリビルド が繰り返される
- 筆者のWebサイトでは、フルビルドに 約4分 かかり、依存関係のダウンロードにも追加時間を要した
Dockerビルドキャッシュの改善 – cargo-chef
cargo-chef ツールを使うと、依存関係だけを別レイヤーとして事前に キャッシュ しておける
- これにより、コード変更時に依存関係ビルドを再利用でき、ビルド速度改善 が期待できる
- 実際に適用すると、全体時間のうち依存関係ビルドは25%にすぎず、最終Webサービスバイナリ のビルドに依然としてかなりの時間がかかっていた(2分50秒~3分)
- 主要な依存関係(axum、reqwest、tokio-postgres など)と約7,000行の自作コードで構成されているにもかかわらず、rustc の単一実行に3分を要する構造だった
rustcのビルド時間分析: cargo --timings
cargo --timings を使うことで、各クレート(compilation unit)ごとのビルド時間を確認できる
- 結果として、最終バイナリのビルド が全体時間の大部分を占めることを確認
- より細かな原因分析には役立つが、コンパイラ内部の具体的な挙動までは十分に把握できない
rustc自身のプロファイリング(-Zself-profile)の活用
- rustcの自己プロファイリング機能を -Zself-profile フラグで有効化し、詳細な動作時間を測定
- そのために環境変数経由でプロファイリングを有効化
- 結果を summarize ツールで分析すると、LLVM LTO(リンク時最適化) と LLVMモジュールのコード生成 が全体時間の60%以上を占めることが分かった
- flamegraphによる可視化でも、codegen_module_perform_lto 段階で全体時間の80%が消費されていることを確認
LTO(リンク時最適化)とビルド最適化オプション
- Rustビルドは基本的に codegen unit ごとに分割されたあと、LTOによって全体最適化が比較的後段で適用される
- LTOには off、thin、fat など複数のオプションがあり、それぞれ性能と最終成果物に影響する
- 筆者のプロジェクトでは
Cargo.toml で LTO を thin、デバッグシンボルも full に設定していた
- さまざまなLTO/デバッグシンボルの組み合わせをテストした結果:
- fullデバッグシンボルはビルド時間を増加させ、fat LTO はビルドを約4倍遅くすることを確認
- LTOとデバッグシンボルを削除しても、最低50秒 のビルド時間が必要だった
追加最適化と所感
- 50秒程度であれば、実際のサービス負荷がほとんどない自身のサイトでは大きな問題ではないが、技術的な好奇心からさらに分析を試みた
- インクリメンタルコンパイル をDockerでうまく活用すればさらに高速化できるが、ビルド環境のクリーンさとDockerキャッシュの両立が必要
LLVM段階の詳細プロファイリング
- LTOとデバッグシンボルを外しても、LLVM_module_optimize 段階でなお70%近い時間を消費
- releaseプロファイルでは opt-level のデフォルト値(3)による最適化コストが大きいと認識し、バイナリに対してのみ opt-level を下げる方法をテスト
- 各種最適化の組み合わせを実験した結果、最適化なし(opt-level=0)では15秒前後、最適化あり(1~3)では50秒前後かかった
LLVMトレースイベントの詳細分析
- rustc の追加フラグ(
-Z time-llvm-passes, -Z llvm-time-trace)を使うことで、LLVM段階ごとの実行時間を詳しく追跡できる
-Z time-llvm-passes は出力が膨大で、Dockerのログ制限を超えることが多く、ログ設定の調整が必要
- ログをファイルに保存して分析すれば、各 LLVM最適化パス ごとの実行時間を個別に確認できる
-Z llvm-time-trace オプションは、chrome tracing形式の巨大なJSON出力を生成し、ファイルサイズが非常に大きいため、一般的なテキスト編集・分析ツールでは扱いにくい
- これを改行単位で分割処理(jsonl)すれば、CLI/スクリプト環境で分析可能
主な知見と結論
- Rustで複雑なプロジェクトをDockerでビルドする際、ビルド速度のボトルネックは主に最終バイナリ のビルドと、それに伴うLLVM最適化段階にある
- LTO、デバッグシンボル、opt-level を調整する際には、ビルド時間とバイナリサイズの間に明確なトレードオフがある
- 最適化オプションを積極的に調整すれば ビルド時間を大幅に短縮 できる一方、最適化を使わないと性能低下の可能性がある
- 大規模なクレート依存関係や商用環境でビルド効率が重要であれば、プロファイリングを積極的に活用して 詳細なボトルネックを具体的に把握 するのが有効な戦略
- Rustのビルドパイプラインを設計する際には、LTO、opt-level、デバッグシンボル、キャッシュ戦略について 精緻な組み合わせ設計 が必要
1件のコメント
Hacker Newsの意見
Rustのプロジェクトは、見た目には小さく見えることが多くて興味深い。第一に、依存関係はコードベースの実際の大きさとは結び付かない。C++では大規模プロジェクトの依存関係をベンダリングしたり、そもそも使わなかったりすることが多いので、40万行のコードのうち重い部分が多ければ「コードが多いのだから遅くても仕方ない」と考えられる。第二に、はるかに問題なのがマクロ。10行、100行単位で繰り返し展開されるマクロは、1万行のプロジェクトでもあっという間に100万行にしてしまう。第三はジェネリクス。ジェネリクスのインスタンス化ごとにCPUリソースを消費する。それでも少し擁護するなら、こうした機能のおかげで、Cで10万行、C++で2万5千行だったものが、Rustでは数千行まで減る利点がある。ただし、こうした機能が過剰に使われることで、エコシステム全体が遅く見えるのも事実。たとえばうちの会社では async-graphql を使っているが、ライブラリ自体は優れている一方で、プロシージャルマクロへの依存が非常に強い。性能関連の問題が何年も open のままで、データ型を追加するたびにコンパイラが確実に遅くなるのを実感する
Ryan Fleuryは Epic RAD Debugger をCで27万8千行のユニティビルド方式(全コードを1つのファイルにまとめた単一コンパイル単位)で作り、Windowsでのクリーンコンパイルがわずか1.5秒しかかからない。この事例だけでも、コンパイルは非常に速くできることを示しているが、RustやSwiftで同じようにできないのはなぜなのか気になる
foo(char*)にfoo(int)を渡すようなおかしな組み合わせすらチェックしないGoが最適化よりコンパイル速度を優先したのは本当に良かったと思う。サーバー、ネットワーキング、glue code の作業では、コンパイルが非常に速いことが何より重要だ。型安全性もある程度は欲しいが、ゆるくプロトタイピングする邪魔にならない範囲でよい。GCがあるのも便利だ。Googleは大規模開発の経験を経て、単純な型、GC、そして非常に速いコンパイルが、実行速度や意味論的な完全性よりはるかに重要だという結論に至ったのだと思う。Goで作られた大規模なネットワーク・インフラ系ソフトウェアの事例を見ても、この選択は完全に当たっている。もちろんGCを許容できない環境や、完全な正確性がより重要な場面ではGoを使わないかもしれないが、私の作業環境ではGoの選択が最適だ
単一のスタティックバイナリをインストールするほうが、コンテナ管理より単純だという主張は理解できない
私のノートPC(Mac M4 Pro)では、Deno(大規模Rustプロジェクト)のフルコンパイルに2分かかる。コマンドベースで見ると、debug は約1分54秒、release は約8分17秒だった。インクリメンタルコンパイルなしで測定した数字だ。実際にはデプロイビルドはCI/CDシステム上で回るので、自分で待つ必要はない
Craneliftの話はどこに出てくるのだろう? 私はRustでゲーム開発をしていて、コンパイル時間が長すぎてほとんど諦めかけたことがある。調べてみると、LLVMは最適化レベルに関係なく遅い。Jai言語の開発者たちがいつも指摘していたことだ。Craneliftにしたらビルド時間が16秒から4秒に短縮された経験もある。Craneliftチームには感心した
subsecondというツールを使ったが、名前どおりシステムのホットリロードを1秒未満で可能にしてくれて、UIプロトタイピングに大いに役立った。 https://github.com/TheBevyFlock/bevy_simple_subsecond_systemRustが遅いとは思わない。同格の言語と比べれば十分速いし、15分もかかっていたC++/Scalaのコンパイルに比べればずっと速い
以前C++開発をしていた立場からすると、Rustのビルドが遅いという主張はよく分からない
インクリメンタルコンパイルは本当に強力だ。初回ビルド後にインクリメンタルキャッシュのスナップショットを固めておけば、変更がない場合はそのまま高速にビルド/デプロイへ活用できる。dockerとの相性も良い。コンパイラのバージョン変更や大きなウェブサイト更新でもない限り、イメージビルドのレイヤーを触らない。コード変更だけのときは、そのレイヤーが再ビルドされないように設定すれば効率的
私のホームページのビルド時間は73msだ。static site generator は17msで再コンパイルし、実際の generator 実行は56msしかかからない。zigのビルドログ出力を添付する
cargo watchでのインクリメンタルビルドなら約1.25秒だ。subsecond[0] のような incremental linking や hotpatch まで使えばさらに速くなる。Zigほどではないが、かなり近い。もし上で言われていた331msが clean(キャッシュなし)ビルドなら、私のウェブサイトの clean ビルド12秒よりははるかに速い。[0]: https://news.ycombinator.com/item?id=44369642