nano-plaid · SIMD school
class 06 · builds on classes 02–05 — the stage-2 kernels, the GEMM bar, profiling

The fastest kernel just moves the bottleneck.

Classes 02–05 spent themselves on the stage-2 scoring kernel. But a query pays for stage 1 first — candidate generation — and once the kernel won, stage 1 was suddenly most of the time. This class is one profile-driven night on those five phases: six optimizations, each tied to a hardware principle, and the arithmetic (Amdahl's law) that decides which were worth writing. fiqa-52k stage-1 on an Apple M4: 6.70 ms → 1.66 ms in one night — every number measured.

01 · what a query actually does

The five phases

A two-stage retriever never scores every document. Stage 1 cheaply narrows millions of docs to a shortlist of ~4096; stage 2 — the kernels of classes 02–05 — scores that shortlist precisely. Everything in this class lives in stage 1, and it is exactly five phases. Click any one:

stage 1 · one query, left to right
Click a phase to see what it does. The amber number is its baseline median on the Apple M4, fiqa-52k — the state this class starts from.

The phase names are ours; the profiler prints two of them differently, so once and for all: flood is the phase it labels approx, and prune is the phase it labels sort. Here is the whole pipeline as measured, before this class's night of work touched anything (an earlier round had already quantized the flood — chapter 02 tells that part):

fiqa-52k · M4 · baselinecdotprobegather flood (approx)prune (sort)total
ms / query2.501.05 0.392.710.11 6.70
share of stage 137%16% 6%40%2%100%

These are per-phase medians, so they don't sum exactly to the total median (2.50 + 1.05 + 0.39 + 2.71 + 0.11 = 6.76, not 6.70) — a small honesty the whole class respects: the median of a sum is not the sum of medians. Two phases carry the pipeline — flood (40%) and cdot (37%) — and the rest of this class is a profile-driven walk down that list, re-sorting it after every win.

02 · the biggest slice, defined

What a "flood" is

The flood is PLAID's approximate scoring pass — how stage 1 ranks candidates without ever touching an embedding. The trick is a decision made at index time: every document token was snapped to its nearest centroid, an integer code in 0..K. So for pruning purposes a document is nothing but its list of codes.

That makes MaxSim over centroid-snapped tokens a pure table lookup into the very matrix stage 1 already built. cdot is q · centroids.T — every query token scored against every centroid, shape [nq, K]. A document's approximate score is then:

approx_score(doc) = Σq ∈ query tokens ( maxc ∈ doc codes cdot[q][c] )

No embeddings, no decompression, not a single multiply — just row lookups and maxes into a matrix that already exists. At fiqa-52k that is ~3.5M row-lookups + maxes per query. Watch it on a toy: a 4-token query, six centroids, and a document that snapped to codes 1, 4, 4, 5:

flood one doc — max over its codes, per query row, then sum
running sum · approx_score ·

The doc's codes select three columns (1, 4, 5). Each query row keeps its best value among them; the sum is the whole score. The duplicate code 4 lands on the same column twice — max is idempotent, so it changes nothing. Hold that thought for chapter 09.

Now the licence for everything that follows. The flood may be sloppy, because its output only has to rank candidates for a 4096-deep cut: a document survives as long as it lands anywhere in the top 4096. So the whole cdot matrix can be quantized to u8 before the flood ever reads it — and it costs nothing measurable:

quality gate · u8 flood vs f32 · nDCG@10schemeq8 floodf32 floodΔ
nfcorpusresidual-nbits40.38090.38090.0000
binary-int8x1bit0.28750.28750.0000
r4 + asym-LUT0.38110.38110.0000
scifactresidual-nbits40.76090.76090.0000
binary-int8x1bit0.68650.68650.0000
r4 + asym-LUT0.76070.76070.0000

Gate passed. u8-quantizing the entire matrix is nDCG-identical to four decimals — two datasets, both quant schemes, and under the stage-2 LUT overlay. The 4096-deep prune cut absorbs sub-LSB rank perturbations exactly, so a sloppier-but-cheaper flood is a free lunch. That is why cdot's output can be bytes, which is what makes chapter 05's kernel possible.

03 · the biggest GEMM in the pipeline

cdot: free parallelism, bit for bit

2.50 ms → 0.64 ms. cdot is one GEMM — [nq, dim] · [dim, K], the query's tokens against all K centroids — and at baseline it ran single-threaded on an otherwise idle 8-core machine. The fix is the most boring kind of speedup, and that is the lesson.

hypothesis

The output [nq, K] splits perfectly over K-column blocks into disjoint slices, so it parallelizes with no coordination. rayon is already a dependency, and the flood upstream already parallelizes across the query — this is consistent with stage 1's existing contract, not a new policy.

measurement

Column-block-parallel on 8 idle cores: 2.50 → 0.64 ms (~3.1–3.8× on the phase, diminishing at this matrix size, as expected). And the result is bit-identical to the serial version — tested, every bit.

verdict

Keep. The interesting part is the why-identical.

Why is a parallel GEMM bit-for-bit identical when floating-point addition isn't associative? Because we split outputs, never a reduction. Each output element is one dim-128 dot product, and that whole 128-long summation lives inside a single column block — no block boundary ever falls in the middle of a reduction. The threads compute different elements; not one of them re-orders a single add. Parallelism touched the schedule, never the arithmetic.

principle

Disjoint outputs are free parallelism. When the split lands between independent results and never inside a reduction, you get the cores for nothing and keep every bit — so the equivalence is a one-line proof, not a fixture full of tolerances.

04 · a top-k that barely looks

probe: selection becomes a stream

1.05 ms → 0.11 ms on the M4; 13× on Neoverse. For each query token, probe picks the top-8 IVF cells to search. The old way built a full K-length index buffer (128 KB of writes at K = 32k) and ran select_nth_unstable with indirect comparisons. We never needed the buffer.

Replace "materialize then quickselect" with a single streaming scan that keeps a running threshold — the current 8th-best. Read the K similarities once, in order, one 64-value chunk at a time. Per chunk: compute the chunk max with a branch-free SIMD reduction (~4 vector instructions), and skip the whole chunk unless its max beats the threshold. The scalar per-value path — the actual top-8 insert — almost never runs. Step through it (top-3 here, for screen; 8-value chunks stand in for the real 64):

running-threshold scan · skip a chunk with one comparison
threshold (3rd-best) · —∞ top-3 so far · { } chunks scanned · 0 / 4

Press step. A dashed, dimmed chunk was dismissed by a single predictable branch — no per-value work at all. Only a chunk whose max clears the threshold is opened up.

The common case is a chunk of 64 values retired by ~4 vector instructions plus one branch — and that branch is predictable, because once the threshold climbs, almost every chunk fails it the same way, run after run. Modern CPUs speculate a well-behaved branch for nearly free; it is the unpredictable branch, or the pointer-chasing compare, that stalls a pipeline. Same top-8 set by value; ties stay arbitrary, exactly as select_nth left them.

principle

Turn selection into a stream with a SIMD pre-filter. Don't build a scratch structure to sort — sweep the data once, let a cheap vector reduction gate the expensive scalar path, and lean on the fact that a predictable branch costs almost nothing.

05 · the night's biggest single win

flood: registers beat memory

Flood 2.29 ms → 0.53 ms. The flood is chapter 02's row-lookup-and-max, ~3.5M of them per query. Its two umaxes were never the cost. We proved that by disassembly — echoing class 05's standing rule: you check the codegen, you never assume it.

The old loop was doc-token-outer, and it paid twice per token: it round-tripped the accumulator through memory on every token, and its inner loop had a runtime trip count (nq is a runtime value), so LLVM kept the trip checks, prologue, and epilogue that a static count would delete. Both costs are pure overhead around two max instructions.

before — accumulator in memory, dynamic trip count
for tok in doc.codes:        // outer
  c = tok
  for qi in 0..nq:          // runtime nq!
    a = load acc[qi]           // ↩ memory
    a = max(a, cdot_q8[c][qi])
    store acc[qi], a          // ↩ memory
// per token: nq load+max+store, plus a
// dynamic-length loop's prologue/epilogue
after — [u8;16] in one register, static chunks
// rows padded to a 16-multiple stride:
// pad bytes quantize to 0 = max-neutral,
// and add 0 to the final sum. both free.
for chunk in 0..nq_padded/16:   // outer
  acc16: [u8;16] = 0            // one vreg
  for tok in doc.codes:        // inner
    v = ld1 cdot_q8[tok][chunk] // 16 B
    acc16 = umax acc16, v      // ← the only op
// no acc traffic, no tail; codes re-read
// once per chunk (≤2 passes at nq≤32, L1-hot)

Invert the loops to chunk-outer / tokens-inner and give each 16-wide chunk a [u8;16] accumulator that LLVM promotes to a single vector register. Pad the quantized cdot rows to a 16-multiple stride, and the tail vanishes: the pad bytes quantize to 0, which is the identity for both max and the final sum — two free invariants. Per token now: load a code, load 16 bytes, one umax.16b. That is the whole inner loop, and the disassembly confirms it — exactly one umax.16b, no accumulator spill, no dynamic epilogue. The old per-token load/store + trip machinery was ~2/3 of the flood.

principle

Keep the hot state in a register and make the trip counts static. Padding to a fixed stride is not a memory trick here — it is what lets the accumulator live in a register and the loop straight-line. The maxes were always cheap; the loop around them was the bill.

The transpose-and-quantize prep that builds this padded, code-major u8 matrix is its own sub-phase (chapter 06 parallelizes it). It is why chapter 02's "cdot output can be bytes" matters so much: the byte layout is the register-resident flood.

06 · the address space is a data structure

gather: the address space is the sort

0.41 ms → 0.10 ms. Gather walks the probed cells' IVF postings and produces the candidate list — deduplicated and sorted — for the flood. Doc ids are dense integers, and that one fact hands you the whole algorithm.

Allocate a bitmap with one bit per doc. Walk the concatenated postings once, setting bits; duplicates collapse for free because setting a set bit is a no-op — an O(n) dedup with no hash, no comparisons. Then scan the bitmap in order: every set bit you encounter is the next candidate, and because you scan low address to high, they come out already sorted. No sort call at all — the address space did the sorting. At 52k docs the bitmap is 52000 / 8 = 6.5 KB, small enough to stay resident in L1 for the whole walk.

principle

When your keys are a dense integer range, the address space is already the sort order. A bitmap turns dedup-and-sort into one linear scan — and it fits in L1 precisely because the keys are dense, so there is nothing to pay for the win.

Two more changes rounded out the night, and both are honest about being small:

07 · the arithmetic that ranks your work

Amdahl: the honest headline

Per-phase ratios ranged from 3.6× to 9.5×. The total was 4.0×. That gap is not disappointing — it is Amdahl's law, and it is the single most useful equation for deciding what to optimize next. Here is the whole night, phase by phase:

stage-1 phases · before → after · µs are ms/query (M4, fiqa-52k)
before (baseline) after (this night)
phasebeforeafterratioshare of pipeline
flood (approx)2.710.733.7×40%
cdot2.500.643.9×37%
probe1.050.119.5×16%
gather0.390.103.9×6%
prune (sort)0.110.120.9×2%
total6.701.664.0×100%

The composition is not an average of the ratios — it is:

total_speedup = 1 / Σi ( sharei / speedupi )

Plug the shares and per-phase ratios in and you get back ~4.0×. And it exposes the trap in chasing ratios: the probe's 9.5× was the biggest ratio of the night — and worth the least. Probe is only 16% of the pipeline, so even an infinitely fast probe caps the whole pipeline at 1 / (1 − 0.16) = 1.19×. Toggle each real win on and off and watch which slices actually move the number:

amdahl calculator · click a phase to toggle its optimization
stage-1 time1.70 ms total speedup4.0×

All six wins on: ~4.0×. Now turn everything off except probe — the night's biggest ratio barely moves the total. Turn on only flood and cdot — the two biggest slices carry almost the whole win. That ordering is the strategy.

Two corollaries, both operational. Attack the biggest slice, not the biggest expected ratio — a 3.7× on 40% of the pipeline (flood) beats a 9.5× on 16% (probe), every time. And re-profile after every round, because success reshuffles the profile: the moment the flood shrank, cdot became the top phase, which is the only reason we then went after the GEMM. You are never optimizing the pipeline you started with.

The same law, one level up. This whole 4.0× on stage 1 moved the end-to-end query from only 8.5 ms to 5.0 ms — because stage 2 now dominates. Beat the kernel and the bottleneck walked into stage 1; beat stage 1 and it walks back to stage 2. Amdahl is fractal.

08 · the shape of the whole campaign

Backwards, on purpose

Read the whole optimization campaign end to end — classes 02 through 06 — and it worked through the pipeline in reverse. That was not sloppiness, it was the profile talking. Each win created the measurement that justified the next.

There is no way to have started at cdot. At the start of the campaign it was a rounding error next to the float rescore; it only became worth a parallel GEMM once four other things got out of its way. "Optimize back to front" is just "re-profile and follow the biggest number" run to its conclusion.

09 · the cheapest optimization of the night

The kill

The best thing we did to the flood was not write a change to it. One idea looked clever, clean, and losslessly free — and died in ten minutes, before a line of Rust.

hypothesis

Clustering maps repeated subwords to the same centroid, so a document's code list has duplicates. max is idempotent, so a deduped side array would cut flood iterations losslessly — a free win, like the duplicate 4 in chapter 02's demo.

measurement

Count the duplicates before building anything. Duplicate rate: 1.0% (scifact), 4.1% (fiqa-52k).

verdict

Killed. A second 4-byte-per-token side array to save ≤4% of flood iterations is not worth its own memory, let alone its complexity. No kernel written.

The lesson is the ratio of effort to certainty. That kill cost a ten-minute counting script and bought total confidence the idea was dead; writing it would have cost a kernel, a bit-identity test, and a benchmark to reach the same "no". The cheapest optimization is the one you kill before you write it — and the way you kill it cheaply is to measure the thing it depends on first.

10 · the platform that lies to you

Never trust the M4 alone

Every number so far is an Apple M4 — and the M4 is the least favorable platform these changes have. Its huge, low-latency L2 quietly absorbs exactly the scatter that server parts pay full price for, so it systematically understates every scatter-elimination win.

The probe scan is 6.7× on the M4 (scifact; 9.5× at fiqa-52k) but 13× on a Neoverse N2 (2.23 → 0.17 ms). The sorted-probe-cells change looks like dead weight on the M4 — pure noise — yet it is exactly the sequential-postings win that server parts want. Judge either on Apple silicon alone and you ship the wrong default. Here is the whole night confirmed across four CPUs:

fiqa-52k stage-1 · before → afterbeforeafterratio
x86-64 AVX2 ubuntu-latest15.625.522.8×
Neoverse N2 ubuntu-24.04-arm12.633.813.3×
Apple (macOS VM) macos CI16.984.843.5×
Apple M4 native6.701.664.0×

Shared CI VMs — read the ratios, not the microseconds (the instances differ between runs, but the ratios sit far above runner noise, and every parity gate was green on all three). The M4's 4.0× is the smallest absolute stage-1 time and among the ratios; the server parts had more scatter to eliminate, so they had more to gain.

rule

Never judge a layout change on Apple silicon alone. A change that reads as worthless on the M4 can be a 13× on Neoverse — the M4's cache hierarchy is forgiving in precisely the way that hides scatter costs. Cross-platform CI is not a formality; it is where memory-layout wins become visible.

Where the profile points next. Every change here lives in the dense stage-1 path (corpora up to ~335k docs); the batched-centroid path above that got only the bitmap-gather win so far. And after all of this, cdot (39–42%) is the largest remaining slice, with the flood and the scatter phases now trimmed close to it — a balanced profile is the signal to stop. Every further idea we costed was under 0.1 ms on the table and platform-fragile. The next real chapter is not in this loop at all: it is recall-coupled pruning, which is quality-gated, and a different kind of hard.

11 · check yourself

Seven questions

Instant feedback, explanations included. Miss one? The chapter number is next to it.

next → class 07 The ternary codec: a rung between one bit and two, the one number it turns on — solved, explained, controlled, and bounded