Guide contents

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:

Text
  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.0

What to look for in the report:

  • Long bars on the critical path. A crate that everything else waits for (syn and 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.

Terminal
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 optimization

A 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:

Terminal
cargo install cargo-llvm-lines
cargo llvm-lines --release | head -20

For the example service (a debug build of the binary):

Text
  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_impl

The 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:

TOMLCargo.toml
[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 faster

Dependencies 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:

Terminal
readelf -p .comment target/debug/app
Text
String 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:

TOML.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:

Rust
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")));
}
Output
config from app.toml
config from /etc/app.toml
user=ana latency_ms=42 ok=true
hello, ada / hello, grace

The 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:

TOMLCargo.toml
[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
    end

Guidelines 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.
TOMLCargo.toml
[workspace]
members = ["crates/*"]
resolver = "3"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
TOMLcrates/billing/Cargo.toml
[dependencies]
serde.workspace = true
tokio.workspace = true

10. 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:

Terminal
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:

Text
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.1

cargo 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:

Text
tests/
└── it/
    ├── main.rs        // mod api; mod billing; mod search;
    ├── api.rs
    ├── billing.rs
    └── search.rs
TOMLCargo.toml
[[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:

YAML.github/workflows/ci.yml
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-targets

Swatinem/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:

TextDockerfile
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:

TOMLCargo.toml
[profile.dist]
inherits = "release"
lto = "fat"
codegen-units = 1
strip = true
Terminal
cargo 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 --timings and cargo llvm-lines before changing anything.
  • Use cargo check for the edit loop. Tune the dev profile: less debug info, optimized dependencies.
  • The linker matters: Rust uses lld by default on x86-64 Linux since 1.90; mold is faster still.
  • Keep generic functions small, use dyn in 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.