Keyboard Shortcuts

Ctrl+K
Open search
Esc
Close search / dialog
↑
Back to top

Three Answers to One Shape

24 minadvancedModule 3: Where It CostsContinues from simd-boundaries
24 min · SIMD and Vector Processing

Why this matters

Everything measured in this course so far was measured on one machine: an AMD Ryzen 5 7430U, x86-64, with AVX2 and FMA and no AVX-512 at all — five CPUID leaves, all zero, printed in the artifact's section 0. That is a real limit and the artifact's limits block says so in the same words. But “this machine is x86-64” is not an excuse for the rest of the course being x86-shaped, because a vector loop is the one piece of code that most needs to be portable and is hardest to make so.

$ ./simdbench | sed -n '/7\. THE SAME SHAPE/,/three architectures in the room/p'
   7. THE SAME SHAPE ON THREE ARCHITECTURES
      QUOTED, NOT MEASURED.  This machine is x86-64 and there is no other
      architecture in the room.  Every claim below is a manual claim with a
      document, and the limits block says so a second time.
  

So this concept is quoted, and the discipline is worth stating before the quotes: every claim below is a manual claim with a document, and not one of them is a measurement. Where this course measured something, it says so and gives the number. Here it does not, and the distinction is the point — the previous five courses each made a point of it, and this one is no different.

Why the concept exists at all is that the three architectures make structurally different choices about the same two questions, and the differences are not degree-of-support differences. They are the difference between a fixed register width and a chosen one, and between having a gather instruction and not having one. A loop written for one of them and benchmarked on another is not a portable benchmark; it is two different benchmarks wearing one source file.

The model: five rows, and only three of them are portable

Every vector loop, on every machine, answers the same five questions. The questions are the same. The answers are not.

THE FIVE ROWS, AND WHERE THEY AGREE 1. how many elements the register width, which is does one instr. FIXED on x86-64 and on NEON, touch? and PROGRAMMABLE on SVE and on RISC-V V DIFFERS 2. how are they loaded? a consecutive load; and on x86-64 sometimes a gather, which this course measured at 2.78x four scalar loads DIFFERS 3. what does the ELEMENTWISE and INDEPENDENT, operation do? on all three THE SAME 4. how are they N INDEPENDENT LANES, ONE reduced? INSTRUCTION, WITH A REDUCTION AT THE END THE SAME 5. what is the tail? a mask on two of them, a scalar epilogue on one DIFFERS

Rows 3 and 4 are the same on all three architectures, and row 4 is the sentence to take away: N independent lanes, one instruction, with a reduction at the end. That is the portable shape, and it is the same sentence the privilege course and the multiprocessor course each arrived at in their own vocabulary.

Rows 1, 2 and 5 are where a benchmark stops meaning the same thing. Row 1 because the width is a compile-time constant on one architecture and a runtime property on another. Row 2 because a gather exists on x86-64 and does not exist in NEON at all. Row 5 because predication exists on SVE and RISC-V V and does not exist on the other two.

The reality: three architectures, quoted

x86-64 — the one that was measured

The width is a property of the encoding: VEX encodes 128 or 256 bits, EVEX encodes 512 and adds the mask, the broadcast and the embedded-rounding fields. That is why the ISA course's length concept is the place to go for the register-width question and not here. AVX2 added vpgatherdd and vpgatherqd, and the previous concept measured them and found them slower than four ordinary scalar loads. The mask is k0–k7 with 64 bits each, and it is AVX-512 only — absent on this machine, proved rather than assumed. And the tail has no answer on a non-AVX-512 part: handle n mod 4 scalar, which the artifact measured as free.

AArch64 NEON — a fixed 128 bits, and no gather

This is the structural difference, and it surprises people who have only written x86 SIMD. NEON is 128 bits and there is no wider general-purpose SIMD register in the base architecture at all. A 4-wide double loop is 2-wide, and there is nothing to change that. So the loop this course measured at 3.89× does not exist as a single register on AArch64 — it exists as two 2-wide halves, or as four accumulators, which is the independence result from module 2 arriving early and unasked for.

