Part 5 · SIMD and the modern ISA
Reading vectorised output
Forty instructions for a three-line loop. Here is how to find the eight that matter and ignore the rest.
What this lesson is trying to break (2)
- A long listing means complicated code.
- Intrinsics are unreadable.
Vectorised listings are long. The length is almost all scaffolding, and once you can skip it they are no harder to read than scalar code.
The three sections
┌─ guard / alias check ───────── skip
├─ scalar prologue ───────────── skip (handles a few elements)
├─ VECTOR BODY ───────────────── read this
├─ horizontal combine ────────── read if it is a reduction
└─ scalar epilogue ───────────── skip (handles the remainder)
Reading order: find the vector body first. It is the block containing
xmm/ymm/zmm registers with a backward jump. Everything above and below
handles at most a handful of elements, however much space it occupies.
Step 1 — locate the vector body
Search for a vector register name. The loop containing it, bounded by a backward branch, is the real work.
Then read the register width off the name and the element size off the mnemonic
suffix (5.1). vpaddd on ymm is 8 lanes of
32-bit integers per instruction — so the loop advances 8 elements per iteration,
and its pointer increment should be 32 bytes. Checking that the increment matches
the lane count is a good way to confirm you have read it correctly.
Step 2 — count loads, stores and real work per iteration
Inside the body, count three things:
- loads (
movdqu,movups,vmovdqa,ldr q…) - stores
- arithmetic (
vpaddd,mulps,fmla…)
That gives you the per-iteration budget, and it is where memory versus compute becomes visible. If you see two loads and one add, you are almost certainly memory-bound (4.4) and widening the vectors will not help.
Note whether loads are aligned or unaligned — movdqa versus movdqu,
vmovaps versus vmovups. Unaligned is the safe default and on modern cores the
penalty is small, so its presence is not a problem, just information.
Step 3 — read the horizontal tail, if there is one
If the loop is a reduction, there is a cascade after it:
movdqa xmm1, xmm0
psrldq xmm1, 8
paddd xmm0, xmm1
movdqa xmm1, xmm0
psrldq xmm1, 4
paddd xmm0, xmm1
movd eax, xmm0Shift the vector right by half its lanes, add, halve again, add — a binary
tree collapsing 4 lanes to 1 in log₂(4) = 2 steps. Then movd extracts the
scalar.
This runs once, not per iteration, which is why it is acceptable despite looking clumsy. And it is why keeping partials in lanes matters (5.1): each horizontal combine costs this cascade.
a shift-and-add cascade on one vector register, just before a movd
A horizontal reduction collapsing lanes to a scalar. Runs once at loop exit.
Skip it when reading for the algorithm — it is always the same shape. Its presence tells you the loop was a reduction rather than a map, which is useful structural information.
Intrinsics
When autovectorisation is not enough, people write intrinsics — functions that map one-to-one onto instructions.
#include <immintrin.h>
int sum8(const int *p) {
__m256i v = _mm256_loadu_si256((const __m256i *)p); // load 8 int32
__m128i lo = _mm256_castsi256_si128(v);
__m128i hi = _mm256_extracti128_si256(v, 1);
__m128i s = _mm_add_epi32(lo, hi); // fold 8 → 4
// ... continue folding
return _mm_cvtsi128_si32(s);
}
They look hostile and are actually systematic once you know the naming:
_mm= 128-bit,_mm256= 256-bit,_mm512= 512-bit- then the operation:
add,mul,load,store,shuffle - then the element type:
epi32(packed 32-bit int),ps(float),pd(double),si256(raw 256 bits)
So _mm256_add_epi32 is “256-bit, add, packed 32-bit integers” — which is exactly
vpaddd ymm. Read them as instruction names with a verbose spelling, not as a
library.
For reading purposes you rarely need more than this. And the practical advice is
worth stating: intrinsics are a last resort. They are unportable — you need a
separate NEON version for ARM64 — hard to maintain, and often beaten by giving the
compiler better information (restrict, a known trip count) and letting it
vectorise. Reach for them when you have measured, tried the free options, and still
need it.
You are reading a 45-instruction listing for a 4-line loop, and you have found a
7-instruction block using ymm registers with a backward branch.
Say what the other 38 instructions are most likely doing, and what you can already
conclude about elements-per-iteration from the ymm and the pointer increment.
What you should now be able to say
- A vectorised loop is guard, scalar prologue, vector body, combine, scalar epilogue — read the body.
- Register name gives width, suffix gives element size, and lanes follows.
- Count loads, stores, arithmetic per iteration to see whether you are memory- or compute-bound.
- A shift-and-add cascade before a
movdis a horizontal reduction, run once. - Intrinsics are systematic: width, operation, element type — and they are a last resort, not a first move.
Next: 6.1 — The lexicon.