Tips for Faster Rust Compile Times

Matthias Endler

Rustのコンパイル時間を高速化するヒント

Rustのビルドが遅いですか?

コンパイル時間を速くするためのヒントをまとめました。このリストはもともと私の個人ブログで公開したものですが、更新してこちらに移しました。

各ヒントはおおむね効果の大きい順に並んでいます。上から順に試してみてください。

全般的なヒント

Rustコンパイラとツールチェインを最新にする

最新のRustを使っていることを確認してください。

rustup update

Rustコンパイラの高速化は現在も進行中の取り組みです。開発チームの尽力により、コンパイラの速度は今年に入って全体で30〜40%向上し、プロジェクトによっては45%以上の改善が見られています。ツールチェインを常に最新の状態に保つメリットは十分にあります。

cargo buildの代わりにcargo checkを使う

# 遅い 🐢
cargo build

# 速い 🐇 (2〜3倍高速)
cargo check

多くの場合、プロジェクトをそもそもコンパイルする必要すらありません。どこかで間違えていないか知りたいだけのことがほとんどです。可能な限り、コンパイル自体をスキップしましょう。必要なのは、高速なリント、型チェック、借用チェックです。

できる限りcargo buildの代わりにcargo checkを使ってください。コードにエラーがないかだけをチェックし、実行ファイルは生成しません。

左側のcargo checkと中央のcargo debugで、実行される命令数がどれだけ違うか見比べてみてください。(それぞれスケールが異なる点に注目してください。)

高速化の比較: check 1、debug 5、opt 20

私がよく使っている小技は、cargo watchを使ってバックグラウンドで実行する方法です。ファイルを更新するたびに自動でcargo checkを実行してくれます。

おまけ: cargo watch -cを使うと、実行のたびに画面がクリアされます。

使われていない依存クレートを削除する

# cargo-macheteをインストール 🔪️
cargo install cargo-machete && cargo machete

# cargo-shearをインストール ✂️🐑
cargo install cargo-shear

# cargo-udepsをインストール 🧼🧹️
cargo install cargo-udeps --locked

リファクタリングの後、依存クレートが不要になっていることがあります。ときどき、削除できる未使用の依存がないか確認するとよいでしょう。

上記のツールは、プロジェクト内の未使用の依存をリストアップしてくれます。それぞれに限界があり、誤検出や検出漏れもあります。3つのツールを併用するのが最も効果的です。

Analyzing dependencies of crates in this directory...
cargo-machete found the following unused dependencies in <project>:
crate1 -- <project>/Cargo.toml:
        clap
crate2 -- <project>/crate2/Cargo.toml:
        anyhow
        async-once-cell
        dirs
        log
        tracing
        url

詳しくはcargo-machetecargo-shearcargo-udepsの各プロジェクトページをご覧ください。

cargo-shearcargo-udepsについては、Nicholas Nethercote氏に教えていただきました。同氏はRust Performance Bookや有名なHow to speed up the Rust compilerシリーズの著者です。

依存クレートを更新する

  1. cargo updateを実行して、semver互換の最新バージョンに更新します。
  2. cargo outdated -wRを実行して、互換性のない可能性のある新しい依存を探します。該当するものを更新し、必要に応じてコードを修正します。
  3. cargo tree --duplicateを実行して、複数バージョンが混在している依存を探します。古いバージョンに依存しているクレートを更新して、単一バージョンにまとめましょう。(指摘してくれた/u/dbdr氏に感謝します。)

(Redditの/u/oherrala氏による手順です。)

さらに、cargo auditを使って、対応が必要な脆弱性や、置き換えが必要な非推奨クレートの通知を受け取るようにしましょう。

コードベースでコンパイルの遅いクレートを見つける

cargo build --timings

このコマンドで、各クレートのコンパイルにかかった時間を確認できます。

cargo build --timings の図

この図の赤い線は、現在コンパイル待ちで、別のクレートによってブロックされているユニット(クレート)の数を示しています。単一のクレートがボトルネックになって多くのクレートが待たされている場合は、そのクレートの改善に集中することで並列性を高められます。

色の意味は次のとおりです。

  • Waiting(赤) — CPUの空きを待っているクレート
  • Inactive(青) — 依存クレートの完了を待っているクレート
  • Active(緑) — 現在コンパイル中のクレート

詳しくはドキュメントをご覧ください。

コンパイル時間をプロファイルする

cargo --timingsよりさらに深く掘り下げたい場合は、cargo rustc -- -Zself-profileでRustのコンパイルをプロファイルできます。生成されたトレースファイルは、フレームグラフやChromiumプロファイラで可視化できます。

すべてのクレートを表示したChromeプロファイラの画像

もう一つの有用なツールがcargo-llvm-linesです。最終的なバイナリで生成された行数や、各ジェネリック関数のコピー数を表示してくれます。どの関数のコンパイルコストが最も高いかを特定するのに役立ちます。

