Rustコンパイラはなぜこんなに遅いのか?
(sharnoff.io)- Dockerデプロイ用のRustウェブサイトのビルドでは、依存関係をキャッシュしても最終クレートだけで 約175秒 かかり、ボトルネックは
rustc内部とLLVM最適化段階に絞り込まれた cargo-chef、cargo --timings、-Zself-profile、measuremeを順に適用した結果、単純な依存関係の問題ではなく、LTOとLLVMコード生成 のコストがビルド時間を支配していた- 古い
Cargo.toml設定のlto = "thin"とdebug = "full"が大きく影響しており、これらを無効化すると最終バイナリのビルドは 172.2秒から約50秒 にまで短縮された - LLVMトレースでは
OptFunction、InlinerPass、core::ptr::drop_in_place、大きな async関数、ジェネリクスの単相化が主要コストとして現れ、インライン化の抑制・関数分割・Pin<Box<dyn Future>>・ジェネリクス削減が追加の改善につながった - 最後に
-Zshare-genericsとDebianベースへのビルド切り替えを適用すると、コンパイル時間は 29.1秒から9.1秒 まで低下し、コード構造だけでなくallocatorやmuslターゲットの有無もビルド時間に大きく影響することが分かった
Dockerビルドで明らかになったボトルネック
- ウェブサイトは主に単一の Rustバイナリ として提供され、以前は静的リンクされたバイナリをビルドしてサーバーへコピーし、その後サービスを再起動していた
- コンテナベースのデプロイへ移行しようとすると、Dockerで高速なRustビルドを構成するのは予想以上に難しかった
- 基本のDockerfileは、ソースが変わるたびにすべてを再ビルドしてしまう
rust:1.87-alpine3.22をbuilderとして使い、x86_64-unknown-linux-muslターゲットでビルド- 最終イメージはAlpineにバイナリだけをコピー
- この方法でのクリーンビルドは、cratesのダウンロード10秒を含めて 3分51秒 かかった
cargo-chefで依存関係キャッシュを分離しても不十分だった
- cargo-chef は、ワークスペースから簡略化された recipe ファイルを作り、それを基に依存関係を別のDockerキャッシュレイヤーで事前ビルドする
- ウェブサイトは数百の依存関係を使っているため、キャッシュ効果は大きいと期待された
- 実測では依存関係のビルドが 1分7秒、キャッシュ済み依存関係を使った最終バイナリのビルドが 2分50秒 だった
- 全体時間の約25%しか依存関係に使われておらず、残りの大半は
web-http-server最終クレートの単一rustc呼び出しで消費されていた
cargo --timings と rustc self-profile
cargo build --release --timingsはクレートごとのコンパイル時間を表示し、最終クレートの時間は 174.1秒 で、cargo build出力の2分54秒とおおむね一致していた- ボトルネックが最終クレート1つに集中していたため、
cargo --timingsだけでは詳細な原因の把握が難しかった rustcのself-profile機能を使うために-Zself-profileを使用した- stableコンパイラで不安定な
-Zフラグを使うためRUSTC_BOOTSTRAP=1を使用 cargo-chefのキャッシュ無効化を避けるため、cargo rustc -- -Z self-profileではなくRUSTFLAGS='-Zself-profile'を使用
- stableコンパイラで不安定な
- measureme の
summarize、flamegraph、croxツールでself-profileデータを分析した summarizeの上位項目はLLVM関連作業に集中していたLLVM_lto_optimize: 851.95秒、全体の33.389%LLVM_module_codegen_emit_obj: 674.94秒、26.452%LLVM_thin_lto_import: 317.75秒、12.453%LLVM_module_optimize: 189.00秒、7.407%
- flamegraphでは
codegen_module_perform_ltoが全体時間の 約80% を占めていた
LTOとデバッグシンボル設定の影響
- Rustコンパイラはクレートを codegen unit に分割し、LLVMへ個別モジュールとして渡す
- LTOはリンク時にcodegen unitやクレート間でインライン化と最適化を行うオプションである
- Cargoと
rustcのLTO選択肢は次の通り- LTO無効
"thin"LTO"fat"LTO- 明示しない場合は単一クレート内のみに制限される “thin local LTO”
- 既存の
Cargo.tomlには数年前に設定した値が残っていたlto = "thin"debug = "full"
debug = "full"は、releaseプロファイルでは通常省かれる完全なデバッグシンボルを有効化する- さまざまな
ltoとdebugの組み合わせを測定すると差は大きかった- LTO無効、
debug=none: 50.0秒 / 21.0MiB - Thin local LTO、
debug=full: 88.2秒 / 256.8MiB "thin"LTO、debug=full: 172.2秒 / 197.5MiB"fat"LTO、debug=full: 287.1秒 / 155.9MiB
- LTO無効、
- 完全なデバッグシンボルはコンパイル時間を 30〜50% 増加させ、fat LTOはLTOを完全に無効化した場合より 約4倍 長くかかった
- LTOとデバッグシンボルを無効にしても、最終バイナリ1つのコンパイルにはなお約50秒かかった
増分コンパイルではなくDockerキャッシュを維持した理由
- ローカル開発では
/targetディレクトリをDockerfileの cache mount に接続し、ビルド間で保持すれば増分コンパイルを使える - ただし、
docker buildが毎回クリーンな環境を持てるという性質を維持しつつ、Docker自体のキャッシュシステムを活用するため、cargo-chefを使い続けた
LTO後にも残ったLLVM最適化コスト
- LTOとデバッグシンボルを無効にした後でも、最終バイナリのコンパイルには約 50秒 かかった
- self-profileを再確認すると、時間の約 70% が
LLVM_module_optimizeに使われており、これはLLVMがコードを最適化する段階である - releaseプロファイルのデフォルト
opt-level = 3を下げて、最終バイナリだけを弱く最適化する設定を試した- 依存関係はキャッシュされるため、
profile.release.package."*"ではopt-level = 3を維持 - 最終クレートだけ
opt-levelを下げる
- 依存関係はキャッシュされるため、
- 測定結果は最適化の有無で大きく分かれた
- 最終
opt-level=0: 約15秒 - 最終
opt-level=1: 約48秒 - 最終
opt-level=2または3: 約50〜55秒 - 最終
opt-level="z": 約42秒
- 最終
- 最終バイナリに何らかの最適化を有効にすると約50秒の基準線が生まれ、最適化を完全に無効にすると約15秒まで短縮された
LLVMトレースデータ収集の難しさ
rustcにはLLVM情報を見るためのフラグがある-Z time-llvm-passes: LLVMプロファイル情報をプレーンテキストで出力-Z llvm-time-trace: Chrome tracing formatでLLVMプロファイルを出力
-Z time-llvm-passesはDocker BuildKitのデフォルトログ制限に引っかかったBUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- これらの環境変数は
docker build呼び出し側ではなくDocker daemonに設定する必要があり、Linuxではsystemdのdrop-inでdocker.serviceに設定できる - 制限を解除すると約 20万行 のテキストが出力され、直接扱うのは難しかった
-Z llvm-time-traceは*.llvm_timings.jsonファイルを生成したが、最終バイナリのトレースファイルは 1.4GiB の単一行JSONだった- Firefox Profiler、Perfetto UI、Chromiumの
chrome://tracingはいずれもこのファイルの扱いに問題があった - JSONをJSONLに変換して一般的なツールで処理した
- 単一JSONオブジェクトの
traceEvents配列を、イベントごとに1行へ分割 - 変換後のイベント数は 7,301,865行 だった
- 単一JSONオブジェクトの
LLVMイベントで見えたボトルネック
- LLVMトレースイベントは主に
"ph":"X"のcomplete eventで、durフィールドがマイクロ秒単位のdurationを表していた "ph":"M"はmetadata eventで、この分析では有用な情報はあまりなかった- aggregateイベントで時間が多く使われた項目は次の通り
Total ModuleInlinerWrapperPass: 665.37秒Total ModuleToPostOrderCGSCCPassAdaptor: 656.47秒Total DevirtSCCRepeatedPass: 632.44秒Total OptFunction: 189.62秒Total InlinerPass: 182.25秒
- この実行は16コアマシンで約 110秒 かかったため、一部のpass時間は重複集計されている
- 大きな軸は関数最適化である
OptFunctionとインライン化であるInlinerPassだった
インライン化しきい値の調整
- LLVMのインライン化オプションは
rustcの-C llvm-argsで渡せる - 2025年6月時点で
rustc -C llvm-args='--help-list-hidden'にはインライン化関連オプションが約100個ある - 実験で使ったオプションは3つだった
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- thresholdは、おおむねその値よりコストの低い関数のインライン化を許可するので、値を下げるとインライン化は減る
- 3つのしきい値を 50 に下げると、時間は 48.8秒から42.2秒 に減少した
- 負荷がほとんどない個人ウェブサイトというユースケースでは、threshold 10 も有望な選択肢と見なされた
OptFunction とジェネリクスの単相化
OptFunctionイベントのargs.detailには最適化対象関数のmangled symbolが入っている- rustfilt でdemangleすると元のRustシンボルが見える
__rustc::__rust_allocserde_json::value::to_value
- 同じ
serde_json::value::to_valueが複数のハッシュで現れるのは、ジェネリック関数が異なる型パラメータで 単相化 されるためである - 他クレートの関数も最終クレート内で最適化される。関数が特定型で単相化される場所が、呼び出し側クレートの文脈だからである
- 最適化時間が大きかった関数の例は次の通り
web_http_server::photos::PhotosState::new内のclosureweb_http_server::run内のclosuretokio_postgres::connect_rawpulldown_cmarkの500行規模のジェネリック関数core::ptr::drop_in_placeの複数の具体型
- 外側のクレート名で大まかに集計すると
coreが 61.53秒 で最大で、そのうち84%はcore::ptr::drop_in_placeのパラメータ化だった
v0シンボルマングリングでasync関数の位置をより明確に見る
- デフォルトのlegacy symbol manglingではclosureの区別がしにくかった
-C symbol-mangling-version=v0を追加すると、closure番号やジェネリック型情報がより明確に現れた- たとえば
serde_json::value::to_valueがどのweb_http_server型で単相化されたのか、完全なジェネリック引数を見られた - v0出力で高コストだった項目は次の通り
<web_http_server::photos::PhotosState>::new::{closure#0}: 1.99秒web_http_server::run::{closure#0}: 1.56秒core::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1.22秒
- 見た目には小さなclosureだったが、LLVM IRをダンプするとasync関数やasync blockが内部的にはネストしたclosureとして表現されていた
- Rustにはすでにasync function/blockのマングリングに関する open issue があった
大きなasync関数と Pin<Box<dyn Future>>
- 高コスト項目の本体はclosureそのものではなく、大きな async関数本体 だった
PhotosState::new関連の最適化時間は当初合計 5.3秒 だった- 単純に関数を分割した最初の試みでは 4.66秒 と、わずかに減っただけだった
- 隣接する
.awaitをまとめて.awaitの数を10個から3個に減らす試みは、逆に 6.24秒 へ増加した - async関数は内部的に複雑なstate machineへloweringされるため、呼び出し側で実装詳細を隠すために
Futureをtrait objectに消去する方法を試した - 使用した関数は
impl Future<Output = T>をPin<Box<dyn Send + Future<Output = T>>>で包む形だった - 各
.await箇所でerase(get_img_candidates()).await?のように適用した結果:PhotosState::new関連時間は 2.14秒 に減少- 全体ビルド時間はプロファイリングなしで 48.8秒から46.8秒 に減少
#[inline(never)]やpoll関数のインライン化無効化も試したが、boxingほどの効果はなかった
複数変更を組み合わせた結果
- 適用したアプローチは3つだった
- LLVM argsによる インライン化の抑制
- メインクレート内の高コスト関数の分割とasync Future boxing
- 依存関係APIのジェネリクス削減による、最終クレートで再コンパイルされる部分の縮小
- 最終Dockerfileでは、3つのインライン化しきい値を 10 に下げる
RUSTFLAGSをcargo chef cookとcargo buildの両方に適用した - メインクレートには10ファイルにまたがって 898行追加、657行削除 の変更が入った
- 依存関係側の変更も含まれていた
pulldown-cmarkのジェネリック関数を非ジェネリック化するPRlol_html、deadpool_postgresで使うAPIの非ジェネリック版を公開するローカルクレート
- この組み合わせにより、最終コンパイル時間は 32.3秒 になった
2025-06-27更新: -Zshare-generics とAlpineの廃止
- BlueskyとLobstersでもらった提案を受け、2つを追加で試した
-Zshare-genericsを有効化- Alpineをやめる
-Zshare-genericsはクレート依存関係のジェネリックインスタンスを再利用するフラグである- releaseビルドではデフォルトで有効ではない
- stable toolchainのdev buildでは有効になっている
- このフラグはnightlyでのみ使用可能
-Zshare-genericsを有効にすると、全体コンパイル時間は 32.3秒から29.1秒 に短縮されたdrop_in_placeのインスタンスは依然として多くコンパイルされていたが、その最適化時間は 21.7秒から17.4秒 に減少した- AlpineからDebianへ変更し、
--target=x86_64-unknown-linux-muslを削除すると、全体コンパイル時間は 29.1秒から9.1秒 へ大きく短縮された - この提案の背景には、デフォルトallocatorがビルド時間に大きく影響しうるという点があった
最終的な数値と残る課題
- 最終的な変化は次の通り
- 出発点: 約175秒
- LTOとデバッグシンボル無効化: 51秒、-71%
- 最終クレート
opt-level = 1: 48.8秒、-4% -C llvm-argsによるインライン化抑制: 40.7秒、-16%- ローカルコード変更: 37.7秒、-7%
- 依存関係変更: 32.3秒、-14%
-Zshare-generics: 29.1秒、-10%- Alpine廃止: 9.1秒、-69%
- 分析過程では、ツールやドキュメントは実際の改善を生み出せる程度には十分機能した
- ただし、いくつか複雑な問題は残っている
- 深いasync関数呼び出しグラフのコンパイル時間は、なお改善の余地がある
core::ptr::drop_in_place<T>をTを定義したクレートでコンパイルするよう特別扱いする案は、一部ケースでは役立つ可能性があるが、ジェネリック型には適用しにくく、未使用のdrop glueまでコンパイルする危険がある-Zshare-genericsは有効だが、完全な解決策ではない- コードベースのどの部分がコンパイル時間を多く消費しているかを切り分け、緩和策を提案するツールがさらに必要かもしれない
- 実用面では、最終クレートに
opt-level = 0を設定する選択も十分ありうる
まだコメントはありません。