31 — Performance, Profiling & Optimization
Rust gives you C-level performance by default, but you can still write slow Rust. This chapter covers how to find and fix bottlenecks.
Mindset
- Don't optimize prematurely. Write clear code; profile; optimize hot spots.
- Measure, measure, measure. Intuition is often wrong.
- Iterate. One change at a time; re-measure each time.
Benchmarking
cargo bench (nightly)
#![feature(test)]
extern crate test;
use test::Bencher;
#[bench]
fn bench_add(b: &mut Bencher) {
b.iter(|| test::black_box(2) + test::black_box(2));
}
black_box prevents the optimizer from constant-folding.
criterion (stable, recommended)
use criterion::{criterion_group, criterion_main, Criterion};
fn bench_fib(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fib(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);
In benches/my_bench.rs. Run with cargo bench. Criterion provides statistics, regressions, and HTML reports.
Custom Benchmarks
For ad-hoc timing:
let start = std::time::Instant::now();
work();
let elapsed = start.elapsed();
Profiling
perf (Linux)
cargo build --release
perf record -g ./target/release/my_app
perf report
For flamegraphs:
cargo install flamegraph
cargo flamegraph
samply
cargo install samply
samply record ./target/release/my_app
Samply gives a web UI with call trees and source-level annotations.
Instruments (macOS)
xcrun xctrace record --template "Time Profiler" --launch ./target/release/my_app
dtrace, vtune (advanced)
For deeper hardware analysis (cache misses, branch mispredicts).
Optimization Techniques
1. Avoid Unnecessary Allocation
// Bad: allocates per call
fn process(items: &[u8]) -> Vec<u8> { items.iter().map(|x| x + 1).collect() }
// Good: caller provides buffer
fn process_into(items: &[u8], out: &mut [u8]) {
for (i, x) in items.iter().enumerate() { out[i] = x + 1; }
}
String::with_capacity, Vec::with_capacity to avoid regrowth.
2. Use &[T] / &str in APIs
Don't take &Vec<T> or &String — they impose ownership and lose the more general slice form. Slices are flexible and equally fast.
3. Choose Iterators Over Explicit Loops (Sometimes)
Iterators often compile to tighter loops because the compiler can reason about them. But for very tight inner loops, the explicit form sometimes wins (or with manual SIMD). Profile both.
4. Box Large Struct Fields
A struct with a large Vec field still has its (ptr, len, cap) header inline (24 bytes), but a large [u8; 1024] field makes the struct huge. Use Box<[u8; 1024]> for large fixed-size data to keep the struct small (good for cache and copying).
5. Avoid Box<dyn Trait> in Hot Paths
Vtable indirection is ~few ns but kills inlining. Genericize hot paths.
6. Cache Locality
- Flat
Vec<T>overVec<Vec<T>>. ArrayVec/SmallVecfor inline storage.- Structure-of-arrays over array-of-structures for SIMD-friendly access.
7. SIMD via std::simd (nightly) or wide/pulp (stable)
#![feature(portable_simd)]
use std::simd::f32x4;
let a = f32x4::from_array([1.0, 2.0, 3.0, 4.0]);
let b = f32x4::from_array([5.0, 6.0, 7.0, 8.0]);
let c = a + b;
For auto-vectorization, write iterator chains and let LLVM do it; check with cargo asm or Godbolt.
8. #[inline] Selectively
#[inline]
fn small() -> u32 { /* ... */ }
#[inline(always)]
fn tiny() -> u32 { /* ... */ }
Don't #[inline(always)] everywhere — it bloats code and hurts i-cache.
9. Avoid Heap Allocations in Hot Loops
- Reuse buffers via
&mut Vec. - Use
arrayvec::ArrayVecfor fixed-size buffers. - Use
smallvec::SmallVecfor small-but-may-grow.
10. Use &mut [T] for In-Place Mutation
fn sum_of_squares(v: &mut [i32]) {
for x in v.iter_mut() { *x = *x * *x; }
}
Avoids allocation; cache-friendly.
11. Lock Granularity
RwLockfor read-heavy.- Shard locks across N buckets for parallel writes.
- Lock-free via atomics when possible.
12. Avoid Reallocations
let mut v = Vec::with_capacity(N);
for x in iter { v.push(x); }
13. Use Arc::clone Carefully
Each Arc::clone does an atomic increment — much cheaper than a deep clone but not free. Avoid in tightest loops.
14. mem::replace and mem::take
let old = mem::take(&mut self.buffer); // self.buffer is now empty
process(old);
Avoids cloning; useful for swap-and-go state.
15. Reduce Trait Object Dispatch
// Generic
fn sum<T>(v: &[T]) -> T where T: Sum + Copy { v.iter().copied().sum() }
// Box<dyn> — slower
fn sum_dyn(v: &[Box<dyn Additive>]) { /* vtable per call */ }
16. Cow to Avoid Allocations
fn normalize(s: &str) -> Cow<str> {
if needs_transform(s) { Cow::Owned(s.to_uppercase()) } else { Cow::Borrowed(s) }
}
17. Pre-compute and Cache
struct Cached { data: Vec<u8> }
impl Cached {
fn lookup(&self, key: usize) -> u8 { self.data[key] }
}
Avoid recomputing in hot paths.
18. String Interning
For repeated short strings, use string_interner/lasso to assign integer IDs.
19. Use &'static Where Appropriate
Avoids lifetime-tracking overhead in some generic contexts. Don't overuse.
20. Compile-Time Computation
const N: usize = 1000;
let arr = [0; N];
Move computation to compile time via const/const fn when possible.
release Profile Pitfalls
- Default
release:opt-level = 3butlto = false,codegen-units = 16(parallel compile, less optimization). For final binaries, bump these. panic = "abort"can unlock more optimizations (no unwinding tables).strip = "symbols"reduces binary size.opt-level = "z"minimizes size, often at a perf cost.
Measuring Allocations
Use a custom allocator that logs:
use std::alloc::{GlobalAlloc, Layout, System};
struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 { eprintln!("alloc {:?}", l); System.alloc(l) }
unsafe fn dealloc(&self, p: *mut u8, l: Layout) { System.dealloc(p, l) }
}
#[global_allocator]
static A: Counting = Counting;
Or use dhat for heap profiling.
cargo bloat
cargo install cargo-bloat
cargo bloat --release
cargo bloat --release --crates
Shows which functions take the most binary size.
Inspecting Assembly
cargo install cargo-asm
cargo asm my_crate::function
Or use Godbolt (godbolt.org) — paste Rust code, see assembly.
Common Performance Pitfalls
String::new()followed by manypush_str: usewith_capacity.format!in hot loops: pre-format or usewrite!into a reused buffer.Vec<u8>for byte parsing:bytes::Bytes/BytesMutare often faster.HashMapwith crypto-strong hash: useFxHashMap/AHashMapfor non-adversarial keys.Vec::pushin a counted loop:Vec::with_capacityonce.to_string()on a&stryou only need to read: just use the&str.Arc::clonein inner loop: clone once, reuse.HashMaplookup-then-insert: useentry(one hash).- Locks held across
await: contention; drop lock first. Vec<Vec<T>>matrices: flat layout + index math is faster.Box<dyn>in inner loops: indirect calls prevent inlining.cloned()instead ofcopied():copiedis faster forCopytypes.Vec<u8>fromread_to_end: useVec::with_capacityif size is known.String::from_utf8thenunwrap:from_utf8_lossyavoids the check.
Memory Layout
#[repr(C)]: fixed, predictable, no padding-optimization.#[repr(transparent)]: same layout as inner.#[repr(packed)]: no padding, alignment 1 — slow on many platforms, UB risk.- Field reordering (default Rust layout) minimizes padding; let the compiler do it.
std::alloc Layout
Allocations must be aligned to Layout::align. Mismatched alignment is UB. Box::new_uninit_slice/Vec::with_capacity handle this for you.
Async Performance
- Avoid
Box<dyn Future>in hot paths; use generics. - Avoid
tokio::spawnfor short-lived work — overhead. Usejoin!/FuturesUnorderedinstead. - Bounded channels for backpressure (vs unbounded that grow).
current_threadruntime for single-threaded apps.
Summary
Profile with criterion, flamegraph, samply, cargo bloat. Optimize hot paths: avoid allocation, use slices, generic over dyn, with_capacity, lock granularity, SIMD. Use release profile + lto = "fat" + codegen-units = 1 for final binaries. Don't trust intuition; measure. Iterate.
Next: Documentation.