$ cargo llvm-lines | head -20

  Lines        Copies         Function name
  -----        ------         -------------
  30737 (100%)   1107 (100%)  (TOTAL)
   1395 (4.5%)     83 (7.5%)  core::ptr::drop_in_place
    760 (2.5%)      2 (0.2%)  alloc::slice::merge_sort
    734 (2.4%)      2 (0.2%)  alloc::raw_vec::RawVec<T,A>::reserve_internal
    666 (2.2%)      1 (0.1%)  cargo_llvm_lines::count_lines
    490 (1.6%)      1 (0.1%)  <std::process::Command as cargo_llvm_lines::PipeTo>::pipe_to
    476 (1.5%)      6 (0.5%)  core::result::Result<T,E>::map
    440 (1.4%)      1 (0.1%)  cargo_llvm_lines::read_llvm_ir
    422 (1.4%)      2 (0.2%)  alloc::slice::merge
    399 (1.3%)      4 (0.4%)  alloc::vec::Vec<T>::extend_desugared
    388 (1.3%)      2 (0.2%)  alloc::slice::insert_head
    366 (1.2%)      5 (0.5%)  core::option::Option<T>::map
    304 (1.0%)      6 (0.5%)  alloc::alloc::box_free
    296 (1.0%)      4 (0.4%)  core::result::Result<T,E>::map_err
    295 (1.0%)      1 (0.1%)  cargo_llvm_lines::wrap_args
    291 (0.9%)      1 (0.1%)  core::char::methods::<impl char>::encode_utf8
    286 (0.9%)      1 (0.1%)  cargo_llvm_lines::run_cargo_rustc
    284 (0.9%)      4 (0.4%)  core::option::Option<T>::ok_or_else

遅い増分ビルドのトラブルシューティング

増分ビルドが予想より遅い場合、rustcバックエンドの特定のクレートがボトルネックになっている可能性があります。

診断するには、samply-Zhuman_readable_cgu_names=yesフラグを組み合わせてコンパイラをプロファイルし、コンパイルに最も時間がかかっているコード生成ユニット(CGU)を特定します。

samply record cargo build

これで、ビルドのボトルネックとなっているクレートを特定できます。特定できたら、問題のあるクレートをリファクタリングしたり分割したりすることで、コンパイル時間を改善できる場合があります。

バックエンドの並列性について詳しくは、Nicholas Nethercote氏のブログ記事をご覧ください。

もう一つの便利なフラグが-Zprint-mono-items=yesです。コンパイル中に単相化されたアイテムをすべて出力します。各CGUで何が生成されているかを把握するのに役立ちます。

RUSTFLAGS="-Zprint-mono-items=yes" cargo +nightly build                                               ⏎

このヒントはCaspar氏(Redditではasparck)からいただきました!

重い依存クレートを置き換える

ときには、よく使われているクレートをより軽量な代替に置き換えられないか探してみるのも有効です。

ここでもcargo treeが役立ちます。どの依存が重いのか、つまり多くのクレートに依存し、ネットワークI/Oを増やしてビルドを遅くしているのかを把握できます。そこから、より軽い代替を探しましょう。

また、cargo-bloat--timeフラグを使うと、クレートごとのビルド時間を確認できます。とても便利です。

以下にいくつか例を挙げます。

クレート代替
serdeminiserde, nanoserde
reqwestureq
claplexopt

クレートを置き換えることで、コンパイル時間が2分22秒から26秒に短縮された例もあります。

ワークスペースを使って大きなクレートを分割する

Cargoにはワークスペースという便利な機能があり、大きなクレートを複数の小さなクレートに分割できます。この分割により、変更のあったクレートだけを再コンパイルすればよくなるため、無駄なコンパイルを避けられます。servovectorのような大規模プロジェクトでは、コンパイル時間を短縮するためにワークスペースが多用されています。

クレート依存の未使用フィーチャーを無効にする

cargo-features-managerは、依存クレートの未使用フィーチャーを無効にするのに役立つ比較的新しいツールです。

cargo install cargo-features-manager
cargo features prune

ときどき、依存クレートのフィーチャーフラグを確認してみましょう。多くのライブラリ作者は、必要に応じてオン・オフできるようにクレートを個別のフィーチャーに分割しています。すべてのクレートのデフォルト機能が本当に必要でしょうか?

例えばtokioには多数のフィーチャーがあり、不要なものは無効にできます。

別の例としてbindgenは、バイナリ用途のためにデフォルトでclapサポートを有効にしています。これはライブラリとして使う場合には不要で、一般的な使い方でもあります。このフィーチャーを無効にしたところ、rust-rocksdbのコンパイル時間がデバッグビルドで約13秒、リリースビルドで約9秒短縮されました。教えてくださった読者のLilian Anatolie Moraru氏に感謝します。

注意

フィーチャーをオフにしても、必ずしもコンパイル時間が改善するとは限らないようです(tikvでの事例はこちらをご覧ください)。それでも、攻撃対象領域を減らしてセキュリティを向上させる意味では良い考えです。また、フィーチャーを無効にすることで依存ツリーをスリム化できます。

クレートのフィーチャー一覧は、cargo addでインストールする際に確認できます。

クレートのフィーチャーフラグを調べたい場合は、docs.rsに掲載されています。例えばtokioのフィーチャーフラグをご覧ください。

未使用のフィーチャーを削除したら、Cargo.lockファイルの差分を確認して、不要な依存がどれだけ整理されたか見てみましょう。

