Skip to content
C++
Library
since C++26
Advanced

std::simd — Portable SIMD (C++26)

std::simd<T, Abi>: type-safe portable SIMD in C++26 (P1928) — write vectorized code once, compile to SSE/AVX/NEON/SVE automatically without intrinsics or platform ifdefs.

std::simd<T, Abi>since C++26

std::simd<T, Abi> is a portable, type-safe abstraction over platform SIMD registers in <simd>. Write arithmetic on width-agnostic vector types and the compiler maps it to the best available instruction set (SSE2, AVX2, AVX-512, NEON, SVE) — no intrinsics, no #ifdef.

Why std::simd?

Before C++26, portable vectorization required one of:

ApproachProblem
Raw intrinsics (_mm256_add_ps)Platform-specific, unreadable, error-prone
Auto-vectorizationUnpredictable, breaks silently with aliasing
Highway / xsimd / VCLGood but non-standard, divergent APIs
OpenMP SIMD pragmasPortable but limited expressiveness

std::simd standardizes the "write once, vectorize everywhere" model: the same source compiles to SSE2 on x86-baseline, AVX2 with -mavx2, NEON on ARM, and SVE on AArch64.

std::experimental vs std:: — Handling Both

P1928 shipped in C++23 drafts under std::experimental::simd and was merged into std:: for C++26. Until compiler support fully lands, guard the namespace carefully:

cpp
// C++23 (experimental) / C++26
#if defined(__cpp_lib_simd)           // C++26 feature-test macro
  #include <simd>
  namespace stdx = std;
#elif defined(__cpp_lib_experimental_simd)  // C++23 draft
  #include <experimental/simd>
  namespace stdx = std::experimental;
#else
  #error "std::simd not available — need GCC 13+, Clang 17+, or MSVC 19.40+"
#endif

// Usage is identical regardless of namespace:
stdx::simd<float> v(1.f);

This pattern lets production code compile on GCC 13 (which ships std::experimental::simd in libstdc++) and on compilers that implement C++26's <simd> header without source changes.

Basic Types

cpp
#include <simd>
// C++26 — use the #if guard above for C++23 compatibility

// Fixed-width: always 4 floats regardless of hardware
std::simd<float, std::simd_abi::fixed_size<4>> v4;

// Native width: optimal register width for the target
std::simd<float> vn;   // 4 floats on SSE2, 8 on AVX2, 16 on AVX-512

// Common type aliases
using float4  = std::simd<float,  std::simd_abi::fixed_size<4>>;
using double2 = std::simd<double, std::simd_abi::fixed_size<2>>;
using int8v   = std::simd<int,    std::simd_abi::native<int>>;

// Query lane count at compile time
constexpr auto W = std::simd<float>::size();  // 4, 8, or 16 depending on target

Construction and Load

cpp
#include <simd>

void example() {
    // Broadcast scalar
    std::simd<float> v1(3.14f);         // all lanes = 3.14

    // From generator (index → value)
    std::simd<int> v2([](int i) { return i * 2; });  // 0, 2, 4, 6, ...

    // Load from contiguous memory
    float buf[8] = {1,2,3,4,5,6,7,8};
    std::simd<float> v3(buf, std::vector_aligned);   // aligned load
    std::simd<float> v4(buf, std::element_aligned);  // unaligned load
}

Arithmetic

std::simd overloads all arithmetic operators — they operate lane-wise:

cpp
#include <simd>
#include <cmath>

void vector_ops() {
    std::simd<float> a(1.f), b(2.f), c(3.f);

    auto sum  = a + b;          // lane-wise add
    auto prod = a * b + c;      // FMA hint
    auto neg  = -a;             // negate
    auto abs_ = std::abs(a);    // std:: math functions are overloaded

    // Horizontal reduction
    float total = std::reduce(a + b);   // sum all lanes

    // Comparison → mask
    auto mask = a < b;   // std::simd_mask<float>
}

Masks and Conditional Operations

Masking is the SIMD equivalent of branching. Rather than taking different code paths per lane, you compute both outcomes and select between them with a mask. This keeps the pipeline full and avoids branch mispredictions.

Producing masks

Comparison operators on std::simd<T> return std::simd_mask<T> — a bitmask with one bit (or lane) per element:

cpp
#include <simd>
// C++26

