Skip to content

[MOD-14956] Add SQ8 quantization support for HNSW index - #1007

Open
dor-forer wants to merge 11 commits into
mainfrom
dor-forer-MOD-14956-hnsw-sq8
Open

[MOD-14956] Add SQ8 quantization support for HNSW index#1007
dor-forer wants to merge 11 commits into
mainfrom
dor-forer-MOD-14956-hnsw-sq8

Conversation

@dor-forer

@dor-forer dor-forer commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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:

  • 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 (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:

Commit 2: review follow-up

No behavioural change intended. Interface, single-source-of-truth and idiom fixes:

  • quantParams is now const void *. Every use reads it and two already cast to const float *. Layout-identical, so not an ABI break, and callers passing non-const still compile. Better to fix before the field ships and freezes.
  • The VecSimQuant_SQ8 comment claimed "with mean normalization", but mean normalization is optional and selected by quantParams. Reworded.
  • GetSQ8StoredDataSize re-derived the stored blob size that QuantPreprocessor'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 as sq8::storage_bytes_count<Metric, WithNorm>(dim), beside the storage_metadata_count it builds on, and both callers use it. This is why the diff touches types/sq8.h and spaces/computer/preprocessors.h, two files beyond ARM's original four: having one shared definition is the entire point of the fix.
  • Restored the return NULL closing 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) 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 both branches duplicated (container construction, addPreprocessor, assert, IndexComponents, return); only the preprocessor and calculator differ.
  • The mean vector is copied with one 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 returning the hint, so the call site neither discards a value nor keeps a third distance function in scope beside sym_func and asym_func that must never be called. query_alignment is const.
  • GetSQ8StoredDataSize is [[nodiscard]] constexpr and dim / with_norm are const.

Commit 3: reject quantized tiered indexes until MOD-14957

Adding quantType to HNSWParams makes it reachable on the tiered path, where nothing handles it. TieredHNSWFactory::NewIndex forwards primaryIndexParams into HNSWFactory::NewIndex, so the primary index quantizes its storage, while NewBFParams does not copy quantType and the frontend stays unquantized. Reachable from any direct C API caller with algo = VecSimAlgo_TIERED and quantType = VecSimQuant_SQ8:

  • FP32/FP16: the getStoredDataSize() assert at tiered_factory.cpp:54 aborts on a debug build; under NDEBUG 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 with no null check, so the process segfaults.

The catch (...) in index_factory.cpp does not help, since neither an abort nor a null dereference is an exception. RediSearch cannot set quantType until 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.RejectsUnsupportedDataType asserts that FLOAT64, BFLOAT16, INT8 and UINT8 with VecSimQuant_SQ8 all return NULL from index creation. Verified red without the fix: with both the type fence and the fall-through return NULL removed, all four are silently built as unquantized indexes. Also documents at EstimateElementSize why 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.

  • Out-of-bounds read through the public C API. VecSimIndex_GetDistanceFrom_Unsafe documents blob as 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 4 in SQ8_FP32_InnerProduct_Impl via vec_sim.cpp:231. getDistanceFrom_Unsafe now returns INVALID_SCORE for a quantized index, the value getDistanceFromInternal already uses for "no answer". AbstractIndexInitParams gains isQuantized for this, parallel to isDisk. Preprocessing internally was rejected because preprocessQuery also 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.
  • Mean-centred FP16 L2 loses correctness. The query is centred then narrowed back to FP16 while storage keeps FP32 min/delta. Verified numerically: mean 10000 gives a per-component error of 1.0 and a self-distance of 4.0 for an identical vector/query pair, and centring -40000 with mean 40000 gives -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 calcDistanceForQuery and separately asserts the public API reports no answer for a raw vector.

Commit 6: serialization guard and IP graph coverage (9e69259)

  • saveIndexIMP refuses a quantized index. The V4 format records neither quantType nor 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 degenerate min == max branch the other tests take.
  • Review nits: assert(false && "...") before the unreachable return NULL (kept alongside it, since assert-only would reopen the silent-fallthrough hole under NDEBUG), 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 NewIndex only: ResolveSQ8Metric applies the is_normalized Cosine-to-IP remap, and SQ8ParamsSupported holds all the fences. Same single-source-of-truth argument as sq8::storage_bytes_count in commit 2.

  • 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. Deliberately untested: the enum holds only those two values, so forming a third is undefined behaviour. The guard is what stops adding SQ4 from reopening the hole.
  • EstimateInitialSize rejects the same set as NewIndex, 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 in 63271c14 was never mirrored into the estimate, so that combination still reported a size.
  • TieredHNSWFactory::EstimateInitialSize rejects quantType != NONE, matching its NewIndex guard. It needs its own check rather than inheriting one, since the primary index accepts FP32 with L2 and SQ8 happily and nothing propagates up.

