Skip to content
rusthacks.dev
Systems

Data Oriented Design in Rust and Why It Speeds Up Code

Arrange data for how it is read, not how it is modeled. Compare array of structs with struct of arrays, see why hot loops get faster with contiguous fields, and when the change is not worth it.

4 min read
Summarize in AI
Flat illustration for the post “Data Oriented Design in Rust and Why It Speeds Up Code”, composed around embedded.

Object-oriented habit says: model a particle as a struct with position, velocity, color, id and a name, keep a Vec<Particle> and loop over it. That is a fine model and, for hot loops over many items, a slow layout. Data-oriented design flips the question from "what is this thing" to "what does this loop read". This post compares layouts with working code; timings are not quoted because they depend on your CPU and compiler. It compiles on Rust 1.90 with edition 2024.

Why layout matters

Reading memory from RAM costs far more than arithmetic. The CPU hides the cost by fetching a 64-byte cache line at a time and by prefetching when access is sequential. A loop that uses only 8 of every 100 bytes it loads spends most of its time waiting for memory, and a layout that puts the used bytes together turns the same loop into a streaming read.

Array of structs

rust
#[derive(Clone)]
struct Particle {
    x: f32,
    y: f32,
    vx: f32,
    vy: f32,
    color: [u8; 4],
    name: String,   // 24 bytes of pointer, capacity, length, rarely used in the loop
    id: u64,
}

fn step_aos(ps: &mut [Particle], dt: f32) {
    for p in ps {
        p.x += p.vx * dt;
        p.y += p.vy * dt;
    }
}

Each Particle is about 64 bytes or more. The loop reads four of those fields, 16 bytes, and skips the rest, but the cache line brought in includes the String header and id. Every particle costs about a cache line of memory traffic for 16 useful bytes.

Struct of arrays

rust
struct Particles {
    x: Vec<f32>,
    y: Vec<f32>,
    vx: Vec<f32>,
    vy: Vec<f32>,
    // cold data kept separately, only touched when needed
    color: Vec<[u8; 4]>,
    name: Vec<String>,
    id: Vec<u64>,
}

fn step_soa(p: &mut Particles, dt: f32) {
    for (x, vx) in p.x.iter_mut().zip(&p.vx) {
        *x += vx * dt;
    }
    for (y, vy) in p.y.iter_mut().zip(&p.vy) {
        *y += vy * dt;
    }
}

Now each loop streams two dense arrays of f32, so every byte of every cache line is used. The compiler can also vectorize these simple loops with SIMD instructions (auto vectorization), which the array-of-structs version often prevents because of the interleaved fields. The cost is ergonomic: adding or removing a particle updates seven vectors, and borrowing "one particle" is no longer a single reference.

Hot and cold splitting

rust
struct Hot {
    x: f32,
    y: f32,
    vx: f32,
    vy: f32,
}

struct Cold {
    name: String,
    id: u64,
    color: [u8; 4],
}

struct World {
    hot: Vec<Hot>,
    cold: Vec<Cold>, // same index as hot
}

A lighter step than full SoA: keep the fields the loop touches in one small struct, and move everything else into a parallel vector addressed by the same index. You keep a single reference to the hot data per particle, and the hot vector is a quarter of the size.

Indices instead of pointers

rust
#[derive(Clone, Copy, PartialEq, Eq)]
struct NodeId(u32);

struct Node {
    parent: Option<NodeId>,
    value: f32,
}

struct Tree {
    nodes: Vec<Node>,
}

impl Tree {
    fn depth(&self, mut id: NodeId) -> u32 {
        let mut d = 0;
        while let Some(p) = self.nodes[id.0 as usize].parent {
            id = p;
            d += 1;
        }
        d
    }
}

A tree or graph stored as a Vec of nodes with u32 indices is compact, contiguous, trivially cloned and has no lifetimes or reference counts. The same pattern solves cyclic structures that fight the borrow checker, which is why arenas and index-based graphs are idiomatic in Rust. Take care that a stale index may refer to a different node after removal; generational indices fix that.

When not to bother

  • The collection is small (hundreds of items) and fits in cache anyway.
  • The loop touches most fields of each item, so a struct layout is already efficient.
  • The time is elsewhere: I/O, allocation, locking. Profile first (profiling with flamegraphs).
  • The code is changing quickly; layout tuning hardens a design.

Measure

Benchmark both layouts on realistic sizes with Criterion or Divan, checking for 10,000 and 1,000,000 elements, since the effect depends on whether the data fits in cache (benchmarking). Also check std::mem::size_of::<T>() to see what you are loading; field reordering by rustc can shrink a struct without you doing anything. For the cache behavior in a broader context, see cache-friendly code and the ECS view in Bevy systems and queries.

Taggeddod
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.