Skip to content

Commit 18bca95

Browse files
committed
Micro benchmarks for sq8 tiered hnsw index
Including TopK/Range/Memory tests Also add a script to convert fp32/fp16 index files to sq8
1 parent 82ae0b9 commit 18bca95

19 files changed

Lines changed: 524 additions & 37 deletions

src/VecSim/algorithms/hnsw/hnsw.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ class HNSWIndex : public VecSimIndexAbstract<DataType, DistType>,
128128
#include "VecSim/algorithms/hnsw/hnsw_base_tests_friends.h"
129129

130130
#include "hnsw_serializer_declarations.h"
131+
132+
public:
133+
// Serialization-only fields for V5 format (SQ8 support)
134+
VecSimQuantType quantType = VecSimQuant_NONE;
135+
std::vector<float> serializedMeanVector; // Mean vector for SQ8 indices (empty if not SQ8)
136+
private:
131137
#endif
132138

133139
protected:

src/VecSim/algorithms/hnsw/hnsw_serializer.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
* Copyright (c) 2006-Present, Redis Ltd.
33
* All rights reserved.
4+
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
45
*
56
* Licensed under your choice of the Redis Source Available License 2.0
67
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
@@ -30,7 +31,7 @@ HNSWSerializer::EncodingVersion HNSWSerializer::ReadVersion(std::ifstream &input
3031
}
3132