EstimateElementSize is left lax on purpose: size_t return with no sentinel, and VecSimIndex_EstimateElementSize is extern "C", so a throw would carry an exception into the C host. It answers for whatever params it is handed, exactly as VecSimParams_GetStoredDataSize always has on the unquantized path. Both functions now say which one validates and why the other cannot.

Flagged rather than buried: this adds 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 unsettled and belongs with MOD-14958.

Verified red without the fix: 5 failures, all "throws nothing". RejectsUnsupportedDataType stayed 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.

  • An out-of-range VecSimMetric aborted the process. FP32 with SQ8 and metric = (VecSimMetric)123 passed the Cosine-only check, matched neither dispatch branch, and hit the assert(false) added in 9e69259c. The unquantized dispatcher throws and VecSimIndex_New catches it, so quantized params were the only way to abort an assertions-enabled host. SQ8ParamsSupported now 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. RejectsOutOfRangeMetric uses (VecSimMetric)3, outside the valid set but inside the enum's value range, so the test does not itself rely on UB.
  • The INVALID_SCORE sentinel is now documented on vec_sim.h. 63271c14 made getDistanceFrom_Unsafe return 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 check quantType.

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.

Ticket Finding Why not here
MOD-17526 Both FP32 SQ8 L2 kernels lose precision through ||x||^2 + ||y||^2 - 2*IP in 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 around offset / spread > ~4000. Pre-existing kernels. The identity is what makes the SQ8-to-SQ8 inner loop a uint8 dot product, so a direct-difference rewrite trades a rare wrong answer for a slowdown nobody measured. Mean normalization removes it entirely, verified to offset 1e6, so the HLD default TRAINING_THRESHOLD is the safe configuration.
MOD-17527 SQ8-to-SQ8 IP SIMD kernels overflow int32 from dim 33,027 (boundary corrected from 33,026: every non-constant quantization has one q = 0). Pre-existing kernels, and the shared uint8 helper means a fix changes int8/uint8 behaviour too.
MOD-17528 QuantPreprocessor casts NaN to uint8 for finite input when the derived range overflows to infinity. UB on AddVector. MOD-14952's component. Needs a contract decision, since AddVector cannot report "unquantizable".
MOD-17529 HNSWSerializer::saveIndex truncates the destination before validating, so a rejected save destroys a valid existing snapshot. Pre-existing ordering that applies to any validation failure, not only the SQ8 guard. Correcting my own earlier claim on that thread that the stub "fails closed".
MOD-17530 BUILD_TESTS getDataByLabel copies dim * sizeof(DataType) from a shorter SQ8 blob. Test-only, no current caller, so the ASan run here is clean. A landmine for MOD-14957 and MOD-14959.
MOD-17531 GraphConstructionIP executes the symmetric IP kernel but never validates its result (every vector quantizes to the same byte payload); metric and multi axes still uncovered. Adding the metric axis means reworking hard-coded L2 expectations across several tests, and this is ARM's suite.

Verification

Run on the final tree, after both commits:

Check Result
./check-format.sh clean
g++ -Wall -Werror -fsyntax-only, with and without -DNDEBUG clean
make build DEBUG=1 clean, no warnings
test_hnsw_sq8 43/43 passed
make unit_test DEBUG=1 2650/2650 passed
make asan 2649/2649 passed, 0 sanitizer reports

Not run: FP_64=1 variants, 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

  • The query_alignment hint comes from the symmetric DataType dispatcher while the asymmetric kernel that consumes the query uses unaligned loads (_mm512_loadu_ps). This costs nothing: QuantPreprocessor::preprocessQuery always allocates a fresh blob via allocate_aligned, so the hint only selects that allocation's alignment. It matches the asymmetric-types contract in spaces.h.
  • The SQ8 branch of EstimateInitialSize uses <float> for the index class even on the FP16 path. Verified correct with a static_assert on sizeof for both the single and multi index classes.
  • mean_sum_squares accumulates in float. 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.
  • Three raw new (allocator) calls with no RAII between them leak if a later constructor throws. preprocessors_factory.h does the same, so this is repo-wide debt rather than something this PR introduced.

