Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 152 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,127 @@ 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;
}

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


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

// Asymmetric: stored vector is SQ8 blob, query is DataType.
auto asym_func =
spaces::GetDistFunc<sq8, float, DataType>(Metric, dim, &asym_storage_alignment);
// 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.

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);
// 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;

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

AbstractIndexInitParams abstractInitParams =
VecSimFactory::NewAbstractInitParams(hnswParams, params->logCtx, is_normalized);

if (hnswParams->quantType == VecSimQuant_SQ8) {
if (hnswParams->type != VecSimType_FLOAT32 && hnswParams->type != VecSimType_FLOAT16) {
return NULL; // SQ8 supports FP32 and FP16 only.
}

VecSimMetric metric = hnswParams->metric;
if (is_normalized && metric == VecSimMetric_Cosine) {
metric = VecSimMetric_IP;
}

if (metric == VecSimMetric_Cosine) {
return NULL; // SQ8 does not support cosine metric.
}

const float *mean_ptr = static_cast<const float *>(hnswParams->quantParams);

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);
}
} 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. Kept so that adding a
// type or metric cannot silently fall through and build an unquantized index instead.
return NULL;
Comment on lines +153 to +156

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 ?

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unknown quantType builds unquantized index

Medium Severity

HNSWFactory::NewIndex only handles quantType == VecSimQuant_SQ8 explicitly. Any other non-VecSimQuant_NONE value falls through to the ordinary unquantized CreateIndexComponents path and builds a full-precision HNSW index without error. Callers that set an unsupported or forward-looking quantization code get silent misconfiguration instead of NULL, unlike the tiered factory which rejects any non-NONE quantType.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 814eab5. Configure here.

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 +211,27 @@ 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_SQ8) {
if (params->type != VecSimType_FLOAT32 && params->type != VecSimType_FLOAT16) {
throw std::invalid_argument("Invalid params->type for VecSimQuant_SQ8");
}
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SQ8 initial size skips metric

Low Severity

The VecSimQuant_SQ8 branch in EstimateInitialSize validates params->type but not params->metric. Configurations such as SQ8 with VecSimMetric_Cosine receive a full SQ8 initial-size estimate even though NewIndex returns NULL for the same params (cosine is rejected unless remapped via is_normalized, which the public C API does not use).

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 814eab5. Configure here.

} 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 +262,20 @@ 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;
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SQ8 element estimate skips validation

Low Severity

The new SQ8 branch in EstimateElementSize always applies GetSQ8StoredDataSize whenever quantType is VecSimQuant_SQ8, without checking that type is FLOAT32 or FLOAT16 or that the metric is supported. In the same file, NewIndex returns NULL for unsupported types and standalone Cosine, and EstimateInitialSize throws on invalid SQ8 types. Callers that size capacity from VecSimIndex_EstimateElementSize alone can get per-element byte counts for parameter sets that cannot produce an index.

Additional Locations (2)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 4d09236. 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.

Accurate, but I do not think it should change here, and the reasoning is worth recording.

The asymmetry is pre-existing behaviour of EstimateElementSize rather than something SQ8 introduced. Its unquantized path calls VecSimParams_GetStoredDataSize (vec_utils.cpp:296-302), which is VecSimType_sizeof(type) * dim plus a Cosine/int8 adjustment and validates nothing, for any algorithm. So the function has always returned a per-element size for parameters that cannot produce an index; the SQ8 branch matches that existing contract.

Making it strict needs an error channel it does not have. The return type is size_t, so the options are a sentinel or a throw, and EstimateElementSize currently contains no throw at all: the only ones in the file are in EstimateInitialSize and the file-loading paths. Adding one would newly carry a C++ exception across the extern "C" boundary via VecSimIndex_EstimateElementSize, which this library specifically avoids, since an exception reaching the C host aborts the host process.

The genuinely inconsistent one is arguably EstimateInitialSize being strict enough to throw, not EstimateElementSize being lax. Deciding the error model for both belongs with MOD-14958, which is what first makes quantType reachable from RediSearch; there is no product exposure before then.

What I did add is coverage of the boundary that does enforce the supported set: HNSWSQ8ParamsTest.RejectsUnsupportedDataType (7719c58) asserts that FLOAT64, BFLOAT16, INT8 and UINT8 with VecSimQuant_SQ8 all return NULL from index creation, plus a comment at the estimate explaining why it deliberately does not repeat the check. Verified red without the fix: with both the type fence and the fall-through return NULL removed, all four types are silently built as unquantized indexes.

} 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
8 changes: 8 additions & 0 deletions src/VecSim/index_factories/tiered_factory.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ inline size_t EstimateInitialSize(const TieredIndexParams *params) {
}

