Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

31 · Vectors

The question

What does vectorising actually buy, and when will the compiler do it for me?

ch30 was about finding the expensive loop in a program nobody here has read. This chapter is what you do to that loop once you have found it, and it asks the narrowest question in the book: the unit can do four of these at once, so what became of the other three?

The material

Five loops, three builds

Loop-O2-O3-O3 -ffast-math
sysfs_vec_gather10 (0 vector)10 (0 vector)10 (0 vector)
sysfs_vec_running11 (0 vector)11 (0 vector)11 (0 vector)
sysfs_vec_scale9 (0 vector)56 (4 vector)56 (4 vector)
sysfs_vec_sum_f328 (0 vector)9 (0 vector)37 (5 vector)
sysfs_vec_sum_i3212 (0 vector)40 (5 vector)40 (5 vector)

Conditions: target host · aarch64 cross compiler, any machine — nothing here was executed · aarch64-linux-gnu-gcc · the book's aarch64 listing flags, then with -O3, then with -O3 -ffast-math · 2026-09-17 · Source: bench/results/vectors-census.json, code hash 7aadd394c6e4d2c1.

Read the second column first. At the book’s own optimisation level — the one every other listing in this book was compiled at, and the one a reader building this code gets — not one of these loops is vectorised. That is the first half of the answer to “when will the compiler do it for me”, and it is not a subtlety: the default is no.

At -O3 two of them widen. At -O3 with permission to change the answer, a third joins them. Two never do, at any setting.

So that table holds four different reasons a loop is not vectorised, and only one of them is about the optimisation level.

The one that widens

sysfs_vec_scale compiled for aarch64 at -O2 — the host target’s instruction set, 9 instructions.