void mask_basics() {
    std::simd<float> a{1.f, -2.f, 3.f, -4.f};
    std::simd<float> zero(0.f);

    std::simd_mask<float> pos = a > zero;  // {true, false, true, false}
    std::simd_mask<float> neg = a < zero;  // {false, true, false, true}

    // Logical combinations
    auto either  = pos || neg;    // all true (no zeros here)
    auto neither = !pos && !neg;  // all false

    // Predicate queries
    bool any_neg = std::any_of(neg);    // true
    bool all_pos = std::all_of(pos);    // false
    bool none_z  = std::none_of(pos == neg); // true

    // Count true lanes
    int n_pos = std::popcount(pos);  // 2
}

Conditional blending with std::where()

std::where(mask, simd) produces a write-masked proxy. Use it to select between two vectors lane-by-lane — the SIMD equivalent of mask ? a : b:

cpp
#include <simd>
// C++26

void conditional_blend() {
    std::simd<float> a{1.f, -2.f,  3.f, -4.f};
    std::simd<float> b{9.f,  8.f,  7.f,  6.f};

    auto mask = a > std::simd<float>(0.f);   // {true, false, true, false}

    // Blend: take 'a' where mask is true, 'b' where false
    // Compiles to VBLENDVPS on AVX, BSL on NEON
    std::simd<float> result = std::experimental::where(mask, a, b);
    // result: {1, 8, 3, 6}

    // Masked store: only write lanes where mask is true
    float out[4] = {0.f, 0.f, 0.f, 0.f};
    std::experimental::where(mask, a).copy_to(out, std::element_aligned);
    // out: {1, 0, 3, 0}  — indices 1 and 3 unchanged
}

Best practice. Prefer std::where() over scalar if inside a loop. The latter prevents vectorization; where() maps directly to a blend or predicated move instruction.

Reducing a mask

std::reduce() on a mask counts true lanes (equivalent to popcount). This is useful for loop-exit conditions or counting matches without converting to integer SIMD:

cpp
#include <simd>
// C++26

int count_positives(std::span<const float> data) {
    using V = std::simd<float>;
    constexpr auto W = V::size();

    int count = 0;
    size_t i = 0;
    for (; i + W <= data.size(); i += W) {
        V v(data.data() + i, std::element_aligned);
        auto mask = v > V(0.f);
        count += std::popcount(mask);   // sum true bits this chunk
    }
    for (; i < data.size(); ++i)
        if (data[i] > 0.f) ++count;
    return count;
}

Memory Operations

Efficient memory access is often the difference between a SIMD loop that actually outperforms scalar code and one that does not. std::simd provides load/store overloads with explicit alignment tags.

Load and store tags

TagMeaningIntrinsic equivalent
std::element_alignedNo alignment guarantee (safe everywhere)_mm_loadu_ps
std::vector_alignedAligned to alignof(simd<T>) — one register width_mm_load_ps
std::overaligned<N>Aligned to N bytes (N must be power of two)_mm_load_ps with manual alignment
cpp
#include <simd>
// C++26

void load_store_demo(float* __restrict dst, const float* __restrict src, size_t n) {
    using V = std::simd<float>;
    constexpr auto W = V::size();

    size_t i = 0;
    for (; i + W <= n; i += W) {
        // Unaligned load (safe but potentially slower on old hardware)
        V v(src + i, std::element_aligned);

        v = v * V(2.f);

        // Unaligned store
        v.copy_to(dst + i, std::element_aligned);
    }
    // Scalar tail
    for (; i < n; ++i) dst[i] = src[i] * 2.f;
}

Using alignas for aligned loads

Aligned loads can be measurably faster on some microarchitectures, and are required when using std::vector_aligned. Use alignas to ensure your buffers meet the requirement:

cpp
#include <simd>
#include <array>
// C++26

void aligned_demo() {
    using V = std::simd<float>;

    // Align the buffer to the simd type's natural alignment
    alignas(V) float src[V::size() * 4];
    alignas(V) float dst[V::size() * 4];

    for (size_t i = 0; i < std::size(src); ++i) src[i] = static_cast<float>(i);

    for (size_t i = 0; i < std::size(src); i += V::size()) {
        // vector_aligned: asserts alignment; UB if the pointer is not aligned
        V v(src + i, std::vector_aligned);
        v = v + V(1.f);
        v.copy_to(dst + i, std::vector_aligned);
    }
}

