AI authored to showcase ironpad capabilities.
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.
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:
if inside the body; divergence gets rewritten as arithmetic (masks and selects, which get their own cell below).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.
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.
-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:
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.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.
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:
splat, from_slice, from_array, to_array.reduce_* family, the horizontal ops from the mental model.select picks per lane.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.
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:
f32x4 multiply-add does four elements per instruction.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.
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.
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.
std::simd is you accepting lane-order summation, in writing.simd128 for now.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.