Skip to content
← All articles

SIMD in Rust: what is stable, what is not

The accurate 2026 map of the three tiers — auto-vectorisation, core::arch intrinsics, and the portable_simd that is still nightly — and why tier one is where the wins are.

Learners hear “Rust has SIMD”, go looking for std::simd, find it does not exist on stable, and conclude the language is behind. The real picture is three distinct tiers with three very different stability stories, and the tier that gets most people most of the win is the one nobody calls SIMD.

Tier 1: auto-vectorisation

Stable, portable, safe, free, and where you should start.

LLVM will emit vector instructions for a loop it can prove is safe to vectorise. No annotations, no unsafe, no per-architecture code — the same source produces AVX2 on a modern x86 and NEON on Apple Silicon, and it will produce AVX-512 on a machine that has it if you ask for it at build time.

The catch is the proof obligation. LLVM vectorises a loop when it can establish that the iterations are independent, the trip count is knowable, the memory accesses do not overlap, and — for reductions — that the operation is associative.

That last one is why the previous item exists. Integer + is associative and LLVM splits integer sums into lanes without being asked. IEEE-754 float + is not, Rust deliberately does not enable fast-math, and so s += v[i] over f64 compiles to a strictly serial dependency chain until you write the eight-lane version.

The measured payoff, from that item, on an f32 dot product over a million elements:

time
naive index loop 0.743 ms
zip 0.484 ms
eight-lane accumulator 0.0775 ms

9.6×, in safe portable code. That is the headline of this article: the thing that gives most people most of the win is writing loops LLVM can vectorise.

💡You have a loop that "should" vectorise and does not. What are the usual causes, and how do you find out which one you have? click to reveal

In rough order of frequency:

A float reduction. sum += a[i] * b[i] cannot be reassociated, so it stays serial. The fix is multiple accumulators, and it is the biggest single win available.

Possible aliasing. If two &mut [f32] could overlap, the compiler must assume every write invalidates every read. In Rust this is usually handled for you — &mut is noalias — but raw pointers, UnsafeCell, or slices derived from the same Vec through unusual routes can defeat it. Splitting with split_at_mut makes disjointness a type-level fact.

An early exit. A break, a ?, a bounds check that could panic, or an unwrap in the loop body all mean the loop might stop partway, so the compiler cannot execute four iterations at once speculatively. Hoisting the check out — assert_eq!(a.len(), b.len()) before the loop — is exactly the recipe from the bounds-checks item, and it is the same mechanism.

A non-vectorisable operation in the body. A function call that did not inline, a division by a runtime value, a match with many arms, integer overflow checks in a debug build.

Unknown or awkward trip count. while loops with data-dependent exits, or lengths the compiler cannot bound.

How to find out: cargo asm or objdump -d and look for vector registers (ymm/zmm on x86, v0v31 with .4s/.2d suffixes on aarch64). LLVM’s -C remark=loop-vectorize will print, per loop, whether it vectorised and why not — that is usually faster than reading assembly.

Tier 2: core::arch intrinsics

Stable since 1.27, and unsafe, per-architecture, and gated.

#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;

#[target_feature(enable = "avx2")]
unsafe fn sum_avx2(v: &[f32]) -> f32 { /* _mm256_loadu_ps, _mm256_add_ps, ... */ }

pub fn sum(v: &[f32]) -> f32 {
    #[cfg(target_arch = "x86_64")]
    if is_x86_feature_detected!("avx2") {
        // SAFETY: guarded by the runtime feature check above.
        return unsafe { sum_avx2(v) };
    }
    sum_scalar(v)
}

Everything in that snippet is stable. Four details in it are the ones that bite:

  • Calling a #[target_feature] function on a CPU that lacks the feature is undefined behaviour, not a fault. You do not get an illegal-instruction trap you can catch; you get UB. That is why the function is unsafe and why the runtime check is mandatory.
  • is_x86_feature_detected! lives in std, not core. no_std libraries cannot use it, which is a genuine papercut for library authors and the reason several crates carry their own CPUID code.
  • #[target_feature] and #[inline(always)] are mutually exclusive (the Reference says so). You cannot force-inline a feature-gated function into a caller compiled for a weaker target.
  • You must write the scalar fallback anyway, so the intrinsic path is additional code, not replacement code — typically 3–5× the source for the same operation, per architecture.

There is also the compile-time route: -C target-cpu=native or -C target-feature=+avx2 lets LLVM use those instructions everywhere, including in auto-vectorised loops, with no unsafe at all. That is the right answer when you control the deployment target, and the wrong answer when you ship binaries to users — the program will die on the first machine without the feature.

cfg_select! (stable in 1.95) is now the std way to branch on cfg conditions in expression position, replacing the cfg-if crate for this pattern.

Tier 3: std::simd / portable_simd

Still nightly-only in 2026.

#![feature(portable_simd)]
use std::simd::f32x8;

This is what people are looking for: portable fixed-width vector types with arithmetic operators, lane-wise comparisons, masks, gather and scatter — safe, no unsafe, compiled to whatever the target has. It has been “coming soon” for years. It is genuinely good and it is genuinely not on stable, and you should not build a stable-Rust design around it.

The usual stable-and-portable answer in the ecosystem is the wide crate, which gives you f32x8-style types on stable by wrapping intrinsics per architecture. It is unavailable here — this course compiles one std-only file — but it is the honest recommendation for real projects, and it deserves saying rather than implying that core::arch is the only option.

💡Given all three tiers, how would you actually approach making a numeric kernel fast in a stable-Rust project you ship to users? click to reveal

In this order, stopping as soon as it is fast enough.

1. Fix the data layout. Contiguous, flat, small element types. A vectorised loop over a Vec<Vec<f32>> is not going to happen; a scalar loop over a flat Vec<f32> might already be fast.

2. Write the loop so LLVM can vectorise it. Multiple accumulators for float reductions, zip or chunks_exact rather than indexing, one assert up front to relate the lengths, no early exits. Then check the assembly, or the loop-vectorize remarks, to confirm it happened. This step is free, portable and safe, and it is where the 9.6× above came from.

3. Measure. If it is fast enough, stop. Most kernels are, and the code you have is code anyone can maintain.

4. If it is still the bottleneck, reach for wide or a similar stable-portable crate before writing intrinsics. You get most of tier 2’s benefit with none of the per-architecture duplication and none of the unsafe.

5. Only then write core::arch intrinsics, for the one architecture that matters most, behind runtime feature detection, with the scalar fallback still present and still tested. Budget for maintaining it: intrinsics code is written once and read for years, and the next person will not know why _mm256_permute2f128_ps is there.

Note what is not on the list: -C target-cpu=native for shipped binaries. It is excellent for a benchmark on your own machine and a support incident waiting to happen anywhere else.

Why this is an article and not a problem

core::arch intrinsics compile perfectly well in one std-only file. But your submission runs on your machine, and this course’s audience is split between Apple Silicon and x86-64. An intrinsics problem would be unsolvable for half of them, and the correct portable shape — runtime detection with a scalar fallback — is untestable, because on any given machine only one branch runs and the grader cannot tell which.

The reframing is the deliverable, and it is worth repeating: most of the win, for most people, comes from writing loops LLVM can vectorise. That is tier one, it is portable, it is safe, and the previous item graded it.