diff --git a/bindings/c/docs/C_API_Design.md b/bindings/c/docs/C_API_Design.md index 597c120e..e65b073b 100644 --- a/bindings/c/docs/C_API_Design.md +++ b/bindings/c/docs/C_API_Design.md @@ -247,6 +247,47 @@ The main search structure providing vector similarity search operations. - A static index is immutable after creation; a dynamic index additionally supports add/delete/consolidate/compact operations +**Dynamic Index Parameters:** + +The `_ex` variants of the dynamic functions (`svs_index_build_dynamic_ex`, +`svs_index_load_dynamic_ex`, `svs_index_convert_dynamic_ex`, +`svs_index_builder_estimate_memory_dynamic_ex`, +`svs_index_builder_estimate_search_memory_dynamic_ex`) take a versioned +`const svs_dynamic_index_params_t*` instead of a bare `blocksize_bytes` argument. +Initialize it with `SVS_INIT_DYNAMIC_INDEX_PARAMS()`, or pass `NULL` to use the defaults +(`blocksize_bytes = 0`, `blocksize_elements = 0`, `sync_kind = SVS_SYNC_KIND_NONE`). +Invalid parameters are rejected with `SVS_ERROR_INVALID_ARGUMENT`; unlike the legacy +`blocksize_bytes` arguments, block sizes are not rounded down to a power of two. + +| Field | Description | +|-------|-------------| +| `blocksize_bytes` | Bytes per block; `0` selects the default, otherwise must be a power of two | +| `blocksize_elements` | Vectors (graph nodes) per block; `0` leaves it unset, otherwise must be a power of two and takes precedence over `blocksize_bytes` | +| `sync_kind` | `uint32_t` holding a `svs_sync_kind_t` value: internal synchronization of the dynamic index handle (see below) | + +| `svs_sync_kind_t` | Behavior | +|-----------------|----------| +| `SVS_SYNC_KIND_NONE` | No internal synchronization (default); the caller must serialize access | +| `SVS_SYNC_KIND_GLOBAL` | Index-wide reader/writer lock: read-only operations (search, `has_id`, `get_distance`, reconstruct, `get_size`, `get_num_threads`, memory queries, use as a conversion source) run concurrently; mutating operations (add/delete points, consolidate, compact, save, `set_num_threads`) are exclusive | +| `SVS_SYNC_KIND_FINE_GRAIN` | Currently behaves as `SVS_SYNC_KIND_GLOBAL`; reserved for finer-grained locking in future releases | + +Synchronization limits: +- `svs_index_free()` must not run concurrently with any other call on the same handle. +- Each thread must use its own `svs_search_results_t` and `svs_error_h`. +- The lock gives no writer priority; sustained read load may starve writers. +- The sync kind is not persisted by `svs_index_save()`; pass it again on load. Query it + with `svs_index_dynamic_get_sync_kind()`. + +```c +svs_dynamic_index_params_t params = SVS_INIT_DYNAMIC_INDEX_PARAMS(); +params.blocksize_elements = 1024; +params.sync_kind = SVS_SYNC_KIND_GLOBAL; + +svs_index_h index = svs_index_build_dynamic_ex( + builder, data, /*ids=*/NULL, num_vectors, ¶ms, err +); +``` + **Future Extensions:** - Range search (all neighbors within distance threshold) - Additional algorithms (Flat, IVF) @@ -275,8 +316,11 @@ plan capacity up front: `svs_memory_breakdown_t` for a given vector count. The dynamic variant also accounts for the block size; `svs_index_builder_get_default_blocksize_bytes` returns the default block size (`blocksize_bytes = 0` selects it). + `svs_index_builder_estimate_memory_dynamic_ex` takes the block size from + `svs_dynamic_index_params_t` instead, including `blocksize_elements`. - `svs_index_builder_estimate_search_memory` / - `svs_index_builder_estimate_search_memory_dynamic` — estimate the scratch memory a + `svs_index_builder_estimate_search_memory_dynamic` / + `svs_index_builder_estimate_search_memory_dynamic_ex` — estimate the scratch memory a search would use for a given query count, neighbor count, search parameters, and optional ID filter (a non-zero `filter_rate` is factored into the estimate). @@ -517,12 +561,12 @@ for full signatures, parameters, and Doxygen documentation. `svs_algorithm_h`, `svs_storage_h`, `svs_search_params_h` - **Enums** (`_t`): `svs_error_code_t`, `svs_distance_metric_t`, `svs_algorithm_type_t`, `svs_data_type_t`, `svs_storage_kind_t`, - `svs_threadpool_kind_t`, `svs_allocator_kind_t` + `svs_threadpool_kind_t`, `svs_allocator_kind_t`, `svs_sync_kind_t` - **Custom interfaces**: `svs_threadpool_i`, `svs_allocator_i`, and `svs_id_filter_i` (versioned ops-table + `self` pointer; build with `SVS_INIT_*_OPS()` / `SVS_MAKE_INTERFACE()`) - **Value structs**: `svs_search_results_t` (CSR result buffer), - `svs_memory_breakdown_t` + `svs_memory_breakdown_t`, `svs_dynamic_index_params_t` ### Function groups @@ -534,15 +578,17 @@ for full signatures, parameters, and Doxygen documentation. | **Storage** | `svs_storage_create_{simple,sq,lvq,leanvec}`, `svs_storage_get_kind`, `svs_storage_free` | | **Search params** | `svs_search_params_create_vamana`, `svs_search_params_free` | | **Builder** | `svs_index_builder_create`, `svs_index_builder_set_{storage,threadpool,threadpool_custom,allocator,allocator_custom}`, `svs_index_builder_free` | -| **Memory estimation** | `svs_index_builder_estimate_memory`, `svs_index_builder_estimate_memory_dynamic`, `svs_index_builder_estimate_search_memory`, `svs_index_builder_estimate_search_memory_dynamic`, `svs_index_builder_get_default_blocksize_bytes` | -| **Index lifecycle** | `svs_index_build`, `svs_index_build_dynamic`, `svs_index_load`, `svs_index_load_dynamic`, `svs_index_save`, `svs_index_free` | -| **Dynamic ops** | `svs_index_dynamic_{add_points,delete_points,has_id,consolidate,compact}` | +| **Memory estimation** | `svs_index_builder_estimate_memory`, `svs_index_builder_estimate_memory_dynamic[_ex]`, `svs_index_builder_estimate_search_memory`, `svs_index_builder_estimate_search_memory_dynamic[_ex]`, `svs_index_builder_get_default_blocksize_bytes` | +| **Index lifecycle** | `svs_index_build`, `svs_index_build_dynamic[_ex]`, `svs_index_load`, `svs_index_load_dynamic[_ex]`, `svs_index_convert`, `svs_index_convert_dynamic[_ex]`, `svs_index_save`, `svs_index_free` | +| **Dynamic ops** | `svs_index_dynamic_{add_points,delete_points,has_id,consolidate,compact,get_sync_kind}` | | **Introspection** | `svs_index_get_num_threads` / `set_num_threads`, `svs_index_get_distance`, `svs_index_reconstruct`, `svs_index_get_memory_usage`, `svs_index_get_memory_breakdown` | | **Search** | `svs_index_search_topk` (+ deprecated `svs_index_search`), `svs_search_results_free` | ### Conventions - Every fallible call takes a trailing optional `svs_error_h out_err` (may be `NULL`). +- Handles are not internally synchronized, except dynamic indices created with a + `sync_kind` other than `SVS_SYNC_KIND_NONE`. - Constructors return an opaque handle or `NULL` on failure; other calls return `bool`. Out-values are written through `out_*` pointer parameters. - LVQ/LeanVec require the compression backend and specific x86 ISA support; when diff --git a/bindings/c/include/svs/c/svs_c.h b/bindings/c/include/svs/c/svs_c.h index 224bf167..3e8c6855 100644 --- a/bindings/c/include/svs/c/svs_c.h +++ b/bindings/c/include/svs/c/svs_c.h @@ -451,11 +451,11 @@ static inline void svs_search_results_row( /// the caller opts in by supplying a large-enough @p struct_size (e.g. via the /// SVS_INIT_MEMORY_BREAKDOWN() macro from the newer header). struct svs_memory_breakdown { - uint32_t version; /// Version of the memory breakdown structure - size_t struct_size; /// Size of the structure, used for versioning - 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.) + uint32_t version; ///< Version of the memory breakdown structure + size_t struct_size; ///< Size of the structure, used for versioning + 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.) }; /// @brief Macro to initialize a svs_memory_breakdown structure with default values @@ -465,12 +465,65 @@ struct svs_memory_breakdown { .graph_bytes = 0, .data_bytes = 0, .metadata_bytes = 0 \ } +/// @brief Dynamic index synchronization kind. +/// +/// With synchronization enabled, the dynamic index handle guards itself with an internal +/// reader/writer lock: read-only operations (search, has_id, get_distance, reconstruct, +/// get_size, get_num_threads, memory queries, and use as a conversion source) take a +/// shared lock and may run concurrently, while mutating operations (add_points, +/// delete_points, consolidate, compact, save, set_num_threads) take an exclusive lock. +/// +/// @remarks Synchronization covers operations on the index only: +/// * svs_index_free() must not run concurrently with any other call on the same handle. +/// * Each thread must use its own svs_search_results_t and svs_error_h. +/// * The lock gives no writer priority: under sustained read load, readers may starve +/// writers. +/// * The sync kind is a runtime property: it is not persisted by svs_index_save(), so +/// pass it again to svs_index_load_dynamic_ex(). +enum svs_sync_kind { + SVS_SYNC_KIND_NONE = 0, ///< No internal synchronization; caller must synchronize + SVS_SYNC_KIND_GLOBAL = 1, ///< Index-wide reader/writer lock + SVS_SYNC_KIND_FINE_GRAIN = 2 ///< Currently the same as GLOBAL; reserved for + ///< finer-grained locking +}; + +/// @brief Structure to hold dynamic index parameters. +/// +/// Forward-compatibility contract: fields not covered by the caller-supplied +/// @p struct_size are treated as their defaults. Initialize with +/// SVS_INIT_DYNAMIC_INDEX_PARAMS(). Passing NULL to an `_ex` function is equivalent to +/// passing a structure with all defaults. +/// +/// The `_ex` functions reject (with SVS_ERROR_INVALID_ARGUMENT) a @p version newer than +/// the library, a @p struct_size larger than the library's structure, block sizes that +/// are not 0 or a power of two, and unknown @p sync_kind values. Unlike the legacy +/// `blocksize_bytes` arguments, block sizes are not rounded down to a power of two. +struct svs_dynamic_index_params { + uint32_t version; ///< Version of the dynamic index parameters structure + size_t struct_size; ///< Size of the structure, used for versioning + size_t blocksize_bytes; ///< Bytes per block: 0 (default) or a power of two + size_t blocksize_elements; ///< Vectors (graph nodes) per block: 0 (unset) or a power + ///< of two; takes precedence over blocksize_bytes + uint32_t sync_kind; ///< Synchronization kind, one of svs_sync_kind values +}; + +/// @brief Macro to initialize a svs_dynamic_index_params structure with default values +#define SVS_INIT_DYNAMIC_INDEX_PARAMS() \ + { \ + .version = SVS_C_API_VERSION, \ + .struct_size = sizeof(struct svs_dynamic_index_params), .blocksize_bytes = 0, \ + .blocksize_elements = 0, .sync_kind = SVS_SYNC_KIND_NONE \ + } + // Handle typedefs; "_h" suffix indicates a handle to an opaque struct /// /// @remarks Thread-safety: unless a specific function documents otherwise, handles /// (svs_error_h, svs_index_h, svs_index_builder_h, svs_algorithm_h, svs_storage_h, /// svs_search_params_h) are not internally synchronized. Do not operate on the same /// handle from multiple threads concurrently without external synchronization. +/// Exception: dynamic indices built or loaded with a svs_sync_kind other than +/// SVS_SYNC_KIND_NONE are internally synchronized, with the limits listed in +/// svs_sync_kind. typedef struct svs_index* svs_index_h; typedef struct svs_index_builder* svs_index_builder_h; typedef struct svs_algorithm* svs_algorithm_h; @@ -486,6 +539,7 @@ typedef enum svs_data_type svs_data_type_t; typedef enum svs_storage_kind svs_storage_kind_t; typedef enum svs_threadpool_kind svs_threadpool_kind_t; typedef enum svs_allocator_kind svs_allocator_kind_t; +typedef enum svs_sync_kind svs_sync_kind_t; typedef struct svs_threadpool_interface_ops svs_threadpool_ops_t; typedef struct svs_threadpool_interface svs_threadpool_t; @@ -500,6 +554,7 @@ 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; +typedef struct svs_dynamic_index_params svs_dynamic_index_params_t; /// @brief Get SVS version information /// @return An integer representing the version of the SVS library, encoded as (major << 16) @@ -878,6 +933,25 @@ SVS_API bool svs_index_builder_estimate_memory_dynamic( svs_error_h out_err /*=NULL*/ ); +/// @brief Estimate the memory usage of a dynamic index based on the builder configuration, +/// number of vectors and parameters +/// @param builder The index builder handle +/// @param num_vectors The number of vectors to be indexed +/// @param params Pointer to the dynamic index parameters structure, or NULL for defaults; +/// only the block size fields are used +/// @param out_breakdown Pointer to a structure to hold the memory breakdown +/// @param out_err An optional error handle to capture errors +/// @return true on success, false on failure +/// @remarks The estimated memory size is approximate. +/// @error SVS_ERROR_INVALID_ARGUMENT if @p params is invalid (see svs_dynamic_index_params) +SVS_API bool svs_index_builder_estimate_memory_dynamic_ex( + svs_index_builder_h builder, + size_t num_vectors, + const svs_dynamic_index_params_t* params /*=NULL*/, + svs_memory_breakdown_t* out_breakdown, + svs_error_h out_err /*=NULL*/ +); + /// @brief Estimate the memory usage of a search operation based on the builder /// configuration, search parameters, number of queries, and nearest neighbors to retrieve /// @param builder The index builder handle @@ -937,6 +1011,34 @@ SVS_API bool svs_index_builder_estimate_search_memory_dynamic( svs_error_h out_err /*=NULL*/ ); +/// @brief Estimate the memory usage of a dynamic index search operation based on the +/// builder configuration, search parameters, number of queries, nearest neighbors to +/// retrieve, and dynamic parameters +/// @param builder The index builder handle +/// @param num_queries The number of queries to be performed +/// @param num_neighbors The number of nearest neighbors to retrieve per query +/// @param search_params The search parameters handle; if NULL, the builder's default search +/// parameters are used +/// @param id_filter An optional ID filter interface; if NULL, no filtering is applied +/// @param params Pointer to the dynamic index parameters structure, or NULL for defaults; +/// validated but currently not used in the estimate - reserved for future use +/// @param out_size Pointer to a variable to receive the estimated memory size +/// @param out_err An optional error handle to capture errors +/// @return true on success, false on failure +/// @remarks See svs_index_builder_estimate_search_memory_dynamic(). The result is +/// currently identical to that function's result. +/// @error SVS_ERROR_INVALID_ARGUMENT if @p params is invalid (see svs_dynamic_index_params) +SVS_API bool svs_index_builder_estimate_search_memory_dynamic_ex( + svs_index_builder_h builder, + size_t num_queries, + size_t num_neighbors, + svs_search_params_h search_params, + svs_id_filter_i id_filter /*=NULL*/, + const svs_dynamic_index_params_t* params /*=NULL*/, + size_t* out_size, + svs_error_h out_err /*=NULL*/ +); + /// @brief Build an index from the provided data /// @param builder The index builder handle /// @param data Pointer to the vector data (float array) @@ -974,6 +1076,27 @@ SVS_API svs_index_h svs_index_build_dynamic( svs_error_h out_err /*=NULL*/ ); +/// @brief Build a dynamic index from the provided data and parameters +/// @param builder The index builder handle +/// @param data Pointer to the vector data (float array) +/// @param ids Pointer to the vector IDs (size_t array). Can be NULL if IDs should be +/// auto-generated from 0 to num_vectors-1. +/// @param num_vectors The number of vectors in the data +/// @param params Pointer to the dynamic index parameters structure, or NULL for defaults +/// @param out_err An optional error handle to capture errors +/// @return A handle to the built dynamic index +/// @remarks Both @p data and @p ids are copied into the index's internal storage; the +/// caller may free or modify them once this call returns. @p params is not retained. +/// @error SVS_ERROR_INVALID_ARGUMENT if @p params is invalid (see svs_dynamic_index_params) +SVS_API svs_index_h svs_index_build_dynamic_ex( + svs_index_builder_h builder, + const float* data, + const size_t* ids /*=NULL*/, + size_t num_vectors, + const svs_dynamic_index_params_t* params /*=NULL*/, + svs_error_h out_err /*=NULL*/ +); + /// @brief Load an index from disk /// @param builder The index builder handle (used for configuration) /// @param directory The directory path to load the index from @@ -996,6 +1119,22 @@ SVS_API svs_index_h svs_index_load_dynamic( svs_error_h out_err /*=NULL*/ ); +/// @brief Load a dynamic index from disk with explicit parameters +/// @param builder The index builder handle (used for configuration) +/// @param directory The directory path to load the index from +/// @param params Pointer to the dynamic index parameters structure, or NULL for defaults +/// @param out_err An optional error handle to capture errors +/// @return A handle to the loaded dynamic index +/// @remarks The block parameters only affect the in-memory layout, not the on-disk +/// format. The sync kind is not persisted with the index and must be passed on every load. +/// @error SVS_ERROR_INVALID_ARGUMENT if @p params is invalid (see svs_dynamic_index_params) +SVS_API svs_index_h svs_index_load_dynamic_ex( + svs_index_builder_h builder, + const char* directory, + const svs_dynamic_index_params_t* params /*=NULL*/, + svs_error_h out_err /*=NULL*/ +); + /// @brief Convert an index using new builder configuration /// @param builder The index builder handle (used for configuration) /// @param src_index The source index handle to convert from @@ -1019,6 +1158,23 @@ SVS_API svs_index_h svs_index_convert_dynamic( svs_error_h out_err /*=NULL*/ ); +/// @brief Convert dynamic index using new builder configuration and explicit parameters +/// @param builder The index builder handle (used for configuration) +/// @param src_index The source dynamic index handle to convert from +/// @param params Pointer to the dynamic index parameters structure for the converted +/// index, or NULL for defaults +/// @param out_err An optional error handle to capture errors +/// @return A handle to the newly converted dynamic index +/// @remarks The sync kind of the converted index is taken from @p params, not from +/// @p src_index. +/// @error SVS_ERROR_INVALID_ARGUMENT if @p params is invalid (see svs_dynamic_index_params) +SVS_API svs_index_h svs_index_convert_dynamic_ex( + svs_index_builder_h builder, + svs_index_h src_index, + const svs_dynamic_index_params_t* params /*=NULL*/, + svs_error_h out_err /*=NULL*/ +); + /// @brief Free the index handle /// @param index The index handle to free SVS_API void svs_index_free(svs_index_h index); @@ -1155,6 +1311,15 @@ SVS_API bool svs_index_dynamic_has_id( svs_index_h index, size_t id, bool* out_has_id, svs_error_h out_err /*=NULL*/ ); +/// @brief Get the synchronization kind of a dynamic index +/// @param index The dynamic index handle +/// @param out_sync_kind Pointer to store the synchronization kind +/// @param out_err An optional error handle to capture errors +/// @return true on success, false on failure +SVS_API bool svs_index_dynamic_get_sync_kind( + svs_index_h index, svs_sync_kind_t* out_sync_kind, svs_error_h out_err /*=NULL*/ +); + /// @brief Get the distance from a specific ID to a query vector in an index /// @param index The index handle /// @param id The vector ID to get the distance for diff --git a/bindings/c/src/data_builder.cpp b/bindings/c/src/data_builder.cpp index 176fb99e..1cc1e060 100644 --- a/bindings/c/src/data_builder.cpp +++ b/bindings/c/src/data_builder.cpp @@ -39,17 +39,16 @@ estimate_size(DataBuilder builder, size_t num_vectors, size_t dimension, svs::li template size_t estimate_blocked_size( - DataBuilder builder, size_t num_vectors, size_t dimension, size_t blocksize_bytes + DataBuilder builder, + size_t num_vectors, + size_t dimension, + svs::data::BlockingParameters block_params ) { using allocator_type = typename DataBuilder::allocator_type; static_assert( svs::data::is_blocked_v, "estimate_blocked_size requires a blocked allocator type." ); - svs::data::BlockingParameters block_params; - if (blocksize_bytes != 0) { - block_params.blocksize_bytes = svs::lib::prevpow2(blocksize_bytes); - } auto allocator = allocator_type{block_params}; return builder.estimate_size(num_vectors, dimension, allocator); } @@ -75,7 +74,7 @@ void register_data_size_specializations(Dispatcher& dispatcher) { for_sq_specializations(blocked_size_closure); } -using BlocksizeArg = std::variant; +using BlocksizeArg = std::variant; using EstimateSizeDispatcher = svs::lib::Dispatcher; @@ -90,13 +89,10 @@ const EstimateSizeDispatcher& build_data_size_dispatcher() { } size_t dispatch_data_size_estimation( - const Storage* storage, - size_t num_vectors, - size_t dimension, - BlocksizeArg blocksize_bytes + const Storage* storage, size_t num_vectors, size_t dimension, BlocksizeArg block_params ) { return build_data_size_dispatcher().invoke( - storage, num_vectors, dimension, blocksize_bytes + storage, num_vectors, dimension, std::move(block_params) ); } //} // namespace @@ -117,7 +113,10 @@ size_t estimate_data_size(const Storage* storage, size_t num_vectors, size_t dim } size_t estimate_data_size_blocked( - const Storage* storage, size_t num_vectors, size_t dimension, size_t blocksize_bytes + const Storage* storage, + size_t num_vectors, + size_t dimension, + const svs::data::BlockingParameters& block_params ) { if (storage == nullptr) { throw std::invalid_argument("Storage pointer cannot be null."); @@ -128,6 +127,6 @@ size_t estimate_data_size_blocked( if (dimension == 0) { throw std::invalid_argument("Dimension must be greater than zero."); } - return dispatch_data_size_estimation(storage, num_vectors, dimension, blocksize_bytes); + return dispatch_data_size_estimation(storage, num_vectors, dimension, block_params); } } // namespace svs::c_runtime diff --git a/bindings/c/src/data_builder.hpp b/bindings/c/src/data_builder.hpp index c92abbd4..d341352b 100644 --- a/bindings/c/src/data_builder.hpp +++ b/bindings/c/src/data_builder.hpp @@ -24,6 +24,9 @@ namespace svs::c_runtime { size_t estimate_data_size(const Storage* storage, size_t num_vectors, size_t dimension); size_t estimate_data_size_blocked( - const Storage* storage, size_t num_vectors, size_t dimension, size_t blocksize_bytes + const Storage* storage, + size_t num_vectors, + size_t dimension, + const svs::data::BlockingParameters& block_params ); } // namespace svs::c_runtime diff --git a/bindings/c/src/dispatcher_dynamic_vamana.cpp b/bindings/c/src/dispatcher_dynamic_vamana.cpp index a9241de1..85a87dc9 100644 --- a/bindings/c/src/dispatcher_dynamic_vamana.cpp +++ b/bindings/c/src/dispatcher_dynamic_vamana.cpp @@ -47,12 +47,8 @@ svs::DynamicVamana build_dynamic_vamana_index( Distance D, svs::threads::ThreadPoolHandle pool, const AllocatorBuilder& allocator_builder, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ) { - svs::data::BlockingParameters block_params; - if (blocksize_bytes != 0) { - block_params.blocksize_bytes = svs::lib::prevpow2(blocksize_bytes); - } using allocator_type = typename DataBuilder::allocator_type; using value_type = typename allocator_type::value_type; @@ -80,12 +76,8 @@ svs::DynamicVamana load_dynamic_vamana_index( Distance D, svs::threads::ThreadPoolHandle pool, const AllocatorBuilder& allocator_builder, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ) { - svs::data::BlockingParameters block_params; - if (blocksize_bytes != 0) { - block_params.blocksize_bytes = svs::lib::prevpow2(blocksize_bytes); - } using allocator_type = typename DataLoader::allocator_type; using value_type = typename allocator_type::value_type; auto data_allocator_handle = allocator_builder.build(); @@ -135,7 +127,7 @@ using BuildDynamicIndexDispatcher = svs::lib::Dispatcher< svs::DistanceType, svs::threads::ThreadPoolHandle, const AllocatorBuilder&, - size_t>; + const svs::data::BlockingParameters&>; const BuildDynamicIndexDispatcher& build_dynamic_vamana_index_dispatcher() { static BuildDynamicIndexDispatcher dispatcher = [] { @@ -155,7 +147,7 @@ using CopyDynamicIndexDispatcher = svs::lib::Dispatcher< svs::DistanceType, svs::threads::ThreadPoolHandle, const AllocatorBuilder&, - size_t>; + const svs::data::BlockingParameters&>; template svs::DynamicVamana copy_dynamic_vamana_index( @@ -166,7 +158,7 @@ svs::DynamicVamana copy_dynamic_vamana_index( Distance distance, svs::threads::ThreadPoolHandle pool, const AllocatorBuilder& allocator_builder, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ) { auto config = src_index.parameters(); @@ -184,12 +176,6 @@ svs::DynamicVamana copy_dynamic_vamana_index( config.build_parameters.apply(build_params); verify_and_set_default_index_parameters(config.build_parameters, distance); - // Determine the blocking parameters based on the provided block size - svs::data::BlockingParameters block_params; - if (blocksize_bytes != 0) { - block_params.blocksize_bytes = svs::lib::prevpow2(blocksize_bytes); - } - // Must match the graph type produced by the build and load paths above. using GraphType = svs::graphs::SimpleGraph>>; @@ -301,7 +287,7 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_build( svs::DistanceType distance_type, svs::threads::ThreadPoolHandle pool, const AllocatorBuilder& allocator_builder, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ) { return build_dynamic_vamana_index_dispatcher().invoke( build_params, @@ -310,7 +296,7 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_build( distance_type, std::move(pool), allocator_builder, - blocksize_bytes + block_params ); } @@ -321,7 +307,7 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_load( svs::DistanceType distance_type, svs::threads::ThreadPoolHandle pool, const AllocatorBuilder& allocator_builder, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ) { return build_dynamic_vamana_index_dispatcher().invoke( build_params, @@ -330,7 +316,7 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_load( distance_type, std::move(pool), allocator_builder, - blocksize_bytes + block_params ); } @@ -342,7 +328,7 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_copy( svs::DistanceType distance_type, svs::threads::ThreadPoolHandle pool, const AllocatorBuilder& allocator_builder, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ) { return copy_dynamic_index_dispatcher().invoke( build_params, @@ -352,7 +338,7 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_copy( distance_type, std::move(pool), allocator_builder, - blocksize_bytes + block_params ); } @@ -362,7 +348,7 @@ svs::index::vamana::MemoryBreakdown dispatch_dynamic_vamana_memory_estimate( size_t dimension, const Storage* storage, svs::DistanceType SVS_UNUSED(distance_type), - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ) { svs::index::vamana::MemoryBreakdown breakdown{}; @@ -374,10 +360,6 @@ svs::index::vamana::MemoryBreakdown dispatch_dynamic_vamana_memory_estimate( const size_t max_degree = build_params.graph_max_degree; - svs::data::BlockingParameters block_params; - if (blocksize_bytes != 0) { - block_params.blocksize_bytes = svs::lib::prevpow2(blocksize_bytes); - } auto graph_allocator = graph_allocator_type{block_params}; auto graph_data_builder = svs::SimpleDataBuilder{}; @@ -386,7 +368,7 @@ svs::index::vamana::MemoryBreakdown dispatch_dynamic_vamana_memory_estimate( // Data size breakdown.data_bytes = - estimate_data_size_blocked(storage, num_vectors, dimension, blocksize_bytes); + estimate_data_size_blocked(storage, num_vectors, dimension, block_params); // Metadata: single entry point held as Idx, plus the SlotMetadata vector, plus the // IDTranslator maps. diff --git a/bindings/c/src/dispatcher_dynamic_vamana.hpp b/bindings/c/src/dispatcher_dynamic_vamana.hpp index 5a96c62d..4e9a2713 100644 --- a/bindings/c/src/dispatcher_dynamic_vamana.hpp +++ b/bindings/c/src/dispatcher_dynamic_vamana.hpp @@ -38,7 +38,7 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_build( svs::DistanceType distance_type, svs::threads::ThreadPoolHandle pool, const AllocatorBuilder& allocator_builder, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ); svs::DynamicVamana dispatch_dynamic_vamana_index_load( @@ -48,7 +48,7 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_load( svs::DistanceType distance_type, svs::threads::ThreadPoolHandle pool, const AllocatorBuilder& allocator_builder, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ); svs::DynamicVamana dispatch_dynamic_vamana_index_copy( @@ -59,7 +59,7 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_copy( svs::DistanceType distance_type, svs::threads::ThreadPoolHandle pool, const AllocatorBuilder& allocator_builder, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ); svs::index::vamana::MemoryBreakdown dispatch_dynamic_vamana_memory_estimate( @@ -68,7 +68,7 @@ svs::index::vamana::MemoryBreakdown dispatch_dynamic_vamana_memory_estimate( size_t dimension, const Storage* storage, svs::DistanceType distance_type, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ); } // namespace svs::c_runtime diff --git a/bindings/c/src/index.hpp b/bindings/c/src/index.hpp index c81e3cde..99c03a30 100644 --- a/bindings/c/src/index.hpp +++ b/bindings/c/src/index.hpp @@ -28,6 +28,9 @@ #include #include +#include +#include +#include #include #include #include @@ -59,10 +62,30 @@ struct Index { }; struct DynamicIndex : public Index { - explicit DynamicIndex(std::unique_ptr builder) - : Index(std::move(builder)) {} + svs_sync_kind_t sync_kind; + mutable std::optional mutex; + + explicit DynamicIndex( + std::unique_ptr builder, + svs_sync_kind_t sync_kind = SVS_SYNC_KIND_NONE + ) + : Index(std::move(builder)) + , sync_kind(sync_kind) { + if (sync_kind != SVS_SYNC_KIND_NONE) { + mutex.emplace(); + } + } ~DynamicIndex() = default; + // An empty lock (no mutex) is returned when synchronization is disabled. + [[nodiscard]] std::shared_lock read_lock() const { + return mutex ? std::shared_lock{*mutex} : std::shared_lock{}; + } + + [[nodiscard]] std::unique_lock write_lock() const { + return mutex ? std::unique_lock{*mutex} : std::unique_lock{}; + } + virtual size_t add_points( svs::data::ConstSimpleDataView new_points, std::span ids ) = 0; diff --git a/bindings/c/src/index_builder.cpp b/bindings/c/src/index_builder.cpp index fb8c4fee..317dc5f7 100644 --- a/bindings/c/src/index_builder.cpp +++ b/bindings/c/src/index_builder.cpp @@ -153,7 +153,9 @@ std::shared_ptr IndexBuilder::copy(const std::shared_ptr& src_inde } std::shared_ptr IndexBuilder::copy_dynamic( - const std::shared_ptr& src_index, size_t blocksize_bytes + const std::shared_ptr& src_index, + const svs::data::BlockingParameters& block_params, + svs_sync_kind_t sync_kind ) { const auto& src_builder = src_index->get_builder(); @@ -180,6 +182,7 @@ std::shared_ptr IndexBuilder::copy_dynamic( throw std::invalid_argument("Source index must be a valid Dynamic Vamana index."); } + auto src_lock = vamana_index->read_lock(); auto index = std::make_shared( *this, dispatch_dynamic_vamana_index_copy( @@ -190,8 +193,9 @@ std::shared_ptr IndexBuilder::copy_dynamic( to_distance_type(distance_metric), pool_builder.build(), allocator_builder, - blocksize_bytes - ) + block_params + ), + sync_kind ); return index; @@ -200,7 +204,8 @@ std::shared_ptr IndexBuilder::copy_dynamic( std::shared_ptr IndexBuilder::build_dynamic( const svs::data::ConstSimpleDataView& data, std::span ids, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params, + svs_sync_kind_t sync_kind ) { if (algorithm->type == SVS_ALGORITHM_TYPE_VAMANA) { auto vamana_algorithm = static_cast(algorithm.get()); @@ -216,8 +221,9 @@ std::shared_ptr IndexBuilder::build_dynamic( to_distance_type(distance_metric), pool_builder.build(), allocator_builder, - blocksize_bytes - ) + block_params + ), + sync_kind ); return index; @@ -225,8 +231,11 @@ std::shared_ptr IndexBuilder::build_dynamic( return nullptr; } -std::shared_ptr -IndexBuilder::load_dynamic(const std::filesystem::path& directory, size_t blocksize_bytes) { +std::shared_ptr IndexBuilder::load_dynamic( + const std::filesystem::path& directory, + const svs::data::BlockingParameters& block_params, + svs_sync_kind_t sync_kind +) { if (algorithm->type == SVS_ALGORITHM_TYPE_VAMANA) { auto vamana_algorithm = static_cast(algorithm.get()); @@ -240,8 +249,9 @@ IndexBuilder::load_dynamic(const std::filesystem::path& directory, size_t blocks to_distance_type(distance_metric), pool_builder.build(), allocator_builder, - blocksize_bytes - ) + block_params + ), + sync_kind ); return index; @@ -266,7 +276,7 @@ IndexBuilder::estimate_memory_breakdown(size_t num_vectors) const { } svs::index::vamana::MemoryBreakdown IndexBuilder::estimate_memory_breakdown_dynamic( - size_t num_vectors, size_t blocksize_bytes + size_t num_vectors, const svs::data::BlockingParameters& block_params ) const { NOT_IMPLEMENTED_IF( algorithm->type != SVS_ALGORITHM_TYPE_VAMANA, @@ -279,7 +289,7 @@ svs::index::vamana::MemoryBreakdown IndexBuilder::estimate_memory_breakdown_dyna dimension, storage.get(), to_distance_type(distance_metric), - blocksize_bytes + block_params ); } @@ -381,7 +391,7 @@ size_t IndexBuilder::estimate_search_memory_dynamic( size_t num_neighbors, const std::shared_ptr& search_params, const IDFilterInterface* id_filter, - size_t SVS_UNUSED(blocksize_bytes) + const svs::data::BlockingParameters& SVS_UNUSED(block_params) ) const { NOT_IMPLEMENTED_IF( algorithm->type != SVS_ALGORITHM_TYPE_VAMANA, diff --git a/bindings/c/src/index_builder.hpp b/bindings/c/src/index_builder.hpp index a6aedbfb..844b3f64 100644 --- a/bindings/c/src/index_builder.hpp +++ b/bindings/c/src/index_builder.hpp @@ -103,14 +103,21 @@ struct IndexBuilder { std::shared_ptr build_dynamic( const svs::data::ConstSimpleDataView& data, std::span ids, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params, + svs_sync_kind_t sync_kind = SVS_SYNC_KIND_NONE ); - std::shared_ptr - load_dynamic(const std::filesystem::path& directory, size_t blocksize_bytes); + std::shared_ptr load_dynamic( + const std::filesystem::path& directory, + const svs::data::BlockingParameters& block_params, + svs_sync_kind_t sync_kind = SVS_SYNC_KIND_NONE + ); - std::shared_ptr - copy_dynamic(const std::shared_ptr& src_index, size_t blocksize_bytes); + std::shared_ptr copy_dynamic( + const std::shared_ptr& src_index, + const svs::data::BlockingParameters& block_params, + svs_sync_kind_t sync_kind = SVS_SYNC_KIND_NONE + ); // Estimate the memory a built static Vamana index would consume // for `num_vectors` vectors. Mirrors the accounting done by @@ -120,8 +127,9 @@ struct IndexBuilder { // Estimate the memory a built dynamic Vamana index would consume // for `num_vectors` vectors. Mirrors the accounting done by // svs::index::vamana::MutableVamanaIndex::get_memory_breakdown(). - svs::index::vamana::MemoryBreakdown - estimate_memory_breakdown_dynamic(size_t num_vectors, size_t blocksize_bytes) const; + svs::index::vamana::MemoryBreakdown estimate_memory_breakdown_dynamic( + size_t num_vectors, const svs::data::BlockingParameters& block_params + ) const; size_t estimate_search_memory( size_t num_queries, @@ -135,7 +143,7 @@ struct IndexBuilder { size_t num_neighbors, const std::shared_ptr& search_params, const IDFilterInterface* id_filter, - size_t blocksize_bytes + const svs::data::BlockingParameters& block_params ) const; }; } // namespace svs::c_runtime diff --git a/bindings/c/src/index_vamana.cpp b/bindings/c/src/index_vamana.cpp index 93bfef77..6302e4bf 100644 --- a/bindings/c/src/index_vamana.cpp +++ b/bindings/c/src/index_vamana.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -95,9 +96,9 @@ void IndexVamana::set_num_threads(size_t num_threads) { ///////////////////////////////////// // DynamicIndexVamana Implementation DynamicIndexVamana::DynamicIndexVamana( - const IndexBuilder& builder, svs::DynamicVamana&& index + const IndexBuilder& builder, svs::DynamicVamana&& index, svs_sync_kind_t sync_kind ) - : DynamicIndex(std::make_unique(builder)) + : DynamicIndex(std::make_unique(builder), sync_kind) , index(std::move(index)) { auto all_ids = this->index.all_ids(); auto [min_it, max_it] = std::minmax_element(all_ids.begin(), all_ids.end()); @@ -122,6 +123,7 @@ std::pair, std::vector> DynamicIndexVamana::sea const std::shared_ptr& search_params, const IDFilterInterface* id_filter ) { + auto lock = read_lock(); auto vamana_search_params = std::static_pointer_cast(search_params); auto results = svs::QueryResult(queries.size(), num_neighbors); @@ -172,6 +174,7 @@ std::pair, std::vector> DynamicIndexVamana::sea size_t DynamicIndexVamana::add_points( svs::data::ConstSimpleDataView new_points, std::span ids ) { + auto lock = write_lock(); // 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()) { @@ -188,6 +191,7 @@ size_t DynamicIndexVamana::add_points( } size_t DynamicIndexVamana::delete_points(std::span ids) { + auto lock = write_lock(); std::vector ids_to_delete; ids_to_delete.reserve(ids.size()); @@ -198,12 +202,42 @@ size_t DynamicIndexVamana::delete_points(std::span ids) { } if (!ids_to_delete.empty()) { + consolidated = false; index.delete_points(svs::lib::as_const_span(ids_to_delete)); } return ids_to_delete.size(); } +void DynamicIndexVamana::save(const std::filesystem::path& directory) { + // Saving consolidates and compacts the index, so it requires exclusive access. + auto lock = write_lock(); + index.save(directory / "config", directory / "graph", directory / "data"); + // MutableVamanaIndex::save() implies consolidate() and compact(). + consolidated = true; +} + +void DynamicIndexVamana::consolidate() { + auto lock = write_lock(); + index.consolidate(); + consolidated = true; +} + +void DynamicIndexVamana::compact(size_t batchsize) { + auto lock = write_lock(); + // Ensure the index is consolidated before compacting. + if (!consolidated) { + index.consolidate(); + consolidated = true; + } + if (batchsize == 0) { + index.compact(); // Use default batch size + } else { + index.compact(batchsize); + } +} + void DynamicIndexVamana::set_num_threads(size_t num_threads) { + auto lock = write_lock(); this->builder->pool_builder.resize(num_threads); index.set_threadpool(this->builder->pool_builder.build()); } diff --git a/bindings/c/src/index_vamana.hpp b/bindings/c/src/index_vamana.hpp index 7bd97c74..ad7c8747 100644 --- a/bindings/c/src/index_vamana.hpp +++ b/bindings/c/src/index_vamana.hpp @@ -74,7 +74,11 @@ 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(const IndexBuilder& builder, svs::DynamicVamana&& index); + DynamicIndexVamana( + const IndexBuilder& builder, + svs::DynamicVamana&& index, + svs_sync_kind_t sync_kind = SVS_SYNC_KIND_NONE + ); ~DynamicIndexVamana() = default; @@ -85,9 +89,7 @@ struct DynamicIndexVamana : public DynamicIndex { const IDFilterInterface* id_filter ) override; - void save(const std::filesystem::path& directory) override { - index.save(directory / "config", directory / "graph", directory / "data"); - } + void save(const std::filesystem::path& directory) override; size_t dimensions() const override { return index.dimensions(); } @@ -97,35 +99,44 @@ struct DynamicIndexVamana : public DynamicIndex { size_t delete_points(std::span ids) override; - bool has_id(size_t id) const override { return index.has_id(id); } + bool has_id(size_t id) const override { + auto lock = read_lock(); + return index.has_id(id); + } float get_distance(size_t id, std::span query) const override { + auto lock = read_lock(); return index.get_distance(id, query); } void reconstruct_at(svs::data::SimpleDataView dst, std::span ids) override { + auto lock = read_lock(); index.reconstruct_at(dst, ids); } - void consolidate() override { index.consolidate(); } + void consolidate() override; - void compact(size_t batchsize) override { - if (batchsize == 0) { - index.compact(); // Use default batch size - } else { - index.compact(batchsize); - } - } + void compact(size_t batchsize) override; - size_t get_num_threads() const override { return index.get_num_threads(); } + size_t get_num_threads() const override { + auto lock = read_lock(); + return index.get_num_threads(); + } void set_num_threads(size_t num_threads) override; svs::index::vamana::MemoryBreakdown get_memory_breakdown() const override { + auto lock = read_lock(); return index.get_memory_breakdown(); } - size_t size() const override { return index.size(); } + size_t size() const override { + auto lock = read_lock(); + return index.size(); + } + + private: + bool consolidated = true; }; } // namespace svs::c_runtime diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp index d242e73b..3db8d6f3 100644 --- a/bindings/c/src/svs_c.cpp +++ b/bindings/c/src/svs_c.cpp @@ -26,6 +26,7 @@ #include "threadpool.hpp" #include "types_support.hpp" +#include #include #include #include @@ -575,6 +576,57 @@ void set_memory_breakdown( } } +struct DynamicIndexParams { + svs::data::BlockingParameters block_params; + svs_sync_kind_t sync_kind = SVS_SYNC_KIND_NONE; +}; + +DynamicIndexParams read_dynamic_index_params(const svs_dynamic_index_params_t* params) { + using namespace svs::c_runtime; + if (params == nullptr) { + return {}; + } + INVALID_ARGUMENT_IF( + params->version > svs_get_version(), + "Incompatible svs_dynamic_index_params_t version" + ); + INVALID_ARGUMENT_IF( + params->struct_size > sizeof(svs_dynamic_index_params_t), + "Incompatible svs_dynamic_index_params_t struct_size" + ); + + // Fields not covered by struct_size keep their defaults. + size_t blocksize_bytes = 0; + size_t blocksize_elements = 0; + uint32_t sync_kind = SVS_SYNC_KIND_NONE; + if (params->struct_size >= offsetof(svs_dynamic_index_params_t, blocksize_bytes) + + sizeof(params->blocksize_bytes)) { + blocksize_bytes = params->blocksize_bytes; + } + if (params->struct_size >= offsetof(svs_dynamic_index_params_t, blocksize_elements) + + sizeof(params->blocksize_elements)) { + blocksize_elements = params->blocksize_elements; + } + if (params->struct_size >= + offsetof(svs_dynamic_index_params_t, sync_kind) + sizeof(params->sync_kind)) { + sync_kind = params->sync_kind; + } + + INVALID_ARGUMENT_IF( + blocksize_bytes != 0 && !std::has_single_bit(blocksize_bytes), + "blocksize_bytes should be 0 or a power of two" + ); + INVALID_ARGUMENT_IF( + blocksize_elements != 0 && !std::has_single_bit(blocksize_elements), + "blocksize_elements should be 0 or a power of two" + ); + EXPECT_ARG_IN_RANGE(sync_kind, SVS_SYNC_KIND_NONE, SVS_SYNC_KIND_FINE_GRAIN); + + return { + make_blocking_parameters(blocksize_bytes, blocksize_elements), + static_cast(sync_kind)}; +} + } // namespace extern "C" bool svs_index_builder_estimate_memory( @@ -640,7 +692,39 @@ extern "C" bool svs_index_builder_estimate_memory_dynamic( auto builder_ptr = builder->impl; INVALID_ARGUMENT_IF(builder_ptr == nullptr, "Invalid index builder handle"); auto breakdown = builder_ptr->estimate_memory_breakdown_dynamic( - num_vectors, blocksize_bytes + num_vectors, make_blocking_parameters(blocksize_bytes) + ); + set_memory_breakdown( + out_breakdown, + breakdown.graph_bytes, + breakdown.data_bytes, + breakdown.metadata_bytes + ); + return true; + }, + out_err, + false + ); +} + +extern "C" bool svs_index_builder_estimate_memory_dynamic_ex( + svs_index_builder_h builder, + size_t num_vectors, + const svs_dynamic_index_params_t* params, + svs_memory_breakdown_t* out_breakdown, + svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(builder); + EXPECT_ARG_NOT_NULL(out_breakdown); + EXPECT_ARG_GT_THAN(num_vectors, 0); + auto builder_ptr = builder->impl; + INVALID_ARGUMENT_IF(builder_ptr == nullptr, "Invalid index builder handle"); + auto dynamic_params = read_dynamic_index_params(params); + auto breakdown = builder_ptr->estimate_memory_breakdown_dynamic( + num_vectors, dynamic_params.block_params ); set_memory_breakdown( out_breakdown, @@ -709,7 +793,41 @@ SVS_API bool svs_index_builder_estimate_search_memory_dynamic( num_neighbors, search_params ? search_params->impl : nullptr, id_filter == nullptr ? nullptr : &filter, - blocksize_bytes + make_blocking_parameters(blocksize_bytes) + ); + *out_size = size; + return true; + }, + out_err, + false + ); +} + +SVS_API bool svs_index_builder_estimate_search_memory_dynamic_ex( + svs_index_builder_h builder, + size_t num_queries, + size_t num_neighbors, + svs_search_params_h search_params, + svs_id_filter_i id_filter, + const svs_dynamic_index_params_t* params, + size_t* out_size, + svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(builder); + EXPECT_ARG_NOT_NULL(out_size); + EXPECT_ARG_GT_THAN(num_queries, 0); + EXPECT_ARG_GT_THAN(num_neighbors, 0); + auto dynamic_params = read_dynamic_index_params(params); + const IDFilterAdapter filter(id_filter); + auto size = builder->impl->estimate_search_memory_dynamic( + num_queries, + num_neighbors, + search_params ? search_params->impl : nullptr, + id_filter == nullptr ? nullptr : &filter, + dynamic_params.block_params ); *out_size = size; return true; @@ -813,7 +931,58 @@ extern "C" svs_index_h svs_index_build_dynamic( } auto index = builder->impl->build_dynamic( - src_data, std::span(ids, num_vectors), blocksize_bytes + src_data, + std::span(ids, num_vectors), + make_blocking_parameters(blocksize_bytes) + ); + if (index == nullptr) { + SET_ERROR(out_err, SVS_ERROR_RUNTIME, "Dynamic index build failed"); + return svs_index_h{nullptr}; + } + + auto result = new svs_index; + result->impl = index; + return result; + }, + out_err + ); +} + +extern "C" svs_index_h svs_index_build_dynamic_ex( + svs_index_builder_h builder, + const float* data, + const size_t* ids, + size_t num_vectors, + const svs_dynamic_index_params_t* params, + svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(builder); + EXPECT_ARG_GT_THAN(num_vectors, 0); + EXPECT_ARG_NOT_NULL(data); + NOT_IMPLEMENTED_IF( + (builder->impl->algorithm->type != SVS_ALGORITHM_TYPE_VAMANA), + "Only Vamana algorithm is currently supported for dynamic index building" + ); + auto dynamic_params = read_dynamic_index_params(params); + auto src_data = svs::data::ConstSimpleDataView( + data, num_vectors, builder->impl->dimension + ); + + std::vector generated_ids; + if (ids == nullptr) { + generated_ids.resize(num_vectors); + std::iota(generated_ids.begin(), generated_ids.end(), 0); + ids = generated_ids.data(); + } + + auto index = builder->impl->build_dynamic( + src_data, + std::span(ids, num_vectors), + dynamic_params.block_params, + dynamic_params.sync_kind ); if (index == nullptr) { SET_ERROR(out_err, SVS_ERROR_RUNTIME, "Dynamic index build failed"); @@ -844,7 +1013,40 @@ extern "C" svs_index_h svs_index_load_dynamic( "Only Vamana algorithm is currently supported for dynamic index loading" ); auto index = builder->impl->load_dynamic( - std::filesystem::path{directory}, blocksize_bytes + std::filesystem::path{directory}, make_blocking_parameters(blocksize_bytes) + ); + if (index == nullptr) { + SET_ERROR(out_err, SVS_ERROR_RUNTIME, "Dynamic index load failed"); + return svs_index_h{nullptr}; + } + auto result = new svs_index; + result->impl = index; + return result; + }, + out_err + ); +} + +extern "C" svs_index_h svs_index_load_dynamic_ex( + svs_index_builder_h builder, + const char* directory, + const svs_dynamic_index_params_t* params, + svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(builder); + EXPECT_ARG_NOT_NULL(directory); + NOT_IMPLEMENTED_IF( + (builder->impl->algorithm->type != SVS_ALGORITHM_TYPE_VAMANA), + "Only Vamana algorithm is currently supported for dynamic index loading" + ); + auto dynamic_params = read_dynamic_index_params(params); + auto index = builder->impl->load_dynamic( + std::filesystem::path{directory}, + dynamic_params.block_params, + dynamic_params.sync_kind ); if (index == nullptr) { SET_ERROR(out_err, SVS_ERROR_RUNTIME, "Dynamic index load failed"); @@ -925,7 +1127,44 @@ extern "C" svs_index_h svs_index_convert_dynamic( std::dynamic_pointer_cast(src_index->impl) == nullptr, "Source index is not a dynamic index" ); - auto index = builder->impl->copy_dynamic(src_index->impl, blocksize_bytes); + auto index = builder->impl->copy_dynamic( + src_index->impl, make_blocking_parameters(blocksize_bytes) + ); + if (index == nullptr) { + SET_ERROR(out_err, SVS_ERROR_RUNTIME, "Dynamic index conversion failed"); + return svs_index_h{nullptr}; + } + auto result = new svs_index; + result->impl = index; + return result; + }, + out_err + ); +} + +extern "C" svs_index_h svs_index_convert_dynamic_ex( + svs_index_builder_h builder, + svs_index_h src_index, + const svs_dynamic_index_params_t* params, + svs_error_h out_err /*=NULL*/ +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(builder); + EXPECT_ARG_NOT_NULL(src_index); + NOT_IMPLEMENTED_IF( + (builder->impl->algorithm->type != SVS_ALGORITHM_TYPE_VAMANA), + "Only Vamana algorithm is currently supported for dynamic index conversion" + ); + INVALID_ARGUMENT_IF( + std::dynamic_pointer_cast(src_index->impl) == nullptr, + "Source index is not a dynamic index" + ); + auto dynamic_params = read_dynamic_index_params(params); + auto index = builder->impl->copy_dynamic( + src_index->impl, dynamic_params.block_params, dynamic_params.sync_kind + ); if (index == nullptr) { SET_ERROR(out_err, SVS_ERROR_RUNTIME, "Dynamic index conversion failed"); return svs_index_h{nullptr}; @@ -1189,6 +1428,26 @@ extern "C" bool svs_index_dynamic_has_id( ); } +extern "C" bool svs_index_dynamic_get_sync_kind( + svs_index_h index, svs_sync_kind_t* out_sync_kind, svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(index); + EXPECT_ARG_NOT_NULL(out_sync_kind); + auto dynamic_index_ptr = std::dynamic_pointer_cast(index->impl); + INVALID_ARGUMENT_IF( + dynamic_index_ptr == nullptr, "Index does not support dynamic updates" + ); + *out_sync_kind = dynamic_index_ptr->sync_kind; + return true; + }, + out_err, + false + ); +} + extern "C" bool svs_index_get_distance( svs_index_h index, size_t id, diff --git a/bindings/c/src/types_support.hpp b/bindings/c/src/types_support.hpp index ccc2acc2..8231ad64 100644 --- a/bindings/c/src/types_support.hpp +++ b/bindings/c/src/types_support.hpp @@ -31,6 +31,19 @@ namespace svs { namespace c_runtime { +// Zero values select the defaults; non-zero values are rounded down to a power of two. +inline svs::data::BlockingParameters +make_blocking_parameters(size_t blocksize_bytes, size_t blocksize_elements = 0) { + svs::data::BlockingParameters block_params; + if (blocksize_bytes != 0) { + block_params.blocksize_bytes = svs::lib::prevpow2(blocksize_bytes); + } + if (blocksize_elements != 0) { + block_params.blocksize_elements = svs::lib::prevpow2(blocksize_elements); + } + return block_params; +} + inline svs::DistanceType to_distance_type(svs_distance_metric_t distance_metric) { switch (distance_metric) { case SVS_DISTANCE_METRIC_EUCLIDEAN: diff --git a/bindings/c/tests/CMakeLists.txt b/bindings/c/tests/CMakeLists.txt index a7c351cc..f61f41ef 100644 --- a/bindings/c/tests/CMakeLists.txt +++ b/bindings/c/tests/CMakeLists.txt @@ -14,6 +14,9 @@ set(TARGET_NAME svs_c_api_test) +# Dynamic index sync tests use threads and require linking with the Threads library +find_package(Threads REQUIRED) + # Check if Catch2 is available find_package(Catch2 3 QUIET) @@ -51,15 +54,17 @@ set(C_API_TEST_SOURCES c_api_index.cpp c_api_index_convert.cpp c_api_dynamic_index.cpp + c_api_dynamic_index_sync.cpp ) # Create test executable add_executable(${TARGET_NAME} ${C_API_TEST_SOURCES}) -# Link with C API library and Catch2 +# Link with C API library, Catch2, and Threads library target_link_libraries(${TARGET_NAME} PRIVATE svs_c_api Catch2::Catch2WithMain + Threads::Threads ) # Tell the tests which storage backends this build is expected to provide, so diff --git a/bindings/c/tests/README.md b/bindings/c/tests/README.md index 4a6ba567..9726b735 100644 --- a/bindings/c/tests/README.md +++ b/bindings/c/tests/README.md @@ -29,6 +29,7 @@ The tests are organized into separate files by functionality: - **c_api_index_builder.cpp**: Tests for index builder creation and configuration - **c_api_index.cpp**: Tests for index building, searching, and basic operations - **c_api_dynamic_index.cpp**: Tests for dynamic index operations (add, delete, consolidate, compact) +- **c_api_dynamic_index_sync.cpp**: Tests for dynamic index parameters (`_ex` functions) and internal synchronization under concurrent readers/writers Note: The main() function is provided by Catch2::Catch2WithMain automatically. @@ -70,6 +71,9 @@ cmake -DSVS_BUILD_C_API_TESTS=OFF .. # Run dynamic index tests ./svs_c_api_test "[c_api][dynamic]" + +# Run dynamic index synchronization tests +./svs_c_api_test "[c_api][dynamic][sync]" ``` ### Run with verbose output diff --git a/bindings/c/tests/c_api_dynamic_index_sync.cpp b/bindings/c/tests/c_api_dynamic_index_sync.cpp new file mode 100644 index 00000000..b65b1c07 --- /dev/null +++ b/bindings/c/tests/c_api_dynamic_index_sync.cpp @@ -0,0 +1,611 @@ +/* + * 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. + */ + +// C API +#include "svs/c/svs_c.h" + +// catch2 +#include "catch2/catch_test_macros.hpp" +#include "catch2/generators/catch_generators.hpp" + +// Test utilities +#include "c_api_test_utils.h" + +// Standard library +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr size_t DIMENSION = 32; +constexpr size_t NUM_VECTORS = 200; +constexpr size_t NUM_QUERIES = 4; +constexpr size_t K = 5; + +// Base of the ID range used by writer threads; disjoint from the initial IDs. +constexpr size_t WRITER_ID_BASE = 100'000; + +// Small blocks keep the tests fast; the default block size is 1 GiB. +constexpr size_t BLOCK_SIZE = 1 << 20; + +// Catch2 assertions are not thread-safe, so worker threads record failures here and the +// main thread asserts on them after joining. +class FailureLog { + public: + void check(bool ok, svs_error_h err, const char* what) { + if (ok) { + return; + } + failures_.fetch_add(1, std::memory_order_relaxed); + std::lock_guard lock{mutex_}; + if (first_.empty()) { + first_ = std::string{what} + ": " + + (err != nullptr ? svs_error_get_message(err) : "unknown error"); + } + } + + size_t count() const { return failures_.load(); } + std::string first() const { + std::lock_guard lock{mutex_}; + return first_; + } + + private: + std::atomic failures_{0}; + mutable std::mutex mutex_; + std::string first_; +}; + +struct SyncFixture { + svs_error_h error = svs_error_create(); + svs_algorithm_h algorithm = nullptr; + svs_index_builder_h builder = nullptr; + std::vector data; + std::vector queries; + std::vector ids; + + SyncFixture() { + generate_test_data(data, NUM_VECTORS, DIMENSION); + generate_test_data(queries, NUM_QUERIES, DIMENSION); + ids.resize(NUM_VECTORS); + for (size_t i = 0; i < NUM_VECTORS; ++i) { + ids[i] = i; + } + algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + svs_index_builder_set_threadpool(builder, SVS_THREADPOOL_KIND_NATIVE, 2, error); + } + + ~SyncFixture() { + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } + + SyncFixture(const SyncFixture&) = delete; + SyncFixture& operator=(const SyncFixture&) = delete; + + svs_index_h build(svs_sync_kind_t sync_kind) { + svs_dynamic_index_params_t params = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + params.blocksize_bytes = BLOCK_SIZE; + params.sync_kind = sync_kind; + return svs_index_build_dynamic_ex( + builder, data.data(), ids.data(), NUM_VECTORS, ¶ms, error + ); + } +}; + +bool is_known_id(size_t id, size_t num_writer_ids) { + return id < NUM_VECTORS || + (id >= WRITER_ID_BASE && id < WRITER_ID_BASE + num_writer_ids); +} + +// Runs concurrent writers (add/delete/consolidate/compact) and readers (search, has_id, +// get_distance, size, memory, conversion) on `index`, then verifies the final state. +void run_concurrent_workload(SyncFixture& fx, svs_index_h index) { + constexpr size_t NUM_WRITERS = 2; + constexpr size_t NUM_READERS = 4; + constexpr size_t ITERATIONS = 20; + constexpr size_t READER_ITERATIONS = 40; + constexpr size_t BATCH = 8; + constexpr size_t NUM_WRITER_IDS = NUM_WRITERS * ITERATIONS * BATCH; + + std::vector batch_data; + generate_test_data(batch_data, BATCH, DIMENSION); + + FailureLog log; + + auto writer = [&](size_t w) { + svs_error_h err = svs_error_create(); + std::vector batch_ids(BATCH); + for (size_t iter = 0; iter < ITERATIONS; ++iter) { + const size_t base = WRITER_ID_BASE + (w * ITERATIONS + iter) * BATCH; + for (size_t i = 0; i < BATCH; ++i) { + batch_ids[i] = base + i; + } + + size_t added = 0; + log.check( + svs_index_dynamic_add_points( + index, batch_data.data(), batch_ids.data(), BATCH, &added, err + ), + err, + "add_points" + ); + log.check(added == BATCH, err, "add_points count"); + + size_t deleted = 0; + log.check( + svs_index_dynamic_delete_points( + index, batch_ids.data(), BATCH / 2, &deleted, err + ), + err, + "delete_points" + ); + log.check(deleted == BATCH / 2, err, "delete_points count"); + + if (iter % 5 == 4) { + log.check(svs_index_dynamic_consolidate(index, err), err, "consolidate"); + log.check(svs_index_dynamic_compact(index, 0, err), err, "compact"); + } + } + svs_error_free(err); + }; + + auto reader = [&](size_t r) { + svs_error_h err = svs_error_create(); + svs_search_results_t results = SVS_INIT_SEARCH_RESULTS(); + std::mt19937 rng(static_cast(r)); + // std::shared_mutex may prefer readers, so pause randomly to let writers in. + std::uniform_int_distribution pause_us(0, 500); + for (size_t iter = 0; iter < READER_ITERATIONS; ++iter) { + std::this_thread::sleep_for(std::chrono::microseconds(pause_us(rng))); + + bool ok = svs_index_search_topk( + index, fx.queries.data(), NUM_QUERIES, K, &results, nullptr, nullptr, err + ); + log.check(ok, err, "search_topk"); + if (ok) { + for (size_t i = 0; i < results.total_results; ++i) { + log.check( + is_known_id(results.indices[i], NUM_WRITER_IDS), + err, + "search returned unknown id" + ); + } + } + + bool has_id = false; + const size_t base_id = (r * 31 + iter) % NUM_VECTORS; + log.check( + svs_index_dynamic_has_id(index, base_id, &has_id, err), err, "has_id" + ); + log.check(has_id, err, "initial id missing"); + + float distance = 0.0f; + log.check( + svs_index_get_distance(index, base_id, fx.queries.data(), &distance, err), + err, + "get_distance" + ); + + size_t size = 0; + log.check(svs_index_get_size(index, &size, err), err, "get_size"); + log.check(size >= NUM_VECTORS, err, "index shrank below initial size"); + + size_t memory = 0; + log.check(svs_index_get_memory_usage(index, &memory, err), err, "memory_usage"); + + if (r == 0 && iter % 8 == 0) { + svs_index_h copy = + svs_index_convert_dynamic(fx.builder, index, BLOCK_SIZE, err); + log.check(copy != nullptr, err, "convert_dynamic"); + svs_index_free(copy); + } + } + svs_search_results_free(&results); + svs_error_free(err); + }; + + std::vector threads; + for (size_t w = 0; w < NUM_WRITERS; ++w) { + threads.emplace_back(writer, w); + } + for (size_t r = 0; r < NUM_READERS; ++r) { + threads.emplace_back(reader, r); + } + for (auto& t : threads) { + t.join(); + } + + CATCH_INFO("First failure: " << log.first()); + CATCH_REQUIRE(log.count() == 0); + + // Final state: first half of every batch deleted, second half present. + for (size_t w = 0; w < NUM_WRITERS; ++w) { + for (size_t iter = 0; iter < ITERATIONS; ++iter) { + const size_t base = WRITER_ID_BASE + (w * ITERATIONS + iter) * BATCH; + for (size_t i = 0; i < BATCH; ++i) { + bool has_id = false; + CATCH_REQUIRE(svs_index_dynamic_has_id(index, base + i, &has_id, fx.error)); + CATCH_REQUIRE(has_id == (i >= BATCH / 2)); + } + } + } + + size_t size = 0; + CATCH_REQUIRE(svs_index_get_size(index, &size, fx.error)); + CATCH_REQUIRE(size == NUM_VECTORS + NUM_WRITER_IDS / 2); +} + +} // namespace + +CATCH_TEST_CASE("C API Dynamic Index Params", "[c_api][index][dynamic][sync]") { + SyncFixture fx; + CATCH_REQUIRE(fx.builder != nullptr); + + CATCH_SECTION("Default params build a usable index") { + svs_dynamic_index_params_t params = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + svs_index_h index = svs_index_build_dynamic_ex( + fx.builder, fx.data.data(), nullptr, NUM_VECTORS, ¶ms, fx.error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + size_t size = 0; + CATCH_REQUIRE(svs_index_get_size(index, &size, fx.error)); + CATCH_REQUIRE(size == NUM_VECTORS); + svs_index_free(index); + } + + CATCH_SECTION("NULL params mean defaults") { + svs_index_h index = svs_index_build_dynamic_ex( + fx.builder, fx.data.data(), nullptr, NUM_VECTORS, nullptr, fx.error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + svs_sync_kind_t kind = SVS_SYNC_KIND_GLOBAL; + CATCH_REQUIRE(svs_index_dynamic_get_sync_kind(index, &kind, fx.error)); + CATCH_REQUIRE(kind == SVS_SYNC_KIND_NONE); + + TempDir dir; + CATCH_REQUIRE(svs_index_save(index, dir.string().c_str(), fx.error)); + svs_index_h loaded = + svs_index_load_dynamic_ex(fx.builder, dir.string().c_str(), nullptr, fx.error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + CATCH_REQUIRE(svs_index_dynamic_get_sync_kind(loaded, &kind, fx.error)); + CATCH_REQUIRE(kind == SVS_SYNC_KIND_NONE); + + svs_index_h converted = + svs_index_convert_dynamic_ex(fx.builder, index, nullptr, fx.error); + CATCH_REQUIRE(converted != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + CATCH_REQUIRE(svs_index_dynamic_get_sync_kind(converted, &kind, fx.error)); + CATCH_REQUIRE(kind == SVS_SYNC_KIND_NONE); + + svs_memory_breakdown_t legacy = SVS_INIT_MEMORY_BREAKDOWN(); + CATCH_REQUIRE(svs_index_builder_estimate_memory_dynamic( + fx.builder, NUM_VECTORS, 0, &legacy, fx.error + )); + svs_memory_breakdown_t by_null = SVS_INIT_MEMORY_BREAKDOWN(); + CATCH_REQUIRE(svs_index_builder_estimate_memory_dynamic_ex( + fx.builder, NUM_VECTORS, nullptr, &by_null, fx.error + )); + CATCH_REQUIRE(by_null.graph_bytes == legacy.graph_bytes); + CATCH_REQUIRE(by_null.data_bytes == legacy.data_bytes); + CATCH_REQUIRE(by_null.metadata_bytes == legacy.metadata_bytes); + + size_t legacy_search = 0; + CATCH_REQUIRE(svs_index_builder_estimate_search_memory_dynamic( + fx.builder, NUM_QUERIES, K, nullptr, nullptr, 0, &legacy_search, fx.error + )); + size_t null_search = 0; + CATCH_REQUIRE(svs_index_builder_estimate_search_memory_dynamic_ex( + fx.builder, NUM_QUERIES, K, nullptr, nullptr, nullptr, &null_search, fx.error + )); + CATCH_REQUIRE(null_search == legacy_search); + + svs_index_free(converted); + svs_index_free(loaded); + svs_index_free(index); + } + + CATCH_SECTION("Invalid params are rejected") { + auto expect_invalid = [&](const svs_dynamic_index_params_t* params) { + svs_index_h index = svs_index_build_dynamic_ex( + fx.builder, fx.data.data(), nullptr, NUM_VECTORS, params, fx.error + ); + CATCH_REQUIRE(index == nullptr); + CATCH_REQUIRE(svs_error_get_code(fx.error) == SVS_ERROR_INVALID_ARGUMENT); + }; + + svs_dynamic_index_params_t bad_bytes = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + bad_bytes.blocksize_bytes = 3000; + expect_invalid(&bad_bytes); + + svs_dynamic_index_params_t bad_elements = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + bad_elements.blocksize_elements = 100; + expect_invalid(&bad_elements); + + svs_dynamic_index_params_t bad_sync = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + bad_sync.sync_kind = 3; + expect_invalid(&bad_sync); + + svs_dynamic_index_params_t bad_version = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + bad_version.version = svs_get_version() + 1; + expect_invalid(&bad_version); + + svs_dynamic_index_params_t bad_size = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + bad_size.struct_size = sizeof(svs_dynamic_index_params_t) + 1; + expect_invalid(&bad_size); + + // Other _ex entry points validate params too. + svs_index_h source = fx.build(SVS_SYNC_KIND_NONE); + CATCH_REQUIRE(source != nullptr); + CATCH_REQUIRE( + svs_index_convert_dynamic_ex(fx.builder, source, &bad_sync, fx.error) == nullptr + ); + CATCH_REQUIRE(svs_error_get_code(fx.error) == SVS_ERROR_INVALID_ARGUMENT); + + TempDir dir; + CATCH_REQUIRE(svs_index_save(source, dir.string().c_str(), fx.error)); + svs_index_free(source); + CATCH_REQUIRE( + svs_index_load_dynamic_ex( + fx.builder, dir.string().c_str(), &bad_bytes, fx.error + ) == nullptr + ); + CATCH_REQUIRE(svs_error_get_code(fx.error) == SVS_ERROR_INVALID_ARGUMENT); + } + + CATCH_SECTION("Sync kind getter") { + svs_sync_kind_t kind = SVS_SYNC_KIND_NONE; + CATCH_REQUIRE_FALSE(svs_index_dynamic_get_sync_kind(nullptr, &kind, fx.error)); + CATCH_REQUIRE(svs_error_get_code(fx.error) == SVS_ERROR_INVALID_ARGUMENT); + + svs_index_h index = fx.build(SVS_SYNC_KIND_GLOBAL); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE_FALSE(svs_index_dynamic_get_sync_kind(index, nullptr, fx.error)); + CATCH_REQUIRE(svs_error_get_code(fx.error) == SVS_ERROR_INVALID_ARGUMENT); + svs_index_free(index); + + svs_index_h static_index = + svs_index_build(fx.builder, fx.data.data(), NUM_VECTORS, fx.error); + CATCH_REQUIRE(static_index != nullptr); + CATCH_REQUIRE_FALSE(svs_index_dynamic_get_sync_kind(static_index, &kind, fx.error)); + CATCH_REQUIRE(svs_error_get_code(fx.error) == SVS_ERROR_INVALID_ARGUMENT); + svs_index_free(static_index); + } + + CATCH_SECTION("Fields beyond struct_size are ignored") { + svs_dynamic_index_params_t params = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + params.struct_size = offsetof(svs_dynamic_index_params_t, blocksize_bytes); + params.blocksize_bytes = 3000; // invalid, but not covered by struct_size + params.blocksize_elements = 100; // invalid, but not covered by struct_size + params.sync_kind = 3; + svs_index_h index = svs_index_build_dynamic_ex( + fx.builder, fx.data.data(), nullptr, NUM_VECTORS, ¶ms, fx.error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + svs_index_free(index); + } + + CATCH_SECTION("Memory estimates honor block parameters") { + svs_memory_breakdown_t legacy = SVS_INIT_MEMORY_BREAKDOWN(); + CATCH_REQUIRE(svs_index_builder_estimate_memory_dynamic( + fx.builder, NUM_VECTORS, BLOCK_SIZE, &legacy, fx.error + )); + + svs_dynamic_index_params_t params = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + params.blocksize_bytes = BLOCK_SIZE; + svs_memory_breakdown_t by_bytes = SVS_INIT_MEMORY_BREAKDOWN(); + CATCH_REQUIRE(svs_index_builder_estimate_memory_dynamic_ex( + fx.builder, NUM_VECTORS, ¶ms, &by_bytes, fx.error + )); + CATCH_REQUIRE(svs_error_ok(fx.error)); + CATCH_REQUIRE(by_bytes.graph_bytes == legacy.graph_bytes); + CATCH_REQUIRE(by_bytes.data_bytes == legacy.data_bytes); + CATCH_REQUIRE(by_bytes.metadata_bytes == legacy.metadata_bytes); + + // Small element-based blocks take precedence and shrink the padded allocation. + params.blocksize_elements = 16; + svs_memory_breakdown_t by_elements = SVS_INIT_MEMORY_BREAKDOWN(); + CATCH_REQUIRE(svs_index_builder_estimate_memory_dynamic_ex( + fx.builder, NUM_VECTORS, ¶ms, &by_elements, fx.error + )); + CATCH_REQUIRE(svs_error_ok(fx.error)); + CATCH_REQUIRE(by_elements.data_bytes < by_bytes.data_bytes); + CATCH_REQUIRE(by_elements.graph_bytes < by_bytes.graph_bytes); + + size_t legacy_search = 0; + CATCH_REQUIRE(svs_index_builder_estimate_search_memory_dynamic( + fx.builder, + NUM_QUERIES, + K, + nullptr, + nullptr, + BLOCK_SIZE, + &legacy_search, + fx.error + )); + size_t ex_search = 0; + CATCH_REQUIRE(svs_index_builder_estimate_search_memory_dynamic_ex( + fx.builder, NUM_QUERIES, K, nullptr, nullptr, ¶ms, &ex_search, fx.error + )); + CATCH_REQUIRE(svs_error_ok(fx.error)); + CATCH_REQUIRE(ex_search == legacy_search); + + svs_dynamic_index_params_t bad_params = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + bad_params.blocksize_elements = 100; + CATCH_REQUIRE_FALSE(svs_index_builder_estimate_memory_dynamic_ex( + fx.builder, NUM_VECTORS, &bad_params, &by_bytes, fx.error + )); + CATCH_REQUIRE(svs_error_get_code(fx.error) == SVS_ERROR_INVALID_ARGUMENT); + CATCH_REQUIRE_FALSE(svs_index_builder_estimate_search_memory_dynamic_ex( + fx.builder, NUM_QUERIES, K, nullptr, nullptr, &bad_params, &ex_search, fx.error + )); + CATCH_REQUIRE(svs_error_get_code(fx.error) == SVS_ERROR_INVALID_ARGUMENT); + } +} + +CATCH_TEST_CASE("C API Dynamic Index Sync Sequential", "[c_api][index][dynamic][sync]") { + // Every operation must work (and not self-deadlock) for every sync kind. + const auto sync_kind = + GENERATE(SVS_SYNC_KIND_NONE, SVS_SYNC_KIND_GLOBAL, SVS_SYNC_KIND_FINE_GRAIN); + CATCH_CAPTURE(sync_kind); + + SyncFixture fx; + svs_index_h index = fx.build(sync_kind); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + + svs_sync_kind_t actual_kind = SVS_SYNC_KIND_NONE; + CATCH_REQUIRE(svs_index_dynamic_get_sync_kind(index, &actual_kind, fx.error)); + CATCH_REQUIRE(actual_kind == sync_kind); + + std::vector new_ids = {NUM_VECTORS, NUM_VECTORS + 1}; + std::vector new_data; + generate_test_data(new_data, new_ids.size(), DIMENSION); + size_t count = 0; + CATCH_REQUIRE(svs_index_dynamic_add_points( + index, new_data.data(), new_ids.data(), new_ids.size(), &count, fx.error + )); + CATCH_REQUIRE(count == new_ids.size()); + CATCH_REQUIRE( + svs_index_dynamic_delete_points(index, new_ids.data(), 1, &count, fx.error) + ); + CATCH_REQUIRE(count == 1); + + bool has_id = true; + CATCH_REQUIRE(svs_index_dynamic_has_id(index, new_ids[0], &has_id, fx.error)); + CATCH_REQUIRE_FALSE(has_id); + + svs_search_results_t results = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + index, fx.queries.data(), NUM_QUERIES, K, &results, nullptr, nullptr, fx.error + )); + svs_search_results_free(&results); + + float distance = 0.0f; + CATCH_REQUIRE(svs_index_get_distance(index, 0, fx.queries.data(), &distance, fx.error)); + std::vector reconstructed(DIMENSION); + CATCH_REQUIRE(svs_index_reconstruct( + index, fx.ids.data(), 1, reconstructed.data(), DIMENSION, fx.error + )); + + CATCH_REQUIRE(svs_index_dynamic_consolidate(index, fx.error)); + CATCH_REQUIRE(svs_index_dynamic_compact(index, 0, fx.error)); + + CATCH_REQUIRE(svs_index_set_num_threads(index, 1, fx.error)); + size_t num_threads = 0; + CATCH_REQUIRE(svs_index_get_num_threads(index, &num_threads, fx.error)); + CATCH_REQUIRE(num_threads == 1); + + size_t memory = 0; + CATCH_REQUIRE(svs_index_get_memory_usage(index, &memory, fx.error)); + CATCH_REQUIRE(memory > 0); + + TempDir dir; + CATCH_REQUIRE(svs_index_save(index, dir.string().c_str(), fx.error)); + + svs_dynamic_index_params_t params = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + params.blocksize_bytes = BLOCK_SIZE; + params.sync_kind = sync_kind; + svs_index_h loaded = + svs_index_load_dynamic_ex(fx.builder, dir.string().c_str(), ¶ms, fx.error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + + size_t size = 0; + CATCH_REQUIRE(svs_index_get_size(loaded, &size, fx.error)); + CATCH_REQUIRE(size == NUM_VECTORS + 1); + CATCH_REQUIRE(svs_index_dynamic_get_sync_kind(loaded, &actual_kind, fx.error)); + CATCH_REQUIRE(actual_kind == sync_kind); + + // The converted index takes its sync kind from params, not from the source. + params.sync_kind = SVS_SYNC_KIND_GLOBAL; + svs_index_h converted = + svs_index_convert_dynamic_ex(fx.builder, index, ¶ms, fx.error); + CATCH_REQUIRE(converted != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + CATCH_REQUIRE(svs_index_dynamic_get_sync_kind(converted, &actual_kind, fx.error)); + CATCH_REQUIRE(actual_kind == SVS_SYNC_KIND_GLOBAL); + CATCH_REQUIRE(svs_index_get_size(converted, &size, fx.error)); + CATCH_REQUIRE(size == NUM_VECTORS + 1); + + svs_index_free(converted); + svs_index_free(loaded); + svs_index_free(index); +} + +CATCH_TEST_CASE("C API Dynamic Index Sync Concurrent", "[c_api][index][dynamic][sync]") { + const auto sync_kind = GENERATE(SVS_SYNC_KIND_GLOBAL, SVS_SYNC_KIND_FINE_GRAIN); + CATCH_CAPTURE(sync_kind); + + SyncFixture fx; + CATCH_REQUIRE(fx.builder != nullptr); + + CATCH_SECTION("Built index") { + svs_index_h index = fx.build(sync_kind); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + run_concurrent_workload(fx, index); + svs_index_free(index); + } + + CATCH_SECTION("Loaded index") { + svs_index_h source = fx.build(SVS_SYNC_KIND_NONE); + CATCH_REQUIRE(source != nullptr); + TempDir dir; + CATCH_REQUIRE(svs_index_save(source, dir.string().c_str(), fx.error)); + svs_index_free(source); + + svs_dynamic_index_params_t params = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + params.blocksize_elements = 64; + params.sync_kind = sync_kind; + svs_index_h index = + svs_index_load_dynamic_ex(fx.builder, dir.string().c_str(), ¶ms, fx.error); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + run_concurrent_workload(fx, index); + svs_index_free(index); + } + + CATCH_SECTION("Converted index") { + svs_index_h source = fx.build(SVS_SYNC_KIND_NONE); + CATCH_REQUIRE(source != nullptr); + + svs_dynamic_index_params_t params = SVS_INIT_DYNAMIC_INDEX_PARAMS(); + params.blocksize_bytes = BLOCK_SIZE; + params.sync_kind = sync_kind; + svs_index_h index = + svs_index_convert_dynamic_ex(fx.builder, source, ¶ms, fx.error); + svs_index_free(source); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(fx.error)); + run_concurrent_workload(fx, index); + svs_index_free(index); + } +}