TL;DR
  • Two PRs became next-plaid 1.7.0's speedup: at matched quality, 4.3–5.4× over 1.6.5 on 10 CPU threads, 4.3–5.1× on a single core. #170 (stage 1) is bit-identical to 1.6.5; #169 (int8 rescoring) moves nDCG by at most 0.0002 in a direct A/B.
  • The trick in #169 is the currency, not the skip. WARP already showed you can score codes without decompressing; the speed comes from doing it in int8, against a weight table that fits in one CPU register. No GPU anywhere in this post.
  • Sanity checks, not the thesis: statistically tied with Stanford PLAID at quality ceilings while several times faster; at 57k documents, about 5× faster than a tuned Rust replication of WARP at WARP’s best quality on GTE; on LateOn, it reaches a higher ceiling WARP cannot tune into.

Every number below comes from the same setup: an Apple M4 MacBook Air with every engine running on CPU (no GPU touches the search path), idle, best-of-3 timing over full BEIR query sets (300 SciFact / 648 FiQA), with query embeddings precomputed once and fed to every engine, so no timing includes query encoding: this post measures search, not the model, on a 2×2 grid of checkpoints (LateOn, GTE-ModernColBERT) × corpora (SciFact 5k, FiQA 57k), at 4-bit residuals unless noted, with nDCG@10 scored from the bundled qrels through one shared scorer. The full protocol, including the statistical tests, is at the end.

Two stages, one bill

If you serve ColBERT-family models, you're running some descendant of PLAID, and every query pays for two stages. Stage 1 is deliberately sloppy: score the query against k-means centroids, probe the inverted lists, prune. Stage 2 cleans up after it: every shortlisted document scored completely, every query token against every document token, over the compressed codes the index stores. That full-fidelity cleanup is what I mean by “exact rescoring” throughout.

one query, two stages
query × centroids
f32 GEMM
IVF probe + prune
stage 1: approximate shortlist
exact MaxSim on codes
stage 2: every candidate token scored
top-k

Stage 2 has always been the slow half, for an embarrassingly concrete reason: the textbook implementation decompresses. Every candidate token gets reconstructed to float32 (512 bytes at 128 dims), renormalized, and fed to a dense MaxSim. next-plaid did this too, until these two PRs.

Figure 1 is the bill, itemized. It comes from an earlier profiling session on SciFact and a 15k FiQA slice, so read it for the shape of each bar; the throughput numbers you can hold me to start at Figure 2. The top bar of each pair is 1.6.5; the bottom is the two PRs. Decompression is the single biggest line item on every top bar, and it simply does not exist on the bottom ones. It is also the roadmap for the rest of this post.

stage 1: build shortlist  #170centroid GEMMIVF probecandidate gatherapprox floodprunestage 2: exact rescore  #169decompressMaxSim compute

FiQA-15k (15,000 docs · 2.0M tokens)  top: 1.6.5 · bottom: the two PRs

05101520 msr = 41.6.5r = 4 · 1.6.5 S1 · centroid GEMM: 1.10 ms 6% of this barr = 4 · 1.6.5 S1 · IVF probe: 0.47 ms 2% of this barr = 4 · 1.6.5 S1 · candidate gather: 0.24 ms 1% of this barr = 4 · 1.6.5 S1 · approx flood: 2.65 ms 14% of this barfloodr = 4 · 1.6.5 S1 · prune: 0.22 ms 1% of this barr = 4 · 1.6.5 S2 · decompress: 10.09 ms 53% of this bar; wall split by measured CPU sharedecompressr = 4 · 1.6.5 S2 · MaxSim compute: 4.16 ms 22% of this bar; wall split by measured CPU sharecompute18.9 ms1.7.0r = 4 · 1.7.0 S1 · centroid GEMM: 0.36 ms 12% of this barr = 4 · 1.7.0 S1 · IVF probe: 0.08 ms 3% of this barr = 4 · 1.7.0 S1 · candidate gather: 0.06 ms 2% of this barr = 4 · 1.7.0 S1 · approx flood: 0.34 ms 11% of this barr = 4 · 1.7.0 S1 · prune: 0.09 ms 3% of this barr = 4 · 1.7.0 S2 · MaxSim compute: 2.08 ms 69% of this bar; no decompress phase, the LUT kernel scores the codes directlyfused3.0 ms6.3×

SciFact (5,183 docs · 1.2M tokens)  top: 1.6.5 · bottom: the two PRs

05101520 msr = 41.6.5r = 4 · 1.6.5 S1 · centroid GEMM: 1.51 ms 7% of this barr = 4 · 1.6.5 S1 · IVF probe: 0.60 ms 3% of this barr = 4 · 1.6.5 S1 · candidate gather: 0.19 ms 1% of this barr = 4 · 1.6.5 S1 · approx flood: 2.23 ms 10% of this barfloodr = 4 · 1.6.5 S1 · prune: 0.12 ms 1% of this barr = 4 · 1.6.5 S2 · decompress: 10.95 ms 52% of this bar; wall split by measured CPU sharedecompressr = 4 · 1.6.5 S2 · MaxSim compute: 5.64 ms 27% of this bar; wall split by measured CPU sharecompute21.2 ms1.7.0r = 4 · 1.7.0 S1 · centroid GEMM: 0.45 ms 12% of this barr = 4 · 1.7.0 S1 · IVF probe: 0.11 ms 3% of this barr = 4 · 1.7.0 S1 · candidate gather: 0.06 ms 2% of this barr = 4 · 1.7.0 S1 · approx flood: 0.37 ms 10% of this barr = 4 · 1.7.0 S1 · prune: 0.09 ms 2% of this barr = 4 · 1.7.0 S2 · MaxSim compute: 2.61 ms 71% of this bar; no decompress phase, the LUT kernel scores the codes directlyfused3.7 ms5.8×
Where the milliseconds go, phase by phase. Per-query search medians with precomputed query embeddings, 1.6.5 (top bar) vs the two PRs (bottom): GTE-ModernColBERT embeddings, same prebuilt indexes, single stream, 4-bit residuals (the deployed default; 2- and 1-bit show the same shape). #169 deletes the decompress phase outright and shrinks the remaining compute; #170 shrinks every stage-1 phase. Measured on the PR development branch in an earlier session, on a 15k FiQA slice rather than the 57k corpus below, so these totals will not line up with Figures 2 and 3; the shape is the point. Hover any segment for its numbers.

