Writing v128 SIMD Intrinsics in Rust

This guide covers writing WebAssembly vector code by hand — loading a v128 from linear memory, operating on its lanes, storing it back, and confirming that the instructions you intended are the instructions the compiler emitted.

Hand-written intrinsics are the second thing to try, not the first. If a loop is simple and contiguous, the compiler will vectorise it for you and the result is easier to maintain. Intrinsics earn their place where autovectorization consistently fails: shuffles, saturating arithmetic, horizontal reductions, and any kernel whose structure the optimiser cannot see through.

The shape of every vector loop Each iteration loads a full 128-bit vector from linear memory, applies one instruction across all lanes, and stores the result back. The loop advances by the vector width, and a scalar tail handles any remaining elements. v128_load 16 bytes from memory one instruction, all lanes f32x4_mul · i8x16_add · shuffle v128_store 16 bytes back advance by the vector width and repeat the tail len % lanes elements remain and must be handled with scalar code — forgetting this is the most common correctness bug

Prerequisites

  • [ ] Rust 1.75+ — std::arch::wasm32 intrinsics are stable for the simd128 feature
  • [ ] RUSTFLAGS="-C target-feature=+simd128" set for the build
  • [ ] wasm-objdump to verify the emitted instructions
  • [ ] A scalar implementation of the same kernel, to check results against

Step-by-step procedure

1. Import the intrinsics and mark the function

use std::arch::wasm32::*;

#[target_feature(enable = "simd128")]
unsafe fn scale_simd(data: &mut [f32], factor: f32) {
    let mul = f32x4_splat(factor);
    let chunks = data.len() / 4;

    for i in 0..chunks {
        let p = data.as_mut_ptr().add(i * 4) as *mut v128;
        let v = v128_load(p);
        v128_store(p, f32x4_mul(v, mul));
    }

    // scalar tail
    for x in data.iter_mut().skip(chunks * 4) {
        *x *= factor;
    }
}

f32x4_splat broadcasts one scalar to all four lanes — the usual way to get a constant into a vector. The unsafe is required because the loads and stores are raw pointer operations, not because SIMD is inherently unsafe.

2. Use lane-wise comparison rather than branching

Vector code has no per-lane if. Comparisons produce a mask of all-ones or all-zeros per lane, which you then combine:

#[target_feature(enable = "simd128")]
unsafe fn clamp_simd(v: v128, lo: v128, hi: v128) -> v128 {
    let too_low = f32x4_lt(v, lo);
    let clamped_low = v128_bitselect(lo, v, too_low);
    let too_high = f32x4_gt(clamped_low, hi);
    v128_bitselect(hi, clamped_low, too_high)
}

v128_bitselect(a, b, mask) takes bits from a where the mask is set and from b elsewhere. That pattern — compare, then select — replaces every conditional you would write in scalar code.

3. Shuffle when the data layout does not match the operation

#[target_feature(enable = "simd128")]
unsafe fn swap_pairs(v: v128) -> v128 {
    i32x4_shuffle::<1, 0, 3, 2>(v, v)
}

Shuffles are the clearest case for intrinsics: no autovectoriser will produce them from ordinary indexing code, and they are what makes deinterleaving RGBA pixels or transposing a small matrix efficient.

4. Gate the call on detection

#[target_feature] makes the function unsafe to call precisely because the caller must guarantee the feature exists. In WebAssembly that guarantee comes from having loaded the SIMD build at all:

#[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) };
    unsafe { scale_simd(data, factor) }
}

5. Verify the emitted instructions

RUSTFLAGS="-C target-feature=+simd128" cargo build --release --target wasm32-unknown-unknown
wasm-objdump -d target/wasm32-unknown-unknown/release/kernel.wasm | grep -oE 'f32x4\.[a-z]+' | sort | uniq -c

Expected output

The disassembly should show the instructions you wrote, and the result should match the scalar version exactly:

$ wasm-objdump -d kernel.wasm | grep -oE '(f32x4|v128)\.[a-z_]+' | sort | uniq -c
      1 f32x4.splat
      1 f32x4.mul
      1 v128.load
      1 v128.store

$ node compare.mjs
scalar : [2.5, 5, 7.5, 10, 12.5]
simd   : [2.5, 5, 7.5, 10, 12.5]
identical ✓

For multiplication the two agree bit for bit, because each lane performs the same IEEE operation as the scalar version. That is not true for reductions, where lane ordering changes the rounding — a fact worth knowing before writing a test that asserts equality on a sum.

When SIMD results are bit-identical and when they are not Element-wise arithmetic performs the same IEEE operation per lane as scalar code and produces identical results. A reduction adds lanes in a different order, and because floating-point addition is not associative the final bits can differ. element-wise — identical mul, add, min, max, compare each lane does exactly what the scalar did assert equality freely in tests reductions — may differ sum, dot product, mean lanes are combined in a different order assert a tolerance, not equality Neither is a bug: floating-point addition is not associative, so summing in a different order is a different, equally valid computation.

Gotchas

error: v128_load is unsafe and requires unsafe function or block. Expected — these are raw pointer operations. Wrap the loop, not every call, and keep the unsafe region small.

The build succeeds but no vector instructions appear. RUSTFLAGS was not applied. It must be set for the compilation that produced the artifact, and changing it does not always trigger a rebuild — cargo clean if in doubt.

