Viscosity
Review
Part II · How machines run code · Day 6

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.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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:

Step throughFour layouts, one loop
Array of objects
Array of objects. Bright cells are the fields x, y, vx, vy that the update loop uses; faded cells are loaded but unused.array of pointers → objects on the heap[0][1][2]grey = object header; bright = fields this loop uses
AoS with real objects: the array holds pointers, and each object sits somewhere on the heap with a 3-word header before its six fields. The loop hops from pointer to object, and the header, mass and health share its cache lines.
1/5
Definition

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:

Moving particles: x += vx, y += vy
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 6.45 ns, p95 7.65 ns per elementarray of objects6.5packed Float64Array: median 3.04 ns, p95 3.74 ns per elementpacked Float64Array3.0 2.1× fasterstruct of arrays: median 1.72 ns, p95 2.86 ns per elementstruct of arrays1.7 3.8× fasterAoSoA, blocks of 8: median 3.15 ns, p95 3.42 ns per elementAoSoA, blocks of 83.1 2.1× faster
Table view
layoutmedian nsp95 ns
array of objects6.457.65
packed Float64Array3.043.74
struct of arrays1.722.86
AoSoA, blocks of 83.153.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.

SoA is fastest; packed AoS and AoSoA land in between.

Reading a single field shows the effect even more strongly, because SoA reads exactly one column and nothing else:

Summing one field (mass)
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 5.60 ns, p95 6.11 ns per elementarray of objects5.6packed Float64Array: median 1.83 ns, p95 1.93 ns per elementpacked Float64Array1.8 3.1× fasterstruct of arrays: median 0.66 ns, p95 0.81 ns per elementstruct of arrays0.7 8.5× fasterAoSoA, blocks of 8: median 1.79 ns, p95 1.89 ns per elementAoSoA, blocks of 81.8 3.1× faster
Table view
layoutmedian nsp95 ns
array of objects5.606.11
packed Float64Array1.831.93
struct of arrays0.660.81
AoSoA, blocks of 81.791.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.

One field out of six: SoA loads only what it uses.

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:

Reading x and vx in shuffled order
Median ns per element, n = 1,000,000 (shorter is faster)
array of objects: median 57.48 ns, p95 69.04 ns per elementarray of objects57.5packed Float64Array: median 7.83 ns, p95 8.58 ns per elementpacked Float64Array7.8 7.3× fasterstruct of arrays: median 7.69 ns, p95 8.56 ns per elementstruct of arrays7.7 7.5× fasterAoSoA, blocks of 8: median 32.19 ns, p95 45.41 ns per elementAoSoA, blocks of 832.2 1.8× faster
Table view
layoutmedian nsp95 ns
array of objects57.4869.04
packed Float64Array7.838.58
struct of arrays7.698.56
AoSoA, blocks of 832.1945.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.

Without a predictable order, the pointer hop to each object dominates. AoSoA pays for its index arithmetic here.

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],
  };
}
Try itMaterializing from columns
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:

Building one real object per element
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 7.75 ns, p95 8.95 ns per elementarray of objects7.8struct of arrays: median 157.96 ns, p95 309.43 ns per elementstruct of arrays158.0 20.4× slowerAoSoA, blocks of 8: median 41.52 ns, p95 169.51 ns per elementAoSoA, blocks of 841.5 5.4× slower
Table view
layoutmedian nsp95 ns
array of objects7.758.95
struct of arrays157.96309.43
AoSoA, blocks of 841.52169.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.

AoS just copies references it already has. SoA must allocate every object.
Definition

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:

Moving 1,000 particles
Median ns per element, n = 1,000 (shorter is faster)
array of objects: median 2.87 ns, p95 3.60 ns per elementarray of objects2.9packed Float64Array: median 3.02 ns, p95 3.15 ns per elementpacked Float64Array3.0 1.1× slowerstruct of arrays: median 1.73 ns, p95 1.94 ns per elementstruct of arrays1.7 1.7× fasterAoSoA, blocks of 8: median 3.39 ns, p95 3.85 ns per elementAoSoA, blocks of 83.4 1.2× slower
Table view
layoutmedian nsp95 ns
array of objects2.873.60
packed Float64Array3.023.15
struct of arrays1.731.94
AoSoA, blocks of 83.393.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.

Small data already fits in cache: the gains shrink, and AoSoA’s index arithmetic costs more than it saves.

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 }];
Definition

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:

Measure it yourselfAoS vs SoA on this device
array of objects
struct of arrays

Each layout runs in its own fresh worker: warm-up, then the median of 25 samples.

Under the hood

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

ExerciseConvert to struct of arrays

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']));
Extra practiceFind a field in AoSoA

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. 1.

    A loop reads only mass from every particle. Which layout should load the fewest unused bytes?

  2. 2.

    In this book’s run, packed AoS was much faster than an array of objects even though both keep AoS order. Why?

  3. 3.

    Why was SoA 20–35 times slower than AoS in the materialize suite?

  4. 4.

    get(s, i) builds { x: s.x[i], y: s.y[i] }. Then const a = get(s, 0); a.x = 99;. What is s.x[0]?

  5. 5.

    Why does AoSoA do badly on random access in plain JavaScript?

  6. 6.

    A game updates x, y, vx, vy every frame but reads name only on a menu screen. What is the sensible layout?

References

  1. [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. [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. [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. [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. [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. [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. [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. [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)