Common pitfall. Passing std::vector_aligned to a pointer that is not aligned to alignof(std::simd<T>) is undefined behavior and produces a segfault (or silent data corruption on platforms without strict alignment). When in doubt, use std::element_aligned.

Processing an array in SIMD-width chunks

The canonical loop pattern handles both the vectorized body and the scalar tail:

cpp
#include <simd>
#include <span>
// C++26

void scale(std::span<float> data, float factor) {
    using V = std::simd<float>;
    constexpr std::size_t W = V::size();

    const V vfactor(factor);
    std::size_t i = 0;

    // Process W elements per iteration
    for (; i + W <= data.size(); i += W) {
        V v(data.data() + i, std::element_aligned);
        v *= vfactor;
        v.copy_to(data.data() + i, std::element_aligned);
    }

    // Scalar tail for remainder (< W elements)
    for (; i < data.size(); ++i)
        data[i] *= factor;
}

Reductions

Reductions collapse all lanes of a SIMD register into a single scalar. They are essential for dot products, norms, min/max searches, and sum-accumulations.

std::reduce(), std::hmax(), std::hmin()

cpp
#include <simd>
// C++26

void reduction_demo() {
    std::simd<float> v{1.f, 5.f, 3.f, 2.f};

    float sum  = std::reduce(v);              // 11.0  (default op: +)
    float prod = std::reduce(v, std::multiplies<>{});  // 30.0
    float mx   = std::hmax(v);               // 5.0
    float mn   = std::hmin(v);               // 1.0
}

Dot product using reductions

cpp
#include <simd>
#include <span>
// C++26

float dot_product(std::span<const float> a, std::span<const float> b) {
    using V = std::simd<float>;
    constexpr auto W = V::size();

    V acc(0.f);
    size_t i = 0;

    // Vectorized: accumulate W lanes at once
    for (; i + W <= a.size(); i += W) {
        V va(a.data() + i, std::element_aligned);
        V vb(b.data() + i, std::element_aligned);
        acc += va * vb;             // FMA-eligible
    }

    // Horizontal sum of all lanes
    float result = std::reduce(acc);

    // Scalar tail
    for (; i < a.size(); ++i) result += a[i] * b[i];

    return result;
}

This compiles to vdpps (SSE4.1), a multiply + vhaddps chain (AVX2), or vmlaq_f32 + vaddvq_f32 (NEON) — automatically selected by the compiler.

Best practice. Accumulate into a SIMD register across the loop, then call std::reduce() exactly once at the end. Calling std::reduce() inside the loop defeats vectorization and may generate a horizontal add per iteration.

Gather/Scatter Limitations

Gather loads (base[indices[i]]) and scatter stores exist in hardware (AVX2 _mm_i32gather_ps, AVX-512 scatter), but they are significantly slower than contiguous loads because:

  1. Cache lines must be fetched for every distinct index — worst case: N distinct cache lines for N lanes.
  2. Out-of-order execution cannot overlap memory operations for the same cache line.
  3. On many microarchitectures gather throughput is one or two lanes per cycle vs. a full register per cycle for aligned loads.

The current std::simd API supports gather via a generator constructor but there is no dedicated gather/scatter syntax — the semantic cost is visible:

cpp
#include <simd>
// C++26

// Gather: readable, but slow — each base[idx[i]] may be a separate cache miss
std::simd<float> gathered([&](int i) { return base[indices[i]]; });

// Scatter: even slower — no hardware path on SSE2/NEON
for (int i = 0; i < std::simd<float>::size(); ++i)
    base[indices[i]] = gathered[i];

AoS → SoA: restructuring data for SIMD

The standard solution is to change your data layout from Array of Structures (AoS) to Structure of Arrays (SoA). AoS interleaves fields — gather required. SoA stores each field contiguously — contiguous loads work directly.

cpp
// AoS: bad for SIMD — loading 'x' from 4 Particles needs gather
struct Particle { float x, y, z, mass; };
std::vector<Particle> particles_aos;   // x y z m | x y z m | x y z m ...

// SoA: SIMD-friendly — 'xs' is contiguous, load all at once
struct Particles {
    std::vector<float> xs, ys, zs, masses;
};

void update_positions_soa(Particles& p, float dt) {
    using V = std::simd<float>;
    constexpr auto W = V::size();
    const V vdt(dt);
    // ... also store velocities in SoA ...
    // All loads are contiguous — maximum throughput
}