コストの高いコードにフィーチャーを追加する

[features]
# デフォルト機能の基本フィーチャー
default = []

# JSONサポート用のオプショナルなフィーチャー
json = ["serde_json"]

# よりコストの高い、あるいは複雑なコード用の別フィーチャー
complex_feature = ["some-expensive-crate"]

プロジェクト内のすべてのコードが同じようにコンパイルコストが高いわけではありません。Cargoのフィーチャーを使えば、クレートよりも細かい粒度でコードを小さな塊に分割できます。そうすれば、必要な機能だけをコンパイルできます。

これはライブラリでは一般的な手法です。例えばserdeには、シリアライズ/デシリアライズ用のコード生成を有効にするderiveというフィーチャーがあります。常に必要なわけではないため、デフォルトでは無効になっています。同様にTokioreqwestにも、有効・無効を切り替えられる多くのフィーチャーがあります。

自分のコードでも同じことができます。上の例では、Cargo.tomljsonフィーチャーがJSONサポートを、complex_featureフィーチャーが別のコストの高いコードパスを有効にします。

再ビルドの根本原因を見つける

コードを変更していないのに多くのクレートが再ビルドされることがあります。多くの場合、ビルドプロセス間で環境変数が異なっていることが原因です(Makefileでのビルド、rust-analyzer、CIビルドなどで環境が異なる場合など)。

cargoのフィンガープリントログを使って、再ビルドがトリガーされた正確な理由を特定しましょう。

export CARGO_LOG="cargo::core::compiler::fingerprint=info"
export RUST_LOG=trace
cargo build -vv

何が再ビルドを引き起こしたかを示す行を探してください。例えば次のような行です。

INFO prepare_target: cargo::core::compiler::fingerprint: dirty: EnvVarChanged { name: "VIRTUAL_ENV", old_value: None, new_value: Some("/path/to/.venv") }

Dirty pyo3-build-config v0.24.1: the env variable VIRTUAL_ENV changed

この「Dirty」という行が見えますか?何が再ビルドを引き起こしたかを正確に教えてくれています。この行をgrepすれば、すべての再ビルド原因を見つけられます。

よくある原因は次のとおりです。

  • 環境変数(CC、CXX、VIRTUAL_ENV、PATHの変更)
  • 異なるビルドツール間でのフィーチャーフラグの不一致
  • 異なるcargoプロファイルの使用
  • 生成されたファイルのタイムスタンプの違い

原因を特定できたら、すべてのビルドプロセスで一貫性を保つようにしましょう。VS Codeのrust-analyzerでは、.vscode/settings.jsonで環境変数を揃えることができます。

{
  "rust-analyzer.check.extraEnv": {
    "CC": "clang",
    "CXX": "clang++", 
    "VIRTUAL_ENV": "/path/to/your/.venv"
  },
  "rust-analyzer.cargo.features": "all"
}

このデバッグ手法により、異なるツール間で環境が不整合な場合に発生する不要な再ビルドを大幅に減らせます。

クレジット: この手法はこちらの記事(著者: Ishan Bhanuka氏)で紹介されていました。

sccacheで依存をキャッシュする

もう一つの便利なプロジェクトが、Mozilla製のsccacheです。コンパイル済みのクレートをキャッシュして、繰り返しのコンパイルを避けます。

しばらく自分のノートPCで使ってみましたが、正直なところ効果はごくわずかでした。同じバージョンの依存を共有する多数の独立したプロジェクトで作業する場合に最も効果を発揮します。共有ビルドサーバーが典型的なユースケースです。

Cranelift: 代替Rustコンパイラ

Rustプロジェクトが、すべてのCIビルドでrustcと並行して別のコンパイラを実行していることをご存知ですか?

rustc_codegen_craneliftCG_CLIFとも呼ばれます)は、CraneliftコンパイラフレームワークをベースにしたRustコンパイラの実験的なバックエンドです。

rustcとCraneliftの比較を、人気のクレートで見てみましょう(青はCraneliftが優れていることを示します)。

rustcとCraneliftのLLVMコンパイル時間比較(Craneliftが有利)

このコンパイラは完全に動作する実行バイナリを生成します。最適化の度合いは劣りますが、ローカルでの開発には最適です。

より詳しい解説はJason Williams氏のページに、プロジェクトのコードはGitHubにあります。

より高速なリンカに切り替える

リンカとは?

リンカは、複数のオブジェクトファイルを一つの実行ファイルにまとめるツールです。
コンパイルプロセスの最後のステップを担います。

リンカがボトルネックになっているかは、次のコマンドで確認できます。

cargo clean
cargo +nightly rustc --bin <your_binary_name> -- -Z time-passes

各ステップの実行時間が出力され、リンク時間も含まれます。

...
time:   0.000   llvm_dump_timing_file
time:   0.001   serialize_work_products
time:   0.002   incr_comp_finalize_session_directory
time:   0.004   link_binary_check_files_are_writeable
time:   0.614   run_linker
time:   0.000   link_binary_remove_temps
time:   0.620   link_binary
time:   0.622   link_crate
time:   0.757   link
time:   3.836   total
    Finished dev [unoptimized + debuginfo] target(s) in 42.75s

