Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 163 additions & 126 deletions c/include/cuvs/neighbors/hnsw.h

Large diffs are not rendered by default.

421 changes: 228 additions & 193 deletions c/src/neighbors/hnsw.cpp

Large diffs are not rendered by default.

414 changes: 279 additions & 135 deletions c/tests/neighbors/ann_hnsw_c.cu

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion cpp/bench/ann/src/cuvs/cuvs_cagra_hnswlib_wrapper.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION.
* SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
Expand Down Expand Up @@ -31,6 +31,9 @@ class cuvs_cagra_hnswlib : public algo<T>, public algo_gpu {
cuvs_cagra_hnswlib(Metric metric, int dim, const build_param& param, int concurrent_searches = 1)
: algo<T>(metric, dim), build_param_{param}
{
// The benchmark metric must reach the HNSW build params, or the index is built with the
// default L2 metric while load() deserializes with the benchmark metric.
build_param_.hnsw_index_params.metric = parse_metric_type(metric);
}

void build(const T* dataset, size_t nrow) final;
Expand Down
189 changes: 85 additions & 104 deletions cpp/include/cuvs/neighbors/hnsw.hpp

Large diffs are not rendered by default.

84 changes: 77 additions & 7 deletions cpp/src/neighbors/detail/hnsw.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION.
* SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

Expand Down Expand Up @@ -30,6 +30,7 @@
#include <fcntl.h>
#include <filesystem>
#include <fstream>
#include <limits>
#include <memory>
#include <mutex>
#include <new>
Expand Down Expand Up @@ -175,6 +176,16 @@ struct index_impl : index<T> {
return "";
}

/**
@brief Mark the in-memory index as diverged from its disk-backed file
*/
void mark_modified() { modified_ = true; }

/**
@brief Whether the in-memory index has diverged from its disk-backed file
*/
bool modified() const { return modified_; }

/**
@brief Ensure the index is loaded into memory.
If the index is disk-backed and not yet loaded, this will load it from the file.
Expand Down Expand Up @@ -213,6 +224,7 @@ struct index_impl : index<T> {
mutable std::unique_ptr<hnswlib::HierarchicalNSW<typename hnsw_dist_t<T>::type>> appr_alg_;
std::unique_ptr<hnswlib::SpaceInterface<typename hnsw_dist_t<T>::type>> space_;
std::optional<cuvs::util::file_descriptor> hnsw_fd_;
bool modified_ = false;
};

template <typename T, HnswHierarchy hierarchy>
Expand Down Expand Up @@ -1233,13 +1245,26 @@ inline std::pair<size_t, size_t> get_available_memory(
return std::make_pair(available_host_memory, available_device_memory);
}

inline void validate_index_params(const index_params& params)
{
RAFT_EXPECTS(params.ef_construction > 0, "ef_construction must be greater than zero.");
RAFT_EXPECTS(params.num_threads >= 0, "num_threads must not be negative.");
}

template <typename T>
std::unique_ptr<index<T>> from_cagra(
raft::resources const& res,
const index_params& params,
const cuvs::neighbors::cagra::index<T, uint32_t>& cagra_index,
std::optional<raft::host_matrix_view<const T, int64_t, raft::row_major>> dataset)
{
// hnswlib clamps ef_construction to at least M during construction and hierarchy NONE does
// not consume it at all, so conversions keep accepting the zero values older callers may
// pass. hnsw::build consumes ef_construction directly and validates it via
// validate_index_params.
RAFT_EXPECTS(params.ef_construction >= 0, "ef_construction must not be negative.");
RAFT_EXPECTS(params.num_threads >= 0, "num_threads must not be negative.");

// special treatment for index on disk
if (cagra_index.dataset_fd().has_value() && cagra_index.graph_fd().has_value()) {
// Get directory from graph file descriptor
Expand Down Expand Up @@ -1394,9 +1419,18 @@ void extend(raft::resources const& res,
raft::host_matrix_view<const T, int64_t, raft::row_major> additional_dataset,
index<T>& idx)
{
RAFT_EXPECTS(params.num_threads >= 0, "num_threads must not be negative.");
RAFT_EXPECTS(idx.hierarchy() != HnswHierarchy::NONE,
"A base-layer-only HNSW index cannot be extended.");
RAFT_EXPECTS(additional_dataset.extent(1) == idx.dim(),
"Number of additional dataset dimensions must equal the index dimensions.");

// If the index is disk-backed, load it into memory first
auto* idx_impl = dynamic_cast<index_impl<T>*>(&idx);
if (idx_impl) { idx_impl->ensure_loaded(); }
if (idx_impl) {
idx_impl->ensure_loaded();
idx_impl->mark_modified();
}

auto* hnswlib_index = reinterpret_cast<hnswlib::HierarchicalNSW<typename hnsw_dist_t<T>::type>*>(
const_cast<void*>(idx.get_index()));
Expand Down Expand Up @@ -1439,6 +1473,11 @@ void search(raft::resources const& res,
raft::host_matrix_view<uint64_t, int64_t, raft::row_major> neighbors,
raft::host_matrix_view<float, int64_t, raft::row_major> distances)
{
// hnswlib searches with a candidate list of max(ef, k), so ef == 0 keeps its historical
// meaning of "derive the candidate list size from k".
RAFT_EXPECTS(params.ef >= 0, "ef must not be negative.");
RAFT_EXPECTS(params.num_threads >= 0, "num_threads must not be negative.");

// If the index is disk-backed, load it into memory first
auto* idx_impl = dynamic_cast<const index_impl<T>*>(&idx);
if (idx_impl) { idx_impl->ensure_loaded(); }
Expand All @@ -1456,6 +1495,8 @@ void search(raft::resources const& res,
auto const* hnswlib_index =
reinterpret_cast<hnswlib::HierarchicalNSW<typename hnsw_dist_t<T>::type> const*>(
idx.get_index());
RAFT_EXPECTS(neighbors.extent(1) <= static_cast<int64_t>(hnswlib_index->cur_element_count),
"k must not exceed the number of vectors in the index.");

// when num_threads == 0, automatically maximize parallelism
if (params.num_threads) {
Expand Down Expand Up @@ -1484,9 +1525,10 @@ void serialize(raft::resources const& res, const std::string& filename, const in
{
auto* idx_impl = dynamic_cast<const index_impl<T>*>(&idx);

// Check if this is a disk-based index (created from disk-backed CAGRA)
if (idx_impl && idx_impl->file_descriptor().has_value()) {
// For disk-based indexes, copy the existing file to the new location
// A disk-backed index whose in-memory state matches the backing file (never loaded, or loaded
// for read-only operations such as search) can be serialized by copying that file. Once the
// index has been mutated via extend, save the live state so extensions are not lost.
if (idx_impl && idx_impl->file_descriptor().has_value() && !idx_impl->modified()) {
std::string source_path = idx_impl->file_path();
RAFT_EXPECTS(!source_path.empty(), "Disk-based index has invalid file path");
RAFT_EXPECTS(std::filesystem::exists(source_path),
Expand Down Expand Up @@ -1518,7 +1560,28 @@ void deserialize(raft::resources const& res,
auto hnsw_index = std::make_unique<index_impl<T>>(dim, metric, params.hierarchy);
auto appr_algo = std::make_unique<hnswlib::HierarchicalNSW<typename hnsw_dist_t<T>::type>>(
hnsw_index->get_space(), filename);
if (params.hierarchy == HnswHierarchy::NONE) { appr_algo->base_layer_only = true; }
// The file records only the per-element layout (label_offset_ == offsetData_ + data size),
// not dim or the element type, so a wrong dim/dtype would silently compute distances over
// garbage. Validate the layout against the requested space instead.
RAFT_EXPECTS(appr_algo->offsetData_ + appr_algo->data_size_ == appr_algo->label_offset_,
"'%s' does not store vectors of dim=%d with the requested element type; pass the "
"dim and dtype the index was serialized with.",
filename.c_str(),
dim);
if (params.hierarchy == HnswHierarchy::NONE) {
appr_algo->base_layer_only = true;
} else if (appr_algo->cur_element_count > 0 && appr_algo->maxlevel_ > 0) {
// The enterpoint comes straight from the file; validate it before using it to index
// linkLists_ so a corrupt file fails cleanly instead of reading out of bounds.
RAFT_EXPECTS(static_cast<size_t>(appr_algo->enterpoint_node_) < appr_algo->max_elements_,
"'%s' is not a valid HNSW index file: the enterpoint node is out of range.",
filename.c_str());
// Base-layer-only files store no upper-layer links, so traversing them as a hierarchical
// index dereferences null link lists. Detect the format here instead of crashing in search.
RAFT_EXPECTS(appr_algo->linkLists_[appr_algo->enterpoint_node_] != nullptr,
"'%s' holds a base-layer-only HNSW index; deserialize it with hierarchy NONE.",
filename.c_str());
}
hnsw_index->set_index(std::move(appr_algo));
*idx = hnsw_index.release();
} catch (const std::bad_alloc& e) {
Expand All @@ -1544,7 +1607,11 @@ std::unique_ptr<index<T>> build(raft::resources const& res,
const index_params& params,
raft::host_matrix_view<const T, int64_t, raft::row_major> dataset)
{
common::nvtx::range<common::nvtx::domain::cuvs> fun_scope("hnsw::build<ACE>");
common::nvtx::range<common::nvtx::domain::cuvs> fun_scope("hnsw::build");

constexpr auto max_m = static_cast<size_t>(std::numeric_limits<int>::max() / 3);
RAFT_EXPECTS(params.M > 0 && params.M <= max_m, "M must be between 1 and %zu.", max_m);
validate_index_params(params);

cuvs::neighbors::cagra::index_params cagra_params =
cagra::index_params::from_hnsw_params(dataset.extents(),
Expand All @@ -1553,6 +1620,9 @@ std::unique_ptr<index<T>> build(raft::resources const& res,
cagra::hnsw_heuristic_type::SAME_GRAPH_FOOTPRINT,
params.metric);
cagra_params.metric = params.metric;
// The host dataset is handed to from_cagra explicitly below, so the CAGRA index does not
// need to hold a device copy of it alive through the CPU-side conversion.
cagra_params.attach_dataset_on_build = false;

// If the user explicitly configured ACE, honor it. Otherwise (default params) apply a
// heuristic that falls back to ACE only when an in-memory CAGRA build would not fit in
Expand Down
33 changes: 16 additions & 17 deletions fern/pages/c_api/c-api-neighbors-hnsw.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ _Source header: `cuvs/neighbors/hnsw.h`_

Hierarchy for HNSW index when converting from CAGRA index

NOTE: When the value is `NONE`, the HNSW index is built as a base-layer-only index.
NOTE: When the value is `NONE`, the HNSW index is built as a base-layer-only index. Such an index is immutable (it cannot be extended) and can only be read by the hnswlib wrapper in cuVS, because its serialization format is not compatible with the original hnswlib. Indexes built with hierarchy `CPU` or `GPU` are mutable and compatible with the original hnswlib library.

```c
enum cuvsHnswHierarchy {
NONE = 0,
CPU = 1,
GPU = 2
};
Expand All @@ -26,6 +27,7 @@ enum cuvsHnswHierarchy {

| Name | Value |
| --- | --- |
| `NONE` | `0` |
| `CPU` | `1` |
| `GPU` | `2` |

Expand Down Expand Up @@ -261,6 +263,7 @@ Convert a CAGRA Index to an HNSW index. NOTE: When hierarchy is:

1. `NONE`: This method uses the filesystem to write the CAGRA index in `/tmp/&lt;random_number&gt;.bin` before reading it as an hnswlib index, then deleting the temporary file. The returned index is immutable and can only be searched by the hnswlib wrapper in cuVS, as the format is not compatible with the original hnswlib.
2. `CPU`: The returned index is mutable and can be extended with additional vectors. The serialized index is also compatible with the original hnswlib library.
3. `GPU`: The returned index is mutable, and its hierarchy is built on the GPU. The serialized index is also compatible with the original hnswlib library.

```c
cuvsError_t cuvsHnswFromCagra(cuvsResources_t res,
Expand All @@ -282,12 +285,12 @@ cuvsHnswIndex_t hnsw_index);

[`cuvsError_t`](/api-reference/c-api-core-c-api#cuvserror-t)

## Build HNSW index using ACE algorithm
## Build HNSW index on the GPU

<a id="cuvshnswbuild"></a>
### cuvsHnswBuild

Build an HNSW index using ACE (Augmented Core Extraction) algorithm.
Build an HNSW index on the GPU and search it on the CPU.

```c
cuvsError_t cuvsHnswBuild(cuvsResources_t res,
Expand All @@ -296,11 +299,7 @@ DLManagedTensor* dataset,
cuvsHnswIndex_t index);
```

ACE enables building HNSW indexes for datasets too large to fit in GPU memory by:

1. Partitioning the dataset using balanced k-means into core and augmented partitions
2. Building sub-indexes for each partition independently
3. Concatenating sub-graphs into a final unified index
cuVS accepts HNSW parameters (`M`, `ef_construction`, hierarchy, and metric), builds a compatible graph on the GPU, and returns an HNSW index for CPU search. Internally, cuVS may use CAGRA graph construction and convert the graph to HNSW format. Users can leave `params-&gt;ace_params` as nullptr for the default in-memory path, or set ACE parameters to request partitioned or disk-backed graph construction.

NOTE: This function requires CUDA to be available at runtime.

Expand All @@ -309,7 +308,7 @@ NOTE: This function requires CUDA to be available at runtime.
| Name | Direction | Type | Description |
| --- | --- | --- | --- |
| `res` | in | [`cuvsResources_t`](/api-reference/c-api-core-c-api#cuvsresources-t) | cuvsResources_t opaque C handle |
| `params` | in | `cuvsHnswIndexParams_t` | cuvsHnswIndexParams_t with ACE parameters configured |
| `params` | in | `cuvsHnswIndexParams_t` | cuvsHnswIndexParams_t with HNSW build parameters |
| `dataset` | in | `DLManagedTensor*` | DLManagedTensor* host dataset to build index from |
| `index` | out | [`cuvsHnswIndex_t`](/api-reference/c-api-neighbors-hnsw#cuvshnswindex) | cuvsHnswIndex_t to return the built HNSW index |

Expand All @@ -322,7 +321,7 @@ NOTE: This function requires CUDA to be available at runtime.
<a id="cuvshnswextend"></a>
### cuvsHnswExtend

Add new vectors to an HNSW index NOTE: The HNSW index can only be extended when the hierarchy is `CPU` when converting from a CAGRA index.
Add new vectors to an HNSW index NOTE: A base-layer-only index with hierarchy `NONE` cannot be extended. Indexes with hierarchy `CPU` or `GPU` can be extended.

```c
cuvsError_t cuvsHnswExtend(cuvsResources_t res,
Expand Down Expand Up @@ -408,9 +407,9 @@ cuvsError_t cuvsHnswSearchParamsDestroy(cuvsHnswSearchParams_t params);
<a id="cuvshnswsearch"></a>
### cuvsHnswSearch

Search a HNSW index with a `DLManagedTensor` which has underlying `DLDeviceType` equal to `kDLCPU`, `kDLCUDAHost`, or `kDLCUDAManaged`. It is also important to note that the HNSW Index must have been built with the same type of `queries`, such that `index.dtype.code == queries.dl_tensor.dtype.code` Supported types for input are:
Search a HNSW index with a `DLManagedTensor` which has underlying `DLDeviceType` equal to `kDLCPU`, `kDLCUDAHost`, or `kDLCUDAManaged`. It is also important to note that the HNSW Index must have been built with the same type of `queries`, such that `index.dtype.code == queries.dl_tensor.dtype.code` and `index.dtype.bits == queries.dl_tensor.dtype.bits` Supported types for input are:

1. `queries`: a. `kDLDataType.code == kDLFloat` and `kDLDataType.bits = 32` b. `kDLDataType.code == kDLInt` and `kDLDataType.bits = 8` c. `kDLDataType.code == kDLUInt` and `kDLDataType.bits = 8`
1. `queries`: a. `kDLDataType.code == kDLFloat` and `kDLDataType.bits = 32` b. `kDLDataType.code == kDLFloat` and `kDLDataType.bits = 16` c. `kDLDataType.code == kDLInt` and `kDLDataType.bits = 8` d. `kDLDataType.code == kDLUInt` and `kDLDataType.bits = 8`
2. `neighbors`: `kDLDataType.code == kDLUInt` and `kDLDataType.bits = 64`
3. `distances`: `kDLDataType.code == kDLFloat` and `kDLDataType.bits = 32` NOTE: When hierarchy is `NONE`, the HNSW index can only be searched by the hnswlib wrapper in cuVS, as the format is not compatible with the original hnswlib.

Expand All @@ -429,7 +428,7 @@ DLManagedTensor* distances);
| --- | --- | --- | --- |
| `res` | in | [`cuvsResources_t`](/api-reference/c-api-core-c-api#cuvsresources-t) | cuvsResources_t opaque C handle |
| `params` | in | [`cuvsHnswSearchParams_t`](/api-reference/c-api-neighbors-hnsw#cuvshnswsearchparams) | cuvsHnswSearchParams_t used to search Hnsw index |
| `index` | in | [`cuvsHnswIndex_t`](/api-reference/c-api-neighbors-hnsw#cuvshnswindex) | cuvsHnswIndex which has been returned by `cuvsHnswFromCagra` |
| `index` | in | [`cuvsHnswIndex_t`](/api-reference/c-api-neighbors-hnsw#cuvshnswindex) | cuvsHnswIndex returned by `cuvsHnswBuild`, `cuvsHnswFromCagra`, or `cuvsHnswDeserialize` |
| `queries` | in | `DLManagedTensor*` | DLManagedTensor* queries dataset to search |
| `neighbors` | out | `DLManagedTensor*` | DLManagedTensor* output `k` neighbors for queries |
| `distances` | out | `DLManagedTensor*` | DLManagedTensor* output `k` distances for queries |
Expand All @@ -443,7 +442,7 @@ DLManagedTensor* distances);
<a id="cuvshnswserialize"></a>
### cuvsHnswSerialize

Serialize a CAGRA index to a file as an hnswlib index NOTE: When hierarchy is `NONE`, the saved hnswlib index is immutable and can only be read by the hnswlib wrapper in cuVS, as the serialization format is not compatible with the original hnswlib. However, when hierarchy is `CPU`, the saved hnswlib index is compatible with the original hnswlib library.
Serialize an HNSW index to a file. NOTE: When hierarchy is `NONE`, the saved hnswlib index is immutable and can only be read by the hnswlib wrapper in cuVS, as the serialization format is not compatible with the original hnswlib. However, when hierarchy is `CPU` or `GPU`, the saved hnswlib index is compatible with the original hnswlib library.

```c
cuvsError_t cuvsHnswSerialize(cuvsResources_t res, const char* filename, cuvsHnswIndex_t index);
Expand Down Expand Up @@ -475,17 +474,17 @@ cuvsDistanceType metric,
cuvsHnswIndex_t index);
```

NOTE: When hierarchy is `NONE`, the loaded hnswlib index is immutable, and only be read by the hnswlib wrapper in cuVS, as the serialization format is not compatible with the original hnswlib. Experimental, both the API and the serialization format are subject to change.
NOTE: When hierarchy is `NONE`, the loaded hnswlib index is immutable and can only be read by the hnswlib wrapper in cuVS, as the serialization format is not compatible with the original hnswlib. Indexes loaded with hierarchy `CPU` or `GPU` are mutable and compatible with the original hnswlib library. Experimental, both the API and the serialization format are subject to change. NOTE: `params-&gt;hierarchy` must match the hierarchy the index was serialized with. Loading a base-layer-only index saved with hierarchy `NONE` requires setting `params-&gt;hierarchy = NONE` (the default created by `cuvsHnswIndexParamsCreate` is `GPU`).

**Parameters**

| Name | Direction | Type | Description |
| --- | --- | --- | --- |
| `res` | in | [`cuvsResources_t`](/api-reference/c-api-core-c-api#cuvsresources-t) | cuvsResources_t opaque C handle |
| `params` | in | `cuvsHnswIndexParams_t` | cuvsHnswIndexParams_t used to load Hnsw index |
| `params` | in | `cuvsHnswIndexParams_t` | cuvsHnswIndexParams_t used to load Hnsw index; `params-&gt;hierarchy` must match the hierarchy the index was serialized with |
| `filename` | in | `const char*` | the name of the file that stores the index |
| `dim` | in | `int` | the dimension of the vectors in the index |
| `metric` | in | [`cuvsDistanceType`](/api-reference/c-api-distance-distance#cuvsdistancetype) | the distance metric used to build the index |
| `metric` | in | [`cuvsDistanceType`](/api-reference/c-api-distance-distance#cuvsdistancetype) | the distance metric used to build the index; takes precedence over `params-&gt;metric` |
| `index` | out | [`cuvsHnswIndex_t`](/api-reference/c-api-neighbors-hnsw#cuvshnswindex) | HNSW index loaded disk |

**Returns**
Expand Down
Loading
Loading