Rust · lesson 29 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Performance 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:
- 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.
Practice
-
Run the zero-cost example with the vector at
10_000_000elements. 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²⁰, pasti64::MAX. A release build turns overflow checks off, so it would wrap around and print a wrong number without complaint. Usei128, orchecked_add, when the range isn’t known to fit. -
In the capacity example, what capacity does a
Vecreach after exactly 5 pushes ontoVec::new()? After 9?Solution
8after 5 pushes and16after 9: the first push allocates room for 4i32s, and each time it fills up the capacity doubles. -
Remove
#[repr(C)]fromBadLayoutand print its size again.Solution
It prints
16, the same asGoodLayout: without#[repr(C)]the compiler is free to reorder the fields, and it putsvaluefirst to avoid padding.#[repr(C)]only matters when the layout must match C or another fixed format.