Skip to main content
Rust · lesson 29 of 30
Rust advanced Lesson 29 of 30

Performance in Rust

Measure and optimize Rust: criterion benchmarks, debug vs release timings, allocation avoidance, struct layout, pre-allocation, Rayon, and const fn.

By SkillByExample · Updated

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"
$ cargo bench -- sum
...
sum/iterator            time:   [1.0187 µs 1.0282 µs 1.0381 µs]
Found 1 outliers among 100 measurements (1.00%)
  1 (1.00%) high mild
...
sum/loop                time:   [1.0084 µs 1.0195 µs 1.0307 µs]
Found 1 outliers among 100 measurements (1.00%)
  1 (1.00%) high severe

(Progress lines trimmed.) Each result is a confidence interval — lower bound, estimate, upper bound — from 100 samples — about 5 million iterations per benchmark. The two intervals overlap, which is criterion’s way of saying the iterator and the loop are the same speed once optimized.

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.

use std::time::Instant;

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

    // Iterator chain — elegant and readable
    let t = Instant::now();
    let result_iter: i64 = data.iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| x * x)
        .sum();
    let iter_ms = t.elapsed().as_millis();

    // Equivalent hand-written loop
    let t = Instant::now();
    let mut result_loop = 0i64;
    for &x in &data {
        if x % 2 == 0 {
            result_loop += x * x;
        }
    }
    let loop_ms = t.elapsed().as_millis();

    assert_eq!(result_iter, result_loop);
    println!("sum = {}", result_iter);
    println!("iterator: {} ms, loop: {} ms", iter_ms, loop_ms);
}
$ rustc zero_cost.rs && ./zero_cost
sum = 4499995500001000000
iterator: 19 ms, loop: 8 ms
$ rustc -O zero_cost.rs && ./zero_cost
sum = 4499995500001000000
iterator: 1 ms, loop: 2 ms

The unoptimized build tells the opposite story from the heading: the iterator chain is more than twice as slow as the loop, because every filter, map and closure call is a real function call. With -O (what cargo build --release uses) those calls are inlined away, both versions drop to one or two milliseconds, and the difference between them is noise — the “zero cost” in zero-cost abstractions is a promise about optimized code. Exact timings vary from machine to machine and run to run; the ratio is the part worth noticing.

The accumulator is i64 for a reason: this sum is far beyond i32::MAX, and even ten million elements would overflow an i64. On overflow a debug build panics with attempt to add with overflow, while a release build, which disables overflow checks, silently wraps to a wrong answer.

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()
}

// windows() borrows slices of the input — only the result Vec is allocated
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));

    let word = String::from("performance");
    println!("vowels: {}", count_vowels(&word)); // &String coerces to &str
    println!("vowels: {}", count_vowels("literal")); // a literal needs no String at all

    let array = [1, 2, 3];
    let vec = vec![4, 5, 6];
    println!("sums: {} {}", sum_slice(&array), sum_slice(&vec[1..]));
}
$ rustc performance_avoiding_unnecessary_allocations.rs && ./performance_avoiding_unnecessary_allocations
[2.0, 3.0, 4.0]
vowels: 4
vowels: 3
sums: 6 11

Because the functions borrow slices, one signature serves a String, a string literal, an array and a sub-range of a Vec — none of the callers had to allocate or copy anything to call them.

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);
}
$ rustc performance_string_performance.rs && ./performance_string_performance
Hello, world!
one, two, three
0,1,2,3,4,

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>());
    println!("good layout: {} bytes", std::mem::size_of::<GoodLayout>());

    // Sequential access — cache friendly (stride = 1 element)
    let data: Vec<i64> = (0..1_000_000).collect();
    let sum: i64 = data.iter().sum(); // CPU prefetcher loves this pattern
    println!("sum: {}", sum);
}
$ rustc performance_memory_layout_and_cache_efficiency.rs && ./performance_memory_layout_and_cache_efficiency
bad  layout: 24 bytes
good layout: 16 bytes
sum: 499999500000

