-
Notifications
You must be signed in to change notification settings - Fork 436
Add algorithm and benchmark for filtered range search #1228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
magdalendobson
wants to merge
60
commits into
main
Choose a base branch
from
users/magdalen/range_filter
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
60 commits
Select commit
Hold shift + click to select a range
97b36ef
finish up recall computation patch
07b3671
Merge branch 'main' of github.com:microsoft/DiskANN
eac3ffb
Merge branch 'main' of github.com:microsoft/DiskANN
17780f8
fix conflict
43eb517
fix conflict
1d3a52b
Merge branch 'main' of github.com:microsoft/DiskANN
17eac62
Merge branch 'main' of github.com:microsoft/DiskANN
0ab4baa
Merge branch 'main' of github.com:microsoft/DiskANN
4ddca60
Merge branch 'main' of github.com:microsoft/DiskANN
54ee01b
fix conflict
9e1743f
Merge branch 'main' of github.com:microsoft/DiskANN
93504e6
Merge branch 'main' of github.com:microsoft/DiskANN
b7c27ce
Merge branch 'main' of github.com:microsoft/DiskANN
1dafc55
Merge branch 'main' of github.com:microsoft/DiskANN
33285e2
Merge branch 'main' of github.com:microsoft/DiskANN
824bdb3
Merge branch 'main' of github.com:microsoft/DiskANN
826600f
Merge branch 'main' of github.com:microsoft/DiskANN
1bea9c5
Merge branch 'main' of github.com:microsoft/DiskANN
c864722
add range groundtruth calculator, rename vector_filters_file
bd05e0f
fmt, clippy, add missing file
1477ac4
Potential fix for pull request finding
magdalendobson 4f6f80a
Potential fix for pull request finding
magdalendobson 2044ba1
fix: return error for filter bitmap mismatch
Copilot df844be
standardize naming to kebab case
6827f9d
standardize variable names, one more kebab-case instance
eb70a6f
merge with main
5edc8e9
add range filter algorithm
db3f379
cargo fmt
37796db
add range searhc
39a319f
add benchmark search file
703bdac
merge with main
9187776
actually finish merge
60c421a
more merge issues
caa63ce
add integration test
427cd18
add groundtruth for test datasets
e05a718
add filtered range search to tests, add range search tests
c4c7ae1
fmt
8603760
remove dashes that were making clippy mad
cd6c7e7
clippy
1f1ae8a
regenerate baselines with new tie breaking protocol
0bb0279
Potential fix for pull request finding
magdalendobson 8374b99
fix copilot induced errors
1e51766
regenerate baselines after bug fix
c2ad6f9
Merge branch 'main' of github.com:microsoft/DiskANN into users/magdal…
1895710
remove range search parts to split out to separate PR
cb78955
added a description field for each test
c79529f
overwrite benchmarks to include descriptions
e5bf7ba
fmt + clippy
eefcd3e
address comments about filtered range search
918a6c0
use vector sort + dedup instead of hashset
bbf3a35
get rid of unnecessary PartialEq and Eq
4585f84
add tests
b0a6677
add a builder for range search params
9571fd0
get rid of duplicate builder
251a0ba
change old description
1440049
remove duplicated code
85bf3af
tighten up benchmark
e30697f
remove edits done by clippy
e4bbced
fix merge conflict
34fb7a0
fmt
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
379 changes: 379 additions & 0 deletions
379
diskann-benchmark-core/src/search/graph/filtered_range.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,379 @@ | ||
| /* | ||
| * Copyright (c) Microsoft Corporation. | ||
| * Licensed under the MIT license. | ||
| */ | ||
|
|
||
| use std::{num::NonZeroUsize, sync::Arc}; | ||
|
|
||
| use diskann::{ | ||
| ANNResult, | ||
| graph::{self, ext::labeled, glue}, | ||
| provider, | ||
| }; | ||
| use diskann_benchmark_runner::utils::{MicroSeconds, percentiles}; | ||
| use diskann_utils::{future::AsyncFriendly, views::Matrix}; | ||
|
|
||
| use crate::{ | ||
| recall, | ||
| search::{self, Search, graph::Strategy}, | ||
| }; | ||
|
|
||
| /// A built-in helper for benchmarking the filtered range search method | ||
| /// [`graph::DiskANNIndex::search`]. | ||
| /// | ||
| /// This is intended to be used in conjunction with [`search::search`] or | ||
| /// [`search::search_all`] and provides some basic additional metrics for the | ||
| /// latter. Result aggregation for [`search::search_all`] is provided by the | ||
| /// [`Aggregator`] type. | ||
| /// | ||
| /// The provided implementation of [`Search`] accepts | ||
| /// [`graph::search::Range`] and returns [`Metrics`] as additional output. | ||
| #[derive(Debug)] | ||
| pub struct FilteredRange<DP, T, S> | ||
| where | ||
| DP: provider::DataProvider, | ||
| { | ||
| index: Arc<graph::DiskANNIndex<DP>>, | ||
| queries: Arc<Matrix<T>>, | ||
| strategy: Strategy<S>, | ||
| labels: Arc<[Arc<dyn labeled::QueryLabelProvider<DP::InternalId>>]>, | ||
| } | ||
|
|
||
| impl<DP, T, S> FilteredRange<DP, T, S> | ||
| where | ||
| DP: provider::DataProvider, | ||
| { | ||
| /// Construct a new [`FilteredRange`] searcher. | ||
| /// | ||
| /// If `strategy` is one of the container variants of [`Strategy`], its length | ||
| /// must match the number of rows in `queries`. If this is the case, then the | ||
| /// strategies will have a querywise correspondence (see [`search::SearchResults`]) | ||
| /// with the query matrix. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns an error if the number of elements in `strategy` is not compatible with | ||
| /// the number of rows in `queries`. | ||
| pub fn new( | ||
| index: Arc<graph::DiskANNIndex<DP>>, | ||
| queries: Arc<Matrix<T>>, | ||
| strategy: Strategy<S>, | ||
| labels: Arc<[Arc<dyn labeled::QueryLabelProvider<DP::InternalId>>]>, | ||
| ) -> anyhow::Result<Arc<Self>> { | ||
| strategy.length_compatible(queries.nrows())?; | ||
|
|
||
| if labels.len() != queries.nrows() { | ||
| Err(anyhow::anyhow!( | ||
| "Number of label providers ({}) must be equal to the number of queries ({})", | ||
| labels.len(), | ||
| queries.nrows() | ||
| )) | ||
| } else { | ||
| Ok(Arc::new(Self { | ||
| index, | ||
| queries, | ||
| strategy, | ||
| labels, | ||
| })) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Placeholder for filtered range search metrics. | ||
| #[derive(Debug, Clone, Copy)] | ||
| #[non_exhaustive] | ||
| pub struct Metrics {} | ||
|
|
||
| impl<DP, T, S> Search for FilteredRange<DP, T, S> | ||
| where | ||
| DP: provider::DataProvider<Context: Default, ExternalId: search::Id>, | ||
| S: for<'a> glue::DefaultSearchStrategy< | ||
| 'a, | ||
| DP, | ||
| &'a [T], | ||
| DP::ExternalId, | ||
| SearchAccessor: glue::SearchAccessor, | ||
| > + Clone | ||
| + AsyncFriendly, | ||
| T: AsyncFriendly + Clone, | ||
| { | ||
| type Id = DP::ExternalId; | ||
| type Parameters = graph::search::Range; | ||
| type Output = Metrics; | ||
|
|
||
| fn num_queries(&self) -> usize { | ||
| self.queries.nrows() | ||
| } | ||
|
|
||
| fn id_count(&self, parameters: &Self::Parameters) -> search::IdCount { | ||
| search::IdCount::Dynamic(NonZeroUsize::new(parameters.starting_l())) | ||
| } | ||
|
|
||
| async fn search<O>( | ||
| &self, | ||
| parameters: &Self::Parameters, | ||
| buffer: &mut O, | ||
| index: usize, | ||
| ) -> ANNResult<Self::Output> | ||
| where | ||
| O: graph::SearchOutputBuffer<DP::ExternalId> + Send, | ||
| { | ||
| let context = DP::Context::default(); | ||
| let filtered_range_search = | ||
| graph::search::FilteredRange::builder(parameters.starting_l(), parameters.radius()) | ||
| .max_returned(parameters.max_returned()) | ||
| .beam_width(parameters.beam_width()) | ||
| .inner_radius(parameters.inner_radius()) | ||
| .initial_slack(parameters.initial_slack()) | ||
| .range_slack(parameters.range_slack()) | ||
| .build_filtered()?; | ||
| let strategy = | ||
| labeled::Filtered::new(self.strategy.get(index)?.clone(), &*self.labels[index]); | ||
| let _ = self | ||
| .index | ||
| .search( | ||
| filtered_range_search, | ||
| &strategy, | ||
| &context, | ||
| self.queries.row(index), | ||
| buffer, | ||
| ) | ||
| .await?; | ||
|
|
||
| Ok(Metrics {}) | ||
| } | ||
| } | ||
|
|
||
| /// An [`search::Aggregate`]d summary of multiple [`FilteredRange`] search runs returned by | ||
| /// the provided [`Aggregator`]. | ||
| #[derive(Debug, Clone)] | ||
| #[non_exhaustive] | ||
| pub struct Summary { | ||
| pub setup: search::Setup, | ||
| pub parameters: graph::search::Range, | ||
| pub end_to_end_latencies: Vec<MicroSeconds>, | ||
| pub mean_latencies: Vec<f64>, | ||
| pub p90_latencies: Vec<MicroSeconds>, | ||
| pub p99_latencies: Vec<MicroSeconds>, | ||
| pub average_precision: recall::AveragePrecisionMetrics, | ||
| } | ||
|
|
||
| /// A [`search::Aggregate`] for collecting the results of multiple [`FilteredRange`] search | ||
| /// runs. | ||
| pub struct Aggregator<'a, I> { | ||
| groundtruth: &'a dyn crate::recall::Rows<I>, | ||
| } | ||
|
|
||
| impl<'a, I> Aggregator<'a, I> { | ||
| pub fn new(groundtruth: &'a dyn crate::recall::Rows<I>) -> Self { | ||
| Self { groundtruth } | ||
| } | ||
| } | ||
|
|
||
| impl<I> search::Aggregate<graph::search::Range, I, Metrics> for Aggregator<'_, I> | ||
| where | ||
| I: crate::recall::RecallCompatible, | ||
| { | ||
| type Output = Summary; | ||
|
|
||
| #[inline(never)] | ||
| fn aggregate( | ||
| &mut self, | ||
| run: search::Run<graph::search::Range>, | ||
| mut results: Vec<search::SearchResults<I, Metrics>>, | ||
| ) -> anyhow::Result<Summary> { | ||
| let average_precision = match results.first() { | ||
| Some(first) => { | ||
| crate::recall::average_precision(first.ids().as_rows(), self.groundtruth)? | ||
| } | ||
| None => anyhow::bail!("Results must be non-empty"), | ||
| }; | ||
|
|
||
| let mut mean_latencies = Vec::with_capacity(results.len()); | ||
| let mut p90_latencies = Vec::with_capacity(results.len()); | ||
| let mut p99_latencies = Vec::with_capacity(results.len()); | ||
|
|
||
| results.iter_mut().for_each(|r| { | ||
| match percentiles::compute_percentiles(r.latencies_mut()) { | ||
| Ok(values) => { | ||
| let percentiles::Percentiles { mean, p90, p99, .. } = values; | ||
| mean_latencies.push(mean); | ||
| p90_latencies.push(p90); | ||
| p99_latencies.push(p99); | ||
| } | ||
| Err(_) => { | ||
| let zero = MicroSeconds::new(0); | ||
| mean_latencies.push(0.0); | ||
| p90_latencies.push(zero); | ||
| p99_latencies.push(zero); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| Ok(Summary { | ||
| setup: run.setup().clone(), | ||
| parameters: *run.parameters(), | ||
| end_to_end_latencies: results.iter().map(|r| r.end_to_end_latency()).collect(), | ||
| mean_latencies, | ||
| p90_latencies, | ||
| p99_latencies, | ||
| average_precision, | ||
| }) | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add tests like all the other entries in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, done |
||
|
|
||
| /////////// | ||
| // Tests // | ||
| /////////// | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| use diskann::graph::{ext::labeled::QueryLabelProvider, test::provider}; | ||
|
|
||
| #[derive(Debug)] | ||
| struct NoOdds; | ||
|
|
||
| impl labeled::QueryLabelProvider<u32> for NoOdds { | ||
| fn is_match(&self, id: u32) -> bool { | ||
| id.is_multiple_of(2) | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_filtered_range() { | ||
| let index = search::graph::test_grid_provider(); | ||
|
|
||
| let mut queries = Matrix::new(0.0f32, 5, index.provider().dim()); | ||
| queries.row_mut(0).copy_from_slice(&[0.0, 0.0, 0.0, 0.0]); | ||
| queries.row_mut(1).copy_from_slice(&[4.0, 0.0, 0.0, 0.0]); | ||
| queries.row_mut(2).copy_from_slice(&[0.0, 4.0, 0.0, 0.0]); | ||
| queries.row_mut(3).copy_from_slice(&[0.0, 0.0, 4.0, 0.0]); | ||
| queries.row_mut(4).copy_from_slice(&[0.0, 0.0, 0.0, 4.0]); | ||
|
|
||
| let queries = Arc::new(queries); | ||
| let labels: Arc<[_]> = (0..queries.nrows()) | ||
| .map(|_| -> Arc<dyn QueryLabelProvider<_>> { Arc::new(NoOdds {}) }) | ||
| .collect(); | ||
|
|
||
| let filtered_range = FilteredRange::new( | ||
| index, | ||
| queries.clone(), | ||
| Strategy::broadcast(provider::Strategy::new()), | ||
| labels, | ||
| ) | ||
| .unwrap(); | ||
|
|
||
| // Test the standard search interface. | ||
| let rt = crate::tokio::runtime(2).unwrap(); | ||
| let results = search::search( | ||
| filtered_range.clone(), | ||
| graph::search::Range::builder(10, 2.0) | ||
| .initial_slack(0.8) | ||
| .range_slack(1.2) | ||
| .build() | ||
| .unwrap(), | ||
| NonZeroUsize::new(2).unwrap(), | ||
| &rt, | ||
| ) | ||
| .unwrap(); | ||
|
|
||
| assert_eq!(results.len(), queries.nrows()); | ||
| let rows = results.ids().as_rows(); | ||
|
|
||
| // Check that only even IDs are returned. | ||
| for r in 0..rows.nrows() { | ||
| for &id in rows.row(r) { | ||
| assert_eq!(id % 2, 0, "Found odd ID {} in row {}", id, r); | ||
| } | ||
| } | ||
|
|
||
| const TWO: NonZeroUsize = NonZeroUsize::new(2).unwrap(); | ||
| let setup = search::Setup { | ||
| threads: TWO, | ||
| tasks: TWO, | ||
| reps: TWO, | ||
| }; | ||
|
|
||
| // Try the aggregated strategy. | ||
| let parameters = [ | ||
| search::Run::new( | ||
| graph::search::Range::builder(10, 2.0) | ||
| .initial_slack(0.8) | ||
| .range_slack(1.2) | ||
| .build() | ||
| .unwrap(), | ||
| setup.clone(), | ||
| ), | ||
| search::Run::new( | ||
| graph::search::Range::builder(15, 2.0) | ||
| .initial_slack(0.8) | ||
| .range_slack(1.2) | ||
| .build() | ||
| .unwrap(), | ||
| setup.clone(), | ||
| ), | ||
| ]; | ||
|
|
||
| let all = search::search_all(filtered_range, parameters, Aggregator::new(rows)).unwrap(); | ||
|
|
||
| assert_eq!(all.len(), 2); | ||
| for summary in all { | ||
| assert_eq!(summary.setup, setup); | ||
| assert_eq!(summary.end_to_end_latencies.len(), TWO.get()); | ||
| assert_eq!(summary.mean_latencies.len(), TWO.get()); | ||
| assert_eq!(summary.p90_latencies.len(), TWO.get()); | ||
| assert_eq!(summary.p99_latencies.len(), TWO.get()); | ||
|
|
||
| let ap = summary.average_precision; | ||
| assert_eq!(ap.num_queries, queries.nrows()); | ||
| assert_eq!( | ||
| ap.average_precision, 1.0, | ||
| "we used a search as the groundtruth" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_filtered_range_error() { | ||
| let index = search::graph::test_grid_provider(); | ||
| let queries = Arc::new(Matrix::new(0.0f32, 2, index.provider().dim())); | ||
|
|
||
| let labels: Arc<[_]> = (0..queries.nrows() + 1) | ||
| .map(|_| -> Arc<dyn QueryLabelProvider<_>> { Arc::new(NoOdds {}) }) | ||
| .collect(); | ||
|
|
||
| let strategy = provider::Strategy::new(); | ||
|
|
||
| // Error for a mismatch between strategies and queries. | ||
| let err = FilteredRange::new( | ||
| index.clone(), | ||
| queries.clone(), | ||
| Strategy::collection([strategy.clone()]), | ||
| labels.clone(), | ||
| ) | ||
| .unwrap_err(); | ||
| let msg = err.to_string(); | ||
| assert!( | ||
| msg.contains("1 strategy was provided when 2 were expected"), | ||
| "failed with {msg}" | ||
| ); | ||
|
|
||
| // Error for a mismatch between label providers and queries. | ||
| let err = FilteredRange::new( | ||
| index, | ||
| queries.clone(), | ||
| Strategy::broadcast(strategy), | ||
| labels, | ||
| ) | ||
| .unwrap_err(); | ||
| let msg = err.to_string(); | ||
| assert!( | ||
| msg.contains( | ||
| "Number of label providers (3) must be equal to the number of queries (2)" | ||
| ), | ||
| "failed with {msg}" | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what's happening here: we want to keep the parameters the same to manage monomorphization in
diskann-benchmark. But, it might be better to define a conversion fromRangetoFilteredRangerather than going back through the builder (which runs the risk of going stale). I'm not sure what the implications are, though, if we ever decide thatFilteredRangeneeds to have additional parameters, but I guess we'll cross that bridge when we come to it.