From 6751df6a5778ccaafc8541271ece9763152ed032 Mon Sep 17 00:00:00 2001 From: ethanglaser <42726565+ethanglaser@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:21:37 -0700 Subject: [PATCH 1/9] Rename cpp runtime binding validation (#343) --- .github/scripts/{test-cpp-runtime-bindings.sh => test-faiss.sh} | 0 .github/workflows/build-cpp-runtime-bindings.yml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename .github/scripts/{test-cpp-runtime-bindings.sh => test-faiss.sh} (100%) diff --git a/.github/scripts/test-cpp-runtime-bindings.sh b/.github/scripts/test-faiss.sh similarity index 100% rename from .github/scripts/test-cpp-runtime-bindings.sh rename to .github/scripts/test-faiss.sh diff --git a/.github/workflows/build-cpp-runtime-bindings.yml b/.github/workflows/build-cpp-runtime-bindings.yml index 468789bf..e438752d 100644 --- a/.github/workflows/build-cpp-runtime-bindings.yml +++ b/.github/workflows/build-cpp-runtime-bindings.yml @@ -125,4 +125,4 @@ jobs: -w /workspace \ -e SUFFIX=${{ matrix.suffix }} \ svs-manylinux228:latest \ - /bin/bash .github/scripts/test-cpp-runtime-bindings.sh + /bin/bash .github/scripts/test-faiss.sh From bbe38c82e2e6dc2c65749ae115fe486a5892049e Mon Sep 17 00:00:00 2001 From: Rafik Saliev Date: Wed, 17 Jun 2026 10:34:34 +0200 Subject: [PATCH 2/9] [Data] Add optional blocksize_elements to BlockingParameters (#344) This pull request enhances the flexibility and predictability of blocked data structures by introducing explicit control over the number of elements per block (`blocksize_elements`) in addition to the existing byte-based blocking (`blocksize_bytes`). It also improves test coverage to verify the new behavior and edge cases. **Blocking parameter improvements:** * Added an optional `blocksize_elements` field to the `BlockingParameters` struct, allowing users to specify the number of elements per block directly. If set, this takes precedence over `blocksize_bytes` when determining block size. (`include/svs/core/data/simple.h`) **Testing and validation:** * Added test to demonstrate the pitfalls of relying solely on `blocksize_bytes` for memory prediction. * Extended unit tests to cover scenarios where `blocksize_elements` is set, including checks for correct block size selection and memory consumption predictions. (`tests/svs/core/data/block.cpp`) --- include/svs/core/data/simple.h | 20 +++- tests/svs/core/data/block.cpp | 163 +++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/include/svs/core/data/simple.h b/include/svs/core/data/simple.h index 251f3055..819a330d 100644 --- a/include/svs/core/data/simple.h +++ b/include/svs/core/data/simple.h @@ -34,6 +34,7 @@ #include "svs/lib/uuid.h" // stdlib +#include #include #include @@ -644,6 +645,7 @@ struct BlockingParameters { public: lib::PowerOfTwo blocksize_bytes = default_blocksize_bytes; + std::optional blocksize_elements = std::nullopt; }; template class Blocked { @@ -719,9 +721,7 @@ class SimpleData> { ///// Constructors SimpleData(size_t n_elements, size_t n_dimensions, const Blocked& alloc) - : blocksize_{lib::prevpow2( - alloc.parameters().blocksize_bytes.value() / (sizeof(T) * n_dimensions) - )} + : blocksize_{compute_blocksize(alloc, n_dimensions)} , blocks_{} , dimensions_{n_dimensions} , size_{n_elements} @@ -949,6 +949,20 @@ class SimpleData> { ); } + private: + // Helper static function to compute blocksize value. + // If blocking parameters have defined blocksize_elements, use it + // directly. Otherwise, compute blocksize based on blocksize_bytes. + static lib::PowerOfTwo compute_blocksize(const Blocked& alloc, size_t dim) { + if (alloc.parameters().blocksize_elements.has_value()) { + return alloc.parameters().blocksize_elements.value(); + } else { + return lib::prevpow2( + alloc.parameters().blocksize_bytes.value() / (sizeof(T) * dim) + ); + } + } + private: // The blocksize in terms of number of vectors. lib::PowerOfTwo blocksize_; diff --git a/tests/svs/core/data/block.cpp b/tests/svs/core/data/block.cpp index 4923b30f..4ce42d45 100644 --- a/tests/svs/core/data/block.cpp +++ b/tests/svs/core/data/block.cpp @@ -31,6 +31,39 @@ namespace { +// Allocator class which records allocated bytes +template class RecordingAllocator { + public: + using value_type = T; + + RecordingAllocator() = default; + + T* allocate(size_t n) { + *allocated_bytes += n * sizeof(T); + return static_cast(::operator new(n * sizeof(T))); + } + + void deallocate(T* p, size_t n) { + *allocated_bytes -= n * sizeof(T); + ::operator delete(p); + } + + template bool operator==(const RecordingAllocator& other) const { + return allocated_bytes == other.allocated_bytes; + } + template bool operator!=(const RecordingAllocator& other) const { + return !(*this == other); + } + + template + RecordingAllocator(const RecordingAllocator& other) + : allocated_bytes(other.allocated_bytes) {} + + size_t& allocated() { return *allocated_bytes; } + + std::shared_ptr allocated_bytes = std::make_shared(0); +}; + template bool is_blocked(const T&) { return false; } template bool is_blocked(const svs::data::BlockedData&) { return true; @@ -152,9 +185,15 @@ CATCH_TEST_CASE("Testing Blocked Data", "[core][data][blocked]") { using T = svs::data::BlockingParameters; auto p = T{}; CATCH_REQUIRE(p.blocksize_bytes == T::default_blocksize_bytes); + CATCH_REQUIRE(p.blocksize_elements == std::nullopt); p = T{.blocksize_bytes = svs::lib::PowerOfTwo(10)}; CATCH_REQUIRE(p.blocksize_bytes == svs::lib::PowerOfTwo(10)); + CATCH_REQUIRE(p.blocksize_elements == std::nullopt); + + p = T{.blocksize_elements = svs::lib::PowerOfTwo(9)}; + CATCH_REQUIRE(p.blocksize_bytes == T::default_blocksize_bytes); + CATCH_REQUIRE(p.blocksize_elements == svs::lib::PowerOfTwo(9)); } CATCH_SECTION("Blocked Allocator") { @@ -185,4 +224,128 @@ CATCH_TEST_CASE("Testing Blocked Data", "[core][data][blocked]") { test_blocked(); test_blocked<5>(); } + + CATCH_SECTION("Different Blocksizes for blocksize_bytes") { + // When BlockingParameters::blocksize_bytes is used (no explicit + // blocksize_elements), the computed blocksize() depends on the per-element byte + // size, i.e. sizeof(T) * dimensions. The same BlockingParameters therefore + // produces very different blocksize_ values for datasets with different element + // types and dimensionalities. + + // 1 MiB block + auto parameters = + svs::data::BlockingParameters{.blocksize_bytes = svs::lib::PowerOfTwo(20)}; + + size_t num_elements = 10; + size_t vector_dims = 1024 + 1 + sizeof(float) * 2; // quantized vectors + size_t graph_degree = 32; + size_t graph_dims = graph_degree + 1; // +1 for edges counter + + RecordingAllocator byte_alloc; + RecordingAllocator int_alloc; + + auto vec_alloc = svs::data::Blocked(parameters, byte_alloc); + auto graph_alloc = svs::data::Blocked(parameters, int_alloc); + + auto vec_data = svs::data::SimpleData( + num_elements, vector_dims, vec_alloc + ); + auto graph_data = + svs::data::SimpleData( + num_elements, graph_dims, graph_alloc + ); + + // Both datasets are configured with the same blocksize_bytes (1 MiB). + CATCH_REQUIRE(vec_data.blocksize_bytes() == graph_data.blocksize_bytes()); + CATCH_REQUIRE(vec_data.blocksize_bytes().value() == (size_t(1) << 20)); + + // Per-element byte sizes differ by 64x (1033 vs 132). + CATCH_REQUIRE(vec_data.element_size() == sizeof(std::byte) * vector_dims); // 1033 + CATCH_REQUIRE(graph_data.element_size() == sizeof(uint32_t) * graph_dims); // 132 + + // Imagine that we are going to predict memory consumption based on element size and + // a blocksize. + auto index_element_size = graph_data.element_size() + vec_data.element_size(); + // We have just 10 vectors - this should trigger allocation of 1 block for graph and + // 1 block for vectors, since both blocksizes are larger than 1. We are assuming + // that the blocksize in elements looks like: + auto blocksize_elements = parameters.blocksize_bytes.value() / + vec_data.element_size(); // 1048576 / 1033 = 1014 + // This is not correct, because the actual block size in elements is computed as the + // previous power of two of this value, which is 512 for vectors and 4096 for + // graphs. So, if we use the same blocksize_bytes to predict memory consumption, we + // will get different results for different element sizes, which is not what we + // want. + auto expected_memory_consumption = + blocksize_elements * + index_element_size; // 1014 * (1033 + 132) = 1014 * 1165 = 1,180,310 + + auto actual_memory_consumption = byte_alloc.allocated() + int_alloc.allocated(); + CATCH_REQUIRE(expected_memory_consumption != actual_memory_consumption); + + // So, if we add 520 vectors to an index, we will get 1 block for graph and 2 blocks + // for vectors. Which means, we have different numbers of blocks for the same number + // of elements, even though the same BlockingParameters were used. + vec_data.resize(520); + graph_data.resize(520); + CATCH_REQUIRE(vec_data.num_blocks() != graph_data.num_blocks()); + + // It is because the graph blocksize_ is 8x larger than the vector blocksize_. + CATCH_REQUIRE(graph_data.blocksize().value() == 8 * vec_data.blocksize().value()); + } + + // This is why, to properly predict memory consumption of blocked datasets, we should + // directly manage blocksize_elements instead of blocksize_bytes, since the former + // directly controls the number of elements per block, while the latter only indirectly + // controls it through the element size. + CATCH_SECTION("Explicit blocksize_elements") { + auto parameters = svs::data::BlockingParameters{ + .blocksize_elements = svs::lib::PowerOfTwo(9) // 512 elements per block + }; + + size_t num_elements = 10; + size_t vector_dims = 1024 + 1 + sizeof(float) * 2; // quantized vectors + size_t graph_degree = 32; + size_t graph_dims = graph_degree + 1; // +1 for edges counter + + RecordingAllocator byte_alloc; + RecordingAllocator int_alloc; + + auto vec_alloc = svs::data::Blocked(parameters, byte_alloc); + auto graph_alloc = svs::data::Blocked(parameters, int_alloc); + + auto vec_data = svs::data::SimpleData( + num_elements, vector_dims, vec_alloc + ); + auto graph_data = + svs::data::SimpleData( + num_elements, graph_dims, graph_alloc + ); + + // Per-element byte sizes differ by 64x (1033 vs 132). + CATCH_REQUIRE(vec_data.element_size() == sizeof(std::byte) * vector_dims); // 1033 + CATCH_REQUIRE(graph_data.element_size() == sizeof(uint32_t) * graph_dims); // 132 + + // Both datasets are configured with the same blocksize_elements (512). + CATCH_REQUIRE(vec_data.blocksize().value() == graph_data.blocksize().value()); + + // Imagine that we are going to predict memory consumption based on element size and + // a blocksize. + auto index_element_size = graph_data.element_size() + vec_data.element_size(); + // We have just 10 vectors - this should trigger allocation of 1 block for graph and + // 1 block for vectors, since both blocksizes are larger than 10. So we can expect + // the memory consumption for 1 block in both datasets is: + auto expected_memory_consumption = + parameters.blocksize_elements.value() * index_element_size; + + auto actual_memory_consumption = byte_alloc.allocated() + int_alloc.allocated(); + CATCH_REQUIRE(expected_memory_consumption == actual_memory_consumption); + + // So, if we add 520 vectors to an index, we will get 2 blocks for graph and 2 + // blocks for vectors. Which means, we have same number of blocks for the same + // number of elements. + vec_data.resize(520); + graph_data.resize(520); + CATCH_REQUIRE(vec_data.num_blocks() == graph_data.num_blocks()); + } } From 0747c56882be4d3a53acbd11215ca9a4cb5e413f Mon Sep 17 00:00:00 2001 From: ethanglaser <42726565+ethanglaser@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:03:48 -0700 Subject: [PATCH 3/9] bump clang version to be compatible with macos-latest (#349) Latest macos github actions image was bumped and no longer supports specified clang versions. This is because we take latest: https://github.com/intel/ScalableVectorSearch/blob/main/.github/workflows/build-macos.yaml#L35 and as far as I can tell there is not an easy way to track this via dependabot. Alternative would be to pin it to a version but then it could sit idle and forgotten. --- .github/workflows/build-macos.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-macos.yaml b/.github/workflows/build-macos.yaml index d53872d7..e31fabad 100644 --- a/.github/workflows/build-macos.yaml +++ b/.github/workflows/build-macos.yaml @@ -36,10 +36,10 @@ jobs: strategy: matrix: build_type: [RelWithDebugInfo] - cxx: [clang++-15] + cxx: [clang++-20] include: - - cxx: clang++-15 - package: llvm@15 + - cxx: clang++-20 + package: llvm@20 cc_name: clang cxx_name: clang++ needs_prefix: true From 154af0ee3c7eab6d67baf5e4f467ce11e89557d6 Mon Sep 17 00:00:00 2001 From: Rafik Saliev Date: Fri, 17 Jul 2026 12:58:56 +0200 Subject: [PATCH 4/9] Refactor Blocked class to meet allocator requirements (#351) This pull request refactors the `Blocked` class template to inherit from its allocator type instead of storing it as a member, and updates the associated test to use a struct-based allocator. This change simplifies allocator handling and improves compatibility with standard allocator patterns. **Core class refactoring:** * `Blocked` now inherits from its allocator type (`Alloc`) instead of storing an `Alloc allocator_` member, which simplifies construction, copying, and access to the allocator. The `get_allocator()` method now returns `*this` (as an allocator), and constructors have been updated accordingly. **Test improvements:** * The test for `Blocked` with an allocator has been updated to use a struct-based allocator (`I`), which provides a `value_type` and integer value for testing propagation and compatibility with the new inheritance-based implementation. --- include/svs/core/data/simple.h | 19 ++++++++++--------- tests/svs/core/data/block.cpp | 12 ++++++++++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/include/svs/core/data/simple.h b/include/svs/core/data/simple.h index 819a330d..33335621 100644 --- a/include/svs/core/data/simple.h +++ b/include/svs/core/data/simple.h @@ -648,31 +648,32 @@ struct BlockingParameters { std::optional blocksize_elements = std::nullopt; }; -template class Blocked { +template class Blocked : public Alloc { public: using allocator_type = Alloc; - const allocator_type& get_allocator() const { return allocator_; } + using value_type = typename std::allocator_traits::value_type; + const allocator_type& get_allocator() const { return *this; } const BlockingParameters& parameters() const { return parameters_; } constexpr Blocked() = default; explicit Blocked(const allocator_type& alloc) - : allocator_{alloc} {} + : allocator_type{alloc} {} explicit Blocked(const BlockingParameters& parameters) - : parameters_{parameters} {} + : allocator_type{} + , parameters_{parameters} {} explicit Blocked(const BlockingParameters& parameters, const allocator_type& alloc) - : parameters_{parameters} - , allocator_{alloc} {} + : allocator_type{alloc} + , parameters_{parameters} {} // Enable rebinding of allocators. template friend class Blocked; template Blocked(const Blocked& other) - : parameters_{other.parameters_} - , allocator_{other.allocator_} {} + : allocator_type{other.get_allocator()} + , parameters_{other.parameters_} {} private: BlockingParameters parameters_{}; - Alloc allocator_{}; }; template inline constexpr bool is_blocked_v = false; diff --git a/tests/svs/core/data/block.cpp b/tests/svs/core/data/block.cpp index 4ce42d45..1702c013 100644 --- a/tests/svs/core/data/block.cpp +++ b/tests/svs/core/data/block.cpp @@ -197,10 +197,18 @@ CATCH_TEST_CASE("Testing Blocked Data", "[core][data][blocked]") { } CATCH_SECTION("Blocked Allocator") { - // Use an integer for the "allocator" to test value propagation. + // Use a simple integer-valued struct for the "allocator" to test value propagation. // Since the `Blocked` class doesn't actually use the allocator, this is okay // for functionality testing. - using T = svs::data::Blocked; + struct I { + using value_type = int; + int value; + I(int v = 0) + : value(v) {} + operator int() const { return value; } + }; + + using T = svs::data::Blocked; using P = svs::data::BlockingParameters; auto x = T(); CATCH_REQUIRE(x.get_allocator() == 0); // Default constructed integer. From 2c8c4f3d5f0a6a836420f75055a5cfa99202196b Mon Sep 17 00:00:00 2001 From: yuejiaointel <108152493+yuejiaointel@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:40:11 -0700 Subject: [PATCH 5/9] [Vamana] Add get_memory_usage() to report allocated bytes (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `get_memory_usage()` returning the total number of bytes a Vamana index has **allocated** — graph storage + vector data + metadata — so an integrator can accurately report and bound SVS memory consumption. Both the static `VamanaIndex` and the dynamic `MutableVamanaIndex` are covered, and the method is plumbed through the orchestrator layers (`VamanaInterface` virtual → `VamanaImpl` override → `Vamana` / `DynamicVamana`) so it is callable on `svs::Vamana` and `svs::DynamicVamana`. ## Why Integrators (e.g. memory-bounded module hosts) need to account for memory SVS allocates via `mmap`/blocked allocators, which bypass the host's `malloc` accounting. The existing `blocksize_bytes()` reports only the initial block size and is neither an upper nor lower bound on the real footprint. `get_memory_usage()` reports the true allocated total across all blocks plus metadata. ## What Accounting is **capacity-based** (the bytes the containers have reserved, not just live elements) so that block over-allocation is reflected: | Component | Source | |---|---| | `graph_bytes` | `graph_.get_data().capacity() * element_size()` | | `data_bytes` | `data_.capacity() * data_.element_size()` | | `metadata_bytes` (dynamic only) | slot-status vector + entry-point list + estimate of the external/internal ID translation maps | A `VamanaMemoryUsage { graph_bytes, data_bytes, metadata_bytes, total() }` struct and `get_memory_breakdown()` expose the per-component split; `get_memory_usage()` returns `get_memory_breakdown().total()`. Notes: - A `detail::dataset_allocated_bytes()` helper uses capacity-based accounting when the dataset exposes `capacity()` (flat/blocked `SimpleData`), and falls back to live element count otherwise (e.g. `SQDataset`). No public accessors or signatures were changed. - The ID-translation map byte size is not directly queryable; it is estimated from the entry count (accurate to within a few percent), with a comment noting the approximation. ## Tests New unit tests at both the core-index and orchestrator levels for the static and dynamic indices: - `tests/svs/index/vamana/index.cpp`, `tests/svs/index/vamana/dynamic_index.cpp` - `tests/svs/orchestrators/vamana.cpp`, `tests/svs/orchestrators/dynamic_vamana.cpp` Assertions: usage `> 0` for a built index, breakdown components sum to the total, and `graph_bytes`/`data_bytes` are non-zero. `[managers]` and the touched index tags pass with no regressions. --- .../cpp/include/svs/runtime/vamana_index.h | 14 ++ bindings/cpp/src/dynamic_vamana_index.cpp | 26 +++- bindings/cpp/src/dynamic_vamana_index_impl.h | 16 +++ bindings/cpp/src/vamana_index.cpp | 11 ++ bindings/cpp/src/vamana_index_impl.h | 16 +++ bindings/cpp/tests/runtime_test.cpp | 127 ++++++++++++++++++ include/svs/core/data.h | 15 +++ 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 ++ tests/svs/index/vamana/dynamic_index.cpp | 40 ++++++ tests/svs/index/vamana/index.cpp | 45 +++++++ tests/svs/orchestrators/dynamic_vamana.cpp | 44 ++++++ tests/svs/orchestrators/vamana.cpp | 36 +++++ 15 files changed, 450 insertions(+), 5 deletions(-) diff --git a/bindings/cpp/include/svs/runtime/vamana_index.h b/bindings/cpp/include/svs/runtime/vamana_index.h index 180daf55..0f11edec 100644 --- a/bindings/cpp/include/svs/runtime/vamana_index.h +++ b/bindings/cpp/include/svs/runtime/vamana_index.h @@ -50,6 +50,14 @@ struct VamanaSearchParameters { }; } // namespace detail +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; } +}; + // Abstract interface for Vamana-based indices. struct SVS_RUNTIME_API VamanaIndex { virtual ~VamanaIndex(); @@ -90,6 +98,12 @@ struct SVS_RUNTIME_API VamanaIndex { // Reconstruct `n` vectors by ID into `output` buffer (n * dim floats). virtual Status reconstruct_at(size_t n, const size_t* ids, float* output) noexcept = 0; + // Return the index memory usage in bytes. + virtual size_t get_memory_usage() const noexcept = 0; + + // Return the bytes allocated by each index component. + virtual Status get_memory_breakdown(MemoryBreakdown* out) const noexcept = 0; + // Utility function to check storage kind support static Status check_storage_kind(StorageKind storage_kind) noexcept; diff --git a/bindings/cpp/src/dynamic_vamana_index.cpp b/bindings/cpp/src/dynamic_vamana_index.cpp index 47366481..0e807bd9 100644 --- a/bindings/cpp/src/dynamic_vamana_index.cpp +++ b/bindings/cpp/src/dynamic_vamana_index.cpp @@ -65,6 +65,17 @@ struct DynamicVamanaIndexManagerBase : public DynamicVamanaIndex { size_t blocksize_bytes() const noexcept override { return impl_->blocksize_bytes(); } + size_t get_memory_usage() const noexcept override { return impl_->get_memory_usage(); } + + Status get_memory_breakdown(MemoryBreakdown* out) const noexcept override { + if (out == nullptr) { + return Status( + ErrorCode::INVALID_ARGUMENT, "memory breakdown output must not be null" + ); + } + return runtime_error_wrapper([&] { impl_->get_memory_breakdown(*out); }); + } + Status remove_selected(size_t* num_removed, const IDFilter& selector) noexcept override { return runtime_error_wrapper([&] { @@ -160,11 +171,13 @@ Status DynamicVamanaIndex::check_params( constexpr static size_t kMaxBlockSizeExp = 30; // 1GB constexpr static size_t kMinBlockSizeExp = 12; // 4KB - if (dynamic_index_params.blocksize_exp > kMaxBlockSizeExp) + if (dynamic_index_params.blocksize_exp > kMaxBlockSizeExp) { return Status(ErrorCode::INVALID_ARGUMENT, "Blocksize is too large"); + } - if (dynamic_index_params.blocksize_exp < kMinBlockSizeExp) + if (dynamic_index_params.blocksize_exp < kMinBlockSizeExp) { return Status(ErrorCode::INVALID_ARGUMENT, "Blocksize is too small"); + } return Status_Ok; } @@ -202,8 +215,9 @@ Status DynamicVamanaIndex::build( *index = nullptr; auto status = DynamicVamanaIndex::check_params(dynamic_index_params); - if (!status.ok()) + if (!status.ok()) { return status; + } return runtime_error_wrapper([&] { auto impl = std::make_unique( @@ -293,8 +307,9 @@ Status DynamicVamanaIndexLeanVec::build( *index = nullptr; auto status = DynamicVamanaIndex::check_params(dynamic_index_params); - if (!status.ok()) + if (!status.ok()) { return status; + } return runtime_error_wrapper([&] { auto impl = std::make_unique( @@ -325,8 +340,9 @@ Status DynamicVamanaIndexLeanVec::build( *index = nullptr; auto status = DynamicVamanaIndex::check_params(dynamic_index_params); - if (!status.ok()) + if (!status.ok()) { return status; + } return runtime_error_wrapper([&] { auto training_data_impl = diff --git a/bindings/cpp/src/dynamic_vamana_index_impl.h b/bindings/cpp/src/dynamic_vamana_index_impl.h index 50cb2093..f89e1bb5 100644 --- a/bindings/cpp/src/dynamic_vamana_index_impl.h +++ b/bindings/cpp/src/dynamic_vamana_index_impl.h @@ -69,6 +69,22 @@ class DynamicVamanaIndexImpl { size_t size() const { return impl_ ? impl_->size() : 0; } + size_t get_memory_usage() const { + return impl_ ? impl_->get_memory_breakdown().total() : 0; + } + + void get_memory_breakdown(MemoryBreakdown& out) const { + if (!impl_) { + out = MemoryBreakdown{}; + return; + } + + auto breakdown = impl_->get_memory_breakdown(); + out.graph_bytes = breakdown.graph_bytes; + out.data_bytes = breakdown.data_bytes; + out.metadata_bytes = breakdown.metadata_bytes; + } + size_t blocksize_bytes() const { return 1u << dynamic_index_params_.blocksize_exp; } size_t dimensions() const { return dim_; } diff --git a/bindings/cpp/src/vamana_index.cpp b/bindings/cpp/src/vamana_index.cpp index 195164c5..e3e5be58 100644 --- a/bindings/cpp/src/vamana_index.cpp +++ b/bindings/cpp/src/vamana_index.cpp @@ -104,6 +104,17 @@ struct VamanaIndexManagerBase : public VamanaIndex { impl_->reconstruct_at(dst, id_span); }); } + + size_t get_memory_usage() const noexcept override { return impl_->get_memory_usage(); } + + Status get_memory_breakdown(MemoryBreakdown* out) const noexcept override { + if (out == nullptr) { + return Status( + ErrorCode::INVALID_ARGUMENT, "memory breakdown output must not be null" + ); + } + return runtime_error_wrapper([&] { impl_->get_memory_breakdown(*out); }); + } }; } // namespace diff --git a/bindings/cpp/src/vamana_index_impl.h b/bindings/cpp/src/vamana_index_impl.h index b145b7f1..21a56abf 100644 --- a/bindings/cpp/src/vamana_index_impl.h +++ b/bindings/cpp/src/vamana_index_impl.h @@ -74,6 +74,22 @@ class VamanaIndexImpl { size_t size() const { return impl_ ? get_impl()->size() : 0; } + size_t get_memory_usage() const { + return impl_ ? get_impl()->get_memory_breakdown().total() : 0; + } + + void get_memory_breakdown(MemoryBreakdown& out) const { + if (!impl_) { + out = MemoryBreakdown{}; + return; + } + + auto breakdown = get_impl()->get_memory_breakdown(); + out.graph_bytes = breakdown.graph_bytes; + out.data_bytes = breakdown.data_bytes; + out.metadata_bytes = breakdown.metadata_bytes; + } + size_t dimensions() const { return dim_; } MetricType metric_type() const { return metric_type_; } diff --git a/bindings/cpp/tests/runtime_test.cpp b/bindings/cpp/tests/runtime_test.cpp index f0972b7f..4ebc25df 100644 --- a/bindings/cpp/tests/runtime_test.cpp +++ b/bindings/cpp/tests/runtime_test.cpp @@ -1483,3 +1483,130 @@ CATCH_TEST_CASE("ReconstructAtStatic", "[runtime][static_vamana]") { svs::runtime::v0::VamanaIndex::destroy(index); } + +CATCH_TEST_CASE("GetMemoryUsageDynamic", "[runtime][memory]") { + const auto& test_data = get_test_data(); + svs::runtime::v0::DynamicVamanaIndex* index = nullptr; + svs::runtime::v0::VamanaIndex::BuildParams build_params{64}; + svs::runtime::v0::Status status = svs::runtime::v0::DynamicVamanaIndex::build( + &index, + test_d, + svs::runtime::v0::MetricType::L2, + svs::runtime::v0::StorageKind::FP32, + build_params + ); + if (!svs::runtime::v0::DynamicVamanaIndex::check_storage_kind( + svs::runtime::v0::StorageKind::FP32 + ) + .ok()) { + CATCH_REQUIRE(!status.ok()); + CATCH_SKIP("Storage kind is not supported, skipping test."); + } + CATCH_REQUIRE(status.ok()); + CATCH_REQUIRE(index != nullptr); + + std::vector labels(test_n); + std::iota(labels.begin(), labels.end(), 0); + + status = index->add(test_n, labels.data(), test_data.data()); + CATCH_REQUIRE(status.ok()); + + // After adding points, the index must report non-zero memory usage and the + // component breakdown must sum to the same value. + const auto dynamic_usage = index->get_memory_usage(); + CATCH_REQUIRE(dynamic_usage > 0); + svs::runtime::v0::MemoryBreakdown dynamic_breakdown{}; + status = index->get_memory_breakdown(&dynamic_breakdown); + CATCH_REQUIRE(status.ok()); + CATCH_REQUIRE(dynamic_breakdown.graph_bytes > 0); + CATCH_REQUIRE(dynamic_breakdown.data_bytes > 0); + CATCH_REQUIRE(dynamic_breakdown.metadata_bytes > 0); + CATCH_REQUIRE(dynamic_breakdown.total() == dynamic_usage); + status = index->get_memory_breakdown(nullptr); + CATCH_REQUIRE(!status.ok()); + CATCH_REQUIRE(status.code == svs::runtime::v0::ErrorCode::INVALID_ARGUMENT); + + svs::runtime::v0::DynamicVamanaIndex::destroy(index); + + // A larger index (more points) should use at least as much memory as a + // smaller one built with the same storage kind / parameters. + svs::runtime::v0::DynamicVamanaIndex* small_index = nullptr; + status = svs::runtime::v0::DynamicVamanaIndex::build( + &small_index, + test_d, + svs::runtime::v0::MetricType::L2, + svs::runtime::v0::StorageKind::FP32, + build_params + ); + CATCH_REQUIRE(status.ok()); + CATCH_REQUIRE(small_index != nullptr); + + const size_t small_n = test_n / 2; + std::vector small_labels(small_n); + std::iota(small_labels.begin(), small_labels.end(), 0); + status = small_index->add(small_n, small_labels.data(), test_data.data()); + CATCH_REQUIRE(status.ok()); + const size_t small_usage = small_index->get_memory_usage(); + CATCH_REQUIRE(small_usage > 0); + + svs::runtime::v0::DynamicVamanaIndex* large_index = nullptr; + status = svs::runtime::v0::DynamicVamanaIndex::build( + &large_index, + test_d, + svs::runtime::v0::MetricType::L2, + svs::runtime::v0::StorageKind::FP32, + build_params + ); + CATCH_REQUIRE(status.ok()); + CATCH_REQUIRE(large_index != nullptr); + std::vector large_labels(test_n); + std::iota(large_labels.begin(), large_labels.end(), 0); + status = large_index->add(test_n, large_labels.data(), test_data.data()); + CATCH_REQUIRE(status.ok()); + const size_t large_usage = large_index->get_memory_usage(); + CATCH_REQUIRE(large_usage > 0); + + CATCH_REQUIRE(large_usage >= small_usage); + + svs::runtime::v0::DynamicVamanaIndex::destroy(small_index); + svs::runtime::v0::DynamicVamanaIndex::destroy(large_index); +} + +CATCH_TEST_CASE("GetMemoryUsageStatic", "[runtime][static_vamana][memory]") { + const auto& test_data = get_test_data(); + svs::runtime::v0::VamanaIndex* index = nullptr; + svs::runtime::v0::VamanaIndex::BuildParams build_params{64}; + svs::runtime::v0::Status status = svs::runtime::v0::VamanaIndex::build( + &index, + test_d, + svs::runtime::v0::MetricType::L2, + svs::runtime::v0::StorageKind::FP32, + build_params + ); + if (!svs::runtime::v0::VamanaIndex::check_storage_kind( + svs::runtime::v0::StorageKind::FP32 + ) + .ok()) { + CATCH_REQUIRE(!status.ok()); + CATCH_SKIP("Storage kind is not supported, skipping test."); + } + CATCH_REQUIRE(status.ok()); + CATCH_REQUIRE(index != nullptr); + + status = index->add(test_n, test_data.data()); + CATCH_REQUIRE(status.ok()); + + // After adding points, the static index must report non-zero memory usage and the + // component breakdown must sum to the same value. + const auto static_usage = index->get_memory_usage(); + CATCH_REQUIRE(static_usage > 0); + svs::runtime::v0::MemoryBreakdown static_breakdown{}; + status = index->get_memory_breakdown(&static_breakdown); + CATCH_REQUIRE(status.ok()); + CATCH_REQUIRE(static_breakdown.graph_bytes > 0); + CATCH_REQUIRE(static_breakdown.data_bytes > 0); + CATCH_REQUIRE(static_breakdown.metadata_bytes > 0); + CATCH_REQUIRE(static_breakdown.total() == static_usage); + + svs::runtime::v0::VamanaIndex::destroy(index); +} diff --git a/include/svs/core/data.h b/include/svs/core/data.h index ba8d85c1..386b6b10 100644 --- a/include/svs/core/data.h +++ b/include/svs/core/data.h @@ -153,6 +153,21 @@ class VectorDataLoader { // Matching rule for uncompressed data. namespace data::detail { + +/// @brief Return the number of bytes allocated for the backing storage of ``dataset``. +/// +/// Capacity-based accounting (the bytes the containers have reserved) is used whenever the +/// dataset exposes a ``capacity()`` accessor (e.g. flat and blocked ``SimpleData``), so +/// that block over-allocation is reflected. Datasets that do not expose ``capacity()`` +/// fall back to the number of live elements. +template size_t dataset_allocated_bytes(const Dataset& dataset) { + if constexpr (requires(const Dataset& d) { d.capacity(); }) { + return dataset.capacity() * dataset.element_size(); + } else { + return dataset.size() * dataset.element_size(); + } +} + template int64_t check_match(svs::DataType type, size_t dims) { // If the types don't match - then there is no match. if (type != svs::datatype_v) { diff --git a/include/svs/index/vamana/dynamic_index.h b/include/svs/index/vamana/dynamic_index.h index 5f0ce7c1..5078a170 100644 --- a/include/svs/index/vamana/dynamic_index.h +++ b/include/svs/index/vamana/dynamic_index.h @@ -321,6 +321,32 @@ 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 70a92135..239054fd 100644 --- a/include/svs/index/vamana/index.h +++ b/include/svs/index/vamana/index.h @@ -177,6 +177,14 @@ 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 Search scratchspace used by the Vamana index. /// @@ -744,6 +752,21 @@ 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 04507738..6d20476b 100644 --- a/include/svs/orchestrators/dynamic_vamana.h +++ b/include/svs/orchestrators/dynamic_vamana.h @@ -198,6 +198,11 @@ 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 c4c4422b..6b141ed0 100644 --- a/include/svs/orchestrators/vamana.h +++ b/include/svs/orchestrators/vamana.h @@ -50,6 +50,8 @@ 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; @@ -127,6 +129,10 @@ 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); } @@ -321,6 +327,11 @@ 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(); diff --git a/tests/svs/index/vamana/dynamic_index.cpp b/tests/svs/index/vamana/dynamic_index.cpp index 798a584c..7f64ce65 100644 --- a/tests/svs/index/vamana/dynamic_index.cpp +++ b/tests/svs/index/vamana/dynamic_index.cpp @@ -364,3 +364,43 @@ CATCH_TEST_CASE( } } } + +CATCH_TEST_CASE("MutableVamana Index Memory Usage", "[graph_index][dynamic_index]") { + const size_t num_threads = 2; + using Distance = svs::distance::DistanceL2; + + auto data = test_dataset::data_blocked_f32(); + const size_t data_size = data.size(); + // Expected data bytes are capacity-based; capture them before the dataset is moved + // into the index so the test can pin the exact value. + const size_t expected_data_bytes = data.capacity() * data.element_size(); + std::vector indices(data_size); + std::iota(indices.begin(), indices.end(), 0); + + svs::index::vamana::VamanaBuildParameters parameters{1.2, 64, 10, 20, 10, true}; + auto index = svs::index::vamana::MutableVamanaIndex( + parameters, std::move(data), indices, Distance(), num_threads + ); + + const size_t expected_graph_bytes = index.view_graph().get_data().capacity() * + index.view_graph().get_data().element_size(); + using Index = decltype(index); + const size_t expected_metadata_bytes = + data_size * sizeof(svs::index::vamana::SlotMetadata) + + sizeof(typename Index::internal_id_type) + + 2 * indices.size() * + (sizeof(typename Index::external_id_type) + + sizeof(typename Index::internal_id_type)); + const size_t expected_total_bytes = + expected_data_bytes + expected_graph_bytes + expected_metadata_bytes; + + // Dynamic get_memory_usage() should exactly match the capacity-based graph and data + // bytes plus the deterministic metadata implied by the input ids. + const auto breakdown = index.get_memory_breakdown(); + CATCH_REQUIRE(breakdown.graph_bytes == expected_graph_bytes); + CATCH_REQUIRE(breakdown.data_bytes == expected_data_bytes); + CATCH_REQUIRE(breakdown.metadata_bytes == expected_metadata_bytes); + CATCH_REQUIRE(breakdown.total() == expected_total_bytes); + const size_t usage = index.get_memory_breakdown().total(); + CATCH_REQUIRE(usage == expected_total_bytes); +} diff --git a/tests/svs/index/vamana/index.cpp b/tests/svs/index/vamana/index.cpp index c63bcefe..284bc68d 100644 --- a/tests/svs/index/vamana/index.cpp +++ b/tests/svs/index/vamana/index.cpp @@ -186,6 +186,51 @@ CATCH_TEST_CASE("Static VamanaIndex Per-Index Logging", "[logging]") { CATCH_REQUIRE(captured_logs[2].find("Batch Size:") != std::string::npos); } +CATCH_TEST_CASE("Vamana Index Memory Usage", "[vamana][index]") { + const size_t N = 128; + using Eltype = float; + const size_t graph_max_degree = 64; + auto data = svs::data::SimpleData::load(test_dataset::data_svs_file()); + + auto graph = svs::graphs::SimpleGraph(data.size(), graph_max_degree); + svs::distance::DistanceL2 distance_function; + uint32_t entry_point = 0; + auto threadpool = svs::threads::DefaultThreadPool(1); + + // Compute the expected allocated bytes directly from each container's own + // capacity()/element_size() before they are moved into the index, so the test pins + // the exact value rather than a lower bound. + const size_t expected_data_bytes = data.capacity() * data.element_size(); + const size_t expected_graph_bytes = + graph.get_data().capacity() * graph.get_data().element_size(); + + svs::index::vamana::VamanaBuildParameters buildParams( + 1.2, graph_max_degree, 10, 20, 10, true + ); + svs::index::vamana::VamanaIndex index( + buildParams, + std::move(graph), + std::move(data), + entry_point, + distance_function, + std::move(threadpool) + ); + + const size_t expected_metadata_bytes = sizeof(uint32_t); + const size_t expected_total_bytes = + expected_data_bytes + expected_graph_bytes + expected_metadata_bytes; + + // Static get_memory_usage() should exactly match the capacity-based bytes implied by + // the input graph, input data, and one entry-point id. + const auto breakdown = index.get_memory_breakdown(); + CATCH_REQUIRE(breakdown.graph_bytes == expected_graph_bytes); + CATCH_REQUIRE(breakdown.data_bytes == expected_data_bytes); + CATCH_REQUIRE(breakdown.metadata_bytes == expected_metadata_bytes); + CATCH_REQUIRE(breakdown.total() == expected_total_bytes); + const size_t usage = index.get_memory_breakdown().total(); + CATCH_REQUIRE(usage == expected_total_bytes); +} + CATCH_TEST_CASE("Vamana Index Save and Load", "[vamana][index][saveload]") { const size_t N = 128; using Eltype = float; diff --git a/tests/svs/orchestrators/dynamic_vamana.cpp b/tests/svs/orchestrators/dynamic_vamana.cpp index b35234eb..10951e7c 100644 --- a/tests/svs/orchestrators/dynamic_vamana.cpp +++ b/tests/svs/orchestrators/dynamic_vamana.cpp @@ -118,3 +118,47 @@ CATCH_TEST_CASE("DynamicVamana Build", "[managers][dynamic_vamana][build]") { } } } + +CATCH_TEST_CASE("DynamicVamana Memory Usage", "[managers][dynamic_vamana]") { + auto distance = svs::distance::DistanceL2(); + auto expected_result = test_dataset::vamana::expected_build_results( + distance, svsbenchmark::Uncompressed(svs::DataType::float32) + ); + auto build_params = expected_result.build_parameters_.value(); + size_t num_threads = 2; + + auto data = svs::data::SimpleData::load(test_dataset::data_svs_file()); + const size_t n = data.size(); + const size_t half = n / 2; + CATCH_REQUIRE(half > 0); + CATCH_REQUIRE(n - half > 0); + + // Build the index over the first half of the dataset (external IDs 0 .. half-1). + auto first_data = svs::data::SimpleData(half, data.dimensions()); + for (size_t i = 0; i < half; ++i) { + first_data.set_datum(i, data.get_datum(i)); + } + std::vector first_ids(half); + std::iota(first_ids.begin(), first_ids.end(), 0); + + svs::DynamicVamana index = svs::DynamicVamana::build( + build_params, std::move(first_data), first_ids, distance, num_threads + ); + + const size_t usage_before = index.get_memory_breakdown().total(); + CATCH_REQUIRE(usage_before > 0); + + // Add the second half of the dataset (external IDs half .. n-1). + const size_t rest = n - half; + auto second_data = svs::data::SimpleData(rest, data.dimensions()); + for (size_t i = 0; i < rest; ++i) { + second_data.set_datum(i, data.get_datum(half + i)); + } + std::vector second_ids(rest); + std::iota(second_ids.begin(), second_ids.end(), half); + index.add_points(second_data.cview(), second_ids); + + // Adding points must increase the reported allocation. + const size_t usage_after = index.get_memory_breakdown().total(); + CATCH_REQUIRE(usage_after > usage_before); +} diff --git a/tests/svs/orchestrators/vamana.cpp b/tests/svs/orchestrators/vamana.cpp index 18efb4ac..fdab19de 100644 --- a/tests/svs/orchestrators/vamana.cpp +++ b/tests/svs/orchestrators/vamana.cpp @@ -107,3 +107,39 @@ CATCH_TEST_CASE("Vamana Build", "[managers][vamana][build]") { } } } + +CATCH_TEST_CASE("Vamana Memory Usage", "[managers][vamana]") { + auto distance = svs::distance::DistanceL2(); + auto expected_result = test_dataset::vamana::expected_build_results( + distance, svsbenchmark::Uncompressed(svs::DataType::float32) + ); + auto build_params = expected_result.build_parameters_.value(); + size_t num_threads = 2; + + auto data = svs::data::SimpleData::load(test_dataset::data_svs_file()); + const size_t full_size = data.size(); + + // Build a full index and assert the reported allocation is non-zero. + svs::Vamana full = svs::Vamana::build( + build_params, + svs::data::SimpleData::load(test_dataset::data_svs_file()), + distance, + num_threads + ); + const size_t full_usage = full.get_memory_breakdown().total(); + CATCH_REQUIRE(full_usage > 0); + + // Monotonicity: an index built over fewer vectors must allocate fewer bytes. + const size_t half_size = full_size / 2; + CATCH_REQUIRE(half_size > 0); + auto half_data = svs::data::SimpleData(half_size, data.dimensions()); + for (size_t i = 0; i < half_size; ++i) { + half_data.set_datum(i, data.get_datum(i)); + } + svs::Vamana half = svs::Vamana::build( + build_params, std::move(half_data), distance, num_threads + ); + const size_t half_usage = half.get_memory_breakdown().total(); + CATCH_REQUIRE(half_usage > 0); + CATCH_REQUIRE(full_usage > half_usage); +} From 078846e3f76829b60f5c256806da1b90e385cda5 Mon Sep 17 00:00:00 2001 From: yuejiaointel <108152493+yuejiaointel@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:59:19 -0700 Subject: [PATCH 6/9] Update SVS_URL to nightly with get_memory_breakdown (#357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the pinned LTO prebuilt SVS library (`bindings/cpp/CMakeLists.txt`) from `svs-shared-library-lto-nightly-2026-05-21-1429` to `svs-shared-library-lto-nightly-2026-07-21-127`, which includes `get_memory_usage()` / `get_memory_breakdown()` (#345). ## Why The `Build and unit tests for C++ runtime bindings (with static library, ON)` job links the runtime bindings against this prebuilt lib. The previously-pinned nightly predated #345, so it failed: ``` bindings/cpp/src/dynamic_vamana_index_impl.h:73: error: no member named 'get_memory_breakdown' in 'svs::DynamicVamana' ``` The new nightly was built from `main` (post-#345, via the private-repo submodule bump intel-innersource#338) and ships the method — verified the tarball's headers contain `get_memory_breakdown`. This should turn that CI job green. Follows the pattern of #311 (Update SVS_URL in binaries). --- bindings/cpp/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/cpp/CMakeLists.txt b/bindings/cpp/CMakeLists.txt index 14aa58b5..832f1a67 100644 --- a/bindings/cpp/CMakeLists.txt +++ b/bindings/cpp/CMakeLists.txt @@ -138,7 +138,7 @@ if (SVS_RUNTIME_ENABLE_LVQ_LEANVEC) else() # Links to LTO-enabled static library, requires GCC/G++ 11.2 if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL "11.2" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS "11.3") - set(SVS_URL "https://github.com/intel/ScalableVectorSearch/releases/download/nightly/svs-shared-library-lto-nightly-2026-05-21-1429.tar.gz" + set(SVS_URL "https://github.com/intel/ScalableVectorSearch/releases/download/nightly/svs-shared-library-lto-nightly-2026-07-21-127.tar.gz" CACHE STRING "URL to download SVS shared library") else() message(WARNING From ba90104d4e9129a146390266d69eac64d415bbb2 Mon Sep 17 00:00:00 2001 From: ethanglaser Date: Mon, 27 Jul 2026 17:32:51 -0700 Subject: [PATCH 7/9] 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 8/9] 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 9/9] 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 );