1 ポイント 投稿者 GN⁺ 2025-06-28 | 1件のコメント | WhatsAppで共有
  • 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件のコメント

 
GN⁺ 2025-06-28
Hacker Newsの意見
  • Rustのプロジェクトは、見た目には小さく見えることが多くて興味深い。第一に、依存関係はコードベースの実際の大きさとは結び付かない。C++では大規模プロジェクトの依存関係をベンダリングしたり、そもそも使わなかったりすることが多いので、40万行のコードのうち重い部分が多ければ「コードが多いのだから遅くても仕方ない」と考えられる。第二に、はるかに問題なのがマクロ。10行、100行単位で繰り返し展開されるマクロは、1万行のプロジェクトでもあっという間に100万行にしてしまう。第三はジェネリクス。ジェネリクスのインスタンス化ごとにCPUリソースを消費する。それでも少し擁護するなら、こうした機能のおかげで、Cで10万行、C++で2万5千行だったものが、Rustでは数千行まで減る利点がある。ただし、こうした機能が過剰に使われることで、エコシステム全体が遅く見えるのも事実。たとえばうちの会社では async-graphql を使っているが、ライブラリ自体は優れている一方で、プロシージャルマクロへの依存が非常に強い。性能関連の問題が何年も open のままで、データ型を追加するたびにコンパイラが確実に遅くなるのを実感する

    • もともとコードが単純だった小さなCユーティリティのようなものをRustで書き直す例が多いのはなぜなのか気になる。10万行級の大規模CプログラムのRust移植より、ずっと小規模なコードの例をよく見る。小さなプログラムのコンパイル速度でRustとCがどう比較されるのか知りたい。興味があるのはプログラムの規模ではなくコンパイル速度だ。参考までに、最近測ったところでは、Rustコンパイラのツールチェーンサイズは、私が使っているGCCの約2倍あった。1. この程度の小さなプログラムは、どの言語であってもメモリ安全性の問題が潜んでいる可能性は低く、規模も小さいので監査もしやすい。10万行のCプログラムとは状況が違う
    • 型を新しく定義するたびにコンパイラが遅くなるのを肌で感じる。記憶が正しければ、コンパイラ性能は型の「深さ」に応じて指数関数的に遅くなる。GraphQLのようなケースではネストした型が多く、この問題が特に深刻
    • マクロが数十行、数百行単位で展開されると、コードベースが幾何級数的に大きくなり得る問題に対応するため、最近分析ツールのサポートが追加された。関連資料: https://nnethercote.github.io/2025/06/26/how-much-code-does-that-proc-macro-generate.html
  • Ryan Fleuryは Epic RAD Debugger をCで27万8千行のユニティビルド方式(全コードを1つのファイルにまとめた単一コンパイル単位)で作り、Windowsでのクリーンコンパイルがわずか1.5秒しかかからない。この事例だけでも、コンパイルは非常に速くできることを示しているが、RustやSwiftで同じようにできないのはなぜなのか気になる

    • ビルド時にコンパイラが行う作業が増えるほど、ビルド時間は長くなる。Goは大規模コードベースでも1秒未満のビルド時間を実現できる。ビルド時に必要な作業だけを最小限にしたシンプルなモジュールシステムと型システム、そして多くの機能をランタイムGCに任せているからだ。逆に、マクロや複雑な型システム、高水準の堅牢性が求められるなら、ビルド時間が長くなるのは避けられない
    • Rustもビルド単位はクレート全体で、コンパイラがLLVM IRに適切なサイズで分割する。重複作業とインクリメンタルビルドのバランスも自動で調整する。Rustはソースコード行数ベースではC++よりビルドが速いことも多い。ただしRustプロジェクトは依存関係まですべてコンパイルする性質がある
    • RustやSwiftがCコンパイラよりコンパイルが遅い理由は、言語自体がはるかに多くの解析作業を必要とするからだ。たとえばRustの borrow checker はただで提供されているわけではない。コンパイル時チェックだけでもかなりのリソースを消費する。Cが速いのは、基本的な文法以上の検査をほとんどしていないからだ。むしろCは foo(char*)foo(int) を渡すようなおかしな組み合わせすらチェックしない
    • 2000年代に数万行規模のC++プロジェクトをコンパイルしていたが、古いコンピュータでも1秒以内でビルドが終わっていた。一方でBoostだけを使った HELLO WORLD は数秒かかった。結局、ビルド速度は言語やコンパイラだけでなく、コード構造や使っている機能によって大きく変わる。CマクロでDOOMを作ることもできるだろうが、たぶん速くはない。逆にRustも、速くビルドできるように構造化することは可能
    • CやGoのように高速コンパイルを志向した言語が速いのはそれほど不思議ではない。本当に難しいのは、Rustのセマンティクスを高速にコンパイルすることだ。この問題はRust公式FAQにも載っている
  • Goが最適化よりコンパイル速度を優先したのは本当に良かったと思う。サーバー、ネットワーキング、glue code の作業では、コンパイルが非常に速いことが何より重要だ。型安全性もある程度は欲しいが、ゆるくプロトタイピングする邪魔にならない範囲でよい。GCがあるのも便利だ。Googleは大規模開発の経験を経て、単純な型、GC、そして非常に速いコンパイルが、実行速度や意味論的な完全性よりはるかに重要だという結論に至ったのだと思う。Goで作られた大規模なネットワーク・インフラ系ソフトウェアの事例を見ても、この選択は完全に当たっている。もちろんGCを許容できない環境や、完全な正確性がより重要な場面ではGoを使わないかもしれないが、私の作業環境ではGoの選択が最適だ

    • 私もGoは好きだが、この言語が組織としてのGoogleのすごい集合知の産物だとは思わない。Googleの経験がもっと反映されていたなら、たとえばヌルポインタ例外の静的排除のような機能は入っていただろう。むしろ何人かのGoogle開発者が自分たちの欲しかった言語を作った結果のように見える
    • 高速コンパイル、ほどほどの型システム、GCといったGoの利点はあるが、設計空間の中ではすでにJavaが近い位置を占めていた。Goが作られたのは、単純に創作意欲が主な理由だったように思えるし、結果として当初のターゲットだったサーバーサイドのC/C++/Javaよりも、スクリプト言語(Python/Ruby/JS)ユーザー層により広く受け入れられた印象がある。スクリプトユーザーは簡単で速い型システムだけを求めていて、Javaは古すぎて面白みがなかった。すでにJavaはサーバー/カンファレンス/ライブラリ分野では入り込む余地がなかった
    • Googleの開発者がC++プロジェクトのコンパイル待ちをしながらGoを設計したという話もある
    • 「obnoxious type」とは何なのか聞きたい。型というのはデータを正しく表現するか、できないかのどちらかでしかなく、実際にはどんな言語でも型チェッカーを無理やり黙らせることはできる
    • Goは設計目的と実際の用途にぴったり合った言語だ。最大のリスクは、並列処理と mutable な状態をチャネル経由で共有するやり方にあり、この部分では微妙で脆いバグが起こり得る。とはいえ、ほとんどのユーザーはそうしたパターンを使わない。私はRustを使っているが、仕事自体が遅いアルゴリズムを遅いハードウェアで限界まで絞り出して動かさなければならない状況だ。そのため大規模並列化が非常に微妙な問題になっている
  • 単一のスタティックバイナリをインストールするほうが、コンテナ管理より単純だという主張は理解できない

    • docker が実際に何をしているのかを明確に理解していないように見える。たとえば「dockerイメージで配布すると毎回全部を新規ビルドする」と言っていたが、社内のビルド/デプロイ環境ではそんな問題はなくても構わない。個人用途なら、開発の利便性を保ったまま、ローカルでビルドしたファイルだけをコンテナに入れても問題ない。ビルド環境の痕跡パスにだけ気を付ければよい。CI/CDやチーム開発では、どこでもゼロからビルド成果物を再現できることに重点が置かれるが、個人作業ではその必要はない
    • 原文での目標は単純化ではなくモダン化だ。「ここ10年でほとんどのソフトウェアがコンテナデプロイを標準にしているのだから、自分のウェブサイトも docker や kubernetes のようなコンテナでデプロイする」ということ。コンテナには、プロセス分離、セキュリティ、標準化されたロギング、水平スケーラビリティなど、さまざまな利点がある
  • 私のノートPC(Mac M4 Pro)では、Deno(大規模Rustプロジェクト)のフルコンパイルに2分かかる。コマンドベースで見ると、debug は約1分54秒、release は約8分17秒だった。インクリメンタルコンパイルなしで測定した数字だ。実際にはデプロイビルドはCI/CDシステム上で回るので、自分で待つ必要はない

    • M1 Maxで6分、M1 Airで11分ほどかかったという関連記事がある
  • Craneliftの話はどこに出てくるのだろう? 私はRustでゲーム開発をしていて、コンパイル時間が長すぎてほとんど諦めかけたことがある。調べてみると、LLVMは最適化レベルに関係なく遅い。Jai言語の開発者たちがいつも指摘していたことだ。Craneliftにしたらビルド時間が16秒から4秒に短縮された経験もある。Craneliftチームには感心した

    • 最近のBevy game jamでは、Dioxusコミュニティ発の subsecond というツールを使ったが、名前どおりシステムのホットリロードを1秒未満で可能にしてくれて、UIプロトタイピングに大いに役立った。 https://github.com/TheBevyFlock/bevy_simple_subsecond_system
    • zigチームもLLVMなしで独自コンパイラ(バックエンド)を作り、ビルド時間を非常に速くしようとしていると聞いている
    • 以前はCraneliftは macOS aarch64 をサポートしていなかったと思っていたが、最近は対応していると知った
    • 16秒のビルド時間でRustを諦めかけたというのは、ちょっと大げさでは?
  • Rustが遅いとは思わない。同格の言語と比べれば十分速いし、15分もかかっていたC++/Scalaのコンパイルに比べればずっと速い

    • 私も同感。Rustのビルドで特に不便を感じたことはない。たぶん初期の悪い印象がそのまま残って、こういう評価になっているのだと思う
    • コンパイル時のメモリ使用量はC/C++に比べて非常に大きい。YouTubeデモ用のVMで大規模Rustプロジェクトをコンパイルするには8GB以上必要だが、C/C++ではそんな心配はしない
    • C++テンプレートがチューリング完全である以上、実際のコードスタイルを考慮せずにビルド時間だけを比較しても意味はない
  • 以前C++開発をしていた立場からすると、Rustのビルドが遅いという主張はよく分からない

    • だからこそRustはC++開発者向けだと評されるのだと思う。C++経験が長い開発者は、すでにツールの不便さに耐える Stockholm syndrome にかかっている
    • C++より速くても、絶対値としては遅いことはあり得る。C++ビルドの悪名がどれほどひどいかは周知のとおりだ。Rustは構造的な言語問題を抱えているわけではないので、そのぶん期待値が高くなっているのだろう
    • 新機能は次々追加されるのに、実際のユーザーの声を聞いて問題解決はあまりしない、典型的なケースに思える
    • Cはコンパイル段階が少なく単純なので速かったが、C++はテンプレートの使用によって、むしろカプセル化の作業の大半を壊してしまったように感じる。1つのテンプレートヘッダを変えただけで、結局プロジェクト全体の98%に影響する気分になる
  • インクリメンタルコンパイルは本当に強力だ。初回ビルド後にインクリメンタルキャッシュのスナップショットを固めておけば、変更がない場合はそのまま高速にビルド/デプロイへ活用できる。dockerとの相性も良い。コンパイラのバージョン変更や大きなウェブサイト更新でもない限り、イメージビルドのレイヤーを触らない。コード変更だけのときは、そのレイヤーが再ビルドされないように設定すれば効率的

    • 私のプロジェクトのインクリメンタル成果物は150GBを超えている。dockerイメージをこれほど大きくして使ったとき、実際にかなり大きな問題が起きたことがある
  • 私のホームページのビルド時間は73msだ。static site generator は17msで再コンパイルし、実際の generator 実行は56msしかかからない。zigのビルドログ出力を添付する

    • C/C++には「Rustがいい」というコメント、Rustには「Zigがいい」というコメントがいつも付く気がする。(調べたらこのコメントの投稿者はzigのメイン開発者だった。)言語布教はコミュニティに有害で、実際には反感を招くだけで新しいユーザーを呼び込まないと思う。本当にその言語を愛しているなら、こうした布教文化を抑えるほうが役に立つ
    • 単一のコンパイル時間指標を示すだけでなく、元記事のテーマに直接関係する議論や解釈もあればもっと良かったと思う
    • 私のRust製ウェブサイト(React風フレームワークと実質的なウェブサーバーを含む)も、cargo watch でのインクリメンタルビルドなら約1.25秒だ。subsecond[0] のような incremental linking や hotpatch まで使えばさらに速くなる。Zigほどではないが、かなり近い。もし上で言われていた331msが clean(キャッシュなし)ビルドなら、私のウェブサイトの clean ビルド12秒よりははるかに速い。[0]: https://news.ycombinator.com/item?id=44369642
    • @AndyKelley にぜひ聞いてみたい。zigのコンパイルが非常に速く、RustやSwiftがいつも遅い決定的な理由は何だと思う?
    • Zigはメモリ安全性を保証しない、という理解で合っているよね?