Skip to content
C++
Domain Deep-Dive
Advanced

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.

cpp
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

cpp
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

cpp
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:

cpp
// 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 needed

The hot loop only touches the hot array. Cold data is accessed by index when needed.

Practical example: particle system

cpp
// 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:

cpp
#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:

bash
# 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

AoSSoA
Code styleOOP-naturalNeeds discipline
Cache (partial access)PoorExcellent
SIMD-friendlyRarelyAlways
Random access by IDGoodPoor
Best forMixed workloadsBulk, uniform processing
Edit on GitHubUpdated 2026-05-01T00:00:00.000Z