Performance & Profiling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0 and the 2024 edition, checked on 2026-10-06. Rust code is fast by default — no garbage collector, no boxing of values, monomorphised generics — but "fast by default" is not "as fast as possible". The method is always the same: build in release mode, measure with a benchmark, profile to find where the time goes, change one thing, and measure again. The community Rust Performance Book covers the topic in depth; this page is a practical summary.

Build settings

Always measure release builds (cargo build --release, cargo bench). Debug builds can be 10-100x slower and their profiles are misleading. Beyond the defaults, the profile settings that matter most:

[profile.release]
opt-level = 3            # default; "s"/"z" for size
lto = "fat"              # whole-program optimisation across crates: slower builds, often 5-20% faster code
codegen-units = 1        # better optimisation, slower builds
panic = "abort"          # smaller code, no unwinding (only if panics may end the process)
debug = "line-tables-only" # keep symbol names and lines for profilers, without full debug info

[profile.bench]
inherits = "release"
  • -C target-cpu=native (in RUSTFLAGS or .cargo/config.toml) lets LLVM use every instruction of the build machine’s CPU (AVX2, AVX-512): good for binaries that run where they are built, wrong for distributed binaries. A safer middle ground is a baseline such as x86-64-v3.

  • Profile-guided optimisation (PGO): build an instrumented binary (-Cprofile-generate), run a representative workload, merge the profiles with llvm-profdata, and rebuild with -Cprofile-use. Typical gains are 10-20% for branch-heavy code such as parsers and compilers; cargo-pgo automates the steps, and BOLT can be layered on top.

  • The global allocator can be swapped (below).

Allocation avoidance

Heap allocation is the most common avoidable cost in Rust programs: each String, Vec or Box created in a hot loop is a call into the allocator. Techniques:

  • pre-size collections with Vec::with_capacity/String::with_capacity, or reserve;

  • reuse buffers across iterations (buf.clear() keeps the capacity);

  • borrow instead of cloning (&str parameters, iterators over references);

  • return Cow<'_, str> from functions that usually do not modify their input;

  • use small-vector types (smallvec::SmallVec<[T; 8]>, arrayvec) for collections that are usually tiny, keeping them on the stack;

  • intern repeated strings, or use Arc<str> for shared immutable text.

use std::borrow::Cow;
use std::fmt::Write;

fn normalise(title: &str) -> Cow<'_, str> {
    if title.chars().all(|c| c.is_ascii_lowercase() || c == ' ') {
        Cow::Borrowed(title)                         // common case: no allocation
    } else {
        Cow::Owned(title.to_lowercase())
    }
}

fn render(lines: &[(&str, u16)]) -> String {
    let mut out = String::with_capacity(lines.len() * 24); // one allocation, sized up front
    let mut buf = String::new();                            // reused scratch buffer
    for (title, year) in lines {
        buf.clear();
        write!(buf, "{year}: {}", normalise(title)).expect("writing to a String cannot fail");
        out.push_str(&buf);
        out.push('\n');
    }
    out
}

fn main() {
    print!("{}", render(&[("dune", 1965), ("Emma", 1815)]));
}

Iterators and zero-cost abstractions

Iterator chains compile to the same machine code as hand-written loops and often better, because slice iterators let the compiler drop bounds checks. Prefer iter().map().sum() and zip over indexing loops (for i in 0..n { a[i] * b[i] }), which need a bounds check per access unless the compiler can prove them redundant. See Iterators.

Inlining and cold paths

  • #[inline] makes a function’s body available for inlining across crates (generic functions already are); it is a hint, rarely needed in application code. #[inline(always)] and #[inline(never)] are stronger hints, useful mainly to steer profiles and benchmarks.

  • #[cold] marks a function as unlikely to be called (error constructors, panic paths), and std::hint::cold_path() (1.95) marks a branch as unlikely, so the optimiser lays out the hot path first.

#[cold]
#[inline(never)]
fn invalid_isbn(text: &str) -> String {
    format!("invalid ISBN: {text}")
}

fn check(text: &str) -> Result<u64, String> {
    match text.parse::<u64>() {
        Ok(n) if text.len() == 13 => Ok(n),
        _ => {
            std::hint::cold_path(); // 1.95: this branch is unlikely
            Err(invalid_isbn(text))
        }
    }
}

fn main() {
    println!("{:?} {:?}", check("9780441013593"), check("12"));
}

Alternative allocators

The default global allocator is the system’s malloc. Multi-threaded, allocation-heavy programs — web services in particular — often run faster and fragment less with mimalloc or jemalloc (tikv-jemallocator). Swapping is two lines, but measure: the benefit depends entirely on the workload.

// Cargo.toml: mimalloc = "0.1"
use mimalloc::MiMalloc;

#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;

fn main() {
    let v: Vec<String> = (0..1000).map(|i| i.to_string()).collect();
    println!("{}", v.len());
}

Benchmarking

Use criterion (stable) for statistically sound micro-benchmarks, with std::hint::black_box to stop the optimiser from deleting the work; see Testing & Benchmarking. For whole programs, hyperfine compares command-line runs, and load generators (oha, k6) measure services. Benchmark on quiet machines, compare against a saved baseline, and look at variance, not just the mean. Mara & Holmes (Refactoring to Rust, chapter 6) benchmark the Rust replacement against the original implementation in the same harness — the right way to justify a rewrite.

Profiling CPU time

A sampling profiler interrupts the program many times per second and records the call stack; the hot functions are the ones that appear most often. On Linux, perf is the standard tool; flamegraphs visualise its output with each function’s width proportional to the time spent in it and below it.

# Keep symbols in release builds: debug = "line-tables-only" in [profile.release], or:
CARGO_PROFILE_RELEASE_DEBUG=line-tables-only cargo build --release

cargo install flamegraph
cargo flamegraph --bin shelf -- search dune     # writes flamegraph.svg (uses perf on Linux, dtrace on macOS)

cargo install samply
samply record ./target/release/shelf search dune # opens the Firefox Profiler in a browser

perf record -g ./target/release/shelf search dune && perf report

Read a flamegraph from the bottom up: wide plateaus at the top are where CPU time is actually spent. In async services, time blocked on I/O does not appear in CPU profiles; use tracing spans and tokio-console for that (see Axum: Performance Tuning). Gangappa & Rangaraj (Mastering Distributed Observability in Rust, chapter 9) also use continuous profiling, which samples production processes all the time.

Profiling memory

  • heaptrack (Linux) and DHAT (part of Valgrind, or the dhat crate) record every allocation with its call stack: where memory is allocated, how much, and how short-lived.

  • With jemalloc as the allocator, its built-in heap profiling (prof:true in MALLOC_CONF, via tikv-jemallocator/jemalloc_pprof) produces pprof profiles of live memory — useful for leaks in long-running services.

  • cargo bloat shows which functions and crates take space in the binary.

Compile-time performance

Long builds slow every iteration. cargo build --timings writes an HTML report showing each crate’s compile time and how much the build runs in parallel — look for long sequential chains and single huge crates. Cargo’s build-performance guide lists the levers: split large crates, trim dependencies and their default features, reduce generic code in hot crates, use cargo check while editing, a faster linker (LLD by default on x86-64 Linux since 1.90; mold), sccache, and the Cranelift codegen backend for debug builds on nightly.