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.

What the vectoriser needs to see A countable loop over contiguous memory with a straight-line body and no aliasing between input and output transforms cleanly. A loop with an early exit, a data-dependent index, or a call the optimiser cannot inline stays scalar. vectorises trip count known before the loop starts contiguous, unit-stride access no branches inside the body no aliasing between input and output iterators over one slice are the easy case stays scalar break or return inside the loop index computed from the data (gather) a call the optimiser will not inline two raw pointers that might overlap each of these blocks the transform outright

Prerequisites

  • [ ] Rust with RUSTFLAGS="-C target-feature=+simd128", or Emscripten with -msimd128
  • [ ] Optimisation on — the vectoriser does not run at -O0 or in a debug profile
  • [ ] wasm-objdump to 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.

Vector body plus scalar tail For an array of 1002 float elements the compiler emits a vector loop covering 1000 of them in 250 iterations of four lanes, and a scalar loop for the final two. Both appear in the disassembly, which is why a scalar instruction is not evidence that vectorisation failed. vector body — 250 iterations × 4 lanes = 1000 elements tail: 2 The tail exists because 1002 is not a multiple of the lane count — it is generated automatically and costs nothing on large inputs. On small inputs it is most of the work, which is why vectorisation only pays above a few thousand elements. Do not read a scalar opcode in the disassembly as failure: check whether a vector opcode is present too.

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.

Branchless bodies vectorise A conditional inside the loop body forces the compiler to keep the loop scalar, because lanes would need to take different paths. Expressing the same logic as a min, max or select gives every lane the same instruction sequence. if *x < 0.0 { *x = 0.0 } lanes would need different paths stays scalar *x = x.max(0.0) every lane runs the same instruction vectorises to f32x4.max The same rewrite works for clamping, thresholding and saturating: replace the branch with min, max, or a comparison-and-select.

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.

← Back to Wasm SIMD & Vectorized Computation