One Instruction, Many Values

So far every instruction you have read operates on a single value at a time: one add adds one pair of numbers, one imul multiplies one pair. Modern x86-64 CPUs can do better. They have vector (or SIMD, Single Instruction Multiple Data) instructions that operate on several values packed side by side in one wide register, all in a single step.

When the optimizer rewrites a loop to use these instructions, it is called vectorisation or auto-vectorisation. This lesson is a first look: enough to recognise vectorised code when you see it, not a deep dive. It is a fitting end to the chapter, because vectorisation is the optimizer at its most dramatic, several iterations of your loop collapsing into a handful of wide instructions.

The Wide Registers

The general-purpose registers you know (rax, edi, and friends) hold one integer each. Alongside them, the CPU has a separate set of SSE registers named xmm0 through xmm15. Each is 128 bits wide, which is room for four 32-bit ints side by side:

        128-bit xmm register
+--------+--------+--------+--------+
| int[0] | int[1] | int[2] | int[3] |
+--------+--------+--------+--------+

A vector instruction operating on an xmm register acts on all four lanes at once. Add two xmm registers and you get four independent integer additions for the price of one instruction. That is the whole idea.

A Loop That Vectorises

Here is a function that triples every element of an eight-element array:

void scale(int* data)
{
    for (int i{0}; i < 8; ++i)
    {
        data[i] = data[i] * 3;
    }
}

At -O0, this is an ordinary scalar loop, handling one element per iteration with the now-familiar stack traffic:

scale(int*):
	push	rbp
	mov	rbp, rsp
	mov	QWORD PTR -24[rbp], rdi
	mov	DWORD PTR -4[rbp], 0
	jmp	.L2
.L3:
	mov	eax, DWORD PTR -4[rbp]
	cdqe
	lea	rdx, 0[0+rax*4]
	mov	rax, QWORD PTR -24[rbp]
	add	rax, rdx
	mov	edx, DWORD PTR [rax]
	mov	eax, DWORD PTR -4[rbp]
	cdqe
	lea	rcx, 0[0+rax*4]
	mov	rax, QWORD PTR -24[rbp]
	add	rcx, rax
	mov	eax, edx
	add	eax, eax
	add	eax, edx
	mov	DWORD PTR [rcx], eax
	add	DWORD PTR -4[rbp], 1
.L2:
	cmp	DWORD PTR -4[rbp], 7
	jle	.L3
	nop
	nop
	pop	rbp
	ret

This is a single element per pass: compute the address of data[i], load it, triple it (add eax, eax then add eax, edx is x*2 + x), store it back, increment i, loop while i <= 7. Eight trips around for eight elements.

The Vectorised Version

Now -O2:

scale(int*):
	movdqu	xmm1, XMMWORD PTR [rdi]
	movdqa	xmm0, xmm1
	pslld	xmm0, 1
	paddd	xmm0, xmm1
	movdqu	xmm1, XMMWORD PTR 16[rdi]
	movups	XMMWORD PTR [rdi], xmm0
	movdqa	xmm0, xmm1
	pslld	xmm0, 1
	paddd	xmm0, xmm1
	movups	XMMWORD PTR 16[rdi], xmm0
	ret

There is no loop at all. No counter, no cmp, no jump back. The eight elements are processed in just two groups of four, and the registers in play are xmm0 and xmm1, the wide vector registers. Let us decode the new mnemonics:

  • movdqu xmm1, XMMWORD PTR [rdi] loads four ints at once from the start of the array into xmm1. XMMWORD PTR means a 128-bit (16-byte) access; movdqu is "move double-quadword unaligned".
  • pslld xmm0, 1 shifts each of the four lanes left by 1, which multiplies each value by 2 (the dd means it operates on packed doublewords, i.e. 32-bit ints, in parallel).
  • paddd xmm0, xmm1 adds the four original values back on. paddd is "packed add doubleword": four independent additions in one instruction. Shift-by-one plus the original is x*2 + x, that is, x*3, computed for all four elements simultaneously.
  • movups XMMWORD PTR [rdi], xmm0 writes the four results back to memory at once.

Then the same four instructions repeat for elements 4 through 7 (note the 16[rdi] offset: 16 bytes is four 4-byte ints further along). Eight multiplications by 3, done as two packed operations. Your eight-iteration loop became a few straight-line vector instructions.

Recognising Vectorised Code

You do not need to memorise the SIMD instruction set to read vectorised assembly. You just need the tells:

  • xmm registers (xmm0..xmm15) instead of the usual eax/rdi family.
  • XMMWORD PTR memory operands, signalling 128-bit (16-byte) accesses.
  • Packed mnemonics, often with a p prefix (paddd, pmulld, psubd) or operating on xmm registers (movdqu, movdqa, movups, pslld). The trailing d frequently means "doubleword", i.e. packed 32-bit lanes.

When you see those, read it as "this is doing several elements per instruction". The exact lane count depends on the element size and register width (four ints in a 128-bit register; wider AVX registers like ymm hold eight).

Why It Is Not Always Possible

Auto-vectorisation is powerful but fragile. The optimizer can only do it when it can prove the iterations are independent and the memory access pattern is regular. A known, fixed trip count (like the 8 here) helps a great deal; pointers that might overlap, data-dependent branches inside the loop, or iterations that depend on each other can all prevent it. That is why a small change to a loop can sometimes make vectorisation appear or vanish, and why, when performance matters, programmers learn to write loops in the simple, regular shapes the vectoriser can handle.

That is also why this lesson is "a glimpse". Vectorisation is a large topic with its own instruction sets (SSE, AVX, AVX-512) and tuning techniques. For the purposes of reading assembly, the goal is simpler: when you spot xmm registers and packed instructions in a -O2 listing, recognise that the optimizer has turned your one-at-a-time loop into a several-at-a-time one. That recognition is the perfect note to end the chapter on, because it captures the whole spirit of the optimizer: same result, far less work.

Key Takeaways

  • Vectorisation (SIMD) uses wide registers to operate on several values with a single instruction.
  • The xmm0..xmm15 registers are 128 bits wide, holding four 32-bit ints side by side.
  • Packed instructions like paddd (packed add doubleword) and pslld act on all lanes at once; movdqu/movups move 128 bits (XMMWORD PTR) at a time.
  • In scale, an eight-iteration loop became two groups of four, with x * 3 computed as pslld + paddd across four lanes simultaneously.
  • Recognise vectorised code by xmm registers, XMMWORD PTR operands, and packed (p-prefixed) mnemonics.
  • Auto-vectorisation requires independent iterations and regular memory access; many loops cannot be vectorised, which is why writing simple, regular loops helps the optimizer.