Rust build speedup — a portable playbook
A small, batteries-included stack that cuts Rust build and test time dramatically on any project — from a single crate to a large workspace. Three drop-in tools plus a few profile settings. No code changes, no lock-in, no project-specific assumptions.
TL;DR
| Lever | What it does | Typical win |
|---|---|---|
| sccache | Caches compiled artifacts across builds/worktrees | up to ~10× on repeat compiles |
| mold | Faster linker, replaces GNU ld | ~2–3× on the link step |
| cargo-nextest | Parallel test runner | ~3× on test-heavy crates |
| thin-LTO release profile | Parallel LTO instead of serial fat-LTO | much faster + lighter release builds |
| BuildKit cache mounts | Persist cargo registry + target/ across Docker image rebuilds | ~10× on warm Docker rebuilds |
The first three are install-once. The last two are a few lines of config. Read the two ⚠️ gotchas before wiring anything — one of them silently disables sccache.
1. sccache — cross-build compile cache
cargo's own incremental cache stops at the workspace boundary and inside target/. sccache sits between cargo and rustc, hashes each compilation's inputs (source + flags + compiler version), and stores the result in a shared cache directory. Any later compile with identical inputs — in another worktree, another crate, after cargo clean — becomes a cache hit.
Wire it globally so every cargo invocation uses it (including non-interactive shells like docker exec and CI), via ~/.cargo/config.toml:
[build]
rustc-wrapper = "sccache"Putting this in config.toml is more reliable than an env var in your shell rc: non-login / non-interactive shells don't source the rc, so an env-var-only setup silently skips the cache there.
The payoff compounds with how many crates share dependencies — every crate that compiles, say, serde or tokio writes those artifacts once; later crates hit them.
2. mold — fast linker
The final link step (GNU ld) can take seconds-to-tens-of-seconds on a large binary with many object files. mold is a near-drop-in replacement.
# ~/.cargo/config.toml
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]This passes -fuse-ld=mold to the C compiler rustc uses for linking; the compiler resolves mold from PATH. (On macOS the modern Apple linker is already fast; mold's macOS sibling sold is optional.)
3. cargo-nextest — parallel tests
cargo test runs one test process per crate, serially across crates. cargo nextest run runs each test in its own process with smarter scheduling — same observable behaviour, typically ~3× faster on test-heavy crates. Keep cargo test as the documented reference; use nextest in CI and local speed loops.
⚠️ Gotcha #1 — sccache and incremental are mutually exclusive
This is the one that silently costs you the entire sccache win. sccache cannot cache a crate built with incremental compilation. It detects the incremental flag and falls through to plain rustc — no cache read, no cache write. If you have BOTH rustc-wrapper = "sccache" AND a global incremental = true, sccache is dead weight.
How to spot it: sccache --show-stats shows Cache hits: 0 / Cache misses: 0 even after a full build.
Fix — scope incremental to the dev profile only, never globally:
# ~/.cargo/config.toml or the workspace Cargo.toml. Do NOT set a global
# [build] incremental — it forces incremental onto release too, killing sccache there.
[profile.dev]
incremental = true # local edit-loop: incremental beats sccache
[profile.release]
incremental = false # deploy / CI: let sccache cache the (rarely-changing) depsRule of thumb: dev → incremental (sccache bypassed, that's fine). Release / CI → no incremental, so sccache caches dependency .rlibs and a repeat build recompiles only your changed crates, not every dependency.
⚠️ Gotcha #2 — fat-LTO is the slowest, heaviest release config
The "max performance" release profile many projects copy is also the slowest to build and the most disk/RAM-hungry:
[profile.release]
lto = true # FAT LTO: serial, whole-program; long link, multi-GB peak temp
codegen-units = 1 # zero codegen parallelismlto = true (fat) + codegen-units = 1 makes the link a single serial pass holding the whole program's bitcode — minutes-long links and large temp files that can fill a disk (a classic ENOSPC mid-deploy). For a server or CLI binary (not a hot micro-benchmark), switch to thin-LTO:
[profile.release]
lto = "thin" # parallel LTO, ~95–99% of fat-LTO's runtime perf
codegen-units = 16 # parallel codegen
strip = true # smaller binary
panic = "abort" # smaller + slightly faster; drops unwinding — verify you don't rely on catch_unwindExpected: release link time drops sharply and peak disk/RAM falls, for a ~1–3% runtime cost that is invisible on an I/O- or network-bound service. Keep fat-LTO only for a genuinely CPU-bound hot binary if you actually have one.
Docker-built Rust: BuildKit cache mounts
sccache/mold help host-native builds. A service that builds inside Docker gets nothing across image rebuilds, because any source change busts the layer that runs cargo build, recompiling every dependency. BuildKit cache mounts persist the cargo registry + target/ across rebuilds:
# syntax=docker/dockerfile:1.7
FROM rust:1-bookworm AS builder
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release && \
cp target/release/<bin> /app/<bin> # copy OUT of the mount before the runtime stage
FROM debian:bookworm-slim
COPY --from=builder /app/<bin> /usr/local/bin/<bin>The cp matters: the cache mount is build-only and is not present in the runtime stage, so copy the binary out of target/ first. Enable BuildKit with DOCKER_BUILDKIT=1 (Compose v2 has it on by default). A source-only change then recompiles just the changed crate — commonly a ~10× warm rebuild.
Caveat — `cargo-chef` and out-of-workspace path deps.cargo-chefis a popular alternative for Docker layer caching, but it panics reconstructing skeletons for crates that depend on path-deps outside the workspace (../sibling-crate):cargo chef cookfails atrecipe.rswith "No such file or directory". If your services use sibling path-deps, prefer cache mounts. For self-contained crates, chef is fine.
Shared / constrained machines: cap parallelism + guard disk
On a CI runner or a shared dev box, uncapped parallelism is dangerous. cargo spawns one rustc per codegen unit, and several concurrent cargo builds can spawn dozens of rustc processes that drive the load average far past the core count, thrashing swap until the box is unresponsive. Two cheap guards:
# cap build + test parallelism to roughly CPU/3..CPU/2 on a shared box
cargo build -j 4
cargo nextest run --test-threads 4
# refuse to start a release build that could ENOSPC (Rust target/ dirs run tens of GB)
FREE=$(df --output=avail / | tail -1) # KiB
[ "$FREE" -lt 26214400 ] && { echo "ABORT: <25G free — prune first"; exit 1; }Reclaim disk safest-first: docker builder prune -af (build cache), then cargo clean --release on idle repos. Avoid docker volume prune on a multi-project host — a dangling volume may be a stopped service's database.
Intentionally optional
- `CARGO_TARGET_DIR` (one shared `target/` across worktrees) — dedupes tens-to- hundreds of GB of repeated
target/, but breaks any tooling that reads a fixed per-worktreetarget/release/<bin>path (e.g. a volume mount to a built binary). Worth it only if nothing depends on those paths. - `profile.dev` `opt-level = 1` — faster dev/test runtime at some compile cost; apply per-crate where dev compile time hurts, not globally.
Monitoring
sccache --show-stats # hit rate + cache size
sccache --zero-stats # reset counters before a measurement
du -sh ~/.cache/sccache # disk usage (LRU-evicts at SCCACHE_CACHE_SIZE, default 10G)Adoption order
- Fix the sccache ↔ incremental conflict — free; un-deads an already-installed tool.
- thin-LTO for release — free; the biggest deploy-build +
ENOSPCwin. - BuildKit cache mounts for any Docker-built Rust service.
- Disk-guard + `-j` cap in build/deploy scripts on shared machines.