Home › Hardware & OS › CPU
algorithm low

SIMD programming

Problem How do you speed up hot loops over arrays by processing several values per instruction, across different CPU types, while still running safely on machines that lack the newer instructions? This guide covers when and how to do it.

Process 4-16 lanes per instruction with AVX2, AVX-512 or NEON via Rust std::arch, with runtime dispatch and a portable fallback.

simdavx2avx-512neonvectorizationintrinsics
Overview

One SIMD instruction applies the same operation to a whole register of lanes: 4 f32 in a 128-bit NEON register, 8 in AVX2, 16 in AVX-512. Compilers auto-vectorize simple loops. Reductions, shuffles, masks and table lookups usually need intrinsics. Write one kernel per instruction set, pick one at startup, and keep a scalar fallback that is easy to vectorize.

Key facts

Use when

  • Hot loops over contiguous arrays: dot products, distances, filters, parsing, checksums, bitboard and NN inference kernels.

Avoid when

  • The loop is memory-bound (roofline): SIMD won't beat bandwidth.
  • Data is pointer-chased or heavily branchy per element. Restructure it first (SoA, batching).

Deep dive

How it works

In: arrays of f32, u8 or similar, processed in chunks of 4, 8 or 16 lanes · Out: lane-wise results (or one reduced value) · State: none between calls, apart from a cached CPU-feature check for dispatch · Cost: AVX2 peak 32 f32 flops per cycle per core with 2 FMA ports.

  1. Lanes, not loops: load a vector, compute lane-wise, store. Cross-lane work (horizontal sums, shuffles, compaction) is the expensive part. Keep it out of the inner loop.
  2. Dependency chains: an FMA has 4-cycle latency on current x86 cores. One accumulator gives one FMA per 4 cycles. Use k independent accumulators and add them at the end.
  3. Dispatch: compile the kernel with #[target_feature(enable = "...")] so LLVM may emit those instructions inside it. Check with is_x86_feature_detected! before calling it. Calling it on a CPU without the feature is undefined behavior, hence unsafe.
  4. Tails: finish the last n % width elements with scalar code, a masked load (AVX-512 _mm512_maskz_loadu_ps, SVE predicates), or a padded buffer.
  5. Compile-time alternative: -C target-cpu=x86-64-v3 (AVX2, FMA, BMI2) or x86-64-v4 (AVX-512 F/BW/CD/DQ/VL) makes the whole binary use them, and cfg!(target_feature = "avx2") becomes true. The binary then faults on older CPUs.

Decision table

SituationChoiceWhy
Simple elementwise loop over slicesPlain Rust with chunks_exact, check the asmLLVM vectorizes it. Intrinsics add risk for no gain
Float reduction (sum, dot)Several accumulators, explicitlyLLVM may not reassociate float adds, so one accumulator stays scalar
Ship one binary to unknown x86Runtime dispatch, cached fn pointerMost Intel client CPUs from Alder Lake on lack AVX-512
Servers you control-C target-cpu=x86-64-v3 or nativeWhole-program vectorization, no dispatch cost
Apple M-series / GravitonNEON intrinsics (baseline)No detection needed. SVE only on some server cores
Byte search, parsing, compactionCompare → movemask (x86) / narrowing shift (NEON)See SIMD string search
Portable code across ISAsstd::simd (nightly) or crates wide, pulp, multiversionFewer hand-written variants, slightly less control

Code

Dot product with AVX-512, AVX2+FMA, NEON and scalar kernels, selected once at runtime. Checked against an f64 reference for lengths 0-1M. Builds for aarch64-apple-darwin and x86_64-linux.

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

/// Portable fallback. 8 independent sums let LLVM vectorize (it may not reorder
/// a single float sum) and keep several FP adds in flight.
pub fn dot_scalar(a: &[f32], b: &[f32]) -> f32 {
    let mut acc = [0.0f32; 8];
    let (ca, cb) = (a.chunks_exact(8), b.chunks_exact(8));
    let tail: f32 = ca.remainder().iter().zip(cb.remainder()).map(|(x, y)| x * y).sum();
    for (x, y) in ca.zip(cb) {
        for k in 0..8 { acc[k] += x[k] * y[k]; }
    }
    acc.iter().sum::<f32>() + tail
}

