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:
- Measure in release mode —
cargo build --release; debug numbers are meaningless. - Write a benchmark — use
criterionto get stable, reproducible measurements. - Profile, don’t guess — use
perf(Linux),Instruments(macOS), orcargo-flamegraphto find the actual hot path. - Check allocations — use
heaptrackordhatto find unexpected allocation pressure. - Optimize the algorithm first — an O(n log n) algorithm beats an optimized O(n²) at scale.
- Try Rayon — if the hot loop is CPU-bound and embarrassingly parallel,
.par_iter()is often the easiest win. - Pre-allocate collections — if you know the final size, call
with_capacity. - Enable LTO —
lto = truein[profile.release]can give 5–20% speedup at the cost of longer link times. - Measure again — confirm the optimization actually helped and did not introduce a regression elsewhere.