Level 10 introduced the auto-vectorizer. This skill is about working with
it at the machine level: reading the vector code GCC produces, recognising
from the asm why a loop wasn't vectorized, and rewriting the C++ so it is —
without intrinsics, and without -ffast-math.
1. What GCC does at each level (GCC 12+)
| flags | vectorizer |
|---|---|
-O1 |
off |
-O2 |
on, very cheap cost model: only loops that need no runtime checks (no aliasing versioning) and are clearly profitable |
-O3 |
on, dynamic cost model: versions loops with runtime alias checks, peels for alignment, vectorizes some early-exit loops (GCC 15+) |
-march=x86-64-v3 |
allows AVX2/FMA: 256-bit ymm, 8 floats or ints per instruction |
| default target | baseline x86-64 = SSE2: 128-bit xmm, 4 per instruction |
The target matters as much as the level: the same -O2 loop may stay scalar
for SSE2 and vectorize for AVX2, because wider lanes make it profitable.
Ask GCC directly:
$ g++ -O2 -march=x86-64-v3 -fopt-info-vec-optimized -c k.cpp
k.cpp:4:26: optimized: loop vectorized using 32 byte vectors
$ g++ -O2 -march=x86-64-v3 -fopt-info-vec-missed -c k.cpp
k.cpp:9:26: missed: couldn't vectorize loop
k.cpp:10:14: missed: not vectorized: complicated access pattern.2. Reading vectorized code
A vectorized loop has a recognisable shape. For y[i] = x[i] + k with
__restrict pointers at -O2 -march=x86-64-v3:
vpbroadcastd ymm1, edx ; k → all 8 lanes, once
.L4: ; main loop: 8 ints per iteration
vpaddd ymm0, ymm1, YMMWORD PTR [rsi+rax]
vmovdqu YMMWORD PTR [rdi+rax], ymm0
add rax, 32
cmp rax, rcx
jne .L4
… ; epilogue: the last 0–7 elements
; (an xmm step and/or a scalar loop)
vzeroupper
retadd rax, 32); the remaining 20 mod 8 = 4 elements go through the epilogue — an xmm step here, or a scalar loop.Things to look for in the Compiler tab:
- ymm / xmm operands with
p(packed) mnemonics —vpaddd,vmulps,vaddps— inside the loop: vectorized.ss/sdsuffixes (vaddss) are scalar float ops that merely use xmm registers. - The induction step:
add rax, 32(8 × 4 bytes) instead ofadd rax, 4. - Prologue checks like
cmp rdx, 7 / jbe(too short for a vector iteration) and, at-O3, address comparisons (alias versioning). - A fold after the loop for reductions:
vextracti128, shuffles, adds.
3. What blocks it — and the fix
Aliasing
#include <cstddef>
void add_k(int* y, const int* x, int k, std::size_t n) {
for (std::size_t i = 0; i < n; ++i) y[i] = x[i] + k;
}x and written through y. Scalar order: iteration 0 writes y[0], which is x[1] — the value iteration 1 reads. An 8-wide load of x[0..7] would read them all before any store and get a different answer.If y == x + 1, each iteration reads what the previous one wrote; loading 8
x values before storing 8 y values would change the result. -O3
handles it with a runtime overlap check (two versions of the loop); -O2
won't. The fix is a promise:
#include <cstddef>
void add_k(int* __restrict y, const int* __restrict x, int k, std::size_t n) {
for (std::size_t i = 0; i < n; ++i) y[i] = x[i] + k;
}__restrict (GCC/Clang/MSVC extension; restrict in C) means "nothing
else accesses this memory through another pointer here". Breaking it is UB.
With std::span parameters, copy .data() into local __restrict
pointers. Aliasing also bites through members: in a loop over
this->v.size() writing out[i], the compiler may have to reload size()
every iteration, because out might point into the vector's own control
block — hoist it into a local.
Loop-carried dependencies
a[i] += a[i - 1] needs the previous iteration's result. No lane-parallel
version exists without changing the algorithm (a SIMD prefix sum is
log₂(lanes) shift-and-add steps — a different program).
a[i] += a[i - 1] — every arrow is a result one iteration needs from the last. There's no set of 8 iterations that can run side by side.Float reductions
#include <cstddef>
float sum(const float* a, std::size_t n) {
float s = 0;
for (std::size_t i = 0; i < n; ++i) s += a[i];
return s;
}Vectorizing this means adding in a different order — (a0+a8+…) + (a1+a9+…) + … — and float addition isn't associative:
#include <cstdio>
int main() {
volatile float big = 1e8f, one = 1.0f;
std::printf("%g %g\n", (big + one) - big, (big - big) + one);
}So GCC keeps the order. For a dot product it still vectorizes the
products and then adds them one at a time (eight vaddss per chunk:
an in-order reduction). Your options:
Write a vectorizable order yourself: independent partial sums. The compiler maps
acc[0..7]onto one ymm register (SLP vectorization):#include <cstddef> float sum(const float* a, std::size_t n) { float acc[8] = {}; std::size_t i = 0; for (; i + 8 <= n; i += 8) for (std::size_t k = 0; k < 8; ++k) acc[k] += a[i + k]; float s = 0; for (; i < n; ++i) s += a[i]; for (float x : acc) s += x; return s; }
vaddps. Hover a column to follow one accumulator.-fassociative-math(part of-ffast-math): file-wide permission — along with other assumptions (no NaN/inf with-ffinite-math-only).#pragma omp simd reduction(+:s)with-fopenmp-simd: permission for one loop.std::reduce/std::transform_reducewithstd::execution::unseq.
Integer sums have no such problem: integer addition is associative, so the compiler regroups freely.
Early exits, calls, strides, conditions
- Early exit (
if (a[i] == v) return i;): scalar at-O2; GCC 15+ can vectorize some at-O3. Hand-written movemask code (x86-simd-intrinsics) is the dependable route. - Calls to functions the compiler can't inline: it can't make 8 calls
at once (unless the function has
#pragma omp declare simdclones). Math functions likesqrtfare fine (vsqrtps);expfneeds a vector math library (-fveclib, glibc's libmvec with-ffast-math). - Strided / gathered access (
x[2*i],x[idx[i]]): needs shuffles orvpgatherdd; often judged unprofitable at-O2. Prefer a structure-of-arrays layout (Level 10, data-oriented design). - Conditions:
y[i] = c ? a : bbecomes a blend — fine. A conditional store (if (c) y[i] = …) needs a masked store; often blocked. - Mixed widths:
intloads intoint64_tsums need widening (vpmovsxdq) — fine, but each widening halves the lanes per instruction.
4. The checklist
When a hot loop isn't vectorized, in this order:
- Is it the right target? (
-march=x86-64-v3ortarget("avx2")) -fopt-info-vec-missed: what does GCC say?- Pointers that may alias →
__restrictlocals. - A float reduction → partial sums.
- Function calls → inline them, or restructure.
- Data layout → contiguous, unit stride.
- Still not? Intrinsics or
std::experimental::simd.
The build task applies step 4: make a float dot product's sum vectorize,
with the codegen stage checking the real -O2 -march=x86-64-v3 output.