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

21. SIMD: one instruction, many numbers

A CPU can add four numbers in the time it takes to add one, but only if the four numbers are sitting side by side. That one condition explains why struct of arrays exists.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Explain what SIMD is, and what a lane is.
  • Describe what a compiler does when it vectorizes a loop, and why some loops can’t be vectorized safely.
  • Explain why SIMD wants a struct-of-arrays layout, not an array of structs.
  • Say accurately where SIMD is available to JavaScript today (and where it isn’t).

The same sum, a million times

Here is the loop at the heart of every particle game:

for (let i = 0; i < n; i++) {
  x[i] = x[i] + vx[i];
}

Every iteration does exactly the same thing: load two numbers, add them, store one. Only the index changes. The CPU decodes the same instructions over and over, a million times, to do a million tiny additions that don’t depend on each other at all.

Surely a machine could just do several at once?

Intuition

Think of a rubber stamp. Stamping a hundred envelopes one by one takes a hundred presses. Now glue four stamps side by side on one handle. Line up four envelopes and one press marks all four. The trick only works if the envelopes are lined up: if they’re scattered over the desk, you’re back to one press each.

One instruction, four answers

That glued stamp exists in real CPUs. Besides the ordinary registers that hold one number, many CPUs have wide registers that hold several numbers at once, and instructions that work on all of them together. Step through an addition done both ways:

Step throughOne add, or four at once
Arrays a, b and c in memory, and in SIMD mode three wide registers with four lanes eacha1234b10203040c11223344[0][1][2][3]one number in, one number out, per instructionscalar: 0 adds
Two arrays of four numbers. We want c[i] = a[i] + b[i] for every i. First, the ordinary way: one number at a time.
1/8
Definition

SIMD stands for single instruction, multiple data: one instruction applies the same operation to several values at once. Those values sit in a wide vector register, and each slot of that register is a lane. A SIMD add adds lane 0 to lane 0, lane 1 to lane 1, and so on. Lanes never mix.

How many lanes you get depends on the register’s width and the size of each number. A 128-bit register has room for four 32-bit floats, or two 64-bit doubles. JavaScript numbers are 64-bit doubles, so at that width they get two lanes. WebAssembly’s SIMD names spell this out: f32x4 means “four 32-bit float lanes”, f64x2 means “two 64-bit lanes”[4]. Some CPUs have wider registers still: the ISPC compiler’s guide describes 256-bit and 512-bit registers on x86 CPUs with AVX-512[3].

You can feel the idea in plain JavaScript by writing the loop the way SIMD hardware would see it: four lanes per step.

Try itA loop shaped like SIMD
const a = [1, 2, 3, 4, 5, 6];
const b = [10, 20, 30, 40, 50, 60];
const c = [];
const W = 4; // lanes per step
let i = 0;
// full steps of W lanes
for (; i + W <= a.length; i += W) {
  for (let l = 0; l < W; l++) {
    c[i + l] = a[i + l] + b[i + l];
  }
}
// the tail that doesn't fill W lanes
for (; i < a.length; i++) {
  c[i] = a[i] + b[i];
}
console.log(c);

That code isn’t faster (it’s still one add at a time underneath). It only shows the shape of the work: chunks of four, plus a tail when the length isn’t a multiple of four.

Vectorization: letting the compiler do it

You rarely write SIMD by hand. Instead, an optimizing compiler looks at a loop like ours and rewrites it to process several iterations per step. LLVM, the compiler framework behind the clang C compiler, has a loop vectorizer that does this, and it is enabled by default[1].

The compiler can only do it when the rewrite can’t change the answer. Two things commonly get in the way[1]:

  1. Overlap. If x and vx might be the same memory, writing four x values before reading the next vx values could change what is read. LLVM handles this by checking for overlap while the program runs and falling back to the plain loop.
  2. Order. Floating-point addition isn’t associative: grouping the additions differently can round differently. Summing in four lanes and adding the lanes at the end changes the grouping, so for floats LLVM only does it when you explicitly allow reordering.
console.log((0.1 + 0.2) + 0.3);
// 0.6000000000000001
console.log(0.1 + (0.2 + 0.3));
// 0.6

Same three numbers, different grouping, different answer. A compiler that silently changed one into the other would be breaking your program.

Definition

Vectorization is rewriting a loop so each step processes several elements with SIMD instructions, one element per lane. A compiler may only do it when it can show the result is unchanged, or when you allow it to change (as with reordered floating-point sums).

Why SIMD wants columns

A vector load is fast because it copies one contiguous run of memory straight into a register: four neighbours in, four lanes full. So ask: in our particle data, are the four x values neighbours?

