Skip to main content

diskann_providers/model/graph/provider/async_/
simple_neighbor_provider.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use std::sync::RwLock;
7
8use crate::storage::{StorageReadProvider, StorageWriteProvider};
9use diskann::{
10    ANNError, ANNResult,
11    graph::AdjacencyList,
12    provider::HasId,
13    utils::{TryIntoVectorId, VectorId},
14};
15use tracing::trace;
16
17use super::common::{AlignedMemoryVectorStore, TestCallCount};
18use crate::storage::{
19    self, AsyncIndexMetadata, AsyncQuantLoadContext, DiskGraphOnly, LoadWith, SaveWith,
20};
21
22pub struct SimpleNeighborProviderAsync<I: VectorId> {
23    // Each adjacency list is stored in a fixed size slice of size max_degree * graph_slack_factor + 1.
24    // The length of the list is stored in the extra element at the end.
25    graph: AlignedMemoryVectorStore<I>,
26    locks: Vec<RwLock<()>>,
27    num_start_points: usize,
28
29    pub num_get_calls: TestCallCount,
30}
31
32impl<I: VectorId> SimpleNeighborProviderAsync<I> {
33    pub fn new(
34        max_points: usize,
35        num_start_points: usize,
36        max_degree: u32,
37        graph_slack_factor: f32,
38    ) -> Self {
39        let size = max_points + num_start_points;
40        let graph = AlignedMemoryVectorStore::with_capacity(
41            size,
42            (max_degree as f32 * graph_slack_factor) as usize + 1,
43        );
44        let locks = (0..size).map(|_| RwLock::new(())).collect::<Vec<_>>();
45
46        Self {
47            graph,
48            locks,
49            num_start_points,
50            num_get_calls: TestCallCount::default(),
51        }
52    }
53
54    /// Return the neighbor list for `index` as a slice.
55    ///
56    /// SAFETY:
57    ///
58    /// This function will never read out of bounds, but it does not synchronize access to
59    /// the data. It must be called while holding the corresponding lock at `self.locks[index]`.
60    unsafe fn get_slice(&self, index: usize) -> &[I] {
61        // SAFETY: This function must be called while the corresponding lock for this slot
62        // is held.
63        let s = unsafe { self.graph.get_slice(index) };
64
65        let len = s[self.graph.dim() - 1].into_usize();
66        &s[0..len]
67    }
68
69    pub fn set_neighbors_sync(&self, id: usize, neighbors: &[I]) -> ANNResult<()> {
70        assert!(
71            neighbors.len() < self.graph.dim(),
72            "neighbors ({}) exceeded max adjacency list size ({})",
73            neighbors.len(),
74            self.graph.dim() - 1,
75        );
76
77        // Lint: We don't have a good way of recovering from lock poisoning anyways.
78        #[allow(clippy::unwrap_used)]
79        let _guard = self.locks[id].write().unwrap();
80
81        // SAFETY: We are holding the write lock for this id.
82        let list = unsafe { self.graph.get_mut_slice(id) };
83        list[0..neighbors.len()].copy_from_slice(neighbors);
84
85        // Lint: neighbor lists won't overflow the VectorIdType
86        #[allow(clippy::unwrap_used)]
87        {
88            list[self.graph.dim() - 1] = neighbors.len().try_into_vector_id().unwrap();
89        }
90        Ok(())
91    }
92
93    pub fn get_neighbors_sync(&self, id: usize, neighbors: &mut AdjacencyList<I>) -> ANNResult<()> {
94        #[cfg(test)]
95        self.num_get_calls.increment();
96
97        // Lint: We don't have a good way of recovering from lock poisoning anyways.
98        #[allow(clippy::unwrap_used)]
99        let _guard = self.locks[id].read().unwrap();
100
101        // SAFETY: We are holding the read lock for `id`.
102        let list = unsafe { self.get_slice(id) };
103        neighbors.overwrite_trusted(list);
104        Ok(())
105    }
106
107    pub fn append_vector_sync(&self, id: usize, new_neighbor_ids: &[I]) -> ANNResult<()> {
108        // Lint: We don't have a good way of recovering from lock poisoning anyways.
109        #[allow(clippy::unwrap_used)]
110        let _guard = self.locks[id].write().unwrap();
111
112        // SAFETY: We took the write lock for `id` above.
113        let list_raw = unsafe { self.graph.get_mut_slice(id) };
114        let len = list_raw[self.graph.dim() - 1].into_usize();
115        let mut new_len = len;
116        let mut list = &mut list_raw[0..len];
117
118        for new_neighbor_id in new_neighbor_ids {
119            if I::contains_simd(list, *new_neighbor_id) {
120                trace!("append_vector: new neighbor already exists");
121                continue;
122            }
123
124            if new_len < self.graph.dim() - 1 {
125                list_raw[new_len] = *new_neighbor_id;
126                new_len += 1;
127                list = &mut list_raw[0..new_len];
128            } else {
129                trace!("append_vector: some new neighbors discarded; adjacency list full");
130                break;
131            }
132        }
133
134        // Lint: adjacency list sizes will not overflow VectorId
135        #[allow(clippy::unwrap_used)]
136        {
137            list_raw[self.graph.dim() - 1] = new_len.try_into_vector_id().unwrap();
138        }
139        Ok(())
140    }
141}
142
143impl<I: VectorId> HasId for SimpleNeighborProviderAsync<I> {
144    type Id = I;
145}
146
147impl SimpleNeighborProviderAsync<u32> {
148    /// Load the graph directly from a canonical DiskANN graph storage at path `path`.
149    ///
150    /// See also: [`storage::bin::load_graph`].
151    pub fn load_direct<P>(provider: &P, path: &str) -> ANNResult<Self>
152    where
153        P: StorageReadProvider,
154    {
155        storage::bin::load_graph(
156            provider,
157            path,
158            |num_points, max_degree, num_start_points| {
159                // The value `num_points` is the total number of vectors discovered in the
160                // source file, including start points.
161                //
162                // Work backwards from this value to determine the internal `max_points`.
163                let max_points = num_points.checked_sub(num_start_points).ok_or_else(|| {
164                    ANNError::log_index_error(format_args!(
165                        "expected {} start points but the on-disk dataset only has {} total points",
166                        num_start_points, num_points,
167                    ))
168                })?;
169
170                // The provided `max_degree` here is the observed maximum degree in the input
171                // file. Therefore, we don't need to apply a slack factor to it.
172                Ok(Self::new(
173                    max_points,
174                    num_start_points,
175                    max_degree as u32,
176                    1.0,
177                ))
178            },
179        )
180    }
181
182    /// Save `self` directly to a canonical DiskANN graph storage at path `path`.
183    ///
184    /// See also: [`storage::bin::save_graph`].
185    pub fn save_direct<P>(&self, provider: &P, start_point: u32, path: &str) -> ANNResult<usize>
186    where
187        P: StorageWriteProvider,
188    {
189        storage::bin::save_graph(self, provider, start_point, path)
190    }
191}
192
193/// This is an adaptor for compatibility with the async index serialization.
194///
195/// The parameter consists of `(start_point, prefix)` because the index start point is not
196/// saved within `SimpleNeighborPRoviderAsync`.
197impl SaveWith<(u32, AsyncIndexMetadata)> for SimpleNeighborProviderAsync<u32> {
198    type Ok = usize;
199    type Error = ANNError;
200
201    async fn save_with<P>(
202        &self,
203        provider: &P,
204        (start_point, metadata): &(u32, AsyncIndexMetadata),
205    ) -> ANNResult<usize>
206    where
207        P: StorageWriteProvider,
208    {
209        self.save_direct(provider, *start_point, metadata.prefix())
210    }
211}
212
213/// This implementation handles the conversion between async index and disk index format.
214/// Parameters:
215/// - `start_point`: The vector ID used during async index building (exceed max_point bounds)
216/// - `actual_start_point`: The real vector ID with identical vector values as `start_point`
217/// - `prefix`: Path prefix for the disk index files
218///
219/// The substitution of `start_point` with `actual_start_point` ensures compatibility
220/// with the on-disk format while preserving the correct entry point information.
221impl SaveWith<(u32, u32, DiskGraphOnly)> for SimpleNeighborProviderAsync<u32> {
222    type Ok = usize;
223    type Error = ANNError;
224
225    async fn save_with<P>(
226        &self,
227        provider: &P,
228        (imem_start_point, actual_start_point, metadata): &(u32, u32, DiskGraphOnly),
229    ) -> Result<Self::Ok, Self::Error>
230    where
231        P: StorageWriteProvider,
232    {
233        let graph = DiskAdaptor {
234            provider: self,
235            inmem_start_point: *imem_start_point,
236            actual_start_point: *actual_start_point,
237        };
238
239        storage::bin::save_graph(&graph, provider, *actual_start_point, metadata.prefix())
240    }
241}
242
243/// This is an adaptor for compatibility with the async index serialization.
244impl LoadWith<AsyncIndexMetadata> for SimpleNeighborProviderAsync<u32> {
245    type Error = ANNError;
246
247    async fn load_with<P>(provider: &P, metadata: &AsyncIndexMetadata) -> ANNResult<Self>
248    where
249        P: StorageReadProvider,
250    {
251        Self::load_direct(provider, metadata.prefix())
252    }
253}
254
255/// This is an adaptor for compatibility with the async index serialization.
256impl LoadWith<AsyncQuantLoadContext> for SimpleNeighborProviderAsync<u32> {
257    type Error = ANNError;
258
259    async fn load_with<P>(provider: &P, ctx: &AsyncQuantLoadContext) -> ANNResult<Self>
260    where
261        P: StorageReadProvider,
262    {
263        Self::load_with(provider, &ctx.metadata).await
264    }
265}
266
267////////////////////////////////////////////
268// SetAdjacencyList and GetAdjacencyList //
269///////////////////////////////////////////
270
271/// Hook into [`storage::bin::load_graph`] by implementing [`storage::bin::SetAdjacencyList`].
272impl storage::bin::SetAdjacencyList for SimpleNeighborProviderAsync<u32> {
273    type Item = u32;
274    fn set_adjacency_list(&mut self, i: usize, element: &[u32]) -> ANNResult<()> {
275        self.set_neighbors_sync(i, element)?;
276        Ok(())
277    }
278}
279
280/// Hook into [`storage::bin::save_graph`] by implementing [`storage::bin::GetAdjacencyList`].
281impl storage::bin::GetAdjacencyList for SimpleNeighborProviderAsync<u32> {
282    type Element = u32;
283    type Item<'a> = AdjacencyList<u32>;
284
285    fn get_adjacency_list(&self, i: usize) -> ANNResult<Self::Item<'_>> {
286        let mut list = AdjacencyList::new();
287        self.get_neighbors_sync(i, &mut list)?;
288        Ok(list)
289    }
290
291    fn total(&self) -> usize {
292        self.locks.len()
293    }
294
295    fn additional_points(&self) -> u64 {
296        self.num_start_points as u64
297    }
298
299    fn max_degree(&self) -> Option<u32> {
300        Some((self.graph.dim() - 1) as u32)
301    }
302}
303
304/// This adaptor translates between the in-memory async index representation
305/// and the on-disk index format during serialization.
306///
307/// Key differences between the formats:
308/// 1. Disk format requires a valid vector ID as start point, while async index uses a
309///    virtual ID (max_points + 1) that exceeds the valid dataset range
310/// 2. In-memory index appends the virtual start point at the end of adjacency lists
311/// 3. Disk format expects additional_points = 0, while async index uses additional_points = 1
312///
313/// This adaptor handles these differences by:
314/// - Substituting the virtual start point ID with an actual dataset ID when found in adjacency lists
315/// - Excluding the virtual point from the total count (subtracting 1 from length)
316/// - Setting additional_points to 0 as required by the disk format specification
317///
318/// Used with [`storage::bin::save_graph`] to persist an async index in standard DiskANN format.
319struct DiskAdaptor<'a> {
320    provider: &'a SimpleNeighborProviderAsync<u32>,
321    inmem_start_point: u32,
322    actual_start_point: u32,
323}
324
325impl storage::bin::GetAdjacencyList for DiskAdaptor<'_> {
326    type Element = u32;
327    type Item<'item>
328        = Vec<u32>
329    where
330        Self: 'item;
331
332    fn get_adjacency_list(&self, i: usize) -> ANNResult<Self::Item<'_>> {
333        let mut list = AdjacencyList::new();
334        self.provider.get_neighbors_sync(i, &mut list)?;
335
336        // Need to change to a `Vec` because remapping the start point can cause duplicates,
337        // and changing the logic to not have duplicates changes the exact nature of the
338        // graph and breaks integration tests for the disk index builder.
339        let mut list: Vec<_> = list.into();
340        for i in list.iter_mut() {
341            if *i == self.inmem_start_point {
342                *i = self.actual_start_point;
343            }
344        }
345
346        Ok(list)
347    }
348
349    fn total(&self) -> usize {
350        // Don't include any start points at the end.
351        self.provider.locks.len() - self.provider.num_start_points
352    }
353
354    /// Fixed to 0 for the disk format
355    fn additional_points(&self) -> u64 {
356        0
357    }
358
359    fn max_degree(&self) -> Option<u32> {
360        None
361    }
362}
363
364///////////
365// Tests //
366///////////
367
368#[cfg(test)]
369mod tests {
370    use crate::storage::VirtualStorageProvider;
371
372    use super::*;
373
374    #[test]
375    fn test_neighbor_provider() {
376        let neighbor_provider = SimpleNeighborProviderAsync::<u32>::new(10, 1, 5, 1.0);
377
378        let adj_list = vec![1, 2, 3];
379        neighbor_provider.set_neighbors_sync(1, &adj_list).unwrap();
380
381        let mut result = AdjacencyList::new();
382        neighbor_provider
383            .get_neighbors_sync(1, &mut result)
384            .unwrap();
385
386        assert_eq!(&adj_list, &*result);
387
388        let new_adj_list = AdjacencyList::from_iter_untrusted([4, 5, 6]);
389        neighbor_provider
390            .set_neighbors_sync(1, &new_adj_list)
391            .unwrap();
392
393        neighbor_provider
394            .get_neighbors_sync(1, &mut result)
395            .unwrap();
396
397        assert_eq!(new_adj_list, result);
398    }
399
400    #[tokio::test]
401    async fn test_save_load() {
402        let max_degree = 5;
403        let max_points = 8;
404        let additional_points = 2;
405
406        let provider =
407            SimpleNeighborProviderAsync::<u32>::new(max_points, additional_points, max_degree, 1.0);
408
409        // Setup a virtual storage provider with memory filesystem
410        let storage = VirtualStorageProvider::new_memory();
411
412        // Fill the graph, each node i will have neighbors [i+1, i+2, i+3]
413        for i in 0..max_points + additional_points {
414            let neighbors: Vec<u32> = (1..4).map(|j| i as u32 + j).collect();
415            provider.set_neighbors_sync(i, &neighbors).unwrap();
416        }
417
418        let prefix = AsyncIndexMetadata::new("/resumable_test");
419        // Test SaveWith implementation
420        let start_point = 0;
421        let result = provider
422            .save_with(&storage, &(start_point, prefix.clone()))
423            .await;
424        assert!(result.is_ok(), "Failed to save with resumable context");
425
426        // Verify the file was created
427        let expected_path = prefix.prefix();
428        assert!(
429            storage.exists(expected_path),
430            "Resumable graph file was not created"
431        );
432
433        let receiver =
434            SimpleNeighborProviderAsync::<u32>::load_direct(&storage, prefix.prefix()).unwrap();
435
436        for i in 0..max_points + additional_points {
437            let mut result = AdjacencyList::new();
438            let mut loaded_result = AdjacencyList::new();
439            provider.get_neighbors_sync(i, &mut result).unwrap();
440            receiver.get_neighbors_sync(i, &mut loaded_result).unwrap();
441            assert_eq!(
442                result, loaded_result,
443                "Adjacency list for node {} doesn't match after loading",
444                i
445            );
446        }
447    }
448}