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-objdump from wabt, 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.

One register, several shapes The same 128 bits are read as four 32-bit floats by f32x4 instructions, as eight 16-bit integers by i16x8 instructions, and as sixteen bytes by i8x16 instructions. The interpretation lives in the instruction rather than in the value. one v128 value — 128 bits f32x4 i16x8 i8x16 Four lanes, eight lanes or sixteen — the wider the lane, the fewer per instruction, which is why byte work vectorises best.

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

  1. 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
  2. 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;
        }
    }
  3. 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'
  4. 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
    }
  5. Keep wasm-opt informed. The optimiser refuses input using features it was not told about:

    wasm-opt kernel.wasm -O3 --enable-simd -o kernel.opt.wasm
  6. 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.

Probe first, then fetch A few dozen bytes of hand-built module containing one vector instruction is validated synchronously. If it validates, the SIMD build is fetched; if not, the baseline build is. The decision happens before any large download starts. WebAssembly.validate(probe) ≈ 30 bytes, synchronous, no fetch true → fetch kernel.simd.wasm vector instructions, 2–8× on the kernel the common path on current browsers false → fetch kernel.wasm scalar build of the same source correct, just slower Because the probe is validated rather than instantiated, it costs a fraction of a millisecond and no network request at all.

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.

Where the gains actually are Byte-wise operations with sixteen lanes see the largest improvements, 32-bit float work a moderate one, and 64-bit float work the least. A memory-bound loop improves hardly at all regardless of lane count, because the bottleneck is elsewhere. i8x16 — byte ops 4–8× f32x4 — float ops 2–3× f64x2 — double ops 1.3–1.8× memory-bound loop ≈ 1.05× — the loads are the limit Indicative only: measure your own kernel. The bottom row is the one people are most often surprised by.

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

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.

← Back to WebAssembly Core Concepts & Browser Runtime