Skip to main content

diskann_benchmark_core/search/graph/
knn.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6//! A built-in helper for benchmarking K-nearest neighbors.
7
8use std::sync::Arc;
9
10use diskann::{
11    ANNResult,
12    graph::{self, glue},
13    provider,
14};
15use diskann_benchmark_runner::utils::{MicroSeconds, percentiles};
16use diskann_utils::{future::AsyncFriendly, views::Matrix};
17
18use crate::{
19    recall,
20    recall::GroundTruthMode,
21    search::{self, Search, graph::Strategy},
22    utils,
23};
24
25/// A built-in helper for benchmarking the K-nearest neighbors method
26/// [`graph::DiskANNIndex::search`].
27///
28/// This is intended to be used in conjunction with [`search::search`] or
29/// [`search::search_all`] and provides some basic additional metrics for
30/// the latter. Result aggregation for [`search::search_all`] is provided
31/// by the [`Aggregator`] type.
32///
33/// The provided implementation of [`Search`] accepts [`graph::search::Knn`]
34/// and returns [`Metrics`] as additional output.
35#[derive(Debug)]
36pub struct KNN<DP, T, S>
37where
38    DP: provider::DataProvider,
39{
40    index: Arc<graph::DiskANNIndex<DP>>,
41    queries: Arc<Matrix<T>>,
42    strategy: Strategy<S>,
43}
44
45impl<DP, T, S> KNN<DP, T, S>
46where
47    DP: provider::DataProvider,
48{
49    /// Construct a new [`KNN`] searcher.
50    ///
51    /// If `strategy` is one of the container variants of [`Strategy`], its length
52    /// must match the number of rows in `queries`. If this is the case, then the
53    /// strategies will have a querywise correspondence (see [`search::SearchResults`])
54    /// with the query matrix.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error if the number of elements in `strategy` is not compatible with
59    /// the number of rows in `queries`.
60    pub fn new(
61        index: Arc<graph::DiskANNIndex<DP>>,
62        queries: Arc<Matrix<T>>,
63        strategy: Strategy<S>,
64    ) -> anyhow::Result<Arc<Self>> {
65        strategy.length_compatible(queries.nrows())?;
66
67        Ok(Arc::new(Self {
68            index,
69            queries,
70            strategy,
71        }))
72    }
73}
74
75/// Additional metrics collected during [`KNN`] search.
76///
77/// # Note
78///
79/// This struct is marked as non-exhaustive to allow for future additions.
80#[derive(Debug, Clone, Copy)]
81#[non_exhaustive]
82pub struct Metrics {
83    /// The number of distance comparisons performed during search.
84    pub comparisons: u32,
85    /// The number of candidates expanded during search.
86    pub hops: u32,
87}
88
89impl<DP, T, S> Search for KNN<DP, T, S>
90where
91    DP: provider::DataProvider<Context: Default, ExternalId: search::Id>,
92    S: for<'a> glue::DefaultSearchStrategy<'a, DP, &'a [T], DP::ExternalId> + Clone + AsyncFriendly,
93    T: AsyncFriendly + Clone,
94{
95    type Id = DP::ExternalId;
96    type Parameters = graph::search::Knn;
97    type Output = Metrics;
98
99    fn num_queries(&self) -> usize {
100        self.queries.nrows()
101    }
102
103    fn id_count(&self, parameters: &Self::Parameters) -> search::IdCount {
104        search::IdCount::Fixed(parameters.k_value())
105    }
106
107    async fn search<O>(
108        &self,
109        parameters: &Self::Parameters,
110        buffer: &mut O,
111        index: usize,
112    ) -> ANNResult<Self::Output>
113    where
114        O: graph::SearchOutputBuffer<DP::ExternalId> + Send,
115    {
116        let context = DP::Context::default();
117        let knn_search = *parameters;
118        let stats = self
119            .index
120            .search(
121                knn_search,
122                self.strategy.get(index)?,
123                &context,
124                self.queries.row(index),
125                buffer,
126            )
127            .await?;
128
129        Ok(Metrics {
130            comparisons: stats.cmps,
131            hops: stats.hops,
132        })
133    }
134}
135
136/// An [`search::Aggregate`]d summary of multiple [`KNN`] search runs
137/// returned by the provided [`Aggregator`].
138///
139/// This struct is marked as non-exhaustive to allow for future additions.
140#[derive(Debug, Clone)]
141#[non_exhaustive]
142pub struct Summary {
143    /// The [`search::Setup`] used for the batch of runs.
144    pub setup: search::Setup,
145
146    /// The [`Search::Parameters`] used for the batch of runs.
147    pub parameters: graph::search::Knn,
148
149    /// The end-to-end latency for each repetition in the batch.
150    pub end_to_end_latencies: Vec<MicroSeconds>,
151
152    /// The average latency for individual queries.
153    ///
154    /// This contains one entry per repetition in the batch.
155    pub mean_latencies: Vec<f64>,
156
157    /// The 90th percentile latency for individual queries.
158    ///
159    /// This contains one entry per repetition in the batch.
160    pub p90_latencies: Vec<MicroSeconds>,
161
162    /// The 99th percentile latency for individual queries.
163    ///
164    /// This contains one entry per repetition in the batch.
165    pub p99_latencies: Vec<MicroSeconds>,
166
167    /// The recall metrics for search.
168    ///
169    /// This implementation assumes that search is deterministic and only
170    /// uses the first repetition's results to compute recall.
171    pub recall: recall::RecallMetrics,
172
173    /// The average number of distance comparisons per query.
174    pub mean_cmps: f64,
175
176    /// The average number of neighbor hops per query.
177    pub mean_hops: f64,
178}
179
180/// A [`search::Aggregate`] for collecting the results of multiple [`KNN`] search runs.
181///
182/// In addition to collecting latencies and other metrics, this aggregator computes
183/// recall using a provided groundtruth.
184///
185/// The aggregated results are available as a [`Summary`].
186pub struct Aggregator<'a, I> {
187    groundtruth: &'a dyn crate::recall::Rows<I>,
188    recall_k: usize,
189    recall_n: usize,
190    groundtruth_mode: GroundTruthMode,
191}
192
193impl<'a, I> Aggregator<'a, I> {
194    /// Construct a new [`Aggregator`] using `groundtruth` for recall computation.
195    ///
196    /// Recall will be computed as `recall_k`-NN recall over the top `recall_n` neighbors.
197    ///
198    /// This implementation allows fewer than `recall_n` neighbors to be returned
199    /// per query without error.
200    pub fn new(
201        groundtruth: &'a dyn crate::recall::Rows<I>,
202        recall_k: usize,
203        recall_n: usize,
204        groundtruth_mode: GroundTruthMode,
205    ) -> Self {
206        Self {
207            groundtruth,
208            recall_k,
209            recall_n,
210            groundtruth_mode,
211        }
212    }
213}
214
215impl<I> search::Aggregate<graph::search::Knn, I, Metrics> for Aggregator<'_, I>
216where
217    I: crate::recall::RecallCompatible,
218{
219    type Output = Summary;
220
221    fn aggregate(
222        &mut self,
223        run: search::Run<graph::search::Knn>,
224        mut results: Vec<search::SearchResults<I, Metrics>>,
225    ) -> anyhow::Result<Summary> {
226        // Compute the recall using just the first result.
227        let recall = match results.first() {
228            Some(first) => crate::recall::knn(
229                self.groundtruth,
230                None,
231                first.ids().as_rows(),
232                self.recall_k,
233                self.recall_n,
234                self.groundtruth_mode,
235            )?,
236            None => anyhow::bail!("Results must be non-empty"),
237        };
238
239        let mut mean_latencies = Vec::with_capacity(results.len());
240        let mut p90_latencies = Vec::with_capacity(results.len());
241        let mut p99_latencies = Vec::with_capacity(results.len());
242
243        results.iter_mut().for_each(|r| {
244            match percentiles::compute_percentiles(r.latencies_mut()) {
245                Ok(values) => {
246                    let percentiles::Percentiles { mean, p90, p99, .. } = values;
247                    mean_latencies.push(mean);
248                    p90_latencies.push(p90);
249                    p99_latencies.push(p99);
250                }
251                Err(_) => {
252                    let zero = MicroSeconds::new(0);
253                    mean_latencies.push(0.0);
254                    p90_latencies.push(zero);
255                    p99_latencies.push(zero);
256                }
257            }
258        });
259
260        Ok(Summary {
261            setup: run.setup().clone(),
262            parameters: *run.parameters(),
263            end_to_end_latencies: results.iter().map(|r| r.end_to_end_latency()).collect(),
264            recall,
265            mean_latencies,
266            p90_latencies,
267            p99_latencies,
268            mean_cmps: utils::average_all(
269                results
270                    .iter()
271                    .flat_map(|r| r.output().iter().map(|o| o.comparisons)),
272            ),
273            mean_hops: utils::average_all(
274                results
275                    .iter()
276                    .flat_map(|r| r.output().iter().map(|o| o.hops)),
277            ),
278        })
279    }
280}
281
282///////////
283// Tests //
284///////////
285
286#[cfg(test)]
287mod tests {
288    use std::num::NonZeroUsize;
289
290    use super::*;
291
292    use diskann::graph::test::provider;
293
294    #[test]
295    fn test_knn() {
296        let nearest_neighbors = 5;
297
298        let index = search::graph::test_grid_provider();
299
300        let mut queries = Matrix::new(0.0f32, 5, index.provider().dim());
301        queries.row_mut(0).copy_from_slice(&[0.0, 0.0, 0.0, 0.0]);
302        queries.row_mut(1).copy_from_slice(&[4.0, 0.0, 0.0, 0.0]);
303        queries.row_mut(2).copy_from_slice(&[0.0, 4.0, 0.0, 0.0]);
304        queries.row_mut(3).copy_from_slice(&[0.0, 0.0, 4.0, 0.0]);
305        queries.row_mut(4).copy_from_slice(&[0.0, 0.0, 0.0, 4.0]);
306
307        let queries = Arc::new(queries);
308
309        let knn = KNN::new(
310            index,
311            queries.clone(),
312            Strategy::broadcast(provider::Strategy::new()),
313        )
314        .unwrap();
315
316        // Test the standard search interface.
317        let rt = crate::tokio::runtime(2).unwrap();
318        let results = search::search(
319            knn.clone(),
320            graph::search::Knn::new(nearest_neighbors, 10, None).unwrap(),
321            NonZeroUsize::new(2).unwrap(),
322            &rt,
323        )
324        .unwrap();
325
326        assert_eq!(results.len(), queries.nrows());
327        let rows = results.ids().as_rows();
328        assert_eq!(*rows.row(0).first().unwrap(), 0);
329
330        for r in 0..rows.nrows() {
331            assert_eq!(rows.row(r).len(), nearest_neighbors);
332        }
333
334        const TWO: NonZeroUsize = NonZeroUsize::new(2).unwrap();
335        let setup = search::Setup {
336            threads: TWO,
337            tasks: TWO,
338            reps: TWO,
339        };
340
341        // Try the aggregated strategy.
342        let parameters = [
343            search::Run::new(
344                graph::search::Knn::new(nearest_neighbors, 10, None).unwrap(),
345                setup.clone(),
346            ),
347            search::Run::new(
348                graph::search::Knn::new(nearest_neighbors, 15, None).unwrap(),
349                setup.clone(),
350            ),
351        ];
352
353        let recall_k = nearest_neighbors;
354        let recall_n = nearest_neighbors;
355
356        let all = search::search_all(
357            knn,
358            parameters,
359            Aggregator::new(rows, recall_k, recall_n, GroundTruthMode::Fixed),
360        )
361        .unwrap();
362
363        assert_eq!(all.len(), 2);
364        for summary in all {
365            assert_eq!(summary.setup, setup);
366            assert_eq!(summary.end_to_end_latencies.len(), TWO.get());
367            assert_eq!(summary.mean_latencies.len(), TWO.get());
368            assert_eq!(summary.p90_latencies.len(), TWO.get());
369            assert_eq!(summary.p99_latencies.len(), TWO.get());
370
371            assert_ne!(summary.mean_cmps, 0.0);
372            assert_ne!(summary.mean_hops, 0.0);
373
374            let recall = summary.recall;
375            assert_eq!(recall.recall_k, recall_k);
376            assert_eq!(recall.recall_n, recall_n);
377            assert_eq!(recall.num_queries, queries.nrows());
378            assert_eq!(recall.average, 1.0, "we used a search as the groundtruth");
379        }
380    }
381
382    #[test]
383    fn test_knn_error() {
384        let index = search::graph::test_grid_provider();
385
386        let queries = Arc::new(Matrix::new(0.0f32, 1, index.provider().dim()));
387        let strategy = provider::Strategy::new();
388
389        let err = KNN::new(
390            index,
391            queries.clone(),
392            Strategy::collection([strategy.clone(), strategy.clone()]),
393        )
394        .unwrap_err();
395        let msg = err.to_string();
396        assert!(
397            msg.contains("2 strategies were provided when 1 was expected"),
398            "failed with {msg}"
399        );
400    }
401}