#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2,fma")]
unsafe fn dot_avx2(a: &[f32], b: &[f32]) -> f32 {
    unsafe {
        let (n, pa, pb) = (a.len(), a.as_ptr(), b.as_ptr());
        let mut acc = [_mm256_setzero_ps(); 4]; // 4 chains hide the 4-cycle FMA latency
        let mut i = 0;
        while i + 32 <= n {
            for k in 0..4 {
                let x = _mm256_loadu_ps(pa.add(i + 8 * k)); // unaligned load: same speed if no line split
                let y = _mm256_loadu_ps(pb.add(i + 8 * k));
                acc[k] = _mm256_fmadd_ps(x, y, acc[k]);
            }
            i += 32;
        }
        let v = _mm256_add_ps(_mm256_add_ps(acc[0], acc[1]), _mm256_add_ps(acc[2], acc[3]));
        // Horizontal sum 8 -> 4 -> 2 -> 1 lanes.
        let s = _mm_add_ps(_mm256_castps256_ps128(v), _mm256_extractf128_ps::<1>(v));
        let s = _mm_add_ps(s, _mm_movehl_ps(s, s));
        let s = _mm_add_ss(s, _mm_shuffle_ps::<0b01>(s, s));
        _mm_cvtss_f32(s) + dot_scalar(&a[i..], &b[i..])
    }
}

#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx512f")]
unsafe fn dot_avx512(a: &[f32], b: &[f32]) -> f32 {
    unsafe {
        let (n, pa, pb) = (a.len(), a.as_ptr(), b.as_ptr());
        let (mut acc0, mut acc1) = (_mm512_setzero_ps(), _mm512_setzero_ps());
        let mut i = 0;
        while i + 32 <= n {
            acc0 = _mm512_fmadd_ps(_mm512_loadu_ps(pa.add(i)), _mm512_loadu_ps(pb.add(i)), acc0);
            acc1 = _mm512_fmadd_ps(_mm512_loadu_ps(pa.add(i + 16)), _mm512_loadu_ps(pb.add(i + 16)), acc1);
            i += 32;
        }
        while i < n {
            // Masked tail: lanes past the end are neither loaded nor faulted.
            let left = (n - i).min(16);
            let m: __mmask16 = if left == 16 { 0xFFFF } else { (1u16 << left) - 1 };
            let x = _mm512_maskz_loadu_ps(m, pa.add(i));
            let y = _mm512_maskz_loadu_ps(m, pb.add(i));
            acc0 = _mm512_fmadd_ps(x, y, acc0);
            i += 16;
        }
        _mm512_reduce_add_ps(_mm512_add_ps(acc0, acc1))
    }
}

#[cfg(target_arch = "aarch64")]
fn dot_neon(a: &[f32], b: &[f32]) -> f32 {
    // NEON is part of the AArch64 baseline: no runtime detection needed.
    unsafe {
        let (n, pa, pb) = (a.len(), a.as_ptr(), b.as_ptr());
        let mut acc = [vdupq_n_f32(0.0); 4];
        let mut i = 0;
        while i + 16 <= n {
            for k in 0..4 {
                // vfmaq_f32(acc, x, y) = acc + x * y, fused
                acc[k] = vfmaq_f32(acc[k], vld1q_f32(pa.add(i + 4 * k)), vld1q_f32(pb.add(i + 4 * k)));
            }
            i += 16;
        }
        let v = vaddq_f32(vaddq_f32(acc[0], acc[1]), vaddq_f32(acc[2], acc[3]));
        vaddvq_f32(v) + dot_scalar(&a[i..], &b[i..]) // vaddvq: horizontal add of 4 lanes
    }
}

/// Pick the best kernel once; call through the pointer in hot loops.
pub fn select_dot() -> fn(&[f32], &[f32]) -> f32 {
    #[cfg(target_arch = "x86_64")]
    {
        if is_x86_feature_detected!("avx512f") {
            return |a, b| unsafe { dot_avx512(a, b) }; // safe: feature checked above
        }
        if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma") {
            return |a, b| unsafe { dot_avx2(a, b) };
        }
    }
    #[cfg(target_arch = "aarch64")]
    return dot_neon;
    #[allow(unreachable_code)]
    dot_scalar
}

pub fn dot(a: &[f32], b: &[f32]) -> f32 {
    assert_eq!(a.len(), b.len()); // the kernels read b[..a.len()] unchecked
    select_dot()(a, b)
}

On an M4 Pro (data in L1), the NEON kernel does about 28 GFLOP/s and the auto-vectorized scalar fallback about 18.