One asymmetric int8 operation

#169 fits in two equation blocks. Some notation first. Write q for one query token and t for one document token, both vectors of dimension D; MaxSim adds up the best t for each q, so everything below is one query token against one document token. The index keeps a centroid table C, one row per cluster, and the codec keeps a table W of sixteen bucket weights, the values a 4-bit code decodes to. What is actually stored for t is its centroid id cid and one code cd per dimension; t itself is only ever implied. The exact score splits cleanly along that decomposition:

\operatorname{stored}(t) = \left(\mathrm{cid},\; c_1, \ldots, c_D\right)what the index holds: a centroid id and D nbits-wide codes, bit-packed
t_d = C[\mathrm{cid}]_d + W[c_d]the decoded token, dimension by dimension; never materialized
\mathrm{score}(q, t) = \frac{1}{\lVert t \rVert}\Big( \textcolor{#0d9488}{q \cdot C[\mathrm{cid}]} \;+\; \textcolor{#2563eb}{\sum_{d} q_d\, W[c_d]} \Big)centroid term: f32, reused from stage 1  ·  residual term: all the remaining work  ·  ‖t‖ is the norm of the decoded token

The first term is free: stage 1's query×centroid GEMM already computed it, so stage 2 just reads it back. The norm is one scalar per token, computed from the f32 weights and kept in a small sidecar file beside the index; for an index built before 1.7.0 the sidecar is built once at load, about a second per 3M tokens, which is why 1.6.x indexes work unchanged. Everything hangs on the residual term, and until recently every PLAID descendant I have read computed it the same way: decompress each stored token to float32, then run a dense dot product.

WARP (Scheerer et al., SIGIR ’25) showed the decompress step is unnecessary: its “implicit decompression” scores the codes directly through a lookup table. When I first read it I assumed skipping materialization was the whole win. It wasn’t: WARP keeps that table in f32, and when I measured it, removing the copy was necessary but not decisive. The decisive gain came from changing the arithmetic currency. So the bet in #169 is that the currency is what matters, and three things happen when the table drops from f32 to int8, all of them compounding. The table W is tiny to begin with: a 4-bit code can take sixteen values, so the codec stores sixteen bucket weights, one representative residual value per code, shared by every dimension of every token in the index. In f32 those sixteen weights are 64 bytes; in int8 they are 16, which is exactly one SIMD register, and that is what lets the gather below become a single tbl instruction instead of a four-register shuffle. A single sdot then retires 16 multiply-adds where an f32 FMA retires 4. And the working set per token drops 4×, so the kernel stays in L1 right where the f32 version falls out of it. Precision is the price, and I pay it only on the small correction term; the big centroid contribution stays f32:

\hat q_d = \operatorname{round}\!\left(q_d / s_q\right), \qquad s_q = \frac{\max_d \lvert q_d \rvert}{127}int8 query: one scale sq per query token, so its largest entry lands on ±127
\hat W_k = \operatorname{round}\!\left(W_k / s_w\right), \qquad s_w = \frac{\max\lvert W \rvert}{127}int8 weights: one scale sw for all sixteen buckets k
\sum_{d} q_d\, W[c_d] \;\approx\; s_q\, s_w \sum_{d} \hat q_d\, \hat W[c_d]the residual term as integer multiply-adds, rescaled once per (query token, document token)

One terminology note, because “asymmetric” is overloaded. I mean it in the ADC sense from the product-quantization literature: the query is never encoded with the document codebook. The stored side keeps its lossy 4-bit codes from index time while the query gets a fresh, near-lossless int8 encoding at search time, and the two sides meet without a shared quantizer.

Now the part I find genuinely pretty, and it's worth slowing down for even if you've never read a line of SIMD. In numpy terms, the residual term is two lines:

w   = W[codes]   # fancy-index gather: 128 lookups
acc = q8 @ w     # integer dot product

W[codes] is the exact same operation as embedding[input_ids]: fancy indexing. And fancy indexing is where vectorization usually goes to die, because it looks like 128 scattered memory reads per token that the CPU can't do anything clever with.

Except for one accident of sizing: the table has sixteen int8 entries, and sixteen bytes is exactly one 128-bit SIMD register. A register is the CPU's version of a tiny fixed-size numpy array, held inside the core itself and operated on whole. ARM's tbl instruction treats one register as a lookup table and a second as sixteen indices, and hands back all sixteen gathered values in a third. (This is the fast scan trick from Quick(er) ADC (André et al.), already powering ScaNN and faiss's IVFPQFastScan, applied here to PLAID's scalar residual codec instead of PQ subspaces.) The table and the indices never leave registers, and nothing is ever decoded to float. That's W[codes], sixteen dimensions at a time, in one instruction.

Here's the whole residual term for one document token on ARM, lightly simplified from the shipping kernel:

// numpy: acc = q8 @ W[codes]
//
// A NEON register is a 128-bit box: sixteen int8 lanes
// side by side. Every instruction below touches all
// sixteen lanes at once, and lane i of a result depends
// only on lane i of its inputs. 128 dims per token; each
// packed byte holds two 4-bit codes, its low and high
// halves ("nibbles").

// One 16-entry table per nibble position: W, permuted
// into the codec's bit-packing order. Two registers at
// 4 bits (8/nbits in general), loaded once per document.
tab_lo = vld1q_s8(W_lo)
tab_hi = vld1q_s8(W_hi)

// Pass 1, once per document token: expand the 64 packed
// bytes into 128 int8 weights in a stack buffer w.
for i in 0, 16, 32, 48:
  bytes = vld1q_u8(packed + i) // 16 bytes = 32 codes
  lo    = bytes & 0x0F         // lane i = low nibble
  hi    = bytes >> 4           // lane i = high nibble
  // tbl is the one lane-crossing step: lane i of the
  // result is table[lo lane i]. Sixteen lookups in one
  // instruction, and the "memory" it reads is a register.
  vst1q_s8(w + i,      vqtbl1q_s8(tab_lo, lo))
  vst1q_s8(w + 64 + i, vqtbl1q_s8(tab_hi, hi))

// Pass 2, once per query token: the dot product over
// those 128 weights, which are still hot in L1.
// sdot multiplies 16 int8 pairs lane by lane, then folds
// each group of four products into one of the four int32
// lanes. q is the query's int8 lanes, permuted once per
// search into the same plane order as w, so lane i
// always meets lane i.
for k in 0, 32, 64, 96:
  a = sdot(a, vld1q_s8(q + k),      vld1q_s8(w + k))
  b = sdot(b, vld1q_s8(q + k + 16), vld1q_s8(w + k + 16))
acc = a + b

// Per document token: 4 loads, 8 tbl, shared by every
// query token. Per query token: 8 sdot, no float. Four
// query tokens fold at once: a pairwise add puts their
// four sums in one register, then the epilogue
// (sq * sw * acc + centroid) * inv_norm runs in float lanes.

sdot deserves its own sentence: it multiplies sixteen int8 pairs and accumulates the products into int32 lanes, a sixteen-element np.dot per instruction. So the full 128-dim exact dot product for one query-token/document-token pair costs eight instructions, plus an eight-tbl expansion paid once per document token and shared by every query token. The f32 version of the same dot product needs 32 fused multiply-adds, plus the 512-byte decompressed token it operates on; this one needs a quarter of the instructions and 64 packed bytes that are already in cache. That's the 4×-per-instruction, 4×-less-traffic arithmetic behind the roofline numbers below.

If registers, lanes and tbl are new to you, I built an interactive course around this exact kernel: class 05 of the nano-plaid SIMD school steps through it with live widgets, from the first lane to the fused loop.

The asymmetry pays one more dividend. Because the weight table is query-independent, the kernel loads each document token's weights once and scores them against every query token (four at a time on NEON, eight or sixteen on x86, with the float epilogue in registers). Classic ADC builds a fresh query-dependent table for every query token before scoring; keeping the multiply inside sdot deletes that whole step. The expanded int8 weights do pass through a 128-byte stack buffer, and that is the point: written once per document token, read by every query token. Nothing is ever decoded to float, and the kernel never reads past the packed bytes.

(x86 tells the same story with different spellings: pshufb for the lookup, maddubs / AVX-512 vpdpbusd for the dot; every path is parity-tested bit-identical to the scalar reference.)

How much does int8 buy over f32? I benchmarked them like-for-like: an f32 lookup kernel with the identical loop structure runs 28.6 GMAC/s where its int8 twin runs 63.7, a 2.2× gap with everything except the arithmetic held fixed. The 4×-larger f32 working set adds a cache tax on top (my blocked-f32 prototype fell out of L1 and ran slower than its naive form). That is why WARP's f32 LUT, despite deleting decompression, barely moves the stage versus 1.6.5's decompress-then-GEMM (10.5–14.2 µs per rescored document): it changes the step but not the currency. Change the currency and the stage drops to 1.9–3.1 µs, 5–8× across the cross-platform PR benchmarks. (The production check: dropping my int8 kernel into WARP makes the same engine and pass 1.0–1.6× faster (1.3–1.5× at the probe depths where it reaches its ceiling).)

What does int8 cost in quality? Only the correction term is quantized and every norm stays exact, so the answer should be “almost nothing”, and it measures that way: ≤0.002 nDCG@10 across three checkpoints, three corpora, and bit-widths 4 down to 1; in a direct A/B on the same indexes, at most 0.0002 either direction.

Then Amdahl's law takes over: collapse stage 2, and stage 1 is suddenly most of the query. That's the second PR, #170: rework every shortlist phase with five standard algorithm swaps, all output-preserving:

Table 1: what changed in each stage-1 phase (bit-identical output, tested)
phase1.6.51.7.0
centroid GEMMsingle blockcolumn-block parallel
IVF probefill K-length buffer, selectrunning-threshold scan
candidate gathersort to dedupbitmap dedup
approximate floodf32 scoresu8 scores, 16-lane register max
prunefull sortpartial select, sort survivors

In the PRs' own cross-platform benchmarks, stage 1 alone gains 4–12× depending on platform; combined, 5.6–7.1× end-to-end (the matched-quality numbers in Figure 2 are the stricter comparison). Nothing in Table 1 would make a paper, but jointly #170 is worth about as much as #169, and it only became worth doing once rescoring got out of the way. One principle covers both halves: spend precision only where the ranking needs it.

What it buys

Same machine, same embeddings, same queries, same scorer: two models, two corpora, and the original Stanford PLAID (driven as shipped, at its paper presets) as the reference point.

SciFact 5k · LateOn0.7450.7500.7550.7600.7650.770501002004008001,600throughput (QPS, log scale) →nDCG@10 →PLAID (original) · ndocs=256: 291 QPS, nDCG@10 0.7532PLAID (original) · 1024: 141 QPS, nDCG@10 0.7603PLAID (original) · 4096: 53 QPS, nDCG@10 0.7613next-plaid 1.6.5 · n_full=256: 414 QPS, nDCG@10 0.7603next-plaid 1.6.5 · 1024: 225 QPS, nDCG@10 0.7617next-plaid 1.6.5 · 4096: 77 QPS, nDCG@10 0.7622next-plaid 1.7.0 · n_full=256: 1,795 QPS, nDCG@10 0.7601next-plaid 1.7.0 · 1024: 943 QPS, nDCG@10 0.7617next-plaid 1.7.0 · 4096: 322 QPS, nDCG@10 0.76224.3× QPSSciFact 5k · GTE-ModernColBERT0.7500.7550.7600.7650.7700.775501002004008001,600throughput (QPS, log scale) →nDCG@10 →PLAID (original) · ndocs=256: 282 QPS, nDCG@10 0.7620PLAID (original) · 1024: 115 QPS, nDCG@10 0.7618PLAID (original) · 4096: 46 QPS, nDCG@10 0.7618next-plaid 1.6.5 · n_full=256: 320 QPS, nDCG@10 0.7601next-plaid 1.6.5 · 1024: 180 QPS, nDCG@10 0.7602next-plaid 1.6.5 · 4096: 68 QPS, nDCG@10 0.7602next-plaid 1.7.0 · n_full=256: 1,507 QPS, nDCG@10 0.7607next-plaid 1.7.0 · 1024: 808 QPS, nDCG@10 0.7602next-plaid 1.7.0 · 4096: 286 QPS, nDCG@10 0.76024.7× QPSFiQA 57k · LateOn0.5000.5050.5100.5150.5200.52550100200400800throughput (QPS, log scale) →nDCG@10 →PLAID (original) · ndocs=256: 185 QPS, nDCG@10 0.5025PLAID (original) · 1024: 83 QPS, nDCG@10 0.5157PLAID (original) · 4096: 32 QPS, nDCG@10 0.5158next-plaid 1.6.5 · n_full=256: 144 QPS, nDCG@10 0.5155next-plaid 1.6.5 · 1024: 93 QPS, nDCG@10 0.5218next-plaid 1.6.5 · 4096: 40 QPS, nDCG@10 0.5219next-plaid 1.7.0 · n_full=256: 784 QPS, nDCG@10 0.5169next-plaid 1.7.0 · 1024: 411 QPS, nDCG@10 0.5217next-plaid 1.7.0 · 4096: 197 QPS, nDCG@10 0.52194.4× QPSFiQA 57k · GTE-ModernColBERT0.4350.4400.4450.4500.4550.46050100200400800throughput (QPS, log scale) →nDCG@10 →PLAID (original) · ndocs=256: 182 QPS, nDCG@10 0.4415PLAID (original) · 1024: 80 QPS, nDCG@10 0.4530PLAID (original) · 4096: 36 QPS, nDCG@10 0.4533next-plaid 1.6.5 · n_full=256: 116 QPS, nDCG@10 0.4449next-plaid 1.6.5 · 1024: 82 QPS, nDCG@10 0.4525next-plaid 1.6.5 · 4096: 37 QPS, nDCG@10 0.4521next-plaid 1.7.0 · n_full=256: 717 QPS, nDCG@10 0.4448next-plaid 1.7.0 · 1024: 448 QPS, nDCG@10 0.4524next-plaid 1.7.0 · 4096: 198 QPS, nDCG@10 0.45225.4× QPS
PLAID (original)next-plaid 1.6.5next-plaid 1.7.0
Dot size = each engine's shortlist knob, growing right→left: PLAID's ndocs / our n_full_scores 256  1024  4096. The knobs are analogues, not equals: PLAID exactly rescores ndocs/4 of its shortlist (hard-coded), so its “1024” dot rescores 256 documents to next-plaid's 1,024; for a work-matched read, compare PLAID's “4096” dot to 1.7.0's “1024”. Hover any point for its numbers.
Three generations, four workloads, one protocol. 10 threads; right and up is better. Columns = model (LateOn left, GTE-ModernColBERT right); rows = corpus. Hollow & dashed = the previous variant, as in every figure. SciFact panels plot means over 5 index builds; each arrow is the like-for-like 1.6.5→1.7.0 gain, comparing each engine's fastest setting within 0.003 nDCG of its own ceiling. Full numbers in Table 2.

Three things I'd want you to take from Figure 2:

1.6.5 and PLAID are the same design, and they measure like it. 1.6.5 is PLAID's architecture in Rust, so the two are close to a tie, with 1.6.5 holding a modest edge worth about what removing the Python boundary buys. There is no quality gap hiding under the speed story either: pooled over all 1,896 paired queries, PLAID vs 1.7.0 is −0.0007, equivalent within ±0.005. The jump only appears once the architecture changes.

One index build is not enough to compare engines. Rebuilding both engines' indexes with five k-means seeds moved a single build's nDCG by ±0.003–0.008, which is as large as the difference between engines, and my first builds happened to favor PLAID's rivals. So the SciFact panels plot the mean over five builds.

PLAID was tuned, not run on defaults. Beyond its paper presets, I swept ncells × ndocs × centroid threshold jointly, 28 configurations per workload. The best of those moves PLAID's frontier by at most 15–25% at matched quality and never comes close to the 1.7.0 curve.

Table 2: the full numbers behind Figure 2, including single-thread
Table 2: lineage at matched quality (each engine's highest-throughput setting with nDCG within 0.003 of its own ceiling; SciFact nDCG = mean over 5 index-build seeds, FiQA = single build)
corpusengine10-thread QPS1-thread QPSnDCG@10
SciFact · LateOnPLAID (original)141550.7603
next-plaid 1.6.5414810.7603
next-plaid 1.7.01,7953480.7601
SciFact · GTEPLAID (original)2821160.7620
next-plaid 1.6.5320720.7601
next-plaid 1.7.01,5073240.7607
FiQA-57k · GTEPLAID (original)80320.4530
next-plaid 1.6.582260.4525
next-plaid 1.7.04481340.4524
FiQA-57k · LateOnPLAID (original)83410.5157
next-plaid 1.6.593310.5218
next-plaid 1.7.04111420.5217

Bonus round: the engine that skips the stage

If exact rescoring is nearly free, the natural cross-examination is a design built on the opposite bet. WARP (SIGIR '25) folds everything into one approximate pass: lookup-table scoring over the probed cells, plus a learned estimate (t′) for every token the probe never visits:

WARP: one pass, no second stage
query × centroids
score probed cells
lookup-table decomposition
impute the rest
t′ estimate for unvisited tokens
top-k
estimate is final

Disclosure up front: what I benchmark is xtr-warp-rs v2.0.2, an independent Rust replication of WARP with its own adaptive controls (not the official reference implementation). It is a fairly optimized engine rather than a naive port; its author explains the design in WARP vs PLAID: inside the SOTA in multi-vector retrieval. On top of it come my patches: precomputed-embedding input, runtime arm toggles, and my int8 kernel donated into its scoring loop. I tuned it past its own auto-tuner where that helped, and I quote its best arm throughout. This isn't a horse race I staged to win; it's the cleanest test I could build of whether skipping the exact stage still buys anything once that stage is nearly free.

The comparison runs on the same 2×2 grid as Figure 2, so the frame carries over: corpus down the rows, model across the columns, throughput right, quality up. Only the cast changes. Each engine appears in both its current and its previous form, and every panel answers one question: once the exact stage is nearly free, does skipping it still buy anything? The panels disagree, and they split on corpus size rather than on model.

SciFact 5k · LateOnceilings tie; every point at 0.7598+ is next-plaid’s0.700.750.801002004008001,600throughput (QPS, log scale) →nDCG@10 →WARP f32 · nprobe=4: 1,905 QPS, nDCG@10 0.7320WARP f32 · 8: 1,567 QPS, nDCG@10 0.7474WARP f32 · 16: 1,112 QPS, nDCG@10 0.7542WARP f32 · 32: 717 QPS, nDCG@10 0.7563WARP f32 · 64: 410 QPS, nDCG@10 0.7533WARP + my kernel · nprobe=4: 2,050 QPS, nDCG@10 0.7332WARP + my kernel · 8: 1,765 QPS, nDCG@10 0.7481WARP + my kernel · 16: 1,366 QPS, nDCG@10 0.7545WARP + my kernel · 32: 935 QPS, nDCG@10 0.7565WARP + my kernel · 64: 578 QPS, nDCG@10 0.7535next-plaid 1.6.5 · n_full=256: 379 QPS, nDCG@10 0.7586next-plaid 1.6.5 · 1024: 220 QPS, nDCG@10 0.7607next-plaid 1.6.5 · 4096: 78 QPS, nDCG@10 0.7607next-plaid 1.7.0 · n_full=256: 1,474 QPS, nDCG@10 0.7598next-plaid 1.7.0 · 512: 1,185 QPS, nDCG@10 0.7622next-plaid 1.7.0 · 1024: 868 QPS, nDCG@10 0.7625next-plaid 1.7.0 · 2048: 567 QPS, nDCG@10 0.7625next-plaid 1.7.0 · 4096: 338 QPS, nDCG@10 0.7625SciFact 5k · GTEWARP is the faster engine here; ceilings tie0.700.750.801002004008001,600throughput (QPS, log scale) →nDCG@10 →WARP f32 · nprobe=4: 1,581 QPS, nDCG@10 0.7564WARP f32 · 8: 1,313 QPS, nDCG@10 0.7614WARP f32 · 16: 956 QPS, nDCG@10 0.7629WARP f32 · 32: 628 QPS, nDCG@10 0.7641WARP f32 · 64: 354 QPS, nDCG@10 0.7604WARP + my kernel · nprobe=4: 1,642 QPS, nDCG@10 0.7564WARP + my kernel · 8: 1,424 QPS, nDCG@10 0.7614WARP + my kernel · 16: 1,164 QPS, nDCG@10 0.7624WARP + my kernel · 32: 818 QPS, nDCG@10 0.7642WARP + my kernel · 64: 476 QPS, nDCG@10 0.7604next-plaid 1.6.5 · n_full=256: 320 QPS, nDCG@10 0.7585next-plaid 1.6.5 · 1024: 195 QPS, nDCG@10 0.7609next-plaid 1.6.5 · 4096: 74 QPS, nDCG@10 0.7609next-plaid 1.7.0 · n_full=256: 1,325 QPS, nDCG@10 0.7610next-plaid 1.7.0 · 512: 1,089 QPS, nDCG@10 0.7607next-plaid 1.7.0 · 1024: 790 QPS, nDCG@10 0.7607next-plaid 1.7.0 · 2048: 522 QPS, nDCG@10 0.7607next-plaid 1.7.0 · 4096: 316 QPS, nDCG@10 0.7607FiQA 57k · LateOnWARP plateaus 0.024 below, and cannot tune out of it0.400.450.50100200400800throughput (QPS, log scale) →nDCG@10 →exact MaxSim 0.5202WARP f32 · nprobe=4: 932 QPS, nDCG@10 0.3845WARP f32 · 8: 751 QPS, nDCG@10 0.4178WARP f32 · 16: 526 QPS, nDCG@10 0.4349WARP f32 · 32: 345 QPS, nDCG@10 0.4492WARP f32 · 64: 203 QPS, nDCG@10 0.4817WARP f32 · 128: 105 QPS, nDCG@10 0.4984WARP + my kernel · nprobe=4: 1,018 QPS, nDCG@10 0.3846WARP + my kernel · 8: 873 QPS, nDCG@10 0.4170WARP + my kernel · 16: 668 QPS, nDCG@10 0.4352WARP + my kernel · 32: 471 QPS, nDCG@10 0.4489WARP + my kernel · 64: 297 QPS, nDCG@10 0.4819WARP + my kernel · 128: 157 QPS, nDCG@10 0.4983next-plaid 1.6.5 · n_full=256: 168 QPS, nDCG@10 0.5155next-plaid 1.6.5 · 1024: 128 QPS, nDCG@10 0.5218next-plaid 1.6.5 · 4096: 70 QPS, nDCG@10 0.5219next-plaid 1.7.0 · n_full=256: 763 QPS, nDCG@10 0.5169next-plaid 1.7.0 · 512: 693 QPS, nDCG@10 0.5196next-plaid 1.7.0 · 1024: 610 QPS, nDCG@10 0.5217next-plaid 1.7.0 · 2048: 463 QPS, nDCG@10 0.5218next-plaid 1.7.0 · 4096: 323 QPS, nDCG@10 0.5219FiQA 57k · GTE5× faster at WARP’s best quality0.350.400.450.5050100200400800throughput (QPS, log scale) →nDCG@10 →exact MaxSim 0.4556WARP f32 · nprobe=4: 821 QPS, nDCG@10 0.3897WARP f32 · 8: 666 QPS, nDCG@10 0.4109WARP f32 · 16: 477 QPS, nDCG@10 0.4244WARP f32 · 32: 297 QPS, nDCG@10 0.4361WARP f32 · 64: 167 QPS, nDCG@10 0.4432WARP f32 · 128: 90 QPS, nDCG@10 0.4466WARP + my kernel · nprobe=4: 866 QPS, nDCG@10 0.3908WARP + my kernel · 8: 757 QPS, nDCG@10 0.4109WARP + my kernel · 16: 592 QPS, nDCG@10 0.4244WARP + my kernel · 32: 428 QPS, nDCG@10 0.4361WARP + my kernel · 64: 250 QPS, nDCG@10 0.4432WARP + my kernel · 128: 122 QPS, nDCG@10 0.4465next-plaid 1.6.5 · n_full=256: 122 QPS, nDCG@10 0.4449next-plaid 1.6.5 · 1024: 98 QPS, nDCG@10 0.4525next-plaid 1.6.5 · 4096: 58 QPS, nDCG@10 0.4521next-plaid 1.7.0 · n_full=256: 633 QPS, nDCG@10 0.4448next-plaid 1.7.0 · 512: 621 QPS, nDCG@10 0.4490next-plaid 1.7.0 · 1024: 529 QPS, nDCG@10 0.4524next-plaid 1.7.0 · 2048: 408 QPS, nDCG@10 0.4522next-plaid 1.7.0 · 4096: 294 QPS, nDCG@10 0.4522
next-plaid 1.7.0next-plaid 1.6.5WARP + my kernelWARP f32
Dot size = each engine's knob, growing right→left. next-plaid n_full_scores 256  512  1024  2048  4096; WARP nprobe 4  8  16  32  64  128 (128 reached on FiQA only, with hand-swept t′). Hover any point for its numbers.
WARP against next-plaid, on the same grid as Figure 2. Batch, 10 threads; right and up is better. Rows = corpus, columns = model. Every panel spans an identical 0.15 nDCG, so a vertical distance means the same thing in all four; on the SciFact row the two ceilings sit within a few thousandths of each other, well inside this query set's ±0.013 resolution limit; only the cheap WARP settings fall away. Solid = current (next-plaid 1.7.0; WARP with my int8 kernel); hollow & dashed = the variant each replaces, as in every figure. WARP is fully tuned, with t′ hand-swept past its own auto-tuner where that helped. Dashed grey on the FiQA row = the float32-exhaustive MaxSim reference. Single-thread numbers for the two cells measured at both thread counts are in Table 3.

On 5k documents (top row), the two designs reach the same quality; next-plaid is faster on LateOn, WARP on GTE. The ceilings tie: on LateOn they differ by less than this query set can resolve (±0.013), so the race is decided on cost, and every frontier point at nDCG ≥ 0.7598 is next-plaid's. What differs is where each design lets you trade. WARP's cheap settings pay for speed in ranking quality (up to −0.029 nDCG at nprobe=4); next-plaid's cheap settings stay on the plateau. An exact second stage means the speed knob stops being a quality knob. On SciFact·GTE WARP wins outright on throughput, leading at every quality level (1,424 vs 1,325 QPS at its ceiling) with the ceilings tied within noise; at 5k with this model the one-pass design is simply the faster engine. And my kernel makes WARP 1.0–1.6× faster (1.3–1.5× at the probe depths where it reaches its ceiling) while changing its rankings not at all; the int8 arithmetic was never the point.

At 57k (bottom row), the exact stage's economics improve and the panels separate. The probe has to reach 4× further for the same quality (appendix below), and the one-pass design either cedes the frontier at every quality above nDCG 0.42 (GTE, where next-plaid is about 5× faster at WARP's best quality, and WARP's only frontier points are its two shallowest probes, well below next-plaid's cheapest setting) or hits a ceiling it cannot tune its way out of: on LateOn, WARP plateaus 0.024 nDCG below next-plaid's ceiling (p<10−4) at 4.4× the per-query cost, the grid's only quality gap that survives multiple-comparison correction, while next-plaid matches the float32-exhaustive reference. Fairness cuts both ways: WARP's auto-tuner mistuned at 57k, so I hand-swept t′ and quote its tuned ceiling, and the same sweep of next-plaid's own knobs showed the historical n_full_scores=4096 default buys nothing over 1024 here.

Table 3: the same comparison on a single thread
Table 3: single-thread numbers for the two cells measured at both thread counts. Same indexes, same queries, same session as the batch figure; only the thread count differs.
workloadenginesettingQPSnDCG@10
SciFact 5k · LateOnnext-plaid 1.7.0n_full=2562780.7598
5122290.7622
10241680.7625
2048880.7625
4096580.7625
next-plaid 1.6.5n_full=256820.7586
1024360.7607
4096150.7607
WARP + my kernelnprobe=47070.7332
85590.7481
162640.7545
322020.7565
641430.7535
WARP f32nprobe=45970.7320
84400.7474
161900.7542
321330.7563
64900.7533
FiQA 57k · GTEnext-plaid 1.7.0n_full=2561410.4448
5121240.4490
10241100.4524
2048860.4522
4096640.4522
next-plaid 1.6.5n_full=256330.4449
1024250.4525
4096140.4521
WARP + my kernelnprobe=42120.3908
81770.4109
161330.4244
32950.4361
64580.4432
128310.4465
WARP f32nprobe=41860.3897
81440.4109
16980.4244
32640.4361
64360.4432
128190.4466
Table 4: FiQA-57k retention vs the float32-exhaustive MaxSim baseline (computed from each cell's uncompressed scores). With GTE the retention difference is undetectable (paired p=0.11); with LateOn it is not: WARP's imputation leaves 4.2% of the baseline behind (p<10−4) while next-plaid shows no detectable difference from it (95% CI [−0.002, +0.005]).
modelenginebest nDCG@10% of float32 nDCGQPS there (batch)
GTE (exact 0.4556)next-plaid 1.7.00.452499.3%529
WARP, jointly tuned0.446698.0%122
LateOn (exact 0.5202)next-plaid 1.7.00.5217100.3%610
WARP, jointly tuned0.498495.8%157
Appendix: why scale favors the exact stage: the probe knob means different things
Table 5: why scale favors the exact stage: the probe knob means different things
SciFact 5kFiQA 57k
tokens per k-means cell72.7234.8 (3.2×)
WARP probes to reach its ceiling32128 (4×); probe depth is its quality knob
next-plaid probe for 99.2% of exhaustive22; probe depth is only a recall knob

The takeaway

One asymmetric operation in the right place: quantize only the correction term, keep it in registers, and the exact stage of a PLAID-family engine stops being the slow half: 5–8× cheaper, on your existing indexes, at the same ranking quality. The other half of the win is stage 1: five standard techniques that only started mattering once rescoring got out of the way. The old trade of exact but slow versus fast but capped turns out, at the scales and models I tested, to have been an artifact of decompress-then-score all along. Int8 implicit decompression ships as the default in next-plaid 1.7.0 (#169 + #170); existing 1.6.x indexes load unchanged. Across the models, corpora, and scales tested here, full-quality rescoring no longer costs meaningful speed. So stop trading quality for it: run at the ceiling, and let the probe knob be a recall knob.

Methodology and statistical protocol

Setup. Apple M4 (ARM NEON + dotprod SIMD; both Rust engines dispatch per-ISA kernels at runtime), idle box, best-of-3 timing over full BEIR query sets (300 SciFact / 648 FiQA); a 2×2 grid of checkpoints (LateOn, GTE-ModernColBERT) × corpora (SciFact 5k, FiQA 57k); 4-bit compression unless noted; nDCG@10 from the bundled qrels with one shared scorer.

Figure 1. The phase breakdown is a separate, earlier single-stream session on the PR development branch against upstream v1.6.5: same prebuilt indexes, 50 length-realistic GTE-ModernColBERT queries, per-phase medians over 100 timed queries, on SciFact and a 15,000-document FiQA slice; 1.6.5's stage-2 wall is split decompress vs compute in proportion to CPU time measured inside its scoring loop, and its quality parity claim rests on the route A/B and Table 4, not that session.

Query embeddings and harnesses. Every engine receives identical precomputed query embeddings at their true variable lengths, so query encoding is excluded from every latency and QPS figure in this post; the numbers measure the search engine alone, and end-to-end latency in a deployment adds the encoder's cost on top (PLAID via a stub-encoder harness around its Python/C++ search path, with query_maxlen raised to each set's longest query; its default of 32 silently truncates candidate generation).

Sessions and thermal control. Figure-2 and Table-2 timings for all three engines and both thread counts come from one back-to-back session (Aug 31). Figure 3 and Tables 3 and 4 come from a separate single session (Sep 2) in which all four engine variants were measured back-to-back within each cell, interleaved knob by knob. The machine is a fanless MacBook Air and throttles under sustained load: across uncontrolled sessions the same 1.7.0 binary and setting measured anywhere from 270 to 630 QPS at 10 threads. The Sep 2 session therefore gated every 10-thread measurement on a canary (1.7.0, FiQA·GTE, n_full=1024) reading within 6% of its cold value of 588 QPS, resting between measurements until it did; the canary stayed between 542 and 585 across 56 checks. The Figure-2 session predates that protocol, so throughput is only ever compared within a figure, never across figures. I did not remeasure Figure 2 under the gate: its 1.6.5-to-1.7.0 ratios are same-session, and the PRs' own cross-platform benchmarks (the source of the 5.6–7.1× quoted above) independently put the combined speedup above 4× on every platform tested.

PLAID tuning. The PLAID tuning sweep is 28 joint configurations per workload over ncells ∈ {1, 2, 4, 8} × ndocs ∈ {256, 1024, 4096} × centroid-score-threshold ∈ {0.4, 0.45, 0.5} (its CPU search path requires a threshold; threshold-free runs raise a TypeError).

Statistics. Quality claims use paired per-query sign-flip permutation tests (20k flips) with bootstrap CIs. Ceiling comparisons are Holm-corrected as one family of five paired tests: PLAID vs 1.7.0 and WARP vs 1.7.0 in each of the two grid-completion cells: SciFact·GTE, where WARP's ceiling was tested at both nprobe=32 and 64, and FiQA·LateOn. The pooled PLAID-vs-1.7.0 analysis clusters sign-flips by query (948 clusters, 1,896 observations) because each query appears under both models; its equivalence claim is TOST at a ±0.005 nDCG margin (90% CI within the margin). Quoted resolution limits (±0.004–0.013 nDCG) are 95% bootstrap CI halfwidths of the paired per-query delta between the engines compared: resolution limits, not equivalence proofs.

Seed study and route A/B. Both engines' SciFact indexes rebuilt at 5 k-means seeds each (only the seed varied) and re-benched at every knob setting; Figure 2's SciFact points and Table 2's SciFact nDCG are means over the 5 draws (throughput from the matched campaign runs; it does not depend on the draw); seed-study benches used the f32 residual route, which the int8 route tracks within 0.0002 nDCG. Route A/B (on the PR branch, before its residual_asym flag was removed at merge): same index, same binary, only the flag flipped; three cells (SciFact·LateOn, SciFact·GTE, FiQA·LateOn), n_full 1024/4096, 1 and 10 threads.

WARP provenance and sessions. The WARP engine benchmarked is xtr-warp-rs v2.0.2, a Rust, WARP-derived implementation with its own adaptive heuristics rather than a bit-for-bit reproduction of the reference, chosen deliberately so the WARP-vs-next-plaid comparison shares one language and runtime model; the PLAID comparison keeps the official reference instead, driven through its Python API, so PLAID timings include Python orchestration its C++ kernels don't see. WARP variants compiled into one binary and toggled at runtime; all four curves in every Figure 3 panel, including the 1.6.5 reference, come from the Sep 2 session described above. Hover any chart point for its exact numbers.

Citation

If this post is useful in your work, please cite it as:

@misc{hsu2026slowhalf,
  author = {Hsu, Chao-Chun},
  title = {The Slow Half of {PLAID}: Making Exact Rescoring
           Cheap with One Int8 Operation},
  year = {2026},
  month = sep,
  howpublished = {Blog post},
  url = {https://chaochunhsu.github.io/blog/slow-half-of-plaid/}
}

References

  1. Keshav Santhanam, Omar Khattab, Christopher Potts, Matei Zaharia. PLAID: An Efficient Engine for Late Interaction Retrieval. CIKM 2022. arXiv:2205.09707. Reference implementation: stanford-futuredata/ColBERT.
  2. Jan Luca Scheerer, Matei Zaharia, Christopher Potts, Gustavo Alonso, Omar Khattab. WARP: An Efficient Engine for Multi-Vector Retrieval. SIGIR 2025. arXiv:2501.17788. Reference implementation: jlscheerer/xtr-warp; the Rust replication benchmarked here: pau-mensa/xtr-warp-rs v2.0.2, described by its author in WARP vs PLAID: inside the SOTA in multi-vector retrieval.
  3. Omar Khattab, Matei Zaharia. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. SIGIR 2020. arXiv:2004.12832.
  4. Keshav Santhanam, Omar Khattab, Jon Saad-Falcon, Christopher Potts, Matei Zaharia. ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction. NAACL 2022. arXiv:2112.01488. The residual codec next-plaid inherits.
  5. Hervé Jégou, Matthijs Douze, Cordelia Schmid. Product Quantization for Nearest Neighbor Search. IEEE TPAMI 2011. The source of the asymmetric-distance (ADC) vocabulary.
  6. Fabien André, Anne-Marie Kermarrec, Nicolas Le Scouarnec. Quicker ADC: Unlocking the Hidden Potential of Product Quantization with SIMD. IEEE TPAMI 2019. arXiv:1812.09162. The register-resident table lookup (“fast scan”).
  7. Ruiqi Guo, Philip Sun, Erik Lindgren, Quan Geng, David Simcha, Felix Chern, Sanjiv Kumar. Accelerating Large-Scale Inference with Anisotropic Vector Quantization. ICML 2020. arXiv:1908.10396 (ScaNN).
  8. Matthijs Douze et al. The Faiss Library. 2024. arXiv:2401.08281 (IVFPQFastScan).
  9. Nandan Thakur, Nils Reimers, Andreas Rücklé, Abhishek Srivastava, Iryna Gurevych. BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. NeurIPS 2021 Datasets and Benchmarks. arXiv:2104.08663. Source of the SciFact and FiQA query sets and qrels.
  10. next-plaid: lightonai/next-plaid. The two pull requests: #169 (int8 asymmetric rescoring) and #170 (stage-1 rework), released in 1.7.0.
  11. Checkpoints: lightonai/GTE-ModernColBERT-v1 and lightonai/LateOn-regularized.