[MOD-14956] Add SQ8 quantization support for HNSW index - #1007
[MOD-14956] Add SQ8 quantization support for HNSW index#1007dor-forer wants to merge 11 commits into
Conversation
2d29bd9 to
4d09236
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1007 +/- ##
==========================================
+ Coverage 97.18% 97.21% +0.02%
==========================================
Files 141 141
Lines 8432 8533 +101
==========================================
+ Hits 8195 8295 +100
- Misses 237 238 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
SQ8 quantizes to uint8 with FP32 metadata and only has kernels for FP32 and FP16 sources, so every other data type must be rejected at index creation. Nothing covered that, which Cursor Bugbot noticed from the other direction on #1007: it flagged that `EstimateElementSize` will happily size a configuration that `NewIndex` refuses to build. That asymmetry is intentional and pre-existing rather than something SQ8 introduced. `EstimateElementSize`'s unquantized path calls `VecSimParams_GetStoredDataSize` (vec_utils.cpp:296), which is `VecSimType_sizeof(type) * dim` plus a Cosine adjustment and validates nothing for any algorithm, so the function has always answered for parameters that cannot produce an index. Making it strict would mean either inventing a sentinel for a `size_t` return or throwing, and `EstimateElementSize` currently contains no `throw` at all, so that would newly carry a C++ exception across the `extern "C"` boundary through `VecSimIndex_EstimateElementSize`. Settling the error model for these two functions belongs with MOD-14958, which is what first makes `quantType` reachable from RediSearch. So this pins the boundary that actually enforces the supported set, and records in a comment why the estimate deliberately does not repeat it. Verified: - Test is red without the fix: removing both the type fence and the fall-through `return NULL` makes it fail for all four types (FLOAT64, BFLOAT16, INT8, UINT8), which are otherwise silently built as unquantized indexes. - ./check-format.sh - make build DEBUG=1 (no warnings) - test_hnsw_sq8: 46/46 passed - make unit_test DEBUG=1: 2653/2653 passed - make asan: 2653/2653 passed, 0 sanitizer reports (the new test exercises the early-return path, so this also covers leaking the allocator set up before it) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lerman25
left a comment
There was a problem hiding this comment.
I lack context for this,
Left some comments, some are AI that seem reasonable
Also there are other AI comments if you can address them
|
|
||
| VecSimIndex *NewIndex(const VecSimParams *params, bool is_normalized) { | ||
| const HNSWParams *hnswParams = ¶ms->algoParams.hnswParams; | ||
|
|
There was a problem hiding this comment.
Will take it, thanks. Cosmetic only, so I have grouped it with the assert suggestion below rather than pushing a commit for a blank line on its own.
| ASSERT_EQ(GenerateAndAddVector(0, 0.25f, 0.25f), 1); | ||
| data_t query[4]; | ||
| GenerateVector(query, 0.5f, 0.25f); | ||
| auto processed_query = CastToHNSW()->preprocessQuery(query); |
There was a problem hiding this comment.
This test bypasses the public VecSimIndex_GetDistanceFrom_Unsafe contract by manually constructing an internal processed query. The API documents blob as a raw type×dimension vector, and C callers have no preprocessing API. For FP32 dim=4 L2, a valid raw query is 16 bytes, but the SQ8 kernel reads the appended y_sum and y_sum_squares at bytes 16–23, causing out-of-bounds reads. Please test this API with query directly and either preprocess internally, expose a public reusable prepared-query context, or reject direct-distance lookups for SQ8.
There was a problem hiding this comment.
Confirmed and fixed in 63271c1. You were right, and it is worse than a contract mismatch: it is an out-of-bounds read reachable from the public C API. I reproduced it under AddressSanitizer with a dim=4 FP32 L2 SQ8 index and a correctly sized 16-byte heap query:
ERROR: AddressSanitizer: heap-buffer-overflow, READ of size 4
#0 SQ8_FP32_InnerProduct_Impl IP.cpp:65
#6 VecSimIndex_GetDistanceFrom_Unsafe vec_sim.cpp:231
Your diagnosis of why the suite missed it is also exactly right: the test obtained a preprocessed blob through CastToHNSW()->preprocessQuery(...), a C++-only path no C caller has.
getDistanceFrom_Unsafe now returns INVALID_SCORE for a quantized index, which is already the value getDistanceFromInternal uses for "no answer", so it needs no new error channel. I considered your first suggestion, preprocessing internally, and did not take it here: preprocessQuery also normalizes cosine queries, so applying it would change behaviour for every existing cosine index, and it would add a per-call allocation on RediSearch's scoring path. Your third suggestion, a public reusable prepared-query context, is the right long-term answer and belongs with MOD-14958, which is what first exposes any of this to the host.
The test now checks the distance maths through calcDistanceForQuery and separately asserts the public API reports no answer for a raw vector, so the raw-blob call is exercised under ASan by every type parameter.
There was a problem hiding this comment.
The out-of-bounds read is fixed by the rejection path. One API-contract detail remains: vec_sim.h still promises that this function returns the distance for a matching type×dimension blob, while an SQ8 index now returns INVALID_SCORE for every label—the same NaN used for a missing label. A standalone caller cannot distinguish “SQ8 operation unsupported” from “label absent.” If the prepared-query API is intentionally deferred, please document this SQ8 restriction and sentinel behavior on the public declaration so callers and MOD-14958 do not treat valid candidates as missing.
There was a problem hiding this comment.
Fair, and fixed in 9b1615c. The sentinel collision is a real trap: INVALID_SCORE is the same NaN getDistanceFromInternal returns for a missing label, so a standalone caller cannot tell "SQ8 unsupported" from "label absent" and could drop valid candidates as missing. That is exactly the failure mode MOD-14958 would hit.
vec_sim.h now documents it on the declaration rather than leaving the old promise standing:
* NOT SUPPORTED for a quantized index (HNSWParams::quantType != VecSimQuant_NONE), which always
* returns INVALID_SCORE here, for every label. The quantized kernels read query metadata appended
* past the raw vector, which a blob matching the documented type and dimension does not carry, so
* honouring the contract above would read past the caller's buffer. Note that INVALID_SCORE is the
* same NaN returned when the label is absent, so a caller cannot distinguish the two: check
* quantType rather than inferring it from the result. Obtaining a real distance needs a prepared
* query, which has no public API yet.
The prepared-query API is still the real answer and still belongs with MOD-14958, but a caller reading the header now finds out before writing the bug rather than after.
Both were raised by @lerman25 and both are real. Verified before fixing rather than taken at face value. 1. Out-of-bounds read through the public C API --------------------------------------------- `VecSimIndex_GetDistanceFrom_Unsafe` documents `blob` as a raw vector matching the index data type and dimension. For a quantized index that is not a usable query blob: `QuantPreprocessor::preprocessQuery` appends FP32 query metadata (`y_sum`, and `y_sum_squares` for L2) which the SQ8 kernels then read, so honouring the documented contract reads past the caller's buffer. Reproduced with AddressSanitizer on a dim=4 FP32 L2 SQ8 index and a correctly sized 16-byte heap query: ERROR: AddressSanitizer: heap-buffer-overflow, READ of size 4 #0 SQ8_FP32_InnerProduct_Impl IP.cpp:65 #6 VecSimIndex_GetDistanceFrom_Unsafe vec_sim.cpp:231 `getDistanceFrom_Unsafe` now returns `INVALID_SCORE` for a quantized index, which is the value `getDistanceFromInternal` already uses for "no answer", so this needs no new error channel. Preprocessing internally was rejected as the fix here: `preprocessQuery` also normalizes cosine queries, so applying it would change behaviour for every existing cosine index, and it would add a per-call allocation on RediSearch's scoring path. A public prepared-query API is the real answer and belongs with MOD-14958. `AbstractIndexInitParams` gains `isQuantized` for this, parallel to `isDisk`. It defaults to false, so every other factory is unaffected, and the same flag is what a serialization guard would need. 2. Mean-centred FP16 L2 loses correctness ----------------------------------------- `QuantPreprocessor<float16, L2, true>::preprocessQuery` centres the query then narrows the result back into the FP16 query body, while storage keeps its centred min/delta in FP32. The two disagree. Verified numerically with the repo's own conversions: x = 1, mean = 10000 centred storage (fp32) = -9999.0 centred query (fp16) = -10000.0 -> per-component error 1.0 L2^2 for an identical vector/query pair at dim=4 = 4.0 centring -40000 with mean 40000 = -80000 -> fp16 -inf At a realistic mean near 1 the error is exactly zero, so this only bites for large mean magnitudes, but it is silent when it does. `HNSWFactory::NewIndex` now rejects FLOAT16 + mean + L2. The same combination with IP is unaffected and still supported, because that path does not centre the query. Fixing it properly means keeping the centred query in FP32 with a matching asymmetric kernel, which is ARM's design and belongs upstream. Test changes ------------ `test_get_distance` verified the distance maths through `VecSimIndex_GetDistanceFrom_Unsafe`, but passed it an internally preprocessed blob obtained via a C++-only path no C caller has, which is why the suite missed the overflow. It now checks the maths through `calcDistanceForQuery` and separately asserts that the public API reports no answer for a raw vector. That call is exercised under ASan by every type parameter. FLOAT16 with a mean vector leaves the functional type set, because every functional test uses L2 and that combination is now rejected. It is covered explicitly by `RejectsMeanCenteredFP16L2`, which also pins that FP16 + mean + IP still constructs. Net effect on the suite is 2653 -> 2643 tests: the 11 dropped typed tests were all exercising a combination that is now unsupported, so nothing that previously worked lost coverage. FP16 + mean + IP is left with construction coverage only and no functional search coverage, which is worth closing alongside the metric/multi parameterization also raised in review. Verified: - ./check-format.sh - make build DEBUG=1 (no warnings) - test_hnsw_sq8: 36/36 passed - test_hnsw_sq8 under ASan: 36/36, 0 sanitizer reports (the ASan repro above is clean after the fix) - make unit_test DEBUG=1: 2643/2643 passed - make asan: 2643/2643 passed, 0 sanitizer reports Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remaining review points from #1007, other than the dim >= 33026 kernel overflow which is recorded in SQ8-SERIES-CARRYFORWARD.md instead. Serialization ------------- The V4 format records type, dim and metric, but neither quantType nor the mean vector, and the file-loading path in HNSWFactory always builds components through CreateIndexComponents, which has no SQ8 branch. A saved SQ8 index therefore reloads as unquantized over quantized bytes, misreading the stride and consuming graph bytes as vector data. saveIndexIMP now throws for a quantized index. This is the same argument as the tiered guard: the combination is not wired yet, so fail closed rather than accept it silently. One wart worth knowing: the caller writes the encoding version before saveIndexIMP runs, so a rejected save leaves a stub file. That still fails closed on load, unlike a complete file with a layout the loader misreads, but whoever adds real SQ8 serialization should move the check ahead of the file being created. Recorded in the carry-forward file, whose "serializer should refuse to save" item this closes. IP graph construction --------------------- Every other functional test uses L2, so the symmetric SQ8-to-SQ8 IP kernel that graph construction selects for an IP index was never executed. That kernel is pre-existing, but this series is the first thing to put it on the insert path, so it should not go in untested. GraphConstructionIP builds a 100-vector dim-16 IP index and searches it. The expected result follows from the metric rather than from assumed self-similarity: this is plain inner product, not cosine, so the distance is 1 - IP and the closest vector is the one with the largest projection onto the query. Vectors and query are positive with magnitude growing by label, so results come back from the highest label downward. My first version of this test asserted the query's own label would rank first and failed correctly, returning 99 instead of 70. Vectors also vary per component, not just per label, so quantization does not collapse into the degenerate min == max branch that the existing tests all take. Review nits ----------- * assert(false && "...") added before the unreachable return NULL in the SQ8 branch, matching svs_factory.cpp. Kept alongside the return rather than replacing it: assert-only would reopen the silent-unquantized-fallthrough hole under NDEBUG, which is the regression that line exists to prevent. * Dropped the blank line this series added after the hnswParams declaration. Verified: - ./check-format.sh - make build DEBUG=1 (no warnings) - test_hnsw_sq8: 42/42 passed - make unit_test DEBUG=1: 2649/2649 passed - make asan: 2649/2649 passed, 0 sanitizer reports Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lerman25
left a comment
There was a problem hiding this comment.
Re-verified against 9e69259c6adc6f5e3118459c2fe565c3490af517 with focused runtime reproducers.
|
|
||
| // Override blob size for the SQ8 storage layout. | ||
| abstractInitParams.storedDataSize = GetSQ8StoredDataSize<Metric>(dim, with_norm); | ||
| abstractInitParams.isQuantized = true; |
There was a problem hiding this comment.
Blocking: the compact storage enabled here also reaches the BUILD_TESTS getDataByLabel helpers, which still copy dim * sizeof(DataType) from the stored pointer. I reproduced this on the current head with FP32/L2, dim=128, blockSize=1: the SQ8 data is 144 bytes, and ASan reports a 512-byte read past the resulting 160-byte element allocation at hnsw_single.h:55. hnsw_multi.h:80 has the same copy. Since this flag is now available, please either reject that helper for quantized indexes or safely dequantize into DataType, with single and multi regressions.
There was a problem hiding this comment.
Confirmed. Both call sites do exactly what you describe:
memcpy(vec.data(), this->getDataByInternalId(id), this->dim * sizeof(DataType));hnsw_single.h:55 and hnsw_multi.h:80, and for a quantized index the stored blob is one byte per component plus 16 to 20 bytes of metadata, so dim * sizeof(DataType) overshoots by design. Your dim=128 FP32 numbers line up: 144 bytes stored, 512 read.
Tracked as MOD-17530 rather than fixed here. Two reasons, and one caveat against myself.
MOD-14956 is scoped to the factory path, the calcDistanceForQuery call sites and the new HNSWParams fields; these are BUILD_TESTS helpers that no product path reaches, and no current test calls them on a quantized index, which is why the ASan run on this PR is clean. So there is no exposure to close today.
The caveat: this is a landmine for the next tickets in the epic. MOD-14957 and MOD-14959 will naturally call getDataByLabel on a quantized index and get a heap overread instead of a clear failure. The ticket says so, and prefers dequantizing into DataType over rejecting, since recovering vectors for comparison is exactly what these helpers exist for. If you would rather have the guard in this PR so main never carries it, that is a four-line change plus the two regressions you asked for and I will add it.
There was a problem hiding this comment.
Okay to defer to MOD-17530. I rechecked the scope: both affected helpers are compiled only under BUILD_TESTS, no product path reaches them, and this PR does not call them for a quantized index. The ticket should remain a prerequisite for tests in MOD-14957 or MOD-14959, but I do not consider it merge-blocking for this PR.
Addresses the three Bugbot findings left open on #1007, plus one gap none of them covered. The supported-SQ8-combination test now lives in one place instead of being open-coded in NewIndex only. ResolveSQ8Metric applies the is_normalized Cosine-to-IP remap and SQ8ParamsSupported holds all three fences: FP32/FP16 only, no Cosine, no mean-centred FP16 L2. Same single-source-of-truth argument as sq8::storage_bytes_count earlier in this series: two copies of one rule drift. * HNSWFactory::NewIndex rejects a quantType that is neither NONE nor SQ8. Previously any other value fell through to the full-precision path and silently built an unquantized index for a caller that asked for a quantized one. Unreachable while the enum holds only those two values, and deliberately not tested, since forming an out-of-range enumerator is undefined behaviour. The guard is what keeps adding SQ4 to the enum from reopening the hole. * HNSWFactory::EstimateInitialSize rejects the same set as NewIndex, replacing a check that validated only the data type. This closes the reported metric gap and also one nobody raised: the mean-centred FP16 L2 fence added in 63271c1 was never mirrored into the estimate, so that combination still reported a size. * TieredHNSWFactory::EstimateInitialSize rejects quantType != NONE, matching the NewIndex guard. It needs its own check rather than inheriting one, because the primary index accepts FP32 with L2 and SQ8 happily; nothing propagates up. EstimateElementSize is left as is on purpose. Its return type is size_t with no sentinel and VecSimIndex_EstimateElementSize is extern "C", so a throw there would carry an exception into the C host. It therefore answers for whatever params it is handed, exactly as VecSimParams_GetStoredDataSize does on the unquantized path. Both estimate functions now say so in a comment. Note that this does add two throw sites reachable through VecSimIndex_EstimateInitialSize, which has no try/catch. That is the existing idiom in both files rather than a new hazard, but the error model for the estimate functions is genuinely unsettled and belongs with MOD-14958. Tests: estimate-side assertions on all four existing rejection tests, including EXPECT_NO_THROW for FP16 with a mean and IP, which stays supported. Verified red without the fix: 5 failures, all "throws nothing", with RejectsUnsupportedDataType still green because the old code already threw for a bad type. Green after: test_hnsw_sq8 42/42, make unit_test DEBUG=1 2649/2649, check-format clean, -Wall -Werror -fsyntax-only clean with and without NDEBUG. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| int addVector(const void *vector_data, labelType label) override; | ||
| vecsim_stl::vector<idType> markDelete(labelType label) override; | ||
| double getDistanceFrom_Unsafe(labelType label, const void *vector_data) const override { | ||
| // The public API documents vector_data as a raw dim-by-type vector, but a quantized index's |
There was a problem hiding this comment.
getDataByLabel SQ8 over-read
High Severity
getDataByLabel still copies dim * sizeof(DataType) from the stored pointer. SQ8 blobs are much smaller (bytes plus metadata), so under BUILD_TESTS this reads past the element allocation. The public distance path was guarded for quantization, but these helpers were not.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit d5d93d6. Configure here.
There was a problem hiding this comment.
Confirmed. Both getDataByLabel overloads copy dim * sizeof(DataType) from stored data, which overshoots an SQ8 blob of one byte per component plus 16 to 20 bytes of metadata. Same finding @lerman25 raised, with an ASan repro at FP32 dim=128: 144 bytes stored, 512 read.
Tracked as MOD-17530. Both helpers are behind BUILD_TESTS and no current test calls them on a quantized index, which is why the ASan run on this PR is clean, so there is nothing to close today; the ticket exists because MOD-14957 and MOD-14959 will call them and deserve a clear failure rather than a heap overread.
| : PreprocessorInterface(allocator), dim(dim), | ||
| storage_bytes_count(dim * sizeof(OUTPUT_TYPE) + | ||
| sq8::storage_metadata_count<Metric>() * sizeof(MetadataType)), | ||
| storage_bytes_count(sq8::storage_bytes_count<Metric>(dim)), |
There was a problem hiding this comment.
Quantization hits UB on finite input
High Severity
quantize derives delta and inv_delta from max - min in FP32 with no finiteness check. Finite inputs such as [-FLT_MAX, +FLT_MAX] make the range and delta infinite, inv_delta zero, then Inf * 0 yields NaN; casting that NaN to uint8_t is undefined behavior. AddVector on an SQ8 index therefore executes C++ UB for otherwise valid finite vectors.
Reviewed by Cursor Bugbot for commit 9b1615c. Configure here.
There was a problem hiding this comment.
Confirmed, same defect @lerman25 raised on this PR, tracked as MOD-17528. Mechanism verified from preprocessors.h:273-275: diff = inf, delta = inf, inv_delta = 0, then inf * 0 is NaN and the static_cast<uint8_t> is UB.
Two things worth adding to the record, because they cut in opposite directions.
Against deferring, and I will not lean on "pre-existing" here: QuantPreprocessor merged with MOD-14952 in #1000, but before this PR nothing constructed an index that used it, so it was reachable only from test_components. This PR is what first puts it on AddVector. So while the buggy code is not new, its reachability from the public API is.
For deferring: the fix is not a finiteness check away, which is why it needs the preprocessor owner rather than a cherry-pick. I measured the obvious repair:
current, all FP32: diff=inf delta=inf inv_delta=0 -> value = nan
widen diff to double only: diff=6.8e38 delta=2.67e36 (finite, fits FP32)
but per-element (x - min_val) in FP32 is still inf
-> value = inf, and casting inf to uint8_t is equally UB
widen the per-element
subtraction to double too: -> value = 255.0, correct
So the range is not the only thing that overflows: the per-element (x - min_val) does too. Getting this right means either double arithmetic per element on the insert path, which is a throughput decision on the hot path, or a reformulation such as x * inv_delta - min_val * inv_delta. Either way it is a design call with a benchmark attached, and AddVector has no way to report "unquantizable" if the answer turns out to be rejection.
The ticket carries both the UBSan output and this table, and asks for a regression at [-FLT_MAX, +FLT_MAX] plus one where the range overflows without either endpoint being extreme.
| auto sym_func = spaces::GetDistFunc<sq8, float>(Metric, dim, &storage_alignment); | ||
| // Asymmetric: stored vector is SQ8 blob, query is DataType. | ||
| auto asym_func = | ||
| spaces::GetDistFunc<sq8, float, DataType>(Metric, dim, &asym_storage_alignment); |
There was a problem hiding this comment.
Blocking: SQ8 L2 mixes metadata from the original stored vector with a cross term computed from its quantized reconstruction, so it can return a negative distance even at small magnitudes. On ac05e54 through the public C API, storing [0, 0.25, 1] and querying [0, 0.2501, 1] makes a radius-0 query return the label with score -0.000490427. This is distinct from MOD-17526: there is no large offset, and the failure comes from the norm and cross term describing different vectors. Please make all terms use the same representation, or use a stable direct-difference formulation, and add this non-grid radius-zero regression.
Widening the helper's return type to uint32_t in the previous commit left the SQ8_SQ8 callers narrowing it back. The AVX512 one assigned it to int, which is the narrowing this series exists to remove: main returned int from the helper, so int = int was fine, and changing the helper to uint32_t made that assignment wrap past dimension 33,025 again. The NEON, NEON_DOTPROD and SVE ones assign to float, which loses exactness past dimension 258 for the same reason the helper stopped returning float. All four now take uint32_t. Type-only: the AVX512 translation unit is byte-for-byte the same size at 514,792. Worth recording, because it is not fixed here: the SQ8_SQ8 choosers have no dimension guard, unlike the plain uint8 ones, so these kernels are selectable at any dimension. SQ8 is independently capped at spaces::MAX_EXACT_UINT8_SIMD_DIM by its uint32 q_sum_squares metadata slot, so the fence belongs with SQ8 index creation in #1007 rather than here. On main nothing constructs an SQ8 index, so it is unreachable today. Found while benchmarking, independently, by two reviewers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… free
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. Keeping it signed means
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, twice the old signed limit of 33,025.
Two bounds follow, because the horizontal total and the lanes run out at
different points:
* UINT8_NARROW_REDUCE_MAX_DIM = 66,051 bounds the 32-bit total, which is
floor(UINT32_MAX / 65,025). Past it the reduce is widened to 64 bits.
* UINT8_SIMD_MAX_DIM = 4 * 66,051 bounds the lanes, which widening the total
does not protect. NEON is the limiting ISA: it combines four accumulators
with vaddq_u32 in 32 bits before any widening reduce sees them, so its
capacity is four lanes' worth. AVX-512 accumulates into 16 lanes from one
accumulator and reaches roughly 1,056,816, and SVE depends on its vector
length, so NEON sets the shared bound for IP, Cosine and L2 alike. Above
it the choosers hand back the scalar kernel, exact by the ret_t change.
Only AVX-512 carries both reduce forms. On ARM widening is free, since
vaddlvq_u32 (UADDLV) and svaddv_u32 are single instructions already producing
64 bits, so those kernels always widen and need no variant.
The AVX-512 pair is two named wrappers, X and X_Wide, rather than a template
argument threaded through the chooser macros. implementation_chooser.h is
shared with every other element type, so keeping a uint8 concern out of it
avoids blast radius, and the narrow wrapper stays byte-for-byte what it was.
That matters, and was measured: putting a runtime branch in the epilogue
instead cost 0.4 to 0.6 ns per call, +20% at dimension 32 and +8% across
55..200 on an Ice Lake-SP Xeon, because the fatter function lost its inlining
in 31 of the Cosine wrappers and grew .text by 18.4%. Two names keep the
choice at index creation and both kernels branch-free. Selection lives inside
the per-ISA Choose_* functions, which already take dim, so no header changes
and no new exported names.
Verified against the narrow-only version: the narrow wrappers are unchanged at
33, 40 and 47 instructions for IP at residual 0, 32 and 33, and 37, 43 and 50
for Cosine, with zero calls in the 33..63 band and the out-of-line Imp count
unchanged at 7. The object file grows 514,792 to 656,120 for the extra 192
instantiations, which is the intended trade.
The SQ8_SQ8 kernels reuse this helper, on main as much as here. They now take
the result as uint64_t and pass Wide as false: SQ8 is capped at the same
66,051 independently, by its uint32 q_sum_squares metadata slot. Previously
the AVX-512 one assigned it to int, which wrapped past 33,025, 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.
Tests walk all four boundaries, at each bound and one 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.
The top boundary is also asserted by pointer identity, since on a host without
a uint8 SIMD tier the value comparisons would pass either way, and the narrow
and widened dispatch results are asserted to differ so the selection is
exercised rather than assumed. 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>
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>
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>
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>
The stored SQ8 metadata held x_sum and x_sum_squares, FP32 sums over the input
floats. Accumulating dim terms in FP32 loses precision as dim grows, and the
distance formulations then subtract large nearly equal quantities, so the error
lands directly on the answer. A vector's distance to itself came out negative,
about -1.7e-12 at dimension 512.
The metadata now holds q_sum and q_sum_squares: exact uint32 sums over the
quantized bytes. Same four slots, same widths, so the blob layout and every size
estimate are unchanged. The reconstruction happens at distance time from those
exact integers, combined in double, which is what makes the result land on the
answer rather than near it.
The L2 formulation is regrouped so the integer combination forms first:
d1^2*S1 + d2^2*S2 - 2*d1*d2*Q == d1*d2*(S1 + S2 - 2Q) + (d1 - d2)*(d1*S1 - d2*S2)
S1 + S2 - 2Q is sum((a[i] - b[i])^2), an exact non-negative integer, so two
blobs sharing a delta cannot produce a negative distance and a blob against
itself produces exactly zero. Written as six independent floating point terms it
did not: the compiler contracts some into fused multiply-adds and not others, so
the products stop rounding identically and stop cancelling.
Rebased onto the merged uint8 and quantize work, and reduced to what that work
leaves necessary. Dropped from the original branch:
- The uint64_t return type and removal of static on UINT8_InnerProductImp. The
static is the fix for the udot and sdot linkage defect, and the widening was
made unnecessary by the dispatcher cap.
- The double scaling arithmetic, the find_min_max endpoint clamping and the
isfinite delta guard. Intermediate and metadata overflow policy is MOD-17838.
- Its own fmin/fmax conversion guard, superseded by the merged one, which is
NaN-safe where fmin and fmax propagate NaN, and costs no libm calls.
- A rewrite of the scalar uint8 inner product epilogue, which is not this
change's purpose.
One limit is documented rather than fixed. The dispatched kernels take their dot
product from the shared uint8 helper, which returns float on NEON and SVE. Float
holds an integer exactly only to 2^24, which sum(a[i]^2) passes around dimension
258 for all-255 bytes and 774 for typical ones, so above that the regrouped
integer can be off by one rounding. The scalar kernel accumulates in integers and
is exact at any dimension. The self-distance test asserts exact zero for the
scalar path and a vector-scaled tolerance for the dispatched one.
MOD-14956 (#1007) is what makes this reachable from a public API; until it lands
this code is exercised only by tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cherry-picked from ARM-software#4 (head 125ea15), squashing the fork's four commits into one. Adds 8-bit scalar quantization (SQ8) to the standalone HNSW index: * `VecSimQuantType` plus `quantType` / `quantParams` on `HNSWParams`. Both fields are appended at the end of the struct and `VecSimQuant_NONE` is 0, so existing zero-initialized and designated-initializer construction is unaffected. * `HNSWFactory` can build SQ8 indexes for FLOAT32 and FLOAT16 data types with the L2 and IP metrics, wiring `QuantPreprocessor` and `DistanceCalculatorWithNorm`, and accounts for SQ8 in `EstimateInitialSize` and `EstimateElementSize`. * For SQ8, `quantParams` points to a `float[dim]` mean vector; a null pointer selects quantization without mean normalization. * New `test_hnsw_sq8` unit-test target and suite. SQ8 support for the tiered HNSW index, serialization and benchmarks is deferred to later PRs in the MOD-14956 series. Redis-side adjustments made during the cherry-pick: * Dropped the added `SPDX-FileCopyrightText` Arm line from the two modified files, matching how #999, #1000 and #1002 landed. It is kept on the new `tests/unit/test_hnsw_sq8.cpp`, where the `BSD-3-Clause` identifier was replaced by this repo's Redis tri-license header. * Wrapped that header so `make check-format` passes at the 100-column limit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up review pass over the cherry-picked MOD-14956 change. No behavioural change is intended: all of these are interface, single-source-of-truth and idiom fixes. Public API (`vec_sim_common.h`): * `quantParams` is now `const void *`. Every use in the tree reads it, and two already cast it to `const float *`. The layout is unchanged, so this is not an ABI break, and callers passing a non-const pointer still compile. Worth doing now, before the field ships and freezes. * The `VecSimQuant_SQ8` comment claimed "with mean normalization". Mean normalization is optional and selected by `quantParams`, exactly as the field's own comment says. Reworded. Storage layout (`types/sq8.h`, `spaces/computer/preprocessors.h`, `index_factories/hnsw_factory.cpp`): * `GetSQ8StoredDataSize` re-derived the stored blob size that `QuantPreprocessor`'s constructors already computed. Two independent formulas for one layout drift silently, which is the bug class fixed in MOD-15303. The formula now lives once, as `sq8::storage_bytes_count<Metric, WithNorm>(dim)`, next to the `storage_metadata_count` it builds on, and both the preprocessor and the factory call it. Factory (`index_factories/hnsw_factory.cpp`): * Restored the `return NULL` that closes the SQ8 branch. It is unreachable today, since the type and metric checks leave only FP32/FP16 x L2/IP, but without it adding a type or metric silently falls through and builds an unquantized index. * `assert(ret == 0)` on `addPreprocessor` is now `assert(ret != -1)`. The function returns -1 on failure, 0 when the container is full, and the next free index otherwise, so 0 is merely the only success value at the current container size of one. `!= -1` is the documented contract and the existing repo idiom. * Hoisted the tail the two branches duplicated (container construction, `addPreprocessor`, assert, `IndexComponents`, return). Only the preprocessor and the distance calculator actually differ. * The mean vector is copied with a single `assign` instead of a zero-filling constructor followed by `memcpy`, which wrote every element twice. * Obtaining the query alignment required calling `GetDistFunc` for a function that is never used, since spaces.h offers no alignment-only query and the asymmetric hint covers the storage operand. That call now lives in a small `GetQueryAlignment<DataType>` adapter that returns the hint, so the call site neither discards a value nor keeps a third distance function in scope next to `sym_func` and `asym_func` that must never be called. `query_alignment` is const. * `GetSQ8StoredDataSize` is `[[nodiscard]] constexpr` and `dim` / `with_norm` are const. Verified: - ./check-format.sh - g++ -std=gnu++20 -Wall -Werror -fsyntax-only, with and without -DNDEBUG - make build DEBUG=1 (no warnings) - test_hnsw_sq8: 44/44 passed - make unit_test DEBUG=1: 2651/2651 passed - make asan: 2651/2651 passed, 0 sanitizer reports Not run: - FP_64=1 variants (this change is FP32/FP16 only) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding `quantType` to `HNSWParams` makes it reachable on the tiered path, where nothing handles it. `TieredHNSWFactory::NewIndex` forwards `primaryIndexParams` straight into `HNSWFactory::NewIndex`, so the primary index quantizes its storage, while `NewBFParams` does not copy `quantType` and the brute-force frontend stays unquantized. The two then disagree on the stored blob layout. Reachable from any direct C API caller with `algo = VecSimAlgo_TIERED` and `quantType = VecSimQuant_SQ8`, in two ways: * FP32 / FP16: `assert(hnsw_index->getStoredDataSize() == storedDataSize)` at tiered_factory.cpp:54 aborts on a debug build. Under NDEBUG the assert is gone and the index is built with mismatched frontend and backend layouts. * FP64 / BF16 / INT8 / UINT8: `HNSWFactory::NewIndex` returns NULL for these types under SQ8, and the result is reinterpret_cast and dereferenced without a null check, so the process segfaults. The `catch (...)` in `index_factory.cpp` does not help: neither an abort nor a null dereference is an exception. RediSearch cannot set `quantType` until MOD-14958, so there is no product exposure today. This guard exists so main does not carry the defect between cherry-picks in this series. MOD-14957, which wires quantization through the tiered index properly, should replace the check and the test that covers it rather than delete them. The test builds `TieredIndexParams` with only `primaryIndexParams` set: no job queue or thread pool is needed, since the factory rejects the params before reaching anything that would use them. Deliberately not using `tieredIndexMock` here, because its destructor dereferences `ctx->index_strong_ref` unconditionally and so requires an index to have been created successfully. Verified: - Test is red without the guard and green with it: exit 134 (SIGABRT on the tiered_factory.cpp:54 assert) versus exit 0. - ./check-format.sh - make build DEBUG=1 (no warnings) - test_hnsw_sq8: 45/45 passed - make unit_test DEBUG=1: 2652/2652 passed - make asan: 2652/2652 passed, 0 sanitizer reports Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQ8 quantizes to uint8 with FP32 metadata and only has kernels for FP32 and FP16 sources, so every other data type must be rejected at index creation. Nothing covered that, which Cursor Bugbot noticed from the other direction on #1007: it flagged that `EstimateElementSize` will happily size a configuration that `NewIndex` refuses to build. That asymmetry is intentional and pre-existing rather than something SQ8 introduced. `EstimateElementSize`'s unquantized path calls `VecSimParams_GetStoredDataSize` (vec_utils.cpp:296), which is `VecSimType_sizeof(type) * dim` plus a Cosine adjustment and validates nothing for any algorithm, so the function has always answered for parameters that cannot produce an index. Making it strict would mean either inventing a sentinel for a `size_t` return or throwing, and `EstimateElementSize` currently contains no `throw` at all, so that would newly carry a C++ exception across the `extern "C"` boundary through `VecSimIndex_EstimateElementSize`. Settling the error model for these two functions belongs with MOD-14958, which is what first makes `quantType` reachable from RediSearch. So this pins the boundary that actually enforces the supported set, and records in a comment why the estimate deliberately does not repeat it. Verified: - Test is red without the fix: removing both the type fence and the fall-through `return NULL` makes it fail for all four types (FLOAT64, BFLOAT16, INT8, UINT8), which are otherwise silently built as unquantized indexes. - ./check-format.sh - make build DEBUG=1 (no warnings) - test_hnsw_sq8: 46/46 passed - make unit_test DEBUG=1: 2653/2653 passed - make asan: 2653/2653 passed, 0 sanitizer reports (the new test exercises the early-return path, so this also covers leaking the allocator set up before it) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were raised by @lerman25 and both are real. Verified before fixing rather than taken at face value. 1. Out-of-bounds read through the public C API --------------------------------------------- `VecSimIndex_GetDistanceFrom_Unsafe` documents `blob` as a raw vector matching the index data type and dimension. For a quantized index that is not a usable query blob: `QuantPreprocessor::preprocessQuery` appends FP32 query metadata (`y_sum`, and `y_sum_squares` for L2) which the SQ8 kernels then read, so honouring the documented contract reads past the caller's buffer. Reproduced with AddressSanitizer on a dim=4 FP32 L2 SQ8 index and a correctly sized 16-byte heap query: ERROR: AddressSanitizer: heap-buffer-overflow, READ of size 4 #0 SQ8_FP32_InnerProduct_Impl IP.cpp:65 #6 VecSimIndex_GetDistanceFrom_Unsafe vec_sim.cpp:231 `getDistanceFrom_Unsafe` now returns `INVALID_SCORE` for a quantized index, which is the value `getDistanceFromInternal` already uses for "no answer", so this needs no new error channel. Preprocessing internally was rejected as the fix here: `preprocessQuery` also normalizes cosine queries, so applying it would change behaviour for every existing cosine index, and it would add a per-call allocation on RediSearch's scoring path. A public prepared-query API is the real answer and belongs with MOD-14958. `AbstractIndexInitParams` gains `isQuantized` for this, parallel to `isDisk`. It defaults to false, so every other factory is unaffected, and the same flag is what a serialization guard would need. 2. Mean-centred FP16 L2 loses correctness ----------------------------------------- `QuantPreprocessor<float16, L2, true>::preprocessQuery` centres the query then narrows the result back into the FP16 query body, while storage keeps its centred min/delta in FP32. The two disagree. Verified numerically with the repo's own conversions: x = 1, mean = 10000 centred storage (fp32) = -9999.0 centred query (fp16) = -10000.0 -> per-component error 1.0 L2^2 for an identical vector/query pair at dim=4 = 4.0 centring -40000 with mean 40000 = -80000 -> fp16 -inf At a realistic mean near 1 the error is exactly zero, so this only bites for large mean magnitudes, but it is silent when it does. `HNSWFactory::NewIndex` now rejects FLOAT16 + mean + L2. The same combination with IP is unaffected and still supported, because that path does not centre the query. Fixing it properly means keeping the centred query in FP32 with a matching asymmetric kernel, which is ARM's design and belongs upstream. Test changes ------------ `test_get_distance` verified the distance maths through `VecSimIndex_GetDistanceFrom_Unsafe`, but passed it an internally preprocessed blob obtained via a C++-only path no C caller has, which is why the suite missed the overflow. It now checks the maths through `calcDistanceForQuery` and separately asserts that the public API reports no answer for a raw vector. That call is exercised under ASan by every type parameter. FLOAT16 with a mean vector leaves the functional type set, because every functional test uses L2 and that combination is now rejected. It is covered explicitly by `RejectsMeanCenteredFP16L2`, which also pins that FP16 + mean + IP still constructs. Net effect on the suite is 2653 -> 2643 tests: the 11 dropped typed tests were all exercising a combination that is now unsupported, so nothing that previously worked lost coverage. FP16 + mean + IP is left with construction coverage only and no functional search coverage, which is worth closing alongside the metric/multi parameterization also raised in review. Verified: - ./check-format.sh - make build DEBUG=1 (no warnings) - test_hnsw_sq8: 36/36 passed - test_hnsw_sq8 under ASan: 36/36, 0 sanitizer reports (the ASan repro above is clean after the fix) - make unit_test DEBUG=1: 2643/2643 passed - make asan: 2643/2643 passed, 0 sanitizer reports Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remaining review points from #1007, other than the dim >= 33026 kernel overflow which is recorded in SQ8-SERIES-CARRYFORWARD.md instead. Serialization ------------- The V4 format records type, dim and metric, but neither quantType nor the mean vector, and the file-loading path in HNSWFactory always builds components through CreateIndexComponents, which has no SQ8 branch. A saved SQ8 index therefore reloads as unquantized over quantized bytes, misreading the stride and consuming graph bytes as vector data. saveIndexIMP now throws for a quantized index. This is the same argument as the tiered guard: the combination is not wired yet, so fail closed rather than accept it silently. One wart worth knowing: the caller writes the encoding version before saveIndexIMP runs, so a rejected save leaves a stub file. That still fails closed on load, unlike a complete file with a layout the loader misreads, but whoever adds real SQ8 serialization should move the check ahead of the file being created. Recorded in the carry-forward file, whose "serializer should refuse to save" item this closes. IP graph construction --------------------- Every other functional test uses L2, so the symmetric SQ8-to-SQ8 IP kernel that graph construction selects for an IP index was never executed. That kernel is pre-existing, but this series is the first thing to put it on the insert path, so it should not go in untested. GraphConstructionIP builds a 100-vector dim-16 IP index and searches it. The expected result follows from the metric rather than from assumed self-similarity: this is plain inner product, not cosine, so the distance is 1 - IP and the closest vector is the one with the largest projection onto the query. Vectors and query are positive with magnitude growing by label, so results come back from the highest label downward. My first version of this test asserted the query's own label would rank first and failed correctly, returning 99 instead of 70. Vectors also vary per component, not just per label, so quantization does not collapse into the degenerate min == max branch that the existing tests all take. Review nits ----------- * assert(false && "...") added before the unreachable return NULL in the SQ8 branch, matching svs_factory.cpp. Kept alongside the return rather than replacing it: assert-only would reopen the silent-unquantized-fallthrough hole under NDEBUG, which is the regression that line exists to prevent. * Dropped the blank line this series added after the hnswParams declaration. Verified: - ./check-format.sh - make build DEBUG=1 (no warnings) - test_hnsw_sq8: 42/42 passed - make unit_test DEBUG=1: 2649/2649 passed - make asan: 2649/2649 passed, 0 sanitizer reports Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses the three Bugbot findings left open on #1007, plus one gap none of them covered. The supported-SQ8-combination test now lives in one place instead of being open-coded in NewIndex only. ResolveSQ8Metric applies the is_normalized Cosine-to-IP remap and SQ8ParamsSupported holds all three fences: FP32/FP16 only, no Cosine, no mean-centred FP16 L2. Same single-source-of-truth argument as sq8::storage_bytes_count earlier in this series: two copies of one rule drift. * HNSWFactory::NewIndex rejects a quantType that is neither NONE nor SQ8. Previously any other value fell through to the full-precision path and silently built an unquantized index for a caller that asked for a quantized one. Unreachable while the enum holds only those two values, and deliberately not tested, since forming an out-of-range enumerator is undefined behaviour. The guard is what keeps adding SQ4 to the enum from reopening the hole. * HNSWFactory::EstimateInitialSize rejects the same set as NewIndex, replacing a check that validated only the data type. This closes the reported metric gap and also one nobody raised: the mean-centred FP16 L2 fence added in 63271c1 was never mirrored into the estimate, so that combination still reported a size. * TieredHNSWFactory::EstimateInitialSize rejects quantType != NONE, matching the NewIndex guard. It needs its own check rather than inheriting one, because the primary index accepts FP32 with L2 and SQ8 happily; nothing propagates up. EstimateElementSize is left as is on purpose. Its return type is size_t with no sentinel and VecSimIndex_EstimateElementSize is extern "C", so a throw there would carry an exception into the C host. It therefore answers for whatever params it is handed, exactly as VecSimParams_GetStoredDataSize does on the unquantized path. Both estimate functions now say so in a comment. Note that this does add two throw sites reachable through VecSimIndex_EstimateInitialSize, which has no try/catch. That is the existing idiom in both files rather than a new hazard, but the error model for the estimate functions is genuinely unsettled and belongs with MOD-14958. Tests: estimate-side assertions on all four existing rejection tests, including EXPECT_NO_THROW for FP16 with a mean and IP, which stays supported. Verified red without the fix: 5 failures, all "throws nothing", with RejectsUnsupportedDataType still green because the old code already threw for a bad type. Green after: test_hnsw_sq8 42/42, make unit_test DEBUG=1 2649/2649, check-format clean, -Wall -Werror -fsyntax-only clean with and without NDEBUG. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the second review round that are inside MOD-14956. The other six are tracked as MOD-17526 to MOD-17531. * An out-of-range VecSimMetric reached the SQ8 dispatcher's unreachable-branch assert and aborted an assertions-enabled process. FP32 with SQ8 and metric = (VecSimMetric)123 passed the Cosine-only check, matched neither the L2 nor the IP branch, and fell through to the assert added in 9e69259. The unquantized dispatcher throws instead, and VecSimIndex_New catches it, so quantized params were the only way to abort the host. Reported by @lerman25, who reproduced it with a death test. SQ8ParamsSupported now whitelists L2 and IP rather than rejecting Cosine alone, so NewIndex returns NULL and EstimateInitialSize throws, as they do for every other unsupported combination. Note the previous commit's refactor preserved this hole verbatim rather than introducing it: the open-coded check it replaced also tested only for Cosine. RejectsOutOfRangeMetric uses (VecSimMetric)3, which is outside the valid set but inside the enum's value range, so the test does not itself rely on undefined behaviour the way (VecSimMetric)123 would. * VecSimIndex_GetDistanceFrom_Unsafe returns INVALID_SCORE for a quantized index as of 63271c1, but vec_sim.h still promised a distance for any blob matching the index type and dimension. Worse, INVALID_SCORE is the same NaN returned for a missing label, so a caller cannot tell "SQ8 unsupported" from "label absent" and could treat valid candidates as missing. The declaration now documents the restriction, the sentinel, the collision with the missing-label case, and that callers must check quantType rather than infer it from the result. This matters for MOD-14958, which is what first exposes any of it to RediSearch. Tests: test_hnsw_sq8 43/43, make unit_test DEBUG=1 2650/2650, check-format clean, -Wall -Werror -fsyntax-only clean. The metric abort was verified red by the reporter on 9e69259 rather than by me: reproducing it here would mean building twice more to watch a debug assert fire, and his death test already pins the pre-fix behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five references across four files. The comments now describe the deferred work by what it is rather than by ticket number, which keeps them readable without a Jira lookup and stops them going stale when tickets are split or renumbered. The tickets are still named in the PR description and the review threads, which is where that traceability belongs. While editing the serializer comment, also corrected a claim it still made. It described the stub file left by a rejected save as failing closed on load, and treated moving the check earlier as a nicety. The save has by then already truncated whatever was at that path, so the comment now says that instead. The code is unchanged; only the description of the known limitation is. Comments only, no executable change. Verified with -Wall -Werror -fsyntax-only on both affected translation units and check-format; tests were not re-run, since no code changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ac05e54 to
ace52fa
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
❌ 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 ace52fa. Configure here.
| if (params->quantType != VecSimQuant_SQ8 || | ||
| !SQ8ParamsSupported(params->type, ResolveSQ8Metric(params->metric, is_normalized), | ||
| params->quantParams != nullptr)) { | ||
| throw std::invalid_argument("Unsupported quantization params for HNSW index"); |
There was a problem hiding this comment.
Estimate API throws into C callers
High Severity
HNSWFactory::EstimateInitialSize and TieredHNSWFactory::EstimateInitialSize throw std::invalid_argument for rejected SQ8 combinations. That exception is not caught by the extern "C" VecSimIndex_EstimateInitialSize wrapper, so a C host (for example FP32 plus SQ8 plus Cosine) terminates instead of getting a failure size. Index creation already returns NULL for the same params.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit ace52fa. Configure here.
RedisAI#1014) * fix(uint8): make the integer accumulators exact, with a dim fallback 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 RedisAI#1007; on main nothing constructs an SQ8 index. Split out of RedisAI#1011 because none of this depends on the SQ8 metadata contract that PR is changing, while all of it affects code reachable today. RedisAI#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> * fix(uint8): accumulate in chunks instead of capping the SIMD dimension 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> * test(uint8): sweep every residual past the chunk boundary, and every 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> * test(uint8): report which tiers the per-tier test actually exercised 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> * docs(uint8): correct the linkage comment, the collision is live on gcc 12.3 The comment claimed both copies of UINT8_InnerProductImp are fully inlined at -O2 so nothing collides, and that static was therefore defensive. Measured on an aarch64 host with gcc 12.3: not inlined. Both objects emit one weak COMDAT symbol per residual under the same mangled name, the linker keeps a single body, and on main the plain NEON inner product and cosine wrappers branch into the NEON_DOTPROD body and execute udot. That faults on a core with asimd but without asimddp, which includes Neoverse-N1 and Graviton2. x86-64 gcc 13/14 and aarch64 clang 18 do inline it and do not collide, which is why the earlier check came back clean and why this cannot be left to the toolchain. static is load-bearing on at least one shipping compiler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(uint8): stop the chunked wrapper pessimising its first chunk, and cheapen the IP epilogue Two findings from benchmark runs on an Ice Lake-SP host and an ARM host, both measured rather than inferred. First, the chunked wrapper was 8-9.5% slower per element than the plain kernel on the stretch its first chunk covers. Cause: the first chunk's length was a compile-time constant, so its loop had a compile-time trip count, and GCC then split that loop's accumulator and copied it in and out every 64 elements. The inner product loop went 12 instructions with no register moves to 13 with two; L2 went 14/0 to 15/2. The later-chunks loop was always fine. Fixed by two changes to the chunked wrappers only, leaving the plain path alone: - the first chunk's length is now a runtime min against the dimension, so the trip count is not a constant. Both loops are back to 12/0 and 14/0, matching the plain kernels exactly, at every residual. The min also makes the chunked wrapper correct at any dimension rather than only past the chunk size. - the residual-0 chunks now go through one out-of-line copy of the kernel instead of being inlined into all 64 chunked wrappers. One call per 65,536 elements is unmeasurable, and it drops the AVX512 inner product family from 41,267 to 34,229 bytes of text, against 14,735 for the plain family alone. Second, the inner product epilogue. Converting the unsigned total to float and subtracting in float costs one instruction more than main's integer subtract plus a single signed convert, and rounds twice instead of once; it measured about 1% slower across all four IP benchmark groups. Restored the integer form, which is also what INT8_InnerProduct already does. The cast to int64_t is required because ret_t is unsigned for uint8, so 1 minus the total would otherwise wrap. Applied to the scalar kernel too, so scalar and SIMD stay bit-identical, which the exactness tests assert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(uint8): spell out the inner product's integer subtract and conversion The epilogue read as `return 1 - static_cast<int64_t>(...)` from a function declared to return float, which relies on an implicit narrowing conversion to carry the intent and reads like a type error. Same arithmetic, written out: hold the total in a signed local, subtract in integer, convert once explicitly. GCC folds the two forms to the same code, so this is readability only. The rationale now appears once per header on the plain wrapper rather than on every wrapper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(uint8): collect the kernel notes into one block per file The rationale had spread to a block above almost every definition, and some of it was repeated across headers and across the choosers. Collected into a single block at the top of each kernel header, with spaces.h left as the one place that carries the chunk-size argument, and the three-line reduce notes cut to one line. The chooser note went from three copies per file to one. Comment lines across the eight uint8 kernel headers: 249 to 161. Codegen is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Add shared uint8 chunked-accumulation driver Extracts the chunked-accumulation formula duplicated across the eight uint8 SIMD kernel headers into a single templated driver, so each kernel's 32-bit per-chunk total stays exact regardless of dimension. * Migrate SVE uint8 IP/L2 kernels to the shared chunking driver Replace the hand-written chunked-accumulation logic in IP_SVE_UINT8.h and L2_SVE_UINT8.h with adapters (UINT8_IPChunkKernel_SVE, UINT8_L2ChunkKernel_SVE) over spaces::uint8_chunked_total, using granule() = 4 * svcntb() for SVE's runtime block size. * Migrate IP kernels to shared uint8_chunking driver Replace local UINT8_InnerProductChunkedImp function templates in three inner-product kernel headers with adapter structs that call the shared chunked-accumulation driver in uint8_chunking.h. Reduces duplication across AVX512F_BW_VL_VNNI_UINT8, NEON_UINT8, and NEON_DOTPROD_UINT8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Add architecture-independent test for uint8 chunked-accumulation driver Exercises spaces::uint8_chunked_total directly with a mock kernel so the chunk-tiling invariants (exact tiling, granule-multiple steps, chunk-size cap) are verified on any host, not only through whatever SIMD kernel a given CPU happens to support. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Assert the granule precondition in the uint8 chunking driver Kernel::granule() must be in (0, UINT8_CHUNK_ELEMENTS] for the chunking arithmetic to hold; assert it instead of dividing by zero or silently underflowing chunk - tail. Documents the precondition that the invariants already relied on. * Add value-based end-to-end check to the uint8 chunked-driver test The tiling offset check compared a recorded offset to the cumulative sum of prior recorded lengths, both derived from the same driver-advanced pointer, so it could not fail on its own. Keep it (it still guards the coupling between the length passed to the kernel and the pointer advance) and add a real correctness check: the mock kernel now returns the sum of the bytes in the slice it was handed, read from a position-dependent fill, and the driver's total is compared against an independent sum over the whole buffer. That catches skipped, duplicated or mis-sized chunks that the offset check cannot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(uint8): make a zero-tier run skip, let CI demand its tier, and enforce the granule at compile time Three review points, all of which made a green result mean less than it looked. A run that exercised no SIMD tier now reports skipped rather than passed. On a host without the relevant instruction set the per-tier test executed no chunked kernel at all, and reporting that as a pass reads as coverage that does not exist. Hardware-specific CI can now name the tier it exists to cover by setting VECSIM_REQUIRE_UINT8_TIER to AVX512F_BW_VL_VNNI, SVE2, SVE, NEON_DOTPROD or NEON. The requirement is checked before the skip, so a mislabeled or silently downgraded runner fails instead of quietly skipping. Verified both ways: without the variable the test skips on this host, with it set the test fails and names the tier it could not reach. The granule precondition was guarded only by assert, which disappears under NDEBUG, so it protected nobody in a release build. granule() is now constexpr in the six fixed-width adapters, and the driver static_asserts the bound whenever the adapter can express it as a constant. Verified that granule 0 and granule 70000 both fail to compile under -DNDEBUG while 64 compiles. SVE keeps the runtime assert because its granule depends on the vector length and genuinely cannot be constant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(uint8): assert worst-case inputs against an independent 64-bit oracle, and let CI demand its tier Closes the two coverage gaps that this PR's own changes created, rather than deferring them. Independent oracle. Every existing uint8 kernel test used the scalar kernel as its oracle, which is a different code path but not an independent one. This series changed the scalar and SIMD inner product epilogues together so they would stay bit-identical, so a test asserting only scalar == SIMD cannot catch that shared convention being wrong. The new test derives its expectation from the inputs alone in 64-bit integer arithmetic and asserts the scalar kernel and every available SIMD tier against it on the same footing. Demonstrated non-vacuous: mutating the scalar epilogue from 1 - ip to ip - 1 leaves the residual-sweep test passing and fails only the oracle test. Worst-case overflow. The residual sweep used a ramp against all-255, averaging roughly half the maximum accumulator load, and the worst case appeared only at seven sampled dimensions through the dispatched tier. The new test sweeps every residual with all-255 against all-255, which puts 65,025 per element into the inner product accumulator, and all-255 against all-0, which does the same for L2. It does so at two bases: 65024+r, which stays under the chunk size and so drives the plain kernel's 32-bit reduce to about 4.23e9, just under UINT32_MAX; and 131072+r, whose total near 8.5e9 only a 64-bit fold can carry. A ramp pair is kept alongside because constant data lets a gap and an overlap of equal size cancel. The test asserts the worst-case totals actually exceed UINT32_MAX, so it cannot quietly stop exercising the fold. Tier discovery is now shared between this test and the per-tier test, so a tier cannot be covered by one and missed by the other. CI. task-unit-test.yml takes a require-uint8-tier input, exported as VECSIM_REQUIRE_UINT8_TIER. The dedicated ARM job requires SVE2, which r8g Graviton4 has, and the coverage job requires AVX512F_BW_VL_VNNI on both suite runs, which c7i Sapphire Rapids has. Those two jobs now fail rather than skip if the hardware they exist to cover is absent. Generic-CPU jobs leave it unset and skip as before. Also documents why SVE keeping only a runtime assert is acceptable: the architecture bounds an SVE vector to 16 to 256 bytes, so its granule is 64 to 1024, far below the 65,536 limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ci): stop an unset tier requirement from failing every job The previous commit wired VECSIM_REQUIRE_UINT8_TIER into task-unit-test.yml as `${{ inputs.require-uint8-tier }}`. When that input is unset, GitHub Actions still puts the variable in the environment with an empty value, so getenv returned a pointer to "" rather than nullptr and the requirement fired asking for a tier named empty string. That failed the per-tier test on every job that did not name a tier, which is the sanitizer, jammy, alpine and macos jobs. Both PRs went red. Two changes. The workflow wiring is reverted: arm.yml, coverage.yml and task-unit-test.yml go back to what they were, so the requirement is opt-in for manual and hardware runs, which is how it was actually used to validate this work on Graviton4 and Sapphire Rapids. And the test now treats an empty value as unset, so re-wiring it later cannot reintroduce the same failure. Verified all three cases: unset skips, set but empty skips, set to a tier this host lacks still fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(uint8): widen the cosine norm accumulator and make the AVX512 reduce unsigned Both defects were found by @lerman25 reviewing MOD-17527, and both are real. The cosine norm. IntegralType_ComputeNorm accumulated into a signed int, so with 65,025 per uint8 element the total passed INT32_MAX from dimension 33,026. That is the norm the cosine preprocessor writes for every stored vector and every query, so a wrong value reached the kernels before they ran: dimension 65,537 produced a NaN norm and 66,052 produced 252.99 instead of about 65,536.49. Widened to uint64_t. int8 had the same shape with a higher bound, overflowing from 133,153. The kernel tests never caught this because they append the norm by hand, so the production path was untested. Added two tests that go through it: VecSim_Normalize on an all-255 uint8 vector at 33,025, 33,026, 65,537 and 66,052, and a uint8 cosine brute force index storing and querying itself at 65,537, where the self-distance must be about zero. The AVX512 reduce. static_cast<uint32_t>(_mm512_reduce_add_epi32(sum)) casts after the reduction, and GCC implements that intrinsic as a chain of signed __v8si ops ending in a scalar `int + int`. A chunk total reaches 65025 * 65536, about 4.26e9, roughly twice INT32_MAX, so the addition overflows inside a single chunk. The wrapped bits are the ones we want, which is why every equality test passes, but the addition is signed-overflow UB. My comment claiming the adds were all vector operations was wrong about that last step. Replaced with a fold that zero-extends the 16 lanes to 64 bits before summing, in both the IP and L2 AVX512 kernels. Costs 2 instructions per call on the plain path, with the SIMD loop unchanged. NEON and SVE are unaffected: vaddvq_u32 and svaddv_u32 are genuinely unsigned. Verification status: the norm fix is verified directly, dimension 65,537 now gives 65,280.5 rather than NaN. The AVX512 fold is compile-verified and cost-measured only. This dev box has no AVX512, so its numeric result and the original UB both need a run on AVX512 hardware, ideally under -fsanitize=signed-integer-overflow at dimensions 33,025, 33,026 and 65,536 as suggested in review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(uint8): replace chunked accumulation with a scalar fallback above the exact dimension The chunked design made the uint8 kernels exact at any dimension, but it cost a shared driver, eight chunked wrappers, eight adapters, eight out-of-line chunk helpers, and a large amount of architecture-specific test machinery, all to serve dimensions above 66,051 that do not occur in real workloads. Embeddings run 384 to 4096. Replaced with a bound and a fallback. UINT8_MAX_EXACT_SIMD_DIM = UINT32_MAX / (UINT8_MAX * UINT8_MAX) = 66,051 Derived from the types rather than written as a literal, because the bound is a property of uint8 accumulating into uint32. At 66,051 the worst-case total is 4,294,966,275, which fits with 1,020 to spare; at 66,052 it does not. The three uint8 dispatchers return the scalar kernel above the bound, once per index, so no distance computation pays for the check. Kept, because none of it depends on chunking: - the widened scalar accumulator, which is what makes the fallback correct - uint32_t SIMD results and the integer-subtract inner product epilogue. These are load-bearing at this bound, not hygiene: the total passes INT32_MAX from dimension 33,026, so a signed result wraps negative across a 33,026-wide band that stays on SIMD - the widened 64-bit AVX-512 fold. Also load-bearing for the same reason: GCC implements _mm512_reduce_add_epi32 as signed vector ops ending in a scalar int + int, which overflows across that same band. It cannot be dropped in favour of the dispatcher bound because the SQ8_SQ8 kernels call the helper directly and never pass through a uint8 chooser - static linkage separating the NEON and NEON_DOTPROD helpers, which fixes an unrelated udot fault on cores without asimddp - the cosine norm accumulator widened to uint64_t Removed: uint8_chunking.h, every _Chunked wrapper, every ChunkKernel adapter, every FullChunk helper, and the multi-chunk tests. Tests now pin two boundaries. The signed boundary, 33,025 against 33,026, where both sides stay on SIMD and 33,026 is what exercises the widened fold. And the dispatcher boundary, 66,051 against 66,052, where the second must come back as the scalar kernel by name. The oracle test sweeps every residual at 33,024+r and 65,984+r with worst-case inputs, asserting the upper base exceeds INT32_MAX and stays within UINT32_MAX, so it cannot drift off the case it exists for. Verified on an AVX-512 host: 472/472 pass, and the per-tier and oracle tests both report AVX512F_BW_VL_VNNI at every boundary dimension rather than passing silently. A negative control demanding SVE2 on that host fails as designed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Trim the uint8 comments back to the density of the surrounding kernels The comment blocks added for the accumulator work had grown past what the rest of the spaces directory carries. The untouched int8 siblings sit at 18 to 23 percent comment lines; several of these files had reached 28 to 51. Removed as duplication rather than as explanation: - compute_norm.h's seven-line accumulator note, down to two. The measured symptoms belong in the pull request, not in the header; the header needs the rule. Its in-loop promotion note went too, since the explicit static_cast says it and the inherited wording had become false. - The six-line fold rationale in both AVX512 kernels, down to three. The arithmetic it restated already lives in spaces.h. - The reciprocal paragraph in spaces.h explaining the AVX512 fold, which the fold now explains at the fold. - The static and always_inline note in six of eight kernels, where it is only hygiene. Kept in the two NEON inner product headers, which is where the names actually collide. Kept the udot linkage note, the bound's derivation, and the INT32_MAX at 33,026 fact, which is what justifies uint32_t and is not visible from any single kernel. Moved one comment rather than dropping it: the note about the epilogue's int64_t cast now sits at each epilogue instead of in a header block ninety lines away. Without that cast the subtraction is unsigned and wraps, so it needs to be readable where someone might simplify it. No code changed. Verified comment-only mechanically, and rebuilt to confirm the tested objects postdate the edits: 578/578. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Cap uint8 SIMD at the signed 32-bit bound instead of widening the reduce The bound was UINT32_MAX / 65025 = 66,051, which required the AVX512 kernels to replace _mm512_reduce_add_epi32 with a zero-extend-then-reduce-in-64-bit sequence, because GCC implements that intrinsic as signed vector operations ending in a scalar int + int. Keeping SIMD exact through the full unsigned range therefore cost a wider epilogue on every call, at every dimension, to serve dimensions from 33,026 to 66,051 that no workload reaches: embeddings run 384 to 4096. The bound is now INT32_MAX / 65025 = 33,025, still derived from the types. Both AVX512 epilogues go back to the single intrinsic, identical to main. Six dispatchers carry the guard rather than three. The SQ8_SQ8 kernels call the shared UINT8_InnerProductImp directly and never pass a uint8 chooser, so with the widened fold gone they would have been the one path still able to wrap a signed reduce. They have their own dimension-aware choosers, so they take the same guard. Their scalar fallback accumulates in float, imprecise past 2^24 but well defined, which is the better of the two failure modes. NEON and SVE reduce genuinely unsigned and would be exact to 66,051 either way. The bound is uniform across architectures on purpose: a per-architecture bound would make the dispatcher's answer to "is this dimension on SIMD" depend on the host, so two machines would disagree about which kernel serves dimension 50,000. Tests collapse to one boundary, since 33,025/33,026 is now both the signed-reduction boundary and the dispatcher boundary. The oracle sweep runs 32,960 to 33,025 contiguously, covering every residual and ending exactly at the cap, and asserts the worst case at the cap is both within INT32_MAX and within one element's product of it, so it fails if it ever drifts off the boundary rather than quietly testing an easier dimension. That assert immediately caught a latent bug in the test itself: each dimension's cosine norm is written at vec + dim, which is payload for every larger dimension, so with a contiguous sweep dimension 32,960's norm corrupted 65 bytes of dimension 33,025's all-255 vector and the total came up 2,753,904 short. The old two-base sweep could not see this because both sides of the comparison read the same corrupted array. Both tier-aware tests now save and restore those bytes. Verified on Xeon Platinum 8375C: 1036/1036, with AVX512F_BW_VL_VNNI reported at all five boundary dimensions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Reduce the uint8 fix to the changes the cap actually requires With SIMD capped at the signed 32-bit bound, main's kernel arithmetic is already correct: ARM does `int32_t result = vaddvq_u32(total_sum)` and x86 does `_mm512_reduce_add_epi32`, and the largest total the cap admits is 2,147,450,625, which fits int32_t. So none of the kernels needed a type change, and the uint32_t returns, the int64_t casts in the inner product epilogues, the L2 Imp/wrapper split and the SQ8 call site changes were all serving the earlier 66,051 design rather than this one. They are reverted to main, along with the unrelated benchmark fixes. What remains is what the cap requires: - spaces.h: the bound, derived from the types. - Six choosers return the scalar kernel above it, once per index. Three for uint8 and three for SQ8_SQ8, because the SQ8 kernels call the shared uint8 helper directly and no uint8 chooser sees those calls. - ret_t is `long long` for every element type rather than `int` for one-byte ones. The scalar kernel is the fallback above the cap, so it has to be exact there. Keeping it signed means the wrappers keep main's `1 - ip` form. - compute_norm.h accumulates into `long long`. The norm is written by the cosine preprocessor for every stored vector and query regardless of which kernel runs, so this is independent of the cap: at dimension 65,537 the norm came back NaN. - Internal linkage on three inner product helpers. The linkage fix covers three headers, not the two NEON ones. IP_SVE_UINT8.h is compiled into both SVE.cpp (-march=armv8-a+sve) and SVE2.cpp (-march=armv9-a+sve2), so one mangled name carried two independently generated bodies there too. That pattern is not uint8-specific and affects 14 SVE headers across every data type; measured benign today, since the shared symbols use identical instruction sets, and tracked as MOD-17759 rather than fixed here. Also dropped the independent-oracle test. Its justification was that this PR changed the scalar and SIMD epilogues together, so asserting scalar == SIMD would be blind to a wrong shared convention. Neither epilogue is changed any more, so that reasoning no longer holds. Its one unique contribution, pinning the bound arithmetically rather than trusting the constant, survives as two static_asserts in the boundary test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Correct the bound's comment: it named the wrong reason and overstated one claim Three fixes, all to text I wrote: The comment justified bounding at the signed limit only through GCC's _mm512_reduce_add_epi32. That is a supporting detail. The reason is that every kernel already holds its total in a signed 32-bit int, on ARM as `int32_t result = vaddvq_u32(...)` and on x86 as the reduce's int return, and this change leaves them alone. It claimed NEON and SVE "would be exact to 66,051 either way". That is false. They reduce with unsigned intrinsics but store the result into int32_t, so reaching 66,051 there would need a type change too. It asserted that widening the reduce "costs measurably at the dimensions that occur in practice". The measurement behind that is from an earlier revision of this branch and was not repeated against this head, so the claim does not belong in a code comment stated as fact. The remaining argument, that the extra range serves no real workload, stands on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * State 33,025 in the bound's comment instead of leading with the number we rejected The comment explained at length why the bound is not at the unsigned limit, so 66,051 appeared twice directly above a constant whose value is 33,025, and reading it left the impression that the constant was the larger number. The value it defines is now stated plainly, with the arithmetic that fixes it, and the road-not-taken argument stays in the pull request description where it belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Rewrite the uint8 tier tests on the suite's existing conventions The two tier-aware tests carried their own machinery: a UInt8TierFuncs struct, an AvailableUInt8Tiers() helper building a vector of tiers, plus RecordProperty, stdout tier reporting, a VECSIM_REQUIRE_UINT8_TIER environment gate and a GTEST_SKIP. Nothing else in test_spaces.cpp works that way, and the file already had a pattern for both jobs. The boundary test now uses the step-down ladder from UINT8L2SqrTest: read the CPU features, then walk SVE2, SVE, NEON_DOTPROD, NEON and AVX512, asserting at dimension 33,025 that each chooser hands back that tier's Choose_UINT8_*_implementation for all three metrics, clearing the tier's flags before the next block and ending on the scalar kernel once none are left. That replaces an EXPECT_NE gated on "does this host have any tier" and says more: every tier the host has is still served at the bound, not merely something other than the scalar kernel. The above-bound assertions now pass the real feature struct rather than nullptr, so the guard is shown overriding the host's best tier. The per-tier exactness test now mirrors UINT8_full_range_test: call each tier's chooser directly under the same ifdef and feature guard, three ASSERT_EQ against the scalar baselines, same message style. Norms come from test_utils::integral_compute_norm instead of a hand-computed sqrt, which also exercises the widened norm accumulator, and each dimension gets its own vectors so there is no saving and restoring of the four norm bytes. Each metric now runs on the pair that maximises its total, L2 on all-255 against all-0 and inner product on all-255 against itself, so both reach 65,025 * dim, which is 2,147,450,625 at the bound and the largest total a signed 32-bit accumulator holds. The previous ramp-against-ones pair peaked near 1.07e9, half the limit, so it never stressed the bound it was placed to validate. Cosine keeps the ramp because the all-zero vector has no norm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Cover the three SQ8 dispatcher guards, which no test reached codecov/patch failed on this PR and it was right. The SQ8_SQ8 guards were the only new lines with no test touching them: the suite does call IP_SQ8_SQ8_GetDistFunc and L2_SQ8_SQ8_GetDistFunc, but only at small dimensions, so the `return ret_dist_func` inside each guard was never taken. That matters more than the coverage number. Those three guards are the whole reason the SQ8 kernels are safe under this cap: they call the shared UINT8_InnerProductImp directly and never pass through a uint8 chooser, so without the guard they are the one path left able to wrap a signed 32-bit total. Shipping them untested would have meant shipping the SQ8 protection on the strength of an argument rather than a test. Three assertions in the boundary test, alongside the uint8 ones, asserting each SQ8 dispatcher hands back its scalar kernel by name above the bound. 1024/1024 on Xeon Platinum 8375C. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fold the uint8 cap tests into the suite's own fixtures The previous round still read as foreign code. Three bespoke TEST_F bodies with sentence-length names, hand-rolled dimension loops, per-metric pointer aliases, static_asserts inside a test body, relative-error comparisons and paragraphs of prose, none of which appear anywhere else in test_spaces.cpp. The file already had a pattern for each job. Dimension sweeps are a second INSTANTIATE_TEST_SUITE_P over an existing fixture, exactly as SQ8_FP16_SIMD_HighDim extends SQ8_FP16_SpacesOptimizationTest. Adversarial values across every tier are a TEST_P in the optimization fixture calling each Choose_UINT8_*_implementation directly, exactly as UINT8_full_range_test does. So: UINT8OptFuncsNearCap instantiates UINT8SpacesOptimizationTest at 32,960 / 32,993 / 33,023 / 33,024 / 33,025, which gets the existing L2, IP and cosine step-down ladders running at near-cap dimensions for free, including the assertion that each tier is still handed out at the cap. That is what the old test's EXPECT_NE and its "does this host have a tier" gate were reaching for, and the fixture already does it properly. UINT8_max_value_test is the worst-case counterpart to UINT8_full_range_test: all-255 against all-0 for L2 and all-255 against itself for IP and cosine, both totalling 255 * 255 * dim, the largest total the 32-bit accumulators hold at the cap. It also adds the NEON and NEON_DOTPROD blocks that UINT8_full_range_test never had. UINT8_DispatcherCapFallback keeps only what is dimension-specific: the cap pinned arithmetically, all six uint8 and SQ8_SQ8 choosers returning their scalar kernel above it, and the scalar kernels being exact there. Exact integer comparisons throughout, no tolerances. 132 added lines where the previous round had 247, and no new test-only machinery. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Compare uint8 IP within a ULP, which is all the two kernel forms agree to Bugbot caught a real defect in the previous commit. UINT8_max_value_test asserted exact float equality between the scalar inner product and every tier's, and the two are not the same expression. The scalar kernel and the AVX512 kernel compute `1 - sum` in integers and convert once; NEON and SVE compute `1.0f - float(sum)`. Those round differently once the total passes 2^24. Measured rather than argued, by evaluating both forms directly: with all-255 bytes, 839 of the 32,994 dimensions in [32, 33025] disagree, and dimension 32,960, which UINT8OptFuncsNearCap instantiates, is one of them: -2143223936 against -2143224064, exactly 128 apart, one float ULP at that magnitude. So the previous commit would have failed on ARM while passing on x86, which is also why the Xeon run did not catch it. IP is now compared within one ULP, computed from the baseline rather than hardcoded. L2 and cosine stay exact: both convert the total to float and do no further integer arithmetic, so every tier and the scalar path produce bit identical results there. The existing suites that UINT8OptFuncsNearCap newly instantiates are safe as they stand: their populate_uint8_vec data at all five dimensions produces totals where both forms agree, checked the same way. That ARM and x86 differ by a ULP on the same uint8 inner product is pre-existing on main, not introduced here, and is a separate question from the cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Give the int8 NEON inner product helper internal linkage too Defect 2 in this PR is UINT8_InnerProductImp, defined identically in IP_NEON_UINT8.h and IP_NEON_DOTPROD_UINT8.h and merged by link order. INT8_InnerProductImp is the same defect one datatype over, in the same two directories, and the fix for uint8 left it untouched. Measured on Graviton4 with gcc 12 against this branch before the change: shared weak symbols, NEON.o vs NEON_DOTPROD.o: 64 of which INT8_InnerProductImp: 64 of which UINT8_InnerProductImp: 0 Every remaining collision was the int8 twin, which is both evidence the uint8 fix works and evidence it did half the job. The consequence is the same. IP_space.cpp selects Choose_INT8_IP_implementation_NEON on features.asimd alone, with no dotprod requirement, and that wrapper calls INT8_InnerProductImp. If the DOTPROD-compiled body is the one the linker keeps, plain NEON executes sdot, which needs asimddp: SIGILL on an armv8-a core without it. Nothing in the source decides which body survives. Two definitions of this same name already avoid the collision, which is the in-repo precedent: IP_AVX512F_BW_VL_VNNI_INT8.h declares it static inline, and IP_SVE_INT8.h has a different template parameter list. The helper is referenced only inside its own headers, so static is sufficient and local. After, on the same host: 0 shared weak symbols, NEON.o has zero sdot and keeps its 472 smull, NEON_DOTPROD.o keeps all 428 sdot. 858/858 tests pass. Not included here: a nm zero-collision CI gate would fail immediately on the 168 SVE/SVE2 collisions this PR does not fix, so it belongs with MOD-17759 where those are tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Use uint64_t for integral norm accumulation * Give the SVE int8 helper internal linkage too, completing the helper sweep The previous commit made the two NEON int8 definitions static and missed this one. IP_SVE_INT8.h is compiled into both SVE.cpp (-march=armv8-a+sve) and SVE2.cpp (-march=armv9-a+sve2), so INT8_InnerProductImp carried one mangled name with two independently generated bodies, 8 instantiations of exactly the defect the previous commit claimed to fix. Measured on Graviton4, shared weak symbols between SVE.cpp.o and SVE2.cpp.o: before: 160, including 8 INT8_InnerProductImp after: 152, zero InnerProductImp of either datatype 858/858 tests pass. What remains between those two objects is the wrappers, 8 instantiations each of UINT8_InnerProductSIMD_SVE, UINT8_CosineSIMD_SVE, UINT8_L2SqrSIMD_SVE and their int8 counterparts. Making a helper static removes the helper's collision but leaves its callers colliding, and those callers now differ more than before since each refers to its own unit's helper. That residue is latent rather than live: across all shared SVE/SVE2 symbols both compilations use identical instruction sets, 57 distinct opcodes each with no difference in either direction, so no SVE2-only instruction can reach an SVE-only core. Closing it means giving the wrappers internal linkage across IP, L2 and Cosine for every data type, which is MOD-17759. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Trim the linkage comments to one line each, and drop the ticket and reviewer name The static changes carried up to three lines of explanation each, wedged between the template header and the signature, restating what the commit message and the pull request already cover. One line above the template is enough to stop someone deleting the keyword as redundant; the reasoning belongs in the history. Twelve comment lines become six. Also removed the ticket number and reviewer handle from the norm test's comment, per review. The defect and why it went unnoticed stand on their own; who reported it and under which ticket is what the pull request and git history are for, and a name in a comment goes stale the moment the file outlives the review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>


Describe the changes in the pull request
Cherry-pick of ARM-software/VectorSimilarity-for-Arm#4 (head
125ea15d), plus a follow-up review pass. Fourth PR in the SQ8 series, after #999, #1000 and #1002.Adds 8-bit scalar quantization (SQ8) to the standalone HNSW index:
VecSimQuantTypeplusquantType/quantParamsonHNSWParams. Both fields are appended at the end of the struct andVecSimQuant_NONEis 0, so existing zero-initialized and designated-initializer construction is unaffected.HNSWFactorycan build SQ8 indexes for FLOAT32 and FLOAT16 data types with the L2 and IP metrics, wiringQuantPreprocessorandDistanceCalculatorWithNorm, and accounts for SQ8 inEstimateInitialSizeandEstimateElementSize.quantParamspoints to afloat[dim]mean vector; a null pointer selects quantization without mean normalization.test_hnsw_sq8unit-test target and suite (44 tests: FP32/FP16 x L2/IP).SQ8 support for the tiered HNSW index, serialization and benchmarks is deferred to later PRs in the series.
Commit 1: the cherry-pick
ARM's four commits squashed into one, with no functional change to their code. Two Redis-side adjustments:
SPDX-FileCopyrightTextArm line from the two modified files, matching how [MOD-14953] Add calcDistanceForQuery to IndexCalculatorInterface #999, [MOD-14952] Support normalization in QuantPreprocessor #1000 and [MOD-14955] Add DistanceCalculatorWithNorm #1002 landed. It is kept on the newtests/unit/test_hnsw_sq8.cpp, where theBSD-3-Clauseidentifier was replaced by this repo's Redis tri-license header.make check-formatpasses at the 100-column limit.Commit 2: review follow-up
No behavioural change intended. Interface, single-source-of-truth and idiom fixes:
quantParamsis nowconst void *. Every use reads it and two already cast toconst float *. Layout-identical, so not an ABI break, and callers passing non-const still compile. Better to fix before the field ships and freezes.VecSimQuant_SQ8comment claimed "with mean normalization", but mean normalization is optional and selected byquantParams. Reworded.GetSQ8StoredDataSizere-derived the stored blob size thatQuantPreprocessor's constructors already computed. Two formulas for one layout drift silently, which is the bug class fixed in MOD-15303. The formula now lives once assq8::storage_bytes_count<Metric, WithNorm>(dim), beside thestorage_metadata_countit builds on, and both callers use it. This is why the diff touchestypes/sq8.handspaces/computer/preprocessors.h, two files beyond ARM's original four: having one shared definition is the entire point of the fix.return NULLclosing the SQ8 branch. Unreachable today, since the type and metric checks leave only FP32/FP16 x L2/IP, but without it adding a type or metric silently falls through and builds an unquantized index.assert(ret == 0)onaddPreprocessoris nowassert(ret != -1). The function returns -1 on failure, 0 when the container is full, and the next free index otherwise, so 0 is merely the only success value at the current container size of one.!= -1is the documented contract and the existing repo idiom.addPreprocessor, assert,IndexComponents, return); only the preprocessor and calculator differ.assigninstead of a zero-filling constructor followed bymemcpy, which wrote every element twice.GetDistFuncfor a function that is never used, sincespaces.hoffers no alignment-only query and the asymmetric hint covers the storage operand. That call now lives in a smallGetQueryAlignment<DataType>adapter returning the hint, so the call site neither discards a value nor keeps a third distance function in scope besidesym_funcandasym_functhat must never be called.query_alignmentis const.GetSQ8StoredDataSizeis[[nodiscard]] constexpranddim/with_normare const.Commit 3: reject quantized tiered indexes until MOD-14957
Adding
quantTypetoHNSWParamsmakes it reachable on the tiered path, where nothing handles it.TieredHNSWFactory::NewIndexforwardsprimaryIndexParamsintoHNSWFactory::NewIndex, so the primary index quantizes its storage, whileNewBFParamsdoes not copyquantTypeand the frontend stays unquantized. Reachable from any direct C API caller withalgo = VecSimAlgo_TIEREDandquantType = VecSimQuant_SQ8:getStoredDataSize()assert attiered_factory.cpp:54aborts on a debug build; underNDEBUGthe index is built with mismatched frontend and backend layouts.HNSWFactory::NewIndexreturns NULL for these types under SQ8, and the result isreinterpret_castand dereferenced with no null check, so the process segfaults.The
catch (...)inindex_factory.cppdoes not help, since neither an abort nor a null dereference is an exception. RediSearch cannot setquantTypeuntil MOD-14958, so there is no product exposure today; the guard exists so main does not carry the defect between cherry-picks. MOD-14957 should replace this check and its test rather than delete them.Commit 4: cover SQ8 rejection of unsupported data types
HNSWSQ8ParamsTest.RejectsUnsupportedDataTypeasserts that FLOAT64, BFLOAT16, INT8 and UINT8 withVecSimQuant_SQ8all returnNULLfrom index creation. Verified red without the fix: with both the type fence and the fall-throughreturn NULLremoved, all four are silently built as unquantized indexes. Also documents atEstimateElementSizewhy the estimate deliberately does not repeat the check (see the Bugbot thread on this PR).Commit 5: fix two defects found in review (63271c1)
Both raised by @lerman25, both verified before fixing.
VecSimIndex_GetDistanceFrom_Unsafedocumentsblobas a raw dim-by-type vector, but the SQ8 kernels read query metadata appended past that, so honouring the documented contract read past the caller's buffer. Reproduced under ASan:heap-buffer-overflow, READ of size 4inSQ8_FP32_InnerProduct_Implviavec_sim.cpp:231.getDistanceFrom_Unsafenow returnsINVALID_SCOREfor a quantized index, the valuegetDistanceFromInternalalready uses for "no answer".AbstractIndexInitParamsgainsisQuantizedfor this, parallel toisDisk. Preprocessing internally was rejected becausepreprocessQueryalso normalizes cosine queries, so it would change behaviour for every existing cosine index; a public prepared-query API is the real answer and belongs with MOD-14958.-inf. Now rejected at construction. FP16 + mean + IP is unaffected and still supported, since that path does not centre the query.The test that should have caught the first one passed a preprocessed blob obtained through a C++-only path no C caller has. It now checks the maths via
calcDistanceForQueryand separately asserts the public API reports no answer for a raw vector.Commit 6: serialization guard and IP graph coverage (9e69259)
saveIndexIMPrefuses a quantized index. The V4 format records neitherquantTypenor the mean, and the loading path always builds unquantized components, so a saved SQ8 index reloaded with the wrong stride and consumed graph bytes as vector data. Same argument as the tiered guard: fail closed. Caveat: the encoding version is written before this check runs, so a rejected save leaves a stub file, which still fails closed on load.GraphConstructionIP. Every other functional test uses L2, so the symmetric SQ8-to-SQ8 IP kernel that graph construction selects was never executed, and this series is the first thing to put it on the insert path. Its vectors vary per component, so it avoids the degeneratemin == maxbranch the other tests take.assert(false && "...")before the unreachablereturn NULL(kept alongside it, since assert-only would reopen the silent-fallthrough hole underNDEBUG), and the stray blank line removed.Commit 7: make the size estimates reject what index creation rejects (d5d93d6)
Closes the three Bugbot findings that were still open, plus one gap none of them covered. No behavioural change on any supported configuration.
The supported-SQ8-combination test now lives in one place instead of being open-coded in
NewIndexonly:ResolveSQ8Metricapplies theis_normalizedCosine-to-IP remap, andSQ8ParamsSupportedholds all the fences. Same single-source-of-truth argument assq8::storage_bytes_countin commit 2.NewIndexrejects aquantTypethat is neitherNONEnorSQ8. Previously any other value fell through to the full-precision path and silently built an unquantized index for a caller that asked for a quantized one. Deliberately untested: the enum holds only those two values, so forming a third is undefined behaviour. The guard is what stops addingSQ4from reopening the hole.EstimateInitialSizerejects the same set asNewIndex, replacing a check that validated only the data type. This closes the reported metric gap and one nobody raised: the mean-centred FP16 L2 fence added in63271c14was never mirrored into the estimate, so that combination still reported a size.TieredHNSWFactory::EstimateInitialSizerejectsquantType != NONE, matching itsNewIndexguard. It needs its own check rather than inheriting one, since the primary index accepts FP32 with L2 and SQ8 happily and nothing propagates up.EstimateElementSizeis left lax on purpose:size_treturn with no sentinel, andVecSimIndex_EstimateElementSizeisextern "C", so a throw would carry an exception into the C host. It answers for whatever params it is handed, exactly asVecSimParams_GetStoredDataSizealways has on the unquantized path. Both functions now say which one validates and why the other cannot.Flagged rather than buried: this adds two
throwsites reachable throughVecSimIndex_EstimateInitialSize, which has notry/catch. That is the existing idiom in both files rather than a new hazard, but the error model for the estimate functions is unsettled and belongs with MOD-14958.Verified red without the fix: 5 failures, all "throws nothing".
RejectsUnsupportedDataTypestayed green, since the old code already threw for a bad type.Commit 8: reject an out-of-range metric, and document the SQ8 distance sentinel (9b1615c)
The two findings from @lerman25's second review round that are inside MOD-14956.
VecSimMetricaborted the process. FP32 with SQ8 andmetric = (VecSimMetric)123passed the Cosine-only check, matched neither dispatch branch, and hit theassert(false)added in9e69259c. The unquantized dispatcher throws andVecSimIndex_Newcatches it, so quantized params were the only way to abort an assertions-enabled host.SQ8ParamsSupportednow whitelists L2 and IP. Note commit 7's refactor preserved this hole verbatim rather than introducing it: the check it replaced also tested only for Cosine.RejectsOutOfRangeMetricuses(VecSimMetric)3, outside the valid set but inside the enum's value range, so the test does not itself rely on UB.INVALID_SCOREsentinel is now documented onvec_sim.h.63271c14madegetDistanceFrom_Unsafereturn it for a quantized index, but the declaration still promised a distance, and it is the same NaN used for a missing label, so a caller could treat valid candidates as absent. The declaration now states the restriction, the collision, and that callers must checkquantType.Deferred to new tickets, all under epic MOD-6132
Findings from the second review round that fall outside MOD-14956 ("wire SQ8 into HNSWIndex + factory") and outside the HLD. Each ticket carries the reproduction and the boundary.
||x||^2 + ||y||^2 - 2*IPin FP32. Stored[100000, 100008]against query[100000, 100000]returns 0.0 where the truth is 64.0, and quantization is exact for that input. Onset aroundoffset / spread > ~4000.TRAINING_THRESHOLDis the safe configuration.q = 0).QuantPreprocessorcasts NaN touint8for finite input when the derived range overflows to infinity. UB onAddVector.AddVectorcannot report "unquantizable".HNSWSerializer::saveIndextruncates the destination before validating, so a rejected save destroys a valid existing snapshot.BUILD_TESTSgetDataByLabelcopiesdim * sizeof(DataType)from a shorter SQ8 blob.GraphConstructionIPexecutes the symmetric IP kernel but never validates its result (every vector quantizes to the same byte payload); metric andmultiaxes still uncovered.Verification
Run on the final tree, after both commits:
./check-format.shg++ -Wall -Werror -fsyntax-only, with and without-DNDEBUGmake build DEBUG=1test_hnsw_sq8make unit_test DEBUG=1make asanNot run:
FP_64=1variants, since this change is FP32/FP16 only.Both new guards were confirmed red without their fix, and the ASan repro above is clean after it.
Test count moved 2653 -> 2650. Dropping FLOAT16-with-mean from the functional type set removed 11 typed tests for a combination that is now rejected, and the two new tests added 6 back. FP16 + mean + IP is left with construction coverage only, and the multi-label path and full metric parameterization remain uncovered; both are tracked in MOD-17531 rather than silently dropped.
Reviewed and deliberately left alone
query_alignmenthint comes from the symmetricDataTypedispatcher while the asymmetric kernel that consumes the query uses unaligned loads (_mm512_loadu_ps). This costs nothing:QuantPreprocessor::preprocessQueryalways allocates a fresh blob viaallocate_aligned, so the hint only selects that allocation's alignment. It matches the asymmetric-types contract inspaces.h.EstimateInitialSizeuses<float>for the index class even on the FP16 path. Verified correct with astatic_assertonsizeoffor both the single and multi index classes.mean_sum_squaresaccumulates infloat. It is a constant additive term on the IP path only, identical for every candidate, so it cannot affect ranking, only the absolute reported distance. Left as is.new (allocator)calls with no RAII between them leak if a later constructor throws.preprocessors_factory.hdoes the same, so this is repo-wide debt rather than something this PR introduced.Which issues this PR fixes
Main objects this PR modified
HNSWFactoryindex creation and memory estimationHNSWParamsand the newVecSimQuantTypepublic APIsq8::storage_bytes_count, now the single definition of the SQ8 storage layout sizeMark if applicable
🤖 Generated with Claude Code
Note
Medium Risk
Touches the public HNSW params API, index construction, distance calculation, and memory estimation. Unsupported paths (tiered, serialization, bad types/metrics) are rejected rather than silently falling through.
Overview
Adds SQ8 quantization to standalone HNSW via new
quantType/quantParamsfields onHNSWParams(appended, defaultNONE). FLOAT32 and FLOAT16 with L2 or IP are supported; optional mean centering is copied fromquantParams.HNSWFactorybuilds quantized indexes withQuantPreprocessorand the matching distance calculators, and size estimates account for the SQ8 stored layout (sq8::storage_bytes_count).getDistanceFrom_Unsafepreprocesses the query on quantized indexes so the public C API does not read past a raw blob.Unsupported combinations fail closed: unknown quantizers, mean-centered FP16 L2, Cosine without pre-normalization, quantized tiered indexes, and serialization of quantized indexes. A new
test_hnsw_sq8suite covers search, range, batch iteration, distance, and those rejections. Tiered SQ8, save/load, and benchmarks are deferred.Reviewed by Cursor Bugbot for commit 0367f36. Bugbot is set up for automated code reviews on this repo. Configure here.