Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

Build Performance in Workspaces

Reference for rust-cargo-workspace-designer · 214 lines

Programming / Rustv1.0.0Updated OCT 21 20267 references6 scripts

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

bash
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 loop

Measure 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 / summarize for compiler-pass detail.
  • cargo tree --workspace -e normal | wc -l and cargo 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 reportMeaningFix
One long bar with nothing else runninga crate on the critical path that everything waits forsplit it, move dependents off it, or shrink it
Long tail where one leaf crate (often the binary) builds alone at the endthe top crate is hugemove code into libraries below it that build in parallel
Many short bars but low concurrency earlya 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 longmacro infrastructure blocks everythingfewer macro crates, smaller syn feature sets, [profile.dev.build-override] opt-level
Build script (build-script-build) runs longnative compilation (cc, cmake), bindgenprebuilt/system library, cache, move codegen to xtask
Codegen phase dominateslots of monomorphised generic code or opt-level high in devsee 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

  1. 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.
  2. Make the graph wide, not deep. app -> service -> repo -> model serialises four crates; if app, service and repo each depend only on model, three of them build in parallel.
  3. 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.
  4. Isolate heavy dependencies behind a crate boundary so most edits never touch the crates that link them. shop-infra pays for sqlx; shop-domain and its tests do not.
  5. Thin binaries. apps/server/src/main.rs wires things together; logic lives in libraries. Large binary crates rebuild entirely on every edit and are slow to link.
  6. Consolidate integration tests into one binary per crate (tests/it/main.rs): N test files = N link steps.
  7. 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 = false at the root for heavy crates; enable only what is used (see features-and-unification.md).
  • Look for duplicates (cargo tree -d): two versions of syn, hyper or windows-sys double 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 --timings for the top 10 slowest dependencies; for each, ask who needs it (cargo tree -i heavy-crate).
  • Prefer serde derives over handwritten macros that each pull their own syn; prefer one well-known macro ecosystem over several.

Profile settings for fast iteration

toml
# 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 = false
  • debug = false or "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 = 1 for your own crates in dev can make tests that do real work much faster at a small build-time cost; measure.
  • codegen-units = 1 and lto = "fat" belong in a dedicated dist profile, not in the everyday release, 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.

PlatformOption
x86_64 LinuxRust 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>]
macOSthe default Apple linker (ld-prime, Xcode 15+) is already fast; split-debuginfo = "unpacked" is the macOS dev default
Windows MSVCrust-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.
rust
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. only Serialize).
  • Use cargo llvm-lines to find the top offenders before refactoring.

Avoiding needless rebuilds

CauseSymptomFix
Different feature sets between commands (-p vs --workspace, build vs test)dependencies recompile when switchingconsistent selections, workspace-hack crate
RUSTFLAGS differ between terminal, IDE and scriptseverything rebuildssame flags everywhere; separate target-dir for the IDE
rust-analyzer and terminal share target/ with different settingsthey invalidate each other, lock contentionrust-analyzer cargo.targetDir = true (uses a subdirectory)
Build script without rerun-if-changedcrate rebuilds whenever any file in the package changesemit precise cargo::rerun-if-changed= / rerun-if-env-changed=
Build script that touches files or embeds timestampsrebuilds every timedeterministic outputs, write only when content changes
env!("GIT_HASH") via build.rs in a low crateevery commit rebuilds the worldput version info in the top binary crate only
Editing a crate at the bottom of the grapheverything above rebuildsexpected; 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 / clippy for the lint job (no codegen), cargo nextest with --partition for tests.
  • Build tests once and reuse: cargo nextest archive then run the archive on several machines.
  • Build only what changed on PRs, but keep a full workspace build on main.
  • Avoid --release in CI unless tests need speed; a custom ci profile 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-preview on nightly, then -Zcodegen-backend=cranelift): much faster debug codegen, slower generated code.
  • -Zshare-generics to reuse monomorphisations from dependencies, and -Zself-profile for 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.

text
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-domain and the dependency builds overlap; shop-app and shop-infra build in parallel once shop-domain's metadata is ready.
  • A handler edit now rebuilds only shop-api and 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

  1. cargo build --workspace --timings from clean and after a typical edit; save both reports.
  2. Identify the critical path and the slowest 10 units.
  3. Cheap wins first: debug = "line-tables-only", [profile.dev.package."*"] opt-level, faster linker, test binary consolidation, disabling unused default features.
  4. Structural changes next: split the crate on the critical path, widen deep chains, isolate heavy deps.
  5. /tmp/crate_graph . to confirm the new layering and no cycles.
  6. Re-measure; keep the change only if the numbers moved.
More in Rust

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.