Auto-vectorization in Rust: how to see it (and when it fails)

Photo by Simon Berger
Auto-vectorization lets rustc turn scalar operations into SIMD instructions without explicit SIMD in your code. But a loop can look like a good candidate and still compile to scalar instructions. LLVM has to prove that the transformation is safe, and its cost model has to favor the vector version.
What LLVM has to prove
rustc lowers Rust into LLVM IR, where LLVM’s vectorizers try to replace scalar code with vector code. On x86, this often means emitting packed SIMD instructions like mulps/vmulps instead of scalar mulss.
Two passes do most of this work:
- The loop vectorizer targets loops.
- The SLP vectorizer packs independent scalar instructions (often within a basic block) into vector operations.
For a loop, LLVM usually needs to establish three things:
- The operations can run in parallel without changing the result. Independent iterations are the simplest case; LLVM also recognizes some dependencies, such as reductions.
- Memory accesses don’t introduce conflicting reads and writes. LLVM can sometimes establish this with runtime alias checks.
- Its cost model favors the vector version.
The target CPU matters too. The easiest way to see what LLVM actually did is to inspect the generated asm/IR. These two loops show why their shape matters.
Case 1: loop-carried dependency
Each multiplication in this loop uses the result of the previous iteration, and every intermediate product is stored. This loop-carried dependency makes it a prefix scan. Reassociating floating-point multiplication can change rounding, so LLVM can’t simply split this computation into independent lanes:
pub fn prefix_product(a: &[f32], b: &mut [f32]) {
let mut acc = 1.0;
for (x, y) in a.iter().zip(b.iter_mut()) {
acc *= *x; // depends on the previous iteration
*y = acc;
}
}
Paste this into Compiler Explorer, select an x86-64 Rust compiler, and add -C opt-level=3. You’ll typically see scalar operations (mulss/movss, or their AVX equivalents) rather than packed SIMD (mulps/vmulps). The exact output depends on the compiler version and target CPU.
In LLVM IR, shufflevector isn’t a reliable indicator: many vectorized loops don’t need it. Look for:
- Vector types like
<4 x float>/<8 x float> - Vector ops like
fmul <4 x float> ... - Widened vector loads/stores
Case 2: independent iterations
Now compare it with a loop where each iteration is independent:
pub fn mul_arrays(a: &mut [f32], b: &[f32], c: f32) {
for (el_1, el_2) in a.iter_mut().zip(b.iter()) {
*el_1 = el_2 * c;
}
}
Here, each iteration performs the same multiplication without using the previous iteration’s result. With no loop-carried dependency, LLVM has a much better candidate for vectorization.
Both examples use zip, so they stop at the shorter slice. Any remaining elements in the output slice stay unchanged.
LLVM still has to prove that the vector loop preserves the original semantics. If it vectorizes on x86, you’ll usually see packed SIMD instructions like mulps/vmulps plus vector loads/stores. In LLVM IR, look for the vector types and operations shown above.
Benchmarking it correctly
These loops perform different computations, so comparing their timings won’t tell you what vectorization changed. To measure that, run the same function with vectorization enabled and disabled.
LLVM’s performance documentation includes examples of auto-vectorization improving speed. For your own code, measure it. As Linus Torvalds put it, “Talk is cheap. Show me the code.”
Rust exposes flags to disable LLVM’s vectorizers:
-C no-vectorize-loops(disable Loop Vectorizer)-C no-vectorize-slp(disable SLP Vectorizer)
That lets you run the same benchmark twice:
# Vectorization on
RUSTFLAGS="-C opt-level=3 -C target-cpu=native" cargo bench
# Vectorization off (both loop + SLP)
RUSTFLAGS="-C opt-level=3 -C target-cpu=native -C no-vectorize-loops -C no-vectorize-slp" cargo bench
Helping LLVM vectorize
To make vectorization more likely:
-
Keep the loop shape simple: predictable bounds and straightforward indexing help LLVM prove safety.
-
Avoid unnecessary loop-carried dependencies. LLVM recognizes reductions, but floating-point sums and products need particular care because reassociation can change their results.
-
Help alias analysis: prefer separate input/output slices (like
(&[T], &mut [T])) so LLVM can assume they don’t overlap. -
Compile for your CPU:
-C target-cpu=nativecan enable wider SIMD and better codegen. A binary built this way may require instructions unavailable on another machine. -
Consider explicit SIMD if measurements justify it.
std::simdis still a nightly-only experimental API. Stable Rust offers architecture-specific intrinsics throughstd::arch; you must ensure the target CPU supports the instructions you call.
Keep the two checks separate: the asm/IR tells you whether the loop vectorized; running the same benchmark with vectorizers enabled and disabled tells you what that changed.