Memory Layout, Performance, and Zero-Cost Abstractions

Reading time
19 min read
Word count
3771 words
Diagram count
4 diagrams

Source: Victor Bona's Obsidian Compendium snapshot, Knowledge base/Rust/06 Memory Layout Performance and Zero Cost Abstractions.md.

Purpose: build a precise mental model for Rust memory layout, allocation, and performance so abstractions can stay readable without hiding expensive behavior. This note connects ownership, layout, caches, compiler optimizations, profiling, and benchmarking for production Rust systems.

Memory Layout, Performance, and Zero-Cost Abstractions

Related notes: 02 Ownership Borrowing and Lifetimes, 04 Error Handling and API Design, 03 Types Traits and Generics, 07 Concurrency Parallelism and Synchronization, 01 Rust Language Fundamentals, 11 Testing Verification Benchmarking and Tooling, 04 Error Handling and API Design, Rust/09 Unsafe Rust and the Rust Memory Model, Data Structures/Data Structures, Design Patterns/Design patterns, Littles law and efficient queue strategy.

Core Claim

Rust performance is mostly a product of explicit data ownership, predictable layout, static dispatch, and the ability to move work out of runtime checks and into compile-time reasoning. The language does not make code fast automatically. It gives the engineer sharper control over allocation, aliasing, lifetimes, representation, and abstraction boundaries.

The phrase "zero cost abstraction" means the abstraction can compile to code comparable to a hand-written lower-level version when used appropriately. It does not mean "always free". Iterator chains, trait objects, async futures, smart pointers, collections, and generic APIs all have concrete layout and control-flow costs that must be understood.

Mental Model

Rendering diagram...

Rust gives the compiler useful facts:

FactSource in RustPerformance effectRisk
A value has a single owner unless borrowedownership and move semanticsdeterministic destruction, fewer accidental copiescloning to satisfy the borrow checker can add allocation
Shared borrows are read-only at the language level&Talias analysis can assume no mutation through that referenceinterior mutability changes the story
Mutable borrows are exclusive&mut Tenables store/load simplification and vectorizationunsafe aliases can cause undefined behavior
Types have known sizes unless behind indirectionSized, genericsstack allocation and monomorphizationlarge stack values can hurt cache or overflow
Layout is mostly compiler-chosen by defaultrepr(Rust)field reordering may reduce paddingcannot expose layout to FFI
Drops are deterministicRAIIno garbage collector pausedrop order and destructor cost matter

Stack vs heap

The stack is an automatic storage region for function frames. The heap is a dynamic storage region managed by an allocator. Rust does not decide stack versus heap from "value type" versus "reference type" the way some managed languages do. It depends on how a value is stored.

fn stack_and_heap() {
    let a: u64 = 42;              // stored in the stack frame
    let pair = (a, 99_u64);       // also in the stack frame
    let boxed = Box::new(pair);   // pair copied or moved into heap allocation
    let vec = vec![1_u64, 2, 3];  // Vec header on stack, buffer on heap

    println!("{boxed:?} {vec:?}");
}

Common layouts:

ExpressionStack containsHeap contains
u64the integernothing
(u64, u64)both integersnothing
Box<T>pointerone T allocation
Vec<T>pointer, length, capacitycontiguous buffer of T
Stringpointer, length, capacityUTF-8 bytes
&Tpointerborrowed storage elsewhere
&[T]pointer and lengthborrowed elements elsewhere
&dyn Traitdata pointer and vtable pointervalue stored elsewhere

Stack Strengths

Stack allocation is very cheap because it is usually pointer adjustment in the current thread's stack. It has strong locality because frames are contiguous and recently used. It also has strict lifetime structure: values are destroyed when their scope exits.

Stack Limits

Large stack values can overflow thread stack limits or push hot data out of cache. Recursive functions must be treated carefully. Moving a large stack value can also be expensive if it becomes a real memory copy after optimization.

const N: usize = 1024 * 1024;

fn risky_stack() {
    let bytes = [0_u8; N]; // large stack object
    consume(&bytes);
}

