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
159 changes: 159 additions & 0 deletions crates/ruvector-diskann/examples/bench_delete_recall.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//! Measure recall and delete latency after deleting 20% of a DiskANN index.

use rand::prelude::*;
use ruvector_diskann::{DiskAnnConfig, DiskAnnIndex};
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant};

const DIM: usize = 32;
const K: usize = 10;
const QUERY_COUNT: usize = 50;

fn l2_squared(a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b)
.map(|(left, right)| {
let delta = left - right;
delta * delta
})
.sum()
}

fn median_micros(samples: &mut [Duration]) -> f64 {
samples.sort_unstable();
samples[samples.len() / 2].as_secs_f64() * 1_000_000.0
}

fn recall_at_10(
index: &DiskAnnIndex,
data: &[(String, Vec<f32>)],
queries: &[usize],
truth: &[HashSet<String>],
) -> f64 {
let matches: usize = queries
.iter()
.zip(truth)
.map(|(&query_idx, expected)| {
index
.search(&data[query_idx].1, K)
.expect("search should succeed")
.iter()
.filter(|result| expected.contains(&result.id))
.count()
})
.sum();
matches as f64 / (queries.len() * K) as f64
}

fn run_case(n: usize) {
let mut rng = StdRng::seed_from_u64(0x679D_15CA ^ n as u64);
let data: Vec<(String, Vec<f32>)> = (0..n)
.map(|idx| {
(
format!("v{idx}"),
(0..DIM).map(|_| rng.gen::<f32>()).collect(),
)
})
.collect();
let config = DiskAnnConfig {
dim: DIM,
max_degree: 32,
build_beam: 64,
search_beam: 64,
alpha: 1.2,
..Default::default()
};

let mut order: Vec<usize> = (0..n).collect();
order.shuffle(&mut rng);
let delete_count = n / 5;
let deleted: HashSet<usize> = order[..delete_count].iter().copied().collect();
let survivor_indices: Vec<usize> = (0..n).filter(|idx| !deleted.contains(idx)).collect();
let queries: Vec<usize> = order[delete_count..delete_count + QUERY_COUNT].to_vec();

let truth: Vec<HashSet<String>> = queries
.iter()
.map(|&query_idx| {
let mut exact: Vec<(usize, f32)> = survivor_indices
.iter()
.map(|&idx| (idx, l2_squared(&data[idx].1, &data[query_idx].1)))
.collect();
exact.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));
exact
.iter()
.take(K)
.map(|(idx, _)| data[*idx].0.clone())
.collect()
})
.collect();

let dir: PathBuf =
std::env::temp_dir().join(format!("ruvector-delete-recall-{}-{n}", std::process::id()));
let _ = fs::remove_dir_all(&dir);

let mut base = DiskAnnIndex::new(config.clone());
base.insert_batch(data.clone()).expect("insert base data");
base.build().expect("build base index");
base.save(&dir).expect("save base index");

let mut deferred = DiskAnnIndex::load(&dir).expect("load deferred index");
let mut repaired = DiskAnnIndex::load(&dir).expect("load repaired index");
let mut deferred_times = Vec::with_capacity(delete_count);
let mut repair_times = Vec::with_capacity(delete_count);

for &idx in &order[..delete_count] {
let started = Instant::now();
deferred
.delete_deferred(&data[idx].0)
.expect("deferred delete");
deferred_times.push(started.elapsed());

let started = Instant::now();
repaired.delete(&data[idx].0).expect("repairing delete");
repair_times.push(started.elapsed());
}

let survivor_data: Vec<(String, Vec<f32>)> = survivor_indices
.iter()
.map(|&idx| data[idx].clone())
.collect();
let mut fresh = DiskAnnIndex::new(config);
fresh
.insert_batch(survivor_data)
.expect("insert survivor data");
fresh.build().expect("build fresh survivor index");

println!("RESULT n={n} mode=main recall_at_10=UNMEASURABLE note=deleted_ids_returned");
println!(
"RESULT n={n} mode=tombstone_only recall_at_10={:.4}",
recall_at_10(&deferred, &data, &queries, &truth)
);
println!(
"RESULT n={n} mode=tombstone_repair recall_at_10={:.4}",
recall_at_10(&repaired, &data, &queries, &truth)
);
println!(
"RESULT n={n} mode=fresh_rebuild recall_at_10={:.4}",
recall_at_10(&fresh, &data, &queries, &truth)
);
println!(
"LATENCY n={n} mode=tombstone_only median_us={:.3}",
median_micros(&mut deferred_times)
);
println!(
"LATENCY n={n} mode=tombstone_repair median_us={:.3}",
median_micros(&mut repair_times)
);

drop((base, deferred, repaired));
let _ = fs::remove_dir_all(dir);
}

fn main() {
println!("BENCHMARK hardware=Apple_M2_Max threads=1 profile=release dim={DIM} k={K}");
for n in [20_000, 100_000] {
run_case(n);
}
}
9 changes: 0 additions & 9 deletions crates/ruvector-diskann/src/distance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,6 @@ impl FlatVectors {
&self.data[start..start + self.dim]
}

