Autovectorizing Loops for Wasm SIMD
This guide is about getting vector instructions without writing any: enabling the target feature, shaping loops so LLVM’s vectoriser can transform them, and — the step that separates hope from fact — reading the disassembly to confirm it did.
Autovectorization is the right first attempt for most numeric kernels. The code stays readable, the scalar and vector versions cannot drift apart because there is only one source, and the compiler handles the tail elements for you. It fails often enough that verification is mandatory, but when it works the result is frequently as good as hand-written intrinsics.
Prerequisites
- [ ] Rust with
RUSTFLAGS="-C target-feature=+simd128", or Emscripten with-msimd128 - [ ] Optimisation on — the vectoriser does not run at
-O0or in a debug profile - [ ]
wasm-objdumpto check the output - [ ] A representative input size — vectorisation of a four-element loop is not worth measuring
Step-by-step procedure
1. Enable the feature and optimise
RUSTFLAGS="-C target-feature=+simd128" \
cargo build --release --target wasm32-unknown-unknown
For C and C++ under Emscripten the equivalent is emcc -O3 -msimd128. Both matter: without the target
feature there are no vector instructions to emit, and without optimisation the vectoriser never runs.
2. Write the loop as an iterator over a slice
#[no_mangle]
pub extern "C" fn add_arrays(a: *const f32, b: *const f32, out: *mut f32, len: usize) {
let a = unsafe { std::slice::from_raw_parts(a, len) };
let b = unsafe { std::slice::from_raw_parts(b, len) };
let out = unsafe { std::slice::from_raw_parts_mut(out, len) };
for ((o, x), y) in out.iter_mut().zip(a).zip(b) {
*o = x + y;
}
}
Iterators help more than they look like they should: they remove the bounds checks that an indexed loop would have to prove redundant, and they express the unit stride directly rather than leaving the optimiser to infer it.
3. Remove branches from the body
A conditional inside the loop usually stops vectorisation. Replacing it with arithmetic often restores it:
// blocks vectorisation
for x in data.iter_mut() {
if *x < 0.0 { *x = 0.0; }
}
// vectorises — no branch, just a max
for x in data.iter_mut() {
*x = x.max(0.0);
}
4. Tell the compiler the buffers do not overlap
Aliasing is the most common silent blocker. In Rust, taking non-overlapping slices communicates this;
in C, restrict does:
void add_arrays(const float *restrict a, const float *restrict b,
float *restrict out, size_t len) {
for (size_t i = 0; i < len; i++) out[i] = a[i] + b[i];
}
5. Verify from the binary
wasm-objdump -d target/wasm32-unknown-unknown/release/kernel.wasm \
| grep -oE '\b(f32x4|i32x4|i8x16|i16x8|v128)\.[a-z_]+' | sort | uniq -c
6. If it did not vectorise, ask why
LLVM can explain its decision. Building the same code for a native target with vectoriser remarks enabled is often faster than guessing, because the diagnostics are the same analysis:
RUSTFLAGS="-C target-feature=+simd128 -C remark=loop-vectorize" \
cargo build --release --target wasm32-unknown-unknown 2>&1 | grep -i vector | head
Expected output
A vectorised kernel shows the vector opcodes and, usually, a scalar tail alongside them:
$ wasm-objdump -d kernel.wasm | grep -oE '\b(f32x4|v128)\.[a-z_]+' | sort | uniq -c
2 v128.load
1 v128.store
1 f32x4.add
$ wasm-objdump -d kernel.wasm | grep -c 'f32.add'
1 # the scalar tail, handling len % 4 elements
Seeing both is the healthy pattern. One f32x4.add plus one f32.add means the compiler produced a
vector body for the bulk and scalar code for the remainder — exactly what you would have written by
hand, without the opportunity to get the tail wrong.
Gotchas
No vector instructions at all. Either RUSTFLAGS did not apply to this build, or optimisation is
off. Confirm with a deliberately trivial loop first — if out[i] = a[i] * 2.0 does not vectorise,
the problem is the build, not the code.
A zip of three iterators refuses to vectorise. Nested zip sometimes obscures the trip count.
Falling back to an indexed loop over a common length, with the slices bound to that length first, often
restores it.
Bounds checks appear in the disassembly. The compiler could not prove the index safe. Slicing to a
known length before the loop — let a = &a[..len]; — usually removes them and unblocks the transform.
It vectorises on native and not on Wasm. The Wasm backend has one vector width and no gather or scatter, so patterns that vectorise on a modern x86 target may not here. Reshape the data or write the kernel with intrinsics.
The vectorised version is slower. Almost always memory-bound work, or an input too small for the vector body to dominate the tail and the setup. Measure at a realistic size before concluding anything.
Performance note
For a compute-bound, element-wise f32 loop over a large array, autovectorisation typically delivers
the same 2–3× improvement as hand-written intrinsics — the compiler is producing the same instruction
sequence. The advantage of writing it this way is maintenance: one implementation, no tail to get
wrong, and the scalar build falls out of the same source by omitting a flag.
Where hand-written code still wins is in the operations autovectorization does not attempt: shuffles, saturating arithmetic, and reductions that need a specific lane-combining order. A pragmatic split is to let the compiler handle the element-wise majority of a kernel and to write intrinsics only for the few functions the disassembly shows it gave up on.
Rewrites that unblock the vectoriser
When the disassembly shows no vector instructions, the cause is nearly always one of a handful of patterns, and each has a mechanical rewrite.
An accumulator with a dependency chain. A running sum forces each iteration to wait for the last, which prevents vectorisation unless the compiler is allowed to reassociate. Splitting into several partial sums removes the chain explicitly:
let mut s0 = 0.0; let mut s1 = 0.0; let mut s2 = 0.0; let mut s3 = 0.0;
for c in data.chunks_exact(4) {
s0 += c[0]; s1 += c[1]; s2 += c[2]; s3 += c[3];
}
let total = s0 + s1 + s2 + s3;
Note that this changes the summation order and therefore the last bits of a floating-point result — which is the same reason the compiler would not do it unasked.
An index the compiler cannot bound. Indexing with a value derived from the data, or from a length it cannot prove, leaves a bounds check in the loop and blocks the transform. Slicing to a known length first usually removes both:
let a = &a[..n];
let b = &b[..n];
for i in 0..n { out[i] = a[i] * b[i]; }
A function call in the body. If it inlines, fine; if it does not, the loop stays scalar. Marking
small helpers #[inline] costs nothing and occasionally makes the difference.
Mixed types forcing conversions. A loop that reads u8 and accumulates into f32 needs widening
and conversion per element, which the vectoriser handles far less readily than uniform arithmetic.
Converting once into a typed buffer, then running a uniform loop, is often faster overall despite the
extra pass.
Reading the remarks output
When the rewrites do not help, LLVM will tell you why if asked. The vectoriser emits remarks explaining its decisions, and although the Wasm backend’s output is less polished than a native target’s, the reasons are the same:
RUSTFLAGS="-C target-feature=+simd128 -C remark=loop-vectorize" \
cargo build --release --target wasm32-unknown-unknown 2>&1 | grep -iE 'vector|remark' | head -20
Typical messages name the obstacle directly — a loop not vectorised because of a control-flow dependency, an unsafe dependent memory operation, or a cost model that decided it was not worth it. That last one is worth knowing about: the compiler sometimes vectorises correctly and then declines because it estimates no gain, which is a different situation from being unable to. If you disagree with that estimate, intrinsics are the way to overrule it.
Frequently Asked Questions
Do I need #[target_feature] on autovectorised functions?
No — that attribute is for functions containing intrinsics. With -C target-feature=+simd128 the whole
module is compiled with SIMD available, and the vectoriser uses it wherever it can.
Will an autovectorised module run on an engine without SIMD? No. The instructions are in the binary regardless of how they got there, so the module fails validation. Build a second artifact without the flag and detect at load time.
Is there a way to force vectorisation? Not reliably. Pragmas and hints exist for native targets with mixed support in the Wasm backend. If a loop matters enough to force, it matters enough to write with intrinsics.
Does the optimisation level matter beyond enabling it?
Yes — the vectoriser runs at -O2 and above, and opt-level = "z" can disable transformations that
increase code size, including some vectorisation. A size-first profile and a vectorised kernel are
partly in tension, which is worth knowing before concluding the flag did nothing.
Related
- Wasm SIMD & vectorized computation — the surrounding feature and deployment.
- Writing v128 SIMD intrinsics in Rust — the fallback when the compiler declines.
- Benchmarking SIMD vs scalar Wasm kernels — measuring what you gained.
- Decoding Wasm opcodes for debugging — reading the disassembly this guide relies on.
← Back to Wasm SIMD & Vectorized Computation