Add algorithm and benchmark for filtered range search - #1228
Add algorithm and benchmark for filtered range search#1228Magdalen Dobson Manohar (magdalendobson) wants to merge 64 commits into
Conversation
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1228 +/- ##
==========================================
+ Coverage 91.46% 91.71% +0.24%
==========================================
Files 516 519 +3
Lines 98276 99209 +933
==========================================
+ Hits 89891 90989 +1098
+ Misses 8385 8220 -165
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Mark Hildebrand (hildebrandmw)
left a comment
There was a problem hiding this comment.
Thanks Magdalen. I've left a number of comments throughout the PR, but they all fall into a few general themes.
- This PR duplicates a lot of logic when that duplication should be abstracted instead. The worst offender is probably
DistanceFiltered. - We need to move away from messing around with the internal of scratch space (
in_range,range_frontier,visitedetc) - that can quickly become very brittle. - The logic in the filtered search implementation can be tightened.
- We should start tagging baselines with more verbose descriptions of what the test is covering and what the corner cases being exercised are.
- Please keep to high level trait objects in
diskann-benchmarkinstead of generics. The former is significantly better for compile times.
In addition, is the intention of this algorithm for research, or is there a production ask for it? I ask because maintaining such algorithms/code is not free. If this is meant for research, please put it behind an experimental feature flag to avoid committing us to it long term.
| merged | ||
| .entry(neighbor.id) | ||
| .and_modify(|best: &mut Neighbor<_>| { | ||
| if neighbor.distance < best.distance { |
There was a problem hiding this comment.
What's happening here? Why are we expecting repeats for IDs and why is picking the best the right choice?
There was a problem hiding this comment.
Overwriting the distance was an error and I've gotten rid of it now. We are expecting repeats because we are merging the best elements found so far with all the filter-satisfying elements found so far, which might have overlap.
| in_range.sort_unstable_by(|left, right| { | ||
| left.distance | ||
| .total_cmp(&right.distance) | ||
| .then_with(|| left.id.cmp(&right.id)) |
There was a problem hiding this comment.
Don't Neighbors already sort themselves?
There was a problem hiding this comment.
No, because scratch.best is merged with the predicate-satisfying points, which are not in sorted order.
| } | ||
|
|
||
| /// Create range search with full options. | ||
| #[allow(clippy::too_many_arguments)] |
There was a problem hiding this comment.
We should probably listen to clippy here. This is a lot of arguments, and while it mirrors Range - I don't think that's sufficient justification. There are several problems:
- There's no real documentation about how these are expected to affect the algorithm.
- Callers of
FilteredRange::with_optionsjust have a sea of values, which can be difficult to remember. - Any changes to the parameters requires a breaking change for all callers.
It would be way better to use a builder interface for these. As a side-benefit, the same builder could be used for both Range and FilteredRange.
There was a problem hiding this comment.
Thanks, I set up a builder
| /// | ||
| /// Both variants carry the item `T` since rejected items are useful for graph navigation. | ||
| #[derive(Debug, Clone, Copy)] | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
There was a problem hiding this comment.
Why are PartialEq and Eq needed? The code compiles fine without them.
There was a problem hiding this comment.
I think this was leftover from an intermediate state. Removed now.
| let (filtered_stats, filtered_results) = | ||
| run_filtered_range_search(&index, query.as_slice(), filtered_range, &filter); | ||
|
|
||
| let baseline = RangeSearchBaseline { |
There was a problem hiding this comment.
There's probably a better way to construct these baselines. A constructor taking the stats object and the Range could automatically fill in a lot of the fields (and we could capture all the fields of `Range~ rather then just like 3).
There was a problem hiding this comment.
Thanks for the suggestion, I added a constructor
| pub(crate) fn run<I>( | ||
| runner: &dyn Range<I>, | ||
| pub(crate) fn run<I, R, F>( | ||
| runner: &R, |
There was a problem hiding this comment.
Why did this change from a &dyn Range to a true generic? The former is quite important to keep compile times down.
Presumably it's because there's a different in the parameters being passed. Please find an alternate solution that isn't as regressive on compile times.
There was a problem hiding this comment.
I found an alternative way to do this, so the generic isn't used anymore
While working on #1228, I noticed that there were some issues with our current implementation of range search and its testing. The main issue with testing is that there were no tests ensuring the `max_results` parameter was respected. I have added two tests that ensure this now. The main code had several issues with how `max_results` was handled: 1. The `max_results` parameter was allowed to be less than the initial L_search. This is a conceptual issue because the user expects `max_results` to stop the search from continuing for too long, and the compute used in the initial search will always be controlled by `initial_search_l`. 2. A `max_results` check was not enforced before deciding to continue to the second round search. This meant that if the max results was reached via the initial search, it might not be respected. 3. The second round search was not terminated when `max_results` was reached, meaning it would continue to perform unnecessary work. This PR fixes these issues by adding additional checks of `max_results` at the correct points in the code. --------- Co-authored-by: Magdalen Manohar <[email protected]>
fff67b6 to
e30697f
Compare
While working on microsoft#1228, I noticed that there were some issues with our current implementation of range search and its testing. The main issue with testing is that there were no tests ensuring the `max_results` parameter was respected. I have added two tests that ensure this now. The main code had several issues with how `max_results` was handled: 1. The `max_results` parameter was allowed to be less than the initial L_search. This is a conceptual issue because the user expects `max_results` to stop the search from continuing for too long, and the compute used in the initial search will always be controlled by `initial_search_l`. 2. A `max_results` check was not enforced before deciding to continue to the second round search. This meant that if the max results was reached via the initial search, it might not be respected. 3. The second round search was not terminated when `max_results` was reached, meaning it would continue to perform unnecessary work. This PR fixes these issues by adding additional checks of `max_results` at the correct points in the code. --------- Co-authored-by: Magdalen Manohar <[email protected]>
Mark Hildebrand (hildebrandmw)
left a comment
There was a problem hiding this comment.
Thanks Magdalen, appreciate the work on this iteration!
| { | ||
| let context = DP::Context::default(); | ||
| let filtered_range_search = | ||
| graph::search::FilteredRange::builder(parameters.starting_l(), parameters.radius()) |
There was a problem hiding this comment.
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 from Range to FilteredRange rather 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 that FilteredRange needs to have additional parameters, but I guess we'll cross that bridge when we come to it.
There was a problem hiding this comment.
Defined a conversion
This PR adds an algorithm for filtered range search to the DiskANN repository. Details of the algorithm, along with the experiments supporting it, are at this Wiki page.
This PR:
diskann, along with integration tests.diskann-benchmark, along with an integration test.test_data.Some enhancements/fixes to range search along the way:
test_datafor yfcc.