Performance & Memory Layout
Prerequisites: FFI & interop with C
Rust gives you control over where data lives and when it is copied. Most performance wins in everyday Rust code come not from clever tricks but from avoiding work: needless allocations, needless clones, needless copies. Measure before and after (next lesson) — intuition about performance is often wrong.
Stack, heap and what a value costs
A local variable lives on the stack: allocating it is free and it is dropped
when the scope ends. Box, Vec, String, HashMap and Rc/Arc put
their contents on the heap, which costs an allocator call to create and
another to free. The value on the stack is just a small handle.
std::mem::size_of shows those handles:
use std::mem::size_of;
#[allow(dead_code)]
struct Loose {
a: u8,
b: u32,
c: u8,
}
#[allow(dead_code)]
#[repr(C)]
struct LooseC {
a: u8,
b: u32,
c: u8,
}
fn main() {
println!("String: {}", size_of::<String>());
println!("Vec<u64>: {}", size_of::<Vec<u64>>());
println!("&str: {}", size_of::<&str>());
println!("Box<u64>: {}", size_of::<Box<u64>>());
println!("Option<Box<u64>>: {}", size_of::<Option<Box<u64>>>());
println!("Option<u32>: {}", size_of::<Option<u32>>());
println!("Box<dyn Fn()>: {}", size_of::<Box<dyn Fn()>>());
println!("Loose: {}", size_of::<Loose>());
println!("LooseC (repr C): {}", size_of::<LooseC>());
}
Expected output (64-bit targets):
String: 24
Vec<u64>: 24
&str: 16
Box<u64>: 8
Option<Box<u64>>: 8
Option<u32>: 8
Box<dyn Fn()>: 16
Loose: 8
LooseC (repr C): 12
What this shows:
StringandVecare pointer + capacity + length. Moving one copies 24 bytes, never the heap contents.&strandBox<dyn Trait>are fat pointers: a data pointer plus a length or vtable pointer.Option<Box<T>>costs nothing extra: aBoxcan never be null, soNoneis stored as null (the niche optimisation).Option<&T>andOption<NonZeroU32>get the same treatment;Option<u32>has no spare bit pattern and needs a separate tag.- By default Rust may reorder fields to minimise padding.
#[repr(C)]keeps declaration order (needed for FFI), which here wastes 4 bytes.
A large enum is as big as its largest variant. If one variant is huge and rare, box its payload so the common variants stay small.
Avoiding needless allocations
fn main() {
// Reserve once instead of growing 1 → 2 → 4 → ... → 1024.
let mut squares = Vec::with_capacity(1_000);
for i in 0..1_000u64 {
squares.push(i * i);
}
println!("{} {}", squares.len(), squares.capacity() >= 1_000);
// Build a string with one buffer instead of a format! per item.
let mut csv = String::with_capacity(64);
for (i, word) in ["a", "b", "c"].iter().enumerate() {
if i > 0 {
csv.push(',');
}
csv.push_str(word);
}
println!("{csv}");
}
Expected output:
1000 true
a,b,c
Other habits that pay off:
- Take
&strand&[T]parameters instead ofString/Vec<T>when you only read the data. - Iterate by reference (
for x in &items) instead of cloning the collection. - Reuse buffers in loops:
buf.clear()keeps the capacity. - Chain iterators instead of collecting into intermediate
Vecs. - Prefer
.to_owned()/.clone()at the point you actually need ownership, not "just in case".
Cow: allocate only when you must
Cow<'a, str> ("clone on write") holds either a borrowed &'a str or an
owned String. It lets a function return its input unchanged without
allocating, and allocate only when it has to modify it:
use std::borrow::Cow;
fn normalize_spaces(input: &str) -> Cow<'_, str> {
if input.contains('\t') {
Cow::Owned(input.replace('\t', " "))
} else {
Cow::Borrowed(input)
}
}
fn main() {
for s in ["plain text", "tab\tseparated"] {
let out = normalize_spaces(s);
let kind = match out {
Cow::Borrowed(_) => "borrowed",
Cow::Owned(_) => "owned",
};
println!("{out:?} ({kind})");
}
}
Expected output:
"plain text" (borrowed)
"tab separated" (owned)
Cow derefs to &str, so callers mostly use it like a normal string.
Release profile options
Debug builds are unoptimised and can be 10–100× slower. Always measure with
--release. For the final binary, a few profile settings trade compile time
for speed or size:
[profile.release]
lto = "fat" # link-time optimisation across all crates
codegen-units = 1 # fewer, larger units optimise better (slower build)
panic = "abort" # smaller binary; no unwinding (no catch_unwind)
strip = "symbols" # remove symbols from the binary
# Keep dev builds of dependencies fast at runtime too.
[profile.dev.package."*"]
opt-level = 2
lto = "thin" gets much of the benefit at lower build cost. Measure each
change; they don't always help.
Common mistakes
- Benchmarking or profiling a debug build.
- Sprinkling
.clone()to satisfy the borrow checker in hot loops. Often a reference, a restructured borrow, orRc/Arcis the real fix. collect::<Vec<_>>()just to call.iter()again on the result.- Using
Stringfor every map key when keys are known at compile time.&'static stror an enum is cheaper. - Micro-optimising before profiling. The slow part is usually I/O, an algorithm, or an allocation in a loop — not what you guessed.
Practice
Write fn escape_html(input: &str) -> Cow<'_, str> that replaces < with
< and > with >, returning the input borrowed when it contains
neither. Pre-size the output string.
Show solution
use std::borrow::Cow;
fn escape_html(input: &str) -> Cow<'_, str> {
if !input.contains(['<', '>']) {
return Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len() + 8);
for ch in input.chars() {
match ch {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
Cow::Owned(out)
}
fn main() {
println!("{}", escape_html("safe text"));
println!("{}", escape_html("<b>bold</b>"));
println!("{}", matches!(escape_html("safe"), Cow::Borrowed(_)));
}
This prints safe text, <b>bold</b> and true.