fn safer_heap() {
    let bytes = vec![0_u8; N]; // header on stack, backing storage on heap
    consume(&bytes);
}

fn consume(bytes: &[u8]) {
    println!("{}", bytes.len());
}

Heap Strengths

Heap allocation supports dynamic sizes, ownership transfer across scopes, shared ownership, trait objects, recursive types, and stable addresses when required.

Heap Costs

Heap allocation can involve allocator metadata, locks or thread-local caches, fragmentation, page faults, cache misses, and unpredictable latency. Allocation can dominate small-object workloads.

fn repeated_allocation(input: &[String]) -> usize {
    input
        .iter()
        .map(|s| s.to_lowercase()) // allocates a new String per item
        .filter(|s| s.starts_with("x"))
        .count()
}

fn allocation_aware(input: &[String]) -> usize {
    input
        .iter()
        .filter(|s| s.as_bytes().first().is_some_and(|b| b.eq_ignore_ascii_case(&b'x')))
        .count()
}

Allocation Behavior

Rust has no hidden tracing garbage collector, but high-level APIs can allocate. Know the difference between a value, a handle, and a backing buffer.

OperationUsually allocates?Notes
Box::new(value)yesone allocation for value
Vec::new()nono buffer until capacity is needed
Vec::with_capacity(n)yes if n > 0one buffer allocation
vec![x; n]yes if n > 0fills a buffer, may clone x
String::new()noempty header only
format!(...)yesallocates a String
to_string() on &stryesallocates a String
Iterator::collect::<Vec<_>>()usuallymay reserve using size hints
Arc::clone(&x)no payload allocationatomic refcount increment
Rc::clone(&x)no payload allocationnon-atomic refcount increment
Box<dyn Trait>yes for the boxed valuealso introduces dynamic dispatch

Growth And Reallocation

Vec and String have length and capacity. Pushing past capacity reallocates and moves elements.

fn build_lines(input: &[&str]) -> Vec<String> {
    let mut lines = Vec::with_capacity(input.len());

    for item in input {
        let mut line = String::with_capacity(item.len() + 8);
        line.push_str("item: ");
        line.push_str(item);
        lines.push(line);
    }

    lines
}

Guidance:

PatternUse whenAvoid when
with_capacitysize is known or cheaply estimatedestimate is wildly high
reserveyou need at least additional capacityallocation failure latency matters
reserve_exactover-allocation is harmfulrepeated growth is expected
shrink_to_fitmemory release is worth possible reallocationhot path code
arena allocationmany objects share one lifetimeobjects need independent destruction
object poolallocation latency dominatespool complexity exceeds benefit

Ownership As Memory Management

Ownership is Rust's primary memory management system. It determines when values are destroyed and who may access them.

struct Connection {
    fd: i32,
}

impl Drop for Connection {
    fn drop(&mut self) {
        // close(self.fd) in real code
        eprintln!("closing fd {}", self.fd);
    }
}

fn handle(conn: Connection) {
    // this function owns conn
    eprintln!("using fd {}", conn.fd);
} // conn is dropped here

The important performance property is not just safety. It is that destruction is deterministic, local, and visible in the type system. You can reason about when buffers, locks, files, sockets, transactions, and temporary allocations are released.

Moves, Copies, And Clones

Rust moves values by default. A move is a semantic transfer of ownership. After optimization, a move may be a register move, a memory copy, or no instruction at all.

#[derive(Clone)]
struct Packet {
    bytes: Vec<u8>,
}

fn transfer(packet: Packet) -> Packet {
    packet // ownership moved, buffer not copied
}

fn duplicate(packet: &Packet) -> Packet {
    packet.clone() // Vec buffer is copied
}

Common mistake: adding .clone() until the borrow checker accepts the code. That may turn a linear ownership transfer into repeated heap allocation.

RAII

RAII means resource acquisition is initialization. The destructor releases the resource when the value leaves scope.

use std::sync::{Arc, Mutex};

