Level 10 used std::experimental::simd: portable, and the compiler picks the
instructions. This skill goes to the metal: the x86 vector registers and the
intrinsics in <immintrin.h> that name individual instructions. You'll
need them for the things portable SIMD and the auto-vectorizer don't do well
— searches with early exits, byte shuffles, bit masks — and to read what the
compiler produces.
1. Registers and lanes
- xmm0–xmm15: 128 bits, SSE/SSE2 — every x86-64 CPU has them, and
scalar
float/doublemath uses them too (addss,mulsd). - ymm0–ymm15: 256 bits, AVX/AVX2. The lower half is the xmm register.
- zmm0–zmm31 + mask registers
k0–k7: AVX-512 (not on every CPU — not on the one grading your code).
Lane 0 is the lowest-addressed element when you load from memory, and the
rightmost in Intel's diagrams. Intrinsic types say only the width:
__m128i/__m256i (any integers), __m128/__m256 (float),
__m128d/__m256d (double). The element type is in the function name:
_mm256_add_epi32 _mm256 = 256-bit add = operation epi32 = packed signed 32-bit ints
_mm_cmpeq_epi8 _mm = 128-bit epi8 = bytes
_mm256_mul_ps ps = packed single (float) pd = packed double ss/sd = scalar
_mm256_loadu_si256 si256 = the whole 256-bit integer register u = unaligned2. Enabling AVX2 for one function
A default x86-64 build may only use SSE2. Rather than -mavx2 for the whole
program (which makes it crash on older CPUs), enable it per function:
#include <cstdio>
#include <immintrin.h>
__attribute__((target("avx2")))
int sum8(const int* p) {
__m256i v = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(p));
__m128i s = _mm_add_epi32(_mm256_castsi256_si128(v), _mm256_extracti128_si256(v, 1));
s = _mm_add_epi32(s, _mm_shuffle_epi32(s, _MM_SHUFFLE(1, 0, 3, 2)));
s = _mm_add_epi32(s, _mm_shuffle_epi32(s, _MM_SHUFFLE(2, 3, 0, 1)));
return _mm_cvtsi128_si32(s);
}
int main() {
int a[8] = {1, 2, 3, 4, 5, 6, 7, 8};
if (__builtin_cpu_supports("avx2")) std::printf("%d\n", sum8(a));
}__builtin_cpu_supports is the runtime check real code does once, before
choosing an implementation. Calling an AVX2 intrinsic from a function
without the target is a compile error ("target specific option mismatch").
3. Loads and stores
| intrinsic | instruction | alignment |
|---|---|---|
_mm256_loadu_si256(p) |
vmovdqu |
any |
_mm256_load_si256(p) |
vmovdqa |
must be 32-byte aligned — faults otherwise |
_mm256_storeu_si256(p, v) |
vmovdqu |
any |
_mm256_set1_epi32(x) |
vpbroadcastd (or vmovd + broadcast) |
— |
_mm256_setr_epi32(a, …, h) |
constants / inserts | lane 0 = a ("r" = reversed vs set) |
Use the u forms unless you've aligned the data yourself (alignas(32),
std::aligned_alloc): on current cores they cost the same when the address
happens to be aligned. And never load past the end of your buffer — handle
the tail with scalar code (or masked loads).
4. Compares, masks and movemask
Vector compares don't set flags; they produce a vector with all-ones lanes where the condition holds:
movemask takes the top bit of each byte: bits 1, 5, 7 → 0xa2._mm256_movemask_epi8 collects the top bit of each of the 32 bytes into an
int. Then scalar bit tricks finish the job: __builtin_popcount counts
matches, __builtin_ctz (tzcnt) finds the first:
#include <cstdio>
#include <cstring>
#include <immintrin.h>
__attribute__((target("avx2,bmi")))
long find_char(const char* s, long n, char c) {
const __m256i vc = _mm256_set1_epi8(c);
long i = 0;
for (; i + 32 <= n; i += 32) {
__m256i v = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(s + i));
unsigned m = static_cast<unsigned>(_mm256_movemask_epi8(_mm256_cmpeq_epi8(v, vc)));
if (m) return i + __builtin_ctz(m);
}
for (; i < n; ++i)
if (s[i] == c) return i;
return -1;
}
int main() {
const char* text = "the quick brown fox jumps over the lazy dog, twice: the quick brown fox";
if (__builtin_cpu_supports("avx2"))
std::printf("%ld %ld\n", find_char(text, std::strlen(text), ':'), find_char(text, std::strlen(text), '!'));
}i + 32 <= n; the last 6 bytes are checked one at a time, so no load ever reads past the end of the buffer.This is the heart of memchr, strlen and JSON parsers like simdjson. The
compiler won't vectorize a loop with an early return at -O2, so
intrinsics are how this gets written.
Masks also drive branch-free selection: _mm256_blendv_epi8(a, b, m)
takes bytes from b where m is set, from a elsewhere; often and/
andnot with the mask, or min/max, do the same in one instruction (the
compiler knows these identities — intrinsics are optimised, not copied 1:1).
5. The 128-bit lane trap
AVX2 was built as two SSE units side by side. Most shuffles work within each 128-bit half and never move data across:
vpshufd applies the same 4-lane pattern to each 128-bit half separately; only vpermd can move data between the halves (at ~3 cycles instead of 1). Lane 0 is on the right.In-lane: shuffle_epi32 (vpshufd), shuffle_epi8 (vpshufb),
unpacklo/hi, alignr, permute_ps (vpermilps). Cross-lane:
permutevar8x32 (vpermd), permute4x64 (vpermq), permute2x128,
extracti128/inserti128, broadcast*. Cross-lane shuffles cost about 3
cycles of latency instead of 1 — that's why the in-lane ones exist.
6. Horizontal reductions
Adding (or max-ing) the lanes of one register together is a fold: halve the width, combine, repeat. Always fold across the 128-bit halves first:
s holds 36 and _mm_cvtsi128_si32 reads lane 0.Do it once, after the loop: keep a vector accumulator inside the loop
and fold at the end. And mind the lane width: eight int32 partial sums
overflow exactly like an int. Widen first — _mm256_cvtepi32_epi64
(vpmovsxdq) turns four int32 into four int64 lanes.
#include <climits>
#include <cstdint>
#include <cstdio>
#include <immintrin.h>
__attribute__((target("avx2")))
void compare(const int* p) {
__m256i v = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(p));
__m256i narrow = _mm256_add_epi32(v, v); // wraps
__m256i wide = _mm256_add_epi64(_mm256_cvtepi32_epi64(_mm256_castsi256_si128(v)),
_mm256_cvtepi32_epi64(_mm256_castsi256_si128(v)));
std::printf("%d %lld\n", _mm256_extract_epi32(narrow, 0), (long long)_mm256_extract_epi64(wide, 0));
}
int main() {
int a[8] = {INT_MAX, 0, 0, 0, 0, 0, 0, 0};
if (__builtin_cpu_supports("avx2")) compare(a);
}INT_MAX + INT_MAX in a 32-bit lane wraps to −2. Sign-extending to 64-bit lanes first (vpmovsxdq) gives the exact 4 294 967 294 — at the price of half as many lanes.7. vzeroupper and mixing SSE with AVX
After using ymm registers, the compiler emits vzeroupper before returning
or calling out. It clears the upper halves so that legacy SSE code (non-VEX
encoded, e.g. in an older library) doesn't pay an AVX↔SSE transition
penalty or a false dependency. With intrinsics you never write it; when
reading asm, it tells you "AVX code ends here".
8. Reading intrinsics code in the Compiler tab
Paste any snippet above into the Compiler tab: every intrinsic becomes one
(or zero) instructions, so it's the easiest asm to read. Hover a mnemonic for
its description, switch on Analyze (llvm-mca) to see how many cycles a loop
body takes, and compare -march=x86-64-v3 with the default to see what
target("avx2") changes.
The build task: count and find bytes, and the max and exact sum of an
int32 array — four kernels that use everything above.