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.
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:
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 · baseline | cdot | probe | gather | flood (approx) | prune (sort) | total |
|---|---|---|---|---|---|---|
| ms / query | 2.50 | 1.05 | 0.39 | 2.71 | 0.11 | 6.70 |
| share of stage 1 | 37% | 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.
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:
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@10 | scheme | q8 flood | f32 flood | Δ |
|---|---|---|---|---|
| nfcorpus | residual-nbits4 | 0.3809 | 0.3809 | 0.0000 |
| binary-int8x1bit | 0.2875 | 0.2875 | 0.0000 | |
| r4 + asym-LUT | 0.3811 | 0.3811 | 0.0000 | |
| scifact | residual-nbits4 | 0.7609 | 0.7609 | 0.0000 |
| binary-int8x1bit | 0.6865 | 0.6865 | 0.0000 | |
| r4 + asym-LUT | 0.7607 | 0.7607 | 0.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.
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.
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.
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.
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.
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.
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):
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.
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.
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.
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
// 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.
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.
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.
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:
- Parallel transpose + quantize (tq 0.35 → 0.20 ms). Same argument as cdot: the blocks are disjoint and each writes contiguous output, and because min/max over a block is order-independent, the result is bit-identical. Modest (~1.7×), but free — it is the prep that builds chapter 05's padded matrix.
- Sorted probe cells (neutral on the M4). The selected cells came out of a
HashSetin arbitrary order, so postings were read in random cell order. Sorting ~100–200 cell ids costs nothing and makes the postings reads sequential. On the M4 the L2 absorbs the scatter, so it measures as noise — kept anyway for server parts, which is the whole point of chapter 10.
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:
| phase | before | after | ratio | share of pipeline |
|---|---|---|---|---|
| flood (approx) | 2.71 | 0.73 | 3.7× | 40% |
| cdot | 2.50 | 0.64 | 3.9× | 37% |
| probe | 1.05 | 0.11 | 9.5× | 16% |
| gather | 0.39 | 0.10 | 3.9× | 6% |
| prune (sort) | 0.11 | 0.12 | 0.9× | 2% |
| total | 6.70 | 1.66 | 4.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:
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.
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.
- Stage 2 first (classes 02–05). The fused kernels made rescoring cheap — which made stage 1 the dominant cost. You cannot see stage 1 as the problem until stage 2 stops being one.
- The flood next. Inside stage 1, the flood was 62–67% of the time, so it went first (the earlier q8-flood + mechanics commits). Attacking the biggest slice, exactly as chapter 07 prescribes.
- Then everything it was hiding. Knocking the flood down surfaced cdot, probe, and gather as the new leaders — this night's work. The profile you fix is never the profile you inherit.
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.
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.
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.
Count the duplicates before building anything. Duplicate rate: 1.0% (scifact), 4.1% (fiqa-52k).
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.
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 → after | before | after | ratio |
|---|---|---|---|
| x86-64 AVX2 ubuntu-latest | 15.62 | 5.52 | 2.8× |
| Neoverse N2 ubuntu-24.04-arm | 12.63 | 3.81 | 3.3× |
| Apple (macOS VM) macos CI | 16.98 | 4.84 | 3.5× |
| Apple M4 native | 6.70 | 1.66 | 4.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.
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.
Seven questions
Instant feedback, explanations included. Miss one? The chapter number is next to it.