Best practice. Profile before restructuring. SoA is beneficial when you process many elements of one field at a time (physics sim, image processing, ML inference). AoS is fine when you access all fields of one element at a time (particle lifetime queries, serialization).

Performance Comparison

std::simd is not guaranteed to be faster than a well-written scalar loop — the compiler still needs to see through the abstraction, and memory bandwidth is often the true bottleneck. However, for compute-bound kernels the gains are substantial:

cpp
// Representative speedups over scalar (approximate, single-precision float):
//
//  Platform        ISA       simd width   Theoretical speedup
//  x86-64 base     SSE2      4 lanes      ~4x
//  x86-64 +AVX2    AVX2      8 lanes      ~8x
//  x86-64 +AVX-512 AVX-512   16 lanes     ~16x
//  ARM Cortex-A78  NEON      4 lanes      ~4x
//  ARM Neoverse N2 SVE       variable     ~8–16x
//
// Real-world throughput for a fused multiply-add loop (measured):
//
//   Kernel: acc += a[i] * b[i]   (dot product, N=1M floats)
//   Scalar (GCC -O2, no auto-vec): ~1.2 GB/s
//   std::simd<float> SSE2:        ~4.5 GB/s  (~3.7x)
//   std::simd<float> AVX2:        ~8.8 GB/s  (~7.3x)
//
// Note: figures approach the memory bandwidth ceiling at ~28 GB/s (DDR5-5600).
// For larger datasets that don't fit in L3 cache, gains shrink toward 1x.

When auto-vectorization is sufficient

The compiler's auto-vectorizer handles simple, straight-line loops over contiguous arrays well. Prefer std::simd when:

  • The loop body has data-dependent conditionals — auto-vec gives up; use masks.
  • You need guaranteed vector width for an algorithm (e.g., a 4-wide QUAT multiply).
  • The kernel crosses translation units — auto-vec cannot vectorize across calls; std::simd types in function signatures force the caller to vectorize.
  • You are targeting multiple ISAs in the same binary and need fixed_size<N> stability at the ABI boundary.

Auto-vectorization is sufficient for: simple element-wise transforms, reductions on plain arrays, and loops that the compiler can trivially prove alias-free.

ABI and Width Selection

Choosing the right ABI tag has correctness and performance implications beyond simply "more lanes = faster."

simd_abi::native<T> vs simd_abi::fixed_size<N>

cpp
#include <simd>
// C++26

// native<T>: the compiler picks the widest type available at compile time
// Pros: maximum throughput; no wasted lanes
// Cons: binary is not portable — recompile for each ISA target
std::simd<float, std::simd_abi::native<float>> vn;  // 4 on SSE2, 8 on AVX2

// fixed_size<N>: always exactly N lanes, regardless of hardware
// Pros: stable struct layout, stable function signatures, portable binary
// Cons: may use multiple registers or scalar fallback on hardware narrower than N
std::simd<float, std::simd_abi::fixed_size<8>> v8;  // always 8 floats

// The default std::simd<T> is equivalent to native<T>
std::simd<float> same_as_native;

When to use fixed_size<N>

fixed_size<N> is the right choice whenever the vector width is part of your public API or serialized format:

cpp
// C++26

// Quaternion math: a quaternion is always 4 floats — fixed_size<4> is correct
using quat = std::simd<float, std::simd_abi::fixed_size<4>>;

quat multiply(quat a, quat b) noexcept;
// This signature is stable across ISAs. Callers link against it as-is.
// With native<float>, the function signature would change width on AVX-512 targets.

When to use native<T>

Use native<T> (or the default std::simd<T>) for internal compute kernels where the caller and callee are always compiled together:

cpp
// C++26

// Physics inner loop: native width, inlined, maximum throughput
inline void integrate(
    std::span<float> pos,
    std::span<const float> vel,
    float dt)
{
    using V = std::simd<float>;       // native width
    constexpr auto W = V::size();
    const V vdt(dt);
    size_t i = 0;
    for (; i + W <= pos.size(); i += W) {
        V p(pos.data() + i, std::element_aligned);
        V v(vel.data() + i, std::element_aligned);
        (p + v * vdt).copy_to(pos.data() + i, std::element_aligned);
    }
    for (; i < pos.size(); ++i) pos[i] += vel[i] * dt;
}

Best practice. Use native<T> everywhere inside a compilation unit. Use fixed_size<N> at shared-library boundaries, in serialized formats, or in algorithm implementations that assume a specific width.

