Reading Assembly

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.

30 minfaded worked examplebeacon training
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 unalignedmovdqa 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, xmm0

Shift 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.

Beaconsee this → think this, without decoding it

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.

Explain it to yourselfwrite it — thinking it does not count

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 movd is 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.