linkステップが遅い場合は、より高速な代替に切り替えてみましょう。

リンカプラットフォーム本番利用可能説明
lldLinux/macOSはいシステムリンカのドロップイン置換
moldLinuxはいLinux向けに最適化
zldmacOSいいえ(非推奨)Appleのldリンカのドロップイン置換

macOS限定: 増分デバッグビルドを高速化する

Rust 1.51では、macOSで増分デバッグビルドを高速化するフラグが追加されました。ユースケースによっては、デバッグビルドが数秒速くなります。あるエンジニアはこのフラグだけでmacOSでのコンパイル時間が70%短縮されたと報告しています。

Cargo.tomlに次の設定を追加してください。

[profile.dev]
split-debuginfo = "unpacked"

このフラグは近いうちにmacOSの標準になるかもしれません。すでにnightlyではデフォルトになっています。

macOS限定: RustのコンパイルをGatekeeperの対象から除外する

GatekeeperはmacOSのセキュリティチェック機能で、バイナリに対して検査を行います。これにより、Rustのビルドが1回あたり数秒遅くなることがあります。解決策は、使っているターミナルをDeveloper Toolsに追加することで、そのターミナルから実行されるプロセスをGatekeeperの検査対象から除外できます。

  1. ターミナルでsudo spctl developer-mode enable-terminalを実行します。
  2. システム設定を開き、「セキュリティとプライバシー」に進みます。
  3. 「プライバシー」タブでDeveloper Toolsを選択します。
  4. 使っているターミナルがリストに表示され、有効になっていることを確認します。iTermやGhosttyなどのサードパーティ製ターミナルを使っている場合は、それらもリストに追加してください。
  5. ターミナルを再起動します。

macOSのDeveloper ToolsでターミナルをGatekeeperの検査対象から除外する設定

このヒントはnextestZedの開発者の皆さんからいただきました。

Windows限定: Rust用にDev Driveを設定する

Windows 11には、開発向けに最適化されたファイルシステムであるDev Driveがあります。Microsoftによると、Dev Driveを使うことで20〜30%程度の速度向上が期待できるとのことです。

Dev Driveのパフォーマンス比較チャート

Rustのコンパイル速度を向上させるには、以下をDev Driveに移動してください。

  • Rustツールチェインフォルダ(CARGO_HOME
  • プロジェクトのコード
  • Cargoのtargetディレクトリ

さらに一歩進めて、上記のフォルダをウイルス対策ソフトの除外対象にも追加すると、さらに高速化が期待できます。除外設定は、Windowsセキュリティの「ウイルスと脅威の防止」の設定から行えます。

Windowsのウイルス対策除外設定

このヒントはnextestチームからいただきました。

コード生成オプションとコンパイラフラグを調整する

Rustにはコード生成に関する膨大な設定が用意されています。一覧を眺めて、プロジェクトに合わせてパラメータを調整してみる価値があります。

コード生成オプションの完全なリストには、多くの掘り出し物があります。参考までに、Bevyの高速ビルド用設定をご覧ください。

手続き型マクロのクレートを避ける

プロジェクトで手続き型マクロを多用している場合(例えばserdeを使っている場合)、Cargo.tomlで最適化レベルを調整してみる価値があります。

[profile.dev.build-override]
opt-level = 3

読者のjfmontanaro氏がGitHubで次のように指摘しています。

ビルド時間が改善する理由は、この設定がビルドスクリプトと手続き型マクロにのみ適用されるためだと思います。ビルドスクリプトと手続き型マクロは、通常のビルドではコンパイルされるだけでなく実行もされ(手続き型マクロの場合は繰り返し実行されることもあります)、プロジェクトで多くの手続き型マクロを使っている場合、マクロ自体を最適化することで理論上多くの時間を節約できるからです。

もう一つのアプローチとして、wattを使ってマクロのコンパイル負荷をWebAssemblyにオフロードする方法もあります。

ドキュメントからの引用です。

マクロを事前にWasmへコンパイルしておくことで、 downstreamのユーザーはマクロのロジックやその依存関係自体をコンパイルする必要がなくなります。

代わりにコンパイルされるのは、小さく自己完結したWasmランタイム(約3秒、すべてのマクロで共有)と、マクロクレートごとにWasmバイトコードをWattランタイムに渡すための小さな手続き型マクロ用シム(依存する手続き型マクロクレートごとに約0.3秒)だけです。これは、複雑な手続き型マクロとその依存関係のコンパイルに20秒以上かかる場合があるのに比べれば、はるかに短い時間です。

なお、このクレートはまだ実験的な段階です。

コストの高い手続き型マクロを見つける

RUSTFLAGS="-Zmacro-stats" cargo +nightly build

一部のマクロはコンパイル時間に大きなコストがかかりますが、具体的にどれくらいでしょうか?コストを定量化することで、そのマクロを最適化したり削除したりする価値があるか判断しやすくなります。生成されるコード量を把握する方法の一つです。

一つの方法はcargo expandで生成されたコードを見ることですが、大規模なコードベースではスケールせず、出力の定量化も困難です。

代替として、-Zmacro-statsフラグを使って大量のコードを生成している手続き型マクロを特定する方法があります。このツールはすでにBevyArbitraryといったプロジェクトで最適化に役立っています。

詳しくは、Nicholas Nethercote氏のブログ記事をご覧ください。

手続き型マクロの条件付きコンパイル

手続き型マクロはRustのコードを解析する必要があり、これは比較的複雑な処理です。手続き型マクロに依存するクレートは、その手続き型マクロがコンパイルされるまで待たなければなりません。例えばserdeはコンパイル時間のボトルネックになり、CPU使用率を制限する要因にもなり得ます。

Rustのコンパイル時間を改善するために、特に共有クレート構造を持つプロジェクトでは、Serdeによるシリアライズを戦略的に扱うことを検討してください。プロジェクト全体で使われる共有クレートにSerdeを直接置くのではなく、Cargoのフィーチャーを通じてオプショナルな依存にする方法です。

cfgcfg_attr属性を使って、共有クレートでのSerdeの使用やderiveをフィーチャーゲートにします。そうすることで、実際にシリアライズ/デシリアライズを行うリーフクレートでのみ有効になるオプショナルな依存にできます。

このアプローチにより、Serdeが共有クレートの非オプショナルな直接依存である場合に起こる、プロジェクト全体がSerde依存のコンパイルを待たされる事態を防げます。

簡単な例で説明しましょう。共有ライブラリクレートと、それに依存するいくつかのクレートからなるRustプロジェクトがあるとします。必要のない部分のビルドでSerdeを不必要にコンパイルしたくはありません。

Cargoのオプショナルフィーチャーを使ってプロジェクトを構成する方法は次のとおりです。

共有クレートのCargo.tomlで、serdeをオプショナルな依存として宣言します。

[package]
name = "shared"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", optional = true }

このクレートでは、フィーチャーが有効な場合のみserdeを含むように条件付きコンパイルを使います。

#[cfg(feature = "serde")]
use serde::{Serialize, Deserialize};

#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MySharedStruct {
    // 構造体のフィールド
}