Comparison with Raw Intrinsics

The same AVX2 fused multiply-add written both ways:

cpp
// Raw intrinsics (x86 AVX2 only) — C++11 or later
#include <immintrin.h>

void fma_avx2(float* dst, const float* a, const float* b, const float* c, int n) {
    for (int i = 0; i < n; i += 8) {
        __m256 va = _mm256_loadu_ps(a + i);
        __m256 vb = _mm256_loadu_ps(b + i);
        __m256 vc = _mm256_loadu_ps(c + i);
        __m256 r  = _mm256_fmadd_ps(va, vb, vc);   // a*b + c
        _mm256_storeu_ps(dst + i, r);
    }
}
cpp
// std::simd — portable, compiles to the same AVX2 instructions with -mavx2
// C++26
#include <simd>
#include <span>

void fma_simd(std::span<float> dst,
              std::span<const float> a,
              std::span<const float> b,
              std::span<const float> c) {
    using V = std::simd<float>;
    constexpr auto W = V::size();
    size_t i = 0;
    for (; i + W <= dst.size(); i += W) {
        V va(a.data() + i, std::element_aligned);
        V vb(b.data() + i, std::element_aligned);
        V vc(c.data() + i, std::element_aligned);
        V r = va * vb + vc;     // FMA-eligible; compiler emits vfmadd231ps
        r.copy_to(dst.data() + i, std::element_aligned);
    }
    for (; i < dst.size(); ++i) dst[i] = a[i] * b[i] + c[i];
}

The intrinsic version is locked to x86 AVX2: it will not compile on ARM, will not use AVX-512 automatically, and requires manual width changes if you later target a 16-wide register. The std::simd version compiles to SSE2, AVX2, AVX-512, or NEON with no source changes.

Common pitfall. Mixing intrinsics and std::simd in the same translation unit is legal but produces fragile code. The compiler tracks vector register state separately for each abstraction level; excessive mixing can generate unnecessary moves between register files. Prefer one or the other within a hot path.

simd_mask

cpp
#include <simd>
// C++26

void mask_ops() {
    std::simd<int> v{1, -2, 3, -4};
    auto neg_mask = v < std::simd<int>(0);  // {false, true, false, true}

    // Logical ops on masks
    auto pos_mask = !neg_mask;

    // Count matching lanes
    int n = std::popcount(neg_mask);   // 2

    // any/all/none
    bool any_neg = std::any_of(neg_mask);   // true
    bool all_pos = std::all_of(pos_mask);   // false
}

Gather and Scatter

cpp
#include <simd>
// C++26

void gather_scatter(float* base, int* indices) {
    using V = std::simd<float, std::simd_abi::fixed_size<4>>;
    using I = std::simd<int,   std::simd_abi::fixed_size<4>>;

    I idx(indices, std::element_aligned);

    // Gather: load non-contiguous elements via generator
    V gathered([&](int i) { return base[idx[i]]; });

    // Scatter: store non-contiguous
    for (int i = 0; i < 4; ++i) base[idx[i]] = gathered[i];
}

Compiler Support

CompilerStatus (2026)
GCC 13+std::experimental::simd (libstdc++) — full P0214
GCC 15+std::simd (C++26 <simd> header, in progress)
Clang 17+Partial via std::experimental
MSVC 19.40+In progress

Use the #if defined(__cpp_lib_simd) guard shown at the top of this page to handle both namespaces portably.

Key Rules

  • std::simd<T> size (::size()) is compile-time but hardware-dependent — don't hard-code it.
  • Prefer aligned loads (std::vector_aligned) for performance; use std::element_aligned when the address is unknown.
  • Branch-free code with masks is faster than scalar branching; avoid if inside SIMD loops.
  • The where() helper replaces blend/select intrinsics — it generates VBLENDVPS, VMOVAPS, or conditional MOVs.
  • SIMD registers are ephemeral — they don't survive function calls unless the callee is inlined or the ABI passes them in registers.
  • For algorithmic code, fixed_size<N> is portable; for peak throughput, use native<T> and let the compiler choose width.
  • Call std::reduce() once after the loop, not inside it — a horizontal reduction inside the loop body serializes the computation.
  • Restructure AoS data to SoA before applying SIMD; gather/scatter throughput is an order of magnitude lower than contiguous loads.
  • Guard the namespace with #if __cpp_lib_simd until all target compilers ship <simd> rather than <experimental/simd>.