← the Lab

Proving the layout win: a data-oriented ECS

shipped data-oriented design Β· 16 July 2026

In an earlier note β€” fitting the combat loop in cache β€” I made a claim and then admitted I couldn't prove it on my own game, because the game is too small for the difference to show. The claim was that how you arrange data in memory can matter more than the algorithm that runs over it. This is the follow-up where I stop predicting and measure. I built the real thing, ran it head to head against the naive version, and let the numbers settle the argument. There's a live demo you can drive in the browser, and the source is open.

The question I wanted to answer

Take a workload and hold it completely fixed β€” same entities, same maths, same final result, down to the last bit. Change nothing except where the bytes live in memory. How much speed is hiding purely in the layout? Most engineers know the textbook answer is "a lot," but textbook knowledge is cheap. I wanted a number I had produced myself, under conditions I could defend, on hardware I own.

The trap with benchmarks like this is that it is very easy to cheat without meaning to β€” to let the fast version quietly do less work, skip a branch, or produce a slightly different answer, and then report a speedup that is really just a difference in what was computed. So I set one rule above all others: both versions must produce a checksum-identical final state. If the two runs disagree by even a rounding error, the benchmark is void. Same work, provably, or it doesn't count.

What the two versions are

Both are entity simulators doing ordinary game-style physics β€” thousands of steering agents that avoid, align with, and group toward their neighbours, drifting through a field of hundreds of thousands of passive particles. Every entity is updated every frame. The only thing that differs is storage:

Crucially, the actual arithmetic is shared between them. The steering rules and the integration step are written once and run by both, so there is no room for the layouts to secretly compute different things. The two programs are the same experiment with one variable moved.

How I measured it

Rigour here is mostly about removing excuses. Each configuration runs the same fixed number of frames, after a warm-up pass so the caches and branch predictors have reached steady state and I'm not timing cold-start noise. I take the median of several runs rather than the best or the average, because the median is the most honest summary of what you'd actually feel β€” it ignores a single unlucky hiccup without letting me cherry-pick the fastest outlier. Timing wraps only the simulation, never the drawing, so the GPU can't muddy a measurement that's supposed to be about the CPU and memory. Everything runs on the same machine, one compiler, one optimisation level, so nothing changes between runs except the entity count and the layout.

I also swept the entity count across several orders of magnitude rather than reporting a single figure β€” because a one-number benchmark hides the most interesting part of the story, which is how the gap behaves as the problem grows.

The result

The layout win is real, and it widens as the workload grows. Holding the work identical and moving only the memory layout, the data-oriented version pulls further ahead the more entities you throw at it:

And both runs end in a checksum-identical state, so this is not a difference in what was computed. It is the same computation, paid for at two very different prices.

Why the gap widens instead of staying flat

This is the part I find genuinely satisfying, because it explains why rather than just reporting what. On modern hardware, arithmetic is nearly free and waiting for memory is the real cost. A value already sitting in the nearest cache is available in a handful of cycles; a value that has to be fetched from main memory can cost a couple of hundred. So the interesting question is never "how much maths" but "how often does the loop stall waiting for data."

The object-oriented version stalls constantly, for two compounding reasons. First, its pointers lead to effectively random addresses, and the hardware prefetcher β€” which is superb at pulling the next chunk of memory in before you ask for it, if your accesses look sequential β€” sees noise and switches off. Second, memory arrives a whole cache line at a time, and each fat object drags along fields the hot loop never touches, so most of every line fetched is wasted. The packed version inverts both: its access pattern is perfectly sequential, so the prefetcher streams data in ahead of the loop, and every cache line is dense with values the loop actually wants.

Small workloads hide all of this, because when everything fits in fast cache there is nothing to wait for and the layouts look identical. The gap only appears once the data outgrows cache and the machine is forced to keep reaching out to slower memory β€” which is exactly why the speedup is invisible at a thousand entities and dominant at a million. The curve isn't a quirk of my code; it's the shape of the memory hierarchy showing through.

The demo, and why it's interactive

A table of numbers convinces the person who already believes you. To make the result something you can feel, I compiled the same simulation to run in the browser and put the layout switch on a key. You watch a live frame-time readout, press a key, and the storage underneath the running simulation swaps between the packed arrays and the scattered objects β€” same scene, same maths, and the number moves. Add a hundred thousand more entities and you watch the gap widen in real time, the abstract curve turned into something moving in front of you.

One honesty note I kept in the demo itself: every entity is genuinely simulated every frame, but only a subset of the passive particles is actually drawn, so the GPU stays out of a measurement that is meant to be about memory. I'd rather state that plainly than let a viewer assume the pixel count and the workload are the same thing.

What I actually learned

How I'd push it further

Seen this go wrong at real scale? If you've shipped data-oriented systems and know a pitfall I'm glossing over β€” a measurement I should trust less, a case where the packed layout quietly loses β€” I'd like to hear it. Tell me where I'm wrong β†’
← back to the Lab