0000000000000000 <sysfs_vec_scale>:
   0:	cbz	x2, 20 <sysfs_vec_scale+0x20>
   4:	mov	x3, #0x0                   	// #0
   8:	ldr	s1, [x1, x3, lsl #2]
   c:	fmul	s1, s1, s0
  10:	str	s1, [x0, x3, lsl #2]
  14:	add	x3, x3, #0x1
  18:	cmp	x2, x3
  1c:	b.ne	8 <sysfs_vec_scale+0x8>  // b.any
  20:	ret

Conditions: target host · aarch64 cross compiler, any machine — nothing here was executed · aarch64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -c · 2026-09-17 · Source: bench/results/vectors-o2.json, code hash 9a4601ec65522b39.

sysfs_vec_scale compiled for aarch64 at -O3 — the host target’s instruction set, 56 instructions.

0000000000000000 <sysfs_vec_scale>:
   0:	cbz	x2, 40 <sysfs_vec_scale+0x40>
   4:	sub	x3, x2, #0x1
   8:	cmp	x3, #0x4
   c:	b.ls	c0 <sysfs_vec_scale+0xc0>  // b.plast
  10:	add	x4, x1, #0x4
  14:	mov	x3, #0x0                   	// #0
  18:	sub	x4, x0, x4
  1c:	cmp	x4, #0x8
  20:	b.hi	44 <sysfs_vec_scale+0x44>  // b.pmore
  24:	nop
  28:	ldr	s1, [x1, x3, lsl #2]
  2c:	fmul	s1, s1, s0
  30:	str	s1, [x0, x3, lsl #2]
  34:	add	x3, x3, #0x1
  38:	cmp	x2, x3
  3c:	b.ne	28 <sysfs_vec_scale+0x28>  // b.any
  40:	ret
  44:	dup	v2.4s, v0.s[0]
  48:	lsr	x4, x2, #2
  4c:	lsl	x4, x4, #4
  50:	ldr	q1, [x1, x3]
  54:	fmul	v1.4s, v1.4s, v2.4s
  58:	str	q1, [x0, x3]
  5c:	add	x3, x3, #0x10
  60:	cmp	x3, x4
  64:	b.ne	50 <sysfs_vec_scale+0x50>  // b.any
  68:	and	x3, x2, #0xfffffffffffffffc
  6c:	tst	x2, #0x3
  70:	b.eq	40 <sysfs_vec_scale+0x40>  // b.none
  74:	ldr	s1, [x1, x3, lsl #2]
  78:	add	x5, x3, #0x1
  7c:	lsl	x4, x3, #2
  80:	fmul	s1, s1, s0
  84:	str	s1, [x0, x3, lsl #2]
  88:	cmp	x2, x5
  8c:	b.ls	40 <sysfs_vec_scale+0x40>  // b.plast
  90:	add	x5, x4, #0x4
  94:	add	x3, x3, #0x2
  98:	ldr	s1, [x1, x5]
  9c:	fmul	s1, s1, s0
  a0:	str	s1, [x0, x5]
  a4:	cmp	x2, x3
  a8:	b.ls	40 <sysfs_vec_scale+0x40>  // b.plast
  ac:	add	x4, x4, #0x8
  b0:	ldr	s1, [x1, x4]
  b4:	fmul	s0, s1, s0
  b8:	str	s0, [x0, x4]
  bc:	ret
  c0:	mov	x3, #0x0                   	// #0
  c4:	ldr	s1, [x1, x3, lsl #2]
  c8:	fmul	s1, s1, s0
  cc:	str	s1, [x0, x3, lsl #2]
  d0:	add	x3, x3, #0x1
  d4:	cmp	x2, x3
  d8:	b.ne	28 <sysfs_vec_scale+0x28>  // b.any
  dc:	b	40 <sysfs_vec_scale+0x40>

Conditions: target host · aarch64 cross compiler, any machine — nothing here was executed · aarch64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -O3 -c · 2026-09-17 · Source: bench/results/vectors-o3.json, code hash 9a4601ec65522b39.

Independent elements, one multiply each, nothing carried between iterations. The -O3 version does four at a time: one load of a quad-word register, one multiply across four lanes, one store.

Look at what it cost. The function is several times longer. The widening is three instructions in the middle, and everything else is getting there — checking whether the pointers overlap, working out how many full vectors there are, and handling the elements left over at the end.

That tail is the part worth carrying away. A loop shorter than one vector gains nothing at all and pays for all of that code, and a loop one element past a vector pays a whole scalar iteration for it. Problem 31.1 is the arithmetic, and the shape it produces is a sawtooth rather than a slope.

The one that is refused, and why it is right

sysfs_vec_sum_f32 compiled for aarch64 at -O3 — the host target’s instruction set, 9 instructions.

0000000000000180 <sysfs_vec_sum_f32>:
 180:	movi	v0.2s, #0x0
 184:	cbz	x1, 1a0 <sysfs_vec_sum_f32+0x20>
 188:	add	x1, x0, x1, lsl #2
 18c:	nop
 190:	ldr	s1, [x0], #4
 194:	fadd	s0, s0, s1
 198:	cmp	x1, x0
 19c:	b.ne	190 <sysfs_vec_sum_f32+0x10>  // b.any
 1a0:	ret

Conditions: target host · aarch64 cross compiler, any machine — nothing here was executed · aarch64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -O3 -c · 2026-09-17 · Source: bench/results/vectors-o3.json, code hash 9a4601ec65522b39.

sysfs_vec_sum_f32 compiled for aarch64 at -O3 -ffast-math — the host target’s instruction set, 37 instructions.

0000000000000180 <sysfs_vec_sum_f32>:
 180:	cbz	x1, 200 <sysfs_vec_sum_f32+0x80>
 184:	sub	x2, x1, #0x1
 188:	cmp	x2, #0x2
 18c:	b.ls	208 <sysfs_vec_sum_f32+0x88>  // b.plast
 190:	lsr	x3, x1, #2
 194:	mov	x2, x0
 198:	movi	v0.4s, #0x0
 19c:	add	x3, x0, x3, lsl #4
 1a0:	ldr	q1, [x2], #16
 1a4:	fadd	v0.4s, v0.4s, v1.4s
 1a8:	cmp	x3, x2
 1ac:	b.ne	1a0 <sysfs_vec_sum_f32+0x20>  // b.any
 1b0:	faddp	v0.4s, v0.4s, v0.4s
 1b4:	and	x2, x1, #0xfffffffffffffffc
 1b8:	faddp	v0.4s, v0.4s, v0.4s
 1bc:	tst	x1, #0x3
 1c0:	b.eq	1fc <sysfs_vec_sum_f32+0x7c>  // b.none
 1c4:	ldr	s1, [x0, x2, lsl #2]
 1c8:	add	x3, x2, #0x1
 1cc:	lsl	x4, x2, #2
 1d0:	fadd	s0, s0, s1
 1d4:	cmp	x1, x3
 1d8:	b.ls	1fc <sysfs_vec_sum_f32+0x7c>  // b.plast
 1dc:	add	x0, x0, x4
 1e0:	add	x2, x2, #0x2
 1e4:	ldr	s1, [x0, #4]
 1e8:	fadd	s0, s0, s1
 1ec:	cmp	x1, x2
 1f0:	b.ls	1fc <sysfs_vec_sum_f32+0x7c>  // b.plast
 1f4:	ldr	s1, [x0, #8]
 1f8:	fadd	s0, s0, s1
 1fc:	ret
 200:	movi	v0.2s, #0x0
 204:	ret
 208:	movi	v0.2s, #0x0
 20c:	mov	x2, #0x0                   	// #0
 210:	b	1c4 <sysfs_vec_sum_f32+0x44>

Conditions: target host · aarch64 cross compiler, any machine — nothing here was executed · aarch64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -O3 -ffast-math -c · 2026-09-17 · Source: bench/results/vectors-o3fast.json, code hash 9a4601ec65522b39.

A sum of floats. -O3 leaves it scalar. The same loop over integers, in the table above, widens at -O3 without being asked.

The difference is not in the loop and not in the compiler. Integer addition is associative and floating-point addition is not. Widening a reduction means adding the numbers in a different order — lanes accumulating independently, partials combined at the end — and a different order can give a different answer. A compiler that did that on its own would be changing your program’s output to make it faster.

The third column grants exactly that permission. -ffast-math says the answer may change, and the loop widens immediately, which is the proof that the refusal was never a limitation. Hold on to this one: the last table in this chapter says what the permission bought.

Problem 31.2 is the disagreement itself. You write both orders, the test supplies an input where they differ, and you compare the bit patterns. Being told that floating-point addition is not associative is not the same as watching the two answers come out different.

The two that never widen

sysfs_vec_running compiled for aarch64 at -O3 -ffast-math — the host target’s instruction set, 11 instructions.

0000000000000240 <sysfs_vec_running>:
 240:	cbz	x2, 268 <sysfs_vec_running+0x28>
 244:	movi	v0.2s, #0x0
 248:	mov	x3, #0x0                   	// #0
 24c:	fmov	s2, #5.000000000000000000e-01
 250:	ldr	s1, [x1, x3, lsl #2]
 254:	fmadd	s0, s0, s2, s1
 258:	str	s0, [x0, x3, lsl #2]
 25c:	add	x3, x3, #0x1
 260:	cmp	x2, x3
 264:	b.ne	250 <sysfs_vec_running+0x10>  // b.any
 268:	ret

Conditions: target host · aarch64 cross compiler, any machine — nothing here was executed · aarch64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -O3 -ffast-math -c · 2026-09-17 · Source: bench/results/vectors-o3fast.json, code hash 9a4601ec65522b39.

Each element needs the one before it. Lanes run at the same time, and the second lane cannot start until the first has finished — so there is nothing for the width to do, and no flag changes that. This is ch27’s dependence chain again, at a different granularity and with the same conclusion.

The other refusal is the gather: the elements are independent, but their addresses are not known until the indices have been loaded. A vector unit is fastest reading a run of adjacent memory, and this loop has no run to read. It stays scalar at every setting in the table.

A note on reading a listing for vector instructions. Counting instructions that name a vector register does not answer the question. This compiler uses a half-width vector form to make a floating-point zero, so the loop the chapter calls impossible to widen contains an instruction that looks like vectorisation and is not. bench/run_vectors.py counts only full-width forms, and found this out by tripping over its own guard.

What it bought

LoopMeasuredArithmetic boundOf the bound
sysfs_vec_gather×0.95×4.023.8%
sysfs_vec_running×0.98×4.024.4%
sysfs_vec_scale×2.97×4.074.3%
sysfs_vec_sum_f32×1.0×4.025.0%
sysfs_vec_sum_i32×1.65×4.041.2%

Conditions: target host · Raspberry Pi 5 Model B Rev 1.1 · Linux 6.18.50+rpt-rpi-2712 · cc (Debian 14.2.0-19) 14.2.0 · -O2 -g -Wall -Wextra · 2026-09-16 · Source: bench/results/vectors-host.json, code hash b50c0d1926e9569e.

Each measured speedup sits beside the most its lane count could possibly have bought.

Read the two loops that never widen first, and then stop reading their last column. Their measured speedup is about one, as it must be, since nothing changed — and the percentage beside it is arithmetic rather than achievement: anything that did not move at all sits at a quarter of a fourfold bound by definition. The column means something only for the rows that widened.

Of those, the one with independent elements and a multiply each got most of the way to its bound, and the integer reduction got a good deal less — the partials still have to be combined at the end, and that combining is not four-at-a-time work.

Then the float sum, which is the finding. It widened, under permission this chapter spent a whole section justifying, and it bought nothing whatever: its measured speedup is one. The compiler’s refusal at -O3 was correct, the permission that overrode it was granted, the vectorisation happened — and the loop takes what it always took, because what limits it is not arithmetic width. Getting a program’s answers changed and its time not changed is the worst trade in this book, and nothing in the census could have predicted it. Only the measurement did.

Compare each speedup with its bound, then. A speedup on its own invites you to be pleased with it; as a fraction of the bound it makes you ask where the rest went — and the answer is a tail, or memory, or a loop that was never the bottleneck to begin with. Problem 31.3 is that arithmetic, and it deliberately does not clamp: a result over the bound means something other than the width changed, and the comparison has stopped being between two versions of one loop.

What we measured

Which loops the compiler widens and at what flags, counted from the disassembly, which needs no machine and so is checked by CI on every push. The runner refuses a census in which nothing widens at -O3, nothing further widens under -ffast-math, or the loop with the dependence acquires vector instructions — each of which would leave the chapter asserting something its own evidence had stopped supporting.

Measured on the board: what the widening actually bought, beside what it was allowed to buy. The five rows include the two loops that never widen, which are there as a control — a loop that did not change should measure as not having changed, and they do.

What this cannot tell you

What a different compiler does. Every refusal in that table is this compiler’s, at this version. The reason behind one of them — reassociation changes the answer — is a fact about arithmetic and will hold everywhere. The other two are judgements about cost, and a different compiler is entitled to judge differently.

What a wider or different vector unit does. NEON is a fixed width. Scalable vector extensions on both of this book’s architectures express the same loops without a compile-time width, which changes the tail arithmetic in problem 31.1 fundamentally rather than in degree. The reference machine does not have one, so this book has nothing to say about it that would be worth reading.

Whether to write intrinsics. Nothing here is written in intrinsics, deliberately: a chapter about what the compiler decides cannot be written in a notation that takes the decision away from it. What that costs is that the book never shows you the ceiling — a hand-written version might beat every row of the last table, and this chapter would not know.

Whether any of this is where your time is going. ch30 is the chapter for that, and the order matters. A loop vectorised to a quarter of its scalar cost, in code holding a hundredth of the running time, has bought you well under one per cent.

Problems

Three, in tests/vectors/lanes.c.

31.1 — What the tail costs. Speedup from a lane count and a trip count. The case to get right is the loop shorter than one vector, which gains nothing and pays for all the code.

python3 -m pytest tests/vectors/test_problem_1_tail.py

31.2 — The same numbers, two orders. Write the sequential sum and the lane-wise one, and watch them disagree. This is the refusal above, reproduced rather than described.

python3 -m pytest tests/vectors/test_problem_2_reassociation.py

31.3 — The speedup you were allowed. A measured speedup as a fraction of the arithmetic bound. Do not clamp it: the value over a hundred is the one that tells you something.

python3 -m pytest tests/vectors/test_problem_3_bound.py

Where to go next

The ARM architecture reference manual’s Advanced SIMD chapter Arm Ltd (n.d.) is the specification for what those instructions do, and the RISC-V vector extension RISC-V International (n.d.) is worth reading beside it for the same reason ch28 put two memory models side by side: it solves the tail problem in problem 31.1 by not having a compile-time width at all, and seeing one design makes the other’s choices visible as choices.

That is the last measurement in the book. Appendix A collects the reference cards, and the preface says what this book set out to make you able to do. The test is not whether you remember any of these numbers, but whether, next time something is slow, the first thing you reach for is a measurement.

References
  1. Arm Ltd. (n.d.). Arm Architecture Reference Manual for A-profile Architecture. https://developer.arm.com/documentation/ddi0487/latest/
  2. RISC-V International. (n.d.). The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture. https://riscv.org/technical/specifications/