Wasm SIMD & Vectorized Computation
WebAssembly’s fixed-width SIMD proposal adds one new value type — v128, a 128-bit vector — and a few
hundred instructions that operate on it. A single f32x4.mul multiplies four floats in one
instruction; a single i8x16.add adds sixteen bytes. For the workloads that suit WebAssembly in the
first place — image filters, audio processing, physics, checksums, decoding — that is the difference
between “faster than JavaScript” and “several times faster than JavaScript”.
The catch is that SIMD is a post-MVP feature. A module containing v128 instructions fails to
validate on an engine without support, so shipping it means either accepting a narrower audience or
building two artifacts and choosing at load time. This area covers writing the vector code, getting
the compiler to generate it, verifying it actually did, and deploying it safely.
Prerequisites
- [ ] A browser with SIMD support — Chrome 91+, Firefox 89+, Safari 16.4+
- [ ] Rust with
RUSTFLAGS="-C target-feature=+simd128", or Emscripten with-msimd128 - [ ]
wasm-objdumpfromwabt, to confirm vector instructions are present in the output - [ ] A benchmark harness you trust — SIMD claims are easy to make and easy to get wrong
- [ ] A baseline build of the same code, for the comparison and for the fallback
What the v128 type actually is
A v128 is 128 bits with no inherent interpretation. The instruction decides how those bits are read:
f32x4.add treats them as four 32-bit floats, i16x8.mul as eight 16-bit integers, i8x16.shuffle
as sixteen bytes. The same register can hold any of those shapes, and moving between interpretations
costs nothing — there is no conversion instruction, only a differently-typed operation on the same
bits.
Two properties follow from the fixed 128-bit width. First, the speedup ceiling for a perfectly
vectorised loop is the lane count: four for f32, sixteen for bytes. Real code lands below that
because of loads, stores and loop overhead, so a 2–3× improvement on float work and 4–8× on byte work
is a realistic expectation rather than a disappointment. Second, unlike native SIMD there is no
256-bit or 512-bit tier to move up to; the portability comes from having exactly one width.
Step-by-step: getting vector instructions into your binary
-
Turn the feature on. For Rust the flag goes in
RUSTFLAGS, because it is a code-generation feature rather than a crate feature:RUSTFLAGS="-C target-feature=+simd128" \ cargo build --release --target wasm32-unknown-unknown -
Write a loop the compiler can vectorise. Contiguous slices, a simple body, no early exits, and a length the optimiser can reason about:
#[no_mangle] pub extern "C" fn scale(ptr: *mut f32, len: usize, factor: f32) { let data = unsafe { std::slice::from_raw_parts_mut(ptr, len) }; for x in data.iter_mut() { *x *= factor; } } -
Confirm it worked. This is the step everyone skips and should not — enabling the flag does not guarantee the compiler used it:
wasm-objdump -d target/wasm32-unknown-unknown/release/kernel.wasm \ | grep -c 'f32x4\|i32x4\|i8x16' -
Reach for intrinsics where the compiler will not. Shuffles, saturating arithmetic and horizontal reductions rarely emerge from autovectorization:
use std::arch::wasm32::*; #[target_feature(enable = "simd128")] unsafe fn sum4(ptr: *const f32) -> f32 { let v = v128_load(ptr as *const v128); let a = f32x4_extract_lane::<0>(v) + f32x4_extract_lane::<1>(v); let b = f32x4_extract_lane::<2>(v) + f32x4_extract_lane::<3>(v); a + b } -
Keep
wasm-optinformed. The optimiser refuses input using features it was not told about:wasm-opt kernel.wasm -O3 --enable-simd -o kernel.opt.wasm -
Build the baseline too. The same source without the flag, as a separate artifact:
cargo build --release --target wasm32-unknown-unknown # no RUSTFLAGS
Feature detection and deployment
A SIMD module fails at validation, not at the first vector instruction, so there is no way to catch the problem partway through execution — the whole module is rejected. That makes deployment a load-time decision: probe for support, then fetch the appropriate binary.
The two builds should come from identical source. Any divergence between them is a correctness risk that will only appear on whichever subset of users takes the branch you tested less — which, given current support levels, is the baseline one.
Optimization flags & tradeoffs, with numbers
Vector code is bigger. Each intrinsic is an instruction, and an autovectorised loop typically emits both a vector body and a scalar tail for the remainder elements, so a hot function can double in size. On a whole module the effect is usually modest — a few per cent — because the vectorised code is concentrated in a handful of kernels.
The throughput picture depends almost entirely on lane width and memory behaviour. Byte-wise work sees
the largest gains because sixteen lanes are in play; f64 work sees the least because there are only
two. A loop that is memory-bound rather than compute-bound sees very little regardless of lane count,
because the bottleneck is the loads, and vectorising the arithmetic does not make the data arrive
faster.
Gotchas & failure modes
CompileError: SIMD unsupported. The engine lacks the feature. This is a validation failure, so
no part of the module runs. Detect and fetch a baseline build rather than catching the error late.
The flag is set but no vector instructions appear. The loop did not vectorise. Common causes are
non-contiguous access, a conditional in the body, an early return, or aliasing the optimiser cannot
rule out. Check with wasm-objdump before assuming the flag did anything.
wasm-opt refuses the binary. Pass --enable-simd. Without it the optimiser rejects input using
instructions it was not told to expect.
Results differ slightly between the SIMD and scalar builds. Floating-point reductions are not associative, and a vectorised sum adds lanes in a different order. This is expected; if bit-identical results matter, do not vectorise the reduction.
Unaligned loads are slower than expected. v128_load does not require alignment, but the
underlying hardware may prefer it. Aligning buffers to 16 bytes is free at allocation time and
occasionally worth several per cent.
The instruction families worth knowing
The proposal defines a few hundred instructions, but they fall into a handful of families, and knowing the families is enough to read most vector code and to recognise what is available.
Construction and access. v128.const builds a constant vector; splat broadcasts one scalar into
every lane; extract_lane and replace_lane read and write a single lane by index. The splat is the
workhorse — nearly every kernel starts by broadcasting a coefficient — while frequent use of
extract_lane in a loop is usually a sign the algorithm is fighting the data layout.
Element-wise arithmetic. add, sub, mul, div, min, max, abs, neg, available per lane
shape. These are the instructions autovectorization produces, and they behave exactly like their scalar
counterparts applied independently to each lane.
Saturating arithmetic. add_sat and sub_sat clamp at the type’s limits rather than wrapping,
which is what pixel and audio work almost always wants. Scalar code has to write the clamp explicitly;
the vector instruction does it for free, which is one of the few places SIMD is simpler than the scalar
equivalent rather than merely faster.
Comparison and selection. Comparisons produce a lane mask of all-ones or all-zeros rather than a
boolean, and bitselect combines two vectors according to that mask. Together they replace every
conditional, and recognising the compare-then-select idiom is most of what makes unfamiliar vector code
readable.
Lane reorganisation. shuffle picks lanes from two vectors by constant index; swizzle does it
with a runtime index vector. These are what deinterleave RGBA pixels, transpose small matrices and
implement table lookups, and they are the family autovectorization will essentially never generate.
Width conversion. extend_low, extend_high and narrow move between lane widths — eight bytes
becoming eight 16-bit values, or the reverse with saturation. Any kernel that multiplies byte data needs
these, because multiplying 8-bit lanes overflows almost immediately.
Reductions. all_true, any_true and bitmask collapse a vector to a scalar answer, which is how
a vectorised search reports whether it found anything. Horizontal sums have no single instruction and
are built from shuffles and adds, which is why summing is the operation where vector and scalar results
diverge in their last bits.
Two absences are worth noting because they shape what is worth attempting: there is no gather — you cannot load from sixteen independent addresses in one instruction — and no scatter. Any algorithm whose inner loop indexes by data rather than by position is therefore a poor fit, however arithmetic-heavy it looks, and that single fact rules out more candidate kernels than everything else combined.
Verification
Three checks, in order: that the instructions are there, that the results match the scalar build, and that the speedup is real on a workload you care about.
wasm-objdump -d kernel.simd.wasm | grep -oE '\b(f32x4|i8x16|i16x8|i32x4)\.[a-z_]+' | sort | uniq -c | sort -rn | head
wasm-validate kernel.simd.wasm && echo 'valid'
node compare.mjs kernel.wasm kernel.simd.wasm # asserts identical output on a fixed input
The middle check matters most. A SIMD kernel that is faster and subtly wrong is worse than no SIMD at all, and the comparison against the scalar build is cheap to automate.
Choosing what to vectorise
Not every hot loop is a candidate, and picking the wrong one wastes a week. Four properties, in roughly descending order of importance, decide whether a kernel will benefit.
It must be compute-bound, not memory-bound. Vector instructions multiply arithmetic throughput and do nothing for the rate at which data arrives from memory. A loop that reads each element once, does a single multiply, and writes it back is already limited by the loads and stores; vectorising the multiply changes almost nothing. The kernels that gain most do several operations per element loaded — a convolution, a colour-space conversion, a dot product inside a matrix multiply.
The access pattern must be contiguous. A vector load reads sixteen consecutive bytes. If the values you want are strided — every fourth element of an interleaved array — assembling a vector requires shuffles that eat the gain. This is why data layout so often matters more than instruction selection: converting an array of structures into a structure of arrays can turn a kernel that resists vectorisation into one that vectorises trivially.
The lane width should be small. Sixteen byte-lanes give a theoretical 16× on the arithmetic; two 64-bit lanes give 2×. Byte and 16-bit integer work — image pixels, audio samples, character classification, checksums — sits at the favourable end. Double-precision numerics sits at the other, and often the better move there is to establish whether single precision is sufficient, which doubles the lane count before any vector instruction is written.
The body should be branch-free. Lanes cannot take different paths, so a conditional either prevents vectorisation entirely or forces a compute-both-and-select pattern that costs more than it saves when the branch is predictable. Kernels expressible as arithmetic — clamps, mixes, thresholds — are ideal; kernels with early exits or data-dependent control flow are usually not.
A short worked example of the triage: an image pipeline with a brightness adjustment, a nearest-colour lookup and a JPEG entropy-coding step. The brightness adjustment is byte-wise, contiguous and branch-free — an excellent candidate that will likely see several times the throughput. The colour lookup is a gather, indexing a palette by a value read from the data, which is precisely the pattern fixed-width SIMD handles worst. The entropy coder is bit-serial with data-dependent branching and cannot be vectorised at all. One of the three is worth the effort, and knowing which before starting is the difference between a good week and a wasted one.
The corollary is that profiling comes first. A kernel that is three per cent of the frame time cannot produce more than a three per cent improvement no matter how well it vectorises, and the effort is better spent on whatever occupies the other ninety-seven.
In this guide
- Detecting SIMD support at runtime — the probe module and where to run it.
- Writing v128 SIMD intrinsics in Rust — hand-written vector code with std::arch::wasm32.
- Autovectorizing loops for Wasm SIMD — writing loops the compiler will vectorise.
- Benchmarking SIMD vs scalar Wasm kernels — measuring the claim honestly.
- Shipping SIMD and baseline builds together — the two-artifact deployment.
Frequently Asked Questions
Is Wasm SIMD as fast as native SIMD? Close, for the operations it has. It maps onto SSE, NEON and equivalents fairly directly, so the generated machine code is comparable. What you give up is width — no 256-bit or 512-bit vectors — and the exotic corners of native instruction sets.
Do I need SIMD if I already use threads? They are complementary rather than alternatives: threads multiply by core count, SIMD by lane count, and a kernel can use both. Threads are the harder of the two to adopt because they need cross-origin isolation, so SIMD is usually the first thing to try.
Can I use SIMD from JavaScript? Not directly — there is no JavaScript SIMD API. The vector instructions exist only inside a module, which is one of the clearer reasons to move a numeric kernel into WebAssembly at all.
What about relaxed SIMD? A later proposal that trades exact reproducibility for speed on some operations. Support is narrower again, and the non-determinism means results can differ between engines — worth it for some workloads, but treat it as a third build rather than a drop-in flag.
Relaxed SIMD, and what it trades
A later proposal, relaxed SIMD, adds instructions whose exact results are allowed to differ between engines in return for mapping more directly onto whatever the underlying hardware provides. A fused multiply-add is the canonical example: some processors have a single instruction that computes it with one rounding step, others need two operations and two roundings, and the relaxed instruction permits either.
The gain is real for the kernels it applies to — fused operations, certain lane-selection patterns, some integer conversions — and can be tens of per cent on top of ordinary SIMD. The cost is that the result is no longer bit-identical across engines, which rules it out for anything requiring reproducibility: a checksum, a consensus computation, a rendering pipeline whose output is compared against a reference image.
Support is also narrower than fixed-width SIMD, which means a third artifact if you want it, and the combinatorics of build variants start to bite. The pragmatic position for most projects is to treat relaxed SIMD as a later optimisation for a specific measured kernel rather than a build flag to enable globally — and to check first whether the kernel is one of the cases where it actually helps, since for plain element-wise arithmetic it changes nothing.
Does SIMD affect binary size much? Modestly. Vector code is denser per unit of work but the compiler usually emits a scalar tail alongside each vectorised loop, so a heavily vectorised module runs a few per cent larger overall. That is small next to the throughput gain and small next to the cost of shipping two artifacts, which is the real size consideration in a SIMD deployment.
Related
- Stack vs heap execution model — where a v128 lives while the code runs.
- Polyfills, alternatives & fallbacks — the detection ladder this feature slots into.
- Wasm performance benchmarking — the harness the numbers above should come from.
- Zero-copy data transfer patterns — getting the data to the kernel without copying it.