Two more differences, and both are absences that turn out to be features:

SVE — the width is a program property, and the tail is a predicate

SVE is the opposite design and it is worth reading as a deliberate answer to the two problems this course measured. The register size is programmable, up to 2048 bits, so a loop written once runs at whatever width the implementation chooses — and the two problems the previous concepts found both have answers here. Predication: a sequence of predicated instructions, so a tail is a predicate rather than a branch or a scalar epilogue. That is the answer to the 1.77× overlapping-iteration row, and it is not a small answer — it removes the cost of the entire problem.

RISC-V V — v0 is the mask

The V extension has named VLEN-bit register groups, currently VLEN ≥ 128, with the width again a property the implementation chooses. Two features are worth naming precisely because they show where AVX-512 was heading. v0 is the mask register — one bit per element, and v0.t4 selects four 32-bit lanes — and it is part of the register file rather than a separate operand. A masked instruction reads vl elements and ignores the rest, so the tail is a change to vl and nothing else: no epilogue, no branch, no scalar code. And a segment load — vlseg — can fetch a strided run into a vector in one instruction, which is the operation the x86 gather is trying to be, done by a different instruction for a regular pattern rather than an arbitrary index list. vrgather.vv exists for the irregular case, for the same reason x86 has one.

Worked: the same loop, three targets, and what changes

Take the loop this course has been measuring — A[i] = A[i]*B[i] + C[i] over 64 doubles, with a reduction at the end — and ask what each architecture can do with it. Read this as a table of decisions, not of features.

STEP x86-64 NEON SVE / RVV ----------------------------------------------------------- register width 32 bytes 16 bytes VLEN, for doubles (AVX2) and that chosen is ALL at runtime the inner instruction vfmadd fmla fmla + vset one register per ... 4 doubles 2 doubles VLEN/8 a gather of 4 scattered YES, and NO vrgather.vv values MEASURED INSTRUCTION exists 2.78x SLOWER EXISTS than 4 loads the tail scalar scalar PREDICATE (free, (free) (nothing to measured) pay) the fold haddpd, or addp, or addv, or extract-tree extract-tree extract-tree

And the three observations that matter, in order.

One: the portable loop is the one that is slowest on the machine you develop on. If you write the hand-gathered version — four scalar loads assembled into a vector — it is the only option on NEON, it is 2.78× faster than the hardware gather on this x86 machine, and it is roughly comparable to a vrgather on RVV. The one implementation that is bad everywhere is the one that uses the widest instruction available at the time of writing, because that instruction is the one that does not exist on the next target.

Two: the width is a portability hazard, and the fix is to not depend on it. A loop whose inner trip count is n / 4 is written for a 256-bit register. On NEON that silently becomes n / 8 and leaves half the data unprocessed unless the code generator knows the target width — and if the code generator does not, the bug is a wrong answer, not a crash. Code that says “process four at a time” where four means “one register” is not portable code; it is x86 code with a generic-looking loop bound.

Three: the two portable rows are 3 and 4, and they are enough. Elementwise, independent, one instruction, reduce at the end. That compiles to something reasonable on all three. Everything else in the table — the width, the gather, the tail — is where a portable implementation has to give something up, and the interesting engineering question is which something.

THE PRACTICAL SHAPE, and it is the same on all three: four (or N) INDEPENDENT accumulators a loop over CONSECUTIVE elements ONE fold, at the end and the portability work is entirely in: - not assuming the width (row 1) - not assuming a gather exists (row 2) - not assuming you can afford a clever tail (row 5)

Which is a shorter list than it looks, because all three of those are the same mistake: writing the fast case for the machine you have and calling it portable. The measure of a portable vector loop is not that it compiles on three architectures; it is that it is correct on all three and that you can say out loud, for each, what you gave up.

Apply it

  1. Count the lanes in each architecture before you write the loop, not after.(Expect NEON to be the surprise: 128 bits, so two doubles, and no wider option. If you have been writing “four doubles at a time” in a portable-looking loop, that loop has been x86 code all along. The exercise is to find those loops in a codebase that claims to be portable and to notice that for (i = 0; i < n; i += 4) is a claim about a register, not about a language.)
  2. Write the gather three ways and see which one survives translation. Four scalar loads, vpgatherdd, and a segment load for a strided case. (Expect the scalar version to be the only one that exists everywhere, and to be the fastest of the three on the x86 machine you are sitting at. That double win is rare in systems work and it is worth noticing when it happens — the portable choice and the fast choice agreeing is not something to expect by default.)
  3. Find a tail and write it three ways, then ask which is correct on which target. Scalar epilogue, overlapping iteration, predicated. (Expect the scalar one to be correct everywhere and to cost nothing, and expect the overlapping one to be a performance bug on the two architectures that have no mask. The interesting exercise is the third one: implement predication behind a feature check and measure what it is worth on a machine that has it, because that number is the argument for requiring a newer baseline.)
  4. Read a man page for an instruction you do not have. The NEON instruction set page has no gather in it at all, and that absence is the finding. (Expect the page to be organised by register and by data type rather than by operation, which makes it hard to search for “is there an instruction that does X” and easy to establish that there is not. Reading an ISA manual for a machine you do not own is a skill, and this course has no way to teach it any other way.)
  5. Write down, for one loop of yours, what you would lose on each of the three.(Expect the honest answer to be a short list — the width, a gather, a tail — and expect that writing it down is what tells you whether the intrinsics were worth it. A loop whose answer is “nothing, it is the same everywhere” is either genuinely well written or you have not thought about it, and there is no third possibility.)

Connect

Backwards, this concept is the answer to both questions the last two concepts raised. The boundaries concept measured a gather instruction at 2.78× four scalar loads and a tail at 1.77× for the clever approach; NEON has no gather and therefore no gather problem, and SVE and RISC-V V have predication and therefore no tail problem. The measurements were never about the machine — they were about the shape of the access, and the shape is architecture-independent even when the instruction is not. The width concept is where the register table lives, and its third trap — that a 512-bit register is not four times a 128-bit one in practice — is the upper-ZMM aliasing, which is an x86-specific artefact of a fixed-width design and does not arise on a machine where the width is chosen.

Forward, the harness concept is the ledger, and the thing it has to be honest about is this one: every claim in this concept is quoted and none of it is measured, so the harness's group on the three architectures checks that the artifact says so, and nothing else. That is the correct treatment and it is worth noticing how small that group is next to the group on the working-set sweep.

Outward, this is the concept where the course's chain becomes visible. The ISA course's length concept is where VEX and EVEX are laid out and where the 0x62 prefix that introduces a 512-bit instruction is decoded; this concept says what the instruction is for, which is the half the ISA course deliberately left open. The privilege course's vector-19 exception is the one place the word SIMD appeared in five courses, and it is an exception table — a case where the ISA contract has an escape hatch and the hardware agrees. The WebAssembly course's instruction concept defines a 128-bit vector type for machines that do not have one, and this concept is the honest reading of what that buys: a portable subset of all three row-1 answers, which is the NEON one, because 128 bits is the only width every architecture in this course agrees on.

And one outward-facing consequence, because this is the concept a backend author will return to. The portable width is 128 bits and it is portable because it is the smallest, not because it is good. Every performance result in this course was measured at 256 bits, and every one of them would be different at 128 — the working-set collapse in module 1 would happen at half the size. Choosing a target width is choosing which ceiling you will hit first, and the choice is a property of the machine rather than of the program. That is the last thing this course has to say about width, and it is a better place to say it than in module 1, because by now you know what the ceiling is made of.

render_lesson_js(&mut page)