Results are wrong for the last few elements. The scalar tail is missing or has an off-by-one. data.len() / 4 chunks leaves len % 4 elements, and they must be handled explicitly.

A shuffle produces surprising output. The index constants select bytes or lanes depending on the instruction family. i8x16_shuffle indexes bytes 0–31 across two vectors; i32x4_shuffle indexes four-byte lanes. Mixing them up is easy and silent.

Performance is worse than scalar. Usually the loop is memory-bound, or the vector body is surrounded by so much lane-extraction that the win is eaten. Check the disassembly for extract_lane-heavy sequences, which usually mean the algorithm is fighting the data layout.

Layout decides whether a vector load is useful With an array of structures, four consecutive bytes hold different fields, so building a vector of one field requires shuffling or separate loads. With a structure of arrays, one load fetches four values of the same field, which is what vector arithmetic wants. array of structures x y z w x y z w x y z w one load gets x, y, z, w of a single item — rarely what the maths wants structure of arrays x x x x x x x x y y y y y y y y one load gets four x values — exactly one vector operand Changing the layout often buys more than any intrinsic tuning, and it makes the autovectoriser more likely to succeed as well.

Performance note

An element-wise f32 kernel like the one above typically runs 2–3× faster than its scalar equivalent once the array is large enough for the loop overhead to disappear — call it a few thousand elements. Below that, the fixed costs dominate and the two are indistinguishable, which is worth remembering before vectorising a function that operates on eight values.

The bigger lever is often data layout rather than instruction selection. A structure-of-arrays layout lets a vector load pull four consecutive values of the same field; an array-of-structures layout forces a shuffle or four separate loads to assemble the same vector. Reorganising the data frequently buys more than any amount of intrinsic tuning, and it makes the autovectoriser more likely to succeed on the surrounding code too.

A worked kernel: RGBA to grayscale

A complete example is worth more than a list of intrinsics, and this one exercises the pieces that matter: a load, lane-width conversion, arithmetic, and a store, over data that is naturally contiguous.

use std::arch::wasm32::*;

/// Convert 4 RGBA pixels (16 bytes) to grayscale in place, using integer weights.
#[target_feature(enable = "simd128")]
unsafe fn gray4(p: *mut u8) {
    let px = v128_load(p as *const v128);          // r g b a  ×4

    // widen the low and high halves to 16-bit lanes so the multiply cannot overflow
    let lo = u16x8_extend_low_u8x16(px);
    let hi = u16x8_extend_high_u8x16(px);

    // weights approximating 0.299 / 0.587 / 0.114 in 8.8 fixed point
    let wl = u16x8(77, 150, 29, 0, 77, 150, 29, 0);

    let ml = u16x8_mul(lo, wl);
    let mh = u16x8_mul(hi, wl);

    // horizontal add within each pixel, then shift back down by 8
    let sl = u16x8_shr(u16x8_add(ml, u16x8_shuffle::<1,0,3,2,5,4,7,6>(ml, ml)), 8);
    let sh = u16x8_shr(u16x8_add(mh, u16x8_shuffle::<1,0,3,2,5,4,7,6>(mh, mh)), 8);

    v128_store(p as *mut v128, u8x16_narrow_i16x8(sl as v128, sh as v128));
}

Three details in that code are the ones people get wrong the first time. The widening step is not optional: multiplying 8-bit lanes by weights larger than one overflows silently, and the result is plausible-looking garbage rather than an error. The shuffle is doing a horizontal add — combining lanes within a pixel rather than across pixels — which is exactly the operation autovectorization will never produce for you. And the narrowing store saturates rather than wrapping, which is what you want for pixel data and would be wrong for a signed computation.

Verifying a hand-written kernel

Because intrinsics bypass the compiler’s reasoning, the scalar comparison is not optional — it is the only thing standing between a fast kernel and a fast wrong kernel:

#[cfg(test)]
mod tests {
    #[test]
    fn matches_scalar() {
        let mut a = sample_pixels();
        let mut b = a.clone();
        gray_scalar(&mut a);
        unsafe { gray_simd(&mut b) };
        assert_eq!(a, b, "SIMD and scalar diverged");
    }
}

Run that test on every build, with inputs that include the awkward cases: a length that is not a multiple of the lane count, an all-zero buffer, and values at the top of the range where saturation and overflow behaviour differ. Those three catch almost every intrinsic bug, and they cost milliseconds.

Frequently Asked Questions

Should I write intrinsics or let the compiler vectorise? Let the compiler try first — see autovectorizing loops for Wasm SIMD. Reach for intrinsics when the disassembly shows it failed, or when you need an operation that autovectorization never produces.

Can I use the packed_simd or wide crates instead? Portable SIMD abstractions work and are more readable, at the cost of less control over exactly which instruction is emitted. For a small kernel the intrinsics are clearer about what you are getting; for a larger codebase the abstraction usually wins.

Do intrinsics work under wasm-pack? Yes — they are ordinary Rust. Set RUSTFLAGS in the environment or in .cargo/config.toml, and remember that wasm-opt needs --enable-simd to process the result.

Are the intrinsic names stable? The ones for the fixed-width SIMD proposal are stable in std::arch::wasm32 and have been for several releases. Relaxed SIMD intrinsics are newer and may still move, which is one more reason to treat that proposal as a later, separate step.

← Back to Wasm SIMD & Vectorized Computation