fn push_metric(metrics: Arc<Mutex<Vec<u64>>>, value: u64) {
    let mut guard = metrics.lock().expect("metrics mutex poisoned");
    guard.push(value);
} // mutex guard is dropped here, unlocking the mutex

Production guidance:

ResourceRAII typeReview concern
Heap allocationBox, Vec, Stringunnecessary allocation or reallocation
File descriptorFile, socket typesflush and close semantics
LockMutexGuard, RwLockReadGuardguard held across slow calls or .await
Transactiontransaction guardrollback behavior on drop
Temporary directorytempdir guardcleanup on panic and process abort

RAII runs on normal scope exit and unwinding panic. It does not run after std::process::abort, mem::forget, process kill, or many FFI-level failures.

Alignment And Padding

Every type has a size and alignment.

use std::mem::{align_of, size_of};

#[repr(C)]
struct Header {
    tag: u8,
    len: u32,
    flags: u16,
}

fn inspect_layout() {
    println!("Header size {}", size_of::<Header>());
    println!("Header align {}", align_of::<Header>());
}

Alignment is the address multiple required for efficient and valid access. Padding is unused space inserted so fields start at valid alignments and arrays of the type remain valid.

Rendering diagram...

Field order can change size under repr(Rust), but do not rely on the compiler's chosen field order. If layout is exposed externally, choose an explicit representation and test it.

use std::mem::size_of;

struct PoorOrder {
    a: u8,
    b: u64,
    c: u8,
}

struct BetterOrder {
    b: u64,
    a: u8,
    c: u8,
}

fn sizes() {
    println!("poor {}", size_of::<PoorOrder>());
    println!("better {}", size_of::<BetterOrder>());
}

Tradeoff:

GoalTechniqueCost
Reduce paddingreorder fields by descending alignmentmay reduce readability
Match C ABI#[repr(C)]freezes order, can increase size
Pack bytes tightly#[repr(packed)]references to unaligned fields are dangerous
Newtype ABI wrapper#[repr(transparent)]only valid for transparent-compatible wrappers

Niche Optimization

A niche is an invalid bit pattern for a type that the compiler can use to encode enum variants without adding a separate tag. This is why Option&lt;&T&gt; is usually pointer-sized: a null pointer can represent None because references cannot be null.

use std::mem::size_of;
use std::num::NonZeroUsize;

fn niche_sizes() {
    assert_eq!(size_of::<Option<&u8>>(), size_of::<&u8>());
    assert_eq!(size_of::<Option<NonZeroUsize>>(), size_of::<usize>());
}

Useful niche-aware types:

TypeInvalid valueUseful enum
&TnullOption&lt;&T&gt;
Box&lt;T&gt;nullOption&lt;Box&lt;T&gt;&gt;
NonNull&lt;T&gt;nullOption&lt;NonNull&lt;T&gt;&gt;
NonZeroU32zeroOption&lt;NonZeroU32&gt;
NonZeroUsizezeroOption&lt;NonZeroUsize&gt;

Guidance:

Use niche optimization forAvoid relying on it when
dense internal structs, indexes, handlesABI or persistence format requires exact bytes
optional non-null pointersunsafe invariants are not well documented
reducing enum size in hot arraysreadability loss exceeds memory benefit

Fat Pointers

Some pointers carry metadata.

PointerWordsMetadata
&T1none
*const T1none
&[T]2length
&str2byte length
&dyn Trait2vtable pointer
Box&lt;dyn Trait&gt;2 in the box handlevtable pointer plus data pointer
use std::mem::size_of;

trait Draw {
    fn draw(&self);
}

struct Circle;

impl Draw for Circle {
    fn draw(&self) {}
}

fn pointer_widths(slice: &[u8], text: &str, object: &dyn Draw) {
    println!("slice ref {}", size_of_val(&slice));
    println!("str ref {}", size_of_val(&text));
    println!("trait object ref {}", size_of_val(&object));
}

fn size_of_val<T>(_: &T) -> usize {
    size_of::<T>()
}

Vtables And Dynamic Dispatch

