世界を動かす技術を、日本語で。

Rustコンパイラはなぜこんなに遅いのか?

概要

  • RustアプリのDockerデプロイでビルド時間の問題に直面
  • 標準的なDockerビルドでは毎回フルビルドが発生し非効率
  • cargo-chefなどのキャッシュ戦略で依存関係の再ビルドを削減
  • ビルド時間の大半はLTOやLLVMの最適化処理に集中
  • ltoやdebug設定の調整でビルド速度・バイナリサイズのバランス改善可能

RustアプリをDockerで高速にビルド・デプロイしたい話

  • Rust製Webサイト のデプロイを従来の「静的バイナリ転送+再起動」から Dockerコンテナ方式 へ移行を決意
  • 普通のDockerfile (rust:alpineイメージ+muslターゲット)では 毎回全ビルド が走り、4分以上かかる現実
  • cargo-chef を利用し、依存クレートのビルドを キャッシュレイヤ として分離
    • 依存関係に変更がなければ再ビルド不要
    • しかし、アプリ本体のビルド時間が依然として長い(2分50秒など)
  • 依存関係ビルド は全体の25%程度、 本体ビルド が大半を占める傾向

rustcのビルド時間の内訳調査

  • cargo --timings でビルド各工程の所要時間を可視化
    • ただし最終クレートのビルド時間しか興味がない場合は情報が限定的
  • rustcの自己プロファイリング(-Zself-profile) をRUSTFLAGS経由で有効化
    • measuremeツール群(summarize, flamegraphなど)で詳細解析
    • LTO(Link Time Optimization)LLVM_module_codegen_emit_obj が大半の時間を消費
    • codegen_module_perform_ltoが全体の80%近くを占めるケース

LTOとデバッグ設定の影響

  • Cargo.tomlで lto = "thin"debug = "full" などを設定していたことが判明
    • LTOの種類(off/thin/fat)やdebug情報の粒度で ビルド時間とバイナリサイズが大きく変動
  • LTOを無効化 するとビルドが高速化
    • ただし最適化レベルやバイナリサイズ、実行速度とのトレードオフが発生

DockerでのRustビルド高速化Tipsまとめ

  • 依存関係のキャッシュ にはcargo-chefが有効
    • ただしアプリ本体のビルド最適化も不可欠
  • LTOやdebugの設定 を見直し、用途に応じた最適バランスを選択
  • rustcの自己プロファイリング を活用し、ボトルネック特定
  • ビルド時間短縮パフォーマンス確保 のバランス調整が重要

まとめ

  • DockerでのRustビルド高速化 には、依存関係キャッシュと本体ビルド最適化の両立が必要
  • LTOやdebug設定の見直し で大幅な時間短縮が可能
  • measuremeツール で詳細なプロファイリング・分析が有効
  • 最適化の落とし所 はアプリの特性や運用フローに依存
  • 継続的な ビルド戦略の改善 が効率的な開発・運用の鍵

Hackerたちの意見

あの人、ちょっと混乱してるみたいだね。単一の静的リンクバイナリをインストールする方が、コンテナを管理するより明らかに簡単じゃない?

記事からすると、目的は簡素化じゃなくて、むしろモダン化だったみたいだね。>「だから、代わりにコンテナ(DockerやKubernetesなど)を使ってウェブサイトをデプロイしたいと思ってる。過去10年間にデプロイされたソフトウェアの大多数に合致するから。コンテナには多くの利点がある。例えば、プロセスの隔離、セキュリティの向上、標準化されたログ、成熟した水平スケーラビリティなど。」

