23. AoS vs SoA, measured
Struct of arrays is not simply faster. Measured across seven kinds of loop, it wins big, loses badly, and sometimes barely matters. Knowing which is which is the whole job of Viscosity.
- Compare AoS, packed AoS, SoA and AoSoA layouts in memory, and predict which loops each one helps.
- Read this book’s benchmark results and explain the wins, the losses and the small-size caveat.
- Explain materialization and why it can make SoA much slower.
- Split hot fields from cold ones.
Same data, four ways
For three chapters you’ve heard that layout matters. Now we measure it.
This book’s repository has a benchmark harness that stores the same
particles (x, y, vx, vy, mass, health) in several ways and
runs the same loops over each:
// aos: an array of objects
ps[i].x
// aos-packed: one Float64Array, 6 per item
f[i * 6 + 0]
// soa: one Float64Array per field
x[i]
// aosoa8: blocks of 8, SoA inside a block
f[blk * 48 + 0 * 8 + lane]
All four hold identical numbers. Only where those numbers sit in memory changes.
Think of a filing cabinet of student records. AoS files one folder per student, with every detail inside. SoA keeps one long list per detail: all names on one list, all grades on another. To average the grades, the lists win: you read one list top to bottom. To print one student’s full record, the folders win: everything is in one place.
What each loop actually loads
The loop we care about most is update-xy: x += vx * dt and
y += vy * dt for every particle. It needs four of the six fields. Step
through what each layout drags into the cache to get them:
AoS vs SoA is the trade-off between keeping each record’s fields together (array of structs) and keeping each field’s values together (struct of arrays). AoS suits code that uses whole records; SoA suits code that uses a few fields of many records. AoSoA (array of structs of arrays) is the hybrid: fixed-size blocks of records, with SoA inside each block.
The wins
Here is update-xy with 100,000 particles, from this book’s committed
benchmark run:
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 6.45 | 7.65 |
| packed Float64Array | 3.04 | 3.74 |
| struct of arrays | 1.72 | 2.86 |
| AoSoA, blocks of 8 | 3.15 | 3.42 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
Reading a single field shows the effect even more strongly, because SoA reads exactly one column and nothing else:
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 5.60 | 6.11 |
| packed Float64Array | 1.83 | 1.93 |
| struct of arrays | 0.66 | 0.81 |
| AoSoA, blocks of 8 | 1.79 | 1.89 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
Notice that packed AoS already captures a big part of the win. It keeps AoS order but drops the object headers, the pointers and the boxed numbers from chapter 22. So part of SoA’s advantage is simply typed storage, and part is locality: SoA goes further only because it also stops loading the unused fields.
Random order is where object arrays suffer most. The random-x suite reads
x and vx at shuffled indexes, so the prefetcher can’t help, and
every object is a pointer to chase:
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 57.48 | 69.04 |
| packed Float64Array | 7.83 | 8.58 |
| struct of arrays | 7.69 | 8.56 |
| AoSoA, blocks of 8 | 32.19 | 45.41 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
The loss: materialization
Sometimes the code really does want an object. Maybe it pushes particles into a list, or hands them to a library. With SoA there is no object, so it has to be built:
function get(s, i) {
return {
x: s.x[i], y: s.y[i],
vx: s.vx[i], vy: s.vy[i],
};
}
const s = {
x: new Float64Array([1, 2]),
y: new Float64Array([3, 4]),
};
function get(s, i) {
return { x: s.x[i], y: s.y[i] };
}
const a = get(s, 0);
const b = get(s, 0);
console.log(a, b);
console.log(a === b);
a.x = 99;
console.log(s.x[0]);Every call allocates a fresh object, so get(s, 0) === get(s, 0) is
false. That’s the identity problem from chapter 4: an optimizer must
never let a program notice. And it costs time. The materialize suite
builds an array holding one real object per element:
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 7.75 | 8.95 |
| struct of arrays | 157.96 | 309.43 |
| AoSoA, blocks of 8 | 41.52 | 169.51 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
Materialization is rebuilding a whole object from its columns. It costs an allocation per object, and the new object has a new identity. Materializing objects that are kept can wipe out every gain SoA made.
There’s a twist. The get-objects suite hands each element to a small
function that wants an object, but doesn’t keep it. There, in this book’s
run, SoA did not lose: V8 inlined the function and removed the
temporary object altogether. Whether materializing costs anything depends
on whether the object escapes, which is exactly the question
Viscosity’s escape analysis must answer. A TypeScript experiment by Zap
found the same shape: SoA wins on reading a few fields and loses when
whole objects must be rebuilt[3].
Small collections and AoSoA
With only 1,000 particles, everything fits in cache, and the picture shrinks:
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 2.87 | 3.60 |
| packed Float64Array | 3.02 | 3.15 |
| struct of arrays | 1.73 | 1.94 |
| AoSoA, blocks of 8 | 3.39 | 3.85 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
AoSoA is a compromise with its own trade-offs. Inside a block, each field
is contiguous: 8 doubles is exactly one cache line, and a natural fit for
SIMD lanes. ISPC builds this into the language as soa<8>, keeping
ordinary pts[i].x syntax while storing blocks[5][4],
and its performance guide recommends it when vector loads
matter[6]. But every random access must work out
a block and a lane, a division and a remainder. In plain JavaScript,
without SIMD, that arithmetic is pure cost.
Hot/cold splitting
You don’t have to move every field. If a loop touches x, y, vx and
vy every frame but name and health almost never, move just the
busy fields into columns:
const hot = {
x: new Float64Array(n),
y: new Float64Array(n),
vx: new Float64Array(n),
vy: new Float64Array(n),
};
const cold = [{ name: 'a', health: 100 }];
A hot/cold split separates fields that are used often (hot) from fields that are used rarely (cold), so hot loops only stream through hot data.
Measure it yourself
These numbers came from one cloud machine. Your phone or laptop has different caches and a different engine version. Run it:
Each layout runs in its own fresh worker: warm-up, then the median of 25 samples.
Choosing a layout means weighing everything above: how many elements, which fields each loop touches, whether access is sequential, and whether objects escape. Mike Acton’s 2014 talk on data-oriented design puts it bluntly: “If you have different data, you have a different problem”[8]. Research on JavaScript arrays makes this decision at run time: a JIT watches an array of objects and switches it to columnar storage automatically[1], then generates specialized code paths for the transformed arrays[2]. Viscosity makes the same decision ahead of time, as a compiler pass, with a cost model fed by exactly these measurements.
Your turn
Write toSoa(ps, fields) that takes an array of objects and a list of field names, and returns an object with one **Float64Array** column per listed field. Fields that aren’t listed are left out (they stay cold).
function toSoa(ps, fields) {
const s = {};
// your code here
return s;
}
const ps = [{ x: 1, y: 2 }];
console.log(toSoa(ps, ['x', 'y']));Particles have 6 fields and are stored in AoSoA blocks of 8: block b holds 8 values of field 0, then 8 of field 1, and so on (48 slots per block). Write aosoaAt(i, f) that returns the slot index of field f of particle i.
function aosoaAt(i, f) {
// your code here
}
console.log(aosoaAt(10, 2));What you learned
- AoS vs SoA is a trade-off. SoA won in this book’s run when a loop touched a few fields of many elements, and in shuffled access.
- Packed AoS captures part of the win, by dropping headers, pointers and boxed numbers; SoA adds locality on top.
- Materialization (rebuilding objects from columns) is SoA’s weakness: it allocates, and it creates new identities. If the object doesn’t escape, the engine may remove it.
- Small collections barely benefit: the data already fits in cache.
- AoSoA stores blocks of SoA: cache- and SIMD-friendly inside a block, but random access pays for index arithmetic.
- A hot/cold split moves only the busy fields into columns.
Next: how to trust any of these numbers. Benchmarking honestly.
Check your understanding
0/6- 1.
A loop reads only
massfrom every particle. Which layout should load the fewest unused bytes? - 2.
In this book’s run, packed AoS was much faster than an array of objects even though both keep AoS order. Why?
- 3.
Why was SoA 20–35 times slower than AoS in the
materializesuite? - 4.
get(s, i)builds{ x: s.x[i], y: s.y[i] }. Thenconst a = get(s, 0); a.x = 99;. What iss.x[0]? - 5.
Why does AoSoA do badly on random access in plain JavaScript?
- 6.
A game updates
x, y, vx, vyevery frame but readsnameonly on a menu screen. What is the sensible layout?
References
- [1] Lukas Makor, Sebastian Kloibhofer, David Leopoldseder, Daniele Bonetta, Lukas Stadler, Hanspeter Mössenböck (2022). Automatic Array Transformation to Columnar Storage at Run Time. Proceedings of the 19th International Conference on Managed Programming Languages and Runtimes (MPLR ’22), ACM, pp. 16–28. doi:10.1145/3546918.3546919 (checked 2026-10-31)
- [2] Sebastian Kloibhofer, Lukas Makor, David Leopoldseder, Daniele Bonetta, Lukas Stadler, Hanspeter Mössenböck (2023). Control Flow Duplication for Columnar Arrays in a Dynamic Compiler. The Art, Science, and Engineering of Programming, vol. 7, issue 3, article 9. doi:10.22152/programming-journal.org/2023/7/9 (checked 2026-10-31)
- [3] Bui Huy Giap (“Zap”, ziap) (2025). Struct of Arrays (SoA) in TypeScript with metaprogramming. Zap’s website (blog post, 17 September 2025). ziap.github.io/blog/parallel-array/ (checked 2026-10-31)
- [4] Matt Pharr, William R. Mark (2012). ispc: A SPMD Compiler for High-Performance CPU Programming. Proceedings of Innovative Parallel Computing (InPar), San Jose, CA. llvm.org/pubs/2012-05-13-InPar-ispc.html (checked 2026-10-31)
- [5] The Intel ISPC project. Intel ISPC User’s Guide: Structure of Array Types. ispc.github.io documentation. ispc.github.io/ispc.html (checked 2026-10-31)
- [6] The Intel ISPC project. Intel ISPC Performance Guide: Use “Structure of Arrays” Layout When Possible. ispc.github.io documentation. ispc.github.io/perfguide.html (checked 2026-10-31)
- [7] MDN contributors. JavaScript typed arrays. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Typed_arrays (checked 2026-10-31)
- [8] Mike Acton (2014). Data-Oriented Design and C++. CppCon 2014 (talk); slides in the conference’s speaker-materials repository, github.com/CppCon/CppCon2014. github.com/CppCon/CppCon2014/tree/master/Presentations/Data-Oriented%20Design%20and%20C%2B%2B (checked 2026-11-01)