他のクレートでは、必要に応じて共有クレートのserdeフィーチャーを有効にします。

[package]
name = "other"
version = "0.1.0"
edition = "2021"

[dependencies]
shared = { path = "../shared", features = ["serde"] }

これで、MySharedStructをSerdeの機能を有効にした状態で使える一方、それを必要としないクレートのコンパイルを肥大化させることもありません。

ジェネリクス: 内部で非ジェネリックな関数を使う

ジェネリックな関数がある場合、使われる型ごとにコンパイルされます。異なる型を多く使っていると問題になることがあります。

よくある解決策は、内部で非ジェネリックな関数を使う方法です。そうすれば、コンパイラは内部の関数を一度だけコンパイルすればよくなります。

これは標準ライブラリでもよく使われているテクニックです。例えばread_to_stringの実装を見てみましょう。

pub fn read_to_string<P: AsRef<Path>>(path: P) -> io::Result<String> {
    fn inner(path: &Path) -> io::Result<String> {
        let mut file = File::open(path)?;
        let size = file.metadata().map(|m| m.len() as usize).ok();
        let mut string = String::with_capacity(size.unwrap_or(0));
        io::default_read_to_string(&mut file, &mut string, size)?;
        Ok(string)
    }
    inner(path.as_ref())
}

自分のコードでも同じことができます。外側の関数はジェネリックにしつつ、実際の処理は内側の非ジェネリックな関数に任せるのです。

cargo-hakariでワークスペースのビルド時間を改善する

次のような依存を持つ大規模なRustワークスペースはありませんか。

  1. 複数のクレートで使われている
  2. クレートごとに異なるフィーチャーセットで使われている

この状況では、どのクレートをビルドするかによってcargoが同じ依存を異なるフィーチャーで複数回ビルドするため、ビルド時間が長くなることがあります。そこで役立つのがcargo-hakariです。「workspace-hack」クレートを自動的に管理するためのツールです。

場合によっては、これにより連続ビルドの時間を50%以上短縮できます。使い方やベンチマークについて詳しくは公式のcargo-hakariドキュメントをご覧ください。

dylibで増分コンパイルを高速化する

# ツールをインストール
cargo install cargo-add-dynamic

# プロジェクトに動的ライブラリを追加
cargo add-dynamic polars --features csv-file,lazy,list,describe,rows,fmt,strings,temporal

これにより、polarsをラップするクレートが動的ライブラリ(Linuxでは.so、macOSでは.dylib、Windowsでは.dll)としてコンパイルされるようになります。

基本的には、依存に対して次のようにパッチを当てます。

[lib]
crate-type = ["dylib"]

このテクニックを使えば、自分のコードだけを変更したときに依存クレートのリンク時間を節約できます。依存自体は、フィーチャーやバージョンを変更したときだけ再コンパイルされます。もちろん、polarsだけでなく任意のクレートで利用できます。

詳しくはRobert Krahn氏のブログ記事ツールのホームページをご覧ください。

新しい並列コンパイラフロントエンドに切り替える

nightly限定ですが、新しい並列コンパイラフロントエンドを有効にできます。試すには、nightlyコンパイラを-Z threads=8オプション付きで実行します。

