If you've been writing Rust for a while, you've probably written something like this:
struct Player {
x: f32,
y: f32,
health: f32,
name: String,
inventory: Vec<Item>,
}
let mut players: Vec<Player> = get_players();
for player in &mut players {
player.x += velocity;
player.y += velocity;
player.health -= damage;
}
This is fine. This is how we're taught. This is Object-Oriented Design.
And it's costing you.
What's Actually Happening in Memory
The problem isn't your code — it's how it's laid out in memory. Your Vec<Player> looks like this in RAM:
[Player 1: x][y][health][name][inventory][padding][Player 2: x][y][health][name][inventory][padding]...
When you iterate over these players, you're jumping around memory. Each Player might be 200 bytes. Your CPU fetches 64-byte cache lines. You're pulling in data you don't need, skipping over data you do, and making your L1 cache beg for mercy.
This is Array of Structures (AoS) — the standard OOP approach. It's intuitive. It's how we think. It's also how we lose 10x performance without noticing.
The Alternative: Structure of Arrays
What if instead of Vec<Player>, you had:
struct Players {
x: Vec<f32>,
y: Vec<f32>,
health: Vec<f32>,
names: Vec<String>,
inventories: Vec<Vec<Item>>,
}
Now when you update positions, you're touching contiguous f32 values. Your CPU loads one cache line and gets four positions. No name data. No inventory data. Just the floats you need.
This is Structure of Arrays (SoA) — the core of Data-Oriented Design (DOD).
When This Matters (And When It Doesn't)
DOD isn't always the answer. It matters when:
- Hot loops: You're processing thousands of items per frame
- Game engines: Bevy uses SoA internally for exactly this reason
- Data pipelines: Batch processing where memory bandwidth is the bottleneck
- Embedded systems: Where cache is tiny and every cycle counts
It doesn't matter when:
- CLI tools: You're processing one file, once
- Web servers: Database and network dominate, not CPU
- Prototypes: Write clear code first, optimize later
The rule: measure first. If your profiler says "memory bound," DOD can help. If it's "compute bound," SIMD or algorithm changes matter more.
Rust Tools for This
A few crates make DOD easier:
bytemuck: Zero-cost wrapper for "plain old data" types. Guarantees your struct has no padding, no Drop, no references. Perfect for SoA storage.
use bytemuck::Zeroable;
#[derive(Zeroable, Clone, Copy)]
struct Position {
x: f32,
y: f32,
}
zerocopy: For deserializing directly into aligned buffers- **
ECS crates** (likehecs` or Bevy's internal ECS): Already do SoA under the hood
The Real Shift
This isn't just about memory layout. It's about thinking differently:
| OOP (AoS) | DOD (SoA) | |-----------|-----------| | Methods on data | Functions on arrays | | Mutate in place | Transform through pipelines | | One struct, many operations | One operation, many structs | | Cache misses are invisible | Cache is the bottleneck |
The hardest part isn't the code. It's remembering that how you organize data matters as much as what the data is.
Start small: next time you have a hot loop over thousands of items, try splitting the fields into separate vectors. You might be surprised what falls out of the profiler.
This is part of an ongoing exploration of performance patterns in Rust. See also: The Quest for Faster Rust Serialization.