Which issues this PR fixes

  1. MOD-14956

Main objects this PR modified

  1. HNSWFactory index creation and memory estimation
  2. HNSWParams and the new VecSimQuantType public API
  3. sq8::storage_bytes_count, now the single definition of the SQ8 storage layout size
  4. SQ8 HNSW unit-test target and test suite

Mark if applicable

  • This PR introduces API changes
  • This PR introduces serialization changes

🤖 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 / quantParams fields on HNSWParams (appended, default NONE). FLOAT32 and FLOAT16 with L2 or IP are supported; optional mean centering is copied from quantParams.

HNSWFactory builds quantized indexes with QuantPreprocessor and the matching distance calculators, and size estimates account for the SQ8 stored layout (sq8::storage_bytes_count). getDistanceFrom_Unsafe preprocesses 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_sq8 suite 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.

@dor-forer
dor-forer force-pushed the dor-forer-MOD-14956-hnsw-sq8 branch 2 times, most recently from 2d29bd9 to 4d09236 Compare August 5, 2026 10:31
@dor-forer
dor-forer marked this pull request as ready for review August 5, 2026 10:40
Comment thread src/VecSim/index_factories/hnsw_factory.cpp
Comment thread src/VecSim/index_factories/hnsw_factory.cpp
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.24771% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (f30a540) to head (0367f36).

Files with missing lines Patch % Lines
src/VecSim/index_factories/hnsw_factory.cpp 96.70% 3 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/VecSim/index_factories/tiered_factory.cpp Outdated
Comment thread src/VecSim/index_factories/hnsw_factory.cpp
Comment thread src/VecSim/index_factories/hnsw_factory.cpp
dor-forer added a commit that referenced this pull request Aug 5, 2026
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>
@dor-forer
dor-forer requested a review from lerman25 August 5, 2026 11:56

@lerman25 lerman25 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = &params->algoParams.hnswParams;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

Nit

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9e69259.

Comment thread src/VecSim/index_factories/hnsw_factory.cpp
Comment thread tests/unit/test_hnsw_sq8.cpp Outdated
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/VecSim/index_factories/hnsw_factory.cpp
Comment thread src/VecSim/index_factories/hnsw_factory.cpp
Comment thread src/VecSim/index_factories/hnsw_factory.cpp
Comment thread tests/unit/test_hnsw_sq8.cpp
dor-forer added a commit that referenced this pull request Aug 9, 2026
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>
Comment thread src/VecSim/index_factories/hnsw_factory.cpp
dor-forer added a commit that referenced this pull request Aug 9, 2026
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 lerman25 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified against 9e69259c6adc6f5e3118459c2fe565c3490af517 with focused runtime reproducers.

Comment thread src/VecSim/index_factories/hnsw_factory.cpp

