- Rustコンパイラの並列化はこれまで主にCargoとLLVMバックエンドに依存していたが、今回 フロントエンドの並列実行 も加わり、ビルド終盤のボトルネックを減らせるようになった
- nightlyでは
-Z threads=8 で実験的機能を有効化できるが、デフォルトは依然として シングルスレッドモード のため、明示的に設定しない限り速度改善はない
- 新しいフロントエンドは Rayon ベースで細かなコンパイル作業を分割し、例の計測ではフロントエンド時間が10.2秒から5.9秒に短縮された
- 実際のコード計測ではコンパイル時間が最大 50%短縮 したが、コード特性・ビルド設定によるばらつきが大きく、メモリ使用量は最大35%増える可能性がある
- 機能はまだ実験段階で、
-Z threads の安定化とstableでのマルチスレッド既定実行を 2024年 目標で進めている
Rustコンパイル並列化の新たな軸
- Rustコンパイラのフロントエンドは、並列実行によってコンパイル時間を短縮できるようになった
- nightlyコンパイラで
-Z threads=8 オプションを指定すると、マルチスレッドフロントエンド を試せる
- この機能はまだ実験的で、stableコンパイラに含まれる目標時期は 2024年 である
既存の最適化と残るボトルネック
Cargoとバックエンド並列化の限界
- CargoはRustプログラムをビルドする際、複数の
rustc プロセスを実行してcrateを並列コンパイルする
-j1 フラグでこの並列化を無効にすると、大きなRustプログラムのコンパイル時間は大幅に増える
- Cargoの
--timings フラグはcrateコンパイルのタイムライン図を生成する
- ripgrep を28仮想コアのマシンでビルドした例では、60本のプロセス行が現れる
- 大半は
rustc で、一部はbuild scriptである
- 最初の20プロセスはcrate間依存がないため同時に開始できる
- ビルド終盤になるほどcrate依存が増え、並列性は低下する
- pipelined compilation により依存crateのコンパイルをある程度重ねられるが、大きなRustプログラムではビルド終盤の並列実行はかなり少なくなる
フロントエンドとバックエンドの役割
- Rustコンパイラは大きく フロントエンド と バックエンド に分かれる
- フロントエンドはパース、型検査、borrow checking などを担当する
- 従来のフロントエンドでは並列実行を使えなかった
- バックエンドはコード生成を担当し、“codegen units” 単位でコードを生成した後、LLVMがこれを並列処理する
- リリースビルドのデフォルトのcodegen unit数は16であるため、例のプロファイルでは16本のLLVMスレッドが現れる
既存バックエンドプロファイルで見えたボトルネック
- Samply でCargoの最後のcrateをリリースビルド時に計測した例では、フロントエンドに 10.2秒 かかった
- バックエンドには 6.2秒 かかり、LLVMスレッドはそのうち 5.9秒 実行された
- LLVMベースの並列コード生成は効果的だが、28コアのマシンでも16本のLLVMスレッドが常に同時実行されていたわけではない
- main threadがMIRをLLVM IRへ変換する処理を直列に行うため、codegen threadの開始部分に階段状の形が現れた
- 完全に直列で動作していたフロントエンドが、最大の改善ポイントとして残っていた
新しい並列フロントエンドの実装方式
- 新しいフロントエンドは Rayon を使い、細粒度の並列コンパイル処理を行う
- 複数のデータ構造はmutexとread-write lockで同期し、必要な箇所ではatomic型を使う
- 多くのフロントエンド作業が並列化されたが、変更は比較的少数の重要な箇所に集中している
- フロントエンドコードの大部分は変更不要だった
8スレッド設定での計測結果
- 並列フロントエンドを有効にして8スレッドを使った同じ例では、フロントエンド実行時間は 10.2秒から5.9秒 に短縮された
- バックエンド実行時間は 6.2秒から5.3秒 に、LLVMスレッド実行時間は 5.9秒から4.9秒 に減少した
- フロントエンドでは
rustc と表示された追加の7スレッドが動作する
- スレッド利用は均一ではなく、8スレッドすべてにアイドル区間があり、さらなる改善余地が残っている
- 8本のLLVMスレッドが同時に開始するのは、8本の
rustc スレッドが8つのcodegen unitのLLVM IRを並列生成するためである
- フロントエンドのスレッド数を16にすると階段状の形は完全に消えるが、そのケースの最終実行時間はほとんど変わらない
プロセス間並列化とプロセス内並列化の組み合わせ
- Rustコンパイルは以前から、Cargoの プロセス間並列化 とバックエンドのプロセス内並列化の恩恵を受けてきた
- これでフロントエンドもプロセス内並列化の利点を活用できるようになった
- 複数の
rustc プロセスが同時に走り、各プロセスが複数スレッドを作る場合は、jobserver protocol がスレッド数を制限する
- プロセス間並列性が高い場合、プロセス内並列性はそれに応じて下がり、総スレッド数はコア数を超えない
使い方
- nightlyコンパイラには並列フロントエンドが 含まれている
- デフォルトは シングルスレッドモード のため、そのままではコンパイル時間は短縮されない
- マルチスレッドモードは
-Z threads オプションで明示的に有効化する必要がある
RUSTFLAGS="-Z threads=8" cargo build --release
- 1つ以上のプロジェクトで
config.toml に設定するには、次を追加する
[build]
rustflags = ["-Z", "threads=8"]
- シングルスレッドモードがデフォルトなのは、慎重な展開のためである
- 並列フロントエンドには新しいコードが多い
- シングルスレッドモードでは新コードの大半を実行できる一方、deadlockのようなスレッド起因のバグ可能性は除外できる
- 並列プログラムはRustであっても直列プログラムより正しく書くのが難しい
- そのため並列フロントエンドは当面、betaやstableリリースには含まれない
性能とメモリへの影響
- シングルスレッドモードでは、並列フロントエンドは従来の直列フロントエンドより一般に 0%〜2% 遅い
-Z threads=8 のマルチスレッドモードでは、実コードでの計測 でコンパイル時間が最大 50% 短縮される可能性がある
- 性能効果はコード特性やビルド設定によって大きく変わる
- 開発ビルドはリリースビルドより大きな改善を得られる可能性がある
- リリースビルドは通常、バックエンド最適化により多くの時間を使うためである
- すでに高速にコンパイルできる小さなプログラムの一部では、マルチスレッドモードのほうがシングルスレッドモードより遅くなる場合がある
- 推奨値は 8スレッド である
- 最も多くテストされた設定で、良い結果が出ることが分かっている
- 8未満の値は利点が小さいが、8コア未満のハードウェアには適している
- 8を超える値は逓減があり、性能が悪化する可能性もある
- 1スレッドから8スレッドへ増やしても改善が50%程度にとどまるのは、フロントエンドが全コンパイル時間の一部であり、バックエンドはすでに並列化されているためである
- マルチスレッドモードではメモリ使用量が大きく増えることがあり、最大 35% の増加が観測されている
正確性とフィードバック
2024年stable目標
- 並列フロントエンドの性能改善作業は進行中である
- プロファイルで見えたように、フロントエンドスレッドの活用率にはまだ改善余地がある
- マルチスレッドモードに残るバグも整理中である
-Z threads オプションの安定化と、stableリリースで並列フロントエンドをマルチスレッドデフォルトとして提供する目標時期は 2024年 である
1件のコメント
Hacker News のコメント
まだ初期段階なのは分かっているが、Rust の弱点はコンパイル速度だと思う
Rust のモノレポで働いたことがあるが、一番の不満はコンパイル速度で、CI/CD コストを押し上げ、キャッシュを消さなければならないときは開発時間も大きく遅くなった
原因は Cargo ではなく Docker のバグだったが、それでもこうした進展は歓迎したい
すでにかなり最適化されていて、現在の Rust コンパイラはほぼすべての主要コンパイラより並列性が高い
Rust の言語設計そのものが、高速なコンパイルを目標に作られた Go のような言語よりもコンパイルを難しくしている
この取り組みがどれだけ直接適用されるかは分からないが、そちらでも大きな改善があるとうれしい
最新の IDE サポートには間違いなく必要な部分
中規模のオープンソース Rust プロジェクト [1] のメンテナーだが、ローカルでの Rust のコンパイル時間はいつも驚くほど速いと感じている
MacBook Pro でのデバッグビルドは数秒程度で、リリースビルドと CI/CD は遅いものの、2年前に Rust を始めてから Rust のコンパイルはとても速いと感じてきた
バランスを取って言うと、本業は Java/Kotlin と Gradle だが、こちらでは本当に氷河のようなコンパイル時間について語れる
自分のオープンソース Rust プロジェクトでは依存関係を最小限にし、
derive[Debug, Clone]のようなもの以外はマクロを使わず、ジェネリクスの使用もかなり抑えているcargo buildでこのプロジェクトをビルドして、コンパイル時間についてフィードバックしてもらえるとうれしい[1]: https://github.com/Orange-OpenSource/hurl
それと、適切なところでプロジェクトを複数のクレートに分けていたのかも気になる
モノレポのビルドに何分かかったのか?
できるだけ正直に言っても大丈夫。主に使っているコンパイラが GHC なので、たいていのことでは驚かない
愚かな質問かもしれないが、バックエンドはフロントエンドが借用チェックを終えるまで待たなければならないのだろうか? だとしたらなぜ?
何かがおかしいと言いたいわけではなく、借用チェックが単なる整合性以上に、バックエンドが依存する不変条件を確立しているのか気になる
たとえば借用チェックエラーが出たら捨てられるような、投機的なバックエンド作業をできない理由があるのか気になる
ただし、最も最適化されたコードは生成できないかもしれない
自分の理解では、悪名高い
noalias最適化のように、借用チェック中に確立された情報を使う最適化がある。この最適化は有効になるまで何度も試行が必要だった[1]また NLL(非字句的ライフタイム)との関係もはっきりしないが、バックエンドが関心を持つ情報を確立するには、少なくとも原始的な借用チェッカーが必要そうだ
ただ mrustc は NLL 機能のある Rust バージョンも借用チェッカーなしでコンパイルするので、必須というよりは最適化寄りの話のように思える
[0]: https://github.com/thepowersgang/mrustc
[1]: https://stackoverflow.com/a/57259339
異なるマシンで使う設定ファイルに固定値を書き込む代わりに、CPU コア数を使わせる方法はある?
安定化されたデフォルト値はコア数になるだろうと予想している
この作業が今どこまで進んでいるのかは分からないが、かつては Cargo の rustc 呼び出し同士を jobserver で調整しようとしていて、そうなればデフォルトがコア数である Cargo のジョブ数を使うことになる
Cargo は負の値でコア数から差し引く方式もサポートしている
RUSTFLAGS環境変数を使えばよく、記事にも載っている:いいね! かなり昔に Rust を使ったときは、おもちゃの例ですらコンパイルがかなり遅かったが、最近戻ってみると Rust は本当に良くなっていて、コンパイル時間をほとんど意識せずに使えるところでは使っている
ただ、少し大きくなったプロジェクトの一つでは、簡単な変更でもコンパイルに5秒以上かかり始め、昔の記憶がよみがえった
ノートPCが航空機のエンジンのように回り出す前に、ほかのことを片付けるまでアナライザーが走らないよう保存を先延ばししたいとまで思うようになった
個人的に最大の痛点なので、どんな進展でも非常にうれしい
良い! ライブラリクレートのエコシステムと違って、自分のバイナリクレートは基本的に大きく単一になりがちだった
今は複数のライブラリクレートに分けている
つまり、コンパイル後半が並列化されないだけでなく、最大のクレート群が逐次処理されるということなので、今回の変更はとても歓迎したい
何年か Rust から半ば距離を置き、Python や TypeScript のような環境で働いていたが、最近あるプロジェクトでまた使ってみたところ、コンパイル速度はほとんど即座に終わるレベルだった
さらに良くなるのはいつでも歓迎だが、すでにかなり素晴らしい状態にある
最近は ChatGPT というチートキーのおかげで、数年前なら詰まっていたような難しい Rust の問題もほぼ乗り越えられるので、Rust の先行きはかなり良さそうに見える
そのときは Docker イメージのビルドに 60〜90分かかり、プロジェクト全体の依存関係がどれほど多いかを実感する
コンパイラを再ビルドせずに並列コンパイラオプションをオフにする方法はある?
使う必要がないし、どうせ codegen units も 1 に設定していて、デバッグしたくない ICE を引き起こしているように見える
デフォルトが 1 スレッドなのは分かっているが、完全に切ってしまいたい
「マルチスレッドモードにはデッドロックを含む既知のバグがある。コンパイルが止まった場合、その一つを踏んだ可能性が高い。」
それなら
-Z threadsを使うのはもう少し待つことにしよう ;)