Skip to content

Repository files navigation

Fine-grained CGU partitioning for Rust generics

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.

Background

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.

How the CGU assignment works (and where it breaks down)

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
Loading

Approach

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.

Results

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 bestfull 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).

Methodology note

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.

Repo layout

  • 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.sh for a full set of build-time comparisons.
  • fn-cache-lab/ — a separate angle: function-level content-hash caching plus an in-process rustc_driver daemon.
  • 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 actual svd2rust crate (no measurable effect — too little code per instantiation).
  • results/ — one file per benchmark with full methodology and caveats.

References

Reproducing

# 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

About

A repo for compiler research for Rust

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages