2 ポイント 投稿者 GN⁺ 2023-11-11 | 1件のコメント | WhatsAppで共有
  • 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年 である

既存の最適化と残るボトルネック

  • Compiler Performance Working Group は何年にもわたってRustコンパイラの性能改善を進めてきた
    • 2023年最初の10か月で、性能計測ツール基準の平均コンパイル時間は 13% 短縮した
    • 最大メモリ使用量は 15% 減少した
    • バイナリサイズは 7% 小さくなった
  • コンパイラはすでにかなり最適化されているため、残る大きな改善余地は 並列性の拡大 に近い

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% の増加が観測されている

正確性とフィードバック

  • シングルスレッドモードの信頼性は高いと見込まれている
  • マルチスレッドモードにはdeadlockを含む既知のバグがある
  • コンパイルが止まった場合、既知バグの1つに遭遇した可能性がある
  • どのフロントエンドを使っても、コンパイラが生成するバイナリは同一であるべきで、差異があればバグとして扱われる
  • 問題があれば、まず WG-compiler-parallel ラベルのIssue を確認し、一致する既存Issueがない場合に新しいIssueを作成できる
  • 一般的なフィードバックは wg-parallel-rustc Zulip channel で受け付けており、特に実コードでの性能効果に関心がある

2024年stable目標

  • 並列フロントエンドの性能改善作業は進行中である
  • プロファイルで見えたように、フロントエンドスレッドの活用率にはまだ改善余地がある
  • マルチスレッドモードに残るバグも整理中である
  • -Z threads オプションの安定化と、stableリリースで並列フロントエンドをマルチスレッドデフォルトとして提供する目標時期は 2024年 である

1件のコメント

 
GN⁺ 2023-11-11
Hacker News のコメント
  • まだ初期段階なのは分かっているが、Rust の弱点はコンパイル速度だと思う
    Rust のモノレポで働いたことがあるが、一番の不満はコンパイル速度で、CI/CD コストを押し上げ、キャッシュを消さなければならないときは開発時間も大きく遅くなった
    原因は Cargo ではなく Docker のバグだったが、それでもこうした進展は歓迎したい

    • 劇的に良くなる可能性は低そう
      すでにかなり最適化されていて、現在の Rust コンパイラはほぼすべての主要コンパイラより並列性が高い
      Rust の言語設計そのものが、高速なコンパイルを目標に作られた Go のような言語よりもコンパイルを難しくしている
    • rust-analyzer は rustc 自体よりもさらにリソースを食う
      この取り組みがどれだけ直接適用されるかは分からないが、そちらでも大きな改善があるとうれしい
      最新の 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
    • Rust では小さなプロジェクトしかやったことがない立場としては、コード行数がどの程度だったのか気になる
      それと、適切なところでプロジェクトを複数のクレートに分けていたのかも気になる
    • 実際どれくらい遅いのか気になる
      モノレポのビルドに何分かかったのか?
      できるだけ正直に言っても大丈夫。主に使っているコンパイラが GHC なので、たいていのことでは驚かない
  • 愚かな質問かもしれないが、バックエンドはフロントエンドが借用チェックを終えるまで待たなければならないのだろうか? だとしたらなぜ?
    何かがおかしいと言いたいわけではなく、借用チェックが単なる整合性以上に、バックエンドが依存する不変条件を確立しているのか気になる
    たとえば借用チェックエラーが出たら捨てられるような、投機的なバックエンド作業をできない理由があるのか気になる

    • mrustc[0] という借用チェッカーのない Rust コンパイラがあるので、少なくとも一部の Rust バージョンは借用チェッカーなしでもコンパイル可能
      ただし、最も最適化されたコードは生成できないかもしれない
      自分の理解では、悪名高い noalias 最適化のように、借用チェック中に確立された情報を使う最適化がある。この最適化は有効になるまで何度も試行が必要だった[1]
      また NLL(非字句的ライフタイム)との関係もはっきりしないが、バックエンドが関心を持つ情報を確立するには、少なくとも原始的な借用チェッカーが必要そうだ
      ただ mrustc は NLL 機能のある Rust バージョンも借用チェッカーなしでコンパイルするので、必須というよりは最適化寄りの話のように思える
      [0]: https://github.com/thepowersgang/mrustc
      [1]: https://stackoverflow.com/a/57259339
    • 厳密に言えば、必ずしもそうしなければならないわけではないと思う
  • 異なるマシンで使う設定ファイルに固定値を書き込む代わりに、CPU コア数を使わせる方法はある?

    • まだ実験段階で nightly のみに制限されているので、一般利用向けに設定されているわけではない点は考慮すべき
      安定化されたデフォルト値はコア数になるだろうと予想している
      この作業が今どこまで進んでいるのかは分からないが、かつては Cargo の rustc 呼び出し同士を jobserver で調整しようとしていて、そうなればデフォルトがコア数である Cargo のジョブ数を使うことになる
      Cargo は負の値でコア数から差し引く方式もサポートしている
    • RUSTFLAGS 環境変数を使えばよく、記事にも載っている:

      $ RUSTFLAGS="-Z threads=8" cargo build --release

    • jobserver プロトコルを使い、Cargo はデフォルトでコア数に初期化するので、新しいフラグを 10000 のような非現実的に大きな値に設定すれば、空いているコア数に使用量が制限されると思う
  • いいね! かなり昔に Rust を使ったときは、おもちゃの例ですらコンパイルがかなり遅かったが、最近戻ってみると Rust は本当に良くなっていて、コンパイル時間をほとんど意識せずに使えるところでは使っている
    ただ、少し大きくなったプロジェクトの一つでは、簡単な変更でもコンパイルに5秒以上かかり始め、昔の記憶がよみがえった
    ノートPCが航空機のエンジンのように回り出す前に、ほかのことを片付けるまでアナライザーが走らないよう保存を先延ばししたいとまで思うようになった
    個人的に最大の痛点なので、どんな進展でも非常にうれしい

  • 良い! ライブラリクレートのエコシステムと違って、自分のバイナリクレートは基本的に大きく単一になりがちだった
    今は複数のライブラリクレートに分けている
    つまり、コンパイル後半が並列化されないだけでなく、最大のクレート群が逐次処理されるということなので、今回の変更はとても歓迎したい

  • 何年か Rust から半ば距離を置き、Python や TypeScript のような環境で働いていたが、最近あるプロジェクトでまた使ってみたところ、コンパイル速度はほとんど即座に終わるレベルだった
    さらに良くなるのはいつでも歓迎だが、すでにかなり素晴らしい状態にある
    最近は ChatGPT というチートキーのおかげで、数年前なら詰まっていたような難しい Rust の問題もほぼ乗り越えられるので、Rust の先行きはかなり良さそうに見える

    • 自分のコンパイル時間はおおむね問題ないが、GitHub Actions でクロスアーキテクチャの Docker イメージを作るときだけは例外
      そのときは Docker イメージのビルドに 60〜90分かかり、プロジェクト全体の依存関係がどれほど多いかを実感する
    • コンパイル時間は依存関係の量・大きさ・複雑さに非常に大きく左右される
  • コンパイラを再ビルドせずに並列コンパイラオプションをオフにする方法はある?
    使う必要がないし、どうせ codegen units も 1 に設定していて、デバッグしたくない ICE を引き起こしているように見える
    デフォルトが 1 スレッドなのは分かっているが、完全に切ってしまいたい

  • 「マルチスレッドモードにはデッドロックを含む既知のバグがある。コンパイルが止まった場合、その一つを踏んだ可能性が高い。」
    それなら -Z threads を使うのはもう少し待つことにしよう ;)