So I was working on a project, incremental builds were slow, and I got curious enough to actually look into why. This is what I found.
People have written about Rust compile times before. Nethercote has a whole series on speeding up the compiler from the inside [6]. Lattimore wrote about deferring codegen to link time to avoid duplicate monomorphizations across crates [7]. PingCAP documented how TiKV's compilation units got huge [5]. This is a different angle — not the from-scratch build, not cross-crate duplication, but why a single edit in a generic-heavy crate forces so much recompilation that it shouldn't.
Rust splits a crate's code into codegen units (CGUs) — independent chunks compiled and cached separately. Edit one function, only its chunk gets recompiled. RFC 1298 [1] describes the goal: "when a function in one compartment changes, functions from other compartments are unaffected and their object code can be reused."
The module doing this is rustc_monomorphize::partitioning [2].
For most code it works fine. For generics it doesn't.
Every monomorphization of a generic function — every concrete type it gets called
with — gets put into one shared bucket. The key used in
compute_codegen_unit_name is (module_def_id, volatile).
The type arguments aren't part of it.
So if a crate has 200 instantiations of ChunkedArray<T> and you
touch one method body, all 200 get re-emitted. Including ones that produce identical
output and share no code with your change. One volatile bucket per module,
everything pooled in, one edit takes the whole thing down.
PRs #70156 [3] and #112946 [4] both worked on this layer of the compiler — one made
-C codegen-units actually respected in incremental mode, the other
improved CGU naming — but neither addressed invalidation scope for generics specifically.
The bucket structure stayed the same.
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#2a2a2a', 'primaryTextColor': '#e8e8e8', 'lineColor': '#555555', 'edgeLabelBackground': '#1a1a1a'}}}%%
flowchart LR
A([Source edit]) --> B{Item content changed?}
B -->|No| C([Reuse cached CGU])
B -->|Yes| D[compute_codegen_unit_name]
D --> E{Volatile generic?}
E -->|No| F([Standard module CGU])
E -->|Status quo| G["Key: module_def_id + volatile\ntype args ignored"]
G --> H[All N instantiations\nin one bucket]
H --> I([Re-emit all N])
E -->|This patch| J["Key: hash(symbol) % shard_count"]
J --> K[Instantiation → 1 of S shards]
K --> L([Re-emit ~N/S])
style G fill:#7a1500,color:#ffcccc
style H fill:#7a1500,color:#ffcccc
style I fill:#7a1500,color:#ffcccc
style J fill:#383838,color:#e8e8e8
style K fill:#383838,color:#e8e8e8
style L fill:#383838,color:#e8e8e8
Status quo (red): one edit, all N re-emitted. This patch (grey): one edit, ~N/shard_count re-emitted.
The first idea was to give each instantiation its own CGU. Maximum cache granularity — edit one thing, only that thing recompiles. Makes sense on paper.
Incremental builds got 20.95× slower.
The reason: merge_codegen_units, the pass that recombines over-fragmented
CGUs back to a sensible count, is deliberately skipped in incremental mode. Each CGU
needs to stay independent to be independently cacheable. Skip the merge pass and
one-CGU-per-instantiation means hundreds of tiny object files with nothing combining
them, and linker overhead takes over completely.
The fix that actually worked: instead of splitting into one bucket per instantiation, shard the volatile bucket into a bounded number of sub-buckets. Each instantiation hashes into a slot:
shard = hash(instantiation_symbol) % shard_count
shard_count = codegen_units.unwrap_or(16)
.max(available_parallelism())
.clamp(4, 128)
One edit invalidates one shard — roughly N/shard_count instantiations instead of all N.
The CGU count stays bounded so link cost stays manageable. The shard count is floored
at the host's thread count so you don't under-utilize cores when someone sets an
explicitly low -C codegen-units.
This is the -Z fine-grained-generic-cgus flag. Patch lives
here.
My first round of benchmarks used the standard touch method — append a comment to the file, rebuild, time it. Seems reasonable. Doesn't work.
rustc fingerprints items by content, not file bytes. A comment changes nothing
semantically, so the compiler correctly figures out nothing changed and reuses every
cached CGU regardless. I verified this with
-C save-temps -Z human-readable-cgu-names: zero CGUs re-emitted in
any configuration. The "15% overhead" I'd been reporting was pure noise —
nothing was being invalidated in the first place.
The fix was a body edit: insert an actual statement into a method body so the MIR fingerprint changes and real invalidation happens. All the numbers below use that.
Benchmarked on polars-core — enough ChunkedArray<T> instantiations
that the volatile bucket is meaningfully expensive to invalidate. Same stage1 compiler
binary held constant across configs in each session.
| Configuration | Cold | Incremental |
|---|---|---|
| Baseline | 1.000× | 1.000× |
| Unbounded (1 CGU/instantiation) | ~3.06× | ~20.95× |
| Hash-sharded patch | 0.988× | 0.961× |
+ -Z threads=4 | 0.657× | 0.852× |
+ -C codegen-units=16 | — | 0.542× |
| + cranelift backend | — | 0.521× |
The thing that surprised me most: just changing codegen-units from 256
(polars-core's dev profile default) to 16 beat the previous best result on its own,
no patched compiler needed. I hadn't tried it because I had no obvious reason to —
until I was investigating whether LLVM's backend parallelism was hiding cranelift's
per-function speed advantage. It was. Turned out the codegen-units value
I hadn't questioned was doing more damage than the bucketing problem I'd been
investigating. Funny how that goes.
I also tried topology-aware clustering — grouping instantiations by call-graph family instead of hashing, so related code shares shards. Built it into a patched stage1 rustc and benchmarked it. 1.375× slower than plain hashing. The test function had no non-generic callees to cluster by, so it always fell through to the hash path anyway and just paid extra overhead getting there. Good idea, wrong crate to test it on.
# Apply the patch to a rust-lang/rust checkout, build stage 1
python3 x.py build --stage 1 library/std compiler/rustc library/proc_macro
# Then use it against any project
RUSTC=/path/to/stage1/bin/rustc RUSTC_BOOTSTRAP=1 \
RUSTFLAGS="-Z fine-grained-generic-cgus" cargo build
Full patch, synthetic benchmark workspace, and every result writeup with raw commands: github.com/Trigodil/Rust.
Thanks to Claude Sonnet 5 and 4.6 for helping move the research faster — tracing through compiler internals, cross-referencing prior work, and running down dead ends would've taken a lot longer without it.
References
-C codegen-units in incremental mode