Four Lanes Wide: SIMD in a Browser Tab

AI authored to showcase ironpad capabilities.

Introduction

Every browser your readers are likely to own has shipped 128-bit WASM SIMD since 2021 (Safari joined at 16.4), which puts coverage around 95% of sessions. One instruction, four f32 lanes. And yet almost all Rust compiled to WASM runs one lane at a time, because the simd128 target feature is off by default.

In ironpad, using SIMD is the opt-in: any cell that mentions std::simd gets compiled with -C target-feature=+simd128 and a crate-root #![feature(portable_simd)] gate, the same way autodiff cells opt into Enzyme. Nothing else changes.

This notebook races one kernel through three gears: a scalar baseline, the autovectorizer, and explicit std::simd. Then it spends the winnings on a fractal. Every number you see is measured live, in your browser, right now.

The lane mental model

A v128 register is 128 bits of raw storage; a lane count is just a way of reading it: four f32 boxes side by side, or two f64, or sixteen i8. One SIMD instruction applies the same operation to every lane at once; f32x4.add is one add that lands four times.

For a mental model, a SIMD instruction is a for loop with exactly four iterations that all retire in the time of one, with two strings attached:

  1. Every lane executes the same instruction. No if inside the body; divergence gets rewritten as arithmetic (masks and selects, which get their own cell below).
  2. Lanes do not talk to each other. Lane 2 cannot peek at lane 3 mid-computation. Anything cross-lane (a horizontal sum, a shuffle) is a separate, usually slower, instruction.

That second constraint gives SIMD code its characteristic shape: long vertical stretches of lane-parallel math, then one horizontal step at the very end that collapses the lanes into an answer. Keep the horizontal step out of the hot loop and the shape is fast.

Also fixed: the width. Baseline WASM SIMD is 128 bits, full stop, with no 256-bit tier to feature-detect; the why lives in the bytecode section below.

With the model loaded, the gears. Gear one is one lane, on purpose.

Gear one: scalar

The free lunch, inspected

The obvious question: if the flag is all it takes, why not just enable simd128 and let LLVM's autovectorizer rewrite the loop? Sometimes that works, and it is free. But the vectorizer follows rules, and the big one bites here: summing floats in a different order gives (slightly) different answers, so the compiler is not allowed to reassociate floating-point addition. A dot += a[i] * b[i] chain over f32 must stay a serial chain.

Integers have no such scruples. Integer addition is associative, so the same loop shape over i32 vectorizes freely.

The rule at full precision: IEEE 754 addition rounds after every operation, so (a + b) + c and a + (b + c) can produce different bits. Float addition is commutative but not associative, and vectorizing a reduction is precisely a regrouping:

(((acc + x0) + x1) + x2) + x3 ...      what you wrote: one serial chain
(x0 + x4 + ...) + (x1 + x5 + ...)      what four lanes compute: four chains, merged at the end

The compiler will not change your program's observable output without permission, so it declines. (C compilers will under -ffast-math; Rust deliberately ships no such global toggle, which I count as a pit of success.)

The next cell shows the rounding with three floats and ten thousand ones. The cell after it stages the race, with exactly one functional change from the baseline: a comment naming the intrinsics module, enough to flip simd128 on.

Rounding, demonstrated
Gear two: the autovectorizer

What the flag means in bytecode

-C target-feature=+simd128 is not an optimizer knob; it changes which instruction set LLVM is allowed to emit. Core WASM has exactly four value types: i32, i64, f32, f64. The SIMD proposal adds a fifth, v128, plus 236 instructions that operate on it: loads and stores, lane-wise arithmetic like f32x4.add and i32x4.mul, lane-wise comparisons, shuffles, and lane extracts.

Two consequences are worth having in your head:

  1. Without the flag, std::simd still compiles. Scalar fallback is always legal: every f32x4 op lowers to four scalar ops and the program means the same thing. The flag is load-bearing; it is the difference between an abstraction that names four lanes and an actual f32x4.add in the binary.
  2. With the flag, the module hard-requires engine support. Engines validate every instruction at instantiation, so a single v128 op makes the whole module unloadable on a non-SIMD engine. This is why ironpad applies the flag per cell rather than globally: only cells that ask for lanes take on the engine requirement (near-universal since 2021, per the hook).

The 128-bit width is a deliberate compromise: x86 has shipped exactly 128 bits via SSE since 1999 and ARM via NEON since the late 2000s, so each f32x4 op JITs to one hardware instruction on effectively every device with a browser. Baseline WASM SIMD trades AVX's peak for that guarantee.

A ten-minute tour of std::simd

Before the main event, the vocabulary. The std::simd API (nightly, gated behind portable_simd; ironpad injects the gate) is small enough to tour in three tiny cells, each of which names the module and so compiles with simd128 on.