/// Zero out a vector (lazy deletion)
#[inline]
pub fn zero_out(&mut self, idx: usize) {
let start = idx * self.dim;
for v in &mut self.data[start..start + self.dim] {
*v = f32::NAN;
}
}

pub fn len(&self) -> usize {
self.count
}
Expand Down
86 changes: 74 additions & 12 deletions crates/ruvector-diskann/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ use rayon::prelude::*;
use std::cmp::Ordering;
use std::collections::BinaryHeap;

fn is_tombstoned(tombstones: &[u64], node: usize) -> bool {
tombstones
.get(node / 64)
.is_some_and(|word| word & (1u64 << (node % 64)) != 0)
}

#[derive(Clone)]
struct Candidate {
id: u32,
Expand All @@ -19,7 +25,7 @@ struct Candidate {

impl PartialEq for Candidate {
fn eq(&self, other: &Self) -> bool {
self.distance == other.distance
self.distance.total_cmp(&other.distance) == Ordering::Equal
}
}
impl Eq for Candidate {}
Expand All @@ -30,10 +36,7 @@ impl PartialOrd for Candidate {
}
impl Ord for Candidate {
fn cmp(&self, other: &Self) -> Ordering {
other
.distance
.partial_cmp(&self.distance)
.unwrap_or(Ordering::Equal)
other.distance.total_cmp(&self.distance)
}
}

Expand All @@ -43,7 +46,7 @@ struct MaxCandidate {
}
impl PartialEq for MaxCandidate {
fn eq(&self, other: &Self) -> bool {
self.distance == other.distance
self.distance.total_cmp(&other.distance) == Ordering::Equal
}
}
impl Eq for MaxCandidate {}
Expand All @@ -54,9 +57,7 @@ impl PartialOrd for MaxCandidate {
}
impl Ord for MaxCandidate {
fn cmp(&self, other: &Self) -> Ordering {
self.distance
.partial_cmp(&other.distance)
.unwrap_or(Ordering::Equal)
self.distance.total_cmp(&other.distance)
}
}

Expand Down Expand Up @@ -198,7 +199,7 @@ impl VamanaGraph {
}

let mut result: Vec<(u32, f32)> = best.into_iter().map(|c| (c.id, c.distance)).collect();
result.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
result.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));
let ids: Vec<u32> = result.into_iter().map(|(id, _)| id).collect();

(ids, visit_count)
Expand Down Expand Up @@ -232,7 +233,7 @@ impl VamanaGraph {
.filter(|&&c| c != node)
.map(|&c| (c, l2_squared(vectors.get(c as usize), node_vec)))
.collect();
sorted.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
sorted.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));

let mut result = Vec::with_capacity(self.max_degree);
for (cand_id, cand_dist) in &sorted {
Expand All @@ -253,6 +254,67 @@ impl VamanaGraph {
result
}

/// Remove a deleted node and locally repair each of its live in-neighbors.
///
/// This index deliberately does not maintain reverse adjacency. Finding the
/// in-neighbors therefore performs a bounded scan of the graph and costs
/// O(n * degree) per deletion. Tombstoned sources and candidates are skipped.
pub(crate) fn repair_deleted(
&mut self,
vectors: &FlatVectors,
deleted: u32,
tombstones: &[u64],
) {
let deleted_idx = deleted as usize;
if deleted_idx >= self.neighbors.len() || !is_tombstoned(tombstones, deleted_idx) {
return;
}

let deleted_out: Vec<u32> = self.neighbors[deleted_idx]
.iter()
.copied()
.filter(|&candidate| {
candidate != deleted && !is_tombstoned(tombstones, candidate as usize)
})
.collect();

if self.medoid == deleted {
if let Some(replacement) = deleted_out.first().copied().or_else(|| {
(0..self.neighbors.len())
.find(|&node| !is_tombstoned(tombstones, node))
.map(|idx| idx as u32)
}) {
self.medoid = replacement;
}
}

for source in 0..self.neighbors.len() {
if source == deleted_idx || !self.neighbors[source].contains(&deleted) {
continue;
}

if is_tombstoned(tombstones, source) {
self.neighbors[source].retain(|&neighbor| neighbor != deleted);
continue;
}

let mut candidates: Vec<u32> = self.neighbors[source]
.iter()
.copied()
.chain(deleted_out.iter().copied())
.filter(|&candidate| {
candidate != deleted && !is_tombstoned(tombstones, candidate as usize)
})
.collect();
candidates.sort_unstable();
candidates.dedup();
self.neighbors[source] =
self.robust_prune(vectors, source as u32, &candidates, self.alpha);
}

self.neighbors[deleted_idx].clear();
}

/// Parallel medoid finding using rayon
fn find_medoid_parallel(&self, vectors: &FlatVectors) -> u32 {
let n = vectors.len();
Expand All @@ -274,7 +336,7 @@ impl VamanaGraph {
(0..n as u32)
.into_par_iter()
.map(|i| (i, l2_squared(vectors.get(i as usize), &centroid)))
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(id, _)| id)
.unwrap_or(0)
}
Expand Down
Loading
Loading