From ba90104d4e9129a146390266d69eac64d415bbb2 Mon Sep 17 00:00:00 2001 From: ethanglaser Date: Mon, 27 Jul 2026 17:32:51 -0700 Subject: [PATCH 1/4] remove duplicated MemoryBreakdown blocks merge conflicts --- include/svs/index/vamana/dynamic_index.h | 26 ---------------------- include/svs/index/vamana/index.h | 23 ------------------- include/svs/orchestrators/dynamic_vamana.h | 5 ----- include/svs/orchestrators/vamana.h | 11 --------- 4 files changed, 65 deletions(-) diff --git a/include/svs/index/vamana/dynamic_index.h b/include/svs/index/vamana/dynamic_index.h index 4c5e5932..90696a46 100644 --- a/include/svs/index/vamana/dynamic_index.h +++ b/include/svs/index/vamana/dynamic_index.h @@ -321,32 +321,6 @@ class MutableVamanaIndex { /// @brief Get the ``graph_max_degree`` used while mutating the graph. size_t get_graph_max_degree() const { return graph_.max_degree(); } - /// @brief Return the bytes allocated by each index component. - /// - /// Reports the capacity-based bytes reserved by the graph adjacency lists, the vector - /// data, and the dynamic metadata (per-slot status, entry-point list, and the - /// external/internal ID translation maps). Capacity-based accounting includes the - /// block over-allocation so integrators can report the true memory footprint. - MemoryBreakdown get_memory_breakdown() const { - MemoryBreakdown usage{}; - usage.graph_bytes = svs::data::detail::dataset_allocated_bytes(graph_.get_data()); - usage.data_bytes = svs::data::detail::dataset_allocated_bytes(data_); - - size_t metadata_bytes = status_.capacity() * sizeof(SlotMetadata); - metadata_bytes += - entry_point_.capacity() * sizeof(typename entry_point_type::value_type); - // The IDTranslator holds two tsl::robin_map instances (external->internal and - // internal->external), neither of which exposes its allocated byte count. We - // approximate the storage as the id pair held in each of the two directions. This - // ignores the maps' load-factor slack and control bytes, so it is an estimate of - // the hash-map overhead that is accurate to within a few percent. - metadata_bytes += 2 * translator_.size() * - (sizeof(IDTranslator::external_id_type) + - sizeof(IDTranslator::internal_id_type)); - usage.metadata_bytes = metadata_bytes; - return usage; - } - /// @brief Get the max candidate pool size used while mutating the graph. size_t get_max_candidates() const { return max_candidates_; } /// @brief Set the max candidate pool size to be used while mutating the graph. diff --git a/include/svs/index/vamana/index.h b/include/svs/index/vamana/index.h index 9ca310d2..d569f0d5 100644 --- a/include/svs/index/vamana/index.h +++ b/include/svs/index/vamana/index.h @@ -177,14 +177,6 @@ struct VamanaIndexParameters { operator==(const VamanaIndexParameters&, const VamanaIndexParameters&) = default; }; -struct MemoryBreakdown { - size_t graph_bytes = 0; - size_t data_bytes = 0; - size_t metadata_bytes = 0; - - size_t total() const { return graph_bytes + data_bytes + metadata_bytes; } -}; - /// /// @brief Memory breakdown for Vamana index. /// @@ -778,21 +770,6 @@ class VamanaIndex { /// @brief Get the ``graph_max_degree`` that was used for graph construction. size_t get_graph_max_degree() const { return graph_.max_degree(); } - /// @brief Return the bytes allocated by each index component. - /// - /// Reports the capacity-based bytes reserved by the graph adjacency lists, the vector - /// data, and the entry-point list (the static index has no slot-status or - /// ID-translation metadata). Capacity-based accounting includes the block - /// over-allocation so integrators can report the true memory footprint. - MemoryBreakdown get_memory_breakdown() const { - MemoryBreakdown usage{}; - usage.graph_bytes = svs::data::detail::dataset_allocated_bytes(graph_.get_data()); - usage.data_bytes = svs::data::detail::dataset_allocated_bytes(data_); - usage.metadata_bytes = - entry_point_.capacity() * sizeof(typename entry_point_type::value_type); - return usage; - } - /// @brief Get the max candidate pool size that was used for graph construction. size_t get_max_candidates() const { return build_parameters_.max_candidate_pool_size; } /// @brief Set the max candidate pool size to be used for graph construction. diff --git a/include/svs/orchestrators/dynamic_vamana.h b/include/svs/orchestrators/dynamic_vamana.h index b0806133..191d8114 100644 --- a/include/svs/orchestrators/dynamic_vamana.h +++ b/include/svs/orchestrators/dynamic_vamana.h @@ -198,11 +198,6 @@ class DynamicVamana : public manager::IndexManager { /// @copydoc svs::index::vamana::MutableVamanaIndex::get_graph_max_degree size_t get_graph_max_degree() const { return impl_->get_graph_max_degree(); } - /// @copydoc svs::index::vamana::MutableVamanaIndex::get_memory_breakdown - svs::index::vamana::MemoryBreakdown get_memory_breakdown() const { - return impl_->get_memory_breakdown(); - } - /// @copydoc svs::index::vamana::MutableVamanaIndex::set_construction_window_size size_t get_construction_window_size() const { return impl_->get_construction_window_size(); diff --git a/include/svs/orchestrators/vamana.h b/include/svs/orchestrators/vamana.h index 67ce9a3d..3b659219 100644 --- a/include/svs/orchestrators/vamana.h +++ b/include/svs/orchestrators/vamana.h @@ -50,8 +50,6 @@ class VamanaInterface { virtual size_t get_graph_max_degree() const = 0; - virtual svs::index::vamana::MemoryBreakdown get_memory_breakdown() const = 0; - virtual void set_construction_window_size(size_t window_size) = 0; virtual size_t get_construction_window_size() const = 0; @@ -132,10 +130,6 @@ class VamanaImpl : public manager::ManagerImpl { size_t get_graph_max_degree() const override { return impl().get_graph_max_degree(); } - svs::index::vamana::MemoryBreakdown get_memory_breakdown() const override { - return impl().get_memory_breakdown(); - } - void set_construction_window_size(size_t window_size) override { impl().set_construction_window_size(window_size); } @@ -334,11 +328,6 @@ class Vamana : public manager::IndexManager { /// @copydoc svs::index::vamana::VamanaIndex::get_graph_max_degree size_t get_graph_max_degree() const { return impl_->get_graph_max_degree(); } - /// @copydoc svs::index::vamana::VamanaIndex::get_memory_breakdown - svs::index::vamana::MemoryBreakdown get_memory_breakdown() const { - return impl_->get_memory_breakdown(); - } - /// @copydoc svs::index::vamana::VamanaIndex::set_construction_window_size size_t get_construction_window_size() const { return impl_->get_construction_window_size(); From 036510f2a4fd1391a8a7f09d1fcacb45ddb1e8ba Mon Sep 17 00:00:00 2001 From: ethanglaser Date: Mon, 27 Jul 2026 17:33:30 -0700 Subject: [PATCH 2/4] leanvec ood C API exposure --- bindings/c/CMakeLists.txt | 1 + bindings/c/include/svs/c_api/svs_c.h | 40 +++++++++ bindings/c/src/data_builder/leanvec.hpp | 19 ++++- bindings/c/src/leanvec_training_data.hpp | 94 +++++++++++++++++++++ bindings/c/src/storage.hpp | 8 ++ bindings/c/src/svs_c.cpp | 98 ++++++++++++++++++++++ bindings/c/tests/c_api_index.cpp | 101 +++++++++++++++++++++++ 7 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 bindings/c/src/leanvec_training_data.hpp diff --git a/bindings/c/CMakeLists.txt b/bindings/c/CMakeLists.txt index cb772737..05a376bb 100644 --- a/bindings/c/CMakeLists.txt +++ b/bindings/c/CMakeLists.txt @@ -27,6 +27,7 @@ set(SVS_C_API_SOURCES src/filtered_search.hpp src/index.hpp src/index_builder.hpp + src/leanvec_training_data.hpp src/storage.hpp src/threadpool.hpp src/types_support.hpp diff --git a/bindings/c/include/svs/c_api/svs_c.h b/bindings/c/include/svs/c_api/svs_c.h index dccccb97..6be4d5ad 100644 --- a/bindings/c/include/svs/c_api/svs_c.h +++ b/bindings/c/include/svs/c_api/svs_c.h @@ -125,6 +125,7 @@ typedef struct svs_index_builder* svs_index_builder_h; typedef struct svs_algorithm* svs_algorithm_h; typedef struct svs_storage* svs_storage_h; typedef struct svs_search_params* svs_search_params_h; +typedef struct svs_leanvec_training_data* svs_leanvec_training_data_h; // Fully defined types; "_t" suffix indicates a fully defined struct typedef enum svs_error_code svs_error_code_t; @@ -305,6 +306,31 @@ SVS_API svs_storage_h svs_storage_create_sq( /// @param storage The storage handle to free SVS_API void svs_storage_free(svs_storage_h storage); +/// @brief Train LeanVec dimensionality-reduction matrices from a data sample +/// @param dim The dimensionality of the data (and training queries) +/// @param num_vectors The number of data vectors in x +/// @param x Pointer to the data vectors [num_vectors x dim] (float array) +/// @param num_queries The number of training queries in x_q (0 for in-distribution) +/// @param x_q Pointer to the training queries [num_queries x dim], or NULL. When +/// provided, matrices are trained out-of-distribution (OOD) using these queries; +/// when num_queries is 0 or x_q is NULL, in-distribution (PCA) matrices are computed. +/// @param leanvec_dims The reduced number of LeanVec dimensions +/// @param out_err An optional error handle to capture errors +/// @return A handle to the trained LeanVec matrices +SVS_API svs_leanvec_training_data_h svs_leanvec_training_data_build( + size_t dim, + size_t num_vectors, + const float* x, + size_t num_queries, + const float* x_q /*=NULL*/, + size_t leanvec_dims, + svs_error_h out_err /*=NULL*/ +); + +/// @brief Free the LeanVec training data handle +/// @param training_data The training data handle to free +SVS_API void svs_leanvec_training_data_free(svs_leanvec_training_data_h training_data); + /// @brief Create an index builder configuration /// @param metric The distance metric to use /// @param dimension The dimensionality of the vectors @@ -333,6 +359,20 @@ SVS_API bool svs_index_builder_set_storage( svs_index_builder_h builder, svs_storage_h storage, svs_error_h out_err /*=NULL*/ ); +/// @brief Attach trained LeanVec matrices to the index builder +/// @param builder The index builder handle +/// @param training_data The trained LeanVec matrices to use when reducing the data. +/// Only applies when the builder's storage is configured for LeanVec; the reduced +/// dataset is built using these matrices instead of computing PCA matrices at build +/// time. Pass NULL to clear a previously attached training data. +/// @param out_err An optional error handle to capture errors +/// @return true on success, false on failure +SVS_API bool svs_index_builder_set_leanvec_training_data( + svs_index_builder_h builder, + svs_leanvec_training_data_h training_data, + svs_error_h out_err /*=NULL*/ +); + /// @brief Set the thread pool configuration for the index builder /// @param builder The index builder handle /// @param kind The kind of thread pool to use diff --git a/bindings/c/src/data_builder/leanvec.hpp b/bindings/c/src/data_builder/leanvec.hpp index 87588c7e..51f31ed7 100644 --- a/bindings/c/src/data_builder/leanvec.hpp +++ b/bindings/c/src/data_builder/leanvec.hpp @@ -40,17 +40,25 @@ #endif // SVS_LEANVEC_HEADER #include +#include #include +#include namespace svs { template > class LeanVecDataBuilder { size_t leanvec_dims_; + // Pre-trained (e.g. out-of-distribution) matrices; empty for PCA reduction. + std::optional> matrices_; public: - LeanVecDataBuilder(size_t leanvec_dims) - : leanvec_dims_(leanvec_dims) {} + LeanVecDataBuilder( + size_t leanvec_dims, + std::optional> matrices = std::nullopt + ) + : leanvec_dims_(leanvec_dims) + , matrices_(std::move(matrices)) {} using data_type = svs::leanvec::LeanDataset< svs::leanvec::UsingLVQ, @@ -67,7 +75,7 @@ class LeanVecDataBuilder { const allocator_type& allocator = {} ) { return data_type::reduce( - view, std::nullopt, pool, 0, svs::lib::MaybeStatic{leanvec_dims_}, allocator + view, matrices_, pool, 0, svs::lib::MaybeStatic{leanvec_dims_}, allocator ); } @@ -95,6 +103,11 @@ struct lib:: static To convert(From from) { auto leanvec = static_cast(from); + if (leanvec->training_data) { + return To{ + leanvec->training_data->leanvec_dims(), + leanvec->training_data->matrices()}; + } return To{leanvec->lenavec_dims}; } }; diff --git a/bindings/c/src/leanvec_training_data.hpp b/bindings/c/src/leanvec_training_data.hpp new file mode 100644 index 00000000..5443c773 --- /dev/null +++ b/bindings/c/src/leanvec_training_data.hpp @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Intel Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC + +#include "svs/c_api/svs_c.h" + +#include +#include +#include +#include + +#ifdef SVS_LEANVEC_HEADER +#include SVS_LEANVEC_HEADER +#else +#include +#endif + +#include + +namespace svs::c_runtime { + +// Holds LeanVec dimensionality-reduction matrices trained from a data sample. +// Mirrors the runtime bindings' LeanVecTrainingData: matrices are computed once +// and later handed to LeanVecDataBuilder to reduce the dataset. When training +// queries are supplied the matrices are learned out-of-distribution (OOD), +// otherwise in-distribution (PCA) matrices are used for both data and queries. +class LeanVecTrainingData { + public: + using matrices_type = svs::leanvec::LeanVecMatrices; + + LeanVecTrainingData( + svs::data::ConstSimpleDataView data, + svs::data::ConstSimpleDataView queries, + size_t leanvec_dims, + svs::threads::ThreadPoolHandle& pool + ) + : leanvec_dims_{leanvec_dims} + , matrices_{ + queries.size() == 0 ? compute_pca(data, leanvec_dims, pool) + : compute_ood(data, queries, leanvec_dims, pool)} {} + + size_t leanvec_dims() const { return leanvec_dims_; } + const matrices_type& matrices() const { return matrices_; } + + private: + size_t leanvec_dims_; + matrices_type matrices_; + + static matrices_type compute_pca( + svs::data::ConstSimpleDataView data, + size_t leanvec_dims, + svs::threads::ThreadPoolHandle& pool + ) { + auto means = svs::utils::compute_medioid(data, pool); + auto matrix = svs::leanvec::compute_leanvec_matrix( + data, means, pool, svs::lib::MaybeStatic{leanvec_dims} + ); + // A copy is used for the query matrix: in PCA mode data and query + // transforms are identical, and passing the same object twice trips + // use-after-move warnings and DenseArray double-free issues. + auto query_matrix = matrix; + return matrices_type{std::move(matrix), std::move(query_matrix)}; + } + + static matrices_type compute_ood( + svs::data::ConstSimpleDataView data, + svs::data::ConstSimpleDataView queries, + size_t leanvec_dims, + svs::threads::ThreadPoolHandle& pool + ) { + return svs::leanvec::compute_leanvec_matrices_ood( + data, queries, pool, svs::lib::MaybeStatic{leanvec_dims} + ); + } +}; + +} // namespace svs::c_runtime + +#endif // SVS_RUNTIME_ENABLE_LVQ_LEANVEC diff --git a/bindings/c/src/storage.hpp b/bindings/c/src/storage.hpp index b35927ca..610698ed 100644 --- a/bindings/c/src/storage.hpp +++ b/bindings/c/src/storage.hpp @@ -25,10 +25,13 @@ #include #ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC +#include "leanvec_training_data.hpp" + #include #endif #include +#include #include namespace svs { @@ -59,6 +62,11 @@ struct StorageLeanVec : public Storage { size_t lenavec_dims; size_t primary_bits; size_t secondary_bits; +#ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC + // Pre-trained reduction matrices; when set, they are used instead of PCA + // matrices computed at build time (enables out-of-distribution LeanVec). + std::shared_ptr training_data; +#endif StorageLeanVec(size_t lenavec_dims, svs_data_type_t primary, svs_data_type_t secondary) : Storage{SVS_STORAGE_KIND_LEANVEC} diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp index 40baf635..54948cef 100644 --- a/bindings/c/src/svs_c.cpp +++ b/bindings/c/src/svs_c.cpp @@ -24,6 +24,10 @@ #include "threadpool.hpp" #include "types_support.hpp" +#ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC +#include "leanvec_training_data.hpp" +#endif + #include #include #include @@ -55,6 +59,12 @@ struct svs_storage { std::shared_ptr impl; }; +struct svs_leanvec_training_data { +#ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC + std::shared_ptr impl; +#endif +}; + extern "C" svs_algorithm_h svs_algorithm_create_vamana( size_t graph_degree, size_t build_window_size, @@ -342,6 +352,63 @@ svs_storage_create_sq(svs_data_type_t data_type, svs_error_h out_err) { extern "C" void svs_storage_free(svs_storage_h storage) { delete storage; } +extern "C" svs_leanvec_training_data_h svs_leanvec_training_data_build( + size_t dim, + size_t num_vectors, + const float* x, + size_t num_queries, + const float* x_q, + size_t leanvec_dims, + svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() -> svs_leanvec_training_data_h { +#ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC + EXPECT_ARG_GT_THAN(dim, 0); + EXPECT_ARG_GT_THAN(num_vectors, 0); + EXPECT_ARG_NOT_NULL(x); + EXPECT_ARG_GT_THAN(leanvec_dims, 0); + EXPECT_ARG_GE_THAN(dim, leanvec_dims); + INVALID_ARGUMENT_IF( + (num_queries > 0 && x_q == nullptr), + "x_q should not be NULL when num_queries is greater than 0" + ); + + auto data = svs::data::ConstSimpleDataView(x, num_vectors, dim); + // A zero-sized view selects the in-distribution (PCA) path. + auto queries = svs::data::ConstSimpleDataView( + x_q, (x_q == nullptr) ? 0 : num_queries, dim + ); + + auto pool = ThreadPoolBuilder{}.build(); + auto training_data = std::make_shared( + data, queries, leanvec_dims, pool + ); + + auto result = new svs_leanvec_training_data; + result->impl = std::move(training_data); + return result; +#else + (void)dim; + (void)num_vectors; + (void)x; + (void)num_queries; + (void)x_q; + (void)leanvec_dims; + throw svs::c_runtime::not_implemented( + "LeanVec training data is not implemented in this build" + ); +#endif + }, + out_err + ); +} + +extern "C" void svs_leanvec_training_data_free(svs_leanvec_training_data_h training_data) { + delete training_data; +} + extern "C" svs_index_builder_h svs_index_builder_create( svs_distance_metric_t metric, size_t dimension, @@ -386,6 +453,37 @@ extern "C" bool svs_index_builder_set_storage( ); } +extern "C" bool svs_index_builder_set_leanvec_training_data( + svs_index_builder_h builder, + svs_leanvec_training_data_h training_data, + svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() -> bool { +#ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC + EXPECT_ARG_NOT_NULL(builder); + auto storage = + std::dynamic_pointer_cast(builder->impl->storage); + INVALID_ARGUMENT_IF( + (storage == nullptr), + "LeanVec training data can only be set on LeanVec storage" + ); + storage->training_data = + (training_data == nullptr) ? nullptr : training_data->impl; + return true; +#else + (void)builder; + (void)training_data; + throw svs::c_runtime::not_implemented( + "LeanVec training data is not implemented in this build" + ); +#endif + }, + out_err + ); +} + extern "C" bool svs_index_builder_set_threadpool( svs_index_builder_h builder, svs_threadpool_kind_t kind, diff --git a/bindings/c/tests/c_api_index.cpp b/bindings/c/tests/c_api_index.cpp index caaaad4d..27f668cb 100644 --- a/bindings/c/tests/c_api_index.cpp +++ b/bindings/c/tests/c_api_index.cpp @@ -250,6 +250,107 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") svs_error_free(error); } + CATCH_SECTION("Index Build and Search with LeanVec OOD Training Data") { + svs_error_h error = svs_error_create(); + + const size_t leanvec_dims = DIMENSION / 2; + + // Train out-of-distribution matrices from the data and a sample of queries. + svs_leanvec_training_data_h training_data = svs_leanvec_training_data_build( + DIMENSION, NUM_VECTORS, data.data(), NUM_QUERIES, queries.data(), leanvec_dims, + error + ); + + // LeanVec is only available on supported hardware/builds; skip otherwise. + if (training_data == nullptr) { + auto code = svs_error_get_code(error); + CATCH_REQUIRE( + (code == SVS_ERROR_NOT_IMPLEMENTED || code == SVS_ERROR_UNSUPPORTED_HW) + ); + svs_error_free(error); + return; + } + CATCH_REQUIRE(svs_error_ok(error)); + + svs_storage_h storage = svs_storage_create_leanvec( + leanvec_dims, SVS_DATA_TYPE_INT4, SVS_DATA_TYPE_INT8, error + ); + CATCH_REQUIRE(storage != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + success = svs_index_builder_set_storage(builder, storage, error); + CATCH_REQUIRE(success); + CATCH_REQUIRE(svs_error_ok(error)); + + success = + svs_index_builder_set_leanvec_training_data(builder, training_data, error); + CATCH_REQUIRE(success); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, nullptr, nullptr, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + for (size_t i = 0; i < NUM_QUERIES; ++i) { + CATCH_REQUIRE(results->results_per_query[i] == K); + } + + svs_search_results_free(results); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_storage_free(storage); + svs_leanvec_training_data_free(training_data); + svs_error_free(error); + } + + CATCH_SECTION("LeanVec training data on non-LeanVec storage is rejected") { + svs_error_h error = svs_error_create(); + + svs_leanvec_training_data_h training_data = svs_leanvec_training_data_build( + DIMENSION, NUM_VECTORS, data.data(), 0, nullptr, DIMENSION / 2, error + ); + if (training_data == nullptr) { + auto code = svs_error_get_code(error); + CATCH_REQUIRE( + (code == SVS_ERROR_NOT_IMPLEMENTED || code == SVS_ERROR_UNSUPPORTED_HW) + ); + svs_error_free(error); + return; + } + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + // Default storage is simple float32, not LeanVec. + bool success = + svs_index_builder_set_leanvec_training_data(builder, training_data, error); + CATCH_REQUIRE(success == false); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_leanvec_training_data_free(training_data); + svs_error_free(error); + } + CATCH_SECTION("Index with Custom Threadpool") { svs_error_h error = svs_error_create(); From bc219f5f8d76861296e1970522f0f2d2e5b43afd Mon Sep 17 00:00:00 2001 From: ethanglaser Date: Tue, 28 Jul 2026 14:14:04 -0700 Subject: [PATCH 3/4] clang formatting --- bindings/c/src/data_builder/leanvec.hpp | 3 +-- bindings/c/src/leanvec_training_data.hpp | 2 +- bindings/c/tests/c_api_index.cpp | 7 ++++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bindings/c/src/data_builder/leanvec.hpp b/bindings/c/src/data_builder/leanvec.hpp index 51f31ed7..18656c68 100644 --- a/bindings/c/src/data_builder/leanvec.hpp +++ b/bindings/c/src/data_builder/leanvec.hpp @@ -105,8 +105,7 @@ struct lib:: auto leanvec = static_cast(from); if (leanvec->training_data) { return To{ - leanvec->training_data->leanvec_dims(), - leanvec->training_data->matrices()}; + leanvec->training_data->leanvec_dims(), leanvec->training_data->matrices()}; } return To{leanvec->lenavec_dims}; } diff --git a/bindings/c/src/leanvec_training_data.hpp b/bindings/c/src/leanvec_training_data.hpp index 5443c773..963a434f 100644 --- a/bindings/c/src/leanvec_training_data.hpp +++ b/bindings/c/src/leanvec_training_data.hpp @@ -52,7 +52,7 @@ class LeanVecTrainingData { : leanvec_dims_{leanvec_dims} , matrices_{ queries.size() == 0 ? compute_pca(data, leanvec_dims, pool) - : compute_ood(data, queries, leanvec_dims, pool)} {} + : compute_ood(data, queries, leanvec_dims, pool)} {} size_t leanvec_dims() const { return leanvec_dims_; } const matrices_type& matrices() const { return matrices_; } diff --git a/bindings/c/tests/c_api_index.cpp b/bindings/c/tests/c_api_index.cpp index 27f668cb..79a1814a 100644 --- a/bindings/c/tests/c_api_index.cpp +++ b/bindings/c/tests/c_api_index.cpp @@ -257,7 +257,12 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") // Train out-of-distribution matrices from the data and a sample of queries. svs_leanvec_training_data_h training_data = svs_leanvec_training_data_build( - DIMENSION, NUM_VECTORS, data.data(), NUM_QUERIES, queries.data(), leanvec_dims, + DIMENSION, + NUM_VECTORS, + data.data(), + NUM_QUERIES, + queries.data(), + leanvec_dims, error ); From 8081160ad16ce2aca610090d06fd1cfe66c9e9a0 Mon Sep 17 00:00:00 2001 From: ethanglaser Date: Tue, 4 Aug 2026 16:19:50 -0700 Subject: [PATCH 4/4] address API concerns + minor/test revisions --- bindings/c/SVS_C_API_Design.md | 12 +++ bindings/c/include/svs/c_api/svs_c.h | 30 +++---- bindings/c/src/data_builder/leanvec.hpp | 7 +- bindings/c/src/storage.hpp | 21 ++++- bindings/c/src/svs_c.cpp | 102 +++++++++++++----------- bindings/c/tests/c_api_index.cpp | 75 +++++++++++++---- 6 files changed, 167 insertions(+), 80 deletions(-) diff --git a/bindings/c/SVS_C_API_Design.md b/bindings/c/SVS_C_API_Design.md index 5c5bcbfe..e244d942 100644 --- a/bindings/c/SVS_C_API_Design.md +++ b/bindings/c/SVS_C_API_Design.md @@ -437,6 +437,7 @@ svs_storage_h svs_storage_create_lvq( ); // LeanVec two-level hierarchical storage +// Reduction matrices are computed from the dataset (PCA) at build time. svs_storage_h svs_storage_create_leanvec( size_t leanvec_dims, // Primary dimensions (usually much smaller) svs_data_type_t primary, // Primary storage type @@ -444,6 +445,17 @@ svs_storage_h svs_storage_create_leanvec( svs_error_h out_err ); +// LeanVec storage using matrices trained up front, e.g. out-of-distribution +// matrices learned from a sample of queries. `leanvec_dims` comes from the +// training data. The storage keeps its own reference to the matrices, so the +// training data handle may be freed as soon as this returns. +svs_storage_h svs_storage_create_leanvec_trained( + svs_leanvec_training_data_h training_data, + svs_data_type_t primary, // Primary storage type + svs_data_type_t secondary, // Secondary/residual storage type + svs_error_h out_err +); + // Cleanup void svs_storage_free(svs_storage_h storage); ``` diff --git a/bindings/c/include/svs/c_api/svs_c.h b/bindings/c/include/svs/c_api/svs_c.h index 6be4d5ad..a28033d7 100644 --- a/bindings/c/include/svs/c_api/svs_c.h +++ b/bindings/c/include/svs/c_api/svs_c.h @@ -331,6 +331,22 @@ SVS_API svs_leanvec_training_data_h svs_leanvec_training_data_build( /// @param training_data The training data handle to free SVS_API void svs_leanvec_training_data_free(svs_leanvec_training_data_h training_data); +/// @brief Create a LeanVec storage configuration from pre-trained matrices +/// @param training_data The trained LeanVec matrices to use when reducing the data, +/// instead of computing PCA matrices at build time. The number of LeanVec dimensions +/// is taken from the training data. The storage retains a reference to the trained +/// matrices, so the training data handle may be freed once this call returns. +/// @param primary The data type of the primary quantization +/// @param secondary The data type of the secondary quantization +/// @param out_err An optional error handle to capture errors +/// @return A handle to the created LeanVec storage +SVS_API svs_storage_h svs_storage_create_leanvec_trained( + svs_leanvec_training_data_h training_data, + svs_data_type_t primary, + svs_data_type_t secondary, + svs_error_h out_err /*=NULL*/ +); + /// @brief Create an index builder configuration /// @param metric The distance metric to use /// @param dimension The dimensionality of the vectors @@ -359,20 +375,6 @@ SVS_API bool svs_index_builder_set_storage( svs_index_builder_h builder, svs_storage_h storage, svs_error_h out_err /*=NULL*/ ); -/// @brief Attach trained LeanVec matrices to the index builder -/// @param builder The index builder handle -/// @param training_data The trained LeanVec matrices to use when reducing the data. -/// Only applies when the builder's storage is configured for LeanVec; the reduced -/// dataset is built using these matrices instead of computing PCA matrices at build -/// time. Pass NULL to clear a previously attached training data. -/// @param out_err An optional error handle to capture errors -/// @return true on success, false on failure -SVS_API bool svs_index_builder_set_leanvec_training_data( - svs_index_builder_h builder, - svs_leanvec_training_data_h training_data, - svs_error_h out_err /*=NULL*/ -); - /// @brief Set the thread pool configuration for the index builder /// @param builder The index builder handle /// @param kind The kind of thread pool to use diff --git a/bindings/c/src/data_builder/leanvec.hpp b/bindings/c/src/data_builder/leanvec.hpp index 18656c68..55a6b9aa 100644 --- a/bindings/c/src/data_builder/leanvec.hpp +++ b/bindings/c/src/data_builder/leanvec.hpp @@ -103,11 +103,12 @@ struct lib:: static To convert(From from) { auto leanvec = static_cast(from); + // `leanvec_dims` is taken from the training data at storage construction, + // so it is authoritative in both cases. if (leanvec->training_data) { - return To{ - leanvec->training_data->leanvec_dims(), leanvec->training_data->matrices()}; + return To{leanvec->leanvec_dims, leanvec->training_data->matrices()}; } - return To{leanvec->lenavec_dims}; + return To{leanvec->leanvec_dims}; } }; diff --git a/bindings/c/src/storage.hpp b/bindings/c/src/storage.hpp index 610698ed..bcf82c77 100644 --- a/bindings/c/src/storage.hpp +++ b/bindings/c/src/storage.hpp @@ -59,18 +59,20 @@ struct StorageSimple : public Storage { }; struct StorageLeanVec : public Storage { - size_t lenavec_dims; + size_t leanvec_dims; size_t primary_bits; size_t secondary_bits; #ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC // Pre-trained reduction matrices; when set, they are used instead of PCA // matrices computed at build time (enables out-of-distribution LeanVec). + // Fixed at construction: `leanvec_dims` is taken from the training data, so + // the two can never disagree. std::shared_ptr training_data; #endif - StorageLeanVec(size_t lenavec_dims, svs_data_type_t primary, svs_data_type_t secondary) + StorageLeanVec(size_t leanvec_dims, svs_data_type_t primary, svs_data_type_t secondary) : Storage{SVS_STORAGE_KIND_LEANVEC} - , lenavec_dims(lenavec_dims) + , leanvec_dims(leanvec_dims) , primary_bits(to_bits_number(primary)) , secondary_bits(to_bits_number(secondary)) { #ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC @@ -86,6 +88,19 @@ struct StorageLeanVec : public Storage { #endif } +#ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC + // Construct from pre-trained matrices. `leanvec_dims` is the single value + // carried by the training data, so no reconciliation is needed. + StorageLeanVec( + std::shared_ptr training_data, + svs_data_type_t primary, + svs_data_type_t secondary + ) + : StorageLeanVec(training_data->leanvec_dims(), primary, secondary) { + this->training_data = std::move(training_data); + } +#endif + static size_t to_bits_number(svs_data_type_t data_type) { switch (data_type) { case SVS_DATA_TYPE_INT4: diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp index 54948cef..847392c7 100644 --- a/bindings/c/src/svs_c.cpp +++ b/bindings/c/src/svs_c.cpp @@ -270,6 +270,27 @@ svs_storage_create_simple(svs_data_type_t data_type, svs_error_h out_err) { ); } +namespace { +// Shared by both LeanVec storage constructors so their accepted types stay in sync. +void validate_leanvec_data_types(svs_data_type_t primary, svs_data_type_t secondary) { + NOT_IMPLEMENTED_IF( + (primary == SVS_DATA_TYPE_FLOAT32 || primary == SVS_DATA_TYPE_FLOAT16 || + secondary == SVS_DATA_TYPE_FLOAT32 || secondary == SVS_DATA_TYPE_FLOAT16), + "Unsupported simple data types for LeanVec primary and secondary" + ); + INVALID_ARGUMENT_IF( + (primary != SVS_DATA_TYPE_INT4 && primary != SVS_DATA_TYPE_UINT4 && + primary != SVS_DATA_TYPE_INT8 && primary != SVS_DATA_TYPE_UINT8), + "Unsupported data type for LeanVec primary storage" + ); + INVALID_ARGUMENT_IF( + (secondary != SVS_DATA_TYPE_INT4 && secondary != SVS_DATA_TYPE_UINT4 && + secondary != SVS_DATA_TYPE_INT8 && secondary != SVS_DATA_TYPE_UINT8), + "Unsupported data type for LeanVec secondary storage" + ); +} +} // namespace + extern "C" svs_storage_h svs_storage_create_leanvec( size_t leanvec_dims, svs_data_type_t primary, @@ -280,27 +301,49 @@ extern "C" svs_storage_h svs_storage_create_leanvec( return wrap_exceptions( [&]() { EXPECT_ARG_GT_THAN(leanvec_dims, 0); - NOT_IMPLEMENTED_IF( - (primary == SVS_DATA_TYPE_FLOAT32 || primary == SVS_DATA_TYPE_FLOAT16 || - secondary == SVS_DATA_TYPE_FLOAT32 || secondary == SVS_DATA_TYPE_FLOAT16), - "Unsupported simple data types for LeanVec primary and secondary" - ); + validate_leanvec_data_types(primary, secondary); + + auto storage = + std::make_shared(leanvec_dims, primary, secondary); + auto result = new svs_storage; + result->impl = storage; + return result; + }, + out_err + ); +} + +extern "C" svs_storage_h svs_storage_create_leanvec_trained( + svs_leanvec_training_data_h training_data, + svs_data_type_t primary, + svs_data_type_t secondary, + svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() -> svs_storage_h { +#ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC + EXPECT_ARG_NOT_NULL(training_data); INVALID_ARGUMENT_IF( - (primary != SVS_DATA_TYPE_INT4 && primary != SVS_DATA_TYPE_UINT4 && - primary != SVS_DATA_TYPE_INT8 && primary != SVS_DATA_TYPE_UINT8), - "Unsupported data type for LeanVec primary storage" - ); - INVALID_ARGUMENT_IF( - (secondary != SVS_DATA_TYPE_INT4 && secondary != SVS_DATA_TYPE_UINT4 && - secondary != SVS_DATA_TYPE_INT8 && secondary != SVS_DATA_TYPE_UINT8), - "Unsupported data type for LeanVec secondary storage" + (training_data->impl == nullptr), "training_data holds no trained matrices" ); + validate_leanvec_data_types(primary, secondary); + // The storage shares ownership of the trained matrices, so the caller + // may free the training data handle as soon as this returns. auto storage = - std::make_shared(leanvec_dims, primary, secondary); + std::make_shared(training_data->impl, primary, secondary); auto result = new svs_storage; result->impl = storage; return result; +#else + (void)training_data; + (void)primary; + (void)secondary; + throw svs::c_runtime::not_implemented( + "LeanVec storage is not implemented in this build" + ); +#endif }, out_err ); @@ -453,37 +496,6 @@ extern "C" bool svs_index_builder_set_storage( ); } -extern "C" bool svs_index_builder_set_leanvec_training_data( - svs_index_builder_h builder, - svs_leanvec_training_data_h training_data, - svs_error_h out_err -) { - using namespace svs::c_runtime; - return wrap_exceptions( - [&]() -> bool { -#ifdef SVS_RUNTIME_ENABLE_LVQ_LEANVEC - EXPECT_ARG_NOT_NULL(builder); - auto storage = - std::dynamic_pointer_cast(builder->impl->storage); - INVALID_ARGUMENT_IF( - (storage == nullptr), - "LeanVec training data can only be set on LeanVec storage" - ); - storage->training_data = - (training_data == nullptr) ? nullptr : training_data->impl; - return true; -#else - (void)builder; - (void)training_data; - throw svs::c_runtime::not_implemented( - "LeanVec training data is not implemented in this build" - ); -#endif - }, - out_err - ); -} - extern "C" bool svs_index_builder_set_threadpool( svs_index_builder_h builder, svs_threadpool_kind_t kind, diff --git a/bindings/c/tests/c_api_index.cpp b/bindings/c/tests/c_api_index.cpp index 79a1814a..136c81e6 100644 --- a/bindings/c/tests/c_api_index.cpp +++ b/bindings/c/tests/c_api_index.cpp @@ -277,12 +277,17 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") } CATCH_REQUIRE(svs_error_ok(error)); - svs_storage_h storage = svs_storage_create_leanvec( - leanvec_dims, SVS_DATA_TYPE_INT4, SVS_DATA_TYPE_INT8, error + // The number of LeanVec dimensions comes from the training data. + svs_storage_h storage = svs_storage_create_leanvec_trained( + training_data, SVS_DATA_TYPE_INT4, SVS_DATA_TYPE_INT8, error ); CATCH_REQUIRE(storage != nullptr); CATCH_REQUIRE(svs_error_ok(error)); + // The storage holds its own reference to the trained matrices, so the + // training data handle can be released before the index is built. + svs_leanvec_training_data_free(training_data); + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); svs_index_builder_h builder = svs_index_builder_create( SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error @@ -297,11 +302,6 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") CATCH_REQUIRE(success); CATCH_REQUIRE(svs_error_ok(error)); - success = - svs_index_builder_set_leanvec_training_data(builder, training_data, error); - CATCH_REQUIRE(success); - CATCH_REQUIRE(svs_error_ok(error)); - svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); CATCH_REQUIRE(index != nullptr); CATCH_REQUIRE(svs_error_ok(error)); @@ -321,13 +321,28 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") svs_index_builder_free(builder); svs_algorithm_free(algorithm); svs_storage_free(storage); - svs_leanvec_training_data_free(training_data); svs_error_free(error); } - CATCH_SECTION("LeanVec training data on non-LeanVec storage is rejected") { + CATCH_SECTION("Pre-trained LeanVec storage rejects NULL training data") { + svs_error_h error = svs_error_create(); + + svs_storage_h storage = svs_storage_create_leanvec_trained( + nullptr, SVS_DATA_TYPE_INT4, SVS_DATA_TYPE_INT8, error + ); + CATCH_REQUIRE(storage == nullptr); + auto code = svs_error_get_code(error); + CATCH_REQUIRE( + (code == SVS_ERROR_INVALID_ARGUMENT || code == SVS_ERROR_NOT_IMPLEMENTED) + ); + + svs_error_free(error); + } + + CATCH_SECTION("Index Build and Search with pre-trained in-distribution LeanVec") { svs_error_h error = svs_error_create(); + // No training queries: in-distribution (PCA) matrices, trained up front. svs_leanvec_training_data_h training_data = svs_leanvec_training_data_build( DIMENSION, NUM_VECTORS, data.data(), 0, nullptr, DIMENSION / 2, error ); @@ -340,19 +355,49 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") return; } + svs_storage_h storage = svs_storage_create_leanvec_trained( + training_data, SVS_DATA_TYPE_INT4, SVS_DATA_TYPE_INT8, error + ); + CATCH_REQUIRE(storage != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + // Freeing here exercises the storage's shared ownership of the matrices: + // the build below still uses them. + svs_leanvec_training_data_free(training_data); + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); svs_index_builder_h builder = svs_index_builder_create( SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error ); - // Default storage is simple float32, not LeanVec. - bool success = - svs_index_builder_set_leanvec_training_data(builder, training_data, error); - CATCH_REQUIRE(success == false); - CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + success = svs_index_builder_set_storage(builder, storage, error); + CATCH_REQUIRE(success); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, nullptr, nullptr, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + for (size_t i = 0; i < NUM_QUERIES; ++i) { + CATCH_REQUIRE(results->results_per_query[i] == K); + } + + svs_search_results_free(results); + svs_index_free(index); svs_index_builder_free(builder); svs_algorithm_free(algorithm); - svs_leanvec_training_data_free(training_data); + svs_storage_free(storage); svs_error_free(error); }