diff --git a/.github/scripts/build-c-api-bindings.sh b/.github/scripts/build-c-api-bindings.sh new file mode 100755 index 000000000..e0a8a4956 --- /dev/null +++ b/.github/scripts/build-c-api-bindings.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 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. + +# Configure, build, install and package the C API bindings. +# +# Inputs (all optional, with defaults suitable for a local run): +# ENABLE_LVQ_LEANVEC ON to statically link the LVQ/LeanVec backend +# REQUIRE_LTO_ARCHIVE ON to fail (not warn) if the compiler can't consume the +# LTO archive; set in CI, left off for local builds +# SUFFIX artifact name suffix (e.g. -public-only) +# WORKSPACE repository root; defaults to this script's repo so it +# also runs outside the container + +set -e + +# In the manylinux/rockylinux containers the pinned gcc-toolset lives behind an +# scl profile script; harmless no-op on a plain runner. +source /etc/bashrc 2>/dev/null || true + +# Repo root, derived from this script's location so no git metadata is needed. +WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +BUILD_DIR="${WORKSPACE}/build_c_api" +INSTALL_DIR="${WORKSPACE}/install_c_api" +ENABLE_LVQ_LEANVEC="${ENABLE_LVQ_LEANVEC:-OFF}" +REQUIRE_LTO_ARCHIVE="${REQUIRE_LTO_ARCHIVE:-OFF}" + +echo "compiler: $(${CXX:-c++} --version | head -1)" + +rm -rf "${BUILD_DIR}" "${INSTALL_DIR}" + +cmake -B"${BUILD_DIR}" -S"${WORKSPACE}/bindings/c" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${INSTALL_DIR}" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DSVS_BUILD_C_API_TESTS=ON \ + -DSVS_RUNTIME_ENABLE_LVQ_LEANVEC="${ENABLE_LVQ_LEANVEC}" \ + -DSVS_REQUIRE_LTO_ARCHIVE="${REQUIRE_LTO_ARCHIVE}" + +cmake --build "${BUILD_DIR}" -j"$(nproc)" + +# Install only the C API component: the dependency headers that a full install +# would also emit are not part of the shipped interface. +cmake --install "${BUILD_DIR}" --component C_API + +tar -czf "${WORKSPACE}/svs-c-api${SUFFIX}.tar.gz" -C "${INSTALL_DIR}" . +echo "Packaged ${WORKSPACE}/svs-c-api${SUFFIX}.tar.gz" diff --git a/.github/scripts/build-cpp-runtime-bindings.sh b/.github/scripts/build-cpp-runtime-bindings.sh index c454eb72a..bbe56509c 100644 --- a/.github/scripts/build-cpp-runtime-bindings.sh +++ b/.github/scripts/build-cpp-runtime-bindings.sh @@ -50,6 +50,7 @@ CMAKE_ARGS=( "-DCMAKE_INSTALL_PREFIX=/workspace/install_cpp_bindings" "-DCMAKE_INSTALL_LIBDIR=lib" "-DSVS_RUNTIME_ENABLE_LVQ_LEANVEC=${ENABLE_LVQ_LEANVEC:-ON}" + "-DSVS_REQUIRE_LTO_ARCHIVE=${REQUIRE_LTO_ARCHIVE:-OFF}" "-DSVS_RUNTIME_ENABLE_IVF=ON" "-DSVS_EXPERIMENTAL_CLANG_TIDY=ON" ) diff --git a/.github/scripts/test-c-api-bindings.sh b/.github/scripts/test-c-api-bindings.sh new file mode 100755 index 000000000..81476a1e2 --- /dev/null +++ b/.github/scripts/test-c-api-bindings.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# 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. + +# Integration test for the packaged C API: verifies the tarball is a usable +# package rather than just a successful compile. Runs against the artifact only, +# with no access to the build tree. +# +# Inputs: +# SUFFIX artifact name suffix (e.g. -public-only) +# WORKSPACE repository root; defaults to this script's repo so it also runs +# outside the container + +set -e + +# Match build-c-api-bindings.sh: pick up the container's pinned gcc-toolset. +source /etc/bashrc 2>/dev/null || true + +# Repo root, derived from this script's location so no git metadata is needed. +WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +STAGE_DIR="${WORKSPACE}/c_api_integration" + +# Prefer the artifact downloaded by the workflow, else a tarball built locally. +TARBALL="${WORKSPACE}/c_api_artifact/svs-c-api${SUFFIX}.tar.gz" +if [ ! -e "${TARBALL}" ]; then + TARBALL="${WORKSPACE}/svs-c-api${SUFFIX}.tar.gz" +fi + +INSTALL_DIR="${STAGE_DIR}/install" +CONSUMER_BUILD="${STAGE_DIR}/consumer-build" + +rm -rf "${STAGE_DIR}" +mkdir -p "${INSTALL_DIR}" +tar -xzf "${TARBALL}" -C "${INSTALL_DIR}" + +echo "::group::Package contents" +find "${INSTALL_DIR}" -type f -o -type l | sort +echo "::endgroup::" + +LIBDIR="${INSTALL_DIR}/lib" +LIB="${LIBDIR}/libsvs_c_api.so" +if [ ! -e "${LIB}" ]; then + echo "ERROR: ${LIB} missing from the package" + exit 1 +fi + +echo "::group::Strong exported symbols" +nm -D --defined-only "${LIB}" | awk '$2=="T"{print $3}' | sort +echo "::endgroup::" + +# Only the documented svs_* C ABI may be exported with strong linkage. This also +# guards the statically linked LVQ/LeanVec backend against leaking symbols. +# +# std:: template instantiations (_ZNSt/_ZSt) are excluded: GCC emits some of these +# with strong linkage from the LTO archive, and they are standard-library code +# rather than SVS implementation detail. The check still catches any leak of an +# actual svs/proprietary internal. +LEAKED=$(nm -D --defined-only "${LIB}" | awk '$2=="T"{print $3}' \ + | grep -v '^svs_' | grep -vE '^_Z+(N?)St' || true) +if [ -n "${LEAKED}" ]; then + echo "ERROR: non-svs_ symbols exported from the C API:" + echo "${LEAKED}" + exit 1 +fi + +# Build a standalone C project against the installed CMake package, the way a +# downstream integration would. Catches exported-target defects (a missing +# find_dependency, or a C++ requirement leaking onto a C consumer) that a +# build-tree-only test cannot see. +cmake -B"${CONSUMER_BUILD}" -S"${WORKSPACE}/bindings/c/tests/consumer" \ + -DCMAKE_PREFIX_PATH="${INSTALL_DIR}" +cmake --build "${CONSUMER_BUILD}" + +LD_LIBRARY_PATH="${LIBDIR}" "${CONSUMER_BUILD}/c_api_consumer" diff --git a/.github/scripts/test-c-api-unit.sh b/.github/scripts/test-c-api-unit.sh new file mode 100755 index 000000000..7ea6e322e --- /dev/null +++ b/.github/scripts/test-c-api-unit.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# 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. + +# Run the C API unit tests and samples out of an existing build tree. +# +# Inputs: +# WORKSPACE repository root; defaults to this script's repo so it also runs +# outside the container + +set -e + +# Match build-c-api-bindings.sh: pick up the container's pinned gcc-toolset. +source /etc/bashrc 2>/dev/null || true + +# Repo root, derived from this script's location so no git metadata is needed. +WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +BUILD_DIR="${WORKSPACE}/build_c_api" + +# LVQ/LeanVec need a specific ISA. The tests already accept +# SVS_ERROR_UNSUPPORTED_HW (but never SVS_ERROR_NOT_IMPLEMENTED), so this is +# reported for triage rather than used to skip anything. +echo "vendor: $(grep -m1 vendor_id /proc/cpuinfo || echo unknown)" +echo "model: $(grep -m1 'model name' /proc/cpuinfo || echo unknown)" +echo "avx512: $(grep -o 'avx512[a-z_0-9]*' /proc/cpuinfo | sort -u | tr '\n' ' ')" + +ctest --test-dir "${BUILD_DIR}" --output-on-failure --no-tests=error + +# The samples are the only executable check that the public headers are usable +# from C and that an end-to-end build/search runs. They regressed to a non-zero +# exit once already, so they are part of the gate. +for sample in c_api_simple c_api_save_load c_api_dynamic; do + echo "::group::${sample}" + "${BUILD_DIR}/samples/${sample}" + echo "::endgroup::" +done 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-c-api-bindings.yml b/.github/workflows/build-c-api-bindings.yml index 782c06e14..a70c68ef1 100644 --- a/.github/workflows/build-c-api-bindings.yml +++ b/.github/workflows/build-c-api-bindings.yml @@ -17,7 +17,7 @@ name: Build and test C API bindings on: push: branches: - - main + - main pull_request: workflow_dispatch: @@ -26,57 +26,98 @@ permissions: # This allows a subsequently queued workflow run to interrupt previous runs concurrency: - group: ${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }} + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true jobs: - build: - name: ${{ matrix.cxx }}, ${{ matrix.build_type }} + build-c-api-bindings: + name: Build and unit tests for C API (${{ matrix.name }}) runs-on: ubuntu-22.04 strategy: matrix: - build_type: [RelWithDebInfo] - cxx: [g++-11, g++-12, clang++-15] + # Mirrors build-cpp-runtime-bindings.yml. include: - - cxx: g++-11 - cc: gcc-11 - - cxx: g++-12 - cc: gcc-12 - - cxx: clang++-15 - cc: clang-15 + - name: "with static library" + enable_lvq_leanvec: "ON" + require_lto: "ON" + suffix: "" + - name: "public only" + enable_lvq_leanvec: "OFF" + require_lto: "OFF" + suffix: "-public-only" fail-fast: false steps: - - uses: actions/checkout@v6 - - - name: Install OpenMP runtime - env: - CXX: ${{ matrix.cxx }} - run: | - sudo apt-get update - # The default libgomp shipped with GCC does not match the clang - # toolchain, so install the LLVM OpenMP runtime (libomp) for clang. - if [[ "${CXX}" == clang* ]]; then - sudo apt-get install -y libomp-15-dev - fi - - - name: Configure build - working-directory: ${{ runner.temp }} - env: - CXX: ${{ matrix.cxx }} - CC: ${{ matrix.cc }} - TEMP_WORKSPACE: ${{ runner.temp }} - run: | - cmake -B${TEMP_WORKSPACE}/build -S${GITHUB_WORKSPACE}/bindings/c \ - -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ - -DSVS_BUILD_C_API_TESTS=ON - - - name: Build C API, tests and samples - working-directory: ${{ runner.temp }}/build - run: make -j$(nproc) - - - name: Run C API tests - env: - CTEST_OUTPUT_ON_FAILURE: 1 - working-directory: ${{ runner.temp }}/build - run: ctest -C ${{ matrix.build_type }} --output-on-failure + - uses: actions/checkout@v6 + + - name: Build Docker image + run: docker build -t svs-manylinux228:latest -f docker/x86_64/manylinux228/Dockerfile . + + - name: Build C API bindings in Docker container + run: | + docker run --rm \ + -v ${{ github.workspace }}:/workspace \ + -w /workspace \ + -e ENABLE_LVQ_LEANVEC=${{ matrix.enable_lvq_leanvec }} \ + -e REQUIRE_LTO_ARCHIVE=${{ matrix.require_lto }} \ + -e SUFFIX=${{ matrix.suffix }} \ + svs-manylinux228:latest \ + /bin/bash .github/scripts/build-c-api-bindings.sh + + - name: Upload C API bindings artifacts + uses: actions/upload-artifact@v7 + with: + name: svs-c-api${{ matrix.suffix }} + path: svs-c-api${{ matrix.suffix }}.tar.gz + retention-days: 7 + + # Run unit tests that were built as part of this job + - name: Run unit tests in Docker container + run: | + docker run --rm \ + -v ${{ github.workspace }}:/workspace \ + -w /workspace \ + svs-manylinux228:latest \ + /bin/bash /workspace/.github/scripts/test-c-api-unit.sh + + # Run integration tests against the packaged artifact. Eventually this should + # run the setup and test scope of the actual downstream integrations; for now it + # just confirms the tarball is functional - it installs, exports only the svs_* + # C ABI, and can be consumed from a standalone C project. + test: + name: Integration tests for C API (${{ matrix.name }}) + needs: build-c-api-bindings + runs-on: ubuntu-22.04 + strategy: + matrix: + include: + - name: "with static library" + suffix: "" + - name: "public only" + suffix: "-public-only" + fail-fast: false + + steps: + - uses: actions/checkout@v6 + + - name: Build Docker image + run: docker build -t svs-manylinux228:latest -f docker/x86_64/manylinux228/Dockerfile . + + # Need to download for a new job + - name: Download C API package + uses: actions/download-artifact@v8 + with: + name: svs-c-api${{ matrix.suffix }} + path: c_api_artifact + + - name: List available artifacts + run: ls -la c_api_artifact/ + + - name: Test packaged C API in Docker container + run: | + docker run --rm \ + -v ${{ github.workspace }}:/workspace \ + -w /workspace \ + -e SUFFIX=${{ matrix.suffix }} \ + svs-manylinux228:latest \ + /bin/bash .github/scripts/test-c-api-bindings.sh diff --git a/.github/workflows/build-cpp-runtime-bindings.yml b/.github/workflows/build-cpp-runtime-bindings.yml index 468789bf5..f13a540e5 100644 --- a/.github/workflows/build-cpp-runtime-bindings.yml +++ b/.github/workflows/build-cpp-runtime-bindings.yml @@ -38,9 +38,11 @@ jobs: include: - name: "with static library" enable_lvq_leanvec: "ON" + require_lto: "ON" suffix: "" - name: "public only" enable_lvq_leanvec: "OFF" + require_lto: "OFF" suffix: "-public-only" fail-fast: false @@ -57,6 +59,7 @@ jobs: -v ${{ github.workspace }}:/workspace \ -w /workspace \ -e ENABLE_LVQ_LEANVEC=${{ matrix.enable_lvq_leanvec }} \ + -e REQUIRE_LTO_ARCHIVE=${{ matrix.require_lto }} \ -e SUFFIX=${{ matrix.suffix }} \ svs-manylinux228:latest \ /bin/bash .github/scripts/build-cpp-runtime-bindings.sh @@ -125,4 +128,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 diff --git a/.github/workflows/build-macos.yaml b/.github/workflows/build-macos.yaml index d53872d79..e31fabad6 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 diff --git a/bindings/c/CMakeLists.txt b/bindings/c/CMakeLists.txt index b88ab589e..863a68cee 100644 --- a/bindings/c/CMakeLists.txt +++ b/bindings/c/CMakeLists.txt @@ -24,6 +24,7 @@ set(SVS_C_API_HEADERS set(SVS_C_API_SOURCES src/algorithm.hpp src/error.hpp + src/filtered_search.hpp src/index.hpp src/index_builder.hpp src/storage.hpp @@ -47,7 +48,10 @@ target_include_directories(${TARGET_NAME} PRIVATE ) find_package(OpenMP REQUIRED) -target_link_libraries(${TARGET_NAME} PUBLIC OpenMP::OpenMP_CXX) +# PRIVATE: OpenMP is an implementation detail linked into the shared library. +# Exporting it would force consumers of the C ABI to resolve a C++ OpenMP +# target they never asked for. +target_link_libraries(${TARGET_NAME} PRIVATE OpenMP::OpenMP_CXX) target_compile_options(${TARGET_NAME} PRIVATE -DSVS_ENABLE_OMP=1 @@ -59,7 +63,10 @@ if(UNIX AND NOT APPLE) target_link_options(${TARGET_NAME} PRIVATE "SHELL:-Wl,--exclude-libs,ALL") endif() -target_compile_features(${TARGET_NAME} INTERFACE cxx_std_20) +# C++20 is required to build this library, but not to consume it: the public +# surface is a C ABI. Keep the requirement PRIVATE so that pure-C consumers are +# not forced to compile as C++20. +target_compile_features(${TARGET_NAME} PRIVATE cxx_std_20) if (NOT DEFINED SVS_CXX_STANDARD OR SVS_CXX_STANDARD STREQUAL "") set(SVS_CXX_STANDARD 20) endif() @@ -73,6 +80,11 @@ target_link_libraries(${TARGET_NAME} PRIVATE svs::svs ) +# The non-LTO fallback below is correct but slower, so nothing fails and CI stays +# green. Set this where the LTO archive is the point (CI) to make the drop an error. +option(SVS_REQUIRE_LTO_ARCHIVE + "Fail instead of warn when the compiler cannot consume the LVQ/LeanVec LTO archive" OFF) + if (SVS_RUNTIME_ENABLE_LVQ_LEANVEC) message(STATUS "Enabling LVQ/LeanVec support in C API") target_compile_definitions(${TARGET_NAME} PRIVATE SVS_RUNTIME_ENABLE_LVQ_LEANVEC) @@ -109,7 +121,15 @@ if (SVS_RUNTIME_ENABLE_LVQ_LEANVEC) set(SVS_URL "https://github.com/intel/ScalableVectorSearch/releases/download/nightly/svs-shared-library-lto-nightly-2026-02-05-1017.tar.gz" CACHE STRING "URL to download SVS shared library") else() - message(WARNING + # The fallback is correct but slower, so nothing downstream fails and CI + # stays green. Set SVS_REQUIRE_LTO_ARCHIVE=ON where the LTO archive is + # the point (the containerised CI job) to make the drop an error. + if(SVS_REQUIRE_LTO_ARCHIVE) + set(SVS_LTO_MESSAGE_LEVEL FATAL_ERROR) + else() + set(SVS_LTO_MESSAGE_LEVEL WARNING) + endif() + message(${SVS_LTO_MESSAGE_LEVEL} "Pre-built LVQ/LeanVec SVS library requires GCC/G++ v.11.2 to apply LTO optimizations." "Current compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}" ) @@ -117,9 +137,15 @@ if (SVS_RUNTIME_ENABLE_LVQ_LEANVEC) CACHE STRING "URL to download SVS shared library") endif() include(FetchContent) + # DOWNLOAD_EXTRACT_TIMESTAMP needs CMake 3.24+; 3.22 is still around locally. + set(SVS_FETCH_EXTRA_ARGS) + if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24") + list(APPEND SVS_FETCH_EXTRA_ARGS DOWNLOAD_EXTRACT_TIMESTAMP TRUE) + endif() FetchContent_Declare( svs URL ${SVS_URL} + ${SVS_FETCH_EXTRA_ARGS} ) FetchContent_MakeAvailable(svs) list(APPEND CMAKE_PREFIX_PATH "${svs_SOURCE_DIR}") diff --git a/bindings/c/include/svs/c_api/svs_c.h b/bindings/c/include/svs/c_api/svs_c.h index 7fd397d01..dccccb970 100644 --- a/bindings/c/include/svs/c_api/svs_c.h +++ b/bindings/c/include/svs/c_api/svs_c.h @@ -89,6 +89,20 @@ struct svs_threadpool_interface { void* self; }; +struct svs_id_filter_interface_ops { + bool (*is_member)(void* self, size_t id); +}; + +struct svs_id_filter_interface { + struct svs_id_filter_interface_ops ops; + void* self; + // filter_rate provides the estimated selectivity of the filter, i.e., the fraction of + // IDs that are expected to pass the filter. A value of 0.01 indicates that 1% of IDs + // are expected to pass, while a value of 1.0 indicates that all IDs are expected to + // pass. If the filter does not provide an estimate, it should be set to 0.0. + float filter_rate; +}; + /// @brief Structure to hold search results struct svs_search_results { size_t num_queries; /// Number of query vectors @@ -97,6 +111,13 @@ struct svs_search_results { float* distances; /// Distances to the nearest neighbors }; +/// @brief Structure to hold memory breakdown for an index +struct svs_memory_breakdown { + size_t graph_bytes; /// Allocated bytes for the graph structure + size_t data_bytes; /// Allocated bytes for the data vectors + size_t metadata_bytes; /// Allocated bytes for metadata (entry points, status, etc.) +}; + // Handle typedefs; "_h" suffix indicates a handle to an opaque struct typedef struct svs_error_desc* svs_error_h; typedef struct svs_index* svs_index_h; @@ -113,7 +134,9 @@ typedef enum svs_data_type svs_data_type_t; typedef enum svs_threadpool_kind svs_threadpool_kind_t; typedef struct svs_threadpool_interface* svs_threadpool_i; +typedef struct svs_id_filter_interface* svs_id_filter_i; typedef struct svs_search_results* svs_search_results_t; +typedef struct svs_memory_breakdown svs_memory_breakdown_t; /// @brief Create an error handle /// @return A handle to the created error object @@ -398,6 +421,10 @@ SVS_API void svs_index_free(svs_index_h index); /// @param search_params The search parameters handle (can be NULL for defaults) /// @param out_err An optional error handle to capture errors /// @return A pointer to the search results structure +/// @deprecated Use svs_index_search_topK() instead, which additionally supports an +/// optional ID filter. This function is equivalent to calling svs_index_search_topK() +/// with a NULL id_filter. +SVS_DEPRECATED("Use svs_index_search_topK() instead") SVS_API svs_search_results_t svs_index_search( svs_index_h index, const float* queries, @@ -407,6 +434,36 @@ SVS_API svs_search_results_t svs_index_search( svs_error_h out_err /*=NULL*/ ); +/// @brief TopK search the index with the provided queries and an optional ID filter +/// @details Performs a TopK search on the index with the provided queries and an optional +/// ID filter. The ID filter allows for filtering the search results based on specific IDs, +/// enabling more targeted searches. If the ID filter is NULL, the search will return the +/// top K results. If ID filter is provided, only the results that pass the filter will be +/// returned. The function returns a pointer to the search results structure, which contains +/// the indices and distances of the nearest neighbors for each query. If ID filter is +/// provided with `filter_rate > 0.0` then the function will account for the actual filter +/// hit rate during the search. If the actual observed filter hit rate is less than the +/// provided `filter_rate` value, the function returns an empty result set. +/// @note The search results structure must be freed using svs_search_results_free() to +/// avoid memory leaks. +/// @param index The index handle +/// @param queries Pointer to the query data (float array) +/// @param num_queries The number of query vectors +/// @param k The number of nearest neighbors to retrieve per query +/// @param search_params The search parameters handle (can be NULL for defaults) +/// @param id_filter The ID filter interface (can be NULL for no filtering) +/// @param out_err An optional error handle to capture errors +/// @return A pointer to the search results structure +SVS_API svs_search_results_t svs_index_search_topK( + svs_index_h index, + const float* queries, + size_t num_queries, + size_t k, + svs_search_params_h search_params /*=NULL*/, + svs_id_filter_i id_filter /*=NULL*/, + svs_error_h out_err /*=NULL*/ +); + /// @brief Free the search results structure /// @param results The search results structure to release SVS_API void svs_search_results_free(svs_search_results_t results); @@ -528,6 +585,28 @@ SVS_API bool svs_index_set_num_threads( svs_index_h index, size_t num_threads, svs_error_h out_err /*=NULL*/ ); +/// @brief Get the total memory usage of the index in bytes +/// @param index The index handle +/// @param out_bytes Pointer to store the total memory usage in bytes +/// @param out_err An optional error handle to capture errors +/// @return true on success, false on failure +/// @remarks This returns the sum of graph_bytes + data_bytes + metadata_bytes +SVS_API bool svs_index_get_memory_usage( + svs_index_h index, size_t* out_bytes, svs_error_h out_err /*=NULL*/ +); + +/// @brief Get the memory breakdown for the index +/// @param index The index handle +/// @param out_breakdown Pointer to store the memory breakdown structure +/// @param out_err An optional error handle to capture errors +/// @return true on success, false on failure +/// @remarks The breakdown reports allocated memory for graph, data, and metadata +/// components. Uses capacity-based accounting for datasets that support it, reflecting +/// the true memory footprint including over-allocation. +SVS_API bool svs_index_get_memory_breakdown( + svs_index_h index, svs_memory_breakdown_t* out_breakdown, svs_error_h out_err /*=NULL*/ +); + #ifdef __cplusplus } #endif diff --git a/bindings/c/include/svs/c_api/svs_c_config.h b/bindings/c/include/svs/c_api/svs_c_config.h index 003985855..14de7efb4 100644 --- a/bindings/c/include/svs/c_api/svs_c_config.h +++ b/bindings/c/include/svs/c_api/svs_c_config.h @@ -35,3 +35,12 @@ #else #define SVS_API SVS_HELPER_DLL_IMPORT #endif + +// Mark an API as deprecated, optionally providing a message for callers. +#if defined _WIN32 || defined __CYGWIN__ +#define SVS_DEPRECATED(msg) __declspec(deprecated(msg)) +#elif defined __GNUC__ || defined __clang__ +#define SVS_DEPRECATED(msg) __attribute__((deprecated(msg))) +#else +#define SVS_DEPRECATED(msg) +#endif diff --git a/bindings/c/samples/dynamic.c b/bindings/c/samples/dynamic.c index 3de193fe4..960e45bd7 100644 --- a/bindings/c/samples/dynamic.c +++ b/bindings/c/samples/dynamic.c @@ -130,6 +130,23 @@ int main() { // storage = svs_storage_create_sq(SVS_DATA_TYPE_INT8, error); + // LeanVec/LVQ are only available in builds that include the compression + // backend. When they are unavailable the build reports NOT_IMPLEMENTED (or + // UNSUPPORTED_HW on hardware lacking the required ISA); fall back to simple + // storage so this sample stays runnable against a public build. + if (!storage) { + svs_error_code_t code = svs_error_get_code(error); + if (code == SVS_ERROR_NOT_IMPLEMENTED || code == SVS_ERROR_UNSUPPORTED_HW) { + fprintf( + stderr, + "LeanVec storage unavailable (%s); falling back to simple float32 " + "storage.\n", + svs_error_get_message(error) + ); + storage = svs_storage_create_simple(SVS_DATA_TYPE_FLOAT32, error); + } + } + if (!storage) { fprintf(stderr, "Failed to create storage: %s\n", svs_error_get_message(error)); ret = 1; @@ -205,7 +222,9 @@ int main() { // Search printf("Searching %d queries for top-%d neighbors...\n", NUM_QUERIES, K); - results = svs_index_search(index, queries, NUM_QUERIES, K, search_params, error); + results = svs_index_search_topK( + index, queries, NUM_QUERIES, K, search_params, NULL /* id_filter */, error + ); if (!results) { fprintf(stderr, "Failed to search index: %s\n", svs_error_get_message(error)); ret = 1; @@ -250,7 +269,9 @@ int main() { // Search again after deletion printf("Searching again after deletion...\n"); - results = svs_index_search(index, queries, NUM_QUERIES, K, search_params, error); + results = svs_index_search_topK( + index, queries, NUM_QUERIES, K, search_params, NULL /* id_filter */, error + ); if (!results) { fprintf( stderr, diff --git a/bindings/c/samples/save_load.c b/bindings/c/samples/save_load.c index 43aaf1ab2..2315020a1 100644 --- a/bindings/c/samples/save_load.c +++ b/bindings/c/samples/save_load.c @@ -130,6 +130,23 @@ int main() { // storage = svs_storage_create_sq(SVS_DATA_TYPE_INT8, error); + // LeanVec/LVQ are only available in builds that include the compression + // backend. When they are unavailable the build reports NOT_IMPLEMENTED (or + // UNSUPPORTED_HW on hardware lacking the required ISA); fall back to simple + // storage so this sample stays runnable against a public build. + if (!storage) { + svs_error_code_t code = svs_error_get_code(error); + if (code == SVS_ERROR_NOT_IMPLEMENTED || code == SVS_ERROR_UNSUPPORTED_HW) { + fprintf( + stderr, + "LeanVec storage unavailable (%s); falling back to simple float32 " + "storage.\n", + svs_error_get_message(error) + ); + storage = svs_storage_create_simple(SVS_DATA_TYPE_FLOAT32, error); + } + } + if (!storage) { fprintf(stderr, "Failed to create storage: %s\n", svs_error_get_message(error)); ret = 1; @@ -166,8 +183,15 @@ int main() { // Search printf("Searching %d queries for top-%d neighbors...\n", NUM_QUERIES, K); - results = - svs_index_search(index, queries, NUM_QUERIES, K, NULL /* search_params */, error); + results = svs_index_search_topK( + index, + queries, + NUM_QUERIES, + K, + NULL /* search_params */, + NULL /* id_filter */, + error + ); if (!results) { fprintf(stderr, "Failed to search index: %s\n", svs_error_get_message(error)); ret = 1; @@ -208,8 +232,15 @@ int main() { printf( "Searching loaded index for %d queries for top-%d neighbors...\n", NUM_QUERIES, K ); - loaded_results = - svs_index_search(index, queries, NUM_QUERIES, K, NULL /* search_params */, error); + loaded_results = svs_index_search_topK( + index, + queries, + NUM_QUERIES, + K, + NULL /* search_params */, + NULL /* id_filter */, + error + ); if (!loaded_results) { fprintf( stderr, "Failed to search loaded index: %s\n", svs_error_get_message(error) diff --git a/bindings/c/samples/simple.c b/bindings/c/samples/simple.c index da7884655..8b79dc9b9 100644 --- a/bindings/c/samples/simple.c +++ b/bindings/c/samples/simple.c @@ -120,6 +120,23 @@ int main() { // storage = svs_storage_create_sq(SVS_DATA_TYPE_INT8, error); + // LeanVec/LVQ are only available in builds that include the compression + // backend. When they are unavailable the build reports NOT_IMPLEMENTED (or + // UNSUPPORTED_HW on hardware lacking the required ISA); fall back to simple + // storage so this sample stays runnable against a public build. + if (!storage) { + svs_error_code_t code = svs_error_get_code(error); + if (code == SVS_ERROR_NOT_IMPLEMENTED || code == SVS_ERROR_UNSUPPORTED_HW) { + fprintf( + stderr, + "LeanVec storage unavailable (%s); falling back to simple float32 " + "storage.\n", + svs_error_get_message(error) + ); + storage = svs_storage_create_simple(SVS_DATA_TYPE_FLOAT32, error); + } + } + if (!storage) { fprintf(stderr, "Failed to create storage: %s\n", svs_error_get_message(error)); ret = 1; @@ -173,7 +190,9 @@ int main() { // Search printf("Searching %d queries for top-%d neighbors...\n", NUM_QUERIES, K); - results = svs_index_search(index, queries, NUM_QUERIES, K, search_params, error); + results = svs_index_search_topK( + index, queries, NUM_QUERIES, K, search_params, NULL /* id_filter */, error + ); if (!results) { fprintf(stderr, "Failed to search index: %s\n", svs_error_get_message(error)); ret = 1; diff --git a/bindings/c/src/filtered_search.hpp b/bindings/c/src/filtered_search.hpp new file mode 100644 index 000000000..b75f4e692 --- /dev/null +++ b/bindings/c/src/filtered_search.hpp @@ -0,0 +1,232 @@ +/* + * 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 + +#include "svs/c_api/svs_c.h" + +#include "types_support.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace svs::c_runtime { + +/// @brief Estimate the batch size for filtered search based on the number of total +/// candidates, hits, goal, hint, and limit. +/// @param total The total number of candidates. +/// @param hits The number of filter hits. +/// @param goal The target number of hits to achieve. +/// @param hint A hint for the batch size - usually based on prior knowledge. +/// @param limit The maximum allowed batch size. E.g. index size. +/// @return The estimated batch size. +inline size_t +estimate_batch_size(size_t total, size_t hits, size_t goal, size_t hint, size_t limit) { + assert(total >= hits); + assert(goal > 0); + if (total == 0 || hits == 0 || hits >= goal) { + return std::min(hint, limit); + } + const auto hit_rate_inv = static_cast(total) / static_cast(hits); + size_t estimated = static_cast(static_cast(goal - hits) * hit_rate_inv); + estimated = std::max(estimated, size_t{1}); + return std::min(estimated, limit); +} + +/// @brief Check if the actual hit rate is sufficient based on the minimum required filter +/// rate. +/// @param total The total number of candidates. +/// @param hits The number of filter hits. +/// @param filter_rate The minimum required filter rate. +/// @return True if the hit rate is sufficient, false otherwise. +inline bool hit_rate_sufficient(size_t total, size_t hits, float filter_rate) { + // by default, assume that the hit rate is sufficient + if (filter_rate <= 0.0f || total == 0) { + return true; + } + const auto hit_rate = static_cast(hits) / static_cast(total); + return hit_rate >= filter_rate; +} + +/// @brief Estimate the initial batch size for filtered search based on the actual filter +/// rate by generating sample IDs and filtering them through the ID filter. +/// @param id_filter The ID filter interface. +/// @param sample_generator A function that generates sample IDs. +/// @param min_sample_size The minimum sample size to consider. +/// @param goal The target number of hits to achieve - usually is K (from TopK). +/// @param hint A hint for the batch size - usually based on prior knowledge. +/// @param limit The maximum allowed batch size. E.g. index size. +/// @return The estimated initial batch size, or 0 if the hit rate is insufficient. +inline size_t estimate_initial_batch_size( + const IDFilterInterface* id_filter, + std::function sample_generator, + size_t min_sample_size, + size_t goal, + size_t hint, + size_t limit +) { + assert(id_filter != nullptr); + const auto filter_rate = id_filter->filter_rate(); + if (filter_rate <= 0.0f) { + // If filter rate is 0.0 or negative, return the `hint` as the initial batch size - + // clamped to `limit` to avoid oversizing. + return std::min(hint, limit); + } + + auto sample_size = + std::max({goal, min_sample_size, static_cast(1.f / filter_rate)}); + assert(sample_size > 0); + size_t hits = 0; + for (size_t i = 0; i < sample_size; ++i) { + size_t id = sample_generator(); + // Stop if the sample generator returns an invalid ID + if (id == static_cast(-1)) { + sample_size = i; // Adjust sample size to the number of valid samples + break; + } + if (id_filter->is_member(id)) { + hits++; + } + } + // if hit rate is less than filter_rate, return 0 - which means we should not even start + // the search + if (!hit_rate_sufficient(sample_size, hits, filter_rate)) { + return 0; + } + return estimate_batch_size(sample_size, hits, goal, hint, limit); +} + +/// @brief Pad the result with empty neighbors starting from a specific index. +/// @param result The query result to pad. +/// @param query_index The index of the query within the result. +/// @param neighbor_start The starting index of neighbors to pad. +inline void +pad_result(svs::QueryResult& result, size_t query_index, size_t neighbor_start) { + assert(query_index < result.n_queries()); + assert(neighbor_start <= result.n_neighbors()); + + static constexpr svs::Neighbor empty_neighbor{ + static_cast(-1), std::numeric_limits::infinity()}; + + for (size_t i = neighbor_start; i < result.n_neighbors(); ++i) { + result.set(empty_neighbor, query_index, i); + } +} + +/// @brief Set the query result to an empty state, with all distances set to infinity and +/// all indices set to -1. +/// @param result The query result to set as empty. +inline void set_empty_result(svs::QueryResult& result) { + std::fill( + result.distances().begin(), + result.distances().end(), + std::numeric_limits::infinity() + ); + std::fill(result.indices().begin(), result.indices().end(), static_cast(-1)); +} + +// Perform a filtered nearest-neighbor search by iterating over batches of candidates and +// keeping only those that pass the filter. The batch size is estimated adaptively based on +// the observed hit rate. Results are written into `results`. +template +void filtered_topk_search( + IndexType& index, + svs::QueryResult& results, + svs::data::ConstSimpleDataView queries, + size_t initial_batch_hint, + const IDFilterInterface* id_filter, + const std::function& sample_generator +) { + // Minimum number of samples to estimate the filter hit rate. This is a trade-off + // between accuracy and performance. A larger sample size gives a more accurate + // estimate of the filter hit rate, but takes longer to compute. + const size_t MIN_SAMPLE_SIZE = 200; + + // Filtered search: we need to estimate the batch size based on the filter rate and + // the number of hits + const auto num_neighbors = results.n_neighbors(); + const auto index_size = index.size(); + + auto initial_batch_size = estimate_initial_batch_size( + id_filter, + sample_generator, + MIN_SAMPLE_SIZE, + num_neighbors, + initial_batch_hint, + index_size + ); + if (initial_batch_size == 0) { + // If the batch size is 0, it means that the filter rate is too low than + // expected and we should not even start the search + set_empty_result(results); + return; + } + + const auto filter_rate = id_filter->filter_rate(); + + auto search_closure = [&](const auto& range, uint64_t SVS_UNUSED(tid)) { + for (auto i : range) { + auto query = queries.get_datum(i); + auto iterator = index.batch_iterator(query); + size_t found = 0; + size_t total_checked = 0; + auto batch_size = initial_batch_size; + do { + batch_size = estimate_batch_size( + total_checked, found, num_neighbors, batch_size, index_size + ); + iterator.next(batch_size); + total_checked += iterator.size(); + for (auto& neighbor : iterator.results()) { + if (id_filter->is_member(neighbor.id())) { + results.set(neighbor, i, found); + found++; + if (found == num_neighbors) { + break; + } + } + } + // TODO: clarify the contract here - should we return partial or no + // result if the hit rate is too low + if (found < num_neighbors && + !hit_rate_sufficient(total_checked, found, filter_rate)) { + found = 0; + break; + } + } while (found < num_neighbors && !iterator.done()); + + // Pad results if not enough neighbors found + pad_result(results, i, found); + } + }; + + svs::threads::parallel_for( + index.get_threadpool_handle(), + svs::threads::StaticPartition{queries.size()}, + search_closure + ); +} + +} // namespace svs::c_runtime diff --git a/bindings/c/src/index.hpp b/bindings/c/src/index.hpp index c38d0c6dc..59ae0184d 100644 --- a/bindings/c/src/index.hpp +++ b/bindings/c/src/index.hpp @@ -18,6 +18,7 @@ #include "svs/c_api/svs_c.h" #include "algorithm.hpp" +#include "filtered_search.hpp" #include "threadpool.hpp" #include @@ -29,10 +30,12 @@ #include #include +#include #include #include namespace svs::c_runtime { + struct Index { svs_algorithm_type algorithm; ThreadPoolBuilder pool_builder; @@ -43,7 +46,8 @@ struct Index { virtual svs::QueryResult search( svs::data::ConstSimpleDataView queries, size_t num_neighbors, - const std::shared_ptr& search_params + const std::shared_ptr& search_params, + const IDFilterInterface* id_filter = nullptr ) = 0; virtual void save(const std::filesystem::path& directory) = 0; virtual size_t dimensions() const = 0; @@ -52,6 +56,7 @@ struct Index { reconstruct_at(svs::data::SimpleDataView dst, std::span ids) = 0; virtual size_t get_num_threads() const = 0; virtual void set_num_threads(size_t num_threads) = 0; + virtual svs::index::vamana::MemoryBreakdown get_memory_breakdown() const = 0; }; struct DynamicIndex : public Index { @@ -77,7 +82,8 @@ struct IndexVamana : public Index { svs::QueryResult search( svs::data::ConstSimpleDataView queries, size_t num_neighbors, - const std::shared_ptr& search_params + const std::shared_ptr& search_params, + const IDFilterInterface* id_filter ) override { auto vamana_search_params = std::static_pointer_cast(search_params); @@ -88,7 +94,21 @@ struct IndexVamana : public Index { params = vamana_search_params->get_search_parameters(); } - index.search(results.view(), queries, params); + if (id_filter == nullptr) { + index.search(results.view(), queries, params); + return results; + } + + std::mt19937 rng(42); + std::uniform_int_distribution dist(0, index.size() - 1); + auto sample_generator = [&]() -> size_t { return dist(rng); }; + + auto batch_hint = + std::max(num_neighbors, params.buffer_config_.get_search_window_size()); + + filtered_topk_search( + index, results, queries, batch_hint, id_filter, sample_generator + ); return results; } @@ -113,19 +133,35 @@ struct IndexVamana : public Index { pool_builder.resize(num_threads); index.set_threadpool(pool_builder.build()); } + + svs::index::vamana::MemoryBreakdown get_memory_breakdown() const override { + return index.get_memory_breakdown(); + } }; struct DynamicIndexVamana : public DynamicIndex { svs::DynamicVamana index; + size_t min_id = 0; // Track the minimum ID added to the index + size_t max_id = 0; // Track the maximum ID added to the index DynamicIndexVamana(svs::DynamicVamana&& index, ThreadPoolBuilder pool_builder) : DynamicIndex(SVS_ALGORITHM_TYPE_VAMANA, pool_builder) - , index(std::move(index)) {} + , index(std::move(index)) { + auto all_ids = this->index.all_ids(); + assert( + !all_ids.empty() && + "DynamicVamana index should have at least one ID after construction." + ); + auto [min_it, max_it] = std::minmax_element(all_ids.begin(), all_ids.end()); + min_id = (min_it == all_ids.end()) ? 0 : *min_it; + max_id = (max_it == all_ids.end()) ? 0 : *max_it; + } ~DynamicIndexVamana() = default; svs::QueryResult search( svs::data::ConstSimpleDataView queries, size_t num_neighbors, - const std::shared_ptr& search_params + const std::shared_ptr& search_params, + const IDFilterInterface* id_filter ) override { auto vamana_search_params = std::static_pointer_cast(search_params); @@ -136,7 +172,42 @@ struct DynamicIndexVamana : public DynamicIndex { params = vamana_search_params->get_search_parameters(); } - index.search(results.view(), queries, params); + if (id_filter == nullptr) { + index.search(results.view(), queries, params); + return results; + } + + std::mt19937 rng(42); + std::uniform_int_distribution dist(min_id, max_id); + // DynamicVamana index IDs provided by user and may have any values and gaps, so we + // need to sample until we find a valid ID. + // The most reliable way would be get all IDs and sample from them, but that may be + // expensive for large indexes. So we sample from the range of IDs and check if they + // exist in the index. If not, we sample again. We limit the number of attempts to + // avoid infinite loops in case of sparse IDs. The maximum number of + // attempts is set to the ratio of the ID range to the index size, or at least 4 + // attempts. This ensures that we have a reasonable chance of finding a valid ID + // without excessive sampling. + // Note: (index.size() + 1) - to avoid division by zero in case the index is empty. + const size_t max_attempts = + std::max((max_id - min_id) / (index.size() + 1), size_t{4}); + + auto sample_generator = [&]() -> size_t { + for (size_t attempt = 0; attempt < max_attempts; ++attempt) { + size_t id = dist(rng); + if (index.has_id(id)) { + return id; + } + } + return static_cast(-1); // Return an invalid ID if no valid ID is found + }; + + auto batch_hint = + std::max(num_neighbors, params.buffer_config_.get_search_window_size()); + + filtered_topk_search( + index, results, queries, batch_hint, id_filter, sample_generator + ); return results; } @@ -149,6 +220,14 @@ struct DynamicIndexVamana : public DynamicIndex { size_t add_points( svs::data::ConstSimpleDataView new_points, std::span ids ) override { + // Track the maximum ID added to the index for ids generator + auto [min_it, max_it] = std::minmax_element(ids.begin(), ids.end()); + if (min_it != ids.end()) { + min_id = std::min(min_id, *min_it); + } + if (max_it != ids.end()) { + max_id = std::max(max_id, *max_it); + } auto old_size = index.size(); index.add_points(new_points, ids); // TODO: This is a bit of a hack - we should ideally return the number of points @@ -199,5 +278,9 @@ struct DynamicIndexVamana : public DynamicIndex { pool_builder.resize(num_threads); index.set_threadpool(pool_builder.build()); } + + svs::index::vamana::MemoryBreakdown get_memory_breakdown() const override { + return index.get_memory_breakdown(); + } }; } // namespace svs::c_runtime diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp index 85f2fa3ef..40baf635d 100644 --- a/bindings/c/src/svs_c.cpp +++ b/bindings/c/src/svs_c.cpp @@ -564,6 +564,22 @@ extern "C" svs_search_results_t svs_index_search( size_t k, svs_search_params_h search_params, svs_error_h out_err +) { + // Deprecated: delegate to svs_index_search_topK without an ID filter to avoid + // duplicating the search and result-marshalling logic. + return svs_index_search_topK( + index, queries, num_queries, k, search_params, nullptr, out_err + ); +} + +extern "C" svs_search_results_t svs_index_search_topK( + svs_index_h index, + const float* queries, + size_t num_queries, + size_t k, + svs_search_params_h search_params, + svs_id_filter_i id_filter, + svs_error_h out_err ) { using namespace svs::c_runtime; return wrap_exceptions( @@ -579,8 +595,13 @@ extern "C" svs_search_results_t svs_index_search( queries, num_queries, index_ptr->dimensions() ); + IDFilterAdapter id_filter_adapter(id_filter); + auto search_results = index_ptr->search( - queries_view, k, search_params == nullptr ? nullptr : search_params->impl + queries_view, + k, + search_params == nullptr ? nullptr : search_params->impl, + id_filter == nullptr ? nullptr : &id_filter_adapter ); svs_search_results_t results = @@ -821,3 +842,42 @@ svs_index_set_num_threads(svs_index_h index, size_t num_threads, svs_error_h out false ); } + +extern "C" bool +svs_index_get_memory_usage(svs_index_h index, size_t* out_bytes, svs_error_h out_err) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(index); + EXPECT_ARG_NOT_NULL(out_bytes); + auto& index_ptr = index->impl; + INVALID_ARGUMENT_IF(index_ptr == nullptr, "Invalid index handle"); + auto breakdown = index_ptr->get_memory_breakdown(); + *out_bytes = breakdown.total(); + return true; + }, + out_err, + false + ); +} + +extern "C" bool svs_index_get_memory_breakdown( + svs_index_h index, svs_memory_breakdown_t* out_breakdown, svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(index); + EXPECT_ARG_NOT_NULL(out_breakdown); + auto& index_ptr = index->impl; + INVALID_ARGUMENT_IF(index_ptr == nullptr, "Invalid index handle"); + auto breakdown = index_ptr->get_memory_breakdown(); + out_breakdown->graph_bytes = breakdown.graph_bytes; + out_breakdown->data_bytes = breakdown.data_bytes; + out_breakdown->metadata_bytes = breakdown.metadata_bytes; + return true; + }, + out_err, + false + ); +} diff --git a/bindings/c/src/types_support.hpp b/bindings/c/src/types_support.hpp index b3f9b38b3..5b261324b 100644 --- a/bindings/c/src/types_support.hpp +++ b/bindings/c/src/types_support.hpp @@ -55,5 +55,47 @@ inline svs::DataType to_data_type(svs_data_type_t data_type) { } } +struct IDFilterInterface { + virtual ~IDFilterInterface() = default; + virtual bool is_member(size_t id) const = 0; + // filter_rate() returns the estimated selectivity of the filter, i.e., the fraction of + // IDs that are expected to pass the filter. A value of 0.01 indicates that 1% of IDs + // are expected to pass, while a value of 1.0 indicates that all IDs are expected to + // pass. If the filter does not provide an estimate, it should return 0.0. + virtual float filter_rate() const = 0; + bool operator()(size_t id) const { return is_member(id); } +}; + +struct IDFilterAdapter : public IDFilterInterface { + const svs_id_filter_i c_filter; + + IDFilterAdapter(const svs_id_filter_i filter) + : c_filter(filter) { + if (c_filter != nullptr) { + const auto rate = c_filter->filter_rate; + if (rate < 0.0f || rate > 1.0f) { + throw std::invalid_argument( + "Filter rate must be between 0.0 and 1.0, inclusive." + ); + } + } + } + + bool is_member(size_t id) const override { + if (c_filter == nullptr || c_filter->ops.is_member == nullptr) { + return true; // If no filter is provided, consider all IDs as valid + } + return c_filter->ops.is_member(c_filter->self, id); + } + + float filter_rate() const override { + // If no filter is provided or the filter rate is NaN, return 0.0 + if (c_filter == nullptr || std::isnan(c_filter->filter_rate)) { + return 0.0f; // If no filter is provided, return 0.0 + } + return c_filter->filter_rate; + } +}; + } // namespace c_runtime } // namespace svs diff --git a/bindings/c/tests/CMakeLists.txt b/bindings/c/tests/CMakeLists.txt index 186450697..a8ca24db2 100644 --- a/bindings/c/tests/CMakeLists.txt +++ b/bindings/c/tests/CMakeLists.txt @@ -61,6 +61,12 @@ target_link_libraries(${TARGET_NAME} PRIVATE Catch2::Catch2WithMain ) +# Tell the tests which storage backends this build is expected to provide, so +# LVQ/LeanVec assertions are real instead of accepting NOT_IMPLEMENTED. +if(SVS_RUNTIME_ENABLE_LVQ_LEANVEC) + target_compile_definitions(${TARGET_NAME} PRIVATE SVS_TEST_EXPECT_LVQ_LEANVEC) +endif() + # Set C++ standard target_compile_features(${TARGET_NAME} PRIVATE cxx_std_20) set_target_properties(${TARGET_NAME} PROPERTIES diff --git a/bindings/c/tests/c_api_dynamic_index.cpp b/bindings/c/tests/c_api_dynamic_index.cpp index 050726c3d..bc1a95652 100644 --- a/bindings/c/tests/c_api_dynamic_index.cpp +++ b/bindings/c/tests/c_api_dynamic_index.cpp @@ -277,7 +277,7 @@ CATCH_TEST_CASE("C API Dynamic Index", "[c_api][index][dynamic]") { generate_test_data(queries, 2, DIMENSION); svs_search_results_t results = - svs_index_search(index, queries.data(), 2, K, nullptr, error); + svs_index_search_topK(index, queries.data(), 2, K, nullptr, nullptr, error); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results->num_queries == 2); @@ -343,8 +343,9 @@ CATCH_TEST_CASE("C API Dynamic Index", "[c_api][index][dynamic]") { std::vector queries; generate_test_data(queries, 2, DIMENSION); - svs_search_results_t results = - svs_index_search(loaded_index, queries.data(), 2, K, nullptr, error); + svs_search_results_t results = svs_index_search_topK( + loaded_index, queries.data(), 2, K, nullptr, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results->num_queries == 2); @@ -354,7 +355,204 @@ CATCH_TEST_CASE("C API Dynamic Index", "[c_api][index][dynamic]") { svs_index_free(index); } + CATCH_SECTION("Memory Accounting Functions") { + // Build dynamic index + svs_index_h index = svs_index_build_dynamic( + builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + // Test get_memory_usage + size_t memory_usage = 0; + success = svs_index_get_memory_usage(index, &memory_usage, error); + CATCH_REQUIRE(success); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(memory_usage > 0); + + // Test get_memory_breakdown + svs_memory_breakdown_t breakdown; + success = svs_index_get_memory_breakdown(index, &breakdown, error); + CATCH_REQUIRE(success); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(breakdown.graph_bytes > 0); + CATCH_REQUIRE(breakdown.data_bytes > 0); + CATCH_REQUIRE(breakdown.metadata_bytes > 0); + + // Verify that breakdown.total() == memory_usage + size_t total = + breakdown.graph_bytes + breakdown.data_bytes + breakdown.metadata_bytes; + CATCH_REQUIRE(total == memory_usage); + + svs_index_free(index); + } + svs_index_builder_free(builder); svs_algorithm_free(algorithm); svs_error_free(error); } + +namespace { + +// ID filter callback: accepts odd IDs only (~50% selectivity). +bool filter_is_odd(void* /*self*/, size_t id) { return (id % 2) == 1; } + +// ID filter callback: accepts IDs strictly below the threshold stored in `self`. +// Used to model a restrictive, low-selectivity filter. +bool filter_below_threshold(void* self, size_t id) { + return id < *static_cast(self); +} + +} // namespace + +CATCH_TEST_CASE( + "C API Dynamic Filtered Search topK", "[c_api][index][dynamic][search][filter]" +) { + const size_t NUM_VECTORS = 1000; + const size_t NUM_QUERIES = 5; + const size_t DIMENSION = 32; + const size_t K = 10; + const size_t NUM_THREADS = 4; + const size_t BLOCK_SIZE = 1024 * 1024; // 1 MB block size for testing + + std::vector data; + std::vector queries; + std::vector ids(NUM_VECTORS); + generate_test_data(data, NUM_VECTORS, DIMENSION); + generate_test_data(queries, NUM_QUERIES, DIMENSION); + for (size_t i = 0; i < NUM_VECTORS; ++i) { + ids[i] = i; + } + + CATCH_SECTION("Normal filter for odd IDs") { + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + svs_index_h index = svs_index_build_dynamic( + builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_params_h search_params = svs_search_params_create_vamana(50, error); + CATCH_REQUIRE(search_params != nullptr); + + // ~50% of the IDs pass the filter. Provide a conservative filter_rate estimate + // (below the true selectivity) so the search is not short-circuited. + svs_id_filter_interface id_filter{}; + id_filter.ops.is_member = &filter_is_odd; + id_filter.self = nullptr; + id_filter.filter_rate = 0.4f; + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, &id_filter, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + + for (size_t q = 0; q < NUM_QUERIES; ++q) { + CATCH_REQUIRE(results->results_per_query[q] == K); + for (size_t j = 0; j < K; ++j) { + size_t idx = results->indices[q * K + j]; + // Every neighbor must be a valid, in-range odd ID. + CATCH_REQUIRE(idx != static_cast(-1)); + CATCH_REQUIRE(idx < NUM_VECTORS); + CATCH_REQUIRE((idx % 2) == 1); + // Distances must be finite and non-decreasing. + CATCH_REQUIRE(std::isfinite(results->distances[q * K + j])); + if (j > 0) { + CATCH_REQUIRE( + results->distances[q * K + j] >= results->distances[q * K + j - 1] + ); + } + } + } + + svs_search_results_free(results); + svs_search_params_free(search_params); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } + + CATCH_SECTION("Low-rate (restrictive) filter") { + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + svs_index_h index = svs_index_build_dynamic( + builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_params_h search_params = svs_search_params_create_vamana(100, error); + CATCH_REQUIRE(search_params != nullptr); + + // Only the lowest 10% of the IDs pass the filter. Provide a conservative + // filter_rate (below the true selectivity) so the search keeps iterating instead + // of giving up early. + size_t max_valid_id = NUM_VECTORS / 10; + svs_id_filter_interface id_filter{}; + id_filter.ops.is_member = &filter_below_threshold; + id_filter.self = &max_valid_id; + id_filter.filter_rate = 0.05f; + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, &id_filter, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + + size_t total_found = 0; + for (size_t q = 0; q < NUM_QUERIES; ++q) { + CATCH_REQUIRE(results->results_per_query[q] == K); + for (size_t j = 0; j < K; ++j) { + size_t idx = results->indices[q * K + j]; + // Padding (unspecified) entries are allowed for a restrictive filter, but + // any specified neighbor must pass the filter predicate. + if (idx != static_cast(-1)) { + CATCH_REQUIRE(idx < max_valid_id); + CATCH_REQUIRE(std::isfinite(results->distances[q * K + j])); + ++total_found; + } + } + } + // The restrictive filter still has plenty of matching vectors, so the search must + // return at least some valid neighbors. + CATCH_REQUIRE(total_found > 0); + + svs_search_results_free(results); + svs_search_params_free(search_params); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } +} diff --git a/bindings/c/tests/c_api_index.cpp b/bindings/c/tests/c_api_index.cpp index 6bd19d298..d9ab2446f 100644 --- a/bindings/c/tests/c_api_index.cpp +++ b/bindings/c/tests/c_api_index.cpp @@ -71,8 +71,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") CATCH_REQUIRE(svs_error_ok(error)); // Perform search - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, search_params, error); + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); @@ -126,8 +127,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") CATCH_REQUIRE(index != nullptr); // Search without explicit search parameters (uses defaults) - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, 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); @@ -165,8 +167,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); CATCH_REQUIRE(index != nullptr); - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, 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(results->num_queries == NUM_QUERIES); @@ -209,8 +212,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") CATCH_REQUIRE(index != nullptr); CATCH_REQUIRE(svs_error_ok(error)); - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, 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); @@ -231,23 +235,22 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") DIMENSION / 2, SVS_DATA_TYPE_INT4, SVS_DATA_TYPE_INT8, error ); CATCH_REQUIRE(check_storage_support(storage, error) == true); - if (storage != nullptr) { + if (storage_usable(storage)) { run_build_and_search(storage); } // LVQ: primary = int4, residual = int8 storage = svs_storage_create_lvq(SVS_DATA_TYPE_INT4, SVS_DATA_TYPE_INT8, error); CATCH_REQUIRE(check_storage_support(storage, error) == true); - if (storage != nullptr) { + if (storage_usable(storage)) { run_build_and_search(storage); } - // Scalar Quantization: int8 + // Scalar Quantization is available in every build - require it to work. storage = svs_storage_create_sq(SVS_DATA_TYPE_INT8, error); - CATCH_REQUIRE(check_storage_support(storage, error) == true); - if (storage != nullptr) { - run_build_and_search(storage); - } + CATCH_REQUIRE(storage != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + run_build_and_search(storage); svs_error_free(error); } @@ -273,10 +276,49 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") CATCH_REQUIRE(svs_error_ok(error)); // Verify index works with custom threadpool + 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)); + + svs_search_results_free(results); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } + + CATCH_SECTION("Deprecated svs_index_search wrapper") { + svs_error_h error = svs_error_create(); + + 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); + + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + // Intentionally exercise the deprecated API to ensure the wrapper still delegates + // correctly to svs_index_search_topK. +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif svs_search_results_t results = svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, error); +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); svs_search_results_free(results); svs_index_free(index); @@ -382,8 +424,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") size_t k_values[] = {1, 5, 10, 20}; for (size_t i = 0; i < sizeof(k_values) / sizeof(k_values[0]); ++i) { size_t k = k_values[i]; - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, k, nullptr, 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(results->num_queries == NUM_QUERIES); @@ -418,8 +461,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") // Perform multiple searches for (size_t i = 0; i < 3; ++i) { - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, 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); @@ -477,8 +521,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") std::vector queries; generate_test_data(queries, 2, DIMENSION); - svs_search_results_t results = - svs_index_search(loaded_index, queries.data(), 2, K, nullptr, error); + svs_search_results_t results = svs_index_search_topK( + loaded_index, queries.data(), 2, K, nullptr, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results->num_queries == 2); @@ -720,4 +765,232 @@ CATCH_TEST_CASE("C API Threadpool Management", "[c_api][index][threadpool]") { svs_algorithm_free(algorithm); svs_error_free(error); } + + CATCH_SECTION("Memory Accounting Functions") { + svs_error_h error = svs_error_create(); + + // Create algorithm + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + // Create builder + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + bool success = + svs_index_builder_set_threadpool(builder, SVS_THREADPOOL_KIND_NATIVE, 4, error); + CATCH_REQUIRE(success); + CATCH_REQUIRE(svs_error_ok(error)); + + // Build index + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + // Test get_memory_usage + size_t memory_usage = 0; + success = svs_index_get_memory_usage(index, &memory_usage, error); + CATCH_REQUIRE(success); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(memory_usage > 0); + + // Test get_memory_breakdown + svs_memory_breakdown_t breakdown; + success = svs_index_get_memory_breakdown(index, &breakdown, error); + CATCH_REQUIRE(success); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(breakdown.graph_bytes > 0); + CATCH_REQUIRE(breakdown.data_bytes > 0); + CATCH_REQUIRE(breakdown.metadata_bytes >= 0); + + // Verify that breakdown.total() == memory_usage + size_t total = + breakdown.graph_bytes + breakdown.data_bytes + breakdown.metadata_bytes; + CATCH_REQUIRE(total == memory_usage); + + // Test null-arg handling for get_memory_usage + svs_error_h error2 = svs_error_create(); + success = svs_index_get_memory_usage(nullptr, &memory_usage, error2); + CATCH_REQUIRE(success == false); + svs_error_free(error2); + + error2 = svs_error_create(); + success = svs_index_get_memory_usage(index, nullptr, error2); + CATCH_REQUIRE(success == false); + svs_error_free(error2); + + // Test null-arg handling for get_memory_breakdown + error2 = svs_error_create(); + success = svs_index_get_memory_breakdown(nullptr, &breakdown, error2); + CATCH_REQUIRE(success == false); + svs_error_free(error2); + + error2 = svs_error_create(); + success = svs_index_get_memory_breakdown(index, nullptr, error2); + CATCH_REQUIRE(success == false); + svs_error_free(error2); + + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } +} + +namespace { + +// ID filter callback: accepts odd IDs only (~50% selectivity). +bool filter_is_odd(void* /*self*/, size_t id) { return (id % 2) == 1; } + +// ID filter callback: accepts IDs strictly below the threshold stored in `self`. +// Used to model a restrictive, low-selectivity filter. +bool filter_below_threshold(void* self, size_t id) { + return id < *static_cast(self); +} + +} // namespace + +CATCH_TEST_CASE("C API Filtered Search topK", "[c_api][index][search][filter]") { + const size_t NUM_VECTORS = 1000; + const size_t NUM_QUERIES = 5; + const size_t DIMENSION = 32; + const size_t K = 10; + const size_t NUM_THREADS = 4; + + std::vector data; + std::vector queries; + generate_test_data(data, NUM_VECTORS, DIMENSION); + generate_test_data(queries, NUM_QUERIES, DIMENSION); + + CATCH_SECTION("Normal filter for odd IDs") { + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + 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_params_h search_params = svs_search_params_create_vamana(50, error); + CATCH_REQUIRE(search_params != nullptr); + + // ~50% of the IDs pass the filter. Provide a conservative filter_rate estimate + // (below the true selectivity) so the search is not short-circuited. + svs_id_filter_interface id_filter{}; + id_filter.ops.is_member = &filter_is_odd; + id_filter.self = nullptr; + id_filter.filter_rate = 0.4f; + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, &id_filter, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + + for (size_t q = 0; q < NUM_QUERIES; ++q) { + CATCH_REQUIRE(results->results_per_query[q] == K); + for (size_t j = 0; j < K; ++j) { + size_t idx = results->indices[q * K + j]; + // Every neighbor must be a valid, in-range odd ID. + CATCH_REQUIRE(idx != static_cast(-1)); + CATCH_REQUIRE(idx < NUM_VECTORS); + CATCH_REQUIRE((idx % 2) == 1); + // Distances must be finite and non-decreasing. + CATCH_REQUIRE(std::isfinite(results->distances[q * K + j])); + if (j > 0) { + CATCH_REQUIRE( + results->distances[q * K + j] >= results->distances[q * K + j - 1] + ); + } + } + } + + svs_search_results_free(results); + svs_search_params_free(search_params); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } + + CATCH_SECTION("Low-rate (restrictive) filter") { + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + 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_params_h search_params = svs_search_params_create_vamana(100, error); + CATCH_REQUIRE(search_params != nullptr); + + // Only the lowest 10% of the IDs pass the filter. Provide a conservative + // filter_rate (below the true selectivity) so the search keeps iterating instead + // of giving up early. + size_t max_valid_id = NUM_VECTORS / 10; + svs_id_filter_interface id_filter{}; + id_filter.ops.is_member = &filter_below_threshold; + id_filter.self = &max_valid_id; + id_filter.filter_rate = 0.05f; + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, &id_filter, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + + size_t total_found = 0; + for (size_t q = 0; q < NUM_QUERIES; ++q) { + CATCH_REQUIRE(results->results_per_query[q] == K); + for (size_t j = 0; j < K; ++j) { + size_t idx = results->indices[q * K + j]; + // Padding (unspecified) entries are allowed for a restrictive filter, but + // any specified neighbor must pass the filter predicate. + if (idx != static_cast(-1)) { + CATCH_REQUIRE(idx < max_valid_id); + CATCH_REQUIRE(std::isfinite(results->distances[q * K + j])); + ++total_found; + } + } + } + // The restrictive filter still has plenty of matching vectors, so the search must + // return at least some valid neighbors. + CATCH_REQUIRE(total_found > 0); + + svs_search_results_free(results); + svs_search_params_free(search_params); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } } diff --git a/bindings/c/tests/c_api_storage.cpp b/bindings/c/tests/c_api_storage.cpp index 22953e442..da7aae6c8 100644 --- a/bindings/c/tests/c_api_storage.cpp +++ b/bindings/c/tests/c_api_storage.cpp @@ -132,8 +132,10 @@ CATCH_TEST_CASE("C API Storage", "[c_api][storage]") { CATCH_SECTION("Scalar Quantization Storage UINT8") { svs_error_h error = svs_error_create(); + // Scalar quantization is part of the public build - always expect success. svs_storage_h storage = svs_storage_create_sq(SVS_DATA_TYPE_UINT8, error); - CATCH_REQUIRE(check_storage_support(storage, error) == true); + CATCH_REQUIRE(storage != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); svs_storage_free(storage); svs_error_free(error); @@ -142,8 +144,10 @@ CATCH_TEST_CASE("C API Storage", "[c_api][storage]") { CATCH_SECTION("Scalar Quantization Storage INT8") { svs_error_h error = svs_error_create(); + // Scalar quantization is part of the public build - always expect success. svs_storage_h storage = svs_storage_create_sq(SVS_DATA_TYPE_INT8, error); - CATCH_REQUIRE(check_storage_support(storage, error) == true); + CATCH_REQUIRE(storage != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); svs_storage_free(storage); svs_error_free(error); diff --git a/bindings/c/tests/c_api_test_utils.h b/bindings/c/tests/c_api_test_utils.h index c1f488f4f..3920d039e 100644 --- a/bindings/c/tests/c_api_test_utils.h +++ b/bindings/c/tests/c_api_test_utils.h @@ -133,11 +133,32 @@ inline float cosine_distance(const float* a, const float* b, size_t dim) { return dot_product / (std::sqrt(norm_a) * std::sqrt(norm_b)); } +/// Check that a compressed-storage constructor behaved as this build should. +/// +/// Previously this accepted SVS_ERROR_NOT_IMPLEMENTED unconditionally, which made +/// every LVQ/LeanVec assertion pass vacuously in a public build - the tests could +/// not distinguish "compression works" from "compression is absent". The expected +/// outcome depends on how the library was configured: +/// +/// * compression compiled in -> success, or SVS_ERROR_UNSUPPORTED_HW when the +/// host CPU lacks the required ISA. NOT_IMPLEMENTED is a failure here. +/// * compression compiled out -> exactly SVS_ERROR_NOT_IMPLEMENTED. Silently +/// succeeding would mean the build flag did not take effect. inline bool check_storage_support(svs_storage_h storage, svs_error_h error) { - if (storage == nullptr) { - auto code = svs_error_get_code(error); - return code == SVS_ERROR_NOT_IMPLEMENTED || code == SVS_ERROR_UNSUPPORTED_HW; - } else { +#ifdef SVS_TEST_EXPECT_LVQ_LEANVEC + if (storage != nullptr) { return svs_error_ok(error) == true; } + // Accept only a genuine hardware limitation, never a missing implementation. + return svs_error_get_code(error) == SVS_ERROR_UNSUPPORTED_HW; +#else + if (storage != nullptr) { + return false; // compression should not be available in a public build + } + return svs_error_get_code(error) == SVS_ERROR_NOT_IMPLEMENTED; +#endif } + +/// True when compressed storage is expected to be usable on this host, so callers +/// can skip the build/search portion of a test that cannot run. +inline bool storage_usable(svs_storage_h storage) { return storage != nullptr; } diff --git a/bindings/c/tests/consumer/CMakeLists.txt b/bindings/c/tests/consumer/CMakeLists.txt new file mode 100644 index 000000000..c02eb9773 --- /dev/null +++ b/bindings/c/tests/consumer/CMakeLists.txt @@ -0,0 +1,26 @@ +# 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. + +# Standalone project that consumes the *installed* C API package the same way a +# downstream integration would. It is deliberately not part of the C API build: +# it must be configured against an install tree so that a broken exported +# target (a missing find_dependency, or a C++ requirement leaking onto a C +# consumer) fails here instead of in the downstream project. +cmake_minimum_required(VERSION 3.21) +project(svs_c_api_consumer LANGUAGES C) + +find_package(svs_c_api REQUIRED) + +add_executable(c_api_consumer main.c) +target_link_libraries(c_api_consumer PRIVATE svs::svs_c_api) diff --git a/bindings/c/tests/consumer/main.c b/bindings/c/tests/consumer/main.c new file mode 100644 index 000000000..6f6cc0b39 --- /dev/null +++ b/bindings/c/tests/consumer/main.c @@ -0,0 +1,87 @@ +/* + * 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. + */ + +/* + * Smoke test for the installed C API package. + * + * This is compiled as C (not C++) on purpose: it proves the shipped headers are + * valid C and that consuming the exported CMake target does not drag C++ flags + * or unresolved C++ dependencies into a plain C project. It also reports which + * compressed storage backends the installed library provides, which is the + * capability a downstream integration has to branch on today. + */ + +#include "svs/c_api/svs_c.h" + +#include +#include + +/* Report whether a storage backend is available, without treating an absent + * proprietary backend as a failure. */ +static void report(const char* name, svs_storage_h storage, svs_error_h error) { + if (storage != NULL) { + printf("%-24s available\n", name); + svs_storage_free(storage); + return; + } + + svs_error_code_t code = svs_error_get_code(error); + const char* reason = "unavailable"; + if (code == SVS_ERROR_NOT_IMPLEMENTED) { + reason = "not built in"; + } else if (code == SVS_ERROR_UNSUPPORTED_HW) { + reason = "unsupported hardware"; + } + printf("%-24s %s (%s)\n", name, reason, svs_error_get_message(error)); +} + +int main(void) { + svs_error_h error = svs_error_create(); + if (error == NULL) { + fprintf(stderr, "failed to create an error handle\n"); + return EXIT_FAILURE; + } + + /* Simple storage is part of every build, so treat its absence as fatal: + * it is the minimum proof that the installed library actually works. */ + svs_storage_h simple = svs_storage_create_simple(SVS_DATA_TYPE_FLOAT32, error); + if (simple == NULL) { + fprintf( + stderr, "failed to create simple storage: %s\n", svs_error_get_message(error) + ); + svs_error_free(error); + return EXIT_FAILURE; + } + printf("%-24s available\n", "simple/float32"); + svs_storage_free(simple); + + report("sq/int8", svs_storage_create_sq(SVS_DATA_TYPE_INT8, error), error); + + report( + "lvq/int8", + svs_storage_create_lvq(SVS_DATA_TYPE_INT8, SVS_DATA_TYPE_VOID, error), + error + ); + + report( + "leanvec/int8", + svs_storage_create_leanvec(64, SVS_DATA_TYPE_INT8, SVS_DATA_TYPE_INT8, error), + error + ); + + svs_error_free(error); + return EXIT_SUCCESS; +} diff --git a/bindings/cpp/CMakeLists.txt b/bindings/cpp/CMakeLists.txt index 14aa58b52..08f4baa2a 100644 --- a/bindings/cpp/CMakeLists.txt +++ b/bindings/cpp/CMakeLists.txt @@ -58,6 +58,11 @@ else() message(STATUS "SVS runtime will be built without IVF support") endif() +# The non-LTO fallback below is correct but slower, so nothing fails and CI stays +# green. Set this where the LTO archive is the point (CI) to make the drop an error. +option(SVS_REQUIRE_LTO_ARCHIVE + "Fail instead of warn when the compiler cannot consume the LVQ/LeanVec LTO archive" OFF) + option(SVS_RUNTIME_ENABLE_LVQ_LEANVEC "Enable compilation of SVS runtime with LVQ and LeanVec support" ON) if (SVS_RUNTIME_ENABLE_LVQ_LEANVEC) message(STATUS "SVS runtime will be built with LVQ support") @@ -138,10 +143,15 @@ 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 + if(SVS_REQUIRE_LTO_ARCHIVE) + set(SVS_LTO_MESSAGE_LEVEL FATAL_ERROR) + else() + set(SVS_LTO_MESSAGE_LEVEL WARNING) + endif() + message(${SVS_LTO_MESSAGE_LEVEL} "Pre-built LVQ/LeanVec SVS library requires GCC/G++ v.11.2 to apply LTO optimizations." "Current compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}" ) @@ -149,10 +159,15 @@ if (SVS_RUNTIME_ENABLE_LVQ_LEANVEC) CACHE STRING "URL to download SVS shared library") endif() include(FetchContent) + # DOWNLOAD_EXTRACT_TIMESTAMP needs CMake 3.24+; 3.22 is still around locally. + set(SVS_FETCH_EXTRA_ARGS) + if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24") + list(APPEND SVS_FETCH_EXTRA_ARGS DOWNLOAD_EXTRACT_TIMESTAMP TRUE) + endif() FetchContent_Declare( svs URL ${SVS_URL} - DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ${SVS_FETCH_EXTRA_ARGS} ) FetchContent_MakeAvailable(svs) list(APPEND CMAKE_PREFIX_PATH "${svs_SOURCE_DIR}") diff --git a/bindings/cpp/include/svs/runtime/vamana_index.h b/bindings/cpp/include/svs/runtime/vamana_index.h index 180daf55c..0f11edec6 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 47366481c..0e807bd98 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 50cb20932..f89e1bb59 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 195164c50..e3e5be589 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 b145b7f11..21a56abfc 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 f0972b7f0..4ebc25df7 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 ba8d85c15..386b6b109 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/core/data/simple.h b/include/svs/core/data/simple.h index 251f30559..333356211 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,33 +645,35 @@ struct BlockingParameters { public: lib::PowerOfTwo blocksize_bytes = default_blocksize_bytes; + 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; @@ -719,9 +722,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 +950,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/include/svs/index/vamana/dynamic_index.h b/include/svs/index/vamana/dynamic_index.h index 5f0ce7c16..90696a461 100644 --- a/include/svs/index/vamana/dynamic_index.h +++ b/include/svs/index/vamana/dynamic_index.h @@ -449,6 +449,32 @@ class MutableVamanaIndex { /// size_t dimensions() const { return data_.dimensions(); } + /// @brief Return memory breakdown for the index. + /// + /// Reports the allocated memory for graph, data, and metadata components. Uses + /// capacity-based accounting for datasets that expose ``capacity()``, so that block + /// over-allocation is reflected. Metadata includes status array, entry points, and an + /// estimated size of the ID translation maps (external/internal ID translation maps). + 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; + } + // Return a `greedy_search` compatible builder for this index. // This is an internal method, mostly used to help implement the batch iterator. ValidBuilder internal_search_builder() const { return ValidBuilder{status_}; } diff --git a/include/svs/index/vamana/index.h b/include/svs/index/vamana/index.h index 70a921353..d569f0d5b 100644 --- a/include/svs/index/vamana/index.h +++ b/include/svs/index/vamana/index.h @@ -177,6 +177,17 @@ struct VamanaIndexParameters { operator==(const VamanaIndexParameters&, const VamanaIndexParameters&) = default; }; +/// +/// @brief Memory breakdown for Vamana index. +/// +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. /// @@ -613,6 +624,21 @@ class VamanaIndex { /// @brief Return the logical number aLf dimensions of the indexed vectors. size_t dimensions() const { return data_.dimensions(); } + /// @brief Return memory breakdown for the index. + /// + /// Reports the allocated memory for graph, data, and metadata components. Uses + /// capacity-based accounting for datasets that expose ``capacity()``, so that block + /// over-allocation is reflected. Metadata includes entry points. Integrators can use + /// this to report the true memory footprint of the index. + 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 Reconstruct vectors. /// /// Reconstruct each vector indexed by an external ID and store the results into diff --git a/include/svs/orchestrators/dynamic_vamana.h b/include/svs/orchestrators/dynamic_vamana.h index 045077387..191d81141 100644 --- a/include/svs/orchestrators/dynamic_vamana.h +++ b/include/svs/orchestrators/dynamic_vamana.h @@ -262,6 +262,11 @@ class DynamicVamana : public manager::IndexManager { impl_->reconstruct_at(data, ids); } + /// @copydoc svs::index::vamana::MutableVamanaIndex::get_memory_breakdown + svs::index::vamana::MemoryBreakdown get_memory_breakdown() const { + return impl_->get_memory_breakdown(); + } + // Building /// /// @brief Construct a DynamicVamana index from a data loader or dataset. diff --git a/include/svs/orchestrators/vamana.h b/include/svs/orchestrators/vamana.h index c4c4422b6..3b659219f 100644 --- a/include/svs/orchestrators/vamana.h +++ b/include/svs/orchestrators/vamana.h @@ -101,6 +101,9 @@ class VamanaInterface { // Non-templated virtual method for distance calculation virtual double get_distance(size_t id, const AnonymousArray<1>& query) const = 0; + + ///// Memory accounting + virtual svs::index::vamana::MemoryBreakdown get_memory_breakdown() const = 0; }; template @@ -267,6 +270,10 @@ class VamanaImpl : public manager::ManagerImpl { } ); } + + svs::index::vamana::MemoryBreakdown get_memory_breakdown() const override { + return impl().get_memory_breakdown(); + } }; ///// Forward declarations @@ -380,6 +387,11 @@ class Vamana : public manager::IndexManager { impl_->reconstruct_at(data, ids); } + /// @copydoc svs::index::vamana::VamanaIndex::get_memory_breakdown + svs::index::vamana::MemoryBreakdown get_memory_breakdown() const { + return impl_->get_memory_breakdown(); + } + /// /// @brief Load a Vamana Index from a previously saved index. /// diff --git a/tests/svs/core/data/block.cpp b/tests/svs/core/data/block.cpp index 4923b30f9..1702c0134 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,16 +185,30 @@ 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") { - // 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. @@ -185,4 +232,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()); + } } diff --git a/tests/svs/index/vamana/dynamic_index.cpp b/tests/svs/index/vamana/dynamic_index.cpp index 798a584c2..7f64ce659 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 c63bcefe3..284bc68d2 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 b35234eb6..10951e7ca 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 18efb4ac5..fdab19de2 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); +}