Data-Oriented Design in C++
"Transform cache-hostile OOP into cache-friendly data layouts: struct-of-arrays, hot/cold splitting, SoA vs AoS, and practical profiling."
TL;DR
Data-oriented design (DOD) means organizing data the way the CPU wants to see it — not the way the problem domain suggests. The core question: when this data is processed, what else does the CPU need at the same time? Group frequently co-accessed data; separate rarely-used data.
The cache problem
Modern CPUs are fast but memory is slow. An L1 cache miss costs ~100 cycles; a RAM access ~300 cycles. A tight loop doing work becomes a loop waiting for memory.
L1 hit: 4 cycles (~1ns)
L2 hit: 12 cycles (~4ns)
L3 hit: 40 cycles (~12ns)
RAM: 200 cycles (~60ns)When you access object->x, the CPU loads a 64-byte cache line. If the next thing you need is object->sprite_data 200 bytes later, that's a separate cache line — and a miss.
Array of Structs vs Struct of Arrays
Array of Structs (AoS) — the OOP default
struct Entity {
// Hot data (accessed every frame)
float x, y, z;
float vx, vy, vz;
// Cold data (accessed rarely)
std::string name;
uint32_t texture_id;
int score;
float ai_state[16]; // big!
};
std::vector<Entity> entities(10000);
// Movement update — touches ALL of each entity's data
for (auto& e : entities) {
e.x += e.vx * dt; // cache line loaded: x,y,z,vx,vy,vz + name + texture + ai_state
e.y += e.vy * dt; // all the cold data was loaded too — wasted bandwidth
e.z += e.vz * dt;
}With 10k entities, each 200+ bytes, the movement loop loads 2MB of data just to access 48 bytes per entity.
Struct of Arrays (SoA) — DOD layout
struct Entities {
std::vector<float> x, y, z; // hot: position
std::vector<float> vx, vy, vz; // hot: velocity
// cold data in separate arrays — not loaded unless needed
std::vector<std::string> name;
std::vector<uint32_t> texture_id;
std::vector<float[16]> ai_state;
size_t count;
};
Entities entities;
entities.x.resize(10000);
// ...
// Movement update — loads ONLY position and velocity arrays
for (size_t i = 0; i < entities.count; ++i) {
entities.x[i] += entities.vx[i] * dt;
entities.y[i] += entities.vy[i] * dt;
entities.z[i] += entities.vz[i] * dt;
}Now the movement loop works on 6 tightly-packed float arrays — roughly 240KB total, fits in L2/L3, no wasted cache line loading.
Hot/Cold splitting
When an entity has both frequently-accessed and rarely-accessed data, split it:
// Frequently accessed every frame
struct EntityHot {
float x, y, z;
float vx, vy, vz;
uint8_t flags;
};
// Accessed only on events (collision, AI update, etc.)
struct EntityCold {
std::string name;
float ai_state[32];
int score;
uint32_t texture_id;
// ...
};
std::vector<EntityHot> hot(10000); // 28 bytes each — tight
std::vector<EntityCold> cold(10000); // 256 bytes each — only loaded when neededThe hot loop only touches the hot array. Cold data is accessed by index when needed.
Practical example: particle system
// AoS — naive
struct Particle {
float x, y, z;
float vx, vy, vz;
float lifetime;
float size;
uint32_t color; // RGBA
bool alive;
};
std::vector<Particle> particles(100000);
// SoA — cache-friendly
struct ParticleSystem {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
std::vector<float> lifetime;
std::vector<float> size;
std::vector<uint32_t> color;
std::vector<bool> alive; // or use a bitset
size_t count;
void update(float dt) {
// Tight loop — all accessed arrays are adjacent in memory
for (size_t i = 0; i < count; ++i) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
z[i] += vz[i] * dt;
lifetime[i] -= dt;
}
}
void removeDeadParticles() {
// Swap-erase dead particles to avoid gaps
for (size_t i = 0; i < count; ) {
if (lifetime[i] <= 0.f) {
--count;
x[i] = x[count];
y[i] = y[count];
// swap all arrays...
} else {
++i;
}
}
}
};SIMD-friendly layouts
SoA data is naturally SIMD-friendly — you can process 4 or 8 floats at once:
#include <immintrin.h> // AVX2
void updatePositionsSIMD(
float* __restrict__ x, float* __restrict__ vx,
float dt, size_t count)
{
__m256 vdt = _mm256_set1_ps(dt);
size_t i = 0;
for (; i + 8 <= count; i += 8) {
__m256 px = _mm256_loadu_ps(x + i);
__m256 pv = _mm256_loadu_ps(vx + i);
_mm256_storeu_ps(x + i, _mm256_fmadd_ps(pv, vdt, px));
}
// Handle remainder
for (; i < count; ++i) {
x[i] += vx[i] * dt;
}
}The compiler can auto-vectorize SoA loops — AoS loops with interleaved fields rarely auto-vectorize.
Profiling data locality
Identify cache issues before redesigning:
# perf stat — cache miss rate
perf stat -e cache-misses,cache-references ./game
# perf record — find hot cache-miss functions
perf record -e cache-misses:pp ./game
perf report
# Valgrind cachegrind — instruction-level cache simulation
valgrind --tool=cachegrind --cache-sim=yes ./game
cg_annotate cachegrind.out.<pid>Look for functions with high D1mr (L1 data cache misses) relative to instructions executed.
When AoS is fine
DOD isn't always the answer:
- Small counts (< 100 entities): AoS is simpler and fast enough.
- Data always accessed together:
AoS{x, y, z}when x,y,z always update together may be better. - Frequent random access by ID: AoS maintains spatial locality per entity.
The rule: measure first, redesign second. Cache misses that show up in profiling are the ones worth fixing.
Summary
| AoS | SoA | |
|---|---|---|
| Code style | OOP-natural | Needs discipline |
| Cache (partial access) | Poor | Excellent |
| SIMD-friendly | Rarely | Always |
| Random access by ID | Good | Poor |
| Best for | Mixed workloads | Bulk, uniform processing |