Skip to main content
Rust advanced Lesson 29 of 30

Performance in Rust

Profiling, benchmarking, zero-cost abstractions, SIMD, memory layout, and optimization techniques in Rust.

Always Measure First

The cardinal rule of performance optimization is to measure before changing anything. Optimizing the wrong part of a program is wasted effort, and some “obvious” optimizations actually make things slower due to cache effects or compiler inlining. Rust provides first-class tools for both microbenchmarking and profiling — use them.

[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "perf_bench"
harness = false
// benches/perf_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn sum_iter(v: &[i64]) -> i64 {
    v.iter().sum()
}

fn sum_loop(v: &[i64]) -> i64 {
    let mut total = 0i64;
    for &x in v {
        total += x;
    }
    total
}

fn bench_sum(c: &mut Criterion) {
    let data: Vec<i64> = (0..10_000).collect();

    let mut group = c.benchmark_group("sum");
    // black_box prevents the compiler from optimizing away the computation entirely
    group.bench_function("iterator", |b| b.iter(|| sum_iter(black_box(&data))));
    group.bench_function("loop",     |b| b.iter(|| sum_loop(black_box(&data))));
    group.finish();
}

criterion_group!(benches, bench_sum);
criterion_main!(benches);
cargo bench                      # run all benchmarks in release mode
cargo bench -- sum               # run only benchmarks matching "sum"

Release Mode and Compile-Time Options

Debug builds include overflow checks, debug assertions, and no optimization. Release builds enable aggressive optimization passes that can make code 10–100x faster. Always use release mode for any number you intend to report.

cargo build --release            # optimized binary
cargo run --release              # run with optimizations
cargo test --release             # find bugs that only appear with optimizations

Fine-tune release settings in Cargo.toml:

[profile.release]
opt-level = 3       # maximum optimization (default)
lto = true          # link-time optimization — slower build, faster binary
codegen-units = 1   # single codegen unit — better optimization, slower compile
panic = "abort"     # smaller binary; no unwinding on panic
strip = true        # strip debug symbols from the binary

# A fast debug profile — slower than release but much faster than debug
[profile.dev]
opt-level = 1

Zero-Cost Abstractions

Rust’s iterators, generics, and closures are zero-cost abstractions — they compile to the same machine code as equivalent hand-written C. The compiler inlines and specializes generic code through monomorphization, and the optimizer collapses iterator chains into tight loops with no intermediate allocations.

fn main() {
    let data: Vec<i32> = (0..1_000_000).collect();

    // Iterator chain — elegant and readable
    let result_iter: i32 = data.iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| x * x)
        .sum();

    // Equivalent hand-written loop — same speed in release mode
    let mut result_loop = 0i32;
    for &x in &data {
        if x % 2 == 0 {
            result_loop += x * x;
        }
    }

    assert_eq!(result_iter, result_loop);
    // Both compile to essentially identical machine code
}

Avoiding Unnecessary Allocations

Heap allocation is not free — it involves system calls, bookkeeping, and cache pressure. The fastest allocation is no allocation. Common strategies: use slices (&[T]) instead of Vec<T> when you only need to read, use &str instead of String, and prefer stack allocation for small, fixed-size data.

// Prefer &str over String for read-only string data
fn count_vowels(s: &str) -> usize {  // takes a borrow, no allocation
    s.chars().filter(|c| "aeiou".contains(*c)).count()
}

// Prefer &[T] over Vec<T> for read-only slices
fn sum_slice(data: &[i32]) -> i32 {  // works on Vec, array, or any contiguous data
    data.iter().sum()
}

// Stack-allocated fixed-size array — no heap allocation
fn moving_average(data: &[f64], window: usize) -> Vec<f64> {
    data.windows(window)
        .map(|w| w.iter().sum::<f64>() / w.len() as f64)
        .collect()
    // .windows() yields &[f64] slices into the original data — no copying
}

fn main() {
    let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
    println!("{:?}", moving_average(&data, 3)); // [2.0, 3.0, 4.0]
}

String Performance

String operations are a common source of unnecessary allocation. Understanding when Rust copies vs borrows lets you avoid most of them.

fn main() {
    // Building strings efficiently
    let parts = vec!["Hello", ", ", "world", "!"];

    // Bad: allocates a new String for every + operation
    // let s = "Hello".to_string() + ", " + "world" + "!";

    // Good: collect into a single allocation
    let s: String = parts.concat();
    println!("{}", s);

    // Good: join with a separator
    let words = vec!["one", "two", "three"];
    let joined = words.join(", ");
    println!("{}", joined); // one, two, three

    // String formatting with pre-allocated capacity
    let mut result = String::with_capacity(64); // avoid reallocation
    for i in 0..5 {
        result.push_str(&i.to_string());
        result.push(',');
    }
    println!("{}", result);
}

Memory Layout and Cache Efficiency

Modern CPUs are dramatically faster when they access memory sequentially (cache-friendly) than when they jump around (cache-unfriendly). Struct field ordering can affect padding and cache line usage. Arrays of structs vs structs of arrays is a classic trade-off depending on your access pattern.

// Poorly laid out struct — padding wastes space, fields accessed non-sequentially
#[repr(C)]
struct BadLayout {
    flag: bool,      // 1 byte
    // 7 bytes padding
    value: f64,      // 8 bytes
    count: u32,      // 4 bytes
    // 4 bytes padding
}  // 24 bytes total