A trait object stores a data pointer and a vtable pointer. The vtable contains function pointers for the concrete implementation, plus metadata such as size, alignment, and drop glue.

Rendering diagram...
trait Encode {
    fn encode(&self, out: &mut Vec<u8>);
}

fn static_dispatch<T: Encode>(value: &T, out: &mut Vec<u8>) {
    value.encode(out); // monomorphized per T
}

fn dynamic_dispatch(value: &dyn Encode, out: &mut Vec<u8>) {
    value.encode(out); // indirect call through vtable
}

Tradeoffs:

DispatchBenefitCostGood fit
Generic static dispatchinlining, specialization by type, no vtable callcode size from monomorphizationhot paths, small methods
Trait object dynamic dispatchsmaller code, heterogeneous collectionsindirect call, less inliningplugin points, large methods, cold paths
Enum dispatchstatic branch over known variantsenum maintenance and branchclosed set of implementations

Zero Cost Abstractions

Zero cost abstractions depend on optimization and representation. In Rust, the main tools are generics, traits, iterators, closures, ownership, RAII, and type states.

fn sum_loop(values: &[u64]) -> u64 {
    let mut sum = 0;
    for value in values {
        sum += *value;
    }
    sum
}

fn sum_iterator(values: &[u64]) -> u64 {
    values.iter().copied().sum()
}

In optimized builds, these often compile to comparable machine code. In debug builds, iterator code can look slower because optimization is limited.

Iterator Fusion

Iterator adapters are lazy. They often compile into one loop without intermediate allocation.

fn count_large_even(values: &[u32]) -> usize {
    values
        .iter()
        .copied()
        .filter(|v| v % 2 == 0)
        .filter(|v| *v > 1000)
        .count()
}

This does not allocate because no collect appears before count.

fn accidental_allocation(values: &[u32]) -> usize {
    values
        .iter()
        .copied()
        .filter(|v| v % 2 == 0)
        .collect::<Vec<_>>() // allocation and copy
        .into_iter()
        .filter(|v| *v > 1000)
        .count()
}

Abstraction Checklist

QuestionWhy it matters
Does this allocate?Allocation can dominate runtime and tail latency
Is dispatch static or dynamic?Dynamic dispatch can block inlining
Does it copy or clone payload data?Clones can hide deep allocation
Is the hot path generic over large closures?Monomorphization can increase binary size
Does the abstraction preserve locality?Fragmented object graphs hurt cache performance
Does debug performance matter?Some abstractions depend on release optimization

Bounds Checks

Indexing slices and vectors performs bounds checks. The compiler can remove many checks when it can prove indices are valid.

fn checked_index(values: &[u64], i: usize) -> Option<u64> {
    values.get(i).copied()
}

fn sum_by_index(values: &[u64]) -> u64 {
    let mut sum = 0;
    for i in 0..values.len() {
        sum += values[i]; // usually one provably safe check pattern
    }
    sum
}

fn sum_by_iter(values: &[u64]) -> u64 {
    values.iter().copied().sum() // often easiest for check elimination
}

Guidance:

PatternPerformance shape
for value in valuesusually best for linear traversal
values.get(i)explicit optional access, no panic
values[i]panics on out of bounds, check may be removed
unsafe { get_unchecked(i) }no check, undefined behavior if wrong

Do not use get_unchecked until profiling proves bounds checks matter and a small safe rewrite cannot remove them. Unsafe unchecked indexing must have a local proof that the index is in bounds.

Inlining

Inlining removes call overhead and exposes optimization opportunities. It can also increase code size and instruction cache pressure.

#[inline]
fn clamp_percent(value: u8) -> u8 {
    value.min(100)
}

#[inline(never)]
fn cold_format_error(code: u32) -> String {
    format!("error code {code}")
}

Rules of thumb:

AttributeMeaningUse
no attributelet the compiler decidedefault
#[inline]make cross-crate inlining more likelytiny hot functions in libraries
#[inline(always)]force request, not absolute guaranteerare, measured hot path
#[inline(never)]keep out of callercold code, profiling clarity
#[cold]mark unlikely patherror construction, slow fallback

