[MOD-17527] Widen and unsign the plain uint8 integer accumulators - #1014
Open
dor-forer wants to merge 4 commits into
Open
[MOD-17527] Widen and unsign the plain uint8 integer accumulators#1014dor-forer wants to merge 4 commits into
dor-forer wants to merge 4 commits into
Conversation
2 tasks
dor-forer
force-pushed
the
dor-forer-MOD-17527-uint8-accumulators
branch
from
August 16, 2026 12:49
612f01f to
24652d4
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Please upload reports for the commit 8a9bfc6 to get more accurate results. Additional details and impacted files@@ Coverage Diff @@
## main #1014 +/- ##
==========================================
- Coverage 97.17% 97.16% -0.01%
==========================================
Files 141 141
Lines 8328 8374 +46
==========================================
+ Hits 8093 8137 +44
- Misses 235 237 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
dor-forer
force-pushed
the
dor-forer-MOD-17527-uint8-accumulators
branch
from
August 16, 2026 13:35
24652d4 to
d3a1530
Compare
dor-forer
force-pushed
the
dor-forer-MOD-17527-uint8-accumulators
branch
from
August 17, 2026 11:16
8173105 to
3be2884
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 3be2884. Configure here.
dor-forer
force-pushed
the
dor-forer-MOD-17527-uint8-accumulators
branch
2 times, most recently
from
August 17, 2026 11:57
87e37e0 to
04c9908
Compare
The uint8 kernels accumulate products or squared differences of bytes, so the
total reaches 255 * 255 * dim = 65025 * dim. Four paths could not hold that,
all of them on the plain int8/uint8 index paths that ship today.
* IP.cpp / L2.cpp: ret_t for a 1-byte element type was int, so the scalar
UINT8_InnerProduct and UINT8_L2Sqr executed signed-overflow UB from
dimension 33,026, while the comment claimed support to 2^16. The
conditional was also dead: only int8_t and uint8_t instantiate these, both
1 byte, so it always selected int. ret_t is now 64-bit for every element
type, which also covers int8 at dimension 131,072. Kept signed so the
"1 - ip" in the wrappers stays signed arithmetic and cannot underflow. The
L2 comment still carried the old byte-counting rationale and is fixed.
* UINT8_InnerProductImp returned float on NEON and SVE, accumulating exactly
in integer lanes and then discarding it, exact only to dimension 258 since
2^24 / 65025 = 258.
* AVX-512 reduced 16 int32 lanes with _mm512_reduce_add_epi32 into a signed
int, wrapping from dimension 33,026.
* L2_AVX512F_BW_VL_VNNI_UINT8 and L2_NEON_UINT8 read an unsigned horizontal
reduce back into a signed int, so the distance went negative from the same
dimension. L2_NEON_DOTPROD_UINT8 and L2_SVE_UINT8 were already unsigned.
The accumulation was never the problem. The SIMD adds wrap modulo 2^32 and are
bit-exact, so the bit pattern was already correct; the top bit was being read
as a sign. An unsigned 32-bit reduce therefore costs nothing over the original
and is exact through dimension 66,051 = floor(UINT32_MAX / 65,025), twice the
old signed limit of 33,025. Verified: the AVX-512 object is byte-for-byte the
same size as before at 514,792, with the same 33 and 40 instructions for
residual 0 and 32.
Above 66,051 the choosers hand back the scalar kernel, which after the ret_t
change is exact to roughly dimension 2.8e14. One comparison at index creation,
reusing the "if (dim < 32) return ret_dist_func" idiom the choosers already
had, and it leaves every kernel untouched.
Three alternatives were tried and rejected, each on evidence:
* Widening the horizontal reduce unconditionally. Measured on an Ice Lake-SP
Xeon it costs 4 extra uops in the epilogue: +20% at dimension 32, +8-11%
across 55-200, +4-5% at 900-1024, on byte-identical loop code.
* A runtime branch selecting the width per call. Measured at +0.4 to 0.6 ns
per call, +15% at dimension 32, and it cost 31 of the 65 Cosine wrappers
their inlining, growing .text by 18.4%.
* Compile-time selection between two named wrappers, extending SIMD to a
second bound of 4 * 66,051. This one is free on the narrow path, verified:
the narrow wrappers stayed byte-identical and the out-of-line count
unchanged. It was rejected for correctness, not cost. That bound assumes
products spread evenly across the four uint32 lanes after NEON's 32-bit
vaddq_u32 merge, and the even case already lands within 1,020 of
UINT32_MAX, while the masked residual load can add up to 16 products, or
1,040,400, into specific lanes. So lanes wrap before the widened reduce
sees them, and a correct bound would have to be derived per kernel from its
accumulator count and residual distribution. The narrow reduce needs none
of that: its bound is on the horizontal total, which does not depend on how
products land in lanes.
Recorded for whoever revisits this: on ARM the widening reduce is free
instruction-for-instruction. Cross-compiling with
clang++ --target=aarch64-linux-gnu -O2 emits addv/fmov w/ucvtf against
uaddlv/fmov x/ucvtf, three instructions either way. So the obstacle to a wider
band is the lane bound, not the reduce.
Nothing comparable supports that range regardless. Lucene caps its
scalar-quantized format at 1,024 dimensions and Elasticsearch caps dense
vectors at 4,096, both keeping a 32-bit accumulator safe by contract. Faiss's
QT_8bit_direct accumulates full-range bytes into 32-bit lanes with no widening
and carries the same theoretical limit. Qdrant quantizes to 0..127, lowering
the per-element cap to 16,129, and its raw uint8 metric still sums into i32.
The SQ8_SQ8 kernels reuse this helper, on main as much as here, so they now
take its result as uint32_t. Previously the AVX-512 one assigned it to int,
which wrapped past 33,025 once the helper stopped returning int, and the three
ARM ones to float, which lost exactness past 258. Note the SQ8_SQ8 choosers
have no dimension guard, unlike the uint8 ones, so the fence belongs with SQ8
index creation in #1007; on main nothing constructs an SQ8 index.
Split out of #1011 because none of this depends on the SQ8 metadata contract
that PR is changing, while all of it affects code reachable today. #1011
depends on this, through the helper above.
The regressions use all-255 bytes, the worst case, which makes the expected
value an exact integer, at dimensions 33,026 and 40,000 for the SIMD path and
66,052 for the fallback. The fallback test asserts the returned function
pointer, not just the distance: on a host with no uint8 SIMD tier the value
comparison would pass either way, but the pointer identity would not. The
existing UINT8 suites stop at dimension 128, which is why all of this went
unseen; being SIMD-versus-scalar comparisons they would also have agreed with
each other wherever both wrapped.
Also fixes the uint8 spaces benchmark fixture, which paired new[] with delete
and stored the trailing norms through unaligned float casts, so measurements
taken from it can be trusted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dor-forer
force-pushed
the
dor-forer-MOD-17527-uint8-accumulators
branch
from
August 17, 2026 12:01
04c9908 to
30bd015
Compare
The previous commit kept the 32-bit SIMD accumulators and had the choosers
hand back the scalar kernel past the dimension where those accumulators stay
exact. That works but gives up SIMD entirely for large-dimension indexes, and
the bound it relied on was only sound for the even lane distribution.
Instead, split each uint8 kernel into an Imp that returns its raw integer
total and two wrappers over it:
- the plain wrapper, unchanged in behaviour, for dimensions up to
UINT8_CHUNK_ELEMENTS (65,536)
- a chunked wrapper that calls Imp once per chunk and folds the per-chunk
totals in 64 bits
The choosers pick between them once per index, so the plain kernel carries no
branch. 65025 * 65536 = 4,261,478,400 <= UINT32_MAX, so each chunk's 32-bit
total is exact, and because every contribution is non-negative no individual
accumulator lane can exceed the chunk total either. That is the entire
correctness argument: no reasoning about how work spreads across lanes.
The first chunk absorbs the residual, which leaves every later chunk a whole
multiple of the kernel's step and so matches the residual-0 precondition. On
SVE the vector length is a runtime value, so that split is computed in the
wrapper rather than at compile time.
Covers all five kernel families (AVX512 VNNI, NEON, NEON DOTPROD, SVE, SVE2)
for L2, inner product and cosine. SQ8_SQ8 calls the helper directly rather
than through a uint8 chooser, so it does not gain chunking; it is capped well
below the chunk size by its uint32 metadata slot, and that fence belongs with
SQ8 index creation.
Also marks each Imp static and always_inline. always_inline keeps the plain
wrappers byte-identical now that Imp has more call sites: without it GCC
outlines Imp and the plain wrapper loses its inlining too. static removes a
latent ODR problem, since the NEON and NEON DOTPROD headers define the same
Imp name with different bodies and both are compiled into an ARM build.
Replaces the fallback test with one that checks the dispatched kernel agrees
exactly with the 64-bit scalar kernel across the chunk boundary, and one that
checks the chooser actually switches families using two dimensions with the
same residual.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tier The boundary test sampled seven dimensions and went through the generic dispatcher. Two gaps followed from that. First, only seven of the 64 residual instantiations were covered past the boundary, so a seam between the residual-bearing first chunk and the residual-0 chunks after it could have survived in the other 57. 65,600 and 196,608 are both multiples of 64, so base + r has residual r; sweeping r over 0..63 at both bases covers every shape one chunk past the boundary and again three chunks past it. A ramp against all-255 is position sensitive, so a seam that skips or double-counts elements changes the total rather than cancelling out, and the total stays above UINT32_MAX so the 64-bit fold is under test throughout. Second, the dispatcher only ever returns the best tier the host supports, so on a machine with SVE the NEON and NEON_DOTPROD chunked kernels never ran at all. The new tier test calls each compiled-in chooser directly, still gated on the CPU supporting it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tier the CPU does not support is skipped by a plain if, so on a host with no uint8 SIMD at all the test passed without checking a single SIMD kernel, and the log gave no way to tell. Record and print the tiers covered per dimension, so a green run states what it proved rather than leaving it to be inferred from the host's feature flags. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Describe the changes in the pull request
The uint8 kernels accumulate products or squared differences of bytes, so the total reaches
255 * 255 * dim = 65025 * dim. Four paths could not hold that, and all of them are on the plainint8/uint8index paths that ship today — unlike the SQ8 work in #1011, which is latent because nothing onmainconstructs an SQ8 index.The accumulation itself was never wrong. The SIMD adds wrap modulo 2³² and are bit-exact, so the bit pattern was already correct; the top bit was being read as a sign. Reading it as unsigned costs nothing and doubles the exact range from dim 33,025 to 66,051. Past that the reduce is widened to 64 bits where that is free or cheap, and above the point where the lanes run out the scalar kernel takes over.
Stack position 1 of 3, base
main:Which issues this PR fixes
Main objects this PR modified
ret_tinIP.cpp/L2.cpp— 64-bit for every element type.UINT8_InnerProductImpin the four IP uint8 kernels — returns the exact integer dot.UINT8_L2SqrSIMD*in the four L2 uint8 kernels — unsigned/widened reduce.spaces.h—MAX_EXACT_UINT8_SIMD_DIM, the single dimension bound.IP_space.cpp/L2_space.cpp— scalar fallback above it.IP_*_SQ8_SQ8.hkernels — they reuse the shared helper, so they take its result asuint32_t.tests/unit/test_spaces.cpp,tests/benchmark/spaces_benchmarks/bm_spaces_uint8.cpp.Mark if applicable
The four defects
IP.cpp/L2.cppret_t = intfor 1-byte elementsUINT8_InnerProductImpNEON / SVEreturn static_cast<float>2²⁴ / 65025)UINT8_InnerProductImpAVX-512_mm512_reduce_add_epi32into a signedintUINT8_L2SqrAVX-512 / NEONintOn (1), the conditional was also dead:
std::conditional_t<sizeof(int_elem_t) == 1, int, long long>only ever seesint8_tanduint8_t, both 1 byte, so it always selectedintand thelong longbranch was unreachable. The comment above it claimed "support to 2^16", butintreaches only 33,025.ret_tis now unconditionally 64-bit, which also coversint8at dim 131,072. Kept signed so the1 - ipin the wrappers stays signed arithmetic and cannot underflow.One bound, on the horizontal total
Above it the choosers hand back the scalar kernel, which is exact after the
ret_tchange. One comparison at index creation, reusing theif (dim < 32) return ret_dist_funcidiom already there, and every kernel is left untouched.The bound is on the horizontal total, which does not depend on how products land in individual lanes. That is the property that makes it verifiable in one line, and it is why a wider bound was abandoned — see below.
Three alternatives tried and rejected, each on evidence
Widening the reduce unconditionally. Measured on an Ice Lake-SP Xeon, 15 repetitions, pinned core, two passes with the A/B order reversed: +20% at dim 32, +8–11% across 55–200, +4–5% at 900–1024, on byte-identical loop code. Four extra uops in a dependency chain, which is why instruction count understates it.
A runtime branch selecting the width per call. Measured at a constant +0.4 to 0.6 ns per call, +15% at dim 32. The mechanism was not the branch alone: the fatter function lost its inlining in 31 of the 65 Cosine wrappers and
.textgrew 18.4%.Compile-time selection between two named wrappers, extending SIMD to a second bound of
4 × 66,051. This one was genuinely free on the narrow path — verified, the narrow wrappers stayed byte-identical and the out-of-line count unchanged. It was rejected for correctness, not cost, and the margin is not close:UINT32_MAXThe bound assumed products spread evenly across the four
uint32lanes after NEON's 32-bitvaddq_u32merge. That holds for the main loop and fails once the residual path is counted, so lanes wrap beforevaddlvq_u32sees them. A corrected bound is derivable — about 264,140 here — but only per kernel, from its accumulator count and residual distribution, and any later change to a residual path breaks it silently. Found by Cursor Bugbot.Chunked accumulation, flushing into a 64-bit total, would be exact at any dimension and needs no lane reasoning. Deferred: it restructures the loop in eight kernels, four of them ARM, and the reach has no known use.
Recorded for whoever revisits this: on ARM the widening reduce is free instruction-for-instruction.
clang++ --target=aarch64-linux-gnu -O2emitsSo the obstacle to a wider band is the lane bound, not the reduce.
Verification
Against the narrow-only version, the narrow wrappers are byte-identical:
callin the 33–63 bandImpcopiesResidual 33–63 is checked specifically because that is the band the runtime branch broke. Object file grows 514,792 → 656,120 bytes for the 192 extra instantiations, which is the intended trade.
Also:
-fsyntax-only -Wall -Wextraclean on both scalar TUs and the test file;check-format.shclean.The "free on ARM" claim is verified, not assumed. Cross-compiling the reduce construct with
clang++ --target=aarch64-linux-gnu -O2emits the same instruction count either way:Same kinds, same functional unit, differing only in register width, which costs nothing on AArch64.
That is why the ARM kernels carry no narrow variant: there would be nothing to choose between. A
two-name treatment was implemented there and then reverted once this measurement existed.
Not built or executed locally; the full ARM build is covered by CI only.
Tests
UINT8_is_exact_across_every_dimension_bandwalks all four boundaries — each bound and one dimension past it — through the dispatched function, so selection is covered as well as arithmetic. All-255 against all-0 is the worst case and keeps every expectation an exact integer.UINT8_dispatchers_fall_back_to_scalar_past_the_simd_dimasserts the top boundary by pointer identity, because on a host without a uint8 SIMD tier the value comparisons would pass either way. It also asserts that the narrow and widened dispatch results are different functions, so the selection is exercised rather than assumed.The existing UINT8 suites run
testing::Range(32, 129), which is why all of this went unseen — and being SIMD-versus-scalar comparisons they would have agreed with each other wherever both wrapped.Also fixed
The uint8 spaces benchmark fixture paired
new[]withdeleteand stored the trailing norms through unalignedfloatcasts. Any measurement previously taken from it was untrustworthy, which matters because the numbers above come from it.Known, not fixed here
The
SQ8_SQ8choosers have no dimension guard, unlike the uint8 ones, so those kernels are selectable at any dimension. SQ8 is independently capped at 66,051 by itsuint32q_sum_squaresmetadata slot, so the fence belongs with SQ8 index creation in #1007. Unreachable onmain.🤖 Generated with Claude Code
Note
Medium Risk
Changes core distance kernels used by vector indexes; wrong math would skew ANN results, but behavior is narrowly scoped to uint8 paths and is heavily regression-tested at high dimensions and chunk boundaries.
Overview
Fixes incorrect uint8 inner-product and L2 distances when dimension pushes dot-product or squared-difference totals past what 32-bit signed accumulators could represent (notably from ~33k dims, with negative L2 in some SIMD paths).
Scalar:
ret_tinIP.cpp/L2.cppis now 64-bit with unsigned types foruint8, andUINT8_InnerProductconverts the integer total tofloatbefore1 - ip.SIMD (AVX-512 VNNI, NEON, NEON_DOTPROD, SVE): Kernels return an exact
uint32_tchunk total (unsigned reduce) instead of signed/floatintermediates; L2 paths split into*Imp+ float wrapper. Fordim > spaces::UINT8_CHUNK_ELEMENTS(65536), new chunked IP/L2/Cosine variants sum exact 32-bit chunk totals in 64 bits; implementation choosers pick chunked vs plain once per index (no per-call branch). Helpers usealways_inline/staticwhere needed so chunked call sites do not break inlining.SQ8↔SQ8 dot paths take
uint32_tfrom the shared uint8 helper (still single-chunk 32-bit there; dimension cap noted for SQ8 elsewhere).Adds
UINT8_CHUNK_ELEMENTSinspaces.h, unit tests through chunk boundaries and all residuals/tiers, and small fixes in the uint8 spaces benchmark (delete[], aligned norm storage).Reviewed by Cursor Bugbot for commit a6db25f. Bugbot is set up for automated code reviews on this repo. Configure here.