// Better layout — group fields by alignment, largest first
#[repr(C)]
struct GoodLayout {
    value: f64,      // 8 bytes — largest alignment first
    count: u32,      // 4 bytes
    flag: bool,      // 1 byte
    // 3 bytes padding
}  // 16 bytes total

fn main() {
    println!("bad  layout: {} bytes", std::mem::size_of::<BadLayout>());   // 24
    println!("good layout: {} bytes", std::mem::size_of::<GoodLayout>());  // 16

    // Sequential access — cache friendly (stride = 1 element)
    let data: Vec<i32> = (0..1_000_000).collect();
    let sum: i32 = data.iter().sum(); // CPU prefetcher loves this pattern
    println!("sum: {}", sum);
}

Capacity Pre-allocation

When you know how many elements a collection will hold, pre-allocating eliminates the series of reallocations that happen as the collection grows. Each reallocation doubles the capacity and copies all existing elements — O(n) total work amortized, but with high constant factors due to heap allocation and memory copying.

fn main() {
    let n = 100_000;

    // Without pre-allocation — several reallocations as Vec grows
    let mut v1: Vec<i32> = Vec::new();
    for i in 0..n { v1.push(i); }

    // With pre-allocation — single allocation, zero copies
    let mut v2: Vec<i32> = Vec::with_capacity(n);
    for i in 0..n { v2.push(i); }

    // HashMap also benefits from pre-allocation
    use std::collections::HashMap;
    let mut map: HashMap<i32, i32> = HashMap::with_capacity(n);
    for i in 0..n as i32 { map.insert(i, i * i); }

    println!("v2 capacity: {}", v2.capacity()); // exactly n — no wasted memory
}

Parallelism with Rayon

When computation is the bottleneck (not I/O), Rayon can divide the work across all CPU cores with a one-word change to your iterator chain. The work-stealing scheduler automatically handles uneven workloads, and because Rust’s ownership model prevents data races, the compiler verifies the parallelism is safe.

use rayon::prelude::*;

fn is_prime(n: u64) -> bool {
    if n < 2 { return false; }
    if n == 2 { return true; }
    if n % 2 == 0 { return false; }
    let limit = (n as f64).sqrt() as u64;
    !(3..=limit).step_by(2).any(|i| n % i == 0)
}

fn main() {
    // Sequential
    let seq: Vec<u64> = (2..500_000).filter(|&n| is_prime(n)).collect();

    // Parallel — change iter() to par_iter(), same result, uses all cores
    let par: Vec<u64> = (2u64..500_000).into_par_iter().filter(|&n| is_prime(n)).collect();

    assert_eq!(seq, par);
    println!("found {} primes", par.len());
}

Compile-Time Computation with const

Moving computation to compile time eliminates runtime cost entirely. const fn can be called at compile time, and const expressions are evaluated by the compiler. This is useful for lookup tables, checksums, and any value that depends only on compile-time constants.

// Computed at compile time — zero runtime cost
const fn factorial(n: u64) -> u64 {
    if n == 0 { 1 } else { n * factorial(n - 1) }
}

const FACT_10: u64 = factorial(10); // compiler evaluates this
const FACT_15: u64 = factorial(15);

// Lookup table built at compile time
const SQUARES: [u32; 16] = {
    let mut arr = [0u32; 16];
    let mut i = 0;
    while i < 16 {
        arr[i] = (i * i) as u32;
        i += 1;
    }
    arr
};

fn main() {
    println!("10! = {}", FACT_10);              // 3628800
    println!("15! = {}", FACT_15);              // 1307674368000
    println!("7^2 = {}", SQUARES[7]);           // 49
    // All of the above are constants in the binary — no computation at runtime
}

Profiling Checklist

Before optimizing, work through this checklist:

  1. Measure in release modecargo build --release; debug numbers are meaningless.
  2. Write a benchmark — use criterion to get stable, reproducible measurements.
  3. Profile, don’t guess — use perf (Linux), Instruments (macOS), or cargo-flamegraph to find the actual hot path.
  4. Check allocations — use heaptrack or dhat to find unexpected allocation pressure.
  5. Optimize the algorithm first — an O(n log n) algorithm beats an optimized O(n²) at scale.
  6. Try Rayon — if the hot loop is CPU-bound and embarrassingly parallel, .par_iter() is often the easiest win.
  7. Pre-allocate collections — if you know the final size, call with_capacity.
  8. Enable LTOlto = true in [profile.release] can give 5–20% speedup at the cost of longer link times.
  9. Measure again — confirm the optimization actually helped and did not introduce a regression elsewhere.

Frequently Asked Questions

Does Rust have zero-cost abstractions?
Yes. Iterators, generics, and closures compile to machine code equivalent to hand-written loops and direct function calls. You pay no runtime cost for using high-level constructs.
When should I use release mode?
Always benchmark and profile in release mode (cargo build --release or cargo bench). Debug mode disables optimizations and inserts overflow checks that make it 10-100x slower. Release mode numbers are what your users actually experience.
How do I find performance bottlenecks in Rust?
Use criterion for microbenchmarks and a profiler (perf on Linux, Instruments on macOS, VTune on Windows) for system-level profiling. Measure before optimizing — the bottleneck is rarely where you expect.