CodeRigor

FREE LESSON

SSE and AVX2 intrinsics

Program the vector unit directly: xmm/ymm lanes, unaligned loads, compare masks and movemask, the 128-bit lane trap in shuffles, horizontal reductions and exact tails, with AVX2 enabled per function.

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

ymm0: 256 bits, eight 32-bit lanes; xmm0 is the low half
Lane 0 is on the right (lowest bits). VEX-encoded 128-bit instructions zero the upper half.
ymm0: 256 bits, eight 32-bit lanes; xmm0 is the low half
Lane 0 is on the right (lowest bits). VEX-encoded 128-bit instructions zero the upper half.
  • xmm0–xmm15: 128 bits, SSE/SSE2 — every x86-64 CPU has them, and scalar float/double math 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:

Text
_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 = unaligned

2. 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:

C++
#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:

Find every 'a': compare 32 bytes, then squeeze the result into bits
movemask takes the top bit of each byte: bits 1, 5, 7 → 0xa2.
Find every 'a': compare 32 bytes, then squeeze the result into bits
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:

C++
#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), '!'));
}
find_char over a 70-byte string: two vector steps, then a scalar tail
The vector loop runs while i + 32 <= n; the last 6 bytes are checked one at a time, so no load ever reads past the end of the buffer.
find_char over a 70-byte string: two vector steps, then a scalar tail
The vector loop runs while 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:

In-lane vs cross-lane: the same 'reverse', two answers
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 vs cross-lane: the same 'reverse', two answers
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:

Folding eight lanes into one: sum8({1, …, 8})
Halve, add, repeat: first the two 128-bit halves, then pairs, then neighbours. After three adds every lane of s holds 36 and _mm_cvtsi128_si32 reads lane 0.
Folding eight lanes into one: sum8({1, …, 8})
Halve, add, repeat: first the two 128-bit halves, then pairs, then neighbours. After three adds every lane of 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.

C++
#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);
}
Widen before you add
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.
Widen before you add
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.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Gentle introduction to SIMD registers and the xmm/ymm lane model.

Short hands-on intro to AVX intrinsics in C++ with a benchmark.

SIMD-thinking with intrinsics: shuffles, permutes and designing algorithms around lanes.

MIT 6.172 lecture whose topics include floating-point and vector hardware (SSE/AVX registers).