31 · Vectors¶
Chapter header
| Target | host — the reference machine, natively |
| Prerequisites | ch30 |
| Assumes | a vector unit — NEON on the reference core. This chapter became measurable when Part V moved to AArch64; on a RISC-V board without RVV 1.0 it reverts to reasoning about code the compiler emits but the hardware cannot run. |
| What it measures | Which of five loops the compiler widens, at three sets of flags: bench/results/vectors-census.json; and what the widening bought: bench/results/vectors-host.json |
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_gather | 10 (0 vector) | 10 (0 vector) | 10 (0 vector) |
sysfs_vec_running | 11 (0 vector) | 11 (0 vector) | 11 (0 vector) |
sysfs_vec_scale | 9 (0 vector) | 56 (4 vector) | 56 (4 vector) |
sysfs_vec_sum_f32 | 8 (0 vector) | 9 (0 vector) | 37 (5 vector) |
sysfs_vec_sum_i32 | 12 (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: retConditions: 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: retConditions: 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: retConditions: 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¶
| Loop | Measured | Arithmetic bound | Of the bound |
|---|---|---|---|
sysfs_vec_gather | ×0.95 | ×4.0 | 23.8% |
sysfs_vec_running | ×0.98 | ×4.0 | 24.4% |
sysfs_vec_scale | ×2.97 | ×4.0 | 74.3% |
sysfs_vec_sum_f32 | ×1.0 | ×4.0 | 25.0% |
sysfs_vec_sum_i32 | ×1.65 | ×4.0 | 41.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.py31.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.py31.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.pyWhere 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.
- Arm Ltd. (n.d.). Arm Architecture Reference Manual for A-profile Architecture. https://developer.arm.com/documentation/ddi0487/latest/
- RISC-V International. (n.d.). The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture. https://riscv.org/technical/specifications/