Skip to main content

diskann_benchmark_core/search/graph/
range.rs

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