Use measurement. Inlining a cold formatting path into a hot loop is a common regression.

Escape Analysis And Scalar Replacement

Rust does not promise a specific escape analysis optimization, but LLVM can remove allocations or scalar-replace aggregates when it proves the value does not escape and the abstraction is transparent enough.

#[derive(Clone, Copy)]
struct Point {
    x: f64,
    y: f64,
}

fn distance_squared(a: Point, b: Point) -> f64 {
    let dx = a.x - b.x;
    let dy = a.y - b.y;
    dx * dx + dy * dy
}

This style gives the optimizer simple values. Contrast with a pointer-heavy object graph where data escapes through trait objects, shared ownership, or FFI.

use std::sync::Arc;

trait Metric {
    fn value(&self) -> f64;
}

fn average(metrics: &[Arc<dyn Metric>]) -> f64 {
    let total: f64 = metrics.iter().map(|m| m.value()).sum();
    total / metrics.len() as f64
}

The second example may be the right design, but it has indirection, atomics, dynamic dispatch, and poor locality.

Cache Locality

Modern CPUs are fast when data access is predictable and contiguous. Rust's type system helps express contiguous storage through Vec&lt;T&gt;, arrays, slices, and stack structs.

struct User {
    id: u64,
    score: u32,
    active: bool,
}

fn count_active_high_score(users: &[User]) -> usize {
    users
        .iter()
        .filter(|u| u.active && u.score > 900)
        .count()
}

Array Of Structs Versus Struct Of Arrays

struct UsersAos {
    users: Vec<User>,
}

struct UsersSoa {
    ids: Vec<u64>,
    scores: Vec<u32>,
    active: Vec<bool>,
}
LayoutBenefitCost
Array of structsnatural modeling, all fields togetherwastes bandwidth if only one field is read
Struct of arraystight scans over one field, SIMD-friendlymore complex invariants, multiple buffers
Hybrid hot/cold splitkeeps hot fields densemore code and indexing discipline

Hot/cold split:

struct SessionHot {
    last_seen_ms: u64,
    request_count: u32,
    state: u8,
}

struct SessionCold {
    user_agent: String,
    debug_label: Option<String>,
}

struct SessionStore {
    hot: Vec<SessionHot>,
    cold: Vec<SessionCold>,
}

This is useful when hot loops touch counters and states but rarely touch strings.

SIMD Overview

SIMD executes one instruction over multiple lanes. Rust can benefit from auto-vectorization, target-specific intrinsics, portable SIMD on nightly when available, and library implementations.

fn add_scalar(a: &[f32], b: &[f32], out: &mut [f32]) {
    assert_eq!(a.len(), b.len());
    assert_eq!(a.len(), out.len());

    for ((out, a), b) in out.iter_mut().zip(a).zip(b) {
        *out = *a + *b;
    }
}

This loop is vectorization-friendly: contiguous slices, no alias between mutable output and immutable inputs as expressed by safe borrowing, simple arithmetic, and known lengths after assertions.

SIMD guidance:

ApproachStabilityUse
Auto-vectorizationstablefirst choice for simple loops
std::arch intrinsicsstable per targetperformance-critical target-specific kernels
Portable SIMDmay require nightly depending on date and feature statecontrolled projects that accept toolchain constraints
Crates such as wide, packed_simd alternatives, domain librariesvarieswhen library maintenance is strong

Always verify generated code or benchmark results. SIMD can lose when data is small, unaligned, branch-heavy, or memory-bandwidth bound.

Profiling Rust Programs

Profiling answers where time goes. Benchmarking answers whether a change made a workload faster. They are related but not interchangeable.

Rendering diagram...

Recommended workflow:

ToolPurposeNotes
cargo build --releaseoptimized binarydebug builds mislead performance analysis
perf on LinuxCPU samplingneeds symbols and permissions
cargo flamegraphflamegraph from samplesexcellent first-pass visualization
heaptrack, dhat, allocator statsallocation profilingplatform and allocator dependent
tokio-consoleasync task diagnosticsfor Tokio applications
criterionstatistically useful microbenchmarkscompare focused workloads
cargo bloatbinary size and monomorphizationuseful for generic-heavy libraries
cargo asm or compiler explorerinspect generated assemblyverify bounds checks, inlining, vectorization