VecSimIndex *NewIndex(const TieredIndexParams *params) {
// Quantization is not wired into the tiered index yet (MOD-14957). Reject it here rather than

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tiered estimates ignore SQ8 rejection

Medium Severity

Tiered index NewIndex functions reject invalid configurations, such as non-NONE quantization, but the associated EstimateInitialSize and EstimateElementSize functions don't perform these same validation checks. This can lead to positive memory estimates for configurations that cannot actually be instantiated.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 814eab5. Configure here.

// 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
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)),
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
9 changes: 9 additions & 0 deletions src/VecSim/types/sq8.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ struct sq8 {
((WithNorm && Metric == VecSimMetric_IP) ? 1 : 0);
}

// Size of a stored SQ8 blob: one byte per dimension, followed by FP32 metadata. Single source
// of truth for the storage layout: every caller that sizes or allocates a stored blob must use
// this, so the layout cannot drift between the preprocessor and the index factories.
template <VecSimMetric Metric, bool WithNorm = false>
static constexpr size_t storage_bytes_count(size_t dim) {
return dim * sizeof(value_type) +
storage_metadata_count<Metric, WithNorm>() * sizeof(float);
}

// Index of x_mean_ip / y_mean_ip in the last slot in metadata array
template <VecSimMetric Metric>
static constexpr size_t mean_ip_index() {
Expand Down
11 changes: 11 additions & 0 deletions src/VecSim/vec_sim_common.h
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ typedef enum {
VecSimType_INT64
} VecSimType;

// Quantization type for HNSW indices.
typedef enum {
VecSimQuant_NONE = 0, // No quantization (default).
// 8-bit scalar quantization. Mean normalization is optional, selected by quantParams below.
VecSimQuant_SQ8 = 1,
} VecSimQuantType;

// Algorithm type/library.
typedef enum { VecSimAlgo_BF, VecSimAlgo_HNSWLIB, VecSimAlgo_TIERED, VecSimAlgo_SVS } VecSimAlgo;

Expand Down Expand Up @@ -156,6 +163,10 @@ typedef struct {
size_t efConstruction;
size_t efRuntime;
double epsilon;
VecSimQuantType quantType; // Quantization type. Default: VecSimQuant_NONE.
// For VecSimQuant_SQ8: pointer to float mean[dim], or NULL for zero mean. Read only, never
// retained: the index copies the mean vector during construction.
const void *quantParams;
} HNSWParams;

typedef struct {
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ endif()

add_executable(test_hnsw ../utils/test_main_with_timeout.cpp ../utils/mock_thread_pool.cpp test_hnsw.cpp test_hnsw_multi.cpp test_hnsw_tiered.cpp unit_test_utils.cpp)
add_executable(test_hnsw_parallel ../utils/test_main_with_timeout.cpp test_hnsw_parallel.cpp ../utils/mock_thread_pool.cpp unit_test_utils.cpp)
add_executable(test_hnsw_sq8 ../utils/test_main_with_timeout.cpp ../utils/mock_thread_pool.cpp test_hnsw_sq8.cpp unit_test_utils.cpp)
add_executable(test_bruteforce ../utils/test_main_with_timeout.cpp test_bruteforce.cpp test_bruteforce_multi.cpp ../utils/mock_thread_pool.cpp unit_test_utils.cpp)
add_executable(test_allocator ../utils/test_main_with_timeout.cpp test_allocator.cpp ../utils/mock_thread_pool.cpp unit_test_utils.cpp)
add_executable(test_spaces ../utils/test_main_with_timeout.cpp test_spaces.cpp)
Expand All @@ -51,6 +52,7 @@ add_executable(test_svs ../utils/test_main_with_timeout.cpp ../utils/mock_thread

target_link_libraries(test_hnsw PUBLIC gtest VectorSimilarity)
target_link_libraries(test_hnsw_parallel PUBLIC gtest VectorSimilarity)
target_link_libraries(test_hnsw_sq8 PUBLIC gtest VectorSimilarity)
target_link_libraries(test_bruteforce PUBLIC gtest VectorSimilarity)
target_link_libraries(test_allocator PUBLIC gtest VectorSimilarity)
target_link_libraries(test_spaces PUBLIC gtest VectorSimilarity)
Expand All @@ -68,6 +70,7 @@ include(GoogleTest)

gtest_discover_tests(test_hnsw)
gtest_discover_tests(test_hnsw_parallel)
gtest_discover_tests(test_hnsw_sq8)
gtest_discover_tests(test_bruteforce)
gtest_discover_tests(test_allocator)
gtest_discover_tests(test_spaces)
Expand Down
Loading
Loading