RUSTFLAGS="-Z threads=8" cargo +nightly build

うまく動作するようであれば、~/.cargo/config.tomlファイルに-Z threads=8を追加してデフォルトにできます。

[build]
rustflags = ["-Z", "threads=8"]

あるいは、シェルの設定ファイル(例: ~/.bashrc~/.zshrc)でcargoのエイリアスを設定する方法もあります。

alias cargo="RUSTFLAGS='-Z threads=8' cargo +nightly"

フロントエンドを-Z threads=8でマルチスレッド実行した場合、実際のコードでのベンチマークではコンパイル時間が最大で50%短縮されることが示されています。ただし、効果はコンパイルするコードによってばらつきがあります。試してみる価値は十分にあります。

並列コンパイラフロントエンドが動作している様子の可視化がこちらです。

並列コンパイラの実行結果

詳しくはRust公式ブログの発表をご覧ください。

ビルドを高速化するためにスクラッチディスクを使う

ファイルシステムがボトルネックになっているかもしれません。ビルドディレクトリ用にインメモリファイルシステムの利用を検討してみてください。

tmpfsのような従来の一時ファイルシステムは、RAMとスワップの合計容量に制限され、大きな中間成果物を生成するビルドでは問題になることがあります。

代わりにLinuxでは、次のオプションでext4ボリュームをマウントします。

-o noauto_da_alloc,data=writeback,lazytime,journal_async_commit,commit=999,nobarrier

十分なRAMがあればファイルはページキャッシュに保存され、書き込みは後で実行されます。クラッシュや電源断の後にはデータが失われたり破損したりする可能性があるため、一時的なファイルシステムとして扱ってください。

クレジット: Redditの/u/The_8472氏です。

より良いハードウェアに投資する

ここまで来たら、コンパイル時間をさらに改善する最も手っ取り早い方法は、ハイエンドなハードウェアにお金をかけることかもしれません。

ノートPCなら、Appleの新しいMacBookのMシリーズがRustのコンパイルで非常に優れたパフォーマンスを発揮します。

TwitterでのRik Arends氏の投稿

M1 Max搭載MacBook Proのベンチマークは、すでに高速なM1と比較しても驚異的です。

プロジェクトM1 MaxM1 Air
Deno6分11秒11分15秒
MeiliSearch1分28秒3分36秒
bat43秒1分23秒
hyperfine23秒42秒
ripgrep16秒37秒

実に2倍のパフォーマンス向上です。

一方でLinuxを使い続けたい場合は、AMD Ryzen Threadripperと32GBのRAMのようなマルチコアCPUでも大きな成果が報告されています。

ポータブルデバイスでは、コンパイルでバッテリーを消耗し、速度も遅くなりがちです。私は自宅にある6コアのAMD FX 6300(RAM 12GB)のマシンをビルドマシンとして使っています。Visual Studio Code Remote Developmentと組み合わせて使えます。

クラウドでコンパイルする

専用のマシンを持っていない場合は、コンパイル処理をクラウドにオフロードする方法もあります。GitHub Codespacesを使えば、GitHub内で完全に設定済みのクラウド開発環境を利用できます。任意のリポジトリから「Code」ボタンをクリックし、「Open with Codespaces」を選択するだけでcodespaceを立ち上げられます。マシンサイズは2コアから32コアまで選べ、すべてのGitHubアカウントには無料の月間利用枠が付いています(現在は2コアマシンで月60時間)。

Codespacesは.devcontainer/devcontainer.jsonファイルによるdev container設定をサポートしています。Rustツールチェインやその他の依存関係を事前にインストールしておけるため、環境が起動した瞬間からすぐに使えます。プルリクエストをレビューする際に特に便利で、PRを直接codespaceで開けば、すべてがセットアップされた状態で作業できます。

ビルド環境をより細かく制御したい場合は、Northflankも選択肢の一つです。専用のクラウドインフラで任意のビルドジョブを実行でき、プランは共有の0.1 vCPUコンテナから32 vCPUの専有マシンまで幅広く用意されています。無料のサンドボックスプランがあり、有料プランは実際のビルド時間に対して秒単位で課金されるため、使った分だけ支払えば済みます。

すべてのクレートをローカルにキャッシュする

インターネット接続が遅い場合、初回ビルドの大部分はcrates.ioからクレートを取得する時間に費やされます。これを緩和するために、すべてのクレートを事前にダウンロードしてローカルにキャッシュしておくことができます。crinerがまさにそれを実現します。

git clone https://github.com/the-lean-crate/criner
cd criner
cargo run --release -- mine

アーカイブサイズは驚くほど手頃で、必要なディスク容量は約50GBです(現時点)。

テストの実行

cargo testの代わりにcargo nextestを使う

cargo install cargo-nextest
cargo nextest run

cargoには標準で小さなテストランナーが付属していますが、特に複数のテストバイナリをビルドする必要がある場合、cargo nextestは並列実行モデルのおかげでcargo testより最大60%高速になることがあります。簡単なベンチマークをいくつか紹介します。

