Skip to content
AgileLogics
8 min read

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:

  • String and Vec are pointer + capacity + length. Moving one copies 24 bytes, never the heap contents.
  • &str and Box<dyn Trait> are fat pointers: a data pointer plus a length or vtable pointer.
  • Option<Box<T>> costs nothing extra: a Box can never be null, so None is stored as null (the niche optimisation). Option<&T> and Option<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 &str and &[T] parameters instead of String / 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, or Rc/Arc is the real fix.
  • collect::<Vec<_>>() just to call .iter() again on the result.
  • Using String for every map key when keys are known at compile time. &'static str or 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 &lt; and > with &gt;, 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("&lt;"),
            '>' => out.push_str("&gt;"),
            _ => 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, &lt;b&gt;bold&lt;/b&gt; and true.