// Override blob size for the SQ8 storage layout.
abstractInitParams.storedDataSize = GetSQ8StoredDataSize<Metric>(dim, with_norm);
abstractInitParams.isQuantized = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/VecSim/index_factories/hnsw_factory.cpp
dor-forer added a commit that referenced this pull request Aug 10, 2026
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>
Comment thread src/VecSim/index_factories/hnsw_factory.cpp
Comment thread src/VecSim/index_factories/hnsw_factory.cpp
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d5d93d6. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/VecSim/algorithms/hnsw/hnsw_serializer_impl.h
: 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)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9b1615c. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@dor-forer
dor-forer requested a review from lerman25 August 10, 2026 10:59
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/VecSim/index_factories/hnsw_factory.cpp
dor-forer added a commit that referenced this pull request Aug 17, 2026
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>
dor-forer added a commit that referenced this pull request Aug 17, 2026
… 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>
dor-forer added a commit that referenced this pull request Aug 17, 2026
The uint8 kernels accumulate products or squared differences of bytes, so the
total reaches 255 * 255 * dim = 65025 * dim. Four paths could not hold that,
all of them on the plain int8/uint8 index paths that ship today.

  * IP.cpp / L2.cpp: ret_t for a 1-byte element type was int, so the scalar
    UINT8_InnerProduct and UINT8_L2Sqr executed signed-overflow UB from
    dimension 33,026, while the comment claimed support to 2^16. The
    conditional was also dead: only int8_t and uint8_t instantiate these, both
    1 byte, so it always selected int. ret_t is now 64-bit for every element
    type, which also covers int8 at dimension 131,072. Kept signed so the
    "1 - ip" in the wrappers stays signed arithmetic and cannot underflow. The
    L2 comment still carried the old byte-counting rationale and is fixed.

  * UINT8_InnerProductImp returned float on NEON and SVE, accumulating exactly
    in integer lanes and then discarding it, exact only to dimension 258 since
    2^24 / 65025 = 258.

  * AVX-512 reduced 16 int32 lanes with _mm512_reduce_add_epi32 into a signed
    int, wrapping from dimension 33,026.

  * L2_AVX512F_BW_VL_VNNI_UINT8 and L2_NEON_UINT8 read an unsigned horizontal
    reduce back into a signed int, so the distance went negative from the same
    dimension. L2_NEON_DOTPROD_UINT8 and L2_SVE_UINT8 were already unsigned.

The accumulation was never the problem. The SIMD adds wrap modulo 2^32 and are
bit-exact, so the bit pattern was already correct; the top bit was being read
as a sign. An unsigned 32-bit reduce therefore costs nothing over the original
and is exact through dimension 66,051 = floor(UINT32_MAX / 65,025), twice the
old signed limit of 33,025. Verified: the AVX-512 object is byte-for-byte the
same size as before at 514,792, with the same 33 and 40 instructions for
residual 0 and 32.

Above 66,051 the choosers hand back the scalar kernel, which after the ret_t
change is exact to roughly dimension 2.8e14. One comparison at index creation,
reusing the "if (dim < 32) return ret_dist_func" idiom the choosers already
had, and it leaves every kernel untouched.

Three alternatives were tried and rejected, each on evidence:

  * Widening the horizontal reduce unconditionally. Measured on an Ice Lake-SP
    Xeon it costs 4 extra uops in the epilogue: +20% at dimension 32, +8-11%
    across 55-200, +4-5% at 900-1024, on byte-identical loop code.

  * A runtime branch selecting the width per call. Measured at +0.4 to 0.6 ns
    per call, +15% at dimension 32, and it cost 31 of the 65 Cosine wrappers
    their inlining, growing .text by 18.4%.

  * Compile-time selection between two named wrappers, extending SIMD to a
    second bound of 4 * 66,051. This one is free on the narrow path, verified:
    the narrow wrappers stayed byte-identical and the out-of-line count
    unchanged. It was rejected for correctness, not cost. That bound assumes
    products spread evenly across the four uint32 lanes after NEON's 32-bit
    vaddq_u32 merge, and the even case already lands within 1,020 of
    UINT32_MAX, while the masked residual load can add up to 16 products, or
    1,040,400, into specific lanes. So lanes wrap before the widened reduce
    sees them, and a correct bound would have to be derived per kernel from its
    accumulator count and residual distribution. The narrow reduce needs none
    of that: its bound is on the horizontal total, which does not depend on how
    products land in lanes.

Recorded for whoever revisits this: on ARM the widening reduce is free
instruction-for-instruction. Cross-compiling with
clang++ --target=aarch64-linux-gnu -O2 emits addv/fmov w/ucvtf against
uaddlv/fmov x/ucvtf, three instructions either way. So the obstacle to a wider
band is the lane bound, not the reduce.

Nothing comparable supports that range regardless. Lucene caps its
scalar-quantized format at 1,024 dimensions and Elasticsearch caps dense
vectors at 4,096, both keeping a 32-bit accumulator safe by contract. Faiss's
QT_8bit_direct accumulates full-range bytes into 32-bit lanes with no widening
and carries the same theoretical limit. Qdrant quantizes to 0..127, lowering
the per-element cap to 16,129, and its raw uint8 metric still sums into i32.

