Viscosity
Review
Part VII · Building Viscosity · Day 27

107. AoSoA code generation

One annotation turns a collection into blocks of 8. Here is the index arithmetic Viscosity writes for it, why the block size must be a power of two, and why, measured in V8, plain SoA usually won anyway.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Choose AoSoA with an annotation, and say when Viscosity refuses it.
  • Compute the slot of any field of any element from the block size, using shifts and masks.
  • Read the AoSoA container and rewritten accesses that Viscosity generates, including the helper for complicated indexes.
  • Explain, from this book’s measurements, why AoSoA rarely beat SoA in V8.

One comment, a different layout

Viscosity never picks AoSoA on its own. You ask for it, with chapter 102’s annotation on the line before the array. This is the corpus program aosoa.js:

// @viscosity aosoa(8)
const ps = [];
for (let i = 0; i < 1003; i++)
  ps.push({ x: i, y: -i, z: i / 2 });
for (let r = 0; r < 2; r++) {
  for (let i = 0; i < ps.length; i++) {
    const p = ps[i];
    p.x += p.z;
    p.y -= 1;
  }
}

(Long lines are wrapped here, and the final printing loop is left out.) viscosity explain agrees (the two loop lines and the bounds line are left out):

ps (line 3) {x, y, z} → AoSoA, blocks of 8
  · annotated @viscosity aosoa(8) at line 2
  · typed Float64Array columns for all 3
    fields
  · about 1003 elements
Intuition

An egg carton holds 12 eggs in a fixed grid. A crate of cartons is easy to count: carton number 3, row 2, cup 4. AoSoA stores elements the same way: fixed-size blocks, and inside each block a small column per field.

Blocks and lanes

Every field of every element goes into one Float64Array called data. Here is how the compiler finds a slot in it:

Step throughFinding a field in AoSoA
// fields x, y, vx (F = 3), B = 8
// slot of field k of element i:
(i >> 3) * 24 // block start
+ k * 8 // field in block
+ (i & 7) // lane
Two AoSoA blocks of 24 slots each, with the slot for field y of element 10 highlightedblock 0: elements 0–7, slots 0–23xyvxblock 1: elements 8–15, slots 24–47xyvxelement i lives in block i >> 3
One Float64Array, cut into blocks of 8 elements. Inside a block it is SoA: 8 x values, then 8 y, then 8 vx. Elements 0 to 7 are in block 0, 8 to 15 in block 1.
1/7
Definition

The block size B is how many elements one AoSoA block holds. With F fields, a block is F × B slots: B values of field 0, then B of field 1, and so on. Field k of element i is at (i >> s) * F*B + k*B + (i & (B-1)), where s = log2(B). Viscosity requires B to be a power of two, so that the block number and the lane are a shift and a mask instead of a division and a remainder.

i >> 3 and i & 7 are chapter 16’s bitwise operators. They work on 32-bit integers[4], which is plenty for an array index. For a power of two, dropping the low bits is dividing and rounding down, and keeping them is the remainder.

The generated container

Here is what Viscosity writes for aosoa.js, reformatted to fit the screen, with the static of method left out:

class __Vsc_ps {
  constructor(cap = 16) {
    this.length = 0;
    this.cap = Math.ceil(cap / 8) * 8;
    this.data =
      new Float64Array(this.cap * 3);
  }
  push(v0, v1, v2) {
    const n = this.length;
    if (n === this.cap) this.grow();
    const b = (n >> 3) * 24 + (n & 7);
    this.data[b + 0] = v0;
    this.data[b + 8] = v1;
    this.data[b + 16] = v2;
    return (this.length = n + 1);
  }
  grow() {
    const d = new Float64Array(
      this.cap * 2 * 3);
    d.set(this.data);
    this.data = d;
    this.cap *= 2;
  }
}

Three things to notice. The capacity is rounded up to whole blocks, and doubling keeps it that way. push works out the slot of field 0 once, then adds k * 8 for the others. And grow() is simpler than SoA’s: blocks never move inside the array, so a bigger array is just the old blocks followed by new empty ones, and one set copies everything.

The rewritten accesses

The inner loop becomes this (each statement is one line in the real output; it is wrapped here):

for (let i = 0; i < ps.length; i++) {
  const p = i;
  ps.data[((p) >> 3) * 24 + 0
    + ((p) & 7)] +=
      ps.data[((p) >> 3) * 24 + 16
        + ((p) & 7)];
  ps.data[((p) >> 3) * 24 + 8
    + ((p) & 7)] -= 1;
}

const p = ps[i] became const p = i, as in chapter 104, and each field access became the formula with F*B = 24 and k*B filled in. The formula mentions the index twice. That is fine for a name like p, but the last line of aosoa.js reads ps[1002].z, which isn’t proven in bounds, so its index is wrapped in the bounds check. Evaluating that twice would be wasteful, so the emitter calls a helper that takes the index once:

ps.data[__vsc_aosoa(
  __vsc_bc(ps, 1002, 'z', false),
  3, 24, 16, 7)]

function __vsc_aosoa(i, sh, stride,
    off, mask) {
  return (i >> sh) * stride + off
    + (i & mask);
}

Both versions of aosoa.js print 1505503 501 14 -10.

Only numbers

Because everything sits in one Float64Array, every field must be a proven number. Add a string field and Viscosity refuses:

ps (line 2) {x, name} → kept as array of
  objects
  · annotated aosoa, but fields name may
    hold non-numbers

A block size that isn’t a power of two is refused the same way. Put aosoa(6) on the playground program below, and the planner keeps the objects, so the compiled code is the source unchanged:

ps (line 2) {x, y, vx} → kept as array
  of objects
  · annotated aosoa(6), but the block
    size must be a power of two (2, 4,
    8, 16, ...)

The emitter still checks log2(B) and throws if it is not a whole number, but the planner never hands it such a block size now. Try the annotation yourself. Change 8 to 4, delete the annotation (Viscosity picks plain SoA), or add id: 'a' to the record:

PlaygroundBlocks of 8

Is it worth it?

ISPC builds this layout into the language: soa<8> Point pts[...] stores 8 x values, then 8 y, and pts[10].x still works: the compiler reads the third x of the second block, exactly our formula[1][2]. There, a block of 8 lines up with the SIMD lanes the program runs on, and ISPC’s guide recommends these layouts because contiguous data allows vector loads and stores[3]. Plain JavaScript gets no such lanes, and the index arithmetic remains.

Moving 100,000 particles
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.5struct 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
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.

AoSoA beats objects here, but not plain SoA.
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.9struct 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
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.

At small sizes AoSoA was slower even than the array of objects.
Reading x and vx in shuffled order
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 30.31 ns, p95 43.24 ns per elementarray of objects30.3struct of arrays: median 6.09 ns, p95 7.25 ns per elementstruct of arrays6.1 5.0× fasterAoSoA, blocks of 8: median 24.66 ns, p95 38.21 ns per elementAoSoA, blocks of 824.7 1.2× faster
Table view
layoutmedian nsp95 ns
array of objects30.3143.24
struct of arrays6.097.25
AoSoA, blocks of 824.6638.21

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.

Random access pays for the block and lane on every read.

This book’s findings put it plainly: AoSoA with blocks of 8 was rarely better than SoA in V8, and at 1,000 elements it was often slower than plain objects. That is why the cost model never chooses it, and why it exists only behind an annotation.

Under the hood

AoSoA earns its keep where the block matches the hardware: B values of a field fill a SIMD register in one load, while one element’s fields stay close together for code that needs a whole record. JavaScript has no SIMD API (chapter 21), so in Viscosity’s JavaScript output only the cost is certain: a shift, a mask and a multiply on every access. Keeping AoSoA as an opt-in strategy is the honest compromise: it’s there for experiments, and for a future back end that can use the lanes.

Your turn

ExerciseAn AoSoA index function

Write slot(i, k, F, B): the index in the shared Float64Array of field k of element i, for F fields in blocks of B. Use a shift and a mask, like Viscosity. If B is not a power of two, throw an error.

function slot(i, k, F, B) {
  // your code here
  return 0;
}

console.log(slot(10, 1, 3, 8));
Extra practiceLoop over blocks, not elements

The generated code works out a block and lane for every access. A hand-written loop can avoid that. Write blockSum(data, n, k, F, B): the sum of field k over the first n elements, looping over blocks and then lanes. Careful: the last block may be only partly used, and its spare slots may hold anything.

function blockSum(data, n, k, F, B) {
  // your code here
  return 0;
}

What you learned

  • // @viscosity aosoa(8) asks for AoSoA. Every field must be a proven number, and the block size must be a power of two.
  • The block size B sets the layout: field k of element i is at (i >> s) * F*B + k*B + (i & (B-1)).
  • The container keeps one Float64Array, with capacity in whole blocks; growing is a single copy.
  • Complicated indexes go through __vsc_aosoa, so they are evaluated once.
  • In V8, AoSoA rarely beat SoA and lost at small sizes, so the cost model never picks it.

Check your understanding

0/6
  1. 1.

    Three fields, blocks of 8. Where does block 2 start?

  2. 2.

    What does Viscosity do with // @viscosity aosoa(6)?

  3. 3.

    Why does ps[1002].z compile to a call to __vsc_aosoa(...) instead of the inline formula?

  4. 4.

    The AoSoA container grows from 16 to 32 elements. What does grow() do?

  5. 5.

    You annotate a collection whose records have a name string with aosoa(8). What happens?

  6. 6.

    What did this book’s V8 measurements show about AoSoA with blocks of 8?

References

  1. [1] 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)
  2. [2] 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)
  3. [3] 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)
  4. [4] MDN contributors. Bitwise AND (&). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND (checked 2026-10-31)