The same three fields cost 24 bytes or 16 depending on order, because #[repr(C)] keeps declaration order and pads each field to its alignment. Without #[repr(C)], Rust is free to reorder fields itself and typically picks the compact layout for you.

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: usize = 100_000;

    // Without pre-allocation — count how often the Vec has to grow
    let mut v1: Vec<i32> = Vec::new();
    let mut reallocations = 0;
    for i in 0..n {
        let before = v1.capacity();
        v1.push(i as i32);
        if v1.capacity() != before { reallocations += 1; }
    }
    println!("v1: {} reallocations, final capacity {}", reallocations, v1.capacity());

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

    // HashMap also benefits from pre-allocation
    use std::collections::HashMap;
    let mut map: HashMap<i64, i64> = HashMap::with_capacity(n);
    for i in 0..n as i64 { map.insert(i, i * i); }
    println!("map: {} entries, capacity {}", map.len(), map.capacity());
}
$ rustc performance_capacity_pre_allocation.rs && ./performance_capacity_pre_allocation
v1: 16 reallocations, final capacity 131072
v2: capacity 100000 before, 100000 after
map: 100000 entries, capacity 114688

Growing from empty took 16 reallocations — capacity 4, 8, 16, … up to 131,072, each move copying everything so far — and left room for 31,072 elements that were never used. with_capacity allocated once and never moved. A HashMap’s capacity is “how many it can hold before resizing”, rounded up internally, so it reports more than was asked for. (The map stores i64 because with i32, i * i overflows once i passes 46,340 — a panic in a debug build.)

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() {
    use std::time::Instant;

    // Sequential
    let t = Instant::now();
    let seq: Vec<u64> = (2..5_000_000).filter(|&n| is_prime(n)).collect();
    let seq_ms = t.elapsed().as_millis();

    // Parallel — change iter() to par_iter(), same result, uses all cores
    let t = Instant::now();
    let par: Vec<u64> = (2u64..5_000_000).into_par_iter().filter(|&n| is_prime(n)).collect();
    let par_ms = t.elapsed().as_millis();

    assert_eq!(seq, par);
    println!("found {} primes", par.len());
    println!("sequential {} ms, parallel {} ms on {} threads",
        seq_ms, par_ms, rayon::current_num_threads());
}
$ cargo run -q --release
found 348513 primes
sequential 510 ms, parallel 126 ms on 12 threads

About a 4× speed-up on 12 threads, not 12×: the work splits unevenly (big numbers take longer to test), and the parallel run still has to collect results in order. Timings are from one machine; the assert_eq! — same primes, same order — is the part that holds everywhere.

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);
    println!("15! = {}", FACT_15);
    println!("7^2 = {}", SQUARES[7]);
    // All of the above are constants in the binary — no computation at runtime
}
$ rustc performance_compile_time_computation_with_const.rs && ./performance_compile_time_computation_with_const
10! = 3628800
15! = 1307674368000
7^2 = 49

Profiling Checklist

Before optimizing, work through this checklist:

  1. Measure in release mode — cargo 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 LTO — lto = 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.

Practice

  1. Run the zero-cost example with the vector at 10_000_000 elements. What happens in a debug build, and why would a release build be worse?

    Solution

    The debug build panics with attempt to add with overflow — the sum of even squares below ten million is about 1.7 × 10²⁰, past i64::MAX. A release build turns overflow checks off, so it would wrap around and print a wrong number without complaint. Use i128, or checked_add, when the range isn’t known to fit.

  2. In the capacity example, what capacity does a Vec reach after exactly 5 pushes onto Vec::new()? After 9?

    Solution

    8 after 5 pushes and 16 after 9: the first push allocates room for 4 i32s, and each time it fills up the capacity doubles.

  3. Remove #[repr(C)] from BadLayout and print its size again.

    Solution

    It prints 16, the same as GoodLayout: without #[repr(C)] the compiler is free to reorder the fields, and it puts value first to avoid padding. #[repr(C)] only matters when the layout must match C or another fixed format.

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. In optimized (release) builds you pay no runtime cost for these high-level constructs; unoptimized debug builds can be several times slower.
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.