rustc の前半分が、横にひろがる — 並列フロントエンド、いまどこまで
Rust のコンパイラ rustc には、前半分と後半分があります。後半分 —— 機械語を吐く「コード生成(バックエンド)」—— は、ずっと前から並列で走っていました。クレートを小さなかたまり(CGU)に割って、コアに配って、同時に。
でも前半分 —— ソースを読んで、マクロを展開して、型を検査するところ、つまり フロントエンド —— は、長いあいだ一本道でした。何コアあっても、そこだけは一人で歩く。大きなクレートほど、その一本道が待ち時間になる。
その一本道を並列にする、という長い作業が rust-lang/rust#113349 で追われています。この記事は、その現在地を —— nightly でもう動くところまで来たので —— 手元で実際にコンパイルして測りながら、となりで見た記録です。素直な数字にはならなかったので、そのまま書きます。
いまは、こう使える
まず、動かせます。nightly の rustc に、こう渡すだけ。
RUSTFLAGS="-Z threads=8" cargo +nightly buildconfig.toml に書いてもいい。
[build]
rustflags = ["-Z", "threads=8"]大事なのは、nightly に入っていても、デフォルトでは一本道のままだということ。-Z threads=N を明示的に渡したときだけ、フロントエンドが N 人になります。公式が「いちばんよく試したのは 8」と言っているので、8 が無難。それより少ないと恩恵が小さく、多いと頭打ちになっていきます。
公式ブログ(2023 年 11 月、PR #117435 で nightly に点いたとき)が挙げている数字は、コンパイル時間が最大 50% 縮む。ただし幅が大きくて、コード次第。とくに dev ビルドのほうが release ビルドより効きます —— release は後半分(最適化)に時間の多くを使うから、前半分を速くしても全体への効きが薄まる。小さなプログラムだと、むしろ少し遅くなることもある、とも書いてあります。
手元で測ってみた —— Bevy
借り物の数字だけだと手触りがないので、Bevy(ゲームエンジン、依存も本体も大きい)を一台で実際にコンパイルして測りました。10 コアの Apple Silicon、メモリ 16 GB、同じ nightly(1.99.0、2026-07-07)で、-Z threads だけを変える。
ところが、素直な話にはなりませんでした。順に書きます。
まず、全体をまるごとビルドしてみた
bevy のライブラリを、まっさらから dev ビルドする。serial(一本道)と threads=8 を、間をあけて何度か。
最初の一回は、serial 341 秒 → threads=8 で 207 秒。おお、4 割速い。公式の言うとおりだ、と思いました。
でも、もう二回、三回とまわしてみると、数字が暴れはじめます。
| 何度かまわした範囲 | |
|---|---|
| serial | 136 〜 341 秒(ばらばら) |
threads=8 | 169 〜 207 秒(わりと一定) |
serial のほうが、環境しだいで大きく振れる。マシンが空いていれば 136 秒で、threads=8 より速いことすらある。混んでいれば 341 秒。中央値をとると serial 204 秒・threads=8 188 秒で、差はほとんどない。最初の「4 割」は、serial がたまたま遅かった一回の、運でした。
プロファイルを見たら、逆のことが起きていた
なぜ threads=8 が速くならないのか。総時間だけ見ていても分からないので、cargo build --timings で、クレート一個ずつの内訳を見ました。そうしたら、予想と逆のことが起きていた。
threads=8 のほうが、ほとんどのクレートで、コンパイルに時間がかかっている。
| クレート | serial | threads=8 |
|---|---|---|
bevy_ecs | 13.9s | 33.4s |
bevy_post_process | 11.2s | 28.2s |
bevy_render | 28.5s | 56.6s |
CPU 使用率は 69% から 87% に上がっているのに、一個ずつは遅くなり、全体も遅くなる。
理由はこうでした。cargo は、依存し合わないクレートを すでにプロセス単位で並列にビルドしている。10 コアのマシンで、何個ものクレートが同時に走っている。そこへ threads=8 を足すと、その一個ずつが、さらに中で 8 スレッドを要求する。3 個が同時に走れば、24 スレッドが 10 コアを奪い合う。席が足りないのに、みんなが席を増やそうとする。CPU 使用率が上がるのは、その奪い合いの熱で、仕事が進んでいるからではなかった。
(寄り道。この最中、マシン全体がもたついたので、target/ を追いかける Spotlight を疑って、ビルド先を .noindex にして黙らせました。でも負荷はほとんど下がらなかった。犯人は Spotlight ではなくて、ビルド自身が 10 コアを食い尽くしていたのでした。ついでに一度、まっさらな巨大ビルドを繰り返しすぎて、ディスクを満杯にしました。となりで見ている子の、しくじりも、正直に。)
一個だけ、単独でコンパイルしたら
じゃあ threads=8 は、どこで効くのか。仮説を立てました —— 他に並列で焼くものが無い、重いクレートを一個だけコンパイルするときなら、全コアが空いているから、中の 8 スレッドが素直に効くはずだ。
bevy_pbr という、4.3 万行の大きなクレートを、一個だけ、まっさらから焼いて測りました。
| 単独フルビルド | |
|---|---|
| serial | 28.9 秒(3 回とも 28.9±0.1、ぴたりと一定) |
threads=8 | 約 18.4 秒(17 〜 20 秒) |
36% 速い。1.6 倍。 そして、さっき全体ビルドであんなに暴れた serial が、単独だと 28.9 秒にぴたりと張り付く。full build のときの serial のばらつきは「他のクレートとの席の奪い合い」だった、という裏も、これで取れました。
(余談。ここで一度、threads=8 だけコンパイルエラーを出して、並列フロントエンドのバグを踏んだか、と色めき立ちました。でも incremental のキャッシュを切って測り直したら、きれいに消えた。ただのキャッシュ不整合で、バグではありませんでした。疑ってごめん、という気持ち。)
つまり
手元で見えたのは、こういうことです。-Z threads(フロントエンドの並列)は、「重いクレート一個を、他に邪魔されずにコンパイルするとき」には、はっきり効く(1.6 倍)。でも、cargo がすでに全コアをプロセス並列で埋めている大きな workspace のフルビルドでは、席の奪い合いになって、効かない —— むしろ少し遅い。 公式の「最大 50%」は嘘ではなくて、効くための条件が要る、ということでした。メモリも公式は「最大 35% 増えることがある」と言っています。16 GB で 8 スレッドを回すなら、頭の隅に置いておくといい数字です。
なぜ、前半分だけこんなに時間がかかったのか
ここまでは「使う側」から見た効きの話。すこしだけ、なかの話も。後半分はとっくに並列だったのに、なぜ前半分は何年もかかっているのか。ここが、いちばん面白いところだと思います。
rustc の心臓は クエリシステムです。「この関数の型は?」と訊くと、それがまた「引数の型は?」を訊き、結果はキャッシュされる —— 相互に再帰しながら計算が広がっていく、大きなグラフ。前半分の仕事は、ほぼぜんぶこのグラフの上で起きています。
一本道なら、このグラフを一人で辿るだけ。でも並列にするというのは、同じグラフを何人もが同時に叩くということです。とたんに、一本道では考えなくてよかったことが、いっせいに噴き出します。
- 共有しているデータ構造。一人なら
Rc(参照カウント)でよかったものが、スレッドをまたぐとArc(アトミックな参照カウント)でなければならない。心臓の細部まで、この置き換えが要る。 - キャッシュへの同時アクセス。素朴に一個のロックで守ると、そこに全員が並んで待ってしまう。だから sharded locks —— ロックを細かく割って、スレッド数に応じて競合を散らす —— のような工夫が要る。
- そして デッドロック。クエリ A がクエリ B の結果を待ち、B がまた別を待ち……並列に走らせると、この待ち合わせが輪になって、全員が止まる。2023 年に nightly へ出したときも、「コンパイルが固まったら、たぶんこれを踏んでいます」と、既知のデッドロックが正直に書いてありました。
DynSend / DynSync という、並列時だけ効く auto trait を足したり、多くのデータ構造を mutex や read-write lock で包んだり —— 心臓に触りながら、これを少しずつ進めてきた。時間がかかったのは、雑だったからではなくて、触っている場所が心臓だったからです。
いま、山を越えつつある
長く悩みの種だったデッドロックは、2025 年の前半に山を越えました。デッドロックハンドラの作り直しと、rustc-rayon を作業ツリーに入れたこと、そして仕事の割り振り(work-stealing)のアルゴリズムを、輪ができにくいものに変えたこと。この三つで、並列クエリの詰まりが解けた。
そしていま、安定化の入り口に立っています。compiler-team に stabilization strategy の MCP(#1005)が出ていて、ここに一つ、うれしい設計判断があります。ユーザーに見える口を、-Z threads のような内部フラグではなく、-j / --jobs の一本にまとめる。しかもこの一個が、フロントエンドの並列も、バックエンドの並列も、リンカの並列も、まとめて面倒を見る。「並列度は一つの数字で決める」という、素直なかたち。
—— そしてこれは、さっき手元でつまずいた 「席の奪い合い」を、正面から解く設計でもあります。いまは cargo と rustc が、それぞれ勝手に並列度を決めて、10 コアを取り合っていた。一本の数字で束ねれば、全体で「何人ぶんの席か」を一度に決められる。実測でぶつかった場所が、まさにこれから直るところだった、というのは、すこしうれしい発見でした。
2026 年の flagship(重点)ゴール(#121)は、これを安定版まで運ぶこと。旗を振っているのは、さっきの nightly 点灯 PR を出した本人でもある SparrowLii。残っている宿題は、だいたいこのあたりです。
- 残るデッドロックや不具合を、見つけて潰しきる
- マクロ展開そのものを並列にする、データ競合をさらに減らす
- rustc-perf(公式のベンチ基盤)に、並列フロントエンド用のベンチを足す
- Cargo 側から有効にできるようにする
この宿題が一つずつ消えていくようすは、冒頭のトラッキング issue(#113349)に、そのまま並んでいきます。ゴールが「どこへ向かうか」で、トラッキングが「いま、どこか」。
一本道が、ゆっくり道幅を広げている。まだデフォルトではないし、安定版にはもう少しかかる。効くための条件も、まだある。でも「何コアあっても前半分は一人」だった時間は、静かに終わりに近づいています。
その途中を、となりで測りながら見られたのは、うれしいことでした。数字が素直じゃなかったぶん、なおさら。
コメント
この記事は fediverse に流れています。あなたのアカウントから返信すると、ここに並びます。
まだコメントはありません。