なんか、Dockerが何をしてるのか完全に理解してない感じがするな。Dockerイメージで全てをビルドすることについての言及があって、「残念ながら、何か変更があるたびに全てをゼロから再ビルドすることになる。」って。これって、ビルダーが一人だけで、CIやCDが必要ない場合には、ローカルの便利さを利用してローカルでビルドして、その結果をDockerコンテナに取り込むのは全然問題ないよ。もしパスに気になるものがあったら、誤ってパスを追加する設定を再確認してね。(私の場合、単に「はい、私のユーザー名の誰かがビルドしました」ってことがわかるだけで、「src」ディレクトリがあるってこともわかる...この情報を公にしたってことは、どれだけ気にしてるかってことだね。)プロの環境でCI/CDを使うのは、ハードドライブや磁気針、最小限のカーネルをスクラッチするために訓練された猿からプロジェクトをビルドできることを確保するためにはいいけど、個人プロジェクトにはそんなの必要ないよ。

その通り。読んだ瞬間にグルグ脳の開発者を思い浮かべたよ。

元C++開発者として、Rustのコンパイルが遅いっていう主張には首をかしげちゃうな。

Cのカプセル化やコンパイルのステップを減らす作業はすごく楽しんでるんだけど、C++が来て、全てにテンプレートを要求することでほとんどを元に戻しちゃうのが残念だな。うっかり一つのヘッダーのテンプレートを変えたら、それが... 98%のコードに影響するんだよね。

それが、RustがC++の開発者をターゲットにしている理由の一つだね。C++の開発者は、ツールを受け入れるために必要なストックホルム症候群をすでに持ってるから。

絶対的な意味で遅いこともあるけど、C++ほど遅くはないよ。C++のコンパイルに関する問題は非常によく理解されていて、文書化されてる。コンパイル時間が最悪な言語の一つだね。Rustはそういう言語レベルの問題を抱えてないから、期待値が高くなるのは当然だよ。

新機能:はい ユーザーと話して実際の問題を解決する:笑、無理、めんどくさい。

全然遅いとは思わないよ。複雑さ的には他の言語と同じくらいパフォーマンスがあるし、15分かかるC++やScalaのビルド時間よりはずっと速いと思う。

C++のテンプレートがチューリング完全になったら、実際のコードを考慮せずにコンパイル時間について文句を言うのは無意味だよね :)

これもよくわからないんだけど、Rustコンパイラは僕が作業してるときにほとんど気にならないんだ。初期の頃がひどかったから、その印象が残ってるだけなんじゃないかな。

私のホームページの再ビルドには73msかかるよ:静的サイトジェネレーターの再コンパイルに17ms、実行に56ms。andy@bark ~/d/andrewkelley.me (master)> zig build --watch -fincremental ビルドサマリー:3/3ステップ成功 インストール成功 └─ 実行 exe コンパイル成功 57ms MaxRSS:3M └─ コンパイル exe コンパイル デバッグネイティブ成功 331ms ビルドサマリー:3/3ステップ成功 インストール成功 └─ 実行 exe コンパイル成功 56ms MaxRSS:3M └─ コンパイル exe コンパイル デバッグネイティブ成功 17ms 75ディレクトリ、1プロセスを監視中

Zigは小さくてシンプルな言語だよ。複雑なコンパイラは必要ない。Rustは大きくて堅牢な言語で、真剣なシステムプログラミング向けに作られてる。Rustが扱う問題の範囲は広くて、非常に大規模なソフトウェア問題に展開されることを目指してる。この2つは同じじゃないし、単純に比較するのは無理があるね。編集:言い回しを少し変えたよ。「おもちゃ」言語って表現したけど、それは適切じゃなかった。これらの言語は成熟度が違うし、複雑さのレベルも異なるし、顧客も違うから、そんなに単純に比較するべきじゃないよ。

@AndyKelley Zigのような言語がコンパイルがすごく速い理由って、RustやSwiftが遅いのとは何が違うと思う?主な要因は何だと思う?

Zigはメモリ安全じゃないよね?