プロジェクトリビジョンテスト数cargo test(秒)nextest(秒)改善率
cruciblecb228c2b4835.141.523.38倍
guppy2cc51b412716.422.802.29倍
mdBook0079184c1993.851.662.31倍
meilisearchbfb1f92772157.0428.991.96倍
omicrone7949cd1619444.08202.502.19倍
penumbra4ecd94cc144125.3890.961.37倍
reqwest3459b8941135.572.262.48倍
ring450ada2817913.129.401.39倍
tokio1f50c571113824.2711.602.09倍

すべての結合テストを単一のバイナリにまとめる

結合テストはありますか?(testsフォルダにあるテストのことです。)Rustコンパイラは、その一つひとつに対してバイナリを作成することをご存知ですか?そして各バイナリは個別にリンクされる必要があります。リンクは非常に遅いため、これがビルド時間の大部分を占めることがあります。🐢 その理由は、多くのシステムリンカ(ldなど)がシングルスレッドだからです。

リンカの仕事を少し楽にするために、すべてのテストを一つのクレートにまとめることができます。(基本的にはテストフォルダにmain.rsを作成し、そこに各テストファイルをmodとして追加します。)

そうすれば、リンカは単一のバイナリだけをビルドすればよくなります。良さそうに聞こえますが、注意点もあります。内部の型や関数を公開(pubにする)必要が出てくるというトレードオフです。

結合テストが大量にある場合、50%の高速化につながることもあります。

このヒントはLuca Palmieri氏、Lucio Franco氏、Azriel Hoh氏からいただきました。ありがとうございます!

遅いテストを環境変数の背後に置く

#[test]
fn completion_works_with_real_standard_library() {
  if std::env::var("RUN_SLOW_TESTS").is_err() {
    return;
  }
  ...
}

遅いテストがある場合は、環境変数の背後に置いてデフォルトでは無効にできます。そうすれば、ローカルではスキップし、CIでのみ実行できます。

(この小技はmatklad(Alex Kladov)氏の記事で学びました。)

CIビルド

CIビルドのヒント

この記事で紹介した多くのテクニックはCIビルドにも適用できます。CIに特化した最適化やベストプラクティスについては、キャッシュ戦略、ワークフローの最適化、GitHub Actions固有の改善などをまとめたTips for Faster CI Buildsの専用ガイドもご覧ください。

依存のキャッシュを使う

特にGitHub ActionsではSwatinem/rust-cacheも利用できます。

ワークフローに1ステップ追加するだけで使えます。

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --all

これにより、ビルド間で依存がキャッシュされ、大幅な高速化が期待できます。

コンパイルとテストのステップを分ける

- name: Compile
  run: cargo test --no-run --locked

- name: Test
  run: cargo test -- --nocapture --quiet

こうすることで、コンパイルにどれだけ時間がかかり、テストの実行にどれだけかかっているかを把握しやすくなります。

CIでは増分コンパイルを無効にする

env:
  CARGO_INCREMENTAL: 0

CIビルドはクリーンビルドに近いため、増分コンパイルは不要な依存関係の追跡やI/Oのオーバーヘッドを増やし、キャッシュの効率を下げてしまいます。無効化する方法はこちらをご覧ください。

デバッグ情報を無効にする

[profile.dev]
debug = 0
strip = "debuginfo"

ビルドを高速化するために、特にデバッガをほとんど使わない場合はデバッグ情報のリンクを避けましょう。デバッグ情報のリンクを避ける方法は2つあります。debug=0でコンパイル自体をスキップする方法と、strip="debuginfo"でリンクをスキップする方法です。残念ながら、これらのオプションを変更するとCargoでフルリビルドが発生する場合があります。

  • Linuxでは、両方を設定するとビルド時間が改善します。
  • Macでは、rustcが外部のstripコマンドを使うためdebug=0を使ってください。
  • Windowsでは、どちらが速いか両方の設定を試してみてください。

デバッグ情報がない場合、バックトレースには行番号ではなく関数名のみが表示される点に注意してください。必要であれば、妥協案としてsplit-debuginfo="unpacked"を使ってください。

副次的な効果として、./targetのサイズも小さくなり、キャッシュ効率の向上にもつながります。

設定の適用方法についてはこちらのサンプル設定をご覧ください。

環境変数で警告をエラーにする

コード内で#![deny(warnings)]を繰り返し記述するのは避けましょう。また、ローカル開発中に警告が出ること自体は問題ありません。

代わりに、CIではすべてのクレートで警告をエラーとして扱うためにRUSTFLAGS-D warningsを追加しましょう。

env:
  RUSTFLAGS: -D warnings

より高速なGitHub Actionsランナーに切り替える

- runs-on: ubuntu-latest
+ runs-on: ubicloud

UbicloudBuildJetRunsOnのようなサービスでは、GitHub Actions用のより高速なワーカーを利用できます。特にRustのパイプラインではコア数がコンパイル時間に大きく影響するため、試してみる価値があります。

こちらはFacebook FollyプロジェクトがUbicloudを使った例です。C++プロジェクトの例ではありますが、より高速なランナーの可能性を示しています。

facebook/follyのビルド時間

サービスに登録した後は、GitHub Actionsのワークフローファイルでランナーを変更するだけで使えます。

