Skip to content

Commit 0fcefb6

Browse files
authored
[MOD-17189] Share SQ8 training state for RAM and disk indexes (#1078)
* [MOD-17189] Share SQ8 training state and threshold * Share SQ8 mean installation between index backends * Clarify SQ8 mean helper name and remove redundant assertions * Wait for in-flight jobs before stopping async test workers
1 parent 1db74be commit 0fcefb6

7 files changed

Lines changed: 199 additions & 58 deletions

File tree

‎src/VecSim/algorithms/hnsw/hnsw.h‎

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "VecSim/vec_sim_common.h"
2222
#include "VecSim/vec_sim_index.h"
2323
#include "VecSim/spaces/computer/preprocessors.h"
24+
#include "VecSim/quantization/sq8_mean.h"
2425
#include <span>
2526
#include "VecSim/tombstone_interface.h"
2627

@@ -94,28 +95,15 @@ class HNSWIndex : public VecSimIndexAbstract<DataType, DistType>,
9495
private:
9596
template <VecSimMetric Metric>
9697
void setSQ8Mean(std::span<const float> mean) noexcept {
97-
// SQ8-specific component access. Introduce a trainer interface when a second quantizer
98-
// needs training, replacing these concrete component casts.
99-
// Assumes exactly one preprocessor: SQ8 WithNorm in slot 0. Tiered cosine inputs
100-
// are normalized by the frontend; a pipeline with a preceding normalizer is unsupported.
101-
auto *container = static_cast<MultiPreprocessorsContainer<DataType, 1> *>(
102-
this->getPreprocessorsContainer());
103-
auto *preprocessor = dynamic_cast<QuantPreprocessor<DataType, Metric, true> *>(
104-
container->getPreprocessors()[0]);
105-
auto *calculator = dynamic_cast<DistanceCalculatorWithNorm<DataType, float, Metric> *>(
106-
this->getIndexCalculator());
107-
assert(preprocessor && calculator);
108-
preprocessor->setMean(mean);
109-
calculator->setMeanSumSquares(mean);
98+
applySQ8Mean<DataType, Metric>(*this->getPreprocessorsContainer(),
99+
*this->getIndexCalculator(), mean);
110100
}
111101

112102
public:
113103
// Writer-only operation: install the mean once, before any stored vector, under the
114104
// exclusive tiered main lock and before submitting insertion jobs. The backend must have
115105
// its final SQ8 WithNorm components; the caller owns and enforces the one-time transition.
116-
// SQ8-specific: assumes a single SQ8 preprocessor in slot 0, with cosine normalization
117-
// performed by the frontend. Introduce a trainer interface when a second quantizer needs
118-
// training. A quantizer that learns storedDataSize from data needs deferred backend creation.
106+
// A quantizer that learns storedDataSize from data needs deferred backend creation.
119107
void setQuantizationMean(std::span<const float> mean) noexcept {
120108
assert(this->isQuantized && curElementCount == 0 && mean.size() == this->dim);
121109
if constexpr (QuantInput<DataType> && std::is_same_v<DistType, float>) {

‎src/VecSim/algorithms/hnsw/hnsw_tiered.h‎

Lines changed: 8 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
#include <utility>
1919

2020
#include "VecSim/algorithms/brute_force/brute_force_single.h"
21-
#include "VecSim/spaces/computer/preprocessors.h"
21+
#include "VecSim/quantization/sq8_training_state.h"
2222
#include "VecSim/vec_sim_tiered_index.h"
2323
#include "hnsw.h"
2424

@@ -51,7 +51,6 @@ struct HNSWSwapJob : public VecsimBaseObject {
5151

5252
static const size_t DEFAULT_PENDING_SWAP_JOBS_THRESHOLD = DEFAULT_BLOCK_SIZE;
5353
static const size_t MAX_PENDING_SWAP_JOBS_THRESHOLD = 100000;
54-
static const size_t MAX_QUANT_NORMALIZATION_SET_SIZE = 100 * DEFAULT_BLOCK_SIZE;
5554

5655
/**
5756
* Definition of a job that repairs a certain node's connection in HNSW Index after delete
@@ -106,17 +105,7 @@ class TieredHNSWIndex : public VecSimTieredIndex<DataType, DistType> {
106105

107106
// Writer-owned state exists only while SQ8 vectors are accumulating in FLAT.
108107
// Reads and maintenance always use the real, initially empty backend.
109-
struct SQAccumulationState {
110-
size_t normalizationSetSize;
111-
vecsim_stl::vector<double> runningSumVec;
112-
113-
SQAccumulationState(const std::shared_ptr<VecSimAllocator> &allocator, size_t dim,
114-
size_t normalizationSetSize)
115-
: normalizationSetSize(
116-
std::min(normalizationSetSize, MAX_QUANT_NORMALIZATION_SET_SIZE)),
117-
runningSumVec(dim, 0.0, allocator) {}
118-
};
119-
std::optional<SQAccumulationState> sqAccumulationState;
108+
std::optional<SQ8TrainingState> sqAccumulationState;
120109

121110
#ifdef BUILD_TESTS
122111
std::function<void()> beforeQuantizationFinalization;
@@ -126,25 +115,16 @@ class TieredHNSWIndex : public VecSimTieredIndex<DataType, DistType> {
126115

127116
void addToSum(std::span<const DataType> vector) {
128117
if constexpr (QuantInput<DataType>) {
129-
auto &sum = sqAccumulationState->runningSumVec;
130-
assert(vector.size() == sum.size());
131-
for (size_t i = 0; i < vector.size(); ++i) {
132-
sum[i] += to_fp32(vector[i]);
133-
}
118+
sqAccumulationState->add(vector);
134119
}
135120
}
136121

137122
void subtractFromSum(std::span<const DataType> vector) {
138123
if constexpr (QuantInput<DataType>) {
139-
auto &sum = sqAccumulationState->runningSumVec;
140-
assert(vector.size() == sum.size());
141-
for (size_t i = 0; i < vector.size(); ++i) {
142-
sum[i] -= to_fp32(vector[i]);
143-
}
124+
sqAccumulationState->subtract(vector);
144125
}
145126
}
146127

147-
[[nodiscard]] vecsim_stl::vector<float> calculateQuantizationMean(size_t count) const;
148128
int addVectorDuringAccumulation(const void *blob, labelType label);
149129
int deleteVectorDuringAccumulation(labelType label);
150130
void finalizeQuantizationAndSubmitJobs(size_t count);
@@ -814,7 +794,8 @@ int TieredHNSWIndex<DataType, DistType>::addVectorDuringAccumulation(const void
814794
this->registerInsertJob(label, job);
815795
}
816796
const size_t count = this->frontendIndex->indexSize();
817-
const bool should_finalize = count >= sqAccumulationState->normalizationSetSize;
797+
assert(sqAccumulationState->getCount() == count);
798+
const bool should_finalize = sqAccumulationState->shouldFinalize();
818799
return std::tuple{result, count, should_finalize};
819800
}();
820801

@@ -842,25 +823,13 @@ int TieredHNSWIndex<DataType, DistType>::deleteVectorDuringAccumulation(labelTyp
842823
for (const auto &[new_id, previous] : updated_ids) {
843824
this->updateInsertJobInternalId(previous.first, new_id, previous.second);
844825
}
826+
assert(sqAccumulationState->getCount() == this->frontendIndex->indexSize());
845827
return removed;
846828
}
847829

848-
template <typename DataType, typename DistType>
849-
vecsim_stl::vector<float>
850-
TieredHNSWIndex<DataType, DistType>::calculateQuantizationMean(size_t count) const {
851-
assert(sqAccumulationState);
852-
assert(count >= sqAccumulationState->normalizationSetSize);
853-
const auto &sum = sqAccumulationState->runningSumVec;
854-
vecsim_stl::vector<float> mean(sum.size(), this->allocator);
855-
for (size_t i = 0; i < sum.size(); ++i) {
856-
mean[i] = static_cast<float>(sum[i] / static_cast<double>(count));
857-
}
858-
return mean;
859-
}
860-
861830
template <typename DataType, typename DistType>
862831
void TieredHNSWIndex<DataType, DistType>::finalizeQuantizationAndSubmitJobs(size_t count) {
863-
const auto mean = calculateQuantizationMean(count);
832+
const auto mean = sqAccumulationState->calculateMean();
864833
// Allocate the snapshot before finishing accumulation. Synchronous execution removes jobs
865834
// from labelToInsertJobs, so iterating the map while dispatching would invalidate iterators.
866835
vecsim_stl::vector<AsyncJob *> jobs(this->allocator);

‎src/VecSim/quantization/sq8_mean.h‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright (c) 2006-Present, Redis Ltd.
3+
* All rights reserved.
4+
*
5+
* Licensed under your choice of the Redis Source Available License 2.0
6+
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
7+
* GNU Affero General Public License v3 (AGPLv3).
8+
*/
9+
#pragma once
10+
11+
#include <cassert>
12+
#include <span>
13+
14+
#include "VecSim/spaces/computer/calculator.h"
15+
#include "VecSim/spaces/computer/preprocessor_container.h"
16+
17+
// Install before storing vectors, with queries excluded by the caller. Requires exactly one
18+
// SQ8 WithNorm preprocessor in slot 0; cosine normalization must be performed by the frontend.
19+
template <QuantInput DataType, VecSimMetric Metric>
20+
void applySQ8Mean(PreprocessorsContainerAbstract &preprocessors,
21+
IndexCalculatorInterface<float> &index_calculator,
22+
std::span<const float> mean) noexcept {
23+
auto &container = static_cast<MultiPreprocessorsContainer<DataType, 1> &>(preprocessors);
24+
auto *preprocessor =
25+
dynamic_cast<QuantPreprocessor<DataType, Metric, true> *>(container.getPreprocessors()[0]);
26+
auto *calculator =
27+
dynamic_cast<DistanceCalculatorWithNorm<DataType, float, Metric> *>(&index_calculator);
28+
assert(preprocessor && calculator);
29+
preprocessor->setMean(mean);
30+
calculator->setMeanSumSquares(mean);
31+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright (c) 2006-Present, Redis Ltd.
3+
* All rights reserved.
4+
*
5+
* Licensed under your choice of the Redis Source Available License 2.0
6+
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
7+
* GNU Affero General Public License v3 (AGPLv3).
8+
*/
9+
#pragma once
10+
11+
#include <algorithm>
12+
#include <cassert>
13+
#include <span>
14+
15+
#include "VecSim/spaces/computer/preprocessors.h"
16+
#include "VecSim/utils/vecsim_stl.h"
17+
18+
static const size_t MAX_QUANT_NORMALIZATION_SET_SIZE = 100 * DEFAULT_BLOCK_SIZE;
19+
20+
/** Writer-owned SQ8 training state. Inputs must be the stored, preprocessed vectors.
21+
* Callers synchronize mutations and subtract a vector before overwriting or deleting it.
22+
*/
23+
class SQ8TrainingState {
24+
size_t normalizationSetSize;
25+
vecsim_stl::vector<double> runningSumVec;
26+
size_t count{0};
27+
28+
public:
29+
SQ8TrainingState(const std::shared_ptr<VecSimAllocator> &allocator, size_t dim,
30+
size_t normalization_set_size)
31+
: normalizationSetSize(std::min(normalization_set_size, MAX_QUANT_NORMALIZATION_SET_SIZE)),
32+
runningSumVec(dim, 0.0, allocator) {}
33+
34+
template <QuantInput T>
35+
void add(std::span<const T> vector) {
36+
assert(vector.size() == runningSumVec.size());
37+
for (size_t i = 0; i < vector.size(); ++i) {
38+
runningSumVec[i] += to_fp32(vector[i]);
39+
}
40+
++count;
41+
}
42+
43+
template <QuantInput T>
44+
void subtract(std::span<const T> vector) {
45+
assert(count > 0);
46+
assert(vector.size() == runningSumVec.size());
47+
for (size_t i = 0; i < vector.size(); ++i) {
48+
runningSumVec[i] -= to_fp32(vector[i]);
49+
}
50+
--count;
51+
}
52+
53+
bool shouldFinalize() const { return count > 0 && count >= normalizationSetSize; }
54+
55+
/// Requires at least one live training vector.
56+
[[nodiscard]] vecsim_stl::vector<float> calculateMean() const {
57+
assert(count > 0);
58+
vecsim_stl::vector<float> mean(runningSumVec.size(), runningSumVec.getAllocator());
59+
for (size_t i = 0; i < runningSumVec.size(); ++i) {
60+
mean[i] = static_cast<float>(runningSumVec[i] / static_cast<double>(count));
61+
}
62+
return mean;
63+
}
64+
65+
size_t getCount() const { return count; }
66+
size_t getNormalizationSetSize() const { return normalizationSetSize; }
67+
const vecsim_stl::vector<double> &getRunningSum() const { return runningSumVec; }
68+
};

‎src/VecSim/vec_sim_common.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,8 @@ typedef struct {
254254
// A struct that contains HNSW Disk tiered index specific params.
255255
// Consider removing and use TieredHNSWParams instead if they both share swapJobThreshold
256256
typedef struct {
257-
char _placeholder; // Reserved for future fields and avoid compiler errors
257+
size_t QuantNormalizationSetSize; // Training threshold, with the semantics of
258+
// TieredHNSWParams::QuantNormalizationSetSize.
258259
} TieredHNSWDiskParams;
259260

260261
// A struct that contains SVS tiered index specific params.

‎tests/unit/test_components.cpp‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <cmath>
1313
#include "gtest/gtest.h"
1414
#include "VecSim/vec_sim.h"
15+
#include "VecSim/quantization/sq8_training_state.h"
1516
#include "VecSim/spaces/computer/preprocessor_container.h"
1617
#include "VecSim/spaces/computer/preprocessors.h"
1718
#include "VecSim/spaces/computer/calculator.h"
@@ -2644,3 +2645,85 @@ TEST(DistanceCalculatorWithNormTest, OddDimension_MatchesBruteForce) {
26442645
delete calc;
26452646
}
26462647
}
2648+
2649+
template <typename T>
2650+
class SQ8TrainingStateTest : public ::testing::Test {};
2651+
2652+
using SQ8TrainingInputTypes = ::testing::Types<float, vecsim_types::float16>;
2653+
TYPED_TEST_SUITE(SQ8TrainingStateTest, SQ8TrainingInputTypes);
2654+
2655+
TYPED_TEST(SQ8TrainingStateTest, SumMeanAndRemoval) {
2656+
auto allocator = VecSimAllocator::newVecsimAllocator();
2657+
SQ8TrainingState state(allocator, 2, 2);
2658+
const TypeParam first[] = {from_fp32<TypeParam>(1.5f), from_fp32<TypeParam>(-2.0f)};
2659+
const TypeParam second[] = {from_fp32<TypeParam>(-0.5f), from_fp32<TypeParam>(4.0f)};
2660+
EXPECT_EQ(state.getCount(), 0);
2661+
EXPECT_FALSE(state.shouldFinalize());
2662+
state.add(std::span<const TypeParam>(first));
2663+
EXPECT_EQ(state.getCount(), 1);
2664+
EXPECT_FALSE(state.shouldFinalize());
2665+
state.add(std::span<const TypeParam>(second));
2666+
EXPECT_EQ(state.getCount(), 2);
2667+
EXPECT_TRUE(state.shouldFinalize());
2668+
EXPECT_DOUBLE_EQ(state.getRunningSum()[0], 1.0);
2669+
EXPECT_DOUBLE_EQ(state.getRunningSum()[1], 2.0);
2670+
const auto mean = state.calculateMean();
2671+
EXPECT_EQ(mean.getAllocator(), allocator);
2672+
EXPECT_FLOAT_EQ(mean[0], 0.5f);
2673+
EXPECT_FLOAT_EQ(mean[1], 1.0f);
2674+
2675+
state.subtract(std::span<const TypeParam>(first));
2676+
EXPECT_EQ(state.getCount(), 1);
2677+
EXPECT_FALSE(state.shouldFinalize());
2678+
const auto remaining_mean = state.calculateMean();
2679+
EXPECT_FLOAT_EQ(remaining_mean[0], -0.5f);
2680+
EXPECT_FLOAT_EQ(remaining_mean[1], 4.0f);
2681+
state.subtract(std::span<const TypeParam>(second));
2682+
EXPECT_EQ(state.getCount(), 0);
2683+
EXPECT_FALSE(state.shouldFinalize());
2684+
EXPECT_DOUBLE_EQ(state.getRunningSum()[0], 0.0);
2685+
EXPECT_DOUBLE_EQ(state.getRunningSum()[1], 0.0);
2686+
}
2687+
2688+
TYPED_TEST(SQ8TrainingStateTest, ReplacementKeepsLiveCount) {
2689+
auto allocator = VecSimAllocator::newVecsimAllocator();
2690+
SQ8TrainingState state(allocator, 1, 2);
2691+
const TypeParam original[] = {from_fp32<TypeParam>(2.0f)};
2692+
const TypeParam replacement[] = {from_fp32<TypeParam>(8.0f)};
2693+
state.add(std::span<const TypeParam>(original));
2694+
state.subtract(std::span<const TypeParam>(original));
2695+
state.add(std::span<const TypeParam>(replacement));
2696+
EXPECT_EQ(state.getCount(), 1);
2697+
EXPECT_FALSE(state.shouldFinalize());
2698+
EXPECT_DOUBLE_EQ(state.getRunningSum()[0], 8.0);
2699+
EXPECT_FLOAT_EQ(state.calculateMean()[0], 8.0f);
2700+
}
2701+
2702+
TEST(SQ8TrainingStateTest, ThresholdIsCapped) {
2703+
auto allocator = VecSimAllocator::newVecsimAllocator();
2704+
SQ8TrainingState state(allocator, 1, std::numeric_limits<size_t>::max());
2705+
EXPECT_EQ(state.getNormalizationSetSize(), MAX_QUANT_NORMALIZATION_SET_SIZE);
2706+
const float vector[] = {1.0f};
2707+
for (size_t i = 0; i < MAX_QUANT_NORMALIZATION_SET_SIZE - 1; ++i) {
2708+
state.add(std::span<const float>(vector));
2709+
}
2710+
EXPECT_FALSE(state.shouldFinalize());
2711+
state.add(std::span<const float>(vector));
2712+
EXPECT_TRUE(state.shouldFinalize());
2713+
EXPECT_EQ(state.getCount(), MAX_QUANT_NORMALIZATION_SET_SIZE);
2714+
EXPECT_FLOAT_EQ(state.calculateMean()[0], 1.0f);
2715+
}
2716+
2717+
TEST(SQ8TrainingStateTest, AccumulatesInFP64) {
2718+
auto allocator = VecSimAllocator::newVecsimAllocator();
2719+
SQ8TrainingState state(allocator, 1, 3);
2720+
const float large[] = {16777216.0f};
2721+
const float small[] = {1.0f};
2722+
state.add(std::span<const float>(large));
2723+
state.add(std::span<const float>(small));
2724+
EXPECT_DOUBLE_EQ(state.getRunningSum()[0], 16777217.0);
2725+
state.subtract(std::span<const float>(large));
2726+
EXPECT_EQ(state.getCount(), 1);
2727+
EXPECT_DOUBLE_EQ(state.getRunningSum()[0], 1.0);
2728+
EXPECT_FLOAT_EQ(state.calculateMean()[0], 1.0f);
2729+
}

‎tests/unit/test_hnsw_tiered.cpp‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3787,6 +3787,7 @@ TYPED_TEST(HNSWTieredIndexTest, bufferLimitAsync) {
37873787
/*do nothing*/
37883788
}
37893789
}
3790+
mock_thread_pool.thread_pool_wait();
37903791
mock_thread_pool.thread_pool_join();
37913792
// Multi-value appends on the second run. Single-value overwrites: an overwrite that goes
37923793
// through the flat buffer reuses its old id, while one inserted straight into HNSW (the buffer
@@ -6012,11 +6013,11 @@ class HNSWTieredIndexTestSQ8 : public ::testing::Test {
60126013
}
60136014

60146015
const vecsim_stl::vector<double> &getRunningSumVec(TieredHNSWIndex<data_t, dist_t> *idx) {
6015-
return idx->sqAccumulationState->runningSumVec;
6016+
return idx->sqAccumulationState->getRunningSum();
60166017
}
60176018

60186019
size_t getNormalizationSetSize(TieredHNSWIndex<data_t, dist_t> *idx) {
6019-
return idx->sqAccumulationState->normalizationSetSize;
6020+
return idx->sqAccumulationState->getNormalizationSetSize();
60206021
}
60216022

60226023
// Compare the actual cached dispatches and preprocessing with a backend constructed with

0 commit comments

Comments
 (0)