Lesson 90 of 94
Compile Times & Build Engineering
Rust does a lot of work at build time, including type and borrow checking, monomorphizing every generic function, and running LLVM's optimizer. Long builds are a common complaint from Rust teams, and a lead who can keep builds fast keeps the team productive.
The good news: most of the time goes into a few predictable places, and the fixes are well known. The numbers in this lesson come from a small axum + Tokio + serde service built on a 4-core cloud VM with Rust 1.94:
| Build | Time |
|---|---|
cargo check, from scratch |
10.5 s |
cargo build (debug), from scratch |
10.6 s |
cargo build --release, from scratch |
15.5 s |
cargo check after editing one file |
0.1 s |
cargo build after editing one file |
0.4 s |
cargo build --release after editing one file |
1.0 s |
Two things stand out. Clean builds are dominated by dependencies (75 compilation units for four direct dependencies), and incremental builds of your own small crate are fast. Real projects get slow when their own code gets large, generic-heavy, or badly split. Both problems have solutions.
Part 1: Measuring
1. Where does the time go? cargo build --timings
cargo build --timings writes an HTML report to target/cargo-timings/ with a timeline of every crate compilation, how many ran in parallel, and which ones were waiting on others. For the example service, the slowest units were:
5.06s tokio 1.53.1
3.11s axum 0.8.9
3.06s syn 3.0.6
2.28s serde_derive 1.0.229
1.81s serde_core 1.0.229
1.46s futures-util 0.3.34
1.24s axum-core 0.5.6
1.16s serde_json 1.0.151
1.10s hyper 1.11.1
0.97s http 1.5.0What to look for in the report:
- Long bars on the critical path. A crate that everything else waits for (
synand derive macros are classics) delays the whole build, however many cores you have. - Low concurrency. If the CPU-usage graph shows long stretches with one or two crates compiling, your crate graph is too deep and narrow (example 9).
- Your own crates. If they're at the end of the timeline and take most of the time, the fixes in Part 3 apply.
2. cargo check for the edit loop
cargo check runs the compiler up to type and borrow checking and skips code generation. It's what rust-analyzer runs in your editor, and it's the right command while writing code. Build only when you need to run something.
cargo check # type and borrow errors, fastest
cargo clippy # check + lints
cargo build # + code generation, debug
cargo test --no-run # compile tests without running them
cargo build --release # + full optimizationA tool like bacon (or cargo watch) reruns check or clippy on every save, which gives a feedback loop close to an IDE's.
3. Finding code bloat with cargo llvm-lines
When your own crate is slow to build, the usual cause is how much LLVM IR it generates, which mostly comes from generic functions instantiated many times. cargo llvm-lines (a separate tool) lists the functions that produce the most IR, counting all their copies:
cargo install cargo-llvm-lines
cargo llvm-lines --release | head -20For the example service (a debug build of the binary):
Lines Copies Function name
----- ------ -------------
24009 762 (TOTAL)
842 (3.5%, 3.5%) 12 (1.6%, 1.6%) std::thread::local::LocalKey<T>::try_with
828 (3.4%, 7.0%) 1 (0.1%, 1.7%) axum::routing::method_routing::MethodRouter<S,E>::merge_for_path
792 (3.3%, 10.3%) 2 (0.3%, 2.0%) tokio::runtime::scheduler::current_thread::CoreGuard::block_on::{{closure}}
760 (3.2%, 13.4%) 1 (0.1%, 2.1%) matchit::tree::Node<T>::insert
485 (2.0%, 15.4%) 1 (0.1%, 2.2%) matchit::tree::Node<T>::insert_route
457 (1.9%, 17.3%) 4 (0.5%, 2.8%) tokio::runtime::park::CachedParkThread::block_on
434 (1.8%, 19.2%) 2 (0.3%, 3.0%) hashbrown::raw::RawTable<T,A>::clone_from_implThe first column is lines of LLVM IR (and the running total), the second is the number of monomorphized copies. Here nothing stands out, which is normal for a small crate. In a large codebase, look for your own generic functions near the top with dozens or hundreds of copies. A function with hundreds of copies is a candidate for the fixes in example 6. For deeper analysis, -Zself-profile on nightly records where the compiler spends time per query, and summarize (from the measureme tools) prints it.
Part 2: Configuration that helps immediately
4. Tune the dev profile
The default dev profile generates full debug info and no optimizations for everything. Two changes help most projects:
[profile.dev]
debug = "line-tables-only" # backtraces with file:line, much less debug info to generate and link
[profile.dev.package."*"]
opt-level = 1 # dependencies: optimized once, cached, fast at runtime
debug = false # no debug info for dependencies
[profile.dev.build-override]
opt-level = 3 # build scripts and proc macros run fasterDependencies compile once and are cached, so optimizing them costs little after the first build and makes debug builds of your service run much faster (especially for serialization, compression, and crypto). Your own code stays unoptimized for fast rebuilds and a good debugging experience. If you step through dependencies in a debugger, keep their debug info.
5. The linker
Every build of a binary ends with linking, which for a large program with debug info can take seconds, even when you changed one line. Since Rust 1.90, the compiler uses LLVM's lld linker by default on x86_64-unknown-linux-gnu, which is much faster than the traditional GNU ld. You can confirm what a binary was linked with:
readelf -p .comment target/debug/appString dump of section '.comment':
[ 1] GCC: (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
[ 2e] Linker: LLD 21.1.8
[ 8c] rustc version 1.94.1 (e408947bf 2026-03-25)On other platforms, or for even faster links on Linux, configure a faster linker such as mold in .cargo/config.toml:
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]macOS's default linker (ld-prime in recent Xcode versions) is already fast. On Windows, lld-link helps with large binaries.
Part 3: Code structure
6. Keep generic functions small
Every instantiation of a generic function is compiled separately (How the Rust Compiler Works). A 200-line generic function used with 30 types is 6,000 lines of code for LLVM. Three techniques cut that down:
use std::fmt::Display;
use std::path::Path;
// 1. The inner-function trick: a thin generic shell around a non-generic body.
pub fn read_config<P: AsRef<Path>>(path: P) -> String {
fn inner(path: &Path) -> String {
// imagine 200 lines of parsing here, compiled once
format!("config from {}", path.display())
}
inner(path.as_ref())
}
// 2. Take `&dyn Trait` where the call isn't hot: one copy for every type.
pub fn log_event(fields: &[(&str, &dyn Display)]) -> String {
fields.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join(" ")
}
// 3. Convert to a concrete type at the boundary instead of propagating generics.
pub fn greet(name: impl Into<String>) -> String {
let name: String = name.into(); // one conversion; the rest of the code isn't generic
format!("hello, {name}")
}
fn main() {
println!("{}", read_config("app.toml"));
println!("{}", read_config(std::path::PathBuf::from("/etc/app.toml")));
println!("{}", log_event(&[("user", &"ana"), ("latency_ms", &42), ("ok", &true)]));
println!("{} / {}", greet("ada"), greet(String::from("grace")));
}config from app.toml
config from /etc/app.toml
user=ana latency_ms=42 ok=true
hello, ada / hello, graceThe rule of thumb: generics for small, hot functions where inlining and specialization pay off; dyn or concrete types for large functions and for code that's called rarely (configuration, logging, error handling, startup).
7. Proc macros and build scripts
Procedural macros (#[derive(Serialize)], #[tokio::main], #[async_trait]) are compiled as separate crates, usually on top of syn, and they run during every compilation of the code that uses them. Build scripts (build.rs) run before the crate compiles. Both sit on the critical path.
| Cost | Mitigation |
|---|---|
syn, quote, and derive crates compiled before anything that uses them |
unavoidable once; keep them optimized with build-override (example 4) |
| derive macros on hundreds of types | derive only the traits you need; avoid #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ...)] by habit |
#[async_trait] on traits that no longer need it |
native async fn in traits (Rust 1.75+) where you don't need dyn |
heavy build.rs (code generation, compiling C code) |
cache outputs, and tell cargo when to rerun: println!("cargo::rerun-if-changed=proto/") |
| macro-heavy frameworks | measure with --timings before choosing between two libraries |
8. Turn off features you don't use
Many crates enable a lot by default. Every enabled feature is code to compile and sometimes additional dependencies. default-features = false plus an explicit list often halves a dependency's build time:
[dependencies]
# Everything, including the multi-thread scheduler, fs, process, and signal support:
# tokio = { version = "1", features = ["full"] }
# Only what the service uses:
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
# reqwest 0.13 without HTTP/2, charset detection, and system proxy support; keep TLS (rustls) and JSON:
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }cargo tree -e features shows which features are enabled and by whom. Remember that features are unified across the build: if any crate in the dependency graph enables a feature, everyone gets it.
9. Workspace layout: wide, not deep
Cargo compiles independent crates in parallel, but a crate can't start until its dependencies are done, and a crate itself is compiled mostly as one unit. Two layouts of the same code:
graph TD
subgraph Deep["Deep: every change rebuilds everything below it"]
A1[app] --> B1[api] --> C1[service] --> D1[domain] --> E1[util]
end
subgraph Wide["Wide: independent crates build in parallel"]
A2[app] --> B2[api]
A2 --> C2[billing]
A2 --> D2[search]
B2 --> E2[domain]
C2 --> E2
D2 --> E2
endGuidelines for a workspace:
- Split a large crate when its incremental builds get slow, along boundaries that change independently (a feature area, a storage layer, generated code).
- Keep the crates at the bottom (shared domain types, utilities) small and stable: a change there rebuilds everything above them.
- Put heavy dependencies (database drivers, gRPC, image codecs) into the crates that need them, not into a shared "common" crate.
- Share versions with
[workspace.dependencies]so the whole workspace uses one version of each dependency.
[workspace]
members = ["crates/*"]
resolver = "3"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }[dependencies]
serde.workspace = true
tokio.workspace = true10. Duplicate dependency versions
Two versions of the same crate in one build means compiling it twice, and often subtle type mismatches ("expected http::Request, found http::Request"). cargo tree -d lists duplicates and who pulls them in:
cargo tree -d # all duplicated packages
cargo tree -i syn@1.0.109 # who depends on this version? (-i = invert)In the example service, a direct dependency on an old syn 1.x (standing in for an old derive crate) creates a second copy next to the syn used by serde and tokio:
syn v1.0.109
└── my-service v0.1.0 (/work/my-service)
syn v3.0.6
├── serde_derive v1.0.229 (proc-macro)
│ └── serde v1.0.229
└── tokio-macros v2.7.2 (proc-macro)
└── tokio v1.53.1cargo tree -d may also list the same version twice: that means it's compiled twice with different features or for different targets (for example once for proc macros running on the host and once for your program), which also costs build time.
Fix duplicates by upgrading the crate that depends on the old version, or by choosing alternatives. cargo deny check bans (next lesson) can fail CI when new duplicates appear.
11. Integration tests: one binary, not twenty
Every file in tests/ is compiled as a separate crate and linked into a separate binary. Twenty test files mean twenty link steps, each including all your dependencies. Put integration tests into one binary with modules:
tests/
└── it/
├── main.rs // mod api; mod billing; mod search;
├── api.rs
├── billing.rs
└── search.rs[[test]]
name = "it"
path = "tests/it/main.rs"On large projects this alone can cut cargo test build time dramatically, because linking dominates test builds. Similarly, doc tests are compiled as many small programs (Rust 2024 merges them into fewer binaries, which helps).
Part 4: CI and containers
12. Caching in CI
A CI job that builds from scratch every time pays the full dependency cost on every push. Cache the cargo registry and the target/ directory, keyed by the lockfile:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2 # caches ~/.cargo and target/, keyed by Cargo.lock
- run: cargo fmt --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo test --all-targetsSwatinem/rust-cache also removes build outputs of your own crates before saving, so the cache holds only dependencies (which change rarely). For a shared cache across many machines or branches, sccache stores compiled crates in S3, GCS, or a GitHub Actions cache.
In CI you can also turn off incremental compilation (CARGO_INCREMENTAL=0), which only helps repeated local builds and makes the cache larger.
13. Docker builds with cargo-chef
A naive COPY . . && cargo build --release in a Dockerfile rebuilds every dependency whenever any source file changes. cargo-chef splits the build so dependencies get their own cached layer:
FROM rust:1.94 AS chef
RUN cargo install cargo-chef --locked
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# This layer is cached until Cargo.toml or Cargo.lock change.
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin my-service
FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/my-service /usr/local/bin/my-service
ENTRYPOINT ["/usr/local/bin/my-service"]The final image contains one binary and the C library, typically tens of megabytes.
14. Faster code generation: Cranelift and the parallel front end
Two nightly compiler features target debug build times:
| Feature | What it does | How |
|---|---|---|
| Cranelift backend | generates code with Cranelift instead of LLVM: less optimized, much faster to produce | rustup component add rustc-codegen-cranelift-preview --toolchain nightly, then -Zcodegen-backend=cranelift for the dev profile |
| Parallel front end | runs type checking and other front-end work on several threads | RUSTFLAGS="-Zthreads=8" on nightly |
Both are opt-in for debug builds; release builds keep using LLVM. They're worth trying on large codebases where incremental debug builds take many seconds. Measure with --timings, as always.
15. Release builds: a deliberate trade-off
Release settings that improve runtime speed and size usually cost build time. Choose per project:
| Setting | Runtime effect | Build time effect |
|---|---|---|
opt-level = 3 (default) |
fastest code | baseline |
codegen-units = 1 |
a few percent faster, smaller | slower (no parallel code generation) |
lto = "thin" |
often faster, smaller | moderately slower |
lto = "fat" |
sometimes a bit more than thin | much slower, single-threaded link |
debug = "line-tables-only" |
none (only file size) | slightly slower |
panic = "abort" |
smaller, slightly faster | slightly faster |
A common setup: the default release profile for CI and tests, plus a custom profile for the artifacts you ship:
[profile.dist]
inherits = "release"
lto = "fat"
codegen-units = 1
strip = truecargo build --profile dist # output in target/dist/16. A build-time checklist
| Symptom | Check | Fix |
|---|---|---|
| slow edit-compile loop | are you running build or test when check would do? |
cargo check, rust-analyzer, bacon |
| slow clean builds | --timings: which dependencies dominate? |
drop unused features, fewer heavy dependencies, CI cache |
| slow incremental builds of your crate | cargo llvm-lines: generic bloat? one huge crate? |
inner functions, dyn in cold code, split the crate |
| slow final step | linker time in --timings |
lld or mold, less debug info |
| slow debug runtime | debug builds of heavy dependencies | [profile.dev.package."*"] opt-level = 1 |
slow cargo test builds |
many files in tests/ |
one integration test binary |
| two versions of a crate | cargo tree -d |
upgrade, unify with [workspace.dependencies] |
| slow Docker builds | dependencies rebuilt every time | cargo-chef |
| slow CI | no cache, or cache includes your own crates | Swatinem/rust-cache, sccache, CARGO_INCREMENTAL=0 |
Summary
- Rust does a lot of work at compile time, so builds can be slow. Clean builds are dominated by dependencies; incremental builds by the size and genericity of your own crates.
- Measure with
cargo build --timingsandcargo llvm-linesbefore changing anything. - Use
cargo checkfor the edit loop. Tune the dev profile: less debug info, optimized dependencies. - The linker matters: Rust uses
lldby default on x86-64 Linux since 1.90;moldis faster still. - Keep generic functions small, use
dynin cold code, derive only what you need, and disable default features you don't use. - Structure workspaces wide rather than deep, unify dependency versions, and build integration tests into one binary.
- Cache dependencies in CI and in Docker (
rust-cache,sccache,cargo-chef). Choose release settings (LTO, codegen units) as a deliberate runtime-versus-build-time trade-off.
Next: Dependencies, Security & CI: auditing and vetting dependencies, licenses, MSRV, semver checks, and a complete CI pipeline.
