I patched the Rust compiler because the build times were annoying me

Ayush Sreejith · September 2026

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.

How incremental compilation works

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.

The generic problem

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.

What I tried first

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.

Hash-sharding

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.

I was testing the wrong thing

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.

Results

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.

ConfigurationColdIncremental
Baseline1.000×1.000×
Unbounded (1 CGU/instantiation)~3.06×~20.95×
Hash-sharded patch0.988×0.961×
+ -Z threads=40.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.

How to try it

# 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

  1. RFC 1298: Incremental compilation
  2. rustc_monomorphize::partitioning
  3. rust-lang/rust #70156 — respecting -C codegen-units in incremental mode
  4. rust-lang/rust #112946 — CGU naming and ordering
  5. Rust's Huge Compilation Units — PingCAP on TiKV crate-level costs
  6. How to speed up the Rust compiler — Nicholas Nethercote, July 2026
  7. Speeding up rustc by being lazy — David Lattimore on deferred codegen and monomorphization duplication
  8. Explicit monomorphization for compilation time reduction — Rust Internals discussion