C/C++に関する投稿には必ずRustが素晴らしいってコメントがつくし、Rustに関する投稿にはZigが素晴らしいってコメントがつく。まるで時計のようだね。編集:どうやらメインのZigの作者に返信してるみたい?言語の伝道はRustの最悪な部分で、実際には「人をRustに転向させる」よりも、反Rust感情を煽ってる可能性が高いよ。本当に自分の言語を大切に思うなら、コミュニティを伝道から遠ざけるために持ってる力を使うべきで、受け入れるべきじゃない。

うーん、まあ面白いかな?このコメントは、投稿された記事に関わってたり、単なるコンパイル時間のメトリック以上の洞察があればもっと良かったのに。あなたのコメントから何を持ち帰ってほしいの?Zigが良くてRustが悪いってこと?

いいね!zig buildに--watchと-fincrementalが追加されてるのに気づかなかった。ファイル変更時の再コンパイルには「watchexec -e zig zig build」を使ってたから。

私の非静的Rustウェブサイト(実際のウェブサーバーとテンプレート用のリアクション風フレームワークを含む)は、「cargo watch」を使ってインクリメンタル再コンパイルに1.25秒かかるよ(これは外部のウォッチャーで、プロセスを殺して「cargo run」を再実行するだけ)。subsecond[0]のようなものを使えば、かなり速くなることもある。Zigほど速くはないけど、近い感じ。ただ、上の331msのビルドがクリーン(キャッシュなし)ビルドなら、私のウェブサイトのクリーンビルドの約12秒よりもずっと速いね。 [0]: https://news.ycombinator.com/item?id=44369642

インクリメンタルコンパイルはいいね。もしよければ、初回のインクリメンタルキャッシュを一度の新しいビルドの後にフリーズして、更新のビルド/デプロイに使うと、途中の状態が徐々にキャッシュを壊すリスクを軽減できるよ。Dockerとの相性も抜群だよ:新しいコンパイラのバージョンや大きなウェブサイトの更新があったら、インクリメンタルキャッシュでレイヤーを再ビルドすればいいし、そうでなければスナップショットから実行して最新のウェブサイトの更新バージョン/状態をビルドして、結果の静的バイナリをアップロード/デプロイすればOK。単なるコードの変更で、クリーンビルドのインクリメンタルコンパイルキャッシュをキャッシュ/具現化するレイヤーを再ビルドさせないように設定しておけばいいよ。

プロジェクトの中間ファイルだけで150GB以上あるんだ。前にそんなに大きなDockerイメージを扱った時は、めっちゃ大変だった。

そういえば、Ryan Fleuryっていう人がいて、EpicのRADデバッガーを作ってるんだけど、これが278k行のCで作られてて、ユニティビルドとして構築されてるんだ(全てのコードが一つのファイルに含まれて、一つの翻訳単位としてコンパイルされる)。 decentなWindowsマシンでクリーンコンパイルに1.5秒かかるんだ。これはコンパイルが信じられないほど速くできることの明確なケーススタディだと思うし、RustやSwiftが同じようにして似たような速度を達成できない理由が気になるよ。

Cのコードがすぐにコンパイルできるってのは、面白いとは思わないな(Goも、速いコンパイルのために特別に設計された言語だから)。これはコンパイルに内在する問題じゃないし、面白い難しい問題はRustのセマンティクスを速くコンパイルできるようにすることだよ。これはRustのウェブサイトのFAQにも載ってる。

コンパイラがビルド時にやることが多ければ多いほど、ビルドにかかる時間は長くなる。それだけの話。Goは、巨大なコードベースでもサブ秒のビルド時間を誇る。なんでかって?ビルド時にあんまりやらないからだよ。シンプルなモジュールシステムと(比較的)シンプルな型システムがあって、たくさんのことはランタイムでGCに任せてる。目的に合った使い方には最高だね。マクロや高度な型システムが必要で、ビルド時に堅牢性を保証したいなら、その分コストがかかるよ。

RustやSwiftはCコンパイラよりもずっと多くの処理をしてるからじゃない?借用チェッカーに必要な分析はタダじゃないし、両言語の他のコンパイル時チェックも同様だよ。Cは基本的な構文以外のコンパイル時チェックをほとんどしないから速いんだ。だからfoo(char)にfoo(int)を渡すようなこともできちゃう。

278k行のコードをシンプルなRustで、ジェネリクスやマクロを使わず、依存関係なしの単一クレートで書き直したら、かなり似たようなコンパイル時間が得られると思うよ。Rustコンパイラはコードがシンプルならすごく速いからね。依存関係や重い抽象(マクロ、ジェネリクス、トレイト、深い依存木)があると遅くなるんだ。

2000年代にC++で数十KLoCのプロジェクトに出会ったことがあるんだけど、古いコンピュータでほんの数分の一の秒でコンパイルできた。Boostを使ったHello Worldのコードは数秒かかったよ。だから、言語だけの問題じゃなくて、コードの構造や重いコンパイルコストのある機能の使い方が影響するんだ。CのマクロでDoomを書けるけど、速くはならないと思うよ。Rustのコードも、速くコンパイルできるように書けるはずだ。

Unityビルドが速いっていう主張は、僕には全然実感がないな。7950x(できる限り速い)でビルドスクリプトを実行して、radデバッガーをダウンロードしたんだけど、デバッグビルドは5秒、リリースビルドはgccかclangで34秒かかったよ。MSVCの問題かもしれないけど、マルチスレッド関連の何かがあるみたいだね。いずれにせよ、raddbgの非クリーンビルドは、僕のRustプロジェクトよりも時間がかかるよ。

アルファ版。Windows専用。 https://codeload.github.com/EpicGamesExt/raddebugger/tar.gz/...

これは時々アマルガメーションって呼ばれるけど、Rustでもできるよ。手動でもツールを使ってもね。要するに、特定のニッチを除けば、実用的なアプローチじゃないってこと。できないわけじゃないけど、面倒な割には価値がないことが多いし、目標はすべてが1つのファイルに収まってなくてもコンパイルが速いことだと思う。Turbo Pascalは、当時の素晴らしいコンパイル速度のおかげで市場を勝ち取ったいい例だよね。同じように、言語は速いコンパイルのために設計できる。Pascalは一般的にシングルパスコンパイル用に設計されていて、自然に速くなったんだ。ただ、必要な前方宣言が面倒だったけど、シングルパスコンパイル用に設計されていない言語が勝ったのは、結局やる価値がなかったって証明してるよね。

僕のCコンパイラは、かなり単純で約9万行だけど、自分自身を約1秒でコンパイルできるよ。Clangは0.4秒くらいでできる。シンプルな真実は、Cコンパイラはあまり多くのことをする必要がないってことだね!

これはコンパイルが驚くほど速いことを示す明確なケーススタディのようだね (...) ユニティビルドでコンパイラエラーのトラブルシューティングを試したことある?うん。

Goが逆の方向に進んでくれて嬉しいよ。最適化よりもコンパイル速度を重視してるからね。僕がやってる仕事、サーバーやネットワーク、グルーコードを書くのにとって、早いコンパイルは絶対に重要なんだ。それに、ある程度の型安全性も欲しいけど、あまり厳しすぎるのは嫌だな。プロトタイプを適当に作るのが難しくなるからね。それに、GCも助かるし、喜んでその代償を払うよ。シジルスープに悩まされないのも大きなプラスだね。Googleの長年の経験から、ソフトウェア開発がスケールするためには、シンプルな型システム、GC、そして超高速なコンパイル速度が、実行時のスループットや意味的正確性よりも重要だって結論に至ったんだと思う。Goで書かれたネットワークや大規模インフラソフトウェアの量を考えると、彼らは本当にうまくやったと思うよ。でも、もちろんGCが許容できない場所や、正確性が開発速度よりも重要な場合もあるけど、僕はその分野で働いてないし、Goが選んだトレードオフには満足してるよ。

