1 ポイント 投稿者 GN⁺ 2025-06-28 | まだコメントはありません。 | WhatsAppで共有
  • Dockerデプロイ用のRustウェブサイトのビルドでは、依存関係をキャッシュしても最終クレートだけで 約175秒 かかり、ボトルネックは rustc 内部とLLVM最適化段階に絞り込まれた
  • cargo-chefcargo --timings-Zself-profilemeasureme を順に適用した結果、単純な依存関係の問題ではなく、LTOとLLVMコード生成 のコストがビルド時間を支配していた
  • 古い Cargo.toml 設定の lto = "thin"debug = "full" が大きく影響しており、これらを無効化すると最終バイナリのビルドは 172.2秒から約50秒 にまで短縮された
  • LLVMトレースでは OptFunctionInlinerPasscore::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 --timingsrustc 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' を使用
  • measuremesummarizeflamegraphcrox ツールで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プロファイルでは通常省かれる完全なデバッグシンボルを有効化する
  • さまざまな ltodebug の組み合わせを測定すると差は大きかった
    • 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
  • 完全なデバッグシンボルはコンパイル時間を 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_SIZE
    • BUILDKIT_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行 だった

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_alloc
    • serde_json::value::to_value
  • 同じ serde_json::value::to_value が複数のハッシュで現れるのは、ジェネリック関数が異なる型パラメータで 単相化 されるためである
  • 他クレートの関数も最終クレート内で最適化される。関数が特定型で単相化される場所が、呼び出し側クレートの文脈だからである
  • 最適化時間が大きかった関数の例は次の通り
    • web_http_server::photos::PhotosState::new 内のclosure
    • web_http_server::run 内のclosure
    • tokio_postgres::connect_raw
    • pulldown_cmark の500行規模のジェネリック関数
    • core::ptr::drop_in_place の複数の具体型
  • 外側のクレート名で大まかに集計すると core61.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 に下げる RUSTFLAGScargo chef cookcargo build の両方に適用した
  • メインクレートには10ファイルにまたがって 898行追加、657行削除 の変更が入った
  • 依存関係側の変更も含まれていた
    • pulldown-cmark のジェネリック関数を非ジェネリック化するPR
    • lol_htmldeadpool_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 を設定する選択も十分ありうる

まだコメントはありません。

まだコメントはありません。