The SQ8_SQ8 kernels reuse this helper, on main as much as here, so they now
take its result as uint32_t. Previously the AVX-512 one assigned it to int,
which wrapped past 33,025 once the helper stopped returning int, and the three
ARM ones to float, which lost exactness past 258. Note the SQ8_SQ8 choosers
have no dimension guard, unlike the uint8 ones, so the fence belongs with SQ8
index creation in #1007; on main nothing constructs an SQ8 index.

Split out of #1011 because none of this depends on the SQ8 metadata contract
that PR is changing, while all of it affects code reachable today. #1011
depends on this, through the helper above.

The regressions use all-255 bytes, the worst case, which makes the expected
value an exact integer, at dimensions 33,026 and 40,000 for the SIMD path and
66,052 for the fallback. The fallback test asserts the returned function
pointer, not just the distance: on a host with no uint8 SIMD tier the value
comparison would pass either way, but the pointer identity would not. The
existing UINT8 suites stop at dimension 128, which is why all of this went
unseen; being SIMD-versus-scalar comparisons they would also have agreed with
each other wherever both wrapped.

Also fixes the uint8 spaces benchmark fixture, which paired new[] with delete
and stored the trailing norms through unaligned float casts, so measurements
taken from it can be trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dor-forer added a commit that referenced this pull request Aug 17, 2026
The uint8 kernels accumulate products or squared differences of bytes, so the
total reaches 255 * 255 * dim = 65025 * dim. Four paths could not hold that,
all of them on the plain int8/uint8 index paths that ship today.

  * IP.cpp / L2.cpp: ret_t for a 1-byte element type was int, so the scalar
    UINT8_InnerProduct and UINT8_L2Sqr executed signed-overflow UB from
    dimension 33,026, while the comment claimed support to 2^16. The
    conditional was also dead: only int8_t and uint8_t instantiate these, both
    1 byte, so it always selected int. ret_t is now 64-bit for every element
    type, which also covers int8 at dimension 131,072. Kept signed so the
    "1 - ip" in the wrappers stays signed arithmetic and cannot underflow. The
    L2 comment still carried the old byte-counting rationale and is fixed.

  * UINT8_InnerProductImp returned float on NEON and SVE, accumulating exactly
    in integer lanes and then discarding it, exact only to dimension 258 since
    2^24 / 65025 = 258.

  * AVX-512 reduced 16 int32 lanes with _mm512_reduce_add_epi32 into a signed
    int, wrapping from dimension 33,026.

  * L2_AVX512F_BW_VL_VNNI_UINT8 and L2_NEON_UINT8 read an unsigned horizontal
    reduce back into a signed int, so the distance went negative from the same
    dimension. L2_NEON_DOTPROD_UINT8 and L2_SVE_UINT8 were already unsigned.

The accumulation was never the problem. The SIMD adds wrap modulo 2^32 and are
bit-exact, so the bit pattern was already correct; the top bit was being read
as a sign. An unsigned 32-bit reduce therefore costs nothing over the original
and is exact through dimension 66,051 = floor(UINT32_MAX / 65,025), twice the
old signed limit of 33,025. Verified: the AVX-512 object is byte-for-byte the
same size as before at 514,792, with the same 33 and 40 instructions for
residual 0 and 32.

Above 66,051 the choosers hand back the scalar kernel, which after the ret_t
change is exact to roughly dimension 2.8e14. One comparison at index creation,
reusing the "if (dim < 32) return ret_dist_func" idiom the choosers already
had, and it leaves every kernel untouched.

Three alternatives were tried and rejected, each on evidence:

  * Widening the horizontal reduce unconditionally. Measured on an Ice Lake-SP
    Xeon it costs 4 extra uops in the epilogue: +20% at dimension 32, +8-11%
    across 55-200, +4-5% at 900-1024, on byte-identical loop code.

  * A runtime branch selecting the width per call. Measured at +0.4 to 0.6 ns
    per call, +15% at dimension 32, and it cost 31 of the 65 Cosine wrappers
    their inlining, growing .text by 18.4%.

  * Compile-time selection between two named wrappers, extending SIMD to a
    second bound of 4 * 66,051. This one is free on the narrow path, verified:
    the narrow wrappers stayed byte-identical and the out-of-line count
    unchanged. It was rejected for correctness, not cost. That bound assumes
    products spread evenly across the four uint32 lanes after NEON's 32-bit
    vaddq_u32 merge, and the even case already lands within 1,020 of
    UINT32_MAX, while the masked residual load can add up to 16 products, or
    1,040,400, into specific lanes. So lanes wrap before the widened reduce
    sees them, and a correct bound would have to be derived per kernel from its
    accumulator count and residual distribution. The narrow reduce needs none
    of that: its bound is on the horizontal total, which does not depend on how
    products land in lanes.

