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.
- 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
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:
// 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
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:
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.
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 6.45 | 7.65 |
| 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.
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 2.87 | 3.60 |
| 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.
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 30.31 | 43.24 |
| struct of arrays | 6.09 | 7.25 |
| AoSoA, blocks of 8 | 24.66 | 38.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.
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.
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
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));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.
Three fields, blocks of 8. Where does block 2 start?
- 2.
What does Viscosity do with
// @viscosity aosoa(6)? - 3.
Why does
ps[1002].zcompile to a call to__vsc_aosoa(...)instead of the inline formula? - 4.
The AoSoA container grows from 16 to 32 elements. What does
grow()do? - 5.
You annotate a collection whose records have a
namestring withaosoa(8). What happens? - 6.
What did this book’s V8 measurements show about AoSoA with blocks of 8?
References
- [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] 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] 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] MDN contributors. Bitwise AND (&). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND (checked 2026-10-31)