Flamegraphs

A flamegraph shows sampled stack traces. Wide frames consume more samples. It does not prove causality by itself, but it quickly highlights where time is spent.

Example commands:

cargo install flamegraph
cargo flamegraph --bin my-service -- --config local.toml

Operational guidance:

SituationWhat to check
Wide allocator framesrepeated allocation, formatting, collection growth
Wide hashing framesmap key choice, hasher, repeated lookups
Wide clone framesaccidental deep clone
Wide parsing framesallocation-heavy parser, validation repeated
Wide lock framescontention, guard scope, blocking in critical section
Wide poll framesasync task churn, wake storm, small futures overhead

Benchmarking With Criterion

Criterion runs statistical benchmarks and helps avoid noisy single-run conclusions.

Example benches/parse.rs:

use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn parse_ids(input: &str) -> Vec<u64> {
    input
        .split(',')
        .filter_map(|part| part.parse::<u64>().ok())
        .collect()
}

fn bench_parse_ids(c: &mut Criterion) {
    let input = (0..10_000)
        .map(|n| n.to_string())
        .collect::<Vec<_>>()
        .join(",");

    c.bench_function("parse_ids", |b| {
        b.iter(|| parse_ids(black_box(&input)))
    });
}

criterion_group!(benches, bench_parse_ids);
criterion_main!(benches);

Cargo.toml:

[dev-dependencies]
criterion = "0.8"

[[bench]]
name = "parse"
harness = false

Benchmark review checklist:

CheckReason
Use black_box for inputs and outputsprevents optimizing away work
Benchmark release codedebug numbers are not representative
Include realistic input sizestiny cases hide allocation and cache effects
Separate setup from measured loopavoids measuring fixture creation
Track variancenoisy systems can fake regressions
Compare against a baselineabsolute time alone is weak evidence
Keep benchmarks in version controlprevents performance archaeology

Performance Anti-Patterns

Anti-patternWhy it hurtsBetter approach
Cloning to satisfy borrowinghidden allocation and memory trafficrestructure ownership, borrow narrower, use indices
format! in a hot loopallocates and formats repeatedlywrite into reusable buffer
Collecting mid-iteratormaterializes temporary datakeep iterator lazy or preallocate once
Arc&lt;Mutex&lt;T&gt;&gt; everywhereatomics, contention, complex ownershipuse ownership transfer, channels, sharding
Trait objects for tiny hot methodsindirect calls block inlininggenerics or enum dispatch
Pointer-rich graphscache misses and allocator pressuredense indexes into Vec arenas
Repeated hash lookupshashing cost and cache missesuse entry API, store handles, choose map carefully
Large enum in dense arraysevery element has max variant sizebox cold large variants or split storage
Holding locks across slow workcontention and latency spikesnarrow guard scope
Async for CPU-bound loopsscheduler overhead, no parallelism by itselfuse sync loop or spawn blocking CPU pool

Practical Patterns

Reuse A Buffer

use std::fmt::Write;

fn render_rows(rows: &[u64]) -> String {
    let mut out = String::with_capacity(rows.len() * 8);

    for row in rows {
        writeln!(&mut out, "row={row}").expect("writing to String cannot fail");
    }

    out
}

Store Handles Instead Of References

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct NodeId(usize);

struct Node {
    weight: u32,
    edges: Vec<NodeId>,
}

struct Graph {
    nodes: Vec<Node>,
}

impl Graph {
    fn neighbors(&self, id: NodeId) -> &[NodeId] {
        &self.nodes[id.0].edges
    }
}

Indexes can avoid self-referential structures, reduce lifetime complexity, and keep storage dense.

Reduce Lock Scope

use std::collections::HashMap;
use std::sync::Mutex;

