use ann_search_rs::prelude::CagraGpuSearchParams;
use ann_search_rs::{
build_exhaustive_index_gpu, build_ivf_index_gpu, build_nndescent_index_gpu,
extract_nndescent_knn_gpu, query_exhaustive_index_gpu_self, query_ivf_index_gpu_self,
query_nndescent_index_gpu_self,
};
use cubecl::Runtime;
use faer::MatRef;
use rayon::prelude::*;
use crate::prelude::*;
const NNDESCENT_GPU_DEFAULT_DEGREE: usize = 30;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum KnnSearchGpu {
#[default]
ExhaustiveGpu,
IvfGpu,
CagraGpu,
}
pub fn parse_knn_method_gpu(s: &str) -> Option<KnnSearchGpu> {
match s.to_lowercase().as_str() {
"exhaustive" | "exhaustive_gpu" => Some(KnnSearchGpu::ExhaustiveGpu),
"ivf" | "ivf_gpu" => Some(KnnSearchGpu::IvfGpu),
"cagra" | "cagra_gpu" | "nndescent_gpu" => Some(KnnSearchGpu::CagraGpu),
_ => None,
}
}
#[derive(Clone, Debug)]
pub struct KnnParamsGpu {
pub knn_method: String,
pub ann_dist: String,
pub k: usize,
pub n_list: Option<usize>,
pub n_probe: Option<usize>,
pub graph_k: Option<usize>,
pub k_build: Option<usize>,
pub n_tree: Option<usize>,
pub delta: f32,
pub rho: Option<f32>,
pub refine_knn: Option<usize>,
pub beam_width: Option<usize>,
pub max_beam_iters: Option<usize>,
pub n_entry_points: Option<usize>,
pub extract_knn: bool,
}
impl KnnParamsGpu {
pub fn new() -> Self {
Self {
knn_method: "exhaustive".to_string(),
ann_dist: "euclidean".to_string(),
k: 15,
n_list: None,
n_probe: None,
graph_k: None,
k_build: None,
n_tree: None,
delta: 0.001,
rho: None,
refine_knn: None,
beam_width: None,
max_beam_iters: None,
n_entry_points: None,
extract_knn: false,
}
}
}
impl Default for KnnParamsGpu {
fn default() -> Self {
Self::new()
}
}
pub(crate) fn cagra_query_params(
params: &KnnParamsGpu,
k_query: usize,
graph_k: Option<usize>,
) -> CagraGpuSearchParams {
let k_graph = graph_k.unwrap_or(NNDESCENT_GPU_DEFAULT_DEGREE);
let scaled_bw = k_query.max(k_graph).max(16) * 2;
CagraGpuSearchParams::new(
params.beam_width.or(Some(scaled_bw)),
params.max_beam_iters.or(Some(scaled_bw * 3)),
params.n_entry_points,
None,
)
}
fn drop_self_neighbours(raw: Vec<Vec<usize>>, k: usize) -> Vec<Vec<usize>> {
raw.into_par_iter()
.enumerate()
.map(|(i, idx)| idx.into_iter().filter(|&j| j != i).take(k).collect())
.collect()
}
pub fn dispatch_knn_gpu<R: Runtime>(
embd: MatRef<f32>,
k: usize,
params: &KnnParamsGpu,
device: R::Device,
seed: usize,
verbose: usize,
) -> Result<Vec<Vec<usize>>, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let detailed = verbosity.detailed_verbosity();
let method = parse_knn_method_gpu(¶ms.knn_method).unwrap_or_else(|| {
println!(
"Unrecognised GPU kNN method provided: {:?}. Defaulting to exhaustive GPU.",
params.knn_method
);
KnnSearchGpu::default()
});
let k_query = k + 1;
let (raw_indices, _) = match method {
KnnSearchGpu::ExhaustiveGpu => {
let index = build_exhaustive_index_gpu::<f32, R>(embd, ¶ms.ann_dist, device)?;
query_exhaustive_index_gpu_self(&index, k_query, false, detailed)?
}
KnnSearchGpu::IvfGpu => {
let index = build_ivf_index_gpu::<f32, R>(
embd,
params.n_list,
None,
¶ms.ann_dist,
seed,
detailed,
device,
)?;
query_ivf_index_gpu_self(&index, k_query, params.n_probe, None, false, detailed)?
}
KnnSearchGpu::CagraGpu => {
let graph_k = if params.extract_knn {
Some(
params
.graph_k
.unwrap_or(NNDESCENT_GPU_DEFAULT_DEGREE)
.max(k_query),
)
} else {
params.graph_k
};
let mut index = build_nndescent_index_gpu::<f32, R>(
embd,
¶ms.ann_dist,
graph_k,
params.k_build,
None,
params.n_tree,
Some(params.delta),
params.rho,
params.refine_knn,
seed,
detailed,
!params.extract_knn,
device,
)?;
if params.extract_knn {
extract_nndescent_knn_gpu(&index, Some(k_query), true, false)?
} else {
let query_params = cagra_query_params(params, k_query, graph_k);
query_nndescent_index_gpu_self(&mut index, k_query, Some(query_params), false)?
}
}
};
Ok(drop_self_neighbours(raw_indices, k))
}
#[cfg(test)]
mod tests {
use super::*;
use ann_search_rs::{build_exhaustive_index, query_exhaustive_self};
use cubecl::wgpu::{WgpuDevice, WgpuRuntime};
use faer::Mat;
fn try_device() -> Option<WgpuDevice> {
let device = WgpuDevice::DefaultDevice;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
WgpuRuntime::client(&device);
}))
.ok()
.map(|_| device)
}
fn clustered_data(n: usize, dim: usize, n_clusters: usize) -> Mat<f32> {
let hash = |a: usize, b: usize| -> f32 {
let mut h = (a as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add((b as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F));
h ^= h >> 29;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
h ^= h >> 32;
((h >> 40) as f32) / 16_777_216.0 - 0.5
};
Mat::<f32>::from_fn(n, dim, |i, j| {
let cluster = i % n_clusters;
let centre = hash(cluster + 1000, j) * 20.0;
centre + hash(i, j) * 2.0
})
}
fn recall(got: &[Vec<usize>], want: &[Vec<usize>]) -> f32 {
let total: f32 = got
.iter()
.zip(want.iter())
.map(|(g, w)| {
let truth: rustc_hash::FxHashSet<usize> = w.iter().copied().collect();
let hits = g.iter().filter(|x| truth.contains(x)).count();
hits as f32 / w.len().max(1) as f32
})
.sum();
total / got.len().max(1) as f32
}
fn cpu_reference(data: &Mat<f32>, k: usize) -> Vec<Vec<usize>> {
let index = build_exhaustive_index(data.as_ref(), "euclidean");
let (idx, _) = query_exhaustive_self(&index, k + 1, false, false).unwrap();
idx.into_iter()
.enumerate()
.map(|(i, row)| row.into_iter().filter(|&j| j != i).take(k).collect())
.collect()
}
#[test]
fn test_parse_knn_method_gpu() {
assert_eq!(
parse_knn_method_gpu("exhaustive"),
Some(KnnSearchGpu::ExhaustiveGpu)
);
assert_eq!(parse_knn_method_gpu("IVF_GPU"), Some(KnnSearchGpu::IvfGpu));
assert_eq!(parse_knn_method_gpu("cagra"), Some(KnnSearchGpu::CagraGpu));
assert_eq!(
parse_knn_method_gpu("nndescent_gpu"),
Some(KnnSearchGpu::CagraGpu)
);
assert_eq!(parse_knn_method_gpu("kmknn"), None);
assert_eq!(parse_knn_method_gpu("annoy"), None);
}
#[test]
fn test_cagra_gpu_both_exits() {
let Some(device) = try_device() else { return };
let (n, dim, k) = (2000usize, 24usize, 15usize);
let data = clustered_data(n, dim, 10);
let want = cpu_reference(&data, k);
for extract in [false, true] {
let params = KnnParamsGpu {
knn_method: "cagra".to_string(),
k,
extract_knn: extract,
refine_knn: Some(1),
..Default::default()
};
let got =
dispatch_knn_gpu::<WgpuRuntime>(data.as_ref(), k, ¶ms, device.clone(), 42, 0)
.unwrap();
assert_eq!(got.len(), n, "extract = {}: wrong row count", extract);
for (i, row) in got.iter().enumerate() {
assert!(row.len() <= k, "row {} returned {} > {}", i, row.len(), k);
assert!(!row.contains(&i), "row {} contains itself", i);
}
let r = recall(&got, &want);
println!("CAGRA GPU (extract = {}): recall {:.4}", extract, r);
assert!(
r > 0.85,
"extract = {}: recall {:.4} below the 0.85 floor",
extract,
r
);
}
}
#[test]
fn test_drop_self_neighbours() {
let raw = vec![vec![0, 3, 1, 2], vec![1, 0, 2, 3], vec![5, 2, 0, 1]];
let out = drop_self_neighbours(raw, 2);
assert_eq!(out, vec![vec![3, 1], vec![0, 2], vec![5, 0]]);
}
#[test]
fn test_exhaustive_gpu_matches_cpu() {
let Some(device) = try_device() else { return };
let (n, dim, k) = (512usize, 16usize, 10usize);
let data = clustered_data(n, dim, 8);
let params = KnnParamsGpu {
knn_method: "exhaustive".to_string(),
..Default::default()
};
let got =
dispatch_knn_gpu::<WgpuRuntime>(data.as_ref(), k, ¶ms, device, 42, 0).unwrap();
assert_eq!(got.len(), n);
for (i, row) in got.iter().enumerate() {
assert_eq!(row.len(), k, "row {} has {} neighbours", i, row.len());
assert!(!row.contains(&i), "row {} contains itself", i);
}
let want = cpu_reference(&data, k);
let r = recall(&got, &want);
assert!(r > 0.99, "exhaustive GPU is exact but recall was {:.4}", r);
}
#[test]
fn test_gpu_knn_recall_at_high_k() {
let Some(device) = try_device() else { return };
let (n, dim, k) = (4000usize, 30usize, 150usize);
let data = clustered_data(n, dim, 12);
let want = cpu_reference(&data, k);
for (method, floor) in [("exhaustive", 0.99f32), ("ivf", 0.90), ("cagra", 0.90)] {
let params = KnnParamsGpu {
knn_method: method.to_string(),
..Default::default()
};
let got =
dispatch_knn_gpu::<WgpuRuntime>(data.as_ref(), k, ¶ms, device.clone(), 42, 0)
.unwrap();
assert_eq!(got.len(), n, "{}: wrong row count", method);
let short = got.iter().filter(|r| r.len() < k).count();
assert_eq!(
short, 0,
"{}: {} of {} rows returned fewer than {} neighbours",
method, short, n, k
);
let r = recall(&got, &want);
println!("{:>10} GPU: recall {:.4} at k = {}", method, r, k);
assert!(
r > floor,
"{}: recall {:.4} below the {:.2} floor at k = {}",
method,
r,
floor,
k
);
}
}
}