From a767068ae9452dfda993405f4b43c04c9528d7cd Mon Sep 17 00:00:00 2001 From: Dor Forer Date: Wed, 22 Jul 2026 16:21:01 +0800 Subject: [PATCH 1/8] [MOD-14952] Support normalization in QuantPreprocessor (#2) * Support norm in QuantPreprocessor * Remove unnecessary allocation and duplicated code (cherry picked from commit 027d5510b2bf7add6a45be9b0c07cda38c15b5ca) --- src/VecSim/spaces/computer/preprocessors.h | 118 +++++++++---- src/VecSim/types/sq8.h | 23 ++- tests/unit/test_components.cpp | 191 +++++++++++++++++++++ 3 files changed, 292 insertions(+), 40 deletions(-) diff --git a/src/VecSim/spaces/computer/preprocessors.h b/src/VecSim/spaces/computer/preprocessors.h index 988ba9dab..33f118cf0 100644 --- a/src/VecSim/spaces/computer/preprocessors.h +++ b/src/VecSim/spaces/computer/preprocessors.h @@ -1,6 +1,7 @@ /* * Copyright (c) 2006-Present, Redis Ltd. * All rights reserved. + * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates * * Licensed under your choice of the Redis Source Available License 2.0 * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the @@ -17,12 +18,14 @@ #include #include #include +#include #include "VecSim/memory/vecsim_base.h" #include "VecSim/spaces/spaces.h" #include "VecSim/memory/memory_utils.h" #include "VecSim/types/float16.h" #include "VecSim/types/sq8.h" +#include "VecSim/utils/vecsim_stl.h" class PreprocessorInterface : public VecsimBaseObject { public: @@ -235,7 +238,7 @@ static inline float to_fp32(T x) { } } -template +template class QuantPreprocessor : public PreprocessorInterface { using OUTPUT_TYPE = uint8_t; using MetadataType = float; // SQ8 metadata is always FP32 (see class doc). @@ -244,13 +247,35 @@ class QuantPreprocessor : public PreprocessorInterface { static_assert(Metric == VecSimMetric_L2 || Metric == VecSimMetric_IP || Metric == VecSimMetric_Cosine, "QuantPreprocessor only supports L2, IP and Cosine metrics"); + static_assert(!WithNorm || Metric != VecSimMetric_Cosine, + "WithNorm does not support Cosine metric."); // Helper function to perform quantization. This function is used by the storage preprocessing // methods. void quantize(const DataType *input, OUTPUT_TYPE *quantized) const { assert(input && quantized); - // Find min and max values (computed in MetadataType regardless of DataType). - auto [min_val, max_val] = find_min_max(input); + + if constexpr (std::is_same_v && !WithNorm) { + quantize_impl(input, quantized); + } else { + float x_mean_ip = 0.0f; // only used for WithNorm + + // Get transformed values (convert to float, mean-subtracted for WithNorm) + vecsim_stl::vector values(this->dim, this->allocator); + for (size_t i = 0; i < this->dim; ++i) { + values[i] = to_fp32(input[i]); + if constexpr (WithNorm) { + x_mean_ip += values[i] * mean[i]; + values[i] -= mean[i]; + } + } + quantize_impl(values.data(), quantized, x_mean_ip); + } + } + + void quantize_impl(const float *values, OUTPUT_TYPE *quantized, float x_mean_ip = 0.0f) const { + // Find min and max values. + auto [min_val, max_val] = find_min_max(values); // Calculate scaling factor (typed as MetadataType because they end up as metadata). const MetadataType diff = (max_val - min_val); @@ -272,10 +297,10 @@ class QuantPreprocessor : public PreprocessorInterface { // Quantize the values for (; i < dim_round_down; i += 4) { // Load once (widened to FP32 if DataType is FP16). - const float x0 = to_fp32(input[i + 0]); - const float x1 = to_fp32(input[i + 1]); - const float x2 = to_fp32(input[i + 2]); - const float x3 = to_fp32(input[i + 3]); + const float x0 = values[i + 0]; + const float x1 = values[i + 1]; + const float x2 = values[i + 2]; + const float x3 = values[i + 3]; // We know (input - min) => 0 // If min == max, all values are the same and should be quantized to 0. // reconstruction will yield the same original value for all vectors. @@ -305,7 +330,7 @@ class QuantPreprocessor : public PreprocessorInterface { MetadataType sum_squares = (q0 + q1) + (q2 + q3); for (; i < this->dim; ++i) { - const float x = to_fp32(input[i]); + const float x = values[i]; quantized[i] = static_cast(std::round((x - min_val) * inv_delta)); sum += x; if constexpr (Metric == VecSimMetric_L2) { @@ -316,26 +341,29 @@ class QuantPreprocessor : public PreprocessorInterface { // Metadata uses MetadataType. Use memcpy because the metadata offset // (dim * sizeof(uint8_t)) is not guaranteed to be sizeof(MetadataType)-aligned. void *meta_dst = quantized + this->dim; - if constexpr (Metric == VecSimMetric_L2) { - const MetadataType buf[4] = {min_val, delta, sum, sum_squares}; - memcpy(meta_dst, buf, sizeof(buf)); - } else { - const MetadataType buf[3] = {min_val, delta, sum}; - memcpy(meta_dst, buf, sizeof(buf)); - } + MetadataType buf[5] = {min_val, delta, sum}; + size_t n = 3; + if constexpr (Metric == VecSimMetric_L2) buf[n++] = sum_squares; + if constexpr (WithNorm) buf[n++] = x_mean_ip; + memcpy(meta_dst, buf, n * sizeof(MetadataType)); } // Computes and writes query metadata (FP32) in a single pass over the input vector. - // For IP/Cosine: writes y_sum = Σy_i - // For L2: writes y_sum = Σy_i and y_sum_squares = Σy_i² + // Without norm: + // For IP/Cosine: writes y_sum = Σy_i + // For L2: writes y_sum = Σy_i and y_sum_squares = Σy_i² + // With norm: additionally appends y_mean_ip = Σ(mean_i * y_i). // The output pointer addresses the metadata region after the query body and may not be // 4-byte aligned (e.g. FP16 query body with odd dim), so writes go through memcpy. void assign_query_metadata(const DataType *input, void *output_metadata) const { + // Accumulators are FP32 to preserve precision for FP16 inputs. // 4 independent accumulators for sum float s0{}, s1{}, s2{}, s3{}; // 4 independent accumulators for sum of squares (only used for L2) float q0{}, q1{}, q2{}, q3{}; + // 4 independent accumulators for y_mean_ip (only used for WithNorm) + float m0{}, m1{}, m2{}, m3{}; size_t i = 0; // round dim down to the nearest multiple of 4 @@ -358,11 +386,19 @@ class QuantPreprocessor : public PreprocessorInterface { q2 += y2 * y2; q3 += y3 * y3; } + + if constexpr (WithNorm) { + m0 += mean[i + 0] * y0; + m1 += mean[i + 1] * y1; + m2 += mean[i + 2] * y2; + m3 += mean[i + 3] * y3; + } } - // Sum/sum_squares become metadata, so they are MetadataType. + // Sum/sum_squares/y_mean_ip become metadata, so they are MetadataType. MetadataType sum = (s0 + s1) + (s2 + s3); MetadataType sum_squares = (q0 + q1) + (q2 + q3); + MetadataType y_mean_ip = (m0 + m1) + (m2 + m3); // Tail: handle remaining elements for (; i < this->dim; ++i) { @@ -371,29 +407,41 @@ class QuantPreprocessor : public PreprocessorInterface { if constexpr (Metric == VecSimMetric_L2) { sum_squares += y * y; } + if constexpr (WithNorm) { + y_mean_ip += mean[i] * y; + } } // Metadata uses MetadataType. Use memcpy because the metadata offset (after the query // body of dim * sizeof(DataType)) is not guaranteed to be sizeof(MetadataType)-aligned // when DataType is float16 and dim is odd. - if constexpr (Metric == VecSimMetric_L2) { - const MetadataType buf[2] = {sum, sum_squares}; - memcpy(output_metadata, buf, sizeof(buf)); - } else { - const MetadataType buf[1] = {sum}; - memcpy(output_metadata, buf, sizeof(buf)); - } + MetadataType buf[3] = {sum}; + size_t n = 1; + if constexpr (Metric == VecSimMetric_L2) buf[n++] = sum_squares; + if constexpr (WithNorm) buf[n++] = y_mean_ip; + memcpy(output_metadata, buf, n * sizeof(MetadataType)); } public: - QuantPreprocessor(std::shared_ptr allocator, size_t dim) + // Standard constructor (WithNorm == false): no mean vector. + QuantPreprocessor(std::shared_ptr allocator, size_t dim) requires(!WithNorm) : PreprocessorInterface(allocator), dim(dim), storage_bytes_count(dim * sizeof(OUTPUT_TYPE) + - (vecsim_types::sq8::storage_metadata_count()) * + sq8::storage_metadata_count() * sizeof(MetadataType)), + query_bytes_count(dim * sizeof(DataType) + + sq8::query_metadata_count() * sizeof(MetadataType)) {} + + // WithNorm constructor: accepts a pre-computed mean vector (FP32, length == dim). + QuantPreprocessor(std::shared_ptr allocator, size_t dim, + const vecsim_stl::vector &mean_vec) requires(WithNorm) + : PreprocessorInterface(allocator), mean(mean_vec), dim(dim), + storage_bytes_count(dim * sizeof(OUTPUT_TYPE) + + sq8::storage_metadata_count() * sizeof(MetadataType)), query_bytes_count(dim * sizeof(DataType) + - (vecsim_types::sq8::query_metadata_count()) * - sizeof(MetadataType)) {} + sq8::query_metadata_count() * sizeof(MetadataType)) { + assert(this->mean.size() == dim && "mean vector size must equal dim"); + } /** * Preprocesses the original blob into separate storage and query blobs. @@ -495,15 +543,15 @@ class QuantPreprocessor : public PreprocessorInterface { } private: - // Returns (min, max) of the input vector evaluated in MetadataType. Both float and float16 - // expose a usable operator< (float16's overload delegates to FP32 semantics), so a single - // std::minmax_element call covers both DataTypes. The returned values are written verbatim - // into the metadata region. - std::pair find_min_max(const DataType *input) const { + std::pair find_min_max(const float *input) const { auto [min_it, max_it] = std::minmax_element(input, input + dim); - return {to_fp32(*min_it), to_fp32(*max_it)}; + return {*min_it, *max_it}; } + // Mean vector for WithNorm=true; zero-size placeholder otherwise (no runtime overhead). + [[no_unique_address]] std::conditional_t, std::monostate> + mean; + const size_t dim; const size_t storage_bytes_count; const size_t query_bytes_count; diff --git a/src/VecSim/types/sq8.h b/src/VecSim/types/sq8.h index dfe296321..28d6379d1 100644 --- a/src/VecSim/types/sq8.h +++ b/src/VecSim/types/sq8.h @@ -1,6 +1,7 @@ /* * Copyright (c) 2006-Present, Redis Ltd. * All rights reserved. + * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates * * Licensed under your choice of the Redis Source Available License 2.0 * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the @@ -31,15 +32,27 @@ struct sq8 { SUM_SQUARES_QUERY = 1 // Only for L2 }; - // Template on metric — compile-time constant when metric is known - template + // Template on Metric and WithNorm — compile-time constants + // WithNorm: one extra metadata slot for x_mean_ip / y_mean_ip + template static constexpr size_t storage_metadata_count() { - return (Metric == VecSimMetric_L2) ? 4 : 3; + return ((Metric == VecSimMetric_L2) ? 4 : 3) + (WithNorm ? 1 : 0); } - template + template static constexpr size_t query_metadata_count() { - return (Metric == VecSimMetric_L2) ? 2 : 1; + return ((Metric == VecSimMetric_L2) ? 2 : 1) + (WithNorm ? 1 : 0); + } + + // Index of x_mean_ip / y_mean_ip in the last slot in metadata array + template + static constexpr size_t mean_ip_index() { + return storage_metadata_count() - 1; + } + + template + static constexpr size_t query_mean_ip_index() { + return query_metadata_count() - 1; } }; diff --git a/tests/unit/test_components.cpp b/tests/unit/test_components.cpp index c40d37e86..cf7d98901 100644 --- a/tests/unit/test_components.cpp +++ b/tests/unit/test_components.cpp @@ -1,6 +1,7 @@ /* * Copyright (c) 2006-Present, Redis Ltd. * All rights reserved. + * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates * * Licensed under your choice of the Redis Source Available License 2.0 * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the @@ -1612,3 +1613,193 @@ TEST(QuantPreprocessorFP16Test, QuantizeReconstructRoundTripL2) { allocator->free_allocation(storage_blob); delete preprocessor; } + +// Shared parameterized fixture for QuantPreprocessor. +template +class QuantPreprocessorWithNormMetricTestBase : public testing::TestWithParam { +protected: + static constexpr size_t dim = 5; + static constexpr unsigned char alignment = 0; + static constexpr size_t original_blob_size = dim * sizeof(DataType); + + std::shared_ptr allocator; + DataType original_blob[dim]; + float widened_blob[dim]; + float mean_vec[dim] = {0.1f, 0.2f, 0.3f, 0.4f, 0.5f}; + + void SetUp() override { + allocator = VecSimAllocator::newVecsimAllocator(); + const float source[dim] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f}; + for (size_t i = 0; i < dim; ++i) { + if constexpr (std::is_same_v) { + original_blob[i] = vecsim_types::FP32_to_FP16(source[i]); + widened_blob[i] = vecsim_types::FP16_to_FP32(original_blob[i]); + } else { + original_blob[i] = source[i]; + widened_blob[i] = source[i]; + } + } + } + + static float load_meta(const void *base, size_t byte_offset) { + float value; + std::memcpy(&value, static_cast(base) + byte_offset, sizeof(value)); + return value; + } + + template + size_t getExpectedStorageSize() const { + return dim * sizeof(uint8_t) + sq8::storage_metadata_count() * sizeof(float); + } + + template + size_t getExpectedQuerySize() const { + return dim * sizeof(DataType) + sq8::query_metadata_count() * sizeof(float); + } + + template + void runQuantizationTest() { + const size_t expected_storage_size = getExpectedStorageSize(); + const size_t expected_query_size = getExpectedQuerySize(); + const size_t storage_meta_offset = dim * sizeof(uint8_t); + const size_t query_meta_offset = dim * sizeof(DataType); + float centered[dim]; + float expected_x_mean_ip = 0.0f; + float expected_y_sum = 0.0f; + float expected_y_sum_squares = 0.0f; + float expected_y_mean_ip = 0.0f; + for (size_t i = 0; i < dim; ++i) { + centered[i] = widened_blob[i] - mean_vec[i]; + expected_x_mean_ip += widened_blob[i] * mean_vec[i]; + expected_y_sum += widened_blob[i]; + expected_y_sum_squares += widened_blob[i] * widened_blob[i]; + expected_y_mean_ip += widened_blob[i] * mean_vec[i]; + } + + constexpr size_t max_storage_baseline = dim * sizeof(uint8_t) + 4 * sizeof(float); + uint8_t baseline_storage[max_storage_baseline]; + ComputeSQ8Quantization(centered, dim, baseline_storage); + + vecsim_stl::vector mean_vector(allocator); + for (size_t i = 0; i < dim; ++i) { + mean_vector.push_back(mean_vec[i]); + } + auto quant_preprocessor = + new (allocator) QuantPreprocessor(allocator, dim, mean_vector); + + { + void *storage_blob = nullptr; + void *query_blob = nullptr; + size_t storage_blob_size = original_blob_size; + size_t query_blob_size = original_blob_size; + quant_preprocessor->preprocess(original_blob, storage_blob, query_blob, + storage_blob_size, query_blob_size, alignment, + alignment); + + ASSERT_NE(storage_blob, nullptr); + ASSERT_EQ(storage_blob_size, expected_storage_size); + ASSERT_NE(query_blob, nullptr); + ASSERT_EQ(query_blob_size, expected_query_size); + + const size_t compare_size = (Metric == VecSimMetric_L2) + ? dim * sizeof(uint8_t) + 4 * sizeof(float) + : dim * sizeof(uint8_t) + 3 * sizeof(float); + EXPECT_NO_FATAL_FAILURE(CompareVectors( + static_cast(storage_blob), baseline_storage, compare_size)); + ASSERT_FLOAT_EQ( + load_meta(storage_blob, + storage_meta_offset + sq8::mean_ip_index() * sizeof(float)), + expected_x_mean_ip); + EXPECT_NO_FATAL_FAILURE(CompareVectors( + static_cast(query_blob), original_blob, dim)); + ASSERT_FLOAT_EQ( + load_meta(query_blob, query_meta_offset + sq8::SUM_QUERY * sizeof(float)), + expected_y_sum); + if constexpr (Metric == VecSimMetric_L2) { + ASSERT_FLOAT_EQ(load_meta(query_blob, query_meta_offset + + sq8::SUM_SQUARES_QUERY * sizeof(float)), + expected_y_sum_squares); + } + ASSERT_FLOAT_EQ( + load_meta(query_blob, + query_meta_offset + sq8::query_mean_ip_index() * sizeof(float)), + expected_y_mean_ip); + allocator->free_allocation(storage_blob); + allocator->free_allocation(query_blob); + } + + { + void *blob = nullptr; + size_t blob_size = original_blob_size; + quant_preprocessor->preprocessForStorage(original_blob, blob, blob_size, alignment); + ASSERT_NE(blob, nullptr); + ASSERT_EQ(blob_size, expected_storage_size); + allocator->free_allocation(blob); + } + + { + void *blob = nullptr; + size_t blob_size = original_blob_size; + quant_preprocessor->preprocessQuery(original_blob, blob, blob_size, alignment); + ASSERT_NE(blob, nullptr); + ASSERT_EQ(blob_size, expected_query_size); + EXPECT_NO_FATAL_FAILURE( + CompareVectors(static_cast(blob), original_blob, dim)); + ASSERT_FLOAT_EQ(load_meta(blob, query_meta_offset + sq8::SUM_QUERY * sizeof(float)), + expected_y_sum); + if constexpr (Metric == VecSimMetric_L2) { + ASSERT_FLOAT_EQ( + load_meta(blob, query_meta_offset + sq8::SUM_SQUARES_QUERY * sizeof(float)), + expected_y_sum_squares); + } + ASSERT_FLOAT_EQ(load_meta(blob, query_meta_offset + + sq8::query_mean_ip_index() * sizeof(float)), + expected_y_mean_ip); + allocator->free_allocation(blob); + } + + delete quant_preprocessor; + } +}; + +using QuantPreprocessorWithNormMetricTest = QuantPreprocessorWithNormMetricTestBase; + +TEST_P(QuantPreprocessorWithNormMetricTest, QuantizationBlobSizeAndMetadata) { + VecSimMetric metric = GetParam(); + switch (metric) { + case VecSimMetric_L2: + runQuantizationTest(); + break; + case VecSimMetric_IP: + runQuantizationTest(); + break; + } +} + +INSTANTIATE_TEST_SUITE_P(QuantPreprocessorWithNormTests, QuantPreprocessorWithNormMetricTest, + testing::Values(VecSimMetric_L2, VecSimMetric_IP), + [](const testing::TestParamInfo &info) { + return VecSimMetric_ToString(info.param); + }); + +using QuantPreprocessorFP16WithNormMetricTest = + QuantPreprocessorWithNormMetricTestBase; + +TEST_P(QuantPreprocessorFP16WithNormMetricTest, QuantizationBlobSizeAndMetadata) { + VecSimMetric metric = GetParam(); + switch (metric) { + case VecSimMetric_L2: + runQuantizationTest(); + break; + case VecSimMetric_IP: + runQuantizationTest(); + break; + } +} + +INSTANTIATE_TEST_SUITE_P(QuantPreprocessorFP16WithNormTests, + QuantPreprocessorFP16WithNormMetricTest, + testing::Values(VecSimMetric_L2, VecSimMetric_IP), + [](const testing::TestParamInfo &info) { + return VecSimMetric_ToString(info.param); + }); From 3c417fdc62fad06e0c81b4de5b77dba3396b05c3 Mon Sep 17 00:00:00 2001 From: Dor Forer Date: Wed, 22 Jul 2026 13:48:07 +0300 Subject: [PATCH 2/8] Format quantization normalization changes --- src/VecSim/spaces/computer/preprocessors.h | 21 ++++++++++++++------- src/VecSim/types/sq8.h | 3 ++- tests/unit/test_components.cpp | 3 ++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/VecSim/spaces/computer/preprocessors.h b/src/VecSim/spaces/computer/preprocessors.h index 33f118cf0..2733bed44 100644 --- a/src/VecSim/spaces/computer/preprocessors.h +++ b/src/VecSim/spaces/computer/preprocessors.h @@ -1,7 +1,8 @@ /* * Copyright (c) 2006-Present, Redis Ltd. * All rights reserved. - * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates + * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates + * * * Licensed under your choice of the Redis Source Available License 2.0 * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the @@ -343,8 +344,10 @@ class QuantPreprocessor : public PreprocessorInterface { void *meta_dst = quantized + this->dim; MetadataType buf[5] = {min_val, delta, sum}; size_t n = 3; - if constexpr (Metric == VecSimMetric_L2) buf[n++] = sum_squares; - if constexpr (WithNorm) buf[n++] = x_mean_ip; + if constexpr (Metric == VecSimMetric_L2) + buf[n++] = sum_squares; + if constexpr (WithNorm) + buf[n++] = x_mean_ip; memcpy(meta_dst, buf, n * sizeof(MetadataType)); } @@ -417,14 +420,17 @@ class QuantPreprocessor : public PreprocessorInterface { // when DataType is float16 and dim is odd. MetadataType buf[3] = {sum}; size_t n = 1; - if constexpr (Metric == VecSimMetric_L2) buf[n++] = sum_squares; - if constexpr (WithNorm) buf[n++] = y_mean_ip; + if constexpr (Metric == VecSimMetric_L2) + buf[n++] = sum_squares; + if constexpr (WithNorm) + buf[n++] = y_mean_ip; memcpy(output_metadata, buf, n * sizeof(MetadataType)); } public: // Standard constructor (WithNorm == false): no mean vector. - QuantPreprocessor(std::shared_ptr allocator, size_t dim) requires(!WithNorm) + QuantPreprocessor(std::shared_ptr allocator, size_t dim) + requires(!WithNorm) : PreprocessorInterface(allocator), dim(dim), storage_bytes_count(dim * sizeof(OUTPUT_TYPE) + sq8::storage_metadata_count() * sizeof(MetadataType)), @@ -433,7 +439,8 @@ class QuantPreprocessor : public PreprocessorInterface { // WithNorm constructor: accepts a pre-computed mean vector (FP32, length == dim). QuantPreprocessor(std::shared_ptr allocator, size_t dim, - const vecsim_stl::vector &mean_vec) requires(WithNorm) + const vecsim_stl::vector &mean_vec) + requires(WithNorm) : PreprocessorInterface(allocator), mean(mean_vec), dim(dim), storage_bytes_count(dim * sizeof(OUTPUT_TYPE) + sq8::storage_metadata_count() * diff --git a/src/VecSim/types/sq8.h b/src/VecSim/types/sq8.h index 28d6379d1..34cf1de97 100644 --- a/src/VecSim/types/sq8.h +++ b/src/VecSim/types/sq8.h @@ -1,7 +1,8 @@ /* * Copyright (c) 2006-Present, Redis Ltd. * All rights reserved. - * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates + * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates + * * * Licensed under your choice of the Redis Source Available License 2.0 * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the diff --git a/tests/unit/test_components.cpp b/tests/unit/test_components.cpp index cf7d98901..129315041 100644 --- a/tests/unit/test_components.cpp +++ b/tests/unit/test_components.cpp @@ -1,7 +1,8 @@ /* * Copyright (c) 2006-Present, Redis Ltd. * All rights reserved. - * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates + * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates + * * * Licensed under your choice of the Redis Source Available License 2.0 * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the From 1995cebe94469c50182562ebb5e8283863de8ecb Mon Sep 17 00:00:00 2001 From: Dor Forer Date: Wed, 22 Jul 2026 18:21:59 +0300 Subject: [PATCH 3/8] Remove ARM copyright headers from modified files --- src/VecSim/spaces/computer/preprocessors.h | 2 -- src/VecSim/types/sq8.h | 2 -- tests/unit/test_components.cpp | 2 -- 3 files changed, 6 deletions(-) diff --git a/src/VecSim/spaces/computer/preprocessors.h b/src/VecSim/spaces/computer/preprocessors.h index 2733bed44..a4f88a7d7 100644 --- a/src/VecSim/spaces/computer/preprocessors.h +++ b/src/VecSim/spaces/computer/preprocessors.h @@ -1,8 +1,6 @@ /* * Copyright (c) 2006-Present, Redis Ltd. * All rights reserved. - * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates - * * * Licensed under your choice of the Redis Source Available License 2.0 * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the diff --git a/src/VecSim/types/sq8.h b/src/VecSim/types/sq8.h index 34cf1de97..adf74a1c8 100644 --- a/src/VecSim/types/sq8.h +++ b/src/VecSim/types/sq8.h @@ -1,8 +1,6 @@ /* * Copyright (c) 2006-Present, Redis Ltd. * All rights reserved. - * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates - * * * Licensed under your choice of the Redis Source Available License 2.0 * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the diff --git a/tests/unit/test_components.cpp b/tests/unit/test_components.cpp index 129315041..224388952 100644 --- a/tests/unit/test_components.cpp +++ b/tests/unit/test_components.cpp @@ -1,8 +1,6 @@ /* * Copyright (c) 2006-Present, Redis Ltd. * All rights reserved. - * SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates - * * * Licensed under your choice of the Redis Source Available License 2.0 * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the From 7eaa73f67da05ea127b5b853c9efc2a2b472e6c3 Mon Sep 17 00:00:00 2001 From: Dor Forer Date: Sun, 26 Jul 2026 09:33:01 +0300 Subject: [PATCH 4/8] Test HNSW stored-to-query dispatch --- tests/unit/test_hnsw.cpp | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/unit/test_hnsw.cpp b/tests/unit/test_hnsw.cpp index e308a5d6c..2eb26786d 100644 --- a/tests/unit/test_hnsw.cpp +++ b/tests/unit/test_hnsw.cpp @@ -11,6 +11,8 @@ #include "VecSim/vec_sim.h" #include "VecSim/vec_sim_debug.h" #include "VecSim/algorithms/hnsw/hnsw_single.h" +#include "VecSim/index_factories/components/components_factory.h" +#include "VecSim/index_factories/factory_utils.h" #include "VecSim/index_factories/hnsw_factory.h" #include "unit_test_utils.h" #include "VecSim/utils/serializer.h" @@ -43,6 +45,50 @@ class HNSWTest : public ::testing::Test { TYPED_TEST_SUITE(HNSWTest, DataTypeSet); +namespace { + +float storedDispatchTestFunc(const void *, const void *, size_t) { return 11.0f; } + +float queryDispatchTestFunc(const void *, const void *, size_t) { return 22.0f; } + +} // namespace + +TEST(HNSWDistanceDispatchTest, UsesStoredToQueryDispatch) { + constexpr size_t dim = 4; + HNSWParams params = { + .type = VecSimType_FLOAT32, + .dim = dim, + .metric = VecSimMetric_L2, + .multi = false, + }; + auto abstract_params = VecSimFactory::NewAbstractInitParams(¶ms, nullptr, false); + auto components = CreateIndexComponents(abstract_params.allocator, params.metric, + params.dim, false); + delete components.indexCalculator; + components.indexCalculator = new (abstract_params.allocator) DistanceCalculatorCommon( + abstract_params.allocator, storedDispatchTestFunc, queryDispatchTestFunc); + + auto *index = new (abstract_params.allocator) + HNSWIndex_Single(¶ms, abstract_params, components); + float vector[dim] = {1.0f, 2.0f, 3.0f, 4.0f}; + float query[dim] = {4.0f, 3.0f, 2.0f, 1.0f}; + VecSimIndex_AddVector(index, vector, 7); + + ASSERT_FLOAT_EQ(index->calcDistance(vector, query), 11.0f); + auto verify_query_score = [](size_t id, double score, size_t) { + ASSERT_EQ(id, 7); + ASSERT_FLOAT_EQ(score, 22.0f); + }; + + runTopKSearchTest(index, query, 1, verify_query_score); + runRangeQueryTest(index, query, 23.0, verify_query_score, 1, BY_SCORE); + VecSimBatchIterator *batch_iterator = VecSimBatchIterator_New(index, query, nullptr); + runBatchIteratorSearchTest(batch_iterator, 1, verify_query_score); + VecSimBatchIterator_Free(batch_iterator); + + VecSimIndex_Free(index); +} + TYPED_TEST(HNSWTest, hnsw_vector_add_test) { size_t dim = 4; From f3672bf230e07f2455fe5095a510fad4c5a3d476 Mon Sep 17 00:00:00 2001 From: Dor Forer Date: Wed, 22 Apr 2026 08:55:00 +0000 Subject: [PATCH 5/8] Add DistanceCalculatorWithNorm (cherry picked from commit 493ee7a8596c19c10ad2b467364fbfefd61732f6) --- src/VecSim/spaces/computer/calculator.h | 123 +++++++ tests/unit/test_components.cpp | 422 ++++++++++++++++++++++++ 2 files changed, 545 insertions(+) diff --git a/src/VecSim/spaces/computer/calculator.h b/src/VecSim/spaces/computer/calculator.h index c12699946..3ca58c1c3 100644 --- a/src/VecSim/spaces/computer/calculator.h +++ b/src/VecSim/spaces/computer/calculator.h @@ -11,6 +11,7 @@ #include "VecSim/memory/vecsim_base.h" #include "VecSim/spaces/spaces.h" +#include "VecSim/types/sq8.h" enum class DistanceMode { StoredToStored, @@ -120,3 +121,125 @@ class DistanceCalculatorCommon return DistanceDispatch::stateless(func); } }; + +/** + * Distance calculator for mean-normalized SQ8 indices. + * + * Stored vectors are SQ8-quantized from x' = x - mean. This calculator applies analytical + * correction terms derived from the per-vector x_mean_ip / y_mean_ip metadata stored in + * the blobs by QuantPreprocessor WithNorm=True to recover correct distances between the + * original (un-shifted) vectors x and y. + * + * Correction formulas (expressed in terms of distance semantics): + * + * Asymmetric (query y as FP32/FP16, stored vector x as SQ8 of x'): + * IP distance: dist(x,y) = asym_func(x_blob, y_blob) - y_mean_ip + * L2 distance: dist(x,y) = asym_func(x_blob, y_blob) + 2*(x_mean_ip - y_mean_ip) + * - mean_sum_squares + * + * Symmetric (both x and y as SQ8 blobs): + * IP distance: dist(x,y) = sym_func(x_blob, y_blob) - x_mean_ip - y_mean_ip + * + mean_sum_squares + * L2 distance: dist(x,y) = sym_func(x_blob, y_blob) (mean terms cancel exactly) + * + * Note: IP dist functions return 1 - IP(x',y'), not raw inner product. L2 dist functions + * return ||x'-y'||² directly. + * + * DataType is float or float16 + * DistType is float + */ +template +class DistanceCalculatorWithNorm + : public DistanceCalculatorInterface> { + static_assert(Metric == VecSimMetric_L2 || Metric == VecSimMetric_IP, + "DistanceCalculatorWithNorm only supports L2 and IP metrics"); + +private: + using sq8 = vecsim_types::sq8; + + struct WithNormDistanceContext { + spaces::dist_func_t stored_func; + spaces::dist_func_t query_func; + float mean_sum_squares; + }; + + const WithNormDistanceContext context_; + + static DistType calcStoredWithContext(const void *opaque_context, const void *v1, + const void *v2, size_t dim) { + const auto *context = static_cast(opaque_context); + DistType base = context->stored_func(v1, v2, dim); + if constexpr (Metric == VecSimMetric_IP) { + // base = 1 - IP(x', y'). We want 1 - IP(x, y). + // IP(x, y) = IP(x', y') + x_mean_ip + y_mean_ip - mean_sum_squares + // => 1 - IP(x, y) = base - x_mean_ip - y_mean_ip + mean_sum_squares + const float *meta1 = + reinterpret_cast(static_cast(v1) + dim); + const float *meta2 = + reinterpret_cast(static_cast(v2) + dim); + float x_mean_ip = meta1[sq8::template mean_ip_index()]; + float y_mean_ip = meta2[sq8::template mean_ip_index()]; + return base - x_mean_ip - y_mean_ip + context->mean_sum_squares; + } else { // L2: ||x - y||² = ||x' - y'||² (mean terms cancel exactly) + return base; + } + } + + static DistType calcQueryWithContext(const void *opaque_context, const void *candidate, + const void *query, size_t dim) { + const auto *context = static_cast(opaque_context); + DistType base = context->query_func(candidate, query, dim); + const float *query_meta = + reinterpret_cast(static_cast(query) + dim); + if constexpr (Metric == VecSimMetric_IP) { + // base = 1 - IP(x', y). We want 1 - IP(x, y). + // IP(x, y) = IP(x', y) + y_mean_ip + // => 1 - IP(x, y) = base - y_mean_ip + float y_mean_ip = query_meta[sq8::template query_mean_ip_index()]; + return base - y_mean_ip; + } else { // L2 + // base = ||x' - y||². We want ||x - y||². + // ||x - y||² = ||x' - y||² + 2*(x_mean_ip - y_mean_ip) - mean_sum_squares + const float *storage_meta = + reinterpret_cast(static_cast(candidate) + dim); + float x_mean_ip = storage_meta[sq8::template mean_ip_index()]; + float y_mean_ip = query_meta[sq8::template query_mean_ip_index()]; + return base + 2.0f * (x_mean_ip - y_mean_ip) - context->mean_sum_squares; + } + } + +public: + DistanceCalculatorWithNorm(std::shared_ptr allocator, + spaces::dist_func_t asym_func, + spaces::dist_func_t sym_func, float mean_sum_squares) + : DistanceCalculatorInterface>(allocator, sym_func, + asym_func), + context_{ + .stored_func = sym_func, + .query_func = asym_func, + .mean_sum_squares = mean_sum_squares, + } {} + + // Symmetric: both v1 and v2 are stored SQ8-of-x' blobs. + DistType calcDistance(const void *v1, const void *v2, size_t dim) const override { + return calcStoredWithContext(&context_, v1, v2, dim); + } + + // Asymmetric: query is raw FP32/FP16 blob, candidate is stored SQ8-of-x' blob. + // Note: query_dist_func expects (storage, query) argument order. + DistType calcDistanceForQuery(const void *candidate, const void *query, + size_t dim) const override { + return calcQueryWithContext(&context_, candidate, query, dim); + } + + DistanceDispatch getDistanceDispatch(DistanceMode mode) const override { + if (mode == DistanceMode::StoredToStored) { + if constexpr (Metric == VecSimMetric_L2) { + // The mean terms cancel for stored-to-stored L2, so retain the stateless fast path. + return DistanceDispatch::stateless(this->dist_func); + } + return DistanceDispatch::stateful(&context_, calcStoredWithContext); + } + return DistanceDispatch::stateful(&context_, calcQueryWithContext); + } +}; diff --git a/tests/unit/test_components.cpp b/tests/unit/test_components.cpp index 224388952..4fdb07f08 100644 --- a/tests/unit/test_components.cpp +++ b/tests/unit/test_components.cpp @@ -13,6 +13,8 @@ #include "VecSim/spaces/computer/preprocessor_container.h" #include "VecSim/spaces/computer/preprocessors.h" #include "VecSim/spaces/computer/calculator.h" +#include "VecSim/spaces/IP_space.h" +#include "VecSim/spaces/L2_space.h" #include "unit_test_utils.h" #include "tests_utils.h" @@ -1802,3 +1804,423 @@ INSTANTIATE_TEST_SUITE_P(QuantPreprocessorFP16WithNormTests, [](const testing::TestParamInfo &info) { return VecSimMetric_ToString(info.param); }); + +// Helper: build storage blob from original vector x and mean. +static void buildStorageBlob(const std::shared_ptr &allocator, const float *x, + const float *mean, size_t dim, VecSimMetric metric, + void *&storage_blob) { + vecsim_stl::vector mean_vec(allocator); + for (size_t i = 0; i < dim; ++i) + mean_vec.push_back(mean[i]); + + storage_blob = nullptr; + size_t sz = dim * sizeof(float); + if (metric == VecSimMetric_IP) { + auto *pp = new (allocator) + QuantPreprocessor(allocator, dim, mean_vec); + pp->preprocessForStorage(x, storage_blob, sz, 0); + delete pp; + } else { + auto *pp = new (allocator) + QuantPreprocessor(allocator, dim, mean_vec); + pp->preprocessForStorage(x, storage_blob, sz, 0); + delete pp; + } +} + +// Helper: build query blob from original vector y and mean. +static void buildQueryBlob(const std::shared_ptr &allocator, const float *y, + const float *mean, size_t dim, VecSimMetric metric, void *&query_blob) { + vecsim_stl::vector mean_vec(allocator); + for (size_t i = 0; i < dim; ++i) + mean_vec.push_back(mean[i]); + + query_blob = nullptr; + size_t sz = dim * sizeof(float); + if (metric == VecSimMetric_IP) { + auto *pp = new (allocator) + QuantPreprocessor(allocator, dim, mean_vec); + pp->preprocessQuery(y, query_blob, sz, 0); + delete pp; + } else { + auto *pp = new (allocator) + QuantPreprocessor(allocator, dim, mean_vec); + pp->preprocessQuery(y, query_blob, sz, 0); + delete pp; + } +} + +// Brute-force IP distance on original (unshifted) float vectors: 1 - dot(x, y). +static float bruteForceIPDist(const float *x, const float *y, size_t dim) { + float dot = 0.0f; + for (size_t i = 0; i < dim; ++i) + dot += x[i] * y[i]; + return 1.0f - dot; +} + +// Brute-force L2 squared distance on original float vectors: sum((x_i - y_i)^2). +static float bruteForceL2Dist(const float *x, const float *y, size_t dim) { + float sum = 0.0f; + for (size_t i = 0; i < dim; ++i) { + float d = x[i] - y[i]; + sum += d * d; + } + return sum; +} + +// Compute mean_sum_squares = sum(mean_i^2). +static float computeMeanSumSquares(const float *mean, size_t dim) { + float s = 0.0f; + for (size_t i = 0; i < dim; ++i) + s += mean[i] * mean[i]; + return s; +} + +TEST(DistanceCalculatorWithNormTest, CalcDistanceForQuery_IP_FP32) { + std::shared_ptr allocator = VecSimAllocator::newVecsimAllocator(); + constexpr size_t dim = 8; + float x[dim] = {1.0f, 2.0f, 3.0f, 4.0f, 1.0f, 2.0f, 3.0f, 4.0f}; + float y[dim] = {0.5f, 1.5f, 2.5f, 3.5f, 0.5f, 1.5f, 2.5f, 3.5f}; + float mean[dim] = {0.5f, 1.0f, 1.5f, 2.0f, 0.5f, 1.0f, 1.5f, 2.0f}; + + float mean_sum_sq = computeMeanSumSquares(mean, dim); + + void *storage_blob = nullptr; + void *query_blob = nullptr; + buildStorageBlob(allocator, x, mean, dim, VecSimMetric_IP, storage_blob); + buildQueryBlob(allocator, y, mean, dim, VecSimMetric_IP, query_blob); + + auto asym_func = spaces::IP_SQ8_FP32_GetDistFunc(dim); + auto sym_func = spaces::IP_SQ8_SQ8_GetDistFunc(dim); + auto *calc = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_func, sym_func, mean_sum_sq); + + float got = calc->calcDistanceForQuery(storage_blob, query_blob, dim); + float expected = bruteForceIPDist(x, y, dim); + + // Allow quantization error + EXPECT_NEAR(got, expected, 0.05f) << "Asymmetric IP distance mismatch"; + + allocator->free_allocation(storage_blob); + allocator->free_allocation(query_blob); + delete calc; +} + +TEST(DistanceCalculatorWithNormTest, CalcDistanceForQuery_L2_FP32) { + std::shared_ptr allocator = VecSimAllocator::newVecsimAllocator(); + constexpr size_t dim = 8; + float x[dim] = {1.0f, 2.0f, 3.0f, 4.0f, 1.0f, 2.0f, 3.0f, 4.0f}; + float y[dim] = {0.5f, 1.5f, 2.5f, 3.5f, 0.5f, 1.5f, 2.5f, 3.5f}; + float mean[dim] = {0.5f, 1.0f, 1.5f, 2.0f, 0.5f, 1.0f, 1.5f, 2.0f}; + + float mean_sum_sq = computeMeanSumSquares(mean, dim); + + void *storage_blob = nullptr; + void *query_blob = nullptr; + buildStorageBlob(allocator, x, mean, dim, VecSimMetric_L2, storage_blob); + buildQueryBlob(allocator, y, mean, dim, VecSimMetric_L2, query_blob); + + auto asym_func = spaces::L2_SQ8_FP32_GetDistFunc(dim); + auto sym_func = spaces::L2_SQ8_SQ8_GetDistFunc(dim); + auto *calc = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_func, sym_func, mean_sum_sq); + + float got = calc->calcDistanceForQuery(storage_blob, query_blob, dim); + float expected = bruteForceL2Dist(x, y, dim); + + EXPECT_NEAR(got, expected, 0.05f) << "Asymmetric L2 distance mismatch"; + + allocator->free_allocation(storage_blob); + allocator->free_allocation(query_blob); + delete calc; +} + +TEST(DistanceCalculatorWithNormTest, CalcDistance_IP_Symmetric) { + std::shared_ptr allocator = VecSimAllocator::newVecsimAllocator(); + constexpr size_t dim = 8; + float x[dim] = {1.0f, 2.0f, 3.0f, 4.0f, 1.0f, 2.0f, 3.0f, 4.0f}; + float y[dim] = {0.5f, 1.5f, 2.5f, 3.5f, 0.5f, 1.5f, 2.5f, 3.5f}; + float mean[dim] = {0.5f, 1.0f, 1.5f, 2.0f, 0.5f, 1.0f, 1.5f, 2.0f}; + + float mean_sum_sq = computeMeanSumSquares(mean, dim); + + void *x_blob = nullptr; + void *y_blob = nullptr; + buildStorageBlob(allocator, x, mean, dim, VecSimMetric_IP, x_blob); + buildStorageBlob(allocator, y, mean, dim, VecSimMetric_IP, y_blob); + + auto asym_func = spaces::IP_SQ8_FP32_GetDistFunc(dim); + auto sym_func = spaces::IP_SQ8_SQ8_GetDistFunc(dim); + auto *calc = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_func, sym_func, mean_sum_sq); + + float got = calc->calcDistance(x_blob, y_blob, dim); + float expected = bruteForceIPDist(x, y, dim); + + EXPECT_NEAR(got, expected, 0.05f) << "Symmetric IP distance mismatch"; + + allocator->free_allocation(x_blob); + allocator->free_allocation(y_blob); + delete calc; +} + +TEST(DistanceCalculatorWithNormTest, CalcDistance_L2_Symmetric) { + std::shared_ptr allocator = VecSimAllocator::newVecsimAllocator(); + constexpr size_t dim = 8; + float x[dim] = {1.0f, 2.0f, 3.0f, 4.0f, 1.0f, 2.0f, 3.0f, 4.0f}; + float y[dim] = {0.5f, 1.5f, 2.5f, 3.5f, 0.5f, 1.5f, 2.5f, 3.5f}; + float mean[dim] = {0.5f, 1.0f, 1.5f, 2.0f, 0.5f, 1.0f, 1.5f, 2.0f}; + + float mean_sum_sq = computeMeanSumSquares(mean, dim); + + void *x_blob = nullptr; + void *y_blob = nullptr; + buildStorageBlob(allocator, x, mean, dim, VecSimMetric_L2, x_blob); + buildStorageBlob(allocator, y, mean, dim, VecSimMetric_L2, y_blob); + + auto asym_func = spaces::L2_SQ8_FP32_GetDistFunc(dim); + auto sym_func = spaces::L2_SQ8_SQ8_GetDistFunc(dim); + auto *calc = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_func, sym_func, mean_sum_sq); + + float got = calc->calcDistance(x_blob, y_blob, dim); + float expected = bruteForceL2Dist(x, y, dim); + + EXPECT_NEAR(got, expected, 0.05f) << "Symmetric L2 distance mismatch"; + + allocator->free_allocation(x_blob); + allocator->free_allocation(y_blob); + delete calc; +} + +TEST(DistanceCalculatorWithNormTest, CalcDistanceForQuery_FP16) { + std::shared_ptr allocator = VecSimAllocator::newVecsimAllocator(); + constexpr size_t dim = 8; + float x_fp32[dim] = {1.0f, 2.0f, 3.0f, 4.0f, 1.0f, 2.0f, 3.0f, 4.0f}; + float y_fp32[dim] = {0.5f, 1.5f, 2.5f, 3.5f, 0.5f, 1.5f, 2.5f, 3.5f}; + float mean[dim] = {0.5f, 1.0f, 1.5f, 2.0f, 0.5f, 1.0f, 1.5f, 2.0f}; + + using DataType = vecsim_types::float16; + + DataType x[dim], y[dim]; + for (size_t i = 0; i < dim; ++i) { + x[i] = vecsim_types::FP32_to_FP16(x_fp32[i]); + y[i] = vecsim_types::FP32_to_FP16(y_fp32[i]); + } + + vecsim_stl::vector mean_vec(allocator); + for (size_t i = 0; i < dim; ++i) + mean_vec.push_back(mean[i]); + float mean_sum_sq = computeMeanSumSquares(mean, dim); + + // Build storage blob from FP16 x + void *storage_blob = nullptr; + size_t sz = dim * sizeof(DataType); + auto *pp_ip = new (allocator) + QuantPreprocessor(allocator, dim, mean_vec); + pp_ip->preprocessForStorage(x, storage_blob, sz, 0); + delete pp_ip; + + // Build query blob from FP16 y + void *query_blob = nullptr; + sz = dim * sizeof(DataType); + auto *pp_qr = new (allocator) + QuantPreprocessor(allocator, dim, mean_vec); + pp_qr->preprocessQuery(y, query_blob, sz, 0); + delete pp_qr; + + auto asym_func = spaces::IP_SQ8_FP16_GetDistFunc(dim); + auto sym_func = spaces::IP_SQ8_SQ8_GetDistFunc(dim); + auto *calc = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_func, sym_func, mean_sum_sq); + + float got = calc->calcDistanceForQuery(storage_blob, query_blob, dim); + float expected = bruteForceIPDist(x_fp32, y_fp32, dim); + + EXPECT_NEAR(got, expected, 0.05f) << "Asymmetric IP FP16 distance mismatch"; + + allocator->free_allocation(storage_blob); + allocator->free_allocation(query_blob); + delete calc; +} + +TEST(DistanceCalculatorWithNormTest, ZeroMean_MatchesBaseSQ8) { + std::shared_ptr allocator = VecSimAllocator::newVecsimAllocator(); + constexpr size_t dim = 8; + float x[dim] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f}; + float y[dim] = {0.5f, 1.5f, 2.5f, 3.5f, 4.5f, 5.5f, 6.5f, 7.5f}; + vecsim_stl::vector zero_mean(dim, 0.0f, allocator); + + // Build WithNorm storage and query blobs using zero mean + auto *pp_ip = + new (allocator) QuantPreprocessor(allocator, dim, zero_mean); + void *x_norm_blob = nullptr, *y_norm_query = nullptr; + size_t sx = dim * sizeof(float), sq = dim * sizeof(float); + pp_ip->preprocessForStorage(x, x_norm_blob, sx, 0); + pp_ip->preprocessQuery(y, y_norm_query, sq, 0); + delete pp_ip; + + auto asym_func = spaces::IP_SQ8_FP32_GetDistFunc(dim); + auto sym_func = spaces::IP_SQ8_SQ8_GetDistFunc(dim); + + auto *norm_calc = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_func, sym_func, 0.0f); + + // With zero mean, correction is 0: result equals raw base function call. + // asym_func expects (storage, query) order. + float norm_asym = norm_calc->calcDistanceForQuery(x_norm_blob, y_norm_query, dim); + float base_asym = asym_func(x_norm_blob, y_norm_query, dim); + EXPECT_FLOAT_EQ(norm_asym, base_asym) + << "WithNorm(zero mean) asymmetric IP should match raw base dist function"; + + // Build a second storage blob for y to test symmetric distance + void *y_norm_blob = nullptr; + sx = dim * sizeof(float); + auto *pp_ip2 = + new (allocator) QuantPreprocessor(allocator, dim, zero_mean); + pp_ip2->preprocessForStorage(y, y_norm_blob, sx, 0); + delete pp_ip2; + + float norm_sym = norm_calc->calcDistance(x_norm_blob, y_norm_blob, dim); + float base_sym = sym_func(x_norm_blob, y_norm_blob, dim); + EXPECT_FLOAT_EQ(norm_sym, base_sym) + << "WithNorm(zero mean) symmetric IP should match raw base dist function"; + + allocator->free_allocation(x_norm_blob); + allocator->free_allocation(y_norm_query); + allocator->free_allocation(y_norm_blob); + delete norm_calc; +} + +TEST(DistanceCalculatorWithNormTest, SymmetricVsAsymmetric_Sanity) { + // For the same two stored vectors, calcDistance (symmetric) and calcDistanceForQuery + // (asymmetric) must agree to within quantization error. + std::shared_ptr allocator = VecSimAllocator::newVecsimAllocator(); + constexpr size_t dim = 8; + float x[dim] = {3.0f, 1.0f, 4.0f, 1.0f, 5.0f, 3.0f, 2.0f, 6.0f}; + float y[dim] = {2.0f, 7.0f, 1.0f, 4.0f, 2.0f, 5.0f, 1.0f, 2.0f}; + float mean[dim] = {1.0f, 2.0f, 1.0f, 2.0f, 1.0f, 2.0f, 1.0f, 2.0f}; + float mean_sum_sq = computeMeanSumSquares(mean, dim); + + // IP + { + void *x_blob = nullptr, *y_blob = nullptr, *y_query = nullptr; + buildStorageBlob(allocator, x, mean, dim, VecSimMetric_IP, x_blob); + buildStorageBlob(allocator, y, mean, dim, VecSimMetric_IP, y_blob); + buildQueryBlob(allocator, y, mean, dim, VecSimMetric_IP, y_query); + + auto asym_func = spaces::IP_SQ8_FP32_GetDistFunc(dim); + auto sym_func = spaces::IP_SQ8_SQ8_GetDistFunc(dim); + auto *calc = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_func, sym_func, mean_sum_sq); + + float sym_dist = calc->calcDistance(x_blob, y_blob, dim); + float asym_dist = calc->calcDistanceForQuery(x_blob, y_query, dim); + // Both should be close to the brute-force answer; use relative tolerance + // since the IP magnitude (~157) amplifies absolute quantization error. + float bf = bruteForceIPDist(x, y, dim); + EXPECT_NEAR(sym_dist, bf, 0.05f) << "Symmetric IP vs brute-force"; + EXPECT_NEAR(asym_dist, bf, 0.05f) << "Asymmetric IP vs brute-force"; + + allocator->free_allocation(x_blob); + allocator->free_allocation(y_blob); + allocator->free_allocation(y_query); + delete calc; + } + + // L2 + { + void *x_blob = nullptr, *y_blob = nullptr, *y_query = nullptr; + buildStorageBlob(allocator, x, mean, dim, VecSimMetric_L2, x_blob); + buildStorageBlob(allocator, y, mean, dim, VecSimMetric_L2, y_blob); + buildQueryBlob(allocator, y, mean, dim, VecSimMetric_L2, y_query); + + auto asym_func = spaces::L2_SQ8_FP32_GetDistFunc(dim); + auto sym_func = spaces::L2_SQ8_SQ8_GetDistFunc(dim); + auto *calc = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_func, sym_func, mean_sum_sq); + + float sym_dist = calc->calcDistance(x_blob, y_blob, dim); + float asym_dist = calc->calcDistanceForQuery(x_blob, y_query, dim); + float bf = bruteForceL2Dist(x, y, dim); + EXPECT_NEAR(sym_dist, bf, 0.05f) << "Symmetric L2 vs brute-force"; + EXPECT_NEAR(asym_dist, bf, 0.05f) << "Asymmetric L2 vs brute-force"; + + allocator->free_allocation(x_blob); + allocator->free_allocation(y_blob); + allocator->free_allocation(y_query); + delete calc; + } +} + +TEST(DistanceCalculatorWithNormTest, RandomVectors) { + // Generate random vector pairs, compute WithNorm distances and verify against brute-force. + std::shared_ptr allocator = VecSimAllocator::newVecsimAllocator(); + constexpr size_t dim = 16; + std::mt19937 rng(12345); + std::uniform_real_distribution dist(-1.0f, 1.0f); + + float mean[dim]; + for (size_t i = 0; i < dim; ++i) + mean[i] = dist(rng) * 0.5f; + float mean_sum_sq = computeMeanSumSquares(mean, dim); + + auto asym_ip = spaces::IP_SQ8_FP32_GetDistFunc(dim); + auto sym_ip = spaces::IP_SQ8_SQ8_GetDistFunc(dim); + auto asym_l2 = spaces::L2_SQ8_FP32_GetDistFunc(dim); + auto sym_l2 = spaces::L2_SQ8_SQ8_GetDistFunc(dim); + + auto *calc_ip = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_ip, sym_ip, mean_sum_sq); + auto *calc_l2 = new (allocator) DistanceCalculatorWithNorm( + allocator, asym_l2, sym_l2, mean_sum_sq); + + int failures = 0; + for (int trial = 0; trial < 100; ++trial) { + float x[dim], y[dim]; + for (size_t i = 0; i < dim; ++i) { + x[i] = dist(rng); + y[i] = dist(rng); + } + + void *x_blob = nullptr, *y_blob = nullptr; + void *y_query_ip = nullptr, *y_query_l2 = nullptr; + buildStorageBlob(allocator, x, mean, dim, VecSimMetric_IP, x_blob); + buildStorageBlob(allocator, y, mean, dim, VecSimMetric_IP, y_blob); + buildQueryBlob(allocator, y, mean, dim, VecSimMetric_IP, y_query_ip); + + void *x_blob_l2 = nullptr, *y_blob_l2 = nullptr; + buildStorageBlob(allocator, x, mean, dim, VecSimMetric_L2, x_blob_l2); + buildStorageBlob(allocator, y, mean, dim, VecSimMetric_L2, y_blob_l2); + buildQueryBlob(allocator, y, mean, dim, VecSimMetric_L2, y_query_l2); + + float bf_ip = bruteForceIPDist(x, y, dim); + float bf_l2 = bruteForceL2Dist(x, y, dim); + + float got_ip_asym = calc_ip->calcDistanceForQuery(x_blob, y_query_ip, dim); + float got_ip_sym = calc_ip->calcDistance(x_blob, y_blob, dim); + float got_l2_asym = calc_l2->calcDistanceForQuery(x_blob_l2, y_query_l2, dim); + float got_l2_sym = calc_l2->calcDistance(x_blob_l2, y_blob_l2, dim); + + if (std::abs(got_ip_asym - bf_ip) > 0.1f) + ++failures; + if (std::abs(got_ip_sym - bf_ip) > 0.1f) + ++failures; + if (std::abs(got_l2_asym - bf_l2) > 0.1f) + ++failures; + if (std::abs(got_l2_sym - bf_l2) > 0.1f) + ++failures; + + allocator->free_allocation(x_blob); + allocator->free_allocation(y_blob); + allocator->free_allocation(y_query_ip); + allocator->free_allocation(x_blob_l2); + allocator->free_allocation(y_blob_l2); + allocator->free_allocation(y_query_l2); + } + + EXPECT_EQ(failures, 0) << failures << " distance computations exceeded tolerance"; + + delete calc_ip; + delete calc_l2; +} From 11b4a5b707b123a87f7f179cf2062793da0a4f75 Mon Sep 17 00:00:00 2001 From: Dor Forer Date: Thu, 23 Jul 2026 04:04:09 +0000 Subject: [PATCH 6/8] Fix unaligned metadata loads (cherry picked from commit c27401d6d5a29e2f98d8c80fbe77f8ce9e57156e) --- src/VecSim/spaces/computer/calculator.h | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/VecSim/spaces/computer/calculator.h b/src/VecSim/spaces/computer/calculator.h index 3ca58c1c3..467900596 100644 --- a/src/VecSim/spaces/computer/calculator.h +++ b/src/VecSim/spaces/computer/calculator.h @@ -12,6 +12,7 @@ #include "VecSim/memory/vecsim_base.h" #include "VecSim/spaces/spaces.h" #include "VecSim/types/sq8.h" +#include "VecSim/utils/alignment.h" enum class DistanceMode { StoredToStored, @@ -173,12 +174,12 @@ class DistanceCalculatorWithNorm // base = 1 - IP(x', y'). We want 1 - IP(x, y). // IP(x, y) = IP(x', y') + x_mean_ip + y_mean_ip - mean_sum_squares // => 1 - IP(x, y) = base - x_mean_ip - y_mean_ip + mean_sum_squares - const float *meta1 = - reinterpret_cast(static_cast(v1) + dim); - const float *meta2 = - reinterpret_cast(static_cast(v2) + dim); - float x_mean_ip = meta1[sq8::template mean_ip_index()]; - float y_mean_ip = meta2[sq8::template mean_ip_index()]; + float x_mean_ip = + load_unaligned(static_cast(v1) + dim + + sq8::template mean_ip_index() * sizeof(float)); + float y_mean_ip = + load_unaligned(static_cast(v2) + dim + + sq8::template mean_ip_index() * sizeof(float)); return base - x_mean_ip - y_mean_ip + context->mean_sum_squares; } else { // L2: ||x - y||² = ||x' - y'||² (mean terms cancel exactly) return base; @@ -189,21 +190,20 @@ class DistanceCalculatorWithNorm const void *query, size_t dim) { const auto *context = static_cast(opaque_context); DistType base = context->query_func(candidate, query, dim); - const float *query_meta = - reinterpret_cast(static_cast(query) + dim); + float y_mean_ip = + load_unaligned(static_cast(query) + dim * sizeof(DataType) + + sq8::template query_mean_ip_index() * sizeof(float)); if constexpr (Metric == VecSimMetric_IP) { // base = 1 - IP(x', y). We want 1 - IP(x, y). // IP(x, y) = IP(x', y) + y_mean_ip // => 1 - IP(x, y) = base - y_mean_ip - float y_mean_ip = query_meta[sq8::template query_mean_ip_index()]; return base - y_mean_ip; } else { // L2 // base = ||x' - y||². We want ||x - y||². // ||x - y||² = ||x' - y||² + 2*(x_mean_ip - y_mean_ip) - mean_sum_squares - const float *storage_meta = - reinterpret_cast(static_cast(candidate) + dim); - float x_mean_ip = storage_meta[sq8::template mean_ip_index()]; - float y_mean_ip = query_meta[sq8::template query_mean_ip_index()]; + float x_mean_ip = + load_unaligned(static_cast(candidate) + dim + + sq8::template mean_ip_index() * sizeof(float)); return base + 2.0f * (x_mean_ip - y_mean_ip) - context->mean_sum_squares; } } From 593c7447573ab4a8d5f0ed5237fb6ca2dea9ece2 Mon Sep 17 00:00:00 2001 From: Dor Forer Date: Thu, 23 Jul 2026 15:14:18 +0300 Subject: [PATCH 7/8] Use contextual dispatch for normalized distances --- src/VecSim/spaces/computer/calculator.h | 14 ++++---------- tests/unit/test_components.cpp | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/VecSim/spaces/computer/calculator.h b/src/VecSim/spaces/computer/calculator.h index 467900596..e1d8a0d5c 100644 --- a/src/VecSim/spaces/computer/calculator.h +++ b/src/VecSim/spaces/computer/calculator.h @@ -150,8 +150,7 @@ class DistanceCalculatorCommon * DistType is float */ template -class DistanceCalculatorWithNorm - : public DistanceCalculatorInterface> { +class DistanceCalculatorWithNorm : public IndexCalculatorInterface { static_assert(Metric == VecSimMetric_L2 || Metric == VecSimMetric_IP, "DistanceCalculatorWithNorm only supports L2 and IP metrics"); @@ -212,13 +211,8 @@ class DistanceCalculatorWithNorm DistanceCalculatorWithNorm(std::shared_ptr allocator, spaces::dist_func_t asym_func, spaces::dist_func_t sym_func, float mean_sum_squares) - : DistanceCalculatorInterface>(allocator, sym_func, - asym_func), - context_{ - .stored_func = sym_func, - .query_func = asym_func, - .mean_sum_squares = mean_sum_squares, - } {} + : IndexCalculatorInterface(allocator), + context_{sym_func, asym_func, mean_sum_squares} {} // Symmetric: both v1 and v2 are stored SQ8-of-x' blobs. DistType calcDistance(const void *v1, const void *v2, size_t dim) const override { @@ -236,7 +230,7 @@ class DistanceCalculatorWithNorm if (mode == DistanceMode::StoredToStored) { if constexpr (Metric == VecSimMetric_L2) { // The mean terms cancel for stored-to-stored L2, so retain the stateless fast path. - return DistanceDispatch::stateless(this->dist_func); + return DistanceDispatch::stateless(context_.stored_func); } return DistanceDispatch::stateful(&context_, calcStoredWithContext); } diff --git a/tests/unit/test_components.cpp b/tests/unit/test_components.cpp index 4fdb07f08..b60564575 100644 --- a/tests/unit/test_components.cpp +++ b/tests/unit/test_components.cpp @@ -1897,9 +1897,14 @@ TEST(DistanceCalculatorWithNormTest, CalcDistanceForQuery_IP_FP32) { float got = calc->calcDistanceForQuery(storage_blob, query_blob, dim); float expected = bruteForceIPDist(x, y, dim); + auto dispatch = calc->getDistanceDispatch(DistanceMode::StoredToQuery); // Allow quantization error EXPECT_NEAR(got, expected, 0.05f) << "Asymmetric IP distance mismatch"; + ASSERT_TRUE(dispatch.isValid()); + EXPECT_EQ(dispatch.stateless_func, nullptr); + EXPECT_NE(dispatch.stateful_func, nullptr); + EXPECT_NEAR(dispatch(storage_blob, query_blob, dim), expected, 0.05f); allocator->free_allocation(storage_blob); allocator->free_allocation(query_blob); @@ -1927,8 +1932,13 @@ TEST(DistanceCalculatorWithNormTest, CalcDistanceForQuery_L2_FP32) { float got = calc->calcDistanceForQuery(storage_blob, query_blob, dim); float expected = bruteForceL2Dist(x, y, dim); + auto dispatch = calc->getDistanceDispatch(DistanceMode::StoredToQuery); EXPECT_NEAR(got, expected, 0.05f) << "Asymmetric L2 distance mismatch"; + ASSERT_TRUE(dispatch.isValid()); + EXPECT_EQ(dispatch.stateless_func, nullptr); + EXPECT_NE(dispatch.stateful_func, nullptr); + EXPECT_NEAR(dispatch(storage_blob, query_blob, dim), expected, 0.05f); allocator->free_allocation(storage_blob); allocator->free_allocation(query_blob); @@ -1956,8 +1966,13 @@ TEST(DistanceCalculatorWithNormTest, CalcDistance_IP_Symmetric) { float got = calc->calcDistance(x_blob, y_blob, dim); float expected = bruteForceIPDist(x, y, dim); + auto dispatch = calc->getDistanceDispatch(DistanceMode::StoredToStored); EXPECT_NEAR(got, expected, 0.05f) << "Symmetric IP distance mismatch"; + ASSERT_TRUE(dispatch.isValid()); + EXPECT_EQ(dispatch.stateless_func, nullptr); + EXPECT_NE(dispatch.stateful_func, nullptr); + EXPECT_NEAR(dispatch(x_blob, y_blob, dim), expected, 0.05f); allocator->free_allocation(x_blob); allocator->free_allocation(y_blob); @@ -1985,8 +2000,13 @@ TEST(DistanceCalculatorWithNormTest, CalcDistance_L2_Symmetric) { float got = calc->calcDistance(x_blob, y_blob, dim); float expected = bruteForceL2Dist(x, y, dim); + auto dispatch = calc->getDistanceDispatch(DistanceMode::StoredToStored); EXPECT_NEAR(got, expected, 0.05f) << "Symmetric L2 distance mismatch"; + ASSERT_TRUE(dispatch.isValid()); + EXPECT_EQ(dispatch.stateless_func, sym_func); + EXPECT_EQ(dispatch.stateful_func, nullptr); + EXPECT_NEAR(dispatch(x_blob, y_blob, dim), expected, 0.05f); allocator->free_allocation(x_blob); allocator->free_allocation(y_blob); From 5070acba89c7f9801fb624c7c50b27f69dede569 Mon Sep 17 00:00:00 2001 From: Dor Forer Date: Thu, 23 Jul 2026 15:44:18 +0300 Subject: [PATCH 8/8] Make normalized distance context initialization explicit --- src/VecSim/spaces/computer/calculator.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/VecSim/spaces/computer/calculator.h b/src/VecSim/spaces/computer/calculator.h index e1d8a0d5c..7c41b4d22 100644 --- a/src/VecSim/spaces/computer/calculator.h +++ b/src/VecSim/spaces/computer/calculator.h @@ -211,8 +211,11 @@ class DistanceCalculatorWithNorm : public IndexCalculatorInterface { DistanceCalculatorWithNorm(std::shared_ptr allocator, spaces::dist_func_t asym_func, spaces::dist_func_t sym_func, float mean_sum_squares) - : IndexCalculatorInterface(allocator), - context_{sym_func, asym_func, mean_sum_squares} {} + : IndexCalculatorInterface(allocator), context_(WithNormDistanceContext{ + .stored_func = sym_func, + .query_func = asym_func, + .mean_sum_squares = mean_sum_squares, + }) {} // Symmetric: both v1 and v2 are stored SQ8-of-x' blobs. DistType calcDistance(const void *v1, const void *v2, size_t dim) const override {