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++26std::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:
| Approach | Problem |
|---|---|
Raw intrinsics (_mm256_add_ps) | Platform-specific, unreadable, error-prone |
| Auto-vectorization | Unpredictable, breaks silently with aliasing |
| Highway / xsimd / VCL | Good but non-standard, divergent APIs |
| OpenMP SIMD pragmas | Portable 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:
// 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
#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 targetConstruction and Load
#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:
#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:
#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:
#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:
#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
| Tag | Meaning | Intrinsic equivalent |
|---|---|---|
std::element_aligned | No alignment guarantee (safe everywhere) | _mm_loadu_ps |
std::vector_aligned | Aligned 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 |
#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:
#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:
#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()
#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
#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:
- Cache lines must be fetched for every distinct index — worst case: N distinct cache lines for N lanes.
- Out-of-order execution cannot overlap memory operations for the same cache line.
- 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:
#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.
// 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:
// 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::simdtypes 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>
#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:
// 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:
// 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:
// 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);
}
}// 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
#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
#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
| Compiler | Status (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; usestd::element_alignedwhen the address is unknown. - Branch-free code with masks is faster than scalar branching; avoid
ifinside SIMD loops. - The
where()helper replaces blend/select intrinsics — it generatesVBLENDVPS,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, usenative<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_simduntil all target compilers ship<simd>rather than<experimental/simd>.