|
| 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