Recorded for whoever revisits this: on ARM the widening reduce is free
instruction-for-instruction. Cross-compiling with
clang++ --target=aarch64-linux-gnu -O2 emits addv/fmov w/ucvtf against
uaddlv/fmov x/ucvtf, three instructions either way. So the obstacle to a wider
band is the lane bound, not the reduce.

Nothing comparable supports that range regardless. Lucene caps its
scalar-quantized format at 1,024 dimensions and Elasticsearch caps dense
vectors at 4,096, both keeping a 32-bit accumulator safe by contract. Faiss's
QT_8bit_direct accumulates full-range bytes into 32-bit lanes with no widening
and carries the same theoretical limit. Qdrant quantizes to 0..127, lowering
the per-element cap to 16,129, and its raw uint8 metric still sums into i32.

The SQ8_SQ8 kernels reuse this helper, on main as much as here, so they now
take its result as uint32_t. Previously the AVX-512 one assigned it to int,
which wrapped past 33,025 once the helper stopped returning int, and the three
ARM ones to float, which lost exactness past 258. Note the SQ8_SQ8 choosers
have no dimension guard, unlike the uint8 ones, so the fence belongs with SQ8
index creation in #1007; on main nothing constructs an SQ8 index.

Split out of #1011 because none of this depends on the SQ8 metadata contract
that PR is changing, while all of it affects code reachable today. #1011
depends on this, through the helper above.

The regressions use all-255 bytes, the worst case, which makes the expected
value an exact integer, at dimensions 33,026 and 40,000 for the SIMD path and
66,052 for the fallback. The fallback test asserts the returned function
pointer, not just the distance: on a host with no uint8 SIMD tier the value
comparison would pass either way, but the pointer identity would not. The
existing UINT8 suites stop at dimension 128, which is why all of this went
unseen; being SIMD-versus-scalar comparisons they would also have agreed with
each other wherever both wrapped.

Also fixes the uint8 spaces benchmark fixture, which paired new[] with delete
and stored the trailing norms through unaligned float casts, so measurements
taken from it can be trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dor-forer added a commit that referenced this pull request Aug 18, 2026
The uint8 kernels accumulate products or squared differences of bytes, so the
total reaches 255 * 255 * dim = 65025 * dim. Four paths could not hold that,
all of them on the plain int8/uint8 index paths that ship today.

  * IP.cpp / L2.cpp: ret_t for a 1-byte element type was int, so the scalar
    UINT8_InnerProduct and UINT8_L2Sqr executed signed-overflow UB from
    dimension 33,026, while the comment claimed support to 2^16. The
    conditional was also dead: only int8_t and uint8_t instantiate these, both
    1 byte, so it always selected int. ret_t is now 64-bit for every element
    type, which also covers int8 at dimension 131,072. Kept signed so the
    "1 - ip" in the wrappers stays signed arithmetic and cannot underflow. The
    L2 comment still carried the old byte-counting rationale and is fixed.

  * UINT8_InnerProductImp returned float on NEON and SVE, accumulating exactly
    in integer lanes and then discarding it, exact only to dimension 258 since
    2^24 / 65025 = 258.

  * AVX-512 reduced 16 int32 lanes with _mm512_reduce_add_epi32 into a signed
    int, wrapping from dimension 33,026.

  * L2_AVX512F_BW_VL_VNNI_UINT8 and L2_NEON_UINT8 read an unsigned horizontal
    reduce back into a signed int, so the distance went negative from the same
    dimension. L2_NEON_DOTPROD_UINT8 and L2_SVE_UINT8 were already unsigned.

The accumulation was never the problem. The SIMD adds wrap modulo 2^32 and are
bit-exact, so the bit pattern was already correct; the top bit was being read
as a sign. An unsigned 32-bit reduce therefore costs nothing over the original
and is exact through dimension 66,051 = floor(UINT32_MAX / 65,025), twice the
old signed limit of 33,025. Verified: the AVX-512 object is byte-for-byte the
same size as before at 514,792, with the same 33 and 40 instructions for
residual 0 and 32.