Dockerビルドの高速化

cargo-chefでDockerビルドを高速化する

RustのコードからDockerイメージをビルドしていますか?cargoがまだプロジェクトの依存関係だけをビルドする機能をサポートしていないため、注意しないと毎回Dockerキャッシュが無効化され、非常に遅くなることがあります。そこでcargo-chefの出番です!⚡

cargo-chefを使えばDockerのレイヤーキャッシュを最大限に活用できるため、RustプロジェクトのDockerビルドを大幅に高速化できます。約1万4千行、約500の依存を持つ商用コードベースでは、5倍の高速化を計測しました。Dockerのビルド時間を約10分から約2分に短縮できました。

興味があれば、Dockerfileの例をどうぞ。

# Step 1: Compute a recipe file
FROM rust as planner
WORKDIR app
RUN cargo install cargo-chef
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# Step 2: Cache project dependencies
FROM rust as cacher
WORKDIR app
RUN cargo install cargo-chef
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json

# Step 3: Build the binary
FROM rust as builder
WORKDIR app
COPY . .
# Copy over the cached dependencies from above
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo
RUN cargo build --release --bin app

# Step 4:
# Create a tiny output image.
# It only contains our final binary.
FROM rust as runtime
WORKDIR app
COPY --from=builder /app/target/release/app /usr/local/bin
ENTRYPOINT ["/usr/local/bin/app"]

cargo-chefは、GitHub Actionsでの継続的インテグレーションやGoogle Cloudへのデプロイの高速化にも役立ちます。

より良いビルドキャッシュのためにEarthlyを検討する

Earthlyは、MakefileやDockerfileなどのビルドツールを置き換えるために設計された比較的新しいビルドツールです。CIで高速かつ増分なRustビルドを提供します。

Earthlyは、CargoのキャッシュとRustの増分コンパイルを効果的に実装することで、CIでのRustビルドを高速化します。このアプローチにより、ローカルでのRustビルドと同様の効率で、CIでの不要な再ビルドを大幅に削減できます。

出典: Earthly for Rust

EarthlyではSatellitesという仕組みを使っています。これはキャッシュデータをローカルに保持し続ける永続的なリモートビルド実行環境です。キャッシュのアップロードやダウンロードをなくすことでCIのビルド時間を大幅に短縮できます。キャッシュデータをコンピュート側に持ってくるのではなく、キャッシュデータとコンピュートを同じ場所に配置することで、キャッシュ転送自体をなくしています。I/Oが減ればビルドは速くなります。

Earthlyはlib/rustライブラリも提供しており、キャッシュ設定を完全に抽象化します。CIでRustが正しくキャッシュされ、増分ビルドされることを保証します。Earthfileでは次のように使えます。

IMPORT github.com/earthly/lib/rust

興味があれば、EarthlyのRustガイドで、キャッシュとコンパイルのステップが最適化されたシンプルなRustの例が詳しく解説されています。

IDE固有の最適化

開発環境でビルド時間が遅いと感じる場合は、以下のヒントも試してみてください。

Visual Studio Codeでデバッグセッションが遅い場合

Visual Studio Codeを使っていてデバッグセッションが遅い場合は、ブレークポイントを設定しすぎていないか確認してください。ブレークポイント1つひとつがデバッグセッションを遅くする可能性があります

関係のないプロジェクトを閉じる

Visual Studio Codeで複数のプロジェクトを開いている場合、それぞれが独自のrust-analyzerのコピーを実行しています。これがマシンを遅くする原因になることがあります。不要なプロジェクトは閉じておきましょう。

rust-analyzerのキャッシュ無効化を修正する

VS Codeでrust-analyzerを使っていて、変更を保存した際にビルドが遅くなったり、serdeのような依存が頻繁に再ビルドされたりする場合は、キャッシュが無効化されている可能性があります。

rust-analyzer用に別のtargetディレクトリを設定することで修正できます。VS Codeの設定(できればユーザー設定)に以下を追加してください。

{
    "rust-analyzer.cargo.targetDir": true
}

これにより、rust-analyzerはデフォルトのtarget/ディレクトリではなくtarget/rust-analyzer内でビルドするようになり、通常のcargo runビルドとの干渉を防げます。

この設定のおかげで大幅に高速化したという報告もあります。

before: 34.98s user 2.02s system 122% cpu 30.176 total
after:   2.62s user 0.60s system 84% cpu 3.803 total

これはrust-analyzerがデバッグビルドをブロックする問題の解決にも役立つかもしれません。

クレジット: このヒントはRedditのasparck氏から共有いただきました。

まとめ

この記事では、多くの内容を扱ってきました。より良いハードウェアを使ったり、コードを最適化したり、より良いツールを使ったりすることで、Rustのビルドを高速化する方法を見てきました。

CIに特化した最適化については、ここで紹介したテクニックを補完するTips for Faster CI Buildsもぜひご覧ください。

この記事のヒントのいくつかが、Rustのビルド高速化に役立ったことを願っています。ほかにビルドを速くする方法を見つけたり、質問やフィードバックがあったりした場合は、ぜひお聞かせください。

追加リソース

原文は Matthias Endler により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。