Top: in an array of structs, the x values are 6 slots apart, so filling a 4-lane register needs a gather from 4 places. Bottom: in a struct of arrays, the 4 x values are side by side and one vector load fills the register.AoS: x values are 6 slots apartx0x1x2x3gather: 4 separate readsSoA: the x column, side by sidex0x1x2x3one vector loadxyvxvymasshealth
SIMD needs the values for its lanes side by side. In an array of structs (top), each x is 6 slots away from the next: the stride is 6. Filling four lanes takes a gather, four separate reads. In a struct of arrays (bottom), the x column is contiguous and one vector load fills the register.

In an array of structs, the x values are spread out with y, vx, vy, mass and health in between. The hardware has to gather them one at a time. The ISPC performance guide says exactly this: contiguous accesses can use efficient vector loads and stores, while the AoS layout needs gathers and runs slower[2]. LLVM’s own documentation notes that its cost model often judges strided gathers not worth vectorizing at all[1].

In a struct of arrays, every column is already lined up for the stamp. That’s the deeper reason SoA keeps appearing in high-performance code: it is the layout that both cache lines and SIMD lanes want.

Under the hood

What about JavaScript itself? There was a proposal, SIMD.js, to add vector types to the language. It was removed from Stage 3 at TC39 and browsers are not implementing it; SIMD on the web moved to WebAssembly instead[6]. WebAssembly has a fixed-width 128-bit SIMD type, v128, with instructions like f64x2.add[4], and it is part of WebAssembly 2.0[5].

So JavaScript has no SIMD API. Does V8 vectorize ordinary JavaScript loops behind your back? None of the sources this book checked say so, so we won’t assume it. This book’s SoA speed-ups come from memory traffic, which you’ll measure in chapter 23. For Viscosity, SIMD is a future payoff: its SoA columns are Float64Arrays[7], contiguous runs of doubles, exactly the input a WebAssembly or native back end would need to use lanes.

Your turn

The core exercise is the shape of every vectorized sum: one accumulator per lane, then a final step that adds the lanes together.

ExerciseSum the way SIMD does

Write sumLanes(xs, W) that sums xs using W lane accumulators, like a vectorized sum. Element i goes into lane i % W. Return { lanes, total }: lanes is the array of W lane sums, and total is the lanes added together.

function sumLanes(xs, W) {
  const lanes = [];
  // your code here
}

const r = sumLanes([1, 2, 3, 4, 5], 2);
console.log(r);
Extra practiceGather a column

Records are packed into one array, nf numbers per record (packed AoS). Write column(packed, nf, f) that returns a **new Float64Array** holding field f of every record, in order. This is the gather that AoS forces on SIMD code.

function column(packed, nf, f) {
  // your code here
}

const p = [1, 2, 3, 4, 5, 6];
console.log(column(p, 3, 1));

What you learned

  • SIMD means one instruction works on several values at once, held in a wide vector register.
  • Each slot of that register is a lane. Lanes work side by side and never mix; a 128-bit register holds two 64-bit doubles.
  • Vectorization is a compiler rewriting a loop into SIMD steps. It must not change the answer, so overlapping memory and floating-point reordering can block it.
  • SIMD loads want contiguous values. AoS spreads a field out with a stride, so it needs gathers; SoA columns load straight into lanes.
  • JavaScript has no SIMD API (SIMD.js was withdrawn). WebAssembly has fixed-width 128-bit SIMD.

Next: what JavaScript numbers and objects actually look like in memory, and why one innocent-looking TypeScript line can make a loop five times slower.

Check your understanding

0/6
  1. 1.

    A SIMD add instruction works on two registers with four lanes each. What does it produce?

  2. 2.

    JavaScript numbers are 64-bit doubles. How many fit in a 128-bit SIMD register?

  3. 3.

    Why might a compiler refuse to vectorize let s = 0; for (...) s += xs[i]; when xs holds floats?

  4. 4.

    Particles are stored as an array of structs with 6 fields each. A SIMD loop wants four x values in one register. What must happen?

  5. 5.

    Which statement about SIMD and JavaScript is accurate?

  6. 6.

    Viscosity stores SoA columns as Float64Arrays. Why does that matter for SIMD?

References

  1. [1] The LLVM Project. Auto-Vectorization in LLVM. LLVM documentation. llvm.org/docs/Vectorizers.html (checked 2026-10-31)
  2. [2] 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)
  3. [3] 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)
  4. [4] The WebAssembly Community Group (SIMD subgroup). WebAssembly 128-bit packed SIMD Extension (proposal overview, SIMD.md). WebAssembly/simd repository on GitHub. github.com/WebAssembly/simd/blob/main/proposals/simd/SIMD.md (checked 2026-10-31)
  5. [5] The WebAssembly Community Group. Finished Proposals. WebAssembly/proposals repository on GitHub. github.com/WebAssembly/proposals/blob/main/finished-proposals.md (checked 2026-10-31)
  6. [6] Ecma TC39 (SIMD.js champions and contributors). SIMD.js (ecmascript_simd): historical repository README. tc39/ecmascript_simd repository on GitHub. github.com/tc39/ecmascript_simd (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)