Lesson 83 of 94
Performance
Rust code is fast by default: no garbage collector, values stored inline instead of behind object headers, and generics compiled into specialized code. Still, the same program can be several times faster or slower depending on a few habits. This lesson covers those habits and the tools to find out where time goes.
The biggest mental shift is that costs are visible in the code. If you see .clone(), String::from, format!, Box::new, collect(), or to_string(), memory is probably being allocated. That makes performance work less about guessing what the runtime did and more about reading the code.
| Aspect | Rust |
|---|---|
| Compilation | compiled ahead of time; --release enables optimizations |
| Freeing memory | memory is freed deterministically when owners are dropped |
| Stack vs heap | you choose explicitly |
| Object layout | structs are stored inline, with no header |
| Microbenchmarks | criterion (or divan) |
| Profilers | perf, cargo flamegraph, samply, DHAT |
Always measure before optimizing, and always measure release builds. The examples here mostly count things (allocations, calls, comparisons) rather than time them, because counts are stable and they're what usually explains the time.
Part 1: Measuring
1. Debug vs release builds
cargo build and cargo run produce debug builds: no optimization, plus overflow checks. They're typically 10 to 50 times slower than cargo build --release. Never benchmark or judge performance from a debug build.
cargo run --release # optimized build and run
cargo build --release # binary in target/release/
RUSTFLAGS="-C target-cpu=native" cargo build --release # use every instruction your CPU has[profile.release]
lto = "fat" # optimize across crate boundaries
codegen-units = 1 # fewer, larger compilation units: slower build, faster code
panic = "abort" # no unwinding tables
[profile.dev]
opt-level = 1 # make debug builds usable when they're too slow to develop with2. A quick timing harness
For a rough measurement, time many iterations in a release build and use std::hint::black_box to stop the optimizer from deleting work whose result isn't used. For anything you'll base decisions on, use criterion, which handles warm-up, statistics, and outliers (see Testing).
use std::hint::black_box;
use std::time::Instant;
fn bench<R>(name: &str, iterations: u32, mut f: impl FnMut() -> R) {
for _ in 0..iterations / 10 {
black_box(f()); // warm caches and branch predictors
}
let start = Instant::now();
for _ in 0..iterations {
black_box(f());
}
println!("{name:<12} {:>8.1} ns/iter", start.elapsed().as_nanos() as f64 / iterations as f64);
}
fn main() {
let data: Vec<u64> = (0..10_000).collect();
bench("sum loop", 10_000, || {
let mut s = 0;
for x in &data {
s += x;
}
s
});
bench("sum iter", 10_000, || data.iter().sum::<u64>());
}3. Counting allocations
Heap allocation is one of the most common hidden costs. A custom global allocator that counts calls makes it visible. We'll reuse this helper in several examples.
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
struct Counting;
static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOCATIONS.fetch_add(1, Relaxed);
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn count_allocs<R>(f: impl FnOnce() -> R) -> (R, usize) {
let before = ALLOCATIONS.load(Relaxed);
let r = f();
(r, ALLOCATIONS.load(Relaxed) - before)
}
fn main() {
let (_, n) = count_allocs(|| 40 + 2);
println!("integer math: {n} allocations");
let (_, n) = count_allocs(|| String::from("hello"));
println!("String::from: {n} allocation");
let (_, n) = count_allocs(|| vec![1, 2, 3].iter().map(|x| x.to_string()).collect::<Vec<_>>());
println!("vec of 3 to_string'd numbers: {n} allocations");
}integer math: 0 allocations
String::from: 1 allocation
vec of 3 to_string'd numbers: 5 allocations(Reallocations go through realloc, whose default implementation calls alloc again, so growing a buffer is counted too.) For real programs, the dhat crate and heap profilers give the same insight with call stacks.
Part 2: Allocation habits
4. with_capacity when you know the size
A growing Vec or String reallocates as it goes (doubling each time). If you know roughly how many elements are coming, reserve once.
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
struct Counting;
static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
ALLOCATIONS.fetch_add(1, Relaxed);
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn count_allocs<R>(f: impl FnOnce() -> R) -> (R, usize) {
let before = ALLOCATIONS.load(Relaxed);
let r = f();
(r, ALLOCATIONS.load(Relaxed) - before)
}
fn main() {
let (v, n) = count_allocs(|| {
let mut v = Vec::new();
for i in 0..10_000u32 {
v.push(i);
}
v
});
println!("Vec::new + 10,000 pushes: {n} allocations (capacity {})", v.capacity());
let (v, n) = count_allocs(|| {
let mut v = Vec::with_capacity(10_000);
for i in 0..10_000u32 {
v.push(i);
}
v
});
println!("with_capacity(10,000): {n} allocation (capacity {})", v.capacity());
let (v, n) = count_allocs(|| (0..10_000u32).collect::<Vec<_>>());
println!("collect from a range: {n} allocation (capacity {})", v.capacity());
}Vec::new + 10,000 pushes: 13 allocations (capacity 16384)
with_capacity(10,000): 1 allocation (capacity 10000)
collect from a range: 1 allocation (capacity 10000)collect() from an iterator with a known length (size_hint) preallocates by itself.
5. Reuse buffers instead of allocating per iteration
clear() empties a String or Vec but keeps its capacity. Reusing one buffer across loop iterations turns thousands of allocations into a handful.
use std::alloc::{GlobalAlloc, Layout, System};
use std::fmt::Write;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
struct Counting;
static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
ALLOCATIONS.fetch_add(1, Relaxed);
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn count_allocs<R>(f: impl FnOnce() -> R) -> (R, usize) {
let before = ALLOCATIONS.load(Relaxed);
let r = f();
(r, ALLOCATIONS.load(Relaxed) - before)
}
fn main() {
let rows: Vec<(u32, &str)> = (0..1000).map(|i| (i, "widget")).collect();
let (total, fresh) = count_allocs(|| {
let mut total = 0;
for (id, name) in &rows {
let line = format!("{id},{name}\n"); // new String every iteration
total += line.len();
}
total
});
let (total2, reused) = count_allocs(|| {
let mut total = 0;
let mut line = String::new();
for (id, name) in &rows {
line.clear(); // keep the capacity
writeln!(line, "{id},{name}").unwrap();
total += line.len();
}
total
});
println!("same result: {}", total == total2);
println!("format! per row: {fresh} allocations");
println!("reused buffer: {reused} allocations");
}same result: true
format! per row: 2000 allocations
reused buffer: 2 allocationsThe same trick applies to read_line into one String, or a Vec of scratch results reused across requests.
6. Borrow instead of cloning
Taking &str or &[T] parameters lets callers pass what they have without copying. A function that takes String forces callers to allocate if they only have a &str (or to give up their own copy).
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
struct Counting;
static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
ALLOCATIONS.fetch_add(1, Relaxed);
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn count_allocs<R>(f: impl FnOnce() -> R) -> (R, usize) {
let before = ALLOCATIONS.load(Relaxed);
let r = f();
(r, ALLOCATIONS.load(Relaxed) - before)
}
fn is_admin_owned(user: String) -> bool {
user.starts_with("admin_")
}
fn is_admin(user: &str) -> bool {
user.starts_with("admin_")
}
fn main() {
let users: Vec<String> = (0..500).map(|i| format!("user_{i}")).collect();
let (a, owned) = count_allocs(|| users.iter().filter(|u| is_admin_owned((*u).clone())).count());
let (b, borrowed) = count_allocs(|| users.iter().filter(|u| is_admin(u)).count());
println!("same answer: {}", a == b);
println!("String parameter: {owned} allocations");
println!("&str parameter: {borrowed} allocations");
}same answer: true
String parameter: 500 allocations
&str parameter: 0 allocations.clone() in a loop is the first thing to look for in a slow function.
7. Iterators are as fast as loops
Iterator chains compile down to the same machine code as hand-written loops, and often better: the compiler can drop bounds checks because the iterator can't go out of range. Prefer whichever is clearer.
fn sum_of_even_squares_loop(v: &[u64]) -> u64 {
let mut total = 0;
for i in 0..v.len() {
if v[i] % 2 == 0 {
total += v[i] * v[i]; // indexing: bounds checks unless the optimizer proves them away
}
}
total
}
fn sum_of_even_squares_iter(v: &[u64]) -> u64 {
v.iter().filter(|&&x| x % 2 == 0).map(|&x| x * x).sum() // no indexing, no bounds checks
}
fn main() {
let data: Vec<u64> = (1..=1000).collect();
let (a, b) = (sum_of_even_squares_loop(&data), sum_of_even_squares_iter(&data));
println!("{a} {b} equal: {}", a == b);
}167167000 167167000 equal: trueRust iterators have no extra overhead, so there's no performance reason to avoid them.
8. Avoid collecting just to iterate again
Every collect() into a Vec allocates. Chains that collect intermediate results and then iterate over them again can usually be one lazy chain.
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
struct Counting;
static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
ALLOCATIONS.fetch_add(1, Relaxed);
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn count_allocs<R>(f: impl FnOnce() -> R) -> (R, usize) {
let before = ALLOCATIONS.load(Relaxed);
let r = f();
(r, ALLOCATIONS.load(Relaxed) - before)
}
fn main() {
let log = "GET /a 200\nGET /b 500\nPOST /c 200\nGET /d 503\n".repeat(250);
let (errors1, eager) = count_allocs(|| {
let lines: Vec<&str> = log.lines().collect();
let fields: Vec<Vec<&str>> = lines.iter().map(|l| l.split(' ').collect()).collect();
let statuses: Vec<&str> = fields.iter().map(|f| f[2]).collect();
statuses.iter().filter(|s| s.starts_with('5')).count()
});
let (errors2, lazy) = count_allocs(|| {
log.lines().filter_map(|l| l.rsplit(' ').next()).filter(|s| s.starts_with('5')).count()
});
println!("errors: {errors1} / {errors2}");
println!("collect at every step: {eager} allocations");
println!("one lazy chain: {lazy} allocations");
}errors: 500 / 500
collect at every step: 1011 allocations
one lazy chain: 0 allocations9. Cow: allocate only when you must
When a function usually returns its input unchanged but sometimes needs to modify it, return Cow<str> (Drop, Deref & Cow). The common path allocates nothing.
use std::borrow::Cow;
fn normalize_path(p: &str) -> Cow<'_, str> {
if p.contains('\\') || p.ends_with('/') {
Cow::Owned(p.replace('\\', "/").trim_end_matches('/').to_string())
} else {
Cow::Borrowed(p) // the common case: no allocation
}
}
fn main() {
let inputs = ["/usr/local/bin", "C:\\Users\\ana", "/tmp/", "/etc/hosts"];
for p in inputs {
let n = normalize_path(p);
let kind = if matches!(n, Cow::Borrowed(_)) { "borrowed" } else { "allocated" };
println!("{p:<16} -> {n:<16} ({kind})");
}
}/usr/local/bin -> /usr/local/bin (borrowed)
C:\Users\ana -> C:/Users/ana (allocated)
/tmp/ -> /tmp (allocated)
/etc/hosts -> /etc/hosts (borrowed)Part 3: Memory layout
10. Sizes of common types
Knowing how big things are helps you reason about memory and cache use. Most Rust types have no header at all; smart pointers are one or two words.
use std::mem::size_of;
use std::rc::Rc;
use std::sync::Arc;
fn main() {
println!("u8: {} byte", size_of::<u8>());
println!("u64 / f64: {} bytes", size_of::<u64>());
println!("&T / Box<T>: {} bytes (one pointer)", size_of::<Box<u64>>());
println!("&str / &[T]: {} bytes (pointer + length)", size_of::<&str>());
println!("String / Vec<T>: {} bytes (pointer + capacity + length)", size_of::<String>());
println!("Rc<T> / Arc<T>: {} / {} bytes", size_of::<Rc<u64>>(), size_of::<Arc<u64>>());
println!("Box<dyn Fn()>: {} bytes (data pointer + vtable pointer)", size_of::<Box<dyn Fn()>>());
println!("(u8, u32, u8): {} bytes (padding)", size_of::<(u8, u32, u8)>());
println!("[u16; 10]: {} bytes (inline, no header)", size_of::<[u16; 10]>());
}u8: 1 byte
u64 / f64: 8 bytes
&T / Box<T>: 8 bytes (one pointer)
&str / &[T]: 16 bytes (pointer + length)
String / Vec<T>: 24 bytes (pointer + capacity + length)
Rc<T> / Arc<T>: 8 / 8 bytes
Box<dyn Fn()>: 16 bytes (data pointer + vtable pointer)
(u8, u32, u8): 8 bytes (padding)
[u16; 10]: 20 bytes (inline, no header)A Rust u64 is 8 bytes, stored inline in its Vec or struct.
11. Option of a pointer is free
The compiler uses impossible values ("niches") to store enum tags. A reference or Box can never be null, so Option<&T> and Option<Box<T>> use null for None and are the same size as the pointer. Same for Option<NonZeroU32>, Option<char>, and Option<bool>.
use std::mem::size_of;
use std::num::NonZeroU32;
fn main() {
println!("Option<Box<u64>>: {} vs Box: {}", size_of::<Option<Box<u64>>>(), size_of::<Box<u64>>());
println!("Option<&str>: {} vs &str: {}", size_of::<Option<&str>>(), size_of::<&str>());
println!("Option<String>: {} vs String: {}", size_of::<Option<String>>(), size_of::<String>());
println!("Option<NonZeroU32>: {} vs u32: {}", size_of::<Option<NonZeroU32>>(), size_of::<u32>());
println!("Option<u32>: {} (no niche: needs a tag)", size_of::<Option<u32>>());
println!("Option<char>: {}", size_of::<Option<char>>());
}Option<Box<u64>>: 8 vs Box: 8
Option<&str>: 16 vs &str: 16
Option<String>: 24 vs String: 24
Option<NonZeroU32>: 4 vs u32: 4
Option<u32>: 8 (no niche: needs a tag)
Option<char>: 4Use NonZeroU32/NonZeroU64 for ids that are never 0, and an Option of them is as compact as the raw number.
12. Shrinking an enum by boxing its large variant
An enum is as big as its largest variant. If one rare variant is huge, every value pays for it, including every element of a Vec of them. Boxing the large variant's data keeps the common case small.
use std::mem::size_of;
#[allow(dead_code)]
enum EventFat {
Click { x: i32, y: i32 },
Key(char),
Upload { name: [u8; 256], size: u64 }, // rare, but 264 bytes
}
#[allow(dead_code)]
struct Upload {
name: [u8; 256],
size: u64,
}
#[allow(dead_code)]
enum EventSlim {
Click { x: i32, y: i32 },
Key(char),
Upload(Box<Upload>), // one pointer
}
fn main() {
println!("EventFat: {} bytes each", size_of::<EventFat>());
println!("EventSlim: {} bytes each", size_of::<EventSlim>());
println!("1M events: {} MB vs {} MB", size_of::<EventFat>(), size_of::<EventSlim>()); // bytes per event = MB per million
}EventFat: 272 bytes each
EventSlim: 16 bytes each
1M events: 272 MB vs 16 MBClippy's large_enum_variant lint points these out for you.
13. Field order and padding
Rust reorders struct fields to minimize padding (unless you ask for #[repr(C)]), so you don't need to hand-sort fields. It can't remove padding inside nested types, though, and bool flags are a whole byte each; packing several into one integer can shrink hot structs.
use std::mem::size_of;
#[allow(dead_code)]
struct Auto {
active: bool,
id: u32,
verified: bool,
admin: bool,
banned: bool,
beta: bool,
muted: bool,
}
#[allow(dead_code)]
#[repr(C)]
struct DeclarationOrder {
active: bool,
id: u32,
verified: bool,
admin: bool,
banned: bool,
beta: bool,
muted: bool,
}
#[allow(dead_code)]
struct Packed {
id: u32,
flags: u8, // one bit per flag: active, verified, admin, banned, beta, muted
}
fn main() {
println!("Rust reordered: {} bytes", size_of::<Auto>());
println!("#[repr(C)] order: {} bytes", size_of::<DeclarationOrder>());
println!("flags in one byte: {} bytes", size_of::<Packed>());
}Rust reordered: 12 bytes
#[repr(C)] order: 16 bytes
flags in one byte: 8 bytesPart 4: Choosing the right tool
14. Faster hashing for trusted keys
HashMap uses SipHash by default: resistant to HashDoS attacks from hostile input, but slower than simpler hashes. For keys you control (integers, internal ids), a faster hasher helps; the rustc-hash (FxHashMap) and ahash crates are the usual choices. You can also plug in your own via BuildHasher:
use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
/// FNV-1a: tiny and fast for short keys. Not DoS-resistant: only for trusted input.
#[derive(Default)]
struct Fnv(u64);
impl Hasher for Fnv {
fn write(&mut self, bytes: &[u8]) {
if self.0 == 0 {
self.0 = 0xcbf29ce484222325;
}
for b in bytes {
self.0 = (self.0 ^ *b as u64).wrapping_mul(0x100000001b3);
}
}
fn finish(&self) -> u64 {
self.0
}
}
type FnvMap<K, V> = HashMap<K, V, BuildHasherDefault<Fnv>>;
fn main() {
let mut counts: FnvMap<u32, u32> = FnvMap::default();
for id in [7, 3, 7, 9, 3, 7] {
*counts.entry(id).or_default() += 1;
}
let mut sorted: Vec<_> = counts.into_iter().collect();
sorted.sort();
println!("{sorted:?}");
}[(3, 2), (7, 3), (9, 1)]15. One lookup instead of two: the entry API
if !map.contains_key(k) { map.insert(k, v) } hashes and searches twice. entry() does it once and gives you the slot. Also, HashMap::with_capacity avoids rehashing as the map grows.
use std::collections::HashMap;
fn word_counts(text: &str) -> HashMap<&str, usize> {
let mut counts = HashMap::with_capacity(64);
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1; // one hash, one search
}
counts
}
fn main() {
let counts = word_counts("to be or not to be that is the question to ask");
let mut top: Vec<_> = counts.into_iter().filter(|&(_, n)| n > 1).collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
println!("{top:?}");
}[("to", 3), ("be", 2)]The keys here are &str slices of the input: the map allocates for its table but not for a single word.
16. sort_by_cached_key for expensive keys
sort_by_key calls the key function on every comparison, roughly n log n times. When the key is expensive (it allocates, parses, or lowercases), sort_by_cached_key computes it once per element.
use std::cell::Cell;
fn main() {
let names: Vec<String> = ["Émile", "bob", "Alice", "dave", "Carol", "ann", "Zoe", "eve"].map(String::from).to_vec();
let calls = Cell::new(0);
let key = |s: &String| {
calls.set(calls.get() + 1);
s.to_lowercase() // allocates a new String every call
};
let mut a = names.clone();
a.sort_by_key(|s| key(s));
let by_key = calls.replace(0);
let mut b = names.clone();
b.sort_by_cached_key(|s| key(s));
let cached = calls.get();
println!("{b:?}");
println!("same order: {}", a == b);
println!("key computed: {by_key} times (sort_by_key) vs {cached} (sort_by_cached_key)");
}["Alice", "ann", "bob", "Carol", "dave", "eve", "Zoe", "Émile"]
same order: true
key computed: 36 times (sort_by_key) vs 8 (sort_by_cached_key)Also: sort_unstable is usually faster than sort when you don't need equal elements to keep their order.
17. Binary search on sorted data
Linear search is O(n); binary_search on sorted data is O(log n). For repeated lookups in data that doesn't change, sorting once and searching many times beats scanning, often even beating a HashMap for small, cache-friendly Vecs.
use std::cell::Cell;
fn main() {
let ids: Vec<u32> = (0..100_000).map(|i| i * 3).collect(); // sorted
let comparisons = Cell::new(0);
let target = 299_997;
let linear = ids.iter().position(|&x| {
comparisons.set(comparisons.get() + 1);
x == target
});
let linear_cmp = comparisons.replace(0);
let binary = ids.binary_search_by(|x| {
comparisons.set(comparisons.get() + 1);
x.cmp(&target)
});
println!("{linear:?} {binary:?}");
println!("comparisons: linear {linear_cmp}, binary {}", comparisons.get());
}Some(99999) Ok(99999)
comparisons: linear 100000, binary 1818. Static vs dynamic dispatch
Generic functions (impl Trait, T: Trait) are compiled separately for each concrete type (monomorphization), so calls are direct and can be inlined. dyn Trait goes through a vtable, which blocks inlining. The trade-off is code size and compile time versus flexibility.
trait Shape {
fn area(&self) -> f64;
}
struct Square(f64);
struct Circle(f64);
impl Shape for Square {
fn area(&self) -> f64 {
self.0 * self.0
}
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.0 * self.0
}
}
// Static: one compiled copy per T; `area` can be inlined into the loop.
fn total_static<T: Shape>(shapes: &[T]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
// Dynamic: one compiled copy; each call goes through a vtable pointer.
fn total_dyn(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
fn main() {
let squares: Vec<Square> = (1..=100).map(|i| Square(i as f64)).collect();
let mixed: Vec<Box<dyn Shape>> = vec![Box::new(Square(2.0)), Box::new(Circle(1.0))];
println!("{:.1}", total_static(&squares));
println!("{:.3}", total_dyn(&mixed));
}338350.0
7.142Neither is "wrong". Use generics in hot inner loops over one type; use dyn for heterogeneous collections and plugin boundaries. Rust makes you choose at compile time.
19. Doing work at compile time with const fn
A const fn can run during compilation. Lookup tables computed this way cost nothing at runtime, not even a static initializer.
const fn crc32_table() -> [u32; 256] {
let mut table = [0u32; 256];
let mut i = 0;
while i < 256 {
let mut c = i as u32;
let mut k = 0;
while k < 8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
k += 1;
}
table[i] = c;
i += 1;
}
table
}
static CRC_TABLE: [u32; 256] = crc32_table(); // computed by the compiler
fn crc32(data: &[u8]) -> u32 {
!data.iter().fold(!0u32, |crc, &b| CRC_TABLE[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8))
}
fn main() {
println!("{:08x}", crc32(b"The quick brown fox jumps over the lazy dog"));
println!("{:08x}", crc32(b""));
}414fa339
00000000(const fn bodies can't use for loops yet, hence the while loops.)
20. Buffered I/O
Every unbuffered write to a file or socket is a system call. Wrapping the writer in BufWriter batches small writes into large ones. A wrapper that counts calls to the underlying writer shows the difference.
use std::io::{self, BufWriter, Write};
struct CountingWriter {
calls: usize,
bytes: usize,
}
impl Write for CountingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.calls += 1; // one call here = one syscall on a real file
self.bytes += buf.len();
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn write_report(out: &mut impl Write) -> io::Result<()> {
for i in 0..1000 {
writeln!(out, "row {i},ok")?;
}
Ok(())
}
fn main() -> io::Result<()> {
let mut raw = CountingWriter { calls: 0, bytes: 0 };
write_report(&mut raw)?;
let mut buffered = BufWriter::new(CountingWriter { calls: 0, bytes: 0 });
write_report(&mut buffered)?;
let inner = buffered.into_inner().map_err(|e| e.into_error())?;
println!("unbuffered: {} writes for {} bytes", raw.calls, raw.bytes);
println!("BufWriter: {} writes for {} bytes", inner.calls, inner.bytes);
Ok(())
}unbuffered: 3000 writes for 10890 bytes
BufWriter: 2 writes for 10890 bytesSame for reading: wrap files in BufReader. And println! locks stdout on every call; in a tight loop, take let mut out = std::io::stdout().lock(); once and writeln!(out, ...) instead.
21. Parallelism with rayon
For CPU-bound work over collections, rayon often gives a near-linear speedup by changing iter() to par_iter(). It pays off when each element takes real work; for tiny per-element work, the coordination overhead can exceed the gain.
use rayon::prelude::*;
fn collatz_len(mut n: u64) -> u32 {
let mut steps = 0;
while n != 1 {
n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 };
steps += 1;
}
steps
}
fn main() {
let sequential = (1..200_000u64).map(collatz_len).max();
let parallel = (1..200_000u64).into_par_iter().map(collatz_len).max();
let longest = (1..200_000u64).into_par_iter().max_by_key(|&n| collatz_len(n));
println!("{sequential:?} {parallel:?}");
println!("longest chain starts at {longest:?}");
}Some(382) Some(382)
longest chain starts at Some(156159)22. Don't share what you can split
Contended locks and atomics serialize threads. When workers can accumulate locally and merge at the end, they run at full speed. This is why rayon's fold/reduce and the "map then reduce" pattern from Threads are fast.
use std::sync::Mutex;
use std::thread;
fn main() {
let data: Vec<u64> = (1..=400_000).collect();
// Contended: every element takes the lock.
let shared = Mutex::new(0u64);
thread::scope(|s| {
for chunk in data.chunks(100_000) {
let shared = &shared;
s.spawn(move || {
for x in chunk {
*shared.lock().unwrap() += x;
}
});
}
});
// Uncontended: each thread sums locally, then results are combined once.
let local: u64 = thread::scope(|s| {
let handles: Vec<_> = data.chunks(100_000).map(|chunk| s.spawn(move || chunk.iter().sum::<u64>())).collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
});
println!("{} {} (400,000 lock acquisitions vs 4 results merged)", shared.into_inner().unwrap(), local);
}80000200000 80000200000 (400,000 lock acquisitions vs 4 results merged)23. Profiling: where does the time go?
Measure with a profiler before optimizing anything. The tools that Rust developers use most:
cargo install flamegraph
cargo flamegraph --bin server # CPU flame graph (uses perf on Linux, dtrace on macOS)
cargo install samply
samply record ./target/release/server # sampling profiler with a Firefox Profiler UI
perf stat -e cache-misses,instructions ./target/release/server # hardware counters (Linux)
cargo install cargo-bloat
cargo bloat --release --crates # which crates make the binary bigKeep debug symbols in release builds while profiling so the profiler shows function names:
[profile.release]
debug = true # or use a separate [profile.profiling] that inherits from releaseFor allocations, the dhat crate records every allocation with its call stack,.
Part 5: Realistic examples
24. Realistic: optimizing a log parser
A first version of a log summarizer allocates for every line: lowercasing, splitting into a Vec, and cloning keys. The optimized version borrows slices of the input and compares case-insensitively without allocating. Same results, far fewer allocations.
use std::alloc::{GlobalAlloc, Layout, System};
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
struct Counting;
static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
ALLOCATIONS.fetch_add(1, Relaxed);
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn count_allocs<R>(f: impl FnOnce() -> R) -> (R, usize) {
let before = ALLOCATIONS.load(Relaxed);
let r = f();
(r, ALLOCATIONS.load(Relaxed) - before)
}
// Version 1: straightforward, allocation-heavy.
fn errors_by_service_v1(log: &str) -> HashMap<String, usize> {
let mut map = HashMap::new();
for line in log.lines() {
let lower = line.to_lowercase();
let parts: Vec<String> = lower.split(' ').map(|s| s.to_string()).collect();
if parts.len() >= 2 && parts[1] == "error" {
*map.entry(parts[0].clone()).or_insert(0) += 1;
}
}
map
}
// Version 2: borrow slices, compare without allocating.
fn errors_by_service_v2(log: &str) -> HashMap<&str, usize> {
let mut map = HashMap::new();
for line in log.lines() {
let mut parts = line.split(' ');
let (Some(service), Some(level)) = (parts.next(), parts.next()) else { continue };
if level.eq_ignore_ascii_case("error") {
*map.entry(service).or_insert(0) += 1;
}
}
map
}
fn main() {
let log = "auth ERROR token expired\nshop INFO order placed\nshop error payment failed\nauth info ok\n".repeat(500);
let (v1, a1) = count_allocs(|| errors_by_service_v1(&log));
let (v2, a2) = count_allocs(|| errors_by_service_v2(&log));
let mut r1: Vec<_> = v1.iter().map(|(k, v)| (k.as_str(), *v)).collect();
let mut r2: Vec<_> = v2.into_iter().collect();
r1.sort();
r2.sort();
println!("{r1:?}");
println!("same result: {}", r1 == r2);
println!("allocations: v1 {a1}, v2 {a2}");
}[("auth", 500), ("shop", 500)]
same result: true
allocations: v1 12501, v2 1(The optimized version assumes service names are already lowercase in the log; that assumption is the kind of thing to write down in a comment.)
25. Realistic: top-k without sorting everything
To find the 3 largest of a million numbers, sorting all of them is O(n log n). select_nth_unstable partitions in O(n) on average, and a small BinaryHeap keeps a running top-k in O(n log k) with only k elements of memory.
use std::cmp::Reverse;
use std::collections::BinaryHeap;
fn top_k_heap(values: impl Iterator<Item = u64>, k: usize) -> Vec<u64> {
let mut heap = BinaryHeap::with_capacity(k + 1); // min-heap of the k largest so far
for v in values {
heap.push(Reverse(v));
if heap.len() > k {
heap.pop(); // drop the smallest
}
}
let mut out: Vec<u64> = heap.into_iter().map(|Reverse(v)| v).collect();
out.sort_unstable_by(|a, b| b.cmp(a));
out
}
fn main() {
// A deterministic pseudo-random sequence.
let data: Vec<u64> = (0..1_000_000u64).map(|i| (i * 2_654_435_761) % 1_000_003).collect();
let heap_result = top_k_heap(data.iter().copied(), 3);
let mut copy = data.clone();
let n = copy.len();
copy.select_nth_unstable(n - 3); // the 3 largest are now in the last 3 slots, unordered
let mut select_result = copy[n - 3..].to_vec();
select_result.sort_unstable_by(|a, b| b.cmp(a));
println!("{heap_result:?}");
println!("same as select_nth_unstable: {}", heap_result == select_result);
}[1000002, 1000001, 1000000]
same as select_nth_unstable: trueThe heap version also works on streams too large to hold in memory, without boxing each number.
26. Realistic: struct of arrays for hot loops
When a loop reads only one field of many objects, storing each field in its own Vec (struct of arrays) keeps the data the loop touches contiguous and cache-friendly. Game engines and analytics code use this layout.
// Array of structs: natural, but a loop over `price` also drags `name` and `tags` through the cache.
#[allow(dead_code)]
struct Product {
name: String,
price: f64,
tags: Vec<String>,
}
// Struct of arrays: each field is contiguous.
struct Products {
names: Vec<String>,
prices: Vec<f64>,
}
fn main() {
let aos: Vec<Product> =
(0..1000).map(|i| Product { name: format!("p{i}"), price: i as f64 * 0.5, tags: vec![] }).collect();
let soa = Products { names: aos.iter().map(|p| p.name.clone()).collect(), prices: aos.iter().map(|p| p.price).collect() };
let total_aos: f64 = aos.iter().map(|p| p.price).sum(); // strides over 56-byte structs
let total_soa: f64 = soa.prices.iter().sum(); // walks one dense array of f64s
println!("{total_aos} {total_soa}");
println!("bytes walked per price: {} vs {}", std::mem::size_of::<Product>(), std::mem::size_of::<f64>());
println!("first name still available: {}", soa.names[0]);
}249750 249750
bytes walked per price: 56 vs 8
first name still available: p027. A performance checklist
| Symptom | Look for | Fix |
|---|---|---|
| slow everywhere | debug build | --release (and measure there) |
| many small allocations | clone(), to_string(), format!, collect() in loops |
borrow (&str, &[T]), reuse buffers, Cow, lazy iterator chains |
Vec/String/HashMap growth |
repeated reallocation | with_capacity, reserve |
| slow maps with trusted keys | SipHash | FxHashMap / ahash, fewer lookups via entry |
| slow sorting | expensive key, stable sort | sort_by_cached_key, sort_unstable |
| slow I/O | unbuffered reads/writes, println! in loops |
BufReader/BufWriter, lock stdout once |
| big memory footprint | large enum variants, bool fields, Option<u32> ids |
box large variants, pack flags, NonZero ids |
| multi-core machine idle | sequential CPU work | rayon, split work instead of sharing locks |
| still slow | guessing | profile: cargo flamegraph, samply, perf, dhat |
Summary
- Measure release builds, with a profiler or
criterion; never judge speed fromcargo runwithout--release. - Allocation is the most common hidden cost, and Rust makes it visible: borrow instead of cloning, preallocate with
with_capacity, reuse buffers withclear(), avoid intermediatecollect()s, and returnCowwhen changes are rare. - Iterators are as fast as loops. Generics compile to specialized code;
dyncosts a vtable call. - Know your layouts: no object headers, niche-optimized
Options, boxed large enum variants, and struct-of-arrays for hot loops. - Pick the right algorithm and tool:
entry, faster hashers for trusted keys,sort_by_cached_key,binary_search,select_nth_unstable,BinaryHeapfor top-k,BufWriter,const fntables, andrayonwith local accumulation.
Next: API Design Idioms: how to design Rust types and functions that are hard to misuse and pleasant to call.
