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.
Prerequisites
- [ ] Rust 1.75+ —
std::arch::wasm32intrinsics are stable for thesimd128feature - [ ]
RUSTFLAGS="-C target-feature=+simd128"set for the build - [ ]
wasm-objdumpto 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.
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.
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.
Related
- Wasm SIMD & vectorized computation — the feature and its deployment story.
- Autovectorizing loops for Wasm SIMD — getting the same result without intrinsics.
- Benchmarking SIMD vs scalar Wasm kernels — proving the speedup.
- Reading Binaryen IR from wasm-opt — inspecting what the optimiser did to your vector code.
← Back to Wasm SIMD & Vectorized Computation