-
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’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bede5ba
0b34e12
07ad3dc
2a28877
2ba737c
5385dc6
7652f0f
a8dfc1c
ace52fa
84eb6d1
0367f36
9a74b2b
3131cff
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,137 @@ 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); | ||
|
|
||
| return with_norm ? sq8::storage_bytes_count<Metric, true>(dim) | ||
| : sq8::storage_bytes_count<Metric, false>(dim); | ||
| } | ||
|
|
||
| // Asymmetric dispatch reports alignment for the stored operand only. Ask the query type's | ||
| // dispatcher for the query allocation alignment. | ||
| 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; | ||
| } | ||
|
|
||
| // Cosine over pre-normalized vectors is computed as inner product. | ||
| [[nodiscard]] constexpr VecSimMetric ResolveSQ8Metric(VecSimMetric metric, bool is_normalized) { | ||
| return (is_normalized && metric == VecSimMetric_Cosine) ? VecSimMetric_IP : metric; | ||
| } | ||
|
|
||
| // Keep construction and initial-size validation in sync. | ||
| [[nodiscard]] constexpr bool SQ8ParamsSupported(VecSimType type, VecSimMetric resolved_metric, | ||
| bool with_mean) { | ||
| // SQ8 kernels accept only FLOAT32 and FLOAT16 input. | ||
| if (type != VecSimType_FLOAT32 && type != VecSimType_FLOAT16) { | ||
| return false; | ||
| } | ||
| // Only L2 and inner product have SQ8 kernels. | ||
| if (resolved_metric != VecSimMetric_L2 && resolved_metric != VecSimMetric_IP) { | ||
| return false; | ||
| } | ||
| // Mean-centered L2 queries are narrowed back to FLOAT16 while the stored metadata remains | ||
| // FLOAT32. Support requires a kernel that keeps the centered query in FLOAT32. | ||
| if (type == VecSimType_FLOAT16 && with_mean && resolved_metric == VecSimMetric_L2) { | ||
| return false; | ||
| } | ||
| return true; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| 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; | ||
|
|
||
| abstractInitParams.storedDataSize = GetSQ8StoredDataSize<Metric>(dim, with_norm); | ||
|
dor-forer marked this conversation as resolved.
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 SQ8 serialization guard is reached only after saveIndex has opened the destination with truncation and written the encoding version. A rejected SQ8 save can therefore destroy an existing snapshot. Please validate before opening the destination or save atomically, with a regression asserting that an existing destination remains unchanged.
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. Fixed. SQ8 serialization validation now runs before the destination is opened. The regression creates an existing destination and verifies its contents remain unchanged after the rejected save. |
||
| abstractInitParams.isQuantized = true; | ||
|
cursor[bot] marked this conversation as resolved.
dor-forer marked this conversation as resolved.
|
||
|
|
||
| // Graph construction compares two stored SQ8 blobs; search compares a stored blob with a | ||
| // DataType query. Both dispatchers report alignment for the stored operand. | ||
| auto sym_func = spaces::GetDistFunc<sq8, float>(Metric, dim, &storage_alignment); | ||
|
dor-forer marked this conversation as resolved.
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. Code flow: sym_func is selected here for stored-to-stored SQ8 comparisons and passed into DistanceCalculatorCommon or DistanceCalculatorWithNorm. During HNSW insertion and pruning, calcDistance invokes that function, which dispatches to SQ8_SQ8_InnerProductImp; it calls UINT8_InnerProductImp, accumulates in 32-bit SIMD lanes, and returns _mm512_reduce_add_epi32(sum) as a signed int. The SQ8 wrapper then converts that already-overflowed result to float and uses it in the dequantization formula, so HNSW can retain the wrong neighbors. A valid self-dot can overflow at dimension 33,027: 33,026 * 255 * 255 = 2,147,515,650, above INT32_MAX. Please widen or safely chunk the accumulator, select a safe fallback, or reject SQ8 dimensions above a proven bound before wiring this kernel into graph construction.
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. This is already handled on the current head by |
||
| auto asym_func = | ||
| spaces::GetDistFunc<sq8, float, DataType>(Metric, dim, &asym_storage_alignment); | ||
|
dor-forer marked this conversation as resolved.
dor-forer marked this conversation as resolved.
|
||
| storage_alignment = spaces::combineAlignments(storage_alignment, asym_storage_alignment); | ||
| const unsigned char query_alignment = GetQueryAlignment<DataType>(Metric, dim); | ||
|
|
||
| PreprocessorInterface *pp = nullptr; | ||
| IndexCalculatorInterface<float> *calc = nullptr; | ||
|
|
||
| if (with_norm) { | ||
| 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); | ||
|
dor-forer marked this conversation as resolved.
|
||
| calc = new (allocator) DistanceCalculatorWithNorm<DataType, float, Metric>( | ||
| allocator, asym_func, sym_func, mean_sum_squares); | ||
| } else { | ||
| pp = new (allocator) QuantPreprocessor<DataType, Metric>(allocator, dim); | ||
|
dor-forer marked this conversation as resolved.
|
||
| 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; | ||
| AbstractIndexInitParams abstractInitParams = | ||
| VecSimFactory::NewAbstractInitParams(hnswParams, params->logCtx, is_normalized); | ||
|
|
||
| if (hnswParams->quantType != VecSimQuant_NONE) { | ||
| // Reject unknown quantizers instead of silently creating an unquantized index. | ||
| 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); | ||
|
dor-forer marked this conversation as resolved.
|
||
| } | ||
| } 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); | ||
| } | ||
| } | ||
|
|
||
| // Keep the return for release builds so future dispatch changes cannot fall through. | ||
| assert(false && "unhandled SQ8 data type and metric combination"); | ||
| return NULL; | ||
|
dor-forer marked this conversation as resolved.
|
||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
|
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); | ||
|
|
@@ -94,7 +221,28 @@ 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) { | ||
| // Keep construction and initial-size validation in sync. | ||
| 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"); | ||
|
dor-forer marked this conversation as resolved.
cursor[bot] marked this conversation as resolved.
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: this throw escapes through VecSimIndex_EstimateInitialSize, an extern C API with no catch or status channel. FP32 plus SQ8 plus Cosine therefore terminates a C caller instead of reporting failure. Please catch at the boundary and return a documented failure value, or add a status plus out-parameter API, with a C-facing regression.
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. Good catch. I added a catch at the VecSimIndex_EstimateInitialSize C boundary so exceptions cannot escape to C callers. It now returns SIZE_MAX, matching the estimator’s existing failure convention, and the contract is documented in vec_sim.h. I also updated the unsupported SQ8 tests, including a direct public C API regression for FP32 + SQ8 + Cosine. |
||
| } | ||
| // Template arguments do not affect these component sizes. | ||
| if (params->quantParams) { | ||
| 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); | ||
| } 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); | ||
|
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) { | ||
|
|
@@ -125,9 +273,21 @@ 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; | ||
| // Preserve the element estimator's existing contract: return a size and validate in NewIndex. | ||
| 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); | ||
| } | ||
|
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). | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.