Above 66,051 the choosers hand back the scalar kernel, which after the ret_t
change is exact to roughly dimension 2.8e14. One comparison at index creation,
reusing the "if (dim < 32) return ret_dist_func" idiom the choosers already
had, and it leaves every kernel untouched.

Three alternatives were tried and rejected, each on evidence:

  * Widening the horizontal reduce unconditionally. Measured on an Ice Lake-SP
    Xeon it costs 4 extra uops in the epilogue: +20% at dimension 32, +8-11%
    across 55-200, +4-5% at 900-1024, on byte-identical loop code.

  * A runtime branch selecting the width per call. Measured at +0.4 to 0.6 ns
    per call, +15% at dimension 32, and it cost 31 of the 65 Cosine wrappers
    their inlining, growing .text by 18.4%.

  * Compile-time selection between two named wrappers, extending SIMD to a
    second bound of 4 * 66,051. This one is free on the narrow path, verified:
    the narrow wrappers stayed byte-identical and the out-of-line count
    unchanged. It was rejected for correctness, not cost. That bound assumes
    products spread evenly across the four uint32 lanes after NEON's 32-bit
    vaddq_u32 merge, and the even case already lands within 1,020 of
    UINT32_MAX, while the masked residual load can add up to 16 products, or
    1,040,400, into specific lanes. So lanes wrap before the widened reduce
    sees them, and a correct bound would have to be derived per kernel from its
    accumulator count and residual distribution. The narrow reduce needs none
    of that: its bound is on the horizontal total, which does not depend on how
    products land in lanes.

Recorded for whoever revisits this: on ARM the widening reduce is free
instruction-for-instruction. Cross-compiling with
clang++ --target=aarch64-linux-gnu -O2 emits addv/fmov w/ucvtf against
uaddlv/fmov x/ucvtf, three instructions either way. So the obstacle to a wider
band is the lane bound, not the reduce.

Nothing comparable supports that range regardless. Lucene caps its
scalar-quantized format at 1,024 dimensions and Elasticsearch caps dense
vectors at 4,096, both keeping a 32-bit accumulator safe by contract. Faiss's
QT_8bit_direct accumulates full-range bytes into 32-bit lanes with no widening
and carries the same theoretical limit. Qdrant quantizes to 0..127, lowering
the per-element cap to 16,129, and its raw uint8 metric still sums into i32.

The SQ8_SQ8 kernels reuse this helper, on main as much as here, so they now
take its result as uint32_t. Previously the AVX-512 one assigned it to int,
which wrapped past 33,025 once the helper stopped returning int, and the three
ARM ones to float, which lost exactness past 258. Note the SQ8_SQ8 choosers
have no dimension guard, unlike the uint8 ones, so the fence belongs with SQ8
index creation in #1007; on main nothing constructs an SQ8 index.

Split out of #1011 because none of this depends on the SQ8 metadata contract
that PR is changing, while all of it affects code reachable today. #1011
depends on this, through the helper above.

The regressions use all-255 bytes, the worst case, which makes the expected
value an exact integer, at dimensions 33,026 and 40,000 for the SIMD path and
66,052 for the fallback. The fallback test asserts the returned function
pointer, not just the distance: on a host with no uint8 SIMD tier the value
comparison would pass either way, but the pointer identity would not. The
existing UINT8 suites stop at dimension 128, which is why all of this went
unseen; being SIMD-versus-scalar comparisons they would also have agreed with
each other wherever both wrapped.

Also fixes the uint8 spaces benchmark fixture, which paired new[] with delete
and stored the trailing norms through unaligned float casts, so measurements
taken from it can be trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dor-forer added a commit that referenced this pull request Aug 19, 2026
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>
dor-forer and others added 3 commits August 20, 2026 15:00
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>
dor-forer and others added 6 commits August 20, 2026 15:00
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>
@dor-forer
dor-forer force-pushed the dor-forer-MOD-14956-hnsw-sq8 branch from ac05e54 to ace52fa Compare August 20, 2026 13:05

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Fix All in Cursor

❌ 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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ace52fa. Configure here.

ethanglaser pushed a commit to ethanglaser/VectorSimilarity that referenced this pull request Aug 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants