An investigation into incremental invalidation scope in rustc's codegen
unit partitioner, with a patch, a benchmark methodology, and measured results
on a large generic-heavy crate.
Rust's incremental compilation is built on codegen units (CGUs): the compiler partitions a crate's monomorphizations into buckets, compiles each bucket independently, and on the next build only re-emits buckets whose content changed. RFC 1298 (original design) describes the goal: "when a function in one compartment changes, functions from other compartments are unaffected and their object code can be reused."
The rustc_monomorphize::partitioning
module implements this. For most items it works as intended. Generic functions
are the exception: every monomorphization of a shared generic within a module
is grouped into one "volatile" CGU, keyed on (module_def_id, volatile) in
compute_codegen_unit_name — the concrete type arguments are ignored. So editing
one instantiation's code path invalidates the cache for every other
instantiation in that module, regardless of whether they share any code with
the change.
This is a known structural tension in the design — PR #70156
(making -C codegen-units respected in incremental mode) and the subsequent
CGU naming work in #112946
both touched this layer without addressing invalidation scope for generics
specifically. PingCAP's writeup on Rust's huge compilation units
describes the broader crate-level version of this problem for TiKV; this repo
looks at the generic-level version inside a single crate.
flowchart LR
A([Source edit in module M]) --> B[cargo: mtime changed\ninvoke rustc]
B --> C[rustc incremental check]
C --> D{Item content\nchanged?}
D -->|No| E([Reuse cached CGU])
D -->|Yes| F[compute_codegen_unit_name]
F --> G{Volatile generic\ninstantiation?}
G -->|No| H([Assign to standard\nmodule CGU])
G -->|Yes — status quo| I["Key: module_def_id + volatile\nignores type args"]
I --> J[All N instantiations\nland in one bucket]
J --> K[Entire volatile bucket\ninvalidated]
K --> L([Re-emit all N instantiations\neven unrelated ones])
G -->|Yes — this patch| M["Key: hash(symbol) % shard_count\nshard_count from -C codegen-units"]
M --> N[Instantiation → 1 of S shards]
N --> O([Invalidate ~N/S instantiations\nblast radius contained])
style I fill:#c0392b,color:#fff
style J fill:#c0392b,color:#fff
style K fill:#c0392b,color:#fff
style L fill:#c0392b,color:#fff
style M fill:#1a6b3a,color:#fff
style N fill:#1a6b3a,color:#fff
style O fill:#1a6b3a,color:#fff
This measures the cost, designs a partitioning fix, validates the methodology,
and benchmarks against a crate large enough to make the effect observable —
polars-core, whose ChunkedArray<T> instantiations survive rustc's
merge_codegen_units pass and make the volatile bucket meaningfully expensive
to invalidate.
Three partitioning strategies are compared:
| Strategy | Mechanism | Incremental result |
|---|---|---|
| Status quo | One bucket per (module_def_id, volatile) |
baseline |
| Unbounded (1 CGU/instantiation) | Per-symbol CGU, no merge pass | ~20.95x slower — worse than status quo |
| Hash-sharded (this patch) | hash(symbol) % shard_count |
0.961x — faster than status quo |
| Topology-aware clustering | Bin-pack by call-graph family | 1.375x — slower, diagnosed why |
The unbounded approach mirrors what one might naively reach for (maximize
cache granularity) and is exactly what fails: merge_codegen_units is skipped
in incremental mode by design, so per-instantiation CGUs produce hundreds of
tiny object files with no recombination step, and the link cost dwarfs any
invalidation savings. The bounded hash-sharding approach avoids this while
still narrowing invalidation scope.
Topology-aware clustering (grouping instantiations by shared call-graph
families, inspired by the RFC's own framing of "closely related functions")
was prototyped, built into a patched stage1 rustc, and benchmarked. It lost:
the tested function had no non-generic callees to cluster by, so it fell back
to the same hash path and paid extra fixed overhead for nothing. The prototype
and its diagnosis are in topology-lab/ and
results/polars-core-topology.md.
All numbers are polars-core (ChunkedArray<T>), same host, body-edit
incremental methodology unless noted. Each row links to its full writeup.
| Configuration | Cold | Incremental | Detail |
|---|---|---|---|
| baseline (no flags) | 1.000x | 1.000x | — |
| unbounded patch (1 CGU per instantiation) | ~3.06x | ~20.95x | badly regressed |
| hash-sharded patch (bounded, current) | 0.988x | 0.961x | win once methodology was fixed |
-Z threads=4 alone |
0.739x | 0.905x | comparison with other tools |
hash-sharded + -Z threads=4 |
0.657x | 0.852x | they compound |
| topology-aware clustering (vs. hash-sharding) | — | 1.375x (lost) | diagnosed why it lost |
| cranelift, in-tree build, alone | 0.932x | 1.009x (wash) | doesn't reproduce the 0.51x reference result |
hash-sharded + threads=4 (cu=16, no cranelift) |
— | 0.542x | codegen-units was the key lever |
hash-sharded + threads=4 + cranelift (cu=16) |
— | 0.521x | current best — full sweep |
Also measured but not stackable with the patch: mold (no effect on lib
crates — comparison) and sccache
(a different axis: cache-hit vs. cache-miss, not incremental speed).
A significant finding during this investigation was that the // touch
incremental methodology (append a trailing comment, rebuild) used in early
benchmarks does not actually trigger CGU invalidation — rustc fingerprints
by item content, not file bytes, so a trailing comment changes nothing
semantically. All numbers in the table above use a body-edit methodology
(insert a statement into should_rechunk()'s body, revert between runs).
The methodology bug and its fix are documented in
results/polars-core-topology.md;
results/polars-core-release.md has the
-C save-temps evidence that first surfaced it.
rustc-patch/fine-grained-generic-cgus.patch— the compiler patch (-Z fine-grained-generic-cgus).rustc-patch/hybrid_build.py— pairs the patch with an external fast-linker pipeline.rust-build-lab/— synthetic 3-crate workspace that reproduces the invalidation behavior in isolation. Run./bench.shfor a full set of build-time comparisons.fn-cache-lab/— a separate angle: function-level content-hash caching plus an in-processrustc_driverdaemon.topology-lab/— prototype and build test of call-graph-aware CGU clustering (lost to hash-sharding; see table above).real-project-test/stm32test-wrapper/— the behavior against an actualsvd2rustcrate (no measurable effect — too little code per instantiation).results/— one file per benchmark with full methodology and caveats.
- RFC 1298: Incremental compilation — the original design for CGU-based incremental builds
rustc_monomorphize::partitioning— module implementing CGU assignment- rust-lang/rust #70156 — making
-C codegen-unitsrespected in incremental mode - rust-lang/rust #112946 — CGU naming and ordering improvements
- Rust's Huge Compilation Units (PingCAP) — related analysis of crate-level compilation unit costs in TiKV
- How to speed up the Rust compiler (Nicholas Nethercote) — ongoing compiler performance work from the rustc team
# synthetic benchmark
cd rust-build-lab && ./bench.sh
# fn-cache-lab: generate the workload, then build with the cache + daemon system
cd fn-cache-lab && python3 gen.py
# build a stage1/stage2 rustc_driver-linked daemon_driver binary first
# (see daemon_driver.rs — requires -Z rustc_private against a matching sysroot)
python3 build_daemon.py
# rustc patch: apply rustc-patch/fine-grained-generic-cgus.patch to a
# rust-lang/rust checkout, build stage 1 (or stage 2 for rustc_driver access),
# then build any project with:
RUSTC=/path/to/stage1/bin/rustc RUSTC_BOOTSTRAP=1 RUSTFLAGS="-Z fine-grained-generic-cgus" cargo build
# topology-lab: no rustc patching or rebuild needed, just a nightly toolchain
cd topology-lab
python3 gen.py
rustc +nightly --crate-type lib -C codegen-units=1 -C opt-level=0 \
-C debuginfo=0 --emit=llvm-ir lib.rs -o lib.ll
python3 extract_callgraph.py lib.ll callgraph.json
python3 simulate.py