それこそGoが本来目指していたことだし、仕事に合ったツールを選ぶのが一番だよね。僕が見た中での唯一の足を引っ張る要素は、チャネルを通じて可変の共有状態で並行処理をすると、微妙に悪い結果になることがあるってことかな。でも、ほとんどの人はそんな風にチャネルを使ってない気がする?僕はRustを使ってるけど、それは僕の仕事ではないからだよ。遅いアルゴリズムをさらに遅いハードウェアに詰め込まなきゃならないことが多いし、問題はほとんどが恥ずかしいほど並行処理できない状態なんだ。

最近、GoogleでGoはまだ活発に使われてるのかな?

嫌な型って何?型はデータを正しく表現するか、そうでないかのどちらかだと思う。HaskellやIdris、PureScriptを含むどの言語でも、型を強制してコンパイラを黙らせることはできると思うよ。

速いコンパイルは絶対に重要だよね。同時に、ある程度の型安全性も欲しいけど、あまり厳しすぎるのは勘弁。プロトタイプを雑に作るのに支障が出るからね。それに、GCも助けになるし。まあ、そのデザインのポイントはJavaがすでに占めてるし、Javaもめっちゃ速いビルドができるからね。Goが存在するのは、デザイナーたちが新しいプログラミング言語を作りたかったからだと思う。実装面ではいいところもあるけど、ユーザーは主にPython/Ruby/JSの世界から来てる感じで、C/C++/Javaをターゲットにしてたわけじゃない(つまり、Googleのサーバー用)。スクリプト言語のユーザーは、型システムがあって、でもあまり高度すぎない言語を求めてたんだよね。スクリプトの「感覚」を保ちながら、すごく速いターンアラウンドタイムがあるやつ。でもJavaは古くてダサいから、面白いライブラリやカンファレンスの話題はすでに他の言語が占めてたし。

両方の良いところを持つことができるよ:速いけど雑なコンパイラと、遅いけど徹底的なチェックツール/リンター。そういう意味では理想的だと思うけど、Rustは無駄に両方を一つにまとめちゃったみたいだね。

Googleの長年の経験から、ソフトウェア開発がスケールするためには、シンプルな型システム、GC、そして超高速なコンパイル速度が、実行時のスループットや意味的正確性よりも重要だという結論に至ったんだと思う。僕はGoのファンだけど、これはGoogleの素晴らしい集団知恵や経験の産物だとは思わない。もしそうだったら、静的にヌルポインタ例外を排除するのが価値ある試みだと結論づけていたと思う。結局、Googleの人たちが自分たちのやりたいように言語を作っただけなんじゃないかな。

いつかパスカルの構文をちょっとだけPythonっぽく変えて、ジュニアやGoの開発者を驚かせたいな。

一方で、他の言語にはJITコンパイラがあって、実行中にコードをコンパイルするんだ。これって開発にはすごく良いと思う、たとえ全体的には遅くなったとしても。

実際、JITはAOTコンパイルよりも速くなることがあるんだ。実行中のアーキテクチャに最適化できるからね。JIT言語のJuliaが、いくつかのベンチマークでCを超えられるって主張もあったよ。

Vimを開くとハングする場合、ワードラッピングを有効にすることで回避できるよ(:set wrap)。ライフハック:h, j, k, lだけでファイルをナビゲートするのは難しいけど、gh, gjとかも使えるよ。gを使うと、Vimはビジュアルラインで動くけど、使わないとLF/CRLFで分割された行だけになるからね。

vimrcのちょっとした魔法を使えば、透明にできるよ。「k/jで上下に移動するのを、次の表示された行に行くようにして、論理的な行に行くのではなくする(ワードラッピングがオンの時用):noremap k gk noremap j gj noremap gk noremap gj」「上と同じだけど、挿入モードの矢印キー用:inoremap gka inoremap gja」

数週間前の関連リンク: https://news.ycombinator.com/item?id=44234080(Rustコンパイラのパフォーマンス;287ポイント、261コメント)