Workspace structure is the biggest lever on Rust build times after "do less work". This file covers how to measure, which structural changes help, and the profile, linker and CI settings that matter. Measure before and after every change; intuition about Rust build times is wrong surprisingly often.
Contents
- Measure first
- Reading cargo build --timings
- Structural fixes
- Dependency diet
- Profile settings for fast iteration
- Linking
- Codegen bloat: generics and macros
- Avoiding needless rebuilds
- CI build speed
- Nightly-only options
- Playbook
Measure first
cargo clean && cargo build --workspace --timings # writes target/cargo-timings/cargo-timing.html
touch crates/shop-domain/src/lib.rs && cargo build --timings # incremental: what rebuilds after an edit?
cargo build --release --timings
cargo check --workspace # front-end only (no codegen): your editor's loopMeasure the scenario that hurts: clean CI build, incremental build after editing a low-level crate, or
cargo test link time. They have different fixes.
Additional tools:
cargo llvm-lines -p crate(cargo-llvm-lines): which functions generate the most LLVM IR (monomorphisation bloat).cargo bloat --release --crates(cargo-bloat): binary size per crate.RUSTFLAGS="-Zself-profile"(nightly) +measureme/summarizefor compiler-pass detail.cargo tree --workspace -e normal | wc -landcargo tree -d: how big the graph is, and duplicates.
Reading cargo build --timings
The HTML report shows each crate as a bar on a timeline, coloured by phase (front-end/metadata vs codegen), plus a concurrency graph of how many units ran in parallel.
| Pattern in the report | Meaning | Fix |
|---|---|---|
| One long bar with nothing else running | a crate on the critical path that everything waits for | split it, move dependents off it, or shrink it |
| Long tail where one leaf crate (often the binary) builds alone at the end | the top crate is huge | move code into libraries below it that build in parallel |
| Many short bars but low concurrency early | a deep chain of dependencies (A -> B -> C -> D) | flatten: make crates depend on the bottom directly, not through each other |
Proc-macro crates (syn, serde_derive) early and long | macro infrastructure blocks everything | fewer macro crates, smaller syn feature sets, [profile.dev.build-override] opt-level |
Build script (build-script-build) runs long | native compilation (cc, cmake), bindgen | prebuilt/system library, cache, move codegen to xtask |
| Codegen phase dominates | lots of monomorphised generic code or opt-level high in dev | see codegen bloat; lower dev opt-level of own crates |
Pipelining: a crate can start compiling as soon as its dependencies' metadata is ready, before their codegen finishes. A dependent therefore waits on the dependency's front-end time, not its full time; this is why splitting a crate whose front-end (type checking) is slow pays off most.
Structural fixes
- Split the crate on the critical path into pieces that do not depend on each other. Two halves that depend on each other in a chain do not help.
- Make the graph wide, not deep.
app -> service -> repo -> modelserialises four crates; ifapp,serviceandrepoeach depend only onmodel, three of them build in parallel. - Keep volatile code high in the graph. An edit rebuilds the edited crate and everything that depends on it. Code that changes daily (business logic, handlers) belongs in crates near the top; stable code (types, protocols, utilities) at the bottom.
- Isolate heavy dependencies behind a crate boundary so most edits never touch the crates that link them.
shop-infrapays for sqlx;shop-domainand its tests do not. - Thin binaries.
apps/server/src/main.rswires things together; logic lives in libraries. Large binary crates rebuild entirely on every edit and are slow to link. - Consolidate integration tests into one binary per crate (
tests/it/main.rs): N test files = N link steps. - Do not over-split. Each crate adds metadata generation, rmeta loading in every dependent, and a manifest to maintain. Merging tiny crates that always change together can speed builds up.
Dependency diet
default-features = falseat the root for heavy crates; enable only what is used (see features-and-unification.md).- Look for duplicates (
cargo tree -d): two versions ofsyn,hyperorwindows-sysdouble their compile time. Align versions, or wait for upstream. - Replace heavy dependencies for trivial uses (a regex for one fixed string, a full HTTP client for one GET in a build script).
- Check
cargo build --timingsfor the top 10 slowest dependencies; for each, ask who needs it (cargo tree -i heavy-crate). - Prefer
serdederives over handwritten macros that each pull their ownsyn; prefer one well-known macro ecosystem over several.
Profile settings for fast iteration
# root Cargo.toml
[profile.dev]
debug = "line-tables-only" # 1.71+: much less debuginfo to generate and link; backtraces keep line numbers
[profile.dev.package."*"]
opt-level = 2 # dependencies are compiled once and cached: make them fast at runtime
[profile.dev.build-override]
opt-level = 3 # build scripts and proc-macros (incl. their deps) execute faster
[profile.release]
lto = "thin"
codegen-units = 1 # release speed; costs build time, so not for dev
[profile.ci] # cargo build --profile ci: optimised enough for tests, faster than release
inherits = "dev"
debug = false
incremental = falsedebug = falseor"line-tables-only"in dev is often the single biggest win for link times on large projects; switch to full debuginfo temporarily when you need a debugger.opt-level = 1for your own crates in dev can make tests that do real work much faster at a small build-time cost; measure.codegen-units = 1andlto = "fat"belong in a dedicateddistprofile, not in the everydayrelease, if release builds are part of the development loop.incremental = true(dev default) is good locally and useless in CI.
Linking
Linking is serial per binary and dominates incremental builds of large binaries and test suites.
| Platform | Option |
|---|---|
| x86_64 Linux | Rust 1.90+ uses rust-lld by default on x86_64-unknown-linux-gnu; before that, or for even faster links, use mold |
| Linux (any) | mold: linker = "clang", rustflags = ["-C", "link-arg=-fuse-ld=mold"] under [target.<triple>] |
| macOS | the default Apple linker (ld-prime, Xcode 15+) is already fast; split-debuginfo = "unpacked" is the macOS dev default |
| Windows MSVC | rust-lld: linker = "rust-lld.exe" under [target.x86_64-pc-windows-msvc] |
Put linker choices in .cargo/config.toml only if every developer and CI image has the linker installed;
otherwise in $CARGO_HOME/config.toml per machine. Fewer, larger test binaries also mean fewer link steps.
Codegen bloat: generics and macros
- Generic functions are monomorphised in each crate that instantiates them, per type. A generic function used with 30 types in 10 crates is compiled up to 300 times.
- Non-generic inner function trick: keep the generic shim thin and forward to a concrete function.
pub fn read_config<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<String> {
fn inner(path: &std::path::Path) -> std::io::Result<String> {
std::fs::read_to_string(path) // compiled once
}
inner(path.as_ref()) // only this line is monomorphised per P
}- Prefer
&dyn Trait/Box<dyn Trait>at boundaries that are not hot. - Large
#[derive]on hundreds of types (serde, clap) generate a lot of code; consider whether all of it is needed (e.g. onlySerialize). - Use
cargo llvm-linesto find the top offenders before refactoring.
Avoiding needless rebuilds
| Cause | Symptom | Fix |
|---|---|---|
Different feature sets between commands (-p vs --workspace, build vs test) | dependencies recompile when switching | consistent selections, workspace-hack crate |
RUSTFLAGS differ between terminal, IDE and scripts | everything rebuilds | same flags everywhere; separate target-dir for the IDE |
rust-analyzer and terminal share target/ with different settings | they invalidate each other, lock contention | rust-analyzer cargo.targetDir = true (uses a subdirectory) |
Build script without rerun-if-changed | crate rebuilds whenever any file in the package changes | emit precise cargo::rerun-if-changed= / rerun-if-env-changed= |
| Build script that touches files or embeds timestamps | rebuilds every time | deterministic outputs, write only when content changes |
env!("GIT_HASH") via build.rs in a low crate | every commit rebuilds the world | put version info in the top binary crate only |
| Editing a crate at the bottom of the graph | everything above rebuilds | expected; move volatile code up |
CI build speed
CARGO_INCREMENTAL=0; cache dependencies (rust-cache) or compilations (sccache); see ci-and-tooling.md.- Use
cargo check/clippyfor the lint job (no codegen),cargo nextestwith--partitionfor tests. - Build tests once and reuse:
cargo nextest archivethen run the archive on several machines. - Build only what changed on PRs, but keep a full workspace build on
main. - Avoid
--releasein CI unless tests need speed; a customciprofile is a middle ground. - Large runners help less than expected when the critical path is one serial crate; fix the structure first.
Nightly-only options
These are not stable; useful for local experiments, risky as a team default:
- Parallel front-end:
RUSTFLAGS="-Z threads=8". - Cranelift codegen backend for dev builds (
rustup component add rustc-codegen-cranelift-previewon nightly, then-Zcodegen-backend=cranelift): much faster debug codegen, slower generated code. -Zshare-genericsto reuse monomorphisations from dependencies, and-Zself-profilefor profiling the compiler.
Worked example: splitting the critical path
Before: shop is one 60k-line library crate plus a binary. --timings shows shop taking 95 s with one core
busy for most of it, then the binary linking for 12 s. Editing any handler rebuilds all 95 s.
before: serde, tokio, sqlx ... -> shop (95 s, serial) -> shop-server (12 s link)
after: serde ... -> shop-domain (14 s)
tokio ... -> shop-app (22 s) -> shop-api (18 s) -> shop-server (thin, 6 s link)
sqlx ... -> shop-infra (25 s) /shop-domainand the dependency builds overlap;shop-appandshop-infrabuild in parallel onceshop-domain's metadata is ready.- A handler edit now rebuilds only
shop-apiand the thin binary: about 25 s instead of 107 s. - Clean build wall time drops less than incremental time (more crates, same total work); that is expected.
debug = "line-tables-only"in dev cut the link from 12 s to 6 s independently of the split.
Numbers are illustrative; the shape (incremental time falling far more than clean time) is typical.
Playbook
cargo build --workspace --timingsfrom clean and after a typical edit; save both reports.- Identify the critical path and the slowest 10 units.
- Cheap wins first:
debug = "line-tables-only",[profile.dev.package."*"] opt-level, faster linker, test binary consolidation, disabling unused default features. - Structural changes next: split the crate on the critical path, widen deep chains, isolate heavy deps.
/tmp/crate_graph .to confirm the new layering and no cycles.- Re-measure; keep the change only if the numbers moved.
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-error-handling-thiserror-anyhow-designer
Design and review Rust error handling with thiserror 2 and anyhow 1: library vs application split, typed error enums with #[from], #[source], #[error(transparent)] and #[non_exhaustive], ErrorKind patterns, context via .context()/.with_context(), bail!/ensure!, source() chains, {:#} and {:?} reports, RUST_BACKTRACE/RUST_LIB_BACKTRACE, Box<dyn Error + Send + Sync>, main -> anyhow::Result or ExitCode, unwrap/expect/panic policy, Option to Result, collecting iterators of Results, axum IntoResponse mapping, retryable errors, tracing, async Send bounds, testing with matches! and downcast_ref, no_std core::error::Error, and snafu, eyre/color-eyre, miette, error-stack.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-tokio-async-service-builder
Build and review production async Rust services on Tokio 1.x and axum 0.8: runtime choice (#[tokio::main] multi_thread vs current_thread, Builder), axum routing with /{id} paths, State, extractors, IntoResponse error types, axum::serve with_graceful_shutdown, tower/tower-http middleware (TraceLayer, TimeoutLayer, CorsLayer, ConcurrencyLimitLayer, RequestBodyLimitLayer), Arc<AppState>, std vs tokio Mutex, JoinSet, TaskTracker, CancellationToken, select! cancel safety, bounded mpsc, oneshot, broadcast, watch, actors, Semaphore, timeouts, retries, spawn_blocking, sqlx PgPool, reqwest Client reuse, tracing, tests with start_paused and oneshot, Dockerfile. Fixes 'future cannot be sent between threads safely', E0373, Rc or MutexGuard held across await, 'Cannot start a runtime from within a runtime', 'Path segments must not start with :'. Ships std-only Rust scanners and a compiling axum 0.8 scaffold.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.