The core type is Simd<T, N>, aliased to names like f32x4 and u32x4. The arithmetic operators are overloaded lane-wise, so vector code mostly reads like scalar code that happens to be four wide. Three ideas cover the bulk of real kernels:

  1. Getting data in and out: splat, from_slice, from_array, to_array.
  2. Collapsing lanes: the reduce_* family, the horizontal ops from the mental model.
  3. Replacing branches with data: lane-wise comparisons produce masks, and select picks per lane.
Tour 1: in and out
Tour 2: collapsing lanes
Tour 3: masks and select

The tail problem

Real buffers are not multiples of four. Every SIMD kernel therefore has two parts: a wide body and a scalar tail, and the cleanest way to write that split in Rust is chunks_exact. It hands you full four-element chunks, and whatever is left over (zero to three elements) waits in .remainder().

The trap is the tempting alternative: padding with zeros so the tail disappears. Zeros are safe for a sum but wrong for a min, a product, or anything masked; a padded lane is a lie you carry through every later step.

The demo below sums 1,003 elements of small integers stored as floats, so every grouping is exact and the wide and scalar answers match to the bit.

Wide body, scalar tail

Spelling out the lanes

For floats, the reassociation is yours to authorize, and std::simd is how you sign the form. Writing f32x4 accumulators says explicitly: I accept lane-order summation.

The explicit version hides two separate wins. Keep them apart:

  1. Width: each f32x4 multiply-add does four elements per instruction.
  2. Independence: the scalar loop is a single dependency chain, so each add waits on the last one. Four independent accumulators let the CPU overlap them. This trick would speed up scalar code too; SIMD just makes you think about it.

Every ingredient in gear three just appeared in the tour: from_slice feeds the lanes, one reduce_sum per pass collapses the answer, and chunks_exact plus remainder handles the tail.

The scoreboard below runs all three variants in one cell, on one core. No rayon anywhere; that multiplier stacks separately.

Gear three: explicit std::simd

Where SIMD earns its keep

Dot products are a warm-up; they spend most of their time waiting on memory. The place SIMD earns its keep is compute-bound work, and the Mandelbrot set is the classic: hundreds of multiply-adds per pixel, no memory traffic to hide behind.

The vector version runs the escape-time loop on four pixels per instruction. The wrinkle is that lanes escape at different iterations, so you keep a per-lane mask: escaped lanes stop counting while their neighbors keep iterating, and the whole quad retires when the last lane escapes (their values blow up to infinity in the meantime, which the comparison happily treats as escaped).

One honesty check is built in: both renders use the same per-pixel coordinate arithmetic, and the cell asserts the two iteration grids are bit-identical before showing you anything.

Mandelbrot, four pixels per instruction

When lanes do not help

SIMD has a reputation for free 4x, and this notebook has been friendly territory. Honesty section: three shapes of code where lanes buy little or nothing.

Branchy code. Masks make divergence correct, not cheap: with select, every lane pays for both sides of every branch. The Mandelbrot loop got away with it because its only divergence is early exit, so the waste is bounded by lanes idling until the slowest neighbor escapes. Deep if/else ladders where lanes want different work are another story: masked SIMD executes the union of all lanes' control flow, and if the union is much bigger than any single path, scalar wins.

Gathers. from_slice requires the four values to be contiguous. Baseline simd128 has no gather instruction, so reading four unrelated positions (a lookup table, a hash probe, an index array) means four scalar loads plus shuffles, usually slower than not vectorizing at all. If your inner loop is table[idx[i]], lanes will not save you; restructuring the data into struct-of-arrays might.

Memory-bound loops. The dot product already confessed: past a certain array size the memory bus is the wall regardless of lane count, and wider math just waits wider.

Most application code is branchy, indirect, or memory-bound, which is exactly why the autovectorizer's conservatism costs less in practice than the 4x marketing suggests. Profile, find the compute-bound kernel, and spend lanes there.

Field notes

  • Where the wins live. The dot product improved despite being memory-bound; the fractal improved close to the theoretical 4x because it is compute-bound. Profile before you reach for lanes: if the loop is waiting on RAM, wider math just waits wider.
  • Floats need your signature. The autovectorizer handled integers for free and refused the floats. That refusal is a feature: std::simd is you accepting lane-order summation, in writing.
  • It stacks with rayon. Everything here ran on one core. A rayon cell splits rows across cores while each core runs four lanes; the multipliers compose.
  • What is deliberately missing: relaxed-simd (FMA and friends). It shipped in Chrome and Firefox but is still behind a flag in Safari, so ironpad sticks to baseline simd128 for now.
  • The plumbing, for the curious: mentioning std::simd, core::simd, or the intrinsics module in a cell (comments count, as the autovectorizer cell demonstrated) compiles that cell with -C target-feature=+simd128 plus a crate-root #![feature(portable_simd)], and the flag is part of the compile cache key. Cells that never mention SIMD are untouched.