-
Notifications
You must be signed in to change notification settings - Fork 32
[MOD-14956] Add SQ8 quantization support for HNSW index #1007
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
274afbc
4d09236
814eab5
7719c58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |||
|
|
||||
| using bfloat16 = vecsim_types::bfloat16; | ||||
| using float16 = vecsim_types::float16; | ||||
| using sq8 = vecsim_types::sq8; | ||||
|
|
||||
| namespace HNSWFactory { | ||||
|
|
||||
|
|
@@ -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); | ||||
|
|
||||
| // Symmetric: both stored vectors are SQ8 blobs. | ||||
| auto sym_func = spaces::GetDistFunc<sq8, float>(Metric, dim, &storage_alignment); | ||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, |
||||
| // 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); | ||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: FP16 + mean + L2 loses correctness through this instantiation. |
||||
| 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 = ¶ms->algoParams.hnswParams; | ||||
|
|
||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe assert false here ? |
||||
| } | ||||
|
cursor[bot] marked this conversation as resolved.
|
||||
|
|
||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unknown quantType builds unquantized indexMedium Severity
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); | ||||
|
|
@@ -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); | ||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SQ8 initial size skips metricLow Severity The 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) { | ||||
|
|
@@ -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); | ||||
| } | ||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SQ8 element estimate skips validationLow Severity The new SQ8 branch in Additional Locations (2)Reviewed by Cursor Bugbot for commit 4d09236. Configure here.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Making it strict needs an error channel it does not have. The return type is The genuinely inconsistent one is arguably What I did add is coverage of the boundary that does enforce the supported set: |
||||
| } 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). | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tiered estimates ignore SQ8 rejectionMedium Severity Tiered index Additional Locations (1)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) { | ||
|
|
||


There was a problem hiding this comment.
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, andmetric, but notquantTypeor 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 makesaveIndex()reject SQ8 and test that failure so it cannot emit a corrupt/unloadable file.