Patches and measurement harness for making cargo check faster, plus the
measurements that justify each one and the ones that killed the ideas that did
not work.
The headline: a cold cargo check of rust-analyzer from an empty target
directory went from 23.77 seconds to 0.47 seconds on an Apple M4.
| project | units | baseline cold | cached cold | cargo's no-op floor | speedup |
|---|---|---|---|---|---|
| rust-analyzer | 239 | 23.77s | 0.47s | 0.10s | 50.6x |
| ripgrep | 46 | 5.37s | 0.18s | 0.03s | 29.8x |
| tokio | 40 | 4.66s | 0.17s | 0.04s | 27.4x |
| solo (zero dependencies) | 1 | 3.28s | 0.09s | 0.01s | 36.4x |
| clap | 23 | 1.54s | 0.15s | 0.04s | 10.3x |
The no-op column is what cargo costs when it decides an already-built directory needs nothing. That is the floor any caching approach can reach.
Full write-up, including the four ideas that failed and why: https://www.drmhse.com/posts/cutting-a-cold-cargo-check-from-24-seconds-to-half-a-second/
| path | contents |
|---|---|
patches/ | the two patches, the upstream commits they apply to, and what each new file does |
harness/ | the benchmark and correctness scripts, all driven by environment variables |
experiments/ | standalone reproductions of single findings, plus the generators they need |
results/ | every measurement, with the hardware and compiler that produced it |
Branches carrying the same work, ready for review or a pull request:
| repository | branch |
|---|---|
| https://github.com/drmhse/cargo | rust-performance |
| https://github.com/drmhse/rust | rust-performance |
Four unstable flags, across two repositories.
| flag | repo | what it does |
|---|---|---|
-Zshared-cache | cargo | reuses built units from a machine-wide cache, keyed by everything that determined the output |
-Zadaptive-threads | cargo | gives each rustc invocation a share of the machine based on how many units can run alongside it |
-Zinterface-hash | cargo, rustc | skips re-checking a crate when its dependencies' public interfaces are unchanged |
-Zearly-metadata | cargo, rustc | emits metadata before body checking, so dependents can start sooner |
-Zshared-cache and -Zadaptive-threads work against a stock nightly rustc.
-Zinterface-hash and -Zearly-metadata need the rustc patch as well.
Both patches are against specific upstream commits, recorded in
patches/BASE.txt, and are verified to apply cleanly to them.
git clone https://github.com/rust-lang/cargo &&cd cargo
git checkout 2305ac97ea879ad32ea0dd6366b5f8dad1ba4ce7
git apply /path/to/patches/cargo.patch
RUSTUP_TOOLCHAIN=nightly cargo build --releaseThe cargo repository's MSRV runs ahead of stable, so the nightly toolchain is required to build it.
git clone https://github.com/rust-lang/rust &&cd rust
git checkout eab115ea6d842276c6ad7b819e08297c8e7693f0
git apply /path/to/patches/rustc.patch
./x.py build --stage 1 compiler/rustc libraryBuilding compiler/rustc on its own leaves stage 1 without a standard library,
so both targets are needed on one line.
Paths come from the environment, so the same scripts run against a patched toolchain or a stock one:
export PATCHED_CARGO=~/src/cargo/target/release/cargo
export PATCHED_RUSTC=~/src/rust/build/aarch64-apple-darwin/stage1/bin/rustc
export BENCH_ROOT=/tmp/rsbench
harness/setup.sh # clone the four test projects, generate the fifth
harness/cold-build.sh # the headline table
harness/cache-diff.sh # differential correctness for the cache
harness/interface-diff.sh # differential correctness for the cutoff
harness/thread-scaling.sh # -Zthreads scaling on one cold compilation
harness/unit-timeline.sh # per-unit concurrency profile of a cold build
harness/additive-edits.sh # how many crates re-check per kind of editThe two differential scripts are the ones that matter. Each runs a sequence of edits, wipes the target directory, and compares exit codes against an unpatched cargo, including deliberately broken code that has to be reported. A single edit followed by a single check cannot expose an artifact set that disagrees with itself, which is how a real bug survived a long run of green benchmarks.
Every timing script prints the number of rustc invocations alongside the seconds, because the count is what explains the time. A cached build of rust-analyzer that takes 0.47 seconds compiles zero units, so the remaining time is cargo deciding that 239 units need nothing, and the way to make it faster is to reduce cargo's per-unit overhead rather than the compiler's.
The same reasoning applies in reverse. During development the cache reached a state where nothing compiled and the build still took 3.27 seconds, which located the bug precisely: 153 of 235 units were being marked dirty by a dependency whose restoration cargo had not yet seen.
experiments/macro-expansion-cost.sh compiles 16,000 lines of items twice, once
written out literally and once produced by a macro_rules! macro, which is what
showed that transcription runs at roughly parse speed and there is nothing there
to cache.
experiments/generate-solo-crate.py writes the zero-dependency crate. It exists
because every other test project mixes the cost of its own code with the cost of
its dependency tree, and those respond to different changes.
experiments/summarise-timeline.py turns a per-unit timing log into the
concurrency profile, which is what showed that a cold build runs at 10-way
parallelism for its first 14 seconds and at 1 for the remaining 28.
src/compiler/fingerprint/read_set.rs implements per-item interface comparison,
which makes the cutoff survive an added pub fn: 31 units and 6.90 seconds
become 1 unit and 0.25 seconds. It is unsound, because skipping a dependent when
the dependency's interface genuinely changed is exactly the case that metadata
pinning cannot repair. It sits behind __CARGO_INTERFACE_ITEMS so the
measurement can be reproduced, and is off by default.
Four defects shaped the final design, and each is a trap that any similar attempt would hit.
A cache key over a local crate has to be transitive. Cargo's fingerprint for
a local unit records which dependencies it had rather than what they contained,
so changing a function signature in a leaf crate left every crate above it with
an unchanged key. Caught by changing to_camel_case in rust-analyzer's stdx,
wiping the target directory, and watching the build succeed with zero errors.
Hard-linking into a cache makes the cache mutable. The entry and the build directory's file become one inode, so anything that rewrites that file in place rewrites the cache with it. Storing copies and restoring links, which is safe because rustc replaces its outputs by rename.
Restoration has to run bottom-up. Cargo prepares a unit before it recurses into that unit's dependencies, so a unit's fingerprint is computed while its dependencies' outputs are still absent, and it memoises them as stale.
rustc stamps each dependency's crate hash into the metadata it emits. A crate skipped by the cutoff keeps metadata naming a hash that no longer exists on disk, and nothing notices until a later edit forces one of those crates to compile. The repair keeps the previous metadata when a unit's interface is unchanged and its dependencies' hashes are unchanged; the second condition is required, because restoring one crate's old metadata while its dependency has a new hash moves the same problem one level up.
The first build of code that has never been built anywhere still costs full price. The gains come from CI, fresh clones, wiped target directories, branch switching, and projects that share dependencies.
The cache is machine-local on purpose. A build script can read a system library or an environment variable that cargo never fingerprints, so its result is only reliably reusable where those are the same.
solo compiles in 2.55 seconds of CPU, so perfect scaling across 10 cores would
be 0.26 seconds, and rustc's parallel frontend currently reaches 1.16 by burning
1.9x the CPU. Closing that gap means making the frontend scale.
Apple M4, 10 cores, 16 GB, macOS 15.7.2, rustc 1.100.0-nightly (67854e511 2026-08-15). Numbers taken on other hardware should say so; the ratios travel
further than the absolute times.