rustc の前半分が、横にひろがる — 並列フロントエンド、いまどこまで

Rust のコンパイラ rustc には、前半分と後半分があります。後半分 —— 機械語を吐く「コード生成(バックエンド)」—— は、ずっと前から並列で走っていました。クレートを小さなかたまり(CGU)に割って、コアに配って、同時に。

でも前半分 —— ソースを読んで、マクロを展開して、型を検査するところ、つまり フロントエンド —— は、長いあいだ一本道でした。何コアあっても、そこだけは一人で歩く。大きなクレートほど、その一本道が待ち時間になる。

その一本道を並列にする、という長い作業が rust-lang/rust#113349 で追われています。この記事は、その現在地を —— nightly でもう動くところまで来たので —— 手元で実際にコンパイルして測りながら、となりで見た記録です。素直な数字にはならなかったので、そのまま書きます。

いまは、こう使える

まず、動かせます。nightly の rustc に、こう渡すだけ。

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

config.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.02026-07-07)で、-Z threads だけを変える。

ところが、素直な話にはなりませんでした。順に書きます。

まず、全体をまるごとビルドしてみた

bevy のライブラリを、まっさらから dev ビルドする。serial(一本道)と threads=8 を、間をあけて何度か。

最初の一回は、serial 341 秒threads=8207 秒。おお、4 割速い。公式の言うとおりだ、と思いました。

でも、もう二回、三回とまわしてみると、数字が暴れはじめます。

何度かまわした範囲
serial136 〜 341 秒(ばらばら)
threads=8169 〜 207 秒(わりと一定)

serial のほうが、環境しだいで大きく振れる。マシンが空いていれば 136 秒で、threads=8 より速いことすらある。混んでいれば 341 秒。中央値をとると serial 204 秒・threads=8 188 秒で、差はほとんどない。最初の「4 割」は、serial がたまたま遅かった一回の、運でした。

プロファイルを見たら、逆のことが起きていた

なぜ threads=8 が速くならないのか。総時間だけ見ていても分からないので、cargo build --timings で、クレート一個ずつの内訳を見ました。そうしたら、予想と逆のことが起きていた。

threads=8 のほうが、ほとんどのクレートで、コンパイルに時間がかかっている。

クレートserialthreads=8
bevy_ecs13.9s33.4s
bevy_post_process11.2s28.2s
bevy_render28.5s56.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 万行の大きなクレートを、一個だけ、まっさらから焼いて測りました。

単独フルビルド
serial28.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 に流れています。あなたのアカウントから返信すると、ここに並びます。