A modern core can add four floats in the time it takes to add one — if the data and the code are shaped for it. SIMD (single instruction, multiple data) instructions operate on short fixed-width vectors of lanes. This skill covers when the compiler does that for you, why it often refuses, and how to write it yourself, portably, with exact handling of the awkward parts: the tail, alignment, masks and reductions.
1. Lanes and registers
| Instruction set | Register | Width | Lanes of float / int32 / bytes |
|---|---|---|---|
| SSE2 (every x86-64 CPU) | XMM | 128 bit | 4 / 4 / 16 |
| AVX2 | YMM | 256 bit | 8 / 8 / 32 |
| AVX-512 | ZMM | 512 bit | 16 / 16 / 64 |
| NEON (every AArch64 CPU) | V | 128 bit | 4 / 4 / 16 |
| SVE / SVE2 (AArch64) | Z | 128–2048 bit, chosen by the CPU | vector-length agnostic |
A plain g++ -O2 build for x86-64 may only assume SSE2. AVX needs -mavx2
(or -march=native, which also makes the binary unportable to older CPUs).
That's why this app's grader and your Compiler tab default to 128-bit vectors.
The AArch64 track will come back to NEON and SVE.
xmm0 is the low 128 bits of ymm0, which is the low half of zmm0. A 128-bit register holds 4 floats or 16 bytes; AVX-512 holds 16 floats.#include <experimental/simd>
#include <cstdint>
#include <print>
namespace stdx = std::experimental;
int main() {
// native_simd<T>: as many lanes as the target's native register holds.
std::println("float lanes: {}", stdx::native_simd<float>::size());
std::println("int32 lanes: {}", stdx::native_simd<std::int32_t>::size());
std::println("byte lanes: {}", stdx::native_simd<std::uint8_t>::size());
}On baseline x86-64 that prints 4, 4 and 16.
2. Auto-vectorisation: what the compiler needs
GCC and Clang vectorise loops on their own (GCC from -O2 with a cautious cost
model, more aggressively at -O3). Ask GCC what it did with
-fopt-info-vec-optimized and what it refused with -fopt-info-vec-missed:
$ g++ -O3 -c kernels.cpp -fopt-info-vec-optimized
kernels.cpp:2: optimized: loop vectorized using 16 byte vectors and unroll factor 4
kernels.cpp:2: optimized: loop versioned for vectorization because of possible aliasing
kernels.cpp:7: optimized: loop vectorized using 16 byte vectors and unroll factor 16
$ g++ -O3 -c kernels.cpp -fopt-info-vec-missed
kernels.cpp:6: missed: not vectorized, possible dependence between data-refsA loop vectorises when:
- The trip count is computable before the loop starts.
for (i = 0; i < n; ++i)is fine even ifnis only known at run time. A loop with an earlyreturnorbreak(a search) usually isn't vectorised by GCC on x86-64. - Iterations are independent.
a[i] += a[i-1](a prefix sum) needs the previous iteration's result: a loop-carried dependency. - Pointers don't alias, or the compiler can check. For
a[i] += b[i]withint* a, const int* b,aandbmight overlap. At-O3GCC versions the loop (a runtime overlap check, then a vector or a scalar path); at-O2it gives up.__restrict(a common extension) or separate arrays remove the question. - Access is contiguous. Unit stride is cheap; strided or indirect access
(
a[idx[i]]) needs gathers, which are slow or absent on baseline x86-64. - Reductions can be reordered. See section 3.
Use the Compiler tab: paste a loop, pick -O3, and look for packed
instructions — paddd (4 × int32 add), addps (4 × float add), pcmpeqb
(16 byte comparisons), vpaddd/vaddps with AVX. Scalar code shows addss,
add, cmp per element instead.
3. Reductions and floating point
Vectorising a sum means keeping V::size() partial sums and combining them
at the end. That reorders the additions. For integers that's fine — wrapping
addition is associative. For floating point it isn't:
#include <experimental/simd>
#include <print>
namespace stdx = std::experimental;
int main() {
float xs[4] = {1e8f, 1.0f, -1e8f, 1.0f};
float s = 0;
for (float x : xs) s += x; // ((1e8 + 1) - 1e8) + 1
using V = stdx::fixed_size_simd<float, 2>;
V acc = 0.0f; // lanes: (1e8 + -1e8), (1 + 1)
for (int i = 0; i < 4; i += 2) acc += V(xs + i, stdx::element_aligned);
std::println("sequential {}, lane-wise {}", s, stdx::reduce(acc));
}That prints sequential 1, lane-wise 2: 1e8 + 1 isn't representable as a
float and rounds back to 1e8. So GCC at -O3 loads a float sum in vectors
but still adds the elements one at a time (addss) to keep the source's
order. You grant permission to reorder with -ffast-math (which also assumes
no NaNs or infinities — a bigger promise than it sounds) or the narrower
-fassociative-math, or by writing the lane-wise sum yourself. Either way,
results can change in the last bits: compare float SIMD results with a
tolerance, never ==.
1e8 + 1 rounds back to 1e8 and one 1 is lost; with two lanes, the big values cancel in lane 0 and both 1s survive in lane 1.4. Explicit SIMD with portable types
When the compiler won't (or you want guarantees), write the vector code yourself. Two options:
Intrinsics — one function per instruction, tied to an instruction set. Precise, unportable, verbose:
#include <emmintrin.h> // SSE2, always available on x86-64 #include <print> int main() { alignas(16) int a[4] = {1, 2, 3, 4}, b[4] = {10, 20, 30, 40}, c[4]; __m128i va = _mm_load_si128(reinterpret_cast<const __m128i*>(a)); __m128i vb = _mm_load_si128(reinterpret_cast<const __m128i*>(b)); _mm_store_si128(reinterpret_cast<__m128i*>(c), _mm_add_epi32(va, vb)); // paddd std::println("{} {} {} {}", c[0], c[1], c[2], c[3]); }Portable SIMD types — a value type with one element per lane and ordinary operators, compiled to whatever the target has.
<experimental/simd>(the Parallelism TS v2) is the TS ancestor of C++26std::simd: same model, slightly different names, and it works in both GCC and Clang here. It's what the build task uses.
Worked example: a float sum with explicit lanes
#include <experimental/simd>
#include <cstddef>
#include <print>
#include <span>
#include <vector>
namespace stdx = std::experimental;
float sum(std::span<const float> x) {
using V = stdx::native_simd<float>;
V acc = 0.0f; // broadcast: every lane 0
std::size_t i = 0;
for (; i + V::size() <= x.size(); i += V::size())
acc += V(x.data() + i, stdx::element_aligned); // load one chunk
float s = stdx::reduce(acc); // combine the lanes
for (; i < x.size(); ++i) s += x[i]; // the tail
return s;
}
int main() {
std::vector<float> v(10);
for (std::size_t i = 0; i < v.size(); ++i) v[i] = float(i) * 0.5f;
std::println("{}", sum(v)); // 0 + 0.5 + ... + 4.5 = 22.5
}Every explicit SIMD loop has this shape:
- Broadcast scalars you need in every lane (
V acc = 0.0f;). - Chunk loop while a whole chunk fits:
i + V::size() <= n. Usingi < nwould load past the end on the last iteration — undefined behaviour that ASan reports and that crashes for real when the buffer ends at a page boundary. - Load with
element_aligned(any address) unless you know the address is aligned tostdx::memory_alignment_v<V>, in which casevector_alignedmay be faster. A subspan at an arbitrary offset is never known to be aligned. - Store results back explicitly (
v.copy_to(p + i, element_aligned)): asimdvalue is a copy, not a view. - Reduce lane-wise accumulators once, after the loop.
- Tail: finish the last
0 … V::size()-1elements with a scalar loop.
What it buys, measured on this machine with g++ -O2 (1 Mi floats, best of
20 runs — your numbers will differ, run it yourself):
float sum, 1M floats: scalar 846 us, simd 212 us (4.0x)The scalar loop can't be vectorised without permission (section 3); the explicit version adds four lanes per instruction.
5. Masks and branch-free code
Comparisons between simd values produce a mask — one bool per lane.
Masks replace branches:
#include <experimental/simd>
#include <print>
namespace stdx = std::experimental;
int main() {
stdx::fixed_size_simd<int, 4> v([](int i) { return i * 3; }); // 0 3 6 9
auto big = v > 4; // F F T T
std::println("count {}", stdx::popcount(big)); // 2
std::println("first {}", stdx::find_first_set(big)); // 2
std::println("any {} all {}", stdx::any_of(big), stdx::all_of(big));
stdx::where(big, v) = 0; // masked assignment: only the T lanes
std::println("sum {}", stdx::reduce(v)); // 0 + 3 + 0 + 0 = 3
auto c = stdx::min(stdx::max(v, decltype(v)(1)), decltype(v)(2)); // clamp to [1, 2]
std::println("clamped {} {} {} {}", int(c[0]), int(c[1]), int(c[2]), int(c[3]));
}popcount(mask)counts matches without a branch per element.where(mask, v) = x(and+=etc.) is a blend: lanes outside the mask keep their value.min/maxcompile tominps/maxps— a branch-free clamp. Unpredictable data makes branches expensive (see the profiling skill); masks cost the same whatever the data.
v > 4 sets lanes 2 and 3. popcount(mask) = 2, and where(mask, v) = 0 blends: lanes outside the mask keep their value.Masked tails. A tempting alternative to the scalar tail is "load the last
partial chunk padded with zeros". The padding then takes part in every
comparison: counting x > -1 counts each zero. Either pad with a value that
can't affect the result, or mask out the padding lanes.
i + 4 <= size stops after two vectors; the last 2 elements go through the scalar loop with the same predicate.Faded example: counting values above a threshold
Complete it (the answer is below):
std::size_t count_above(std::span<const int> xs, int t) {
using V = stdx::native_simd<int>;
const V vt = /* ??? */;
std::size_t n = 0, i = 0;
for (; /* ??? */; i += V::size()) {
V v(xs.data() + i, stdx::element_aligned);
n += /* ??? */;
}
for (; i < xs.size(); ++i) n += /* ??? */;
return n;
}Answer
const V vt = t; · i + V::size() <= xs.size() · stdx::popcount(v > vt)
· xs[i] > t. The scalar tail uses exactly the same predicate as the lanes.
6. Counting in lanes: watch the width
Byte kernels (counting newlines, character classes) get 16 lanes per SSE
register. If you count matches in byte lanes — where(v == '\n', counts) += 1
— each counter is an unsigned char and wraps after 255. Either add
popcount(mask) into a std::size_t every chunk, or flush the byte counters
into a wider total at least every 255 chunks. The same applies to any narrow
accumulator lane (int16, float precision).
7. Data layout for SIMD
SIMD wants each lane's data next to each other. An array of structs
{x, y, z, mass} interleaves fields, so loading "4 x values" needs a gather
or shuffles. A struct of arrays (xs[], ys[], …) makes each field one
contiguous stream — load, compute, store. That's the AoS → SoA transformation
from the memory-layout skill; data-oriented design takes it further.
x values: from AoS they're 16 bytes apart (a gather or shuffles); from SoA they're one contiguous 16-byte load.8. C++26 std::simd
C++26 standardises the same model in <simd>, under namespace std::simd.
GCC 16 already ships an early implementation (it doesn't define
__cpp_lib_simd yet, so treat it as experimental; Clang 23's library doesn't
have it):
#include <simd>
#include <cstddef>
#include <print>
#include <span>
#include <vector>
namespace simd = std::simd;
std::size_t count_above(std::span<const int> xs, int t) {
using V = simd::vec<int>; // TS: native_simd<int>
std::size_t n = 0, i = 0;
for (; i + V::size() <= xs.size(); i += V::size())
n += simd::reduce_count(simd::unchecked_load<V>(xs.subspan(i, V::size())) > t);
for (; i < xs.size(); ++i) n += xs[i] > t;
return n;
}
int main() {
std::vector<int> v{1, 9, 3, 12, 5, 7, 20, 2, 11};
std::println("{}", count_above(v, 6)); // 5
}Parallelism TS (std::experimental) |
C++26 (std::simd) |
|---|---|
native_simd<T> |
vec<T> |
simd_mask<T> |
mask<T> |
V(p, element_aligned) |
unchecked_load<V>(range), partial_load<V>(range) (zero-fills missing lanes) |
v.copy_to(p, element_aligned) |
unchecked_store(v, range), partial_store |
popcount(mask) |
reduce_count(mask) |
where(mask, v) = x |
select(mask, x, v) |
reduce(v) |
reduce(v), reduce_min, reduce_max |
Summary
- SIMD lanes: 4 floats/ints or 16 bytes per SSE/NEON register; more with AVX.
- Compilers vectorise independent, countable, contiguous loops; aliasing,
loop-carried dependencies, early exits and float reductions stop them. Check
with
-fopt-info-vec-missedor the Compiler tab. - Float sums reorder additions when vectorised: only with permission, and the result changes in the last bits.
- Explicit SIMD shape: broadcast → chunk loop while a whole chunk fits →
element_alignedloads → store back → reduce → scalar tail. - Masks (
popcount,where,min/max) replace branches; padding and narrow lane counters are the classic correctness traps. <experimental/simd>today; C++26std::simdhas the same model.