Skip to content

Commit d76693e

Browse files
committed
first commit
1 parent 7cf69cb commit d76693e

2 files changed

Lines changed: 259 additions & 0 deletions

File tree

rust/cuvs/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,13 @@ ndarray = "0.15"
1515
[dev-dependencies]
1616
ndarray-rand = "0.14"
1717
mark-flaky-tests = "1"
18+
# Used only by the `vamana` example to load and search a cuVS-serialized
19+
# Vamana index via Microsoft DiskANN's Rust API.
20+
diskann = "0.51"
21+
diskann-providers = "0.51"
22+
diskann-vector = "0.51"
23+
tokio = { version = "1", features = ["rt-multi-thread"] }
24+
25+
[[example]]
26+
name = "vamana"
27+
required-features = []

rust/cuvs/examples/vamana.rs

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
//! End-to-end example: build a Vamana graph index on the GPU with cuVS,
7+
//! serialize it to disk, then load and search it via Microsoft DiskANN's
8+
//! Rust API (`diskann-providers`).
9+
//!
10+
//! cuVS' `vamana::Index::serialize` writes the canonical DiskANN in-memory
11+
//! file format:
12+
//!
13+
//! <prefix> - the Vamana graph (24-byte LE header followed by
14+
//! per-node `u32` adjacency lists)
15+
//! <prefix>.data - the dataset in DiskANN's binary vector format
16+
//! (`u32 npts; u32 dim; T data[npts*dim]`)
17+
//!
18+
//! Those are exactly the layouts that `diskann-providers` expects in
19+
//! `storage::bin::{load_graph, load_from_bin}`, so we can call
20+
//! `load_fp_index` directly on the cuVS-written files.
21+
//!
22+
//! ## Caveats
23+
//!
24+
//! - The Rust DiskANN loader currently requires `num_frozen_pts >= 1`
25+
//! (`NonZeroUsize`). cuVS does not reserve frozen points, so the loader
26+
//! reinterprets the *last* vector as a frozen entry-point sentinel and
27+
//! excludes it from search results. For our random dataset that's fine,
28+
//! but be aware that point id `npts - 1` will never be returned. The
29+
//! medoid id stored in the cuVS graph header is also ignored by the
30+
//! Rust loader; entry points come from the trailing `num_frozen_pts`.
31+
//!
32+
//! Run with: `cargo run --release --example vamana`
33+
34+
use std::env;
35+
use std::fs;
36+
use std::num::NonZeroUsize;
37+
use std::path::PathBuf;
38+
39+
use cuvs::distance_type::DistanceType;
40+
use cuvs::vamana::{Index as VamanaIndex, IndexParams};
41+
use cuvs::{ManagedTensor, Resources, Result as CuvsResult};
42+
43+
use ndarray::Array2;
44+
use ndarray_rand::rand_distr::Uniform;
45+
use ndarray_rand::RandomExt;
46+
47+
// Microsoft DiskANN imports (search side).
48+
use diskann::graph::config::{Builder as ConfigBuilder, MaxDegree};
49+
use diskann::graph::search::Knn;
50+
use diskann::graph::search_output_buffer::IdDistance;
51+
use diskann::provider::DefaultContext;
52+
use diskann::utils::ONE;
53+
use diskann_providers::index::wrapped_async::DiskANNIndex as SyncDiskANNIndex;
54+
use diskann_providers::model::configuration::IndexConfiguration;
55+
use diskann_providers::model::graph::provider::async_::common::{FullPrecision, NoStore};
56+
use diskann_providers::model::graph::provider::async_::inmem::FullPrecisionProvider;
57+
use diskann_providers::storage::FileStorageProvider;
58+
use diskann_vector::distance::Metric;
59+
60+
const N_DATAPOINTS: usize = 50_000;
61+
const N_FEATURES: usize = 64;
62+
const N_QUERIES: usize = 100;
63+
const K: usize = 10;
64+
65+
/// Build a Vamana index with cuVS and serialize it to `<prefix>` and
66+
/// `<prefix>.data` in DiskANN in-memory format.
67+
fn build_and_serialize(dataset: &Array2<f32>, prefix: &str) -> CuvsResult<()> {
68+
let res = Resources::new()?;
69+
let dataset_device = ManagedTensor::from(dataset).to_device(&res)?;
70+
71+
let build_params = IndexParams::new()?
72+
.set_metric(DistanceType::L2Expanded)
73+
.set_graph_degree(32)
74+
.set_visited_size(64)
75+
.set_alpha(1.2);
76+
77+
let start = std::time::Instant::now();
78+
let index = VamanaIndex::build(&res, &build_params, dataset_device)?;
79+
println!(
80+
"Built Vamana index on GPU in {:.2?} (R=32, L=64)",
81+
start.elapsed()
82+
);
83+
84+
index.serialize(&res, prefix, /* include_dataset = */ true)
85+
}
86+
87+
/// Load the cuVS-written index using the Microsoft DiskANN Rust API and
88+
/// run a batch of k-NN queries against it.
89+
fn search_with_diskann(
90+
prefix: &str,
91+
queries: &Array2<f32>,
92+
k: usize,
93+
l_search: usize,
94+
) -> Result<(Vec<u32>, Vec<f32>), Box<dyn std::error::Error>> {
95+
// R/L used at build time. The values must be at least as large as the
96+
// graph's actual max degree / search list, but they don't need to match
97+
// exactly. They feed `IndexConfiguration` and size internal buffers.
98+
let graph_degree = 32usize;
99+
let l_build = 64usize;
100+
101+
let config = ConfigBuilder::new(
102+
graph_degree,
103+
MaxDegree::default_slack(),
104+
l_build,
105+
Metric::L2.into(),
106+
)
107+
.build()?;
108+
109+
// `max_points` here is the total count the loader will see in the
110+
// `<prefix>.data` file (cuVS writes every dataset row).
111+
let index_config = IndexConfiguration::new(
112+
Metric::L2,
113+
N_FEATURES,
114+
N_DATAPOINTS,
115+
ONE, // num_frozen_pts: smallest legal value; the last point is treated as a sentinel.
116+
0, // num_threads: 0 = pick a default
117+
config,
118+
);
119+
120+
let storage = FileStorageProvider;
121+
122+
// `load_fp_index` returns a `graph::DiskANNIndex<FullPrecisionProvider<f32, NoStore>>`.
123+
// Wrap it in the synchronous helper so we can call `.search(...)` from non-async code.
124+
let index: SyncDiskANNIndex<FullPrecisionProvider<f32, NoStore>> =
125+
SyncDiskANNIndex::load_with_multi_thread_runtime(&storage, &(prefix, index_config))?;
126+
127+
println!("Loaded the cuVS-written Vamana index via diskann-providers.");
128+
129+
let n_queries = queries.shape()[0];
130+
let mut all_ids = vec![0u32; n_queries * k];
131+
let mut all_dists = vec![0.0f32; n_queries * k];
132+
133+
let search_params = Knn::new(k, l_search, None)?;
134+
135+
let start = std::time::Instant::now();
136+
for (qi, query) in queries.outer_iter().enumerate() {
137+
let id_slice = &mut all_ids[qi * k..(qi + 1) * k];
138+
let dist_slice = &mut all_dists[qi * k..(qi + 1) * k];
139+
let mut buffer = IdDistance::new(id_slice, dist_slice);
140+
141+
index.search(
142+
search_params,
143+
&FullPrecision,
144+
&DefaultContext,
145+
query.as_slice().expect("contiguous queries"),
146+
&mut buffer,
147+
)?;
148+
}
149+
println!(
150+
"Searched {} queries (k={}, L_search={}) in {:.2?}",
151+
n_queries,
152+
k,
153+
l_search,
154+
start.elapsed()
155+
);
156+
157+
Ok((all_ids, all_dists))
158+
}
159+
160+
/// Brute-force squared-L2 top-k for sanity-checking recall.
161+
fn brute_force_topk(data: &Array2<f32>, queries: &Array2<f32>, k: usize) -> Vec<u32> {
162+
let n = data.shape()[0];
163+
let nq = queries.shape()[0];
164+
let mut out = vec![0u32; nq * k];
165+
166+
let data_norms: Vec<f32> = data.outer_iter().map(|row| row.dot(&row)).collect();
167+
168+
for (qi, q) in queries.outer_iter().enumerate() {
169+
let q_norm: f32 = q.dot(&q);
170+
let mut dists: Vec<(usize, f32)> = (0..n)
171+
.map(|i| {
172+
let row = data.row(i);
173+
let dot: f32 = row.dot(&q);
174+
(i, q_norm + data_norms[i] - 2.0 * dot)
175+
})
176+
.collect();
177+
dists.select_nth_unstable_by(k, |a, b| a.1.partial_cmp(&b.1).unwrap());
178+
dists[..k].sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
179+
for (j, (id, _)) in dists.iter().take(k).enumerate() {
180+
out[qi * k + j] = *id as u32;
181+
}
182+
}
183+
out
184+
}
185+
186+
fn vamana_example() -> Result<(), Box<dyn std::error::Error>> {
187+
// 1. Generate dataset.
188+
let dataset =
189+
ndarray::Array::<f32, _>::random((N_DATAPOINTS, N_FEATURES), Uniform::new(0., 1.0));
190+
let queries = ndarray::Array::<f32, _>::random((N_QUERIES, N_FEATURES), Uniform::new(0., 1.0));
191+
192+
let out_dir: PathBuf = env::temp_dir().join("cuvs_vamana_example");
193+
fs::create_dir_all(&out_dir)?;
194+
let prefix = out_dir.join("ann");
195+
let prefix_str = prefix.to_str().expect("non-utf8 output path").to_string();
196+
197+
// 2. Build + serialize via cuVS (GPU).
198+
build_and_serialize(&dataset, &prefix_str)?;
199+
for entry in fs::read_dir(&out_dir)? {
200+
let entry = entry?;
201+
println!(
202+
" wrote {} ({} bytes)",
203+
entry.path().display(),
204+
entry.metadata()?.len()
205+
);
206+
}
207+
208+
// 3. Load + search via Microsoft DiskANN (CPU).
209+
let (ids, _dists) = search_with_diskann(&prefix_str, &queries, K, /* L_search = */ 64)?;
210+
211+
// 4. Recall sanity check vs exact L2 top-k. Note: id == N_DATAPOINTS - 1
212+
// can never be returned by the Rust loader (frozen-point caveat above),
213+
// so we exclude it from the ground-truth set.
214+
let gt = brute_force_topk(&dataset, &queries, K);
215+
let frozen_id = (N_DATAPOINTS - 1) as u32;
216+
let mut matches = 0usize;
217+
let mut compared = 0usize;
218+
for q in 0..N_QUERIES {
219+
let gt_set: std::collections::HashSet<u32> = gt[q * K..(q + 1) * K]
220+
.iter()
221+
.copied()
222+
.filter(|id| *id != frozen_id)
223+
.collect();
224+
let pred_set: std::collections::HashSet<u32> =
225+
ids[q * K..(q + 1) * K].iter().copied().collect();
226+
compared += gt_set.len();
227+
matches += gt_set.intersection(&pred_set).count();
228+
}
229+
if compared > 0 {
230+
println!(
231+
"recall@{} = {:.3} ({} / {})",
232+
K,
233+
matches as f64 / compared as f64,
234+
matches,
235+
compared
236+
);
237+
}
238+
239+
println!("First query, top-{} ids: {:?}", K, &ids[..K]);
240+
241+
Ok(())
242+
}
243+
244+
fn main() {
245+
if let Err(e) = vamana_example() {
246+
eprintln!("Failed to run Vamana + DiskANN example: {}", e);
247+
std::process::exit(1);
248+
}
249+
}

0 commit comments

Comments
 (0)