Skip to content
5 changes: 5 additions & 0 deletions src/VecSim/algorithms/hnsw/hnsw_multi.h
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ class HNSWIndex_Multi : public HNSWIndex<DataType, DistType> {
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 {
// See the note in hnsw_single.h: a quantized index cannot answer a raw-blob distance query
// without reading past the caller's vector.
if (this->isQuantized) {
return INVALID_SCORE;
}
return getDistanceFromInternal(label, vector_data);
}
int removeLabel(labelType label) override { return labelLookup.erase(label); }
Expand Down
11 changes: 11 additions & 0 deletions src/VecSim/algorithms/hnsw/hnsw_serializer_impl.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ HNSWIndex<DataType, DistType>::HNSWIndex(std::ifstream &input, const HNSWParams

template <typename DataType, typename DistType>
void HNSWIndex<DataType, DistType>::saveIndexIMP(std::ofstream &output) {
// The V4 format records type, dim and metric, but neither quantType nor the mean vector, and
// the loading path always builds unquantized components. A saved quantized index would
// therefore reload with the wrong stride and consume graph bytes as vector data. Refuse instead
// of emitting a file that cannot be decoded. Note the caller has already written the encoding
// version by this point, so a rejected save leaves a stub file behind, and worse, it has
// already truncated whatever was at that path. Both are tracked separately; whoever adds
// quantized serialization should move this check ahead of the file being opened.
if (this->isQuantized) {
throw std::runtime_error(
"Cannot save index: serialization of quantized indexes is not supported");
}
Comment thread
cursor[bot] marked this conversation as resolved.
this->saveIndexFields(output);
this->saveGraph(output);
}
Expand Down
8 changes: 8 additions & 0 deletions src/VecSim/algorithms/hnsw/hnsw_single.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ class HNSWIndex_Single : public HNSWIndex<DataType, DistType> {
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 CursorFix 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.

// kernels read query metadata appended past that, so honouring the documented contract here
// would read out of bounds. There is no public API for producing a quantized query blob,
// and adding one is a separate decision. Report "no answer" rather than read past the
// caller's blob. See the note on VecSimIndex_GetDistanceFrom_Unsafe in vec_sim.h.
if (this->isQuantized) {
return INVALID_SCORE;
}
return getDistanceFromInternal(label, vector_data);
}
int removeLabel(labelType label) override { return labelLookup.erase(label); }
Expand Down
204 changes: 200 additions & 4 deletions src/VecSim/index_factories/hnsw_factory.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

using bfloat16 = vecsim_types::bfloat16;
using float16 = vecsim_types::float16;
using sq8 = vecsim_types::sq8;

namespace HNSWFactory {

Expand All @@ -34,11 +35,166 @@ NewIndex_ChooseMultiOrSingle(const HNSWParams *params,
HNSWIndex_Single<DataType, DistType>(params, abstractInitParams, components);
}

template <VecSimMetric Metric>
[[nodiscard]] constexpr size_t GetSQ8StoredDataSize(size_t dim, bool with_norm) {
static_assert(Metric == VecSimMetric_L2 || Metric == VecSimMetric_IP);

// WithNorm is a template parameter, so dispatch the runtime flag to the two instantiations.
return with_norm ? sq8::storage_bytes_count<Metric, true>(dim)
: sq8::storage_bytes_count<Metric, false>(dim);
}

// Alignment required by a query blob of type DataType. Per the asymmetric-types contract in
// spaces.h, the hint returned alongside an asymmetric distance function describes its first
// (storage) operand, so the query side must be obtained from the symmetric dispatcher for the
// query's own type. Only that hint is wanted here, never the function it returns, so the call is
// contained in this adapter instead of leaving a discarded value at the call site.
template <typename DataType>
[[nodiscard]] unsigned char GetQueryAlignment(VecSimMetric metric, size_t dim) {
unsigned char alignment = 0;
spaces::GetDistFunc<DataType, float>(metric, dim, &alignment);
return alignment;
}

// The metric the SQ8 components are actually built for. A Cosine index whose blobs are already
// normalized by someone else (the frontend of a tiered index) is built as IP.
[[nodiscard]] constexpr VecSimMetric ResolveSQ8Metric(VecSimMetric metric, bool is_normalized) {
return (is_normalized && metric == VecSimMetric_Cosine) ? VecSimMetric_IP : metric;
}

// Single definition of the SQ8 parameter combinations this factory can build, taking the metric
// already resolved by ResolveSQ8Metric. NewIndex fails closed when this returns false, and
// EstimateInitialSize rejects the same set, so it cannot report a size for parameters that cannot
// produce an index.
[[nodiscard]] constexpr bool SQ8ParamsSupported(VecSimType type, VecSimMetric resolved_metric,
bool with_mean) {
// Kernels exist for FP32 and FP16 sources only.
if (type != VecSimType_FLOAT32 && type != VecSimType_FLOAT16) {
return false;
}
// Only L2 and IP have SQ8 kernels. Cosine needs a normalization step the SQ8 preprocessor does
// not perform, and anything outside the enum has to be rejected here as well: the dispatch in
// NewIndex would otherwise fall through to its unreachable-branch assert and abort an
// assertions-enabled host, where the unquantized path throws and is caught by VecSimIndex_New.
if (resolved_metric != VecSimMetric_L2 && resolved_metric != VecSimMetric_IP) {
return false;
}
// Mean-centred FP16 L2 loses correctness: QuantPreprocessor centres the query and narrows the
// result back into the FP16 query body, while storage keeps its centred min/delta in FP32. The
// two then disagree, so an identical vector and query pair yields a non-zero distance (mean
// 10000 gives a per-component error of 1.0), and a large enough mean overflows FP16 to
// infinity. Enabling this needs an asymmetric kernel that takes an FP32 centred query. Only the
// WithNorm && L2 branch centres the query, so FP16 with a mean and IP is unaffected.
if (type == VecSimType_FLOAT16 && with_mean && resolved_metric == VecSimMetric_L2) {
return false;
}
return true;
Comment thread
cursor[bot] marked this conversation as resolved.
}

// Helper to build an SQ8-quantized HNSW index given compile-time DataType and Metric.
template <typename DataType, VecSimMetric Metric>
VecSimIndex *NewIndex_SQ8(const HNSWParams *hnswParams, AbstractIndexInitParams abstractInitParams,
const float *mean_ptr) {
auto &allocator = abstractInitParams.allocator;
const size_t dim = abstractInitParams.dim;
const bool with_norm = mean_ptr != nullptr;
unsigned char storage_alignment = 0, asym_storage_alignment = 0;

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

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 existing test serializer now silently accepts a layout its loader cannot decode. V4 records type, dim, and metric, but not quantType or the mean, and loading always constructs unquantized components. For example, FP32/L2 at dim 128 writes a 144-byte SQ8 blob per vector, while the loader expects 512 bytes of FP32 data and consumes following graph bytes as vector data. If SQ8 serialization is intentionally deferred, please make saveIndex() reject SQ8 and test that failure so it cannot emit a corrupt/unloadable file.

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 structurally, and I agree with your proposed fix. quantType appears nowhere in hnsw_serializer.h, and the file-loading path in HNSWFactory::NewIndex always builds components through CreateIndexComponents, which has no SQ8 branch at all, so a saved SQ8 index reloads as an unquantized one with the wrong stride.

This is the same shape as the tiered hazard: a combination that is not wired yet but is silently accepted. The guard belongs in this PR by the same argument, and the isQuantized flag added in 63271c1 for the distance-API fix is what saveIndex would test.

I have not done it in this round because the requested scope was the two blocking findings. It is a small follow-up: reject SQ8 in saveIndex and test that it fails rather than emitting a file the loader cannot decode. Happy to add it here if you want it before merge.

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. saveIndexIMP now throws for a quantized index, covered by HNSWSQ8Test.RejectsSerialization across all three type parameters.

One wart to flag rather than hide: HNSWSerializer::saveIndex writes the encoding version before calling saveIndexIMP, so a rejected save leaves a stub file behind. That still fails closed on load, unlike a complete file whose layout the loader misreads, but validating before the file is created would need a new virtual hook on the serializer base across four files, which felt disproportionate for a path that cannot be reached from RediSearch yet. Noted in the carry-forward doc so whoever adds real SQ8 serialization moves the check earlier.

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 on 9e69259: the consequence is stronger than merely leaving a new stub. HNSWSerializer::saveIndex opens location with truncation and writes V4 before this guard runs. I pre-created the destination with sentinel contents, invoked the rejected SQ8 save, and after the exception the sentinel had been replaced by exactly the four-byte version field. Thus an unsupported save can destroy a valid existing snapshot. Please validate before opening/truncating (or save atomically through a temporary file), and make the regression assert that an existing destination remains byte-for-byte unchanged.

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.

You are right and my previous reply on this thread was wrong. I called the leftover stub "fails closed on load" and treated moving the check earlier as a nicety. It truncates a valid snapshot, which is data loss, not a cosmetic wart. Corrected in the carry-forward doc as well, since that is where I recorded the mistaken version.

The ordering is as you describe: saveIndex opens location with truncation and writes V4 before saveIndexIMP runs any validation, so the guard added in 9e69259 fires only after the previous file is already gone.

One thing worth adding, because it changes who owns the fix: this is not specific to the SQ8 guard, and not caused by it. Truncate-before-validate applies to any validation failure inside saveIndexIMP. Without the guard an SQ8 save writes a complete file whose layout the loader misreads, which also overwrites the previous snapshot. So SQ8 made a pre-existing hazard observable rather than creating it.

Tracked as MOD-17529, which had no owning ticket before: HLD section 3.3.6 puts feature persistence in RediSearch RDB (MOD-14958) and says nothing about VecSim's own serializer. The ticket takes your requirement verbatim, that the regression assert the destination is byte-for-byte unchanged rather than merely that the save threw, and prefers your atomic-temp-file option over validate-before-open, since it fixes the whole class instead of one check.

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.

Still blocking. The SQ8 serialization guard added here deterministically reaches this pre-existing truncate-before-validate ordering, so an unsupported operation introduced by this PR can destroy an existing snapshot. Tracking the general serializer problem in MOD-17529 is useful, but this PR still needs to validate before opening the destination or save atomically, with a preservation regression.

abstractInitParams.isQuantized = true;
Comment thread
cursor[bot] marked this conversation as resolved.

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.


// Symmetric: both stored vectors are SQ8 blobs.
auto sym_func = spaces::GetDistFunc<sq8, float>(Metric, dim, &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: the newly selected symmetric SQ8 kernel can overflow for valid large dimensions. On AVX512 VNNI, SQ8_SQ8_InnerProductImp receives an int from UINT8_InnerProductImp, whose horizontal reduction is signed 32-bit. A dimension-33027 vector that quantizes one component to 0 and 33026 components to 255 has self-dot 65025 * 33026 = 2147515650, exceeding INT_MAX. The wrapped value feeds both IP and L2 graph construction/pruning, so HNSW can be built with incorrect distances. Please use a wide/chunked accumulation or select a safe fallback above the overflow boundary, and add a boundary regression.

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.

The mechanism is real and I verified it, but I would like to take it as a separate ticket rather than in this cherry-pick. Two corrections to the scope first, both of which make it worth its own change:

It is not AVX512-only. All four SIMD SQ8-to-SQ8 kernels route through UINT8_InnerProductImp and inherit the int accumulator: IP_AVX512F_BW_VL_VNNI_SQ8_SQ8.h, IP_NEON_SQ8_SQ8.h, IP_NEON_DOTPROD_SQ8_SQ8.h and IP_SVE_SQ8_SQ8.h. The scalar fallback in IP.cpp:150 accumulates into a float and does not wrap, so the exposure is exactly the SIMD paths, on x86 and ARM alike.

The threshold is confirmed: the per-element product caps at 255*255 = 65025, and _mm512_reduce_add_epi32 returns int, so the sum exceeds INT_MAX from dim = 33026 (your 33026 case overshoots by 32,003). I found no dimension cap anywhere in VecSim, so it is reachable in principle from a direct C API caller.

My reasoning for separating it: the kernels are pre-existing (they landed with the SQ8 distance-function PRs and are already exercised by test_spaces and the benchmarks), this PR is only the first thing to select them for storage-to-storage comparisons, and the trigger needs a dimension over 33025 plus data that quantizes almost every component to 255. Fixing it properly means widening or chunking the accumulation in the shared UINT8 helper, which changes int8/uint8 index behaviour too and deserves its own boundary regression rather than riding along here.

I will open a ticket against the kernels with the above. Say the word if you would rather it block this PR and I will pull it in.

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 rechecked the exact boundary while re-reviewing the latest head. The original comment's first realizable self-dot overflow at dimension 33,027 is correct. Every non-constant per-vector quantization has at least one q=0, so at dimension 33,026 the maximum self-dot is 33,025 * 65,025 = 2,147,450,625, which still fits INT_MAX; dimension 33,027 permits 33,026 * 65,025 = 2,147,515,650, exceeding it by 32,003. The broader cross-platform overflow conclusion remains unchanged.

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.

Accepted, your boundary is right and mine was off by one dimension. The reasoning is the part I had missed: the minimum of every non-constant vector maps to q = 0, so at least one component contributes nothing to the self-dot. At dim 33,026 the largest achievable value is 33,025 * 65,025 = 2,147,450,625, which still fits INT_MAX; 33,027 permits 33,026 * 65,025 = 2,147,515,650, over by 32,003.

Corrected in the carry-forward doc, which had propagated 33,026 from my earlier reply, and MOD-17527 carries the corrected boundary along with the four affected SIMD kernels, the float-accumulating scalar fallback, and the note that the shared uint8 helper means a fix changes int8 and uint8 index behaviour too and needs its own boundary regression.

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.

Still blocking. The implementation may predate this PR, but this PR is what selects the symmetric SQ8 kernel for HNSW graph construction. Dimension 33,027 is accepted by the public API and deterministically produces wrapped distances, so MOD-17527 is useful tracking but does not make the new path safe to merge. Please widen or chunk the accumulator, select a safe fallback, or reject SQ8 above a proven safe dimension before enabling it here.

// 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: selecting these FP32 SQ8 distance functions exposes catastrophic cancellation in both L2 kernels. On this exact head I ran the actual QuantPreprocessor with stored x = [100000, 100008] and y = [100000, 100000]. Quantization represents x exactly (q = [0, 255], delta = 8/255), so reconstructed L2虏 is 64; SQ8_FP32_L2Sqr returns 0 and SQ8_SQ8_L2Sqr returns 4096. Both use ||x||虏 + ||y||虏 - 2*IP in FP32, so nearby high-offset vectors can be mis-ranked during both query evaluation and graph construction. Please use a numerically stable direct-difference/centred formulation (or sufficient precision) and add this regression.

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 I reproduced it independently before deciding anything. Pure FP32 arithmetic, your exact input:

x = [100000, 100008] (stored, SQ8)   y = [100000, 100000] (query, FP32)
q = [0, 255], delta = 8/255 -> reconstructs to exactly [100000, 100008]
x_sum_sq = 20001599488   (true 20001600064, off by 576)
ip       = 20000800768   (true 20000800000, off by 768)
kernel L2^2 = 0.0        true L2^2 = 64.0

Your framing is right and worth restating because it is the part that makes this real: quantization is exact here, so this is the summation form and not the 8 bits. Drop the quantizer entirely and ||x||^2 + ||y||^2 - 2*IP in FP32 still returns 0. One FP32 step at 4e10 is about 4768, roughly 75x the true answer.

I also measured the onset, which sharpens the exposure:

common offset (spread 8) no mean vector with mean vector
0 to 1e4 correct (64) correct
1e5, 1e6 0 instead of 64 correct (64)

Onset is around offset / spread > ~4000, where the ratio exceeds the FP32 mantissa. Mean normalization removes it entirely, verified to offset 1e6, because centring shrinks exactly the magnitudes the identity is unstable on. So the exposure is SQ8 with TRAINING_THRESHOLD = 0 on data with a large DC offset, and the HLD default of 10 * DEFAULT_BLOCK_SIZE is the safe configuration.

Tracked as MOD-17526 rather than fixed here, with the repro, the boundary table and both fix options in the ticket. My reasoning: the kernels are pre-existing, MOD-14956 is scoped to wiring them into the factory, and the HLD does not specify their numerics. More to the point, the identity is what reduces the SQ8-to-SQ8 inner loop to a uint8 dot product, which is the entire reason VNNI and NEON DOTPROD help. Your preferred fix, a direct-difference formulation, gives that up, and I am not willing to trade a rare wrong answer for a permanent slowdown nobody has measured inside a cherry-pick. The ticket flags a cheaper candidate to measure first: widen the L2 metadata and the final subtraction to FP64, which fixes this case because the cancellation is in that subtraction.

Where I think you have the stronger argument, and I want it on the record rather than buried: unlike the int32 overflow, this needs no absurd dimension, just a plausible data distribution. So I am not deferring it on "unreachable", only on "the fix is a kernel design change that needs ARM's benchmark numbers". Say the word and I will pull it in.

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.

Still blocking. Null quantParams is an explicitly supported configuration in this PR, and this PR is what wires the inaccurate kernels into both query evaluation and graph construction. The need to benchmark alternative kernel formulations affects which fix to choose, not whether known-wrong distances can ship. Please fix the calculation or reject the affected no-mean SQ8 L2 configuration; MOD-17526 can retain broader optimization work.

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.

// Both hints describe the same stored blob, so they must be combined rather than overwritten.
storage_alignment = spaces::combineAlignments(storage_alignment, asym_storage_alignment);
// Queries stay in DataType and are compared against stored blobs by asym_func.
const unsigned char query_alignment = GetQueryAlignment<DataType>(Metric, dim);

PreprocessorInterface *pp = nullptr;
IndexCalculatorInterface<float> *calc = nullptr;

if (with_norm) {
// Mean-centered SQ8 quantization with norm correction.
vecsim_stl::vector<float> mean_vec(allocator);
mean_vec.assign(mean_ptr, mean_ptr + dim);

float mean_sum_squares = 0.0f;
for (float v : mean_vec) {
mean_sum_squares += v * v;
}

pp = new (allocator) QuantPreprocessor<DataType, Metric, true>(allocator, dim, mean_vec);

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: FP16 + mean + L2 loses correctness through this instantiation. QuantPreprocessor<float16, L2, true>::preprocessQuery computes input[i] - mean[i] in FP32, then narrows it back into the FP16 query body, while storage quantization keeps its centered min/delta in FP32. Identical vector/query pairs can therefore diverge: for x = y = [1,1,1,1] and mean [10000,...], storage represents -9999 but the query rounds to -10000, yielding self-distance 4. A valid FP16 query -40000 with mean 40000 also overflows after centering. Please keep mean-centered FP16 L2 queries in FP32 with a matching asymmetric kernel, or reject/validate this combination, and add a regression.

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. I reproduced your numbers exactly using the repo's own conversions before changing anything:

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

One qualifier worth recording: at a realistic mean near 1 the error is exactly zero, so this only bites for large mean magnitudes. It is silent when it does, though, so it still needs handling.

HNSWFactory::NewIndex now rejects FLOAT16 + mean + L2. Note the scope is narrower than the comment implies: only the WithNorm && L2 branch centres the query, so FP16 + mean + IP is unaffected and stays supported. RejectsMeanCenteredFP16L2 pins both halves of that.

Your preferred fix, keeping the centred query in FP32 with a matching asymmetric kernel, is the correct one but it is an ARM design change plus new kernel work, so I have left it for their series rather than doing it in a cherry-pick. FLOAT16-with-mean also leaves the functional type set, since every functional test uses L2; that trades 11 typed tests for correctness, and none of them were exercising a combination that still works.

calc = new (allocator) DistanceCalculatorWithNorm<DataType, float, Metric>(
allocator, asym_func, sym_func, mean_sum_squares);
} else {
// Plain SQ8 quantization without mean centering.
pp = new (allocator) QuantPreprocessor<DataType, Metric>(allocator, dim);

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: this preprocessor accepts finite FP32 vectors whose scale arithmetic becomes non-finite. On this head, processing [-FLT_MAX, +FLT_MAX] makes max-min and delta infinite, inv_delta zero, and then Inf * 0 NaN; UBSan reports preprocessors.h:326:63: -nan is outside the range of representable values of type 'unsigned char'. Thus AddVector executes C++ undefined behavior for finite input. Please reject non-finite derived ranges/metadata or compute the range in wider precision and explicitly validate/clamp before conversion, with a sanitizer regression.

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 from the code. preprocessors.h:273-275:

const MetadataType diff = (max_val - min_val);
const MetadataType delta = (diff == 0.0f) ? MetadataType{1} : diff / MetadataType{255};
const MetadataType inv_delta = MetadataType{1} / delta;

For [-FLT_MAX, +FLT_MAX] the subtraction itself overflows: diff = inf, delta = inf, inv_delta = 0, then (x - min_val) * inv_delta is inf * 0 = NaN, and the static_cast<uint8_t> at line 326 is undefined behaviour. The diff == 0 guard covers the degenerate equal-values case but not overflow of the range, which is the gap you found.

Worth stating explicitly since it is what makes this more than a hardening nit: every input value is finite and within the type. Only the derived range is not. So AddVector executes UB for input the API accepts as valid.

Tracked as MOD-17528 rather than fixed here. QuantPreprocessor is MOD-14952's component, already merged in #1000, and MOD-14956 only wires it into the factory. The HLD covers two quantization edge cases in section 5.3, zero variance and zero-magnitude cosine vectors, but not a non-finite derived range, so this needs a decision rather than a patch: there is no way for AddVector to report "unquantizable", so the fix is probably compute-in-wider-precision or clamp, not reject, and that choice belongs with the preprocessor owner. The ticket carries your UBSan output and asks for both regressions, including one where the range overflows without either endpoint being extreme.

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.

Still blocking. This PR is the first change that makes QuantPreprocessor reachable through public AddVector, so finite input now reaches C++ undefined behavior in a supported SQ8 index. The design and performance tradeoff may require more work, but that does not make the public UB safe to defer. Please reformulate or widen the arithmetic, or otherwise prevent the unsafe configuration from being exposed here, with the sanitizer regression in MOD-17528.

// sym_func for storage-storage; asym_func for query-storage.
calc = new (allocator) DistanceCalculatorCommon<float>(allocator, sym_func, asym_func);
}

auto *container = new (allocator)
MultiPreprocessorsContainer<DataType, 1>(allocator, query_alignment, storage_alignment);
[[maybe_unused]] const int ret = container->addPreprocessor(pp);
assert(ret != -1 && "SQ8 preprocessor was not added correctly");

IndexComponents<DataType, float> components{calc, container};
return NewIndex_ChooseMultiOrSingle<DataType, float>(hnswParams, abstractInitParams,
components);
}

VecSimIndex *NewIndex(const VecSimParams *params, bool is_normalized) {
const HNSWParams *hnswParams = &params->algoParams.hnswParams;
AbstractIndexInitParams abstractInitParams =
VecSimFactory::NewAbstractInitParams(hnswParams, params->logCtx, is_normalized);

if (hnswParams->quantType != VecSimQuant_NONE) {
// Any quantization type this factory does not implement must fail closed. Falling through
// to the plain path below would silently build a full-precision index for a caller that
// asked for a quantized one. Unreachable while VecSimQuantType holds only NONE and SQ8, and
// not covered by a test because forming an out-of-range enumerator is undefined behaviour;
// the guard is what keeps adding SQ4 to the enum from becoming a silent fallthrough.
if (hnswParams->quantType != VecSimQuant_SQ8) {
return NULL;
}

const VecSimMetric metric = ResolveSQ8Metric(hnswParams->metric, is_normalized);
const float *mean_ptr = static_cast<const float *>(hnswParams->quantParams);

if (!SQ8ParamsSupported(hnswParams->type, metric, mean_ptr != nullptr)) {
return NULL;
}

if (hnswParams->type == VecSimType_FLOAT32) {
if (metric == VecSimMetric_L2) {
return NewIndex_SQ8<float, VecSimMetric_L2>(hnswParams, abstractInitParams,
mean_ptr);
} else if (metric == VecSimMetric_IP) {
return NewIndex_SQ8<float, VecSimMetric_IP>(hnswParams, abstractInitParams,
mean_ptr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

L2 SQ8 catastrophic cancellation

High Severity

Wiring FP32 SQ8 L2 into HNSW selects kernels that compute L2虏 as ||x||虏 + ||y||虏 - 2*IP in FP32. For nearby high-offset vectors that cancel, SQ8_FP32_L2Sqr and SQ8_SQ8_L2Sqr return badly wrong distances, so graph construction and search can mis-rank candidates.

Fix in CursorFix 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, and independently reproduced: stored [100000, 100008] against query [100000, 100000] returns 0.0 where the truth is 64.0, with quantization exact for that input, so this is the summation form and not the 8 bits. SQ8_SQ8_L2Sqr returns 4096 for the same pair.

Same finding @lerman25 raised on this PR; the full analysis, including the measured onset around offset / spread > ~4000 and the fact that mean normalization removes it entirely, is in that thread and in MOD-17526.

Deferred rather than fixed here: the kernels are pre-existing, MOD-14956 is scoped to wiring them into the factory, and the identity is what makes the SQ8-to-SQ8 inner loop a uint8 dot product that VNNI and NEON DOTPROD accelerate, so replacing it needs benchmark numbers this cherry-pick cannot produce.

}
} else if (hnswParams->type == VecSimType_FLOAT16) {
if (metric == VecSimMetric_L2) {
return NewIndex_SQ8<float16, VecSimMetric_L2>(hnswParams, abstractInitParams,
mean_ptr);
} else if (metric == VecSimMetric_IP) {
return NewIndex_SQ8<float16, VecSimMetric_IP>(hnswParams, abstractInitParams,
mean_ptr);
}
}

// Unreachable today: the checks above leave only FP32/FP16 x L2/IP. The assert makes a
// debug build shout if a new type or metric ever reaches here, and the return keeps a
// release build failing closed rather than falling through and silently building an
// unquantized index instead.
assert(false && "unhandled SQ8 data type and metric combination");
return NULL;
Comment on lines +189 to +195

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.

Maybe assert false 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.

Good call, and it matches repo precedent (svs_factory.cpp:89 and friends use assert(false && "...") for unreachable type/metric combinations).

I would like to do both rather than swap one for the other:

assert(false && "unhandled SQ8 type/metric combination");
return NULL;

The assert makes a debug build shout if a future type or metric reaches here, which is what you are after. Keeping the return NULL means a release build still fails closed instead of falling through and silently building an unquantized index, which is the regression that line exists to prevent (it was missing until 4d09236). Assert-only would restore exactly that hole under NDEBUG.

Shout if you would rather have the assert alone and I will drop the return.

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, kept alongside the return NULL as described above.

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 rechecked this after the assertion was added, and this location is reachable today: the SQ8 branch rejects Cosine but does not reject other out-of-range VecSimMetric values. A public C caller using FP32/SQ8 with metric = (VecSimMetric)123 reaches this assertion and aborts an assertions-enabled process; I reproduced it with a death test on the current head. Before this change the trailing return NULL handled that path, while the unquantized dispatcher throws and is caught by VecSimIndex_New. Please explicitly reject anything other than L2/IP before dispatch, or remove the assertion, and add an invalid-enum regression.

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 9b1615c. You are right that the assert made this reachable, and right about the asymmetry: the unquantized dispatcher throws and VecSimIndex_New catches it, so quantized params were the only way to abort the host.

Worth being precise about what happened, since it was my change either way. The refactor in d5d93d6 pulled the fences into a single SQ8ParamsSupported predicate, and that predicate tested resolved_metric == VecSimMetric_Cosine, exactly like the open-coded check it replaced. So the hole was preserved verbatim rather than introduced, and my "unreachable today" comment on the assert was wrong for any metric outside the enum.

SQ8ParamsSupported now whitelists L2 and IP instead:

if (resolved_metric != VecSimMetric_L2 && resolved_metric != VecSimMetric_IP) {
    return false;
}

So NewIndex returns NULL and EstimateInitialSize throws, which is what every other unsupported combination does, and the assert keeps its job of catching a new type or metric that the dispatch forgot rather than caller garbage.

HNSWSQ8ParamsTest.RejectsOutOfRangeMetric pins it. One deliberate difference from your repro: it uses (VecSimMetric)3 rather than 123. VecSimMetric has three enumerators, so its value range is 0 to 3, and 3 is the smallest value outside the valid set that is still inside that range. Forming 123 is itself undefined behaviour, and I would rather the regression not depend on UB to demonstrate a UB fix. Same code path, same result.

}
Comment thread
cursor[bot] marked this conversation as resolved.

Comment thread
cursor[bot] marked this conversation as resolved.
if (hnswParams->type == VecSimType_FLOAT32) {
IndexComponents<float, float> indexComponents = CreateIndexComponents<float, float>(
abstractInitParams.allocator, hnswParams->metric, hnswParams->dim, is_normalized);
Expand Down Expand Up @@ -94,7 +250,31 @@ size_t EstimateInitialSize(const HNSWParams *params, bool is_normalized) {
size_t allocations_overhead = VecSimAllocator::getAllocationOverheadSize();

size_t est = sizeof(VecSimAllocator) + allocations_overhead;
if (params->type == VecSimType_FLOAT32) {

if (params->quantType != VecSimQuant_NONE) {
// Reject exactly what NewIndex rejects. Reporting a size for a combination that cannot be
// built lets a caller size its capacity from an index it will then fail to create.
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
Collaborator

Choose a reason for hiding this comment

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

Blocking: this new throw escapes through the public extern C VecSimIndex_EstimateInitialSize wrapper, which has no catch or status channel. I compiled an actual C caller against this head; FP32 plus SQ8 plus Cosine terminates with exit 134 after an uncaught std::invalid_argument. The new EXPECT_THROW tests call the C wrapper from C++ and therefore encode behavior that a C host cannot handle. Please keep the C API non-throwing by catching at the boundary and returning a documented failure value, or introduce a status plus out-parameter API, with a C-facing regression. Deferring the general estimator contract to MOD-14958 does not make the new reachable throw sites safe here.

}
// Calculator + preprocessor container + preprocessor.
// Use representative types; sizeof is independent of the template parameters.
if (params->quantParams) { // mean provided, WithNorm = true
est += allocations_overhead +
sizeof(DistanceCalculatorWithNorm<float, float, VecSimMetric_L2>);
est += allocations_overhead + sizeof(MultiPreprocessorsContainer<float, 1>);
est += allocations_overhead + sizeof(QuantPreprocessor<float, VecSimMetric_L2, true>);
est += allocations_overhead +
params->dim * sizeof(float); // mean vector in QuantPreprocessor
} else {
est += allocations_overhead + sizeof(DistanceCalculatorCommon<float>);
est += allocations_overhead + sizeof(MultiPreprocessorsContainer<float, 1>);
est += allocations_overhead + sizeof(QuantPreprocessor<float, VecSimMetric_L2>);
}
est += EstimateInitialSize_ChooseMultiOrSingle<float>(params->multi);
Comment thread
cursor[bot] marked this conversation as resolved.
} else if (params->type == VecSimType_FLOAT32) {
est += EstimateComponentsMemory<float, float>(params->metric, is_normalized);
est += EstimateInitialSize_ChooseMultiOrSingle<float>(params->multi);
} else if (params->type == VecSimType_FLOAT64) {
Expand Down Expand Up @@ -125,9 +305,25 @@ size_t EstimateElementSize(const HNSWParams *params) {
size_t M = (params->M) ? params->M : HNSW_DEFAULT_M;
size_t elementGraphDataSize = sizeof(ElementGraphData) + sizeof(idType) * M * 2;

size_t size_total_data_per_element =
elementGraphDataSize +
VecSimParams_GetStoredDataSize(params->type, params->dim, params->metric);
size_t stored_data_size;
// Unlike EstimateInitialSize, this does not reject unsupported combinations: the return type is
// size_t with no sentinel, and VecSimIndex_EstimateElementSize is extern "C", so throwing here
// 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. Index
// creation is the boundary that enforces the supported set (see SQ8ParamsSupported).
if (params->quantType == VecSimQuant_SQ8) {
bool with_norm = params->quantParams != nullptr;
if (params->metric == VecSimMetric_L2) {
stored_data_size = GetSQ8StoredDataSize<VecSimMetric_L2>(params->dim, with_norm);
} else {
stored_data_size = GetSQ8StoredDataSize<VecSimMetric_IP>(params->dim, with_norm);
}
Comment thread
cursor[bot] marked this conversation as resolved.
} else {
stored_data_size =
VecSimParams_GetStoredDataSize(params->type, params->dim, params->metric);
}

size_t size_total_data_per_element = elementGraphDataSize + stored_data_size;

// when reserving space for new labels in the lookup hash table, each entry is a pointer to a
// label node (bucket).
Expand Down
17 changes: 17 additions & 0 deletions src/VecSim/index_factories/tiered_factory.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ inline VecSimIndex *NewIndex(const TieredIndexParams *params) {
inline size_t EstimateInitialSize(const TieredIndexParams *params) {
HNSWParams hnsw_params = params->primaryIndexParams->algoParams.hnswParams;

// NewIndex below rejects quantization until the tiered index handles it, so refuse to size it
// too. The primary index on its own would accept these params, so without this check the
// estimate reports a number for an index that cannot be created.
if (hnsw_params.quantType != VecSimQuant_NONE) {
throw std::invalid_argument("Quantization is not supported for tiered HNSW indexes");
}

// Add size estimation of VecSimTieredIndex sub indexes.
// Normalization is done by the frontend index.
size_t est = HNSWFactory::EstimateInitialSize(&hnsw_params, true);
Expand Down Expand Up @@ -95,6 +102,14 @@ inline size_t EstimateInitialSize(const TieredIndexParams *params) {
}

VecSimIndex *NewIndex(const TieredIndexParams *params) {
// Quantization is not wired into the tiered index yet. Reject it here rather than let it
// through: the primary index would be built from these params and quantize its storage, while
// NewBFParams does not carry quantType, so the frontend would stay unquantized and the two
// would disagree on the stored blob layout.
if (params->primaryIndexParams->algoParams.hnswParams.quantType != VecSimQuant_NONE) {
return nullptr;
}

// Tiered index that contains HNSW index as primary index
VecSimType type = params->primaryIndexParams->algoParams.hnswParams.type;
if (type == VecSimType_FLOAT32) {
Expand Down Expand Up @@ -233,6 +248,8 @@ size_t EstimateInitialSize(const TieredIndexParams *params) {
}

size_t EstimateElementSize(const TieredIndexParams *params) {
// Deliberately not validated here, unlike EstimateInitialSize above: see the note in
// HNSWFactory::EstimateElementSize on why this function has no error channel.
size_t est = 0;
if (params->primaryIndexParams->algo == VecSimAlgo_HNSWLIB) {
est = HNSWFactory::EstimateElementSize(&params->primaryIndexParams->algoParams.hnswParams);
Expand Down
7 changes: 2 additions & 5 deletions src/VecSim/spaces/computer/preprocessors.h
Original file line number Diff line number Diff line change
Expand Up @@ -426,8 +426,7 @@ class QuantPreprocessor : public PreprocessorInterface {
QuantPreprocessor(std::shared_ptr<VecSimAllocator> allocator, size_t dim)
requires(!WithNorm)
: 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 CursorFix 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.

query_bytes_count(dim * sizeof(DataType) +
sq8::query_metadata_count<Metric>() * sizeof(MetadataType)) {}

Expand All @@ -436,9 +435,7 @@ class QuantPreprocessor : public PreprocessorInterface {
const vecsim_stl::vector<float> &mean_vec)
requires(WithNorm)
: PreprocessorInterface(allocator), mean(mean_vec), dim(dim),
storage_bytes_count(dim * sizeof(OUTPUT_TYPE) +
sq8::storage_metadata_count<Metric, WithNorm>() *
sizeof(MetadataType)),
storage_bytes_count(sq8::storage_bytes_count<Metric, WithNorm>(dim)),
query_bytes_count(dim * sizeof(DataType) +
sq8::query_metadata_count<Metric, WithNorm>() * sizeof(MetadataType)) {
assert(this->mean.size() == dim && "mean vector size must equal dim");
Expand Down
Loading
Loading