Hardware Optimization in Rust: Cache-Friendly Layouts, Data-Oriented Design, and SIMD with std::arch
Writing correct Rust is only half the job in systems programming. The other half is writing Rust that the hardware loves. Modern CPUs are extraordinary machines, but they come with quirks: they hate random memory access, they love doing the same operation on many values at once, and they spend most of their time waiting for data rather than computing.
This guide covers three interconnected ideas: cache-friendly data layout (arranging your data so the CPU rarely has to wait), Data-Oriented Design (DOD) (a design philosophy that puts data shape first), and SIMD with std::arch (running one instruction on 4, 8, or 16 values simultaneously).
Every concept starts with a real-world analogy. No prior performance engineering experience needed.
Part 1: The Memory Wall — Why Layout Matters at All
The Assembly Line Analogy
Picture a car factory. The assembly robot can bolt on a car door in 0.001 seconds. But the door parts are stored in a warehouse two kilometers away. The robot spends 30 seconds per door just waiting for the forklift to deliver the part. The robot is not slow—the delivery is slow.
Modern CPUs face the same problem. A CPU core can execute a multiplication in 3 nanoseconds, but fetching a value from RAM takes 60–100 nanoseconds—20–30× slower. The CPU spends most of its time stalled, waiting for the memory forklift to arrive. The solution is to arrange data so that everything the CPU needs next is already in the L1 or L2 cache, the warehouse right next to the robot.
Cache Lines: The Unit of Memory Transfer
Every time the CPU reads one byte from RAM it actually fetches 64 consecutive bytes (one cache line) and stores the whole thing in the L1 cache. This is why layout matters: if the data you will use next lives in those same 64 bytes, you get it for free. If it lives somewhere else entirely, you pay another 60 ns trip to RAM.
// One 64-byte cache line holds:
// 64 × u8 (1 byte each)
// 8 × u64 (8 bytes each)
// 4 × f32 (4 bytes each) ← plus 4 × f32 = 32 bytes spare
// 2 × [f32; 4] (16 bytes each — a 128-bit SIMD register's worth)
// If you read data[0], the CPU fetches data[0..64] automatically.
// Iterating data[1], data[2], ... costs NOTHING extra until data[64].
let data: [f32; 1024] = [0.0f32; 1024]; // 4 KB, 64 cache lines
let sum: f32 = data.iter().sum(); // touches each line exactly once — optimalPart 2: Cache-Friendly Data Layout
Rule 1 — Keep Hot Fields Together
Fields that are accessed together in a hot loop should live in the same struct, so they land in the same cache line. Fields that are rarely accessed (“cold” data) should be moved out, so they don’t waste cache space.
Analogy: a chef keeps the salt, pepper, and olive oil on the counter top (hot data — used every minute). The cake molds are in a cabinet across the room (cold data — used once a week). Putting cake molds on the counter just clutters the workspace.
// BAD: hot fields (position, velocity) and cold fields (name, metadata)
// all crammed into one struct — 128+ bytes, never fits in one cache line
struct Entity {
position: [f32; 3], // 12 bytes — read every frame
velocity: [f32; 3], // 12 bytes — read every frame
name: String, // 24 bytes — read once (for UI)
description: String, // 24 bytes — read once (for UI)
spawn_time: u64, // 8 bytes — read once (for logging)
// total: 80+ bytes, spans at least 2 cache lines
}
// GOOD: split into hot and cold halves
struct EntityHot {
position: [f32; 3], // 12 bytes
velocity: [f32; 3], // 12 bytes
// 24 bytes total — two entities fit in one cache line
}
struct EntityCold {
name: String,
description: String,
spawn_time: u64,
}
// Hot loop only touches EntityHot — extremely cache-friendly
fn update_positions(hot: &mut [EntityHot], dt: f32) {
for e in hot.iter_mut() {
e.position[0] += e.velocity[0] * dt;
e.position[1] += e.velocity[1] * dt;
e.position[2] += e.velocity[2] * dt;
}
}Rule 2 — Mind Struct Padding and Field Order
The Rust compiler (following the platform ABI) inserts invisible padding bytes between fields to satisfy alignment requirements. A poorly ordered struct wastes bytes in every instance, reducing how many fit in a cache line.
use std::mem::size_of;
// BAD field order — Rust inserts 7 bytes of padding
struct Wasteful {
a: u8, // 1 byte
// 7 bytes padding (to align 'b' to 8)
b: u64, // 8 bytes
c: u8, // 1 byte
// 7 bytes padding (to keep struct size a multiple of 8)
// total: 24 bytes — 14 bytes are padding!
}
// GOOD field order — largest fields first
struct Compact {
b: u64, // 8 bytes
a: u8, // 1 byte
c: u8, // 1 byte
// 6 bytes padding (to keep size a multiple of 8)
// total: 16 bytes — only 6 bytes of padding
}
fn main() {
println!("Wasteful: {} bytes", size_of::<Wasteful>()); // 24
println!("Compact: {} bytes", size_of::<Compact>()); // 16
// If you store 1 million of these: 8 MB difference!
}
// Tip: use cargo-bloat or the 'memoffset' crate to inspect layout
// Use #[repr(C)] to get predictable C-compatible layout
// Use #[repr(packed)] only as a last resort (may cause misaligned reads)Quick rule: order fields largest to smallest. A u64 followed by two u32s followed by four u8s wastes nothing. Mixing sizes randomly wastes up to half your struct as padding.
Rule 3 — Prefer Contiguous Arrays Over Linked Collections
A Vec<T> stores all elements back-to-back in one heap allocation. A linked list or a tree of Box<Node> scatters each node across the heap. Walking the linked structure means a pointer chase per node — one potential cache miss per step.
use std::collections::LinkedList;
const N: usize = 1_000_000;
// Vec: 1 allocation, all elements contiguous
// Iterating = 1 cache miss per 16 elements (64 bytes / 4 bytes per f32)
let vec: Vec<f32> = (0..N as u32).map(|x| x as f32).collect();
let vec_sum: f32 = vec.iter().sum(); // ~fast
// LinkedList: N allocations, elements scattered across heap
// Iterating = up to 1 cache miss per element
let mut list: LinkedList<f32> = LinkedList::new();
for i in 0..N { list.push_back(i as f32); }
let list_sum: f32 = list.iter().sum(); // ~3–10× slower
// In practice, benchmark with 'criterion':
// Vec iteration: ~1 ms
// LinkedList iteration: ~8 ms (for 1M f32s on a modern desktop)Rule 4 — Avoid Pointer Indirection in Hot Paths
Every Box<T>, Arc<T>, or dyn Trait introduces a level of indirection: the CPU must load the pointer, then follow it to a potentially cold memory location. In a tight loop over thousands of objects, this adds up fast.
// SLOW: each shape is a heap allocation; vtable dispatch on every call
trait Shape { fn area(&self) -> f32; }
struct Circle { r: f32 }
struct Rect { w: f32, h: f32 }
impl Shape for Circle { fn area(&self) -> f32 { std::f32::consts::PI * self.r * self.r } }
impl Shape for Rect { fn area(&self) -> f32 { self.w * self.h } }
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
let total: f32 = shapes.iter().map(|s| s.area()).sum();
// FAST: all variants in one enum, stored in a flat Vec — zero indirection
#[derive(Copy, Clone)]
enum ShapeFlat {
Circle { r: f32 },
Rect { w: f32, h: f32 },
}
impl ShapeFlat {
fn area(self) -> f32 {
match self {
ShapeFlat::Circle { r } => std::f32::consts::PI * r * r,
ShapeFlat::Rect { w, h } => w * h,
}
}
}
let shapes: Vec<ShapeFlat> = vec![
ShapeFlat::Circle { r: 1.0 },
ShapeFlat::Rect { w: 2.0, h: 3.0 },
];
let total: f32 = shapes.iter().copied().map(ShapeFlat::area).sum();
// All data is contiguous — CPU prefetcher loves thisPart 3: Data-Oriented Design (DOD)
The Spreadsheet vs Rolodex Analogy
Suppose you need to find every contact whose last name starts with “S”. If names are stored in individual rolodex cards scattered across your desk (Array of Structs), you must flip through every card, reading all its fields just to check the name. Most of the information on each card is irrelevant and wastes your time.
But if you keep a single spreadsheet column of just the last names (Struct of Arrays), you scan one tight column of data and find every “S” in seconds. The CPU does exactly the same thing: scanning one dense column of identical-sized values is dramatically faster than jumping between fat structs.
Array of Structs (AoS) vs Struct of Arrays (SoA)
This is the core trade-off in DOD. AoS (the OOP default) is convenient and readable. SoA is faster when you process many objects but only touch one or two fields at a time.
// Array of Structs — the intuitive layout
#[derive(Clone)]
struct Particle {
x: f32, // 4 bytes ← needed in physics step
y: f32, // 4 bytes ← needed in physics step
z: f32, // 4 bytes ← needed in physics step
vx: f32, // 4 bytes ← needed in physics step
vy: f32, // 4 bytes ← needed in physics step
vz: f32, // 4 bytes ← needed in physics step
mass: f32, // 4 bytes ← needed in physics step
color: u32, // 4 bytes ← only needed for rendering
name_idx: u32, // 4 bytes ← only needed for UI
flags: u32, // 4 bytes ← only needed for game logic
// 40 bytes total per particle
}
// When updating physics, we load 40 bytes per particle
// but only USE 28 bytes — 30% of each cache line is wasted
let mut particles: Vec<Particle> = vec![/* 100,000 particles */];
fn update_positions_aos(particles: &mut [Particle], dt: f32) {
for p in particles.iter_mut() {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
}
// Each particle = 40 bytes loaded; 28 bytes used; 12 bytes waste per particle
}// Struct of Arrays — each field is its own contiguous Vec
struct Particles {
x: Vec<f32>,
y: Vec<f32>,
z: Vec<f32>,
vx: Vec<f32>,
vy: Vec<f32>,
vz: Vec<f32>,
mass: Vec<f32>,
color: Vec<u32>, // cold — only for rendering
name_idx: Vec<u32>, // cold — only for UI
flags: Vec<u32>, // cold — only for game logic
len: usize,
}
fn update_positions_soa(p: &mut Particles, dt: f32) {
// Only touch x, y, z, vx, vy, vz — the physics columns
// Each Vec is its own contiguous allocation → 100% utilisation per line
let n = p.len;
for i in 0..n { p.x[i] += p.vx[i] * dt; }
for i in 0..n { p.y[i] += p.vy[i] * dt; }
for i in 0..n { p.z[i] += p.vz[i] * dt; }
// color, name_idx, flags are never touched → never evicted from cache
}
// Benchmark result for 100,000 particles (typical game sim):
// AoS: ~2.1 ms
// SoA: ~0.6 ms (~3.5× faster)Hybrid AoSoA: Best of Both Worlds
Pure SoA is cache-optimal for SIMD but awkward to work with. Pure AoS is ergonomic but wasteful. The Array of Structs of Arrays (AoSoA) layout groups entities into fixed-size chunks (e.g. 8 or 16 — one SIMD register's worth), with each chunk stored SoA-style. This is the layout used by game engines like Unity (DOTS) and libraries like Arrow.
const CHUNK: usize = 8; // matches AVX 256-bit register (8 × f32)
struct ParticleChunk {
x: [f32; CHUNK], // 32 bytes — one AVX register
y: [f32; CHUNK], // 32 bytes
z: [f32; CHUNK], // 32 bytes
vx: [f32; CHUNK], // 32 bytes
vy: [f32; CHUNK], // 32 bytes
vz: [f32; CHUNK], // 32 bytes
// 192 bytes per chunk = 3 cache lines
// but we process 8 particles at a time → perfectly aligned for SIMD
}
struct Particles {
chunks: Vec<ParticleChunk>,
remainder: usize, // how many particles are valid in the last chunk
}
fn update_positions_aosoa(particles: &mut Particles, dt: f32) {
for chunk in particles.chunks.iter_mut() {
for i in 0..CHUNK {
chunk.x[i] += chunk.vx[i] * dt;
chunk.y[i] += chunk.vy[i] * dt;
chunk.z[i] += chunk.vz[i] * dt;
}
// The auto-vectoriser can now convert this loop into AVX instructions
// because chunk.x is 32-byte aligned and CHUNK == 8
}
}DOD Principle: Think in Transformations, Not Objects
Object-Oriented Design asks: “what is this thing and what can it do?” Data-Oriented Design asks: “what data does this system need, and what transformation does it apply?”
OOP Thinking
- Entity has position, health, inventory
- Entity.update() calls Entity.move()
- Inheritance / trait objects for polymorphism
- Each object owns and manages itself
- Virtual dispatch per method call
DOD Thinking
- Separate arrays: positions[], health[], inventories[]
- System processes all positions in one pass
- Enum or tag for type dispatch
- Systems operate on data tables
- No virtual dispatch; branch predictor wins
Part 4: SIMD Basics with std::arch
The Restaurant Order Analogy
A regular kitchen processes one order at a time: chop one vegetable, plate one dish, serve one customer. A SIMD kitchen has a chef who can chop 8 vegetables simultaneously with one motion of a very wide knife, plate 8 dishes at once, and serve 8 customers in the same time it takes a regular kitchen to serve one.
SIMD (Single Instruction, Multiple Data) is the wide knife. Instead of one CPU instruction operating on one value, a SIMD instruction operates on a whole vector registerthat holds 4, 8, or 16 values at once. Add 8 pairs of floats in one instruction instead of 8 separate instructions.
SIMD Register Widths
SSE2 (128-bit)
- Available since Pentium 4 (2001)
- 4 × f32 or 2 × f64 per register
- Baseline for x86-64
- Rust type:
__m128
AVX / AVX2 (256-bit)
- Available since Sandy Bridge (2011)
- 8 × f32 or 4 × f64 per register
- Most modern desktops & servers
- Rust type:
__m256
AVX-512 (512-bit)
- Available on Skylake-X, Zen 4+
- 16 × f32 or 8 × f64
- Server / HPC workloads
- Rust type:
__m512
Level 0: Let the Compiler Auto-Vectorise
Before reaching for std::arch, try letting the Rust compiler (via LLVM) auto-vectorise your loops. Simple loops over slices of primitives are often auto-vectorised when you compile with -C target-cpu=native.
# .cargo/config.toml (project-wide)
[build]
rustflags = ["-C", "target-cpu=native"]
# Or per invocation:
RUSTFLAGS="-C target-cpu=native" cargo build --release
# Inspect the generated assembly to confirm vectorisation:
cargo install cargo-show-asm
cargo asm --lib my_crate::sum_f32 --release// This loop is a prime candidate for auto-vectorisation:
// - fixed-type slice (&[f32])
// - simple arithmetic, no conditionals
// - no aliasing (single mutable borrow)
pub fn scale(data: &mut [f32], factor: f32) {
for x in data.iter_mut() {
*x *= factor;
}
}
// With -C target-cpu=native on AVX2, LLVM emits roughly:
// vmulps ymm0, ymm1, [ptr] ← multiplies 8 f32s in one instruction
// add ptr, 32
// jmp loop_top
// (scalar fallback for the remainder)Level 1: Portable SIMD with std::simd (Nightly)
The std::simd module (currently nightly-only, tracking issue for stabilisation) lets you write SIMD code that is portable across x86, ARM, RISC-V, and WebAssembly. The API is high-level and safe.
#![feature(portable_simd)]
use std::simd::{f32x8, SimdFloat};
pub fn dot_product_simd(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
let mut sum = f32x8::splat(0.0); // [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
let chunks = a.len() / 8;
for i in 0..chunks {
let va = f32x8::from_slice(&a[i*8..]); // load 8 floats
let vb = f32x8::from_slice(&b[i*8..]); // load 8 floats
sum += va * vb; // 8 multiplies, 8 adds — ONE instruction each
}
let mut result: f32 = sum.reduce_sum(); // horizontal add across lanes
// Handle remainder (when len is not a multiple of 8)
for i in (chunks * 8)..a.len() {
result += a[i] * b[i];
}
result
}
// Compared to scalar dot product:
// scalar: 1 multiply + 1 add per iteration
// f32x8 SIMD: 8 multiplies + 8 adds per iteration — 8× throughputLevel 2: Platform-Specific SIMD with std::arch
When you need maximum control — or when stable Rust is required — use std::arch. This module exposes CPU intrinsics directly: functions like _mm256_add_ps that map one-to-one to actual CPU instructions. It is unsafe because you must guarantee the CPU supports the instruction set.
The standard pattern is to dispatch at runtime using is_x86_feature_detected!, so the same binary runs on both old and new CPUs.
use std::arch::x86_64::*;
/// Sum a slice of f32s, using AVX2 if available, scalar otherwise.
pub fn sum_f32(data: &[f32]) -> f32 {
// Runtime CPU feature detection — safe to call anywhere
if is_x86_feature_detected!("avx2") {
// SAFETY: we just checked that AVX2 is available
unsafe { sum_f32_avx2(data) }
} else {
data.iter().sum()
}
}
/// AVX2 implementation: processes 8 f32s per iteration.
///
/// # Safety
/// Caller must guarantee that AVX2 is available on this CPU.
#[target_feature(enable = "avx2")]
unsafe fn sum_f32_avx2(data: &[f32]) -> f32 {
let mut acc = _mm256_setzero_ps(); // [0.0 × 8]
let chunks = data.len() / 8;
for i in 0..chunks {
// Load 8 f32s from memory (unaligned load is fine on modern CPUs)
let v = _mm256_loadu_ps(data.as_ptr().add(i * 8));
// Add 8 floats in parallel: acc += v
acc = _mm256_add_ps(acc, v);
}
// Horizontal reduction: sum all 8 lanes of 'acc' into one f32
// Step 1: fold upper 4 lanes into lower 4
let low = _mm256_castps256_ps128(acc);
let high = _mm256_extractf128_ps(acc, 1);
let sum4 = _mm_add_ps(low, high);
// Step 2: fold upper 2 lanes into lower 2
let shuf = _mm_movehdup_ps(sum4);
let sum2 = _mm_add_ps(sum4, shuf);
// Step 3: fold upper 1 lane into lower 1
let shuf = _mm_movehl_ps(shuf, sum2);
let sum1 = _mm_add_ss(sum2, shuf);
let result = _mm_cvtss_f32(sum1);
// Handle remainder elements the scalar way
let remainder: f32 = data[chunks * 8..].iter().sum();
result + remainder
}#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "sse2")]
pub unsafe fn find_byte_sse2(haystack: &[u8], needle: u8) -> Option<usize> {
use std::arch::x86_64::*;
// Broadcast 'needle' to all 16 lanes of an SSE2 register
let target = _mm_set1_epi8(needle as i8);
let mut i = 0;
while i + 16 <= haystack.len() {
// Load 16 bytes from haystack
let chunk = _mm_loadu_si128(haystack.as_ptr().add(i) as *const __m128i);
// Compare all 16 bytes simultaneously: result lane = 0xFF if equal, 0x00 if not
let cmp = _mm_cmpeq_epi8(chunk, target);
// Pack comparison results into a 16-bit bitmask (1 bit per byte)
let mask = _mm_movemask_epi8(cmp) as u32;
if mask != 0 {
// trailing_zeros gives the position of the first match within the chunk
return Some(i + mask.trailing_zeros() as usize);
}
i += 16;
}
// Scalar fallback for remaining bytes
haystack[i..].iter().position(|&b| b == needle).map(|pos| i + pos)
}The Naming Convention for std::arch Intrinsics
Intel intrinsic names look cryptic until you learn the pattern. Every name follows: _mm[width]_operation_type
| Part | Meaning | Examples |
|---|---|---|
_mm | 128-bit (SSE) | _mm_add_ps |
_mm256 | 256-bit (AVX/AVX2) | _mm256_mul_ps |
_mm512 | 512-bit (AVX-512) | _mm512_fmadd_ps |
suffix _ps | packed single (f32) | 4/8/16 × f32 |
suffix _pd | packed double (f64) | 2/4/8 × f64 |
suffix _epi8 | packed int (i8) | 16/32/64 × i8 |
suffix _ss | scalar single | operates on lowest lane only |
Part 5: A Complete Example — SIMD Particle Physics
This example combines all three techniques: SoA layout, chunked AoSoA organisation, and AVX2 intrinsics to update 8 particles per iteration with SIMD arithmetic.
#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;
const CHUNK: usize = 8; // AVX2: 256 bits / 32 bits per f32
#[repr(C, align(32))] // 32-byte alignment for AVX2 aligned loads
struct ParticleChunk {
x: [f32; CHUNK],
y: [f32; CHUNK],
vx: [f32; CHUNK],
vy: [f32; CHUNK],
}
struct World {
chunks: Vec<ParticleChunk>,
}
impl World {
pub fn update(&mut self, dt: f32) {
if is_x86_feature_detected!("avx2") {
// SAFETY: we just confirmed AVX2 is available
unsafe { self.update_avx2(dt); }
} else {
self.update_scalar(dt);
}
}
#[target_feature(enable = "avx2")]
unsafe fn update_avx2(&mut self, dt: f32) {
let vdt = _mm256_set1_ps(dt); // broadcast dt to all 8 lanes
for chunk in self.chunks.iter_mut() {
// Load 8 x-positions and 8 x-velocities simultaneously
let px = _mm256_load_ps(chunk.x.as_ptr()); // aligned load
let py = _mm256_load_ps(chunk.y.as_ptr());
let vx = _mm256_load_ps(chunk.vx.as_ptr());
let vy = _mm256_load_ps(chunk.vy.as_ptr());
// x += vx * dt for all 8 particles in ONE fused multiply-add
let new_px = _mm256_fmadd_ps(vx, vdt, px);
let new_py = _mm256_fmadd_ps(vy, vdt, py);
// Store results back
_mm256_store_ps(chunk.x.as_mut_ptr(), new_px);
_mm256_store_ps(chunk.y.as_mut_ptr(), new_py);
}
}
fn update_scalar(&mut self, dt: f32) {
for chunk in self.chunks.iter_mut() {
for i in 0..CHUNK {
chunk.x[i] += chunk.vx[i] * dt;
chunk.y[i] += chunk.vy[i] * dt;
}
}
}
}_mm256_fmadd_ps is a Fused Multiply-Add (FMA) instruction: it computes a * b + c in one clock cycle instead of two, with higher precision than doing them separately. For physics simulations running millions of particles per frame, this alone can halve the compute time.
Optimisation Checklist & Decision Guide
Step-by-Step Process
- Profile first with
cargo flamegraphorperf stat. Never optimise without data. - Check cache miss rates with
perf stat -e cache-misses,cache-references. High miss rates mean layout is the bottleneck. - Order struct fields largest-first to eliminate padding. Check with
size_of::<T>(). - Split hot and cold fields into separate structs if profiling shows cold fields polluting cache.
- Switch to SoA for systems that process many objects but only access a few fields per pass.
- Enable
target-cpu=nativeand check if auto-vectorisation solves the problem before writing intrinsics manually. - Use
std::simd(nightly) for portable SIMD, orstd::arch(stable) with runtime dispatch for maximum control.
Layout Checklist
- ✓Largest fields first in structs
- ✓Hot and cold fields separated
- ✓
Vec<T>over linked list for iteration - ✓Flat enum over boxed trait objects in hot paths
- ✓SoA for multi-field objects processed field-by-field
SIMD Checklist
- ✓Try
-C target-cpu=nativefirst - ✓Use
is_x86_feature_detected!for runtime dispatch - ✓Mark intrinsic functions with
#[target_feature(enable = "avx2")] - ✓Align SIMD buffers to 32 bytes with
#[repr(align(32))] - ✓Always provide a scalar fallback path
Hardware optimization is not premature optimization—it is thinking about the machine when designing your data. Cache-friendly layout means your CPU spends time computing instead of waiting. Data-Oriented Design means your systems process data in the shape the hardware loves. SIMD means one instruction does the work of eight. Start with profiling, apply layout improvements first (they are free), use auto-vectorisation second, and reach for std::arch intrinsics last. The hardware is on your side once you learn to speak its language. Happy hacking!