fn get_or_compute(cache: &Mutex<HashMap<String, u64>>, key: &str) -> u64 {
    if let Some(value) = cache.lock().expect("cache poisoned").get(key).copied() {
        return value;
    }

    let value = expensive_compute(key);
    cache
        .lock()
        .expect("cache poisoned")
        .insert(key.to_owned(), value);
    value
}

fn expensive_compute(key: &str) -> u64 {
    key.len() as u64
}

This example avoids holding the lock while computing. It may compute duplicate values under races. That can be acceptable when duplicate work is cheaper than broad locking.

Experimental Method for Performance Work

A performance claim needs a workload, environment, metric, baseline, and uncertainty. Start with a user-visible budget such as p99 latency, throughput at a fixed concurrency, peak resident memory, CPU seconds per job, or energy per unit of work.

Use this sequence:

  1. Freeze correctness tests and define the representative input distribution.
  2. Record toolchain, target, features, profile, CPU governor, machine model, and relevant kernel settings.
  3. Measure the end-to-end baseline before isolating a suspected component.
  4. Profile wall time, CPU samples, allocation, locks, I/O, and hardware counters as appropriate.
  5. Form one causal hypothesis and change one relevant variable.
  6. Repeat enough samples to distinguish the effect from noise.
  7. Confirm the whole-system metric and check for regressions in memory, tails, portability, and correctness.

Microbenchmarks should isolate stable work, use black_box where compiler elimination is plausible, and avoid timing setup unless setup is the subject. Report distributions or confidence intervals rather than only the fastest observation.

Cache Locality and False Sharing

Cache behavior often dominates arithmetic cost. Sequential access through compact data usually beats pointer chasing, even when both designs have the same asymptotic complexity.

False sharing occurs when independent values written by different cores occupy the same cache line. The program has no logical lock contention, but cache-coherence traffic serializes progress. Diagnose it with scaling curves and hardware counters, then consider sharding, per-thread accumulation, ownership transfer, or cache padding. A hard-coded alignment is architecture-specific; a reviewed abstraction such as crossbeam_utils::CachePadded communicates intent better.

Structure-of-arrays layouts help when a hot loop touches only a subset of fields. Array-of-structures layouts help when each operation consumes most fields of one entity. Measure both with realistic traversal and update patterns.

NUMA and Thread Placement

On a non-uniform memory access system, a core reaches some memory controllers faster than others. Cross-socket traffic can dominate a well-vectorized algorithm, especially for large shared structures.

NUMA behavior is an operating-system and machine property, not a Rust guarantee. Common Linux policies place physical pages according to first touch, so initialization thread placement can influence later access cost. Validate the actual policy rather than assuming it.

  • Partition long-lived data and workers by NUMA node when the workload has a natural shard key.
  • Initialize large buffers from the threads or nodes that will process them.
  • Avoid one globally mutated allocator-heavy structure when per-node ownership and periodic merge preserve semantics.
  • Thread pinning can improve locality but can also reduce scheduler flexibility and worsen imbalance.
  • Record CPU topology, affinity, memory policy, page size, and cross-node counters with every NUMA benchmark.
  • Measure tail latency and throughput under the deployed socket count; a single-socket development machine cannot validate the design.

NUMA optimization belongs after algorithm, allocation, and contention profiling. It can make portable code platform-specific, so isolate topology policy from the core algorithm.

Allocation Strategy and Fallibility

Allocation optimization is about frequency, size distribution, contention, and lifetime.

  • Use with_capacity when a trustworthy upper estimate exists; excessive reserve raises peak memory and cache pressure.
  • Reuse owned buffers across iterations when this does not retain pathological high-water capacity forever.
  • reserve may abort or panic according to allocator behavior; try_reserve exposes capacity failure where the application can recover.
  • Arena allocation makes bulk teardown cheap but does not remove destructor, retention, or reference-validity concerns.
  • Small-vector and string-interning designs trade branches, larger element size, or global coordination for fewer allocations.
  • A different global allocator can change contention and fragmentation, but it cannot repair unnecessary ownership churn.

Measure allocations with an allocator profiler or scoped counter rather than inferring them only from source code.

Aliasing, Bounds Checks, and Vectorization

Rust references carry validity and aliasing promises that optimizers may exploit. An exclusive &mut T must not overlap active accesses that violate its contract. Breaking that promise in unsafe code is undefined behavior even if the generated code appears correct in a debug build.

Before using unchecked indexing:

  1. Inspect optimized assembly or compiler reports to see whether checks remain.
  2. Restructure the loop with iterators, slices, chunks_exact, or a single precondition that allows check elimination.
  3. Benchmark the safe version.
  4. If unsafe code is still material, state the bounds invariant next to the operation and test it with Miri, sanitizers, and adversarial lengths.

Safe iterators often vectorize well because they expose a monotonic traversal without repeated opaque indexing.

PGO, LTO, and CPU Targeting

Link-time optimization improves cross-crate optimization at the cost of build time and memory. Profile-guided optimization uses recorded workload behavior to guide layout and inlining. Both must be evaluated on the final artifact with production-like inputs.

-C target-cpu=native may enable instructions unavailable on deployment machines. Use it for machine-local binaries or homogeneous fleets with an explicit CPU baseline. For portable distribution, select a documented target CPU or use runtime feature detection and multiversioning for narrow kernels.

Post-link optimizers such as BOLT can improve code layout on supported platforms after collecting profiles. They add another artifact transformation, so retain debuginfo mappings, provenance, and reproducible release steps.

Production Guidance

AreaGuidance
API designAccept &str, &[T], and generic iterators when callers should not allocate
OwnershipPrefer ownership transfer over Arc unless sharing is required
CollectionsPreallocate when cardinality is known, but avoid huge speculative reserves
LayoutMeasure size_of for dense structs and enums
DispatchUse generics in hot paths and trait objects at architectural boundaries
CachesOptimize data layout before micro-optimizing arithmetic
AsyncMeasure task counts, wakeups, and allocation per request
ErrorsKeep rich errors on cold paths; avoid formatting unless needed
BuildProfile with release settings and representative features
SafetyDo not replace safe code with unsafe code for speed without a benchmark and proof

Review Checklist

  • Does this change introduce a new allocation in a hot path?
  • Does a .clone() copy heap-backed data or just a cheap handle?
  • Are buffers reused where repeated formatting or parsing occurs?
  • Are Vec and String capacities chosen intentionally?
  • Does the data layout match the traversal pattern?
  • Are large structs or enums stored densely in arrays without measuring size?
  • Are trait objects used in code that needs inlining?
  • Are bounds checks visible in profiles, or is unsafe indexing premature?
  • Are locks held across I/O, logging, allocation, or .await?
  • Are benchmarks realistic, checked into the repo, and run in release mode?
  • Was the whole program profiled before micro-optimizing?
  • Did a performance change preserve safety invariants covered in Rust/09 Unsafe Rust and the Rust Memory Model?

Common Mistakes

  1. Assuming "Rust is fast" means any Rust code is fast.
  2. Treating stack allocation as infinite or always better.
  3. Treating heap allocation as always bad, even when it simplifies ownership and reduces copies.
  4. Adding Arc&lt;Mutex&lt;_&gt;&gt; before deciding who should own the data.
  5. Measuring debug builds.
  6. Ignoring allocation in format!, to_string, collect, and parser combinators.
  7. Trusting microbenchmarks that do not resemble production inputs.
  8. Reading a flamegraph as a complete explanation rather than a map of where to investigate.
  9. Using unsafe unchecked indexing before proving bounds checks are material.
  10. Freezing repr(C) layout for internal types without an ABI reason.

Field Notes

For serious Rust systems, the highest leverage performance work is usually:

  1. Remove accidental allocation.
  2. Improve data locality.
  3. Reduce synchronization scope.
  4. Keep hot code monomorphic and inlinable.
  5. Use profiling to guide changes.
  6. Use Criterion to guard focused improvements.
  7. Keep unsafe optimizations rare, documented, and reviewed with the same rigor as security-sensitive code.

Primary Sources