Top-k in a heap, and integer kernels that use the instructions built for them - #54
Merged
Merged
Conversation
Every accepted candidate triggered an O(k) rescan of all k slots to find the new
worst entry, and the final ordering was an O(k^2) exchange sort. For a scan where
the distance itself is cheap - 1-bit Hamming over a preloaded index is 96 bytes
of popcount per row - that bookkeeping was the query.
The candidate set is now a binary max-heap over (distance, rowid) in the parallel
arrays the cursor already owns: the root is the worst entry still in the set, so
admitting a candidate is one comparison plus O(log k), and the final ordering is
a heapsort in place. 20k rows, dim 768, 1BIT preloaded, ms per query:
k=10 0.06 -> 0.05
k=100 0.10 -> 0.06
k=1000 3.17 -> 0.16 20x
k=4000 33.46 -> 0.55 61x
Small k is unchanged, as expected - there was never much to rescan.
This also removes a latent out-of-bounds read. max_index was never reset between
filters, so a cursor reused with a smaller k (a correlated subquery, or a join
where k varies per row) indexed the reallocated array with a stale offset. A
heap root is always slot zero, so there is no index to go stale.
Tie-breaking among equal distances changes: neither sort is stable and the old
order was not a guarantee. Across 144 top-k queries covering every distance,
quantization type, and both scan shapes, all 144 return the same multiset of
distances; 136 return the same rows in the same order, and the 8 that differ are
cases where several rows tie at the k-th distance and either choice is equally
correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integer kernels widened every byte to 32 bits before multiplying - twelve or
more instructions per 16 bytes - and accumulated on a single dependency chain.
Both architectures have instructions for exactly this shape:
* NEON: absolute difference, widening multiply, pairwise-accumulate. Five
instructions per 16 bytes instead of twelve.
* x86: PSADBW sums the absolute differences of a whole vector in one go, which
is L1 outright; PMADDWD multiplies 16-bit pairs and adds adjacent products
into 32-bit lanes, which is a squared difference or a dot product depending on
what you feed it. Two byte-sized factors always fit 16 bits, and PMADDWD's
pairwise add keeps the running value inside 32 bits.
The signed kernels reuse the unsigned ones where the arithmetic allows: biasing
an int8 by 0x80 maps it onto uint8 without changing any difference between two
elements, so L2 and L1 are the unsigned kernel plus one XOR per vector. Dot and
cosine need the true signed values and get their own widening path. AVX2 and
AVX-512 cosine also stop making three separate passes over the data.
Plus four accumulators, as the f32 kernels already had.
NEON, dim 768, cache-resident (Mvec/s):
L2 SQ_L2 COSINE DOT L1
u8 18 -> 68 18 -> 73 12 -> 29 15 -> 83 22 -> 98
i8 18 -> 53 18 -> 53 12 -> 29 15 -> 82 25 -> 74
End to end this is the result that actually moves, because u8 rows are a quarter
the size of f32 and the scan is compute-bound rather than bandwidth-bound. A
quantized scan over 60k rows at dim 768:
preloaded 17.4 -> 62.6 Mvec/s 3.6x
from disk 13.1 -> 29.2 Mvec/s 2.2x
Accuracy improves: the reductions now widen to 64 bits before folding lanes -
each lane is itself a running total, and folding them in 32 bits capped the
usable dimension well below what the accumulators could hold - and cosine sums
exact integers and divides in double rather than accumulating in float. Against
a double-precision reference across 25 dimensions including tails, u8/i8 cosine
goes from 5.9e-08 to 2.9e-08 worst error, and every other integer kernel is
unchanged or better.
Verified on CPU, NEON, SSE2 and AVX2 with that reference harness, and end to end
across 144 top-k queries covering every distance, quantization type and scan
shape: 136 return identical rows, and the 8 that differ are ties where either
choice is equally correct. AVX-512 is compile-verified here; the avx512 CI job
runs the suite on those kernels under Intel SDE.
SSE2 is left alone: it is only selected on a CPU with neither AVX-512 nor AVX2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
marcobambini
changed the base branch from
fix/audit-memory-safety-and-simd
to
main
August 24, 2026 20:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #53 — review that one first, and this diff shows only the two commits
here. The two changes are independent of each other; both were measured before
and after, and both were checked for result equivalence, not just for speed.
Top-k in a heap
Every accepted candidate triggered an O(k) rescan of all k slots to find the new
worst entry, and the final ordering was an O(k²) exchange sort. Where the
distance itself is cheap — 1-bit Hamming over a preloaded index is 96 bytes of
popcount per row — that bookkeeping was the query. 20k rows, dim 768, ms per
query:
Small k is unchanged, as expected — there was never much to rescan.
It also removes a latent out-of-bounds read:
max_indexwas never reset betweenfilters, so a cursor reused with a smaller k (a correlated subquery, or a join
where k varies per row) indexed the reallocated array with a stale offset. A
heap root is always slot zero.
Integer kernels
The u8/i8 kernels widened every byte to 32 bits before multiplying — twelve or
more instructions per 16 bytes — on a single accumulator chain. Both
architectures have instructions for exactly this shape. NEON: absolute
difference, widening multiply, pairwise-accumulate, five instructions per 16
bytes. x86:
PSADBWsums the absolute differences of a whole vector in one go,which is L1 outright, and
PMADDWDmultiplies 16-bit pairs and adds adjacentproducts into 32-bit lanes, which is a squared difference or a dot product
depending on what you feed it.
The signed kernels reuse the unsigned ones where the arithmetic allows: biasing
an int8 by 0x80 maps it onto uint8 without changing any difference between two
elements, so L2 and L1 become the unsigned kernel plus one XOR per vector. Dot
and cosine need the true signed values. AVX2 and AVX-512 cosine also stop making
three separate passes over the data.
NEON, dim 768, cache-resident (Mvec/s):
End to end this is the change that moves, because u8 rows are a quarter the size
of f32 and the scan is compute-bound rather than bandwidth-bound. Quantized scan
over 60k rows at dim 768: 17.4 → 62.6 Mvec/s preloaded (3.6×), 13.1 → 29.2
from disk. For comparison, the f32 kernel work in #53 only reached 1.6×
end to end, because there the scan is waiting on memory.
Accuracy improves rather than degrades. The reductions now widen to 64 bits
before folding lanes — each lane is itself a running total, and folding eight or
sixteen of them in 32 bits capped the usable dimension well below what the
accumulators could hold — and cosine sums exact integers and divides in double
instead of accumulating in float. Against a double-precision reference across 25
dimensions including tails, u8/i8 cosine goes from 5.9e-08 to 2.9e-08 worst
error, and every other integer kernel is unchanged or better.
Verification
avx512job ran the full suite on these kernels under Intel SDE —distance backend: AVX512, 1447/1447. Both runs so far landed on AMD EPYCrunners with no AVX-512, so the emulated path is the one that got exercised.
scan shapes. 136 return identical rows; the 8 that differ are ties where either
choice is equally correct. All 144 equivalent.
Notes for review
old order was not a guarantee, but it is observable.
AVX-512 nor AVX2.
uint32_t, which wrapsabove dimension ≈ 66,000. That is now the only backend with the limit; it is
pre-existing and not addressed here.
not in this PR. Measured: the prefetch does nothing (the loop is a
sequential bandwidth-bound stream, the hardware prefetcher already has it), and
batching is worth 1.3× at the cost of a batched-kernel API across five
backends. The 3.2× that looked like batching in the prototype turned out to be
the badly written kernel this PR replaces.
🤖 Generated with Claude Code