Complexity and constants

  • Peak per core per cycle (f32): AVX2 with 2 FMA ports = 2 × 8 × 2 = 32 flops. AVX-512 with 2 FMA ports = 64.
  • Loads: 2-3 per cycle on current x86. A dot product needs 2 loads per FMA, so it is load-bound at L1 before it is FMA-bound.
  • Lane-crossing shuffles (vpermps, vpermd) cost order of 3 cycles latency. In-lane shuffles cost 1. Gathers are order of one element per cycle: not a free random load.
  • AVX-512 frequency: Skylake-SP lowers its clock under heavy 512-bit code. Ice Lake and later server parts much less. Zen 4 runs 512-bit ops on 256-bit units without a clock drop.

Efficiency

  • Cost: peak per core and cycle is 32 f32 flops with AVX2 and 2 FMA ports, 64 with AVX-512. A dot product needs 2 loads per FMA, so it is load-bound at L1 (2-3 loads per cycle) before FMA-bound. Bound: FMA ports or L1 loads in cache, memory bandwidth beyond it (roofline).
  • Levers:
    • k independent accumulators — one FMA per 4 cycles → near 2 per cycle with about 8 accumulators — float reductions
    • -C target-cpu=x86-64-v3 or native — whole-program AVX2/FMA, no dispatch cost — servers you control
    • runtime dispatch with is_x86_feature_detected! — one binary reaches AVX2 or AVX-512 — unknown x86 fleets; many Intel client CPUs lack AVX-512
    • compare plus movemask on bytes — gigabytes per second for JSON parsing (Langdale and Lemire) — parsing, search
  • Naive → standard → state of the art:
  • naive: scalar loop, one accumulator · 0.5 flop per cycle (2 flops per 4-cycle FMA)
  • standard: AVX2 with 8 accumulators · up to 32 flops per cycle per core
  • state of the art: AVX-512 with mask tails and runtime dispatch · up to 64 flops per cycle, capped by L1 loads or memory bandwidth

Implementation notes

  • Rust: x86 SSE/AVX/AVX2/FMA intrinsics are stable, AArch64 NEON since 1.59, AVX-512 since 1.89. std::simd is still nightly-only.
  • Inside a #[target_feature] function, recent Rust treats intrinsics without pointer arguments as safe. Loads and stores through raw pointers stay unsafe.
  • is_x86_feature_detected! caches its result, but resolve the kernel once per call site anyway (fn pointer or OnceLock). Branching per element prevents inlining.
  • Unaligned loads (loadu, vld1q) are as fast as aligned ones unless they cross a cache line. Align hot buffers to 64 B (128 B on Apple) to avoid splits.
  • Mixing legacy SSE and AVX encodings costs a state transition. Compilers insert vzeroupper. Hand-written asm must do it too.
  • AArch64 NEON idioms: vaddvq_f32 (horizontal add), vqtbl1q_u8 (16-byte table lookup), vshrn_n_u16 + vget_lane_u64 as a movemask substitute.
  • Integer dot products: AVX-512 VNNI vpdpbusd and NEON sdot/udot (vdotq_s32, needs the dotprod feature) give int8 → int32 in one instruction. They are the basis of quantized inference.

Pitfalls

  • Different summation order gives different float results per ISA. Tests need a tolerance, and outputs must not depend on bit-exact sums (lockstep, hashes).
  • Reading past the slice end in the tail "because it's in the same page": UB in Rust, and a real fault at a page boundary.
  • Forgetting fma when enabling avx2: they are separate CPUID bits.
  • Measuring with data that fits in L1, then deploying on DRAM-sized arrays.
  • Denormals: some x86 operations are order of 100× slower on subnormal inputs. Flush them to zero (MXCSR FTZ/DAZ) only if the semantics allow it.

Variants and alternatives

SVE/SVE2vector-length-agnostic with predicates. Graviton3 has 256-bit SVE, Graviton4 128-bit SVE2. Apple cores up to M3 have NEON only. M4 adds SME.
GPUfor kernels with large data parallelism and enough arithmetic intensity.
SWAR64-bit integer tricks (for example a zero-byte test) where SIMD isn't available.

In production