3233
void HNSWSerializer::saveIndex(const std::string &location) {
33-
EncodingVersion version = EncodingVersion::V4;
34+
EncodingVersion version = getWriteVersion();
3435
std::ofstream output(location, std::ios::binary);
3536
writeBinaryPOD(output, version);
3637
saveIndexIMP(output);

src/VecSim/algorithms/hnsw/hnsw_serializer.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
* Copyright (c) 2006-Present, Redis Ltd.
33
* All rights reserved.
4+
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
45
*
56
* Licensed under your choice of the Redis Source Available License 2.0
67
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
@@ -22,6 +23,7 @@ class HNSWSerializer : public Serializer {
2223
DEPRECATED = 2, // Last deprecated version
2324
V3,
2425
V4,
26+
V5, // SQ8 quantization fields (quantType + mean vector)
2527
INVALID
2628
};
2729

@@ -36,6 +38,10 @@ class HNSWSerializer : public Serializer {
3638
protected:
3739
EncodingVersion m_version;
3840

41+
// Determines the version to write when saving. Override in derived classes
42+
// to write V5 for SQ8 indices.
43+
virtual EncodingVersion getWriteVersion() const { return EncodingVersion::V4; }
44+
3945
private:
4046
void saveIndexFields(std::ofstream &output) const = 0;
4147
};

src/VecSim/algorithms/hnsw/hnsw_serializer_declarations.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
* Copyright (c) 2006-Present, Redis Ltd.
33
* All rights reserved.
4+
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
45
*
56
* Licensed under your choice of the Redis Source Available License 2.0
67
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
@@ -25,6 +26,12 @@ virtual void saveIndexIMP(std::ofstream &output) override;
2526
// used by index factory to load nodes connections
2627
void restoreGraph(std::ifstream &input, HNSWSerializer::EncodingVersion version);
2728

29+
// Returns V5 if this is an SQ8 index, V4 otherwise.
30+
HNSWSerializer::EncodingVersion getWriteVersion() const override {
31+
return quantType != VecSimQuant_NONE ? HNSWSerializer::EncodingVersion::V5
32+
: HNSWSerializer::EncodingVersion::V4;
33+
}
34+
2835
private:
2936
// Functions for index saving.
3037
void saveIndexFields(std::ofstream &output) const override;

src/VecSim/algorithms/hnsw/hnsw_serializer_impl.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
* Copyright (c) 2006-Present, Redis Ltd.
33
* All rights reserved.
4+
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
45
*
56
* Licensed under your choice of the Redis Source Available License 2.0
67
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
@@ -253,6 +254,18 @@ void HNSWIndex<DataType, DistType>::saveIndexFields(std::ofstream &output) const
253254
writeBinaryPOD(output, this->maxElements); // This will be used to restore the index initial
254255
// capacity
255256

257+
// V5 fields: SQ8 quantization support (between factory params and index build params)
258+
if (this->quantType != VecSimQuant_NONE) {
259+
writeBinaryPOD(output, this->quantType);
260+
// Write mean vector (dim floats). Empty vector means no mean (zero-mean SQ8).
261+
bool hasMean = !this->serializedMeanVector.empty();
262+
writeBinaryPOD(output, hasMean);
263+
if (hasMean) {
264+
output.write(reinterpret_cast<const char *>(this->serializedMeanVector.data()),
265+
this->dim * sizeof(float));
266+
}
267+
}
268+
256269
// Save index build parameters
257270
writeBinaryPOD(output, this->M);
258271
writeBinaryPOD(output, this->M0);

src/VecSim/algorithms/hnsw/hnsw_tiered.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,12 @@ TieredHNSWIndex<DataType, DistType>::TieredHNSWIndex(HNSWIndex<DataType, DistTyp
715715
: std::min(tiered_index_params.specificParams.tieredHnswParams.swapJobThreshold,
716716
MAX_PENDING_SWAP_JOBS_THRESHOLD);
717717

718+
// For benchmark, we create tiered hnsw index from existing hnsw index
719+
// primaryIndexParams is nullptr and accumulation phase is skipped for sq8
720+
if (tiered_index_params.primaryIndexParams == nullptr) {
721+
return;
722+
}
723+
718724
// Initialize SQ accumulation phase if quantization is enabled.
719725
auto &hnswParams = tiered_index_params.primaryIndexParams->algoParams.hnswParams;
720726
if (hnswParams.quantType != VecSimQuant_NONE) {

src/VecSim/index_factories/hnsw_factory.cpp

Lines changed: 156 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ VecSimIndex *NewIndex_SQ8(const HNSWParams *hnswParams,
5151
auto asym_func =
5252
spaces::GetDistFunc<vecsim_types::sq8, float, DataType>(Metric, dim, &alignment);
5353

54+
HNSWIndex<DataType, float> *index = nullptr;
55+
5456
if (mean_ptr == nullptr) {
5557
// No mean vector: plain SQ8 quantization without mean centering.
5658
auto *pp = new (allocator) QuantPreprocessor<DataType, Metric>(allocator, dim);
@@ -65,32 +67,43 @@ VecSimIndex *NewIndex_SQ8(const HNSWParams *hnswParams,
6567
new (allocator) DistanceCalculatorCommon<float>(allocator, sym_func, asym_func);
6668

6769
IndexComponents<DataType, float> components{calc, container};
68-
return NewIndex_ChooseMultiOrSingle<DataType, float>(hnswParams, abstractInitParams,
69-
components);
70-
}
70+
index = NewIndex_ChooseMultiOrSingle<DataType, float>(hnswParams, abstractInitParams,
71+
components);
72+
} else {
73+
// With mean vector: mean-centered SQ8 quantization with norm correction.
74+
vecsim_stl::vector<float> mean_vec(dim, 0.0f, allocator);
75+
std::copy(mean_ptr, mean_ptr + dim, mean_vec.begin());
7176

72-
// With mean vector: mean-centered SQ8 quantization with norm correction.
73-
vecsim_stl::vector<float> mean_vec(dim, 0.0f, allocator);
74-
std::copy(mean_ptr, mean_ptr + dim, mean_vec.begin());
77+
float mean_sum_squares = 0.0f;
78+
for (float v : mean_vec) {
79+
mean_sum_squares += v * v;
80+
}
7581

76-
float mean_sum_squares = 0.0f;
77-
for (float v : mean_vec) {
78-
mean_sum_squares += v * v;
79-
}
82+
auto *pp =
83+
new (allocator) QuantPreprocessor<DataType, Metric, true>(allocator, dim, mean_vec);
84+
auto *container =
85+
new (allocator) MultiPreprocessorsContainer<DataType, 1>(allocator, alignment);
86+
int ret = container->addPreprocessor(pp);
87+
assert(ret == 0 && "SQ8 preprocessor was not added correctly");
88+
UNUSED(ret);
8089

81-
auto *pp = new (allocator) QuantPreprocessor<DataType, Metric, true>(allocator, dim, mean_vec);
82-
auto *container =
83-
new (allocator) MultiPreprocessorsContainer<DataType, 1>(allocator, alignment);
84-
int ret = container->addPreprocessor(pp);
85-
assert(ret == 0 && "SQ8 preprocessor was not added correctly");
86-
UNUSED(ret);
90+
auto *calc = new (allocator) DistanceCalculatorWithNorm<DataType, float, Metric>(
91+
allocator, asym_func, sym_func, mean_sum_squares);
8792

88-
auto *calc = new (allocator) DistanceCalculatorWithNorm<DataType, float, Metric>(
89-
allocator, asym_func, sym_func, mean_sum_squares);
93+
IndexComponents<DataType, float> components{calc, container};
94+
index = NewIndex_ChooseMultiOrSingle<DataType, float>(hnswParams, abstractInitParams,
95+
components);
96+
}
9097

91-
IndexComponents<DataType, float> components{calc, container};
92-
return NewIndex_ChooseMultiOrSingle<DataType, float>(hnswParams, abstractInitParams,
93-
components);
98+
#ifdef BUILD_TESTS
99+
// Store quantization metadata for serialization support.
100+
index->quantType = hnswParams->quantType;
101+
if (mean_ptr != nullptr) {
102+
index->serializedMeanVector.assign(mean_ptr, mean_ptr + dim);
103+
}
104+
#endif
105+
106+
return index;
94107
}
95108

96109
VecSimIndex *NewIndex(const VecSimParams *params, bool is_normalized) {
@@ -297,7 +310,8 @@ template <typename DataType, typename DistType = DataType>
297310
inline VecSimIndex *NewIndex_ChooseMultiOrSingle(std::ifstream &input, const HNSWParams *params,
298311
const AbstractIndexInitParams &abstractInitParams,
299312
IndexComponents<DataType, DistType> &components,
300-
HNSWSerializer::EncodingVersion version) {
313+
HNSWSerializer::EncodingVersion version,
314+
const std::vector<float> &meanVector = {}) {
301315
HNSWIndex<DataType, DistType> *index = nullptr;
302316
// check if single and call the ctor that loads index information from file.
303317
if (params->multi)
@@ -309,17 +323,79 @@ inline VecSimIndex *NewIndex_ChooseMultiOrSingle(std::ifstream &input, const HNS
309323

310324
index->restoreGraph(input, version);
311325

326+
#ifdef BUILD_TESTS
327+
// Store quantization metadata for re-serialization.
328+
index->quantType = params->quantType;
329+
if (!meanVector.empty()) {
330+
index->serializedMeanVector = meanVector;
331+
}
332+
#endif
333+
312334
return index;
313335
}
314336

315-
// Initialize @params from file for V3
316-
static void InitializeParams(std::ifstream &source_params, HNSWParams &params) {
337+
// Initialize @params from file for V3+. For V5+ also reads quantType and mean vector.
338+
static void InitializeParams(std::ifstream &source_params, HNSWParams &params,
339+
HNSWSerializer::EncodingVersion version,
340+
std::vector<float> &meanVector) {
317341
Serializer::readBinaryPOD(source_params, params.dim);
318342
Serializer::readBinaryPOD(source_params, params.type);
319343
Serializer::readBinaryPOD(source_params, params.metric);
320344
Serializer::readBinaryPOD(source_params, params.blockSize);
321345
Serializer::readBinaryPOD(source_params, params.multi);
322346
Serializer::readBinaryPOD(source_params, params.initialCapacity);
347+
348+
// V5: read quantization fields
349+
if (version >= HNSWSerializer::EncodingVersion::V5) {
350+
Serializer::readBinaryPOD(source_params, params.quantType);
351+
if (params.quantType == VecSimQuant_SQ8) {
352+
bool hasMean = false;
353+
Serializer::readBinaryPOD(source_params, hasMean);
354+
if (hasMean) {
355+
meanVector.resize(params.dim);
356+
source_params.read(reinterpret_cast<char *>(meanVector.data()),
357+
params.dim * sizeof(float));
358+
}
359+
}
360+
}
361+
}
362+
363+
// Helper to create SQ8 IndexComponents for loading from file.
364+
template <typename DataType, VecSimMetric Metric>
365+
IndexComponents<DataType, float>
366+
CreateSQ8Components(const std::shared_ptr<VecSimAllocator> &allocator, size_t dim,
367+
const float *mean_ptr) {
368+
unsigned char alignment = 0;
369+
370+
auto sym_func = spaces::GetDistFunc<vecsim_types::sq8, float>(Metric, dim, &alignment);
371+
auto asym_func =
372+
spaces::GetDistFunc<vecsim_types::sq8, float, DataType>(Metric, dim, &alignment);
373+
374+
if (mean_ptr == nullptr) {
375+
auto *pp = new (allocator) QuantPreprocessor<DataType, Metric>(allocator, dim);
376+
auto *container =
377+
new (allocator) MultiPreprocessorsContainer<DataType, 1>(allocator, alignment);
378+
container->addPreprocessor(pp);
379+
auto *calc =
380+
new (allocator) DistanceCalculatorCommon<float>(allocator, sym_func, asym_func);
381+
return IndexComponents<DataType, float>{calc, container};
382+
}
383+
384+
vecsim_stl::vector<float> mean_vec(dim, 0.0f, allocator);
385+
std::copy(mean_ptr, mean_ptr + dim, mean_vec.begin());
386+
387+
float mean_sum_squares = 0.0f;
388+
for (float v : mean_vec) {
389+
mean_sum_squares += v * v;
390+
}
391+
392+
auto *pp = new (allocator) QuantPreprocessor<DataType, Metric, true>(allocator, dim, mean_vec);
393+
auto *container =
394+
new (allocator) MultiPreprocessorsContainer<DataType, 1>(allocator, alignment);
395+
container->addPreprocessor(pp);
396+
auto *calc = new (allocator) DistanceCalculatorWithNorm<DataType, float, Metric>(
397+
allocator, asym_func, sym_func, mean_sum_squares);
398+
return IndexComponents<DataType, float>{calc, container};
323399
}
324400

325401
VecSimIndex *NewIndex(const std::string &location, bool is_normalized) {
@@ -344,14 +420,64 @@ VecSimIndex *NewIndex(const std::string &location, bool is_normalized) {
344420
bad_name);
345421
}
346422

347-
HNSWParams params;
348-
InitializeParams(input, params);
349-
350-
VecSimParams vecsimParams = {.algo = VecSimAlgo_HNSWLIB,
351-
.algoParams = {.hnswParams = HNSWParams{params}}};
423+
HNSWParams params = {};
424+
std::vector<float> meanVector;
425+
InitializeParams(input, params, version, meanVector);
352426

353427
AbstractIndexInitParams abstractInitParams =
354-
VecSimFactory::NewAbstractInitParams(&params, vecsimParams.logCtx, is_normalized);
428+
VecSimFactory::NewAbstractInitParams(&params, nullptr, is_normalized);
429+
430+
// SQ8 quantized index path
431+
if (params.quantType == VecSimQuant_SQ8) {
432+
const float *mean_ptr = meanVector.empty() ? nullptr : meanVector.data();
433+
VecSimMetric metric = params.metric;
434+
if (is_normalized && metric == VecSimMetric_Cosine) {
435+
metric = VecSimMetric_IP;
436+
}
437+
438+
// Override blob sizes for SQ8 storage layout.
439+
size_t dim = params.dim;
440+
if (metric == VecSimMetric_L2) {
441+
abstractInitParams.storedDataSize =
442+
dim + (mean_ptr ? sq8::storage_metadata_count_with_norm<VecSimMetric_L2>()
443+
: sq8::storage_metadata_count<VecSimMetric_L2>()) *
444+
sizeof(float);
445+
} else {
446+
abstractInitParams.storedDataSize =
447+
dim + (mean_ptr ? sq8::storage_metadata_count_with_norm<VecSimMetric_IP>()
448+
: sq8::storage_metadata_count<VecSimMetric_IP>()) *
449+
sizeof(float);
450+
}
451+
452+
if (params.type == VecSimType_FLOAT32) {
453+
abstractInitParams.inputBlobSize = dim * sizeof(float);
454+
if (metric == VecSimMetric_L2) {
455+
auto components = CreateSQ8Components<float, VecSimMetric_L2>(
456+
abstractInitParams.allocator, dim, mean_ptr);
457+
return NewIndex_ChooseMultiOrSingle<float>(input, &params, abstractInitParams,
458+
components, version, meanVector);
459+
} else {
460+
auto components = CreateSQ8Components<float, VecSimMetric_IP>(
461+
abstractInitParams.allocator, dim, mean_ptr);
462+
return NewIndex_ChooseMultiOrSingle<float>(input, &params, abstractInitParams,
463+
components, version, meanVector);
464+
}
465+
} else if (params.type == VecSimType_FLOAT16) {
466+
abstractInitParams.inputBlobSize = dim * sizeof(float16);
467+
if (metric == VecSimMetric_L2) {
468+
auto components = CreateSQ8Components<float16, VecSimMetric_L2>(
469+
abstractInitParams.allocator, dim, mean_ptr);
470+
return NewIndex_ChooseMultiOrSingle<float16, float>(
471+
input, &params, abstractInitParams, components, version, meanVector);
472+
} else {
473+
auto components = CreateSQ8Components<float16, VecSimMetric_IP>(
474+
abstractInitParams.allocator, dim, mean_ptr);
475+
return NewIndex_ChooseMultiOrSingle<float16, float>(
476+
input, &params, abstractInitParams, components, version, meanVector);
477+
}
478+
}
479+
}
480+
355481
if (params.type == VecSimType_FLOAT32) {
356482
IndexComponents<float, float> indexComponents = CreateIndexComponents<float, float>(
357483
abstractInitParams.allocator, params.metric, abstractInitParams.dim, is_normalized);

src/VecSim/index_factories/tiered_factory.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
* Copyright (c) 2006-Present, Redis Ltd.
33
* All rights reserved.
4+
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
45
*
56
* Licensed under your choice of the Redis Source Available License 2.0
67
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
@@ -42,7 +43,6 @@ VecSimIndex *NewIndex(const TieredIndexParams *params, HNSWIndex<DataType, DistT
4243
AbstractIndexInitParams abstractInitParams =
4344
VecSimFactory::NewAbstractInitParams(&bf_params, nullptr, false);
4445
assert(hnsw_index->getInputBlobSize() == abstractInitParams.storedDataSize);
45-
assert(hnsw_index->getStoredDataSize() == abstractInitParams.storedDataSize);
4646
auto frontendIndex = static_cast<BruteForceIndex<DataType, DistType> *>(
4747
BruteForceFactory::NewIndex(&bf_params, abstractInitParams, false));
4848

0 commit comments

Comments
 (0)