Frontend Labs
⋯

36. Performance: release profiles, benchmarks, profiling, allocations

36.1 Always measure release builds

Debug builds are often 10 to 50 times slower than release builds for iterator-heavy code. Never benchmark cargo run without --release.

[profile.release]
lto = "fat"            # whole-program optimization; slower builds
codegen-units = 1
debug = "line-tables-only"   # symbols for perf/flamegraph without much size cost

[profile.bench]
inherits = "release"

36.2 Micro-benchmarking without fooling yourself

use std::hint::black_box;
use std::time::Instant;

fn sum_index(v: &[u64]) -> u64 { let mut s = 0; for i in 0..v.len() { s += v[i]; } s }
fn sum_iter(v: &[u64]) -> u64 { v.iter().sum() }

fn bench(name: &str, f: impl Fn() -> u64) {
    for _ in 0..3 { black_box(f()); }                  // warm-up: caches, branch predictor, CPU frequency
    let runs = 50;
    let t = Instant::now();
    for _ in 0..runs { black_box(f()); }               // black_box stops the optimizer from deleting the work
    println!("{name:<10} {:?}/run", t.elapsed() / runs);
}

fn main() {
    let v: Vec<u64> = (0..2_000_000).collect();
    bench("index", || sum_index(black_box(&v)));
    bench("iterator", || sum_iter(black_box(&v)));
}

On an Apple Silicon laptop this printed about 11 ms (index) and 7 ms (iterator) per run in a debug build, and about 0.2 ms for both with --release. The debug build was roughly 40 times slower, and in release LLVM proved i < v.len() and removed the bounds check, so the two versions compiled to nearly the same loop. A single run like this is noisy. Run it several times before you trust any difference.

For real benchmarks use criterion (statistics, outlier detection, comparison against a saved baseline) or divan. The methodology is the same as Go §37: warm up, repeat, compare distributions rather than single runs, and change one thing at a time.

36.3 Profiling

cargo install flamegraph
cargo flamegraph --bin api          # perf on Linux, dtrace/xctrace on macOS -> flamegraph.svg
perf record -g --call-graph dwarf target/release/api && perf report   # Linux, by hand

These are the same tools as OS Ch 81 and C §42.1. A Rust binary is a native binary, so everything that works for C works here. Use dhat or heaptrack for allocation profiling.

36.4 The usual wins, in the order they usually pay off

  1. Algorithm and I/O. Buffer your writes (Ch 24), batch syscalls, and stream instead of loading whole files.
  2. Allocations. Vec::with_capacity, reuse buffers (buf.clear() keeps the capacity), use &str instead of String in hot paths, and Cow<str> when you only sometimes need to allocate.
  3. Clones. Search the hot path for .clone() and .to_string().
  4. Data layout. Use contiguous Vecs instead of pointer-chasing (Vec<Box<T>>, linked lists), and struct-of-arrays for scans (C §42.2). Pad hot atomics to separate cache lines to avoid false sharing (crossbeam_utils::CachePadded).
  5. Allocator. Switching the global allocator to mimalloc or jemalloc is often a 5–20% gain for allocation-heavy servers (Ch 43 shows how to install one).
  6. Target CPU. RUSTFLAGS="-C target-cpu=native" enables AVX2 and similar instructions, but the binary then runs only on CPUs like the build machine.

Generated from real-life-rust-guide.md · 8 Oct 2026 16:36