simdjson, Hyperscan, Rust's memchr crate (SSE2/AVX2/NEON), Stockfish NNUE (AVX2/AVX-512/NEON paths), ClickHouse, and every BLAS (OpenBLAS, MKL, Apple Accelerate).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
wideRustLokathor/widecargo add wide————portable SIMD types on stable Rust
pulpRustsarah-quinones/pulpcargo add pulp————safe abstraction with runtime dispatch over SIMD levels
multiversionRustcalebzulawski/multiversioncargo add multiversion————function multiversioning with runtime selection
HighwayC++google/highwaybrew install highway————portable SIMD with dynamic dispatch (x86, Arm, RISC-V)
SIMDeCsimd-everywhere/simdebrew install simde————portable implementations of x86 and Arm intrinsics

Sources

  • Intel, Intel Intrinsics Guide.
  • Arm, Arm C Language Extensions (ACLE) and Neon Intrinsics Reference. ACLE
  • Agner Fog, Instruction tables and Optimizing software in C++. tables · optimizing
  • Geoff Langdale, Daniel Lemire, Parsing Gigabytes of JSON per Second, VLDB Journal, 2019. paper · code
  • Wojciech Muła, Nathan Kurz, Daniel Lemire, Faster Population Counts Using AVX2 Instructions, The Computer Journal, 2018. paper

Used by

Adaptive radix tree (ART)Databases need an in-memory ordered index that supports range and prefix queries at nearly hash-table speed, without wasting memory on sparse nodes.
auto vectorization · plannedLoop and SLP vectorization: dependence tests, cost model, turning scalar code into SIMD
Bit packing and PFORLarge arrays of small integers (posting lists, column chunks, dictionary codes) waste space at full width, and bulk decoding must be fast. This stores them compactly while tolerating a few outliers.CRC32C checksumsStorage engines, logs and network protocols must detect accidental corruption such as bit flips and torn writes in blocks, records and frames, using a check that is fast enough not to slow the data path.
dynamic time warping · plannedDTW alignment of time series and trajectories of different speed
erasure coding · plannedReed-Solomon k+m coding to survive m lost shards at lower overhead than replication
fft convolution · plannedOverlap-add and uniformly partitioned convolution for long FIR filters and reverb
HNSWApplications that must find the vectors most similar to a query (embeddings search, RAG, recommendations) among millions of in-memory vectors, while still accepting new inserts, need fast approximate nearest-neighbour lookup.IVF-PQVector search over hundreds of millions to billions of embeddings, where full vectors do not fit in memory, needs compact storage and fast approximate nearest-neighbour lookup, on CPU or GPU.
ldpc codes · plannedLow-density parity-check codes with soft-decision min-sum decoding, used in NAND flash
motion estimation · plannedBlock matching and sub-pixel motion search for inter prediction
Multiple sequence alignmentMany related sequences (gene families, text versions, log lines, recognizer outputs) must be laid out so that corresponding items share a column, with gaps for insertions and deletions, to compare all of them at once.NNUE incremental evaluationA search program scoring millions of states per second per core, where each state differs from the last by a few changes, wants the strength of a neural-network evaluation at roughly the cost of a handcrafted one, on an ordinary CPU.
pair hmm · plannedPair hidden Markov model for read-haplotype likelihoods, vectorized forward algorithm
profile hmm search · plannedProfile HMMs (HMMER, HHblits) for sensitive homology search
Roofline modelAn engineer optimising a compute-heavy kernel needs to know whether it is limited by arithmetic or by memory bandwidth, and how much faster it could possibly get, before deciding what to work on.
sample rate conversion · plannedPolyphase and windowed-sinc resampling; asynchronous rate conversion for clock drift
SIMD substring searchTools that scan large buffers for a byte or substring (grep-like scans, log and protocol parsing, delimiter search) want to find matches much faster than checking one byte at a time.Smith-Waterman local alignmentTwo long sequences share only one stretch of similarity, surrounded by unrelated parts, with substitutions and inserted or deleted items inside it. A search, mapping or matching system must find that best stretch and score it.
stabilizer simulation · plannedSimulate Clifford circuits in polynomial time with stabilizer tableaus (Gottesman-Knill, Stim)
state vector simulation · plannedSimulate qubits as a 2^n amplitude vector with gate kernels, SIMD and GPU
Stream VByteDecoding billions of variable-length integers, as in search index posting lists, is slowed by branching on each byte's length. This makes bulk decoding of mixed-size integers fast.
vector packet processing · plannedProcess packets in vectors through a node graph to amortise i-cache and dispatch cost
wavelet transform · plannedDiscrete wavelet transforms (Haar, CDF 9/7 lifting) for compression and multiscale analysis

Systems (56):

Alternatives and related