CAGRA Bloom Filter#2236
Conversation
📝 WalkthroughSummary by CodeRabbit
WalkthroughThis PR integrates cuco bloom filters into CAGRA search by adding a new ChangesBloom Filter Support for CAGRA Search
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
examples/cpp/plot_filter_benchmark.py (1)
120-153: 💤 Low value
plot_panel_linesappears unused.This function is defined but never called in the script. Consider removing it if not needed, or adding a comment indicating its intended future use.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@examples/cpp/plot_filter_benchmark.py` around lines 120 - 153, The function plot_panel_lines is defined but is never called anywhere in the script. Either remove this function entirely if it is not needed, or if it is intended for future use, add a docstring or comment at the top of the function that clearly explains its intended use case and why it is being retained. This will help clarify whether the function is dead code or work-in-progress that should remain in the codebase.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@examples/cpp/src/cagra_filter_benchmark.cu`:
- Around line 146-151: The `valid_ids_device` variable is destroyed when the if
block ends at line 151, but the `bloom.add_async()` call is asynchronous and may
still access this memory after it's freed. To fix this, either move the stream
synchronization call (currently at line 158) inside the if block before the
closing brace to ensure the async operation completes before valid_ids_device is
destroyed, or replace the `bloom.add_async()` call with the synchronous
`bloom.add()` method to avoid the race condition entirely.
---
Nitpick comments:
In `@examples/cpp/plot_filter_benchmark.py`:
- Around line 120-153: The function plot_panel_lines is defined but is never
called anywhere in the script. Either remove this function entirely if it is not
needed, or if it is intended for future use, add a docstring or comment at the
top of the function that clearly explains its intended use case and why it is
being retained. This will help clarify whether the function is dead code or
work-in-progress that should remain in the codebase.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 232cce46-f7f4-454f-98de-3f4904ed45c9
📒 Files selected for processing (3)
examples/cpp/CMakeLists.txtexamples/cpp/plot_filter_benchmark.pyexamples/cpp/src/cagra_filter_benchmark.cu
🚧 Files skipped from review as they are similar to previous changes (1)
- examples/cpp/CMakeLists.txt
dantegd
left a comment
There was a problem hiding this comment.
One thing I noticed: cagra::merge() accepts a base_filter, but its implementation only handles Bitset. A Bloom filter would fall through to the unfiltered branch and keep every row. Should we reject Bloom there for now, like Bitmap, unless probabilistic merge filtering is meant to be supported here?
| { | ||
| if (keys.extent(0) == 0) { return; } | ||
|
|
||
| auto inferred_dataset_rows = static_cast<std::size_t>(keys.extent(0)); |
There was a problem hiding this comment.
Should we separate index cardinality from insertion-batch size here? For example, if a 1,000-row index has 500 valid IDs and the caller uses the documented filtering_rate = 0.5, this treats the dataset size as 500 and allocates for only 250 insertions, even though all 500 keys are added. That invalidates the requested FPR and can reduce CAGRA recall, right? What do you think about accepting the index row count or expected insertion count explicitly?
There was a problem hiding this comment.
Hmm, good catch. I think accepting the index row count is a far cleaner design and tightens the contract by not having to infer the size of the bloom filter.
| } | ||
|
|
||
| RAFT_EXPECTS( | ||
| inferred_dataset_rows == *configured_dataset_rows, |
There was a problem hiding this comment.
Is requiring every add() batch to have exactly the same length intentional? This prevents normal chunked insertion and also prevents clearing and repopulating with a different cardinality. Could sizing capacity be fixed independently while allowing arbitrary add-batch sizes?
There was a problem hiding this comment.
Fixed as a consequence of above comment
| rmm::device_uvector<std::uint8_t> hits(dataset_rows, stream); | ||
|
|
||
| auto first_id = thrust::counting_iterator<key_type>(0); | ||
| impl_->filter.contains_async(first_id, first_id + dataset_rows, hits.data(), stream); |
There was a problem hiding this comment.
The default CAGRA path calls this estimate on every search, so each search allocates an N-byte buffer, probes every index row, and synchronizes before ANN search begins, that could be costly, no? Could we cache an estimate invalidated by add/clear, retain insertion metadata, or use bounded sampling instead?
There was a problem hiding this comment.
Also fixed by bounding to size ahead of time in the constructor. Very, very good catch.
| RAFT_LOG_INFO("Bloom filter index recall = %f", bloom_index_recall); | ||
| bloom_recalls.push_back(bloom_recall); | ||
| } | ||
| if (bloom_recalls.size() > 1) { |
There was a problem hiding this comment.
Should we make every Bloom run assert that each returned ID is accepted by global_bloom_filter.contains, and enforce an absolute recall floor against the exact filtered ground truth?
Currently the normal one-FPR path makes no assertions, and the 4096-block floor means the FPR sweep does not exercise different filter sizes.
There was a problem hiding this comment.
This brings me to another question: should we just remove num_blocks from the constructor? I think so. We can always just infer it for the user and make the API less confusing to use.
| */ | ||
|
|
||
| enum class FilterType { None, Bitmap, Bitset, UDF }; | ||
| enum class FilterType { None, Bitmap, Bitset, Bloom, UDF }; |
There was a problem hiding this comment.
Would it make sense to append Bloom after UDF, or assign explicit values, so the existing UDF ordinal does not change? The C++ API is not the stable ABI boundary, so this is not necessarily blocking, but preserving the value is inexpensive.
There was a problem hiding this comment.
Ah, let me bound this by giving them actual values also then to tighten this for the future.
| [[nodiscard]] std::size_t num_blocks() const noexcept; | ||
| [[nodiscard]] float estimate_filtering_rate(raft::resources const& res, | ||
| std::size_t dataset_rows) const; | ||
| [[nodiscard]] impl const& get_impl() const noexcept; |
There was a problem hiding this comment.
Could the internal factory be made a friend instead? Exposing impl through the public API weakens the intended opacity and couples the core wrapper to neighbors internals.
There was a problem hiding this comment.
I went down the friend class route, and did not like the idea of forward declaring an internal type in a public header to then mark it as friend. Good idea to just declare a friend function instead, I did not think of that.
|
|
||
| bloom_filter() = default; | ||
|
|
||
| explicit bloom_filter(const cuvs::core::bloom_filter& bloom_filter) |
There was a problem hiding this comment.
The referenced core filter must outlive the adapter and all searches, and must not be moved or mutated concurrently, right? The stored pointer otherwise becomes invalid or refers to a moved-from object without a diagnostic, so we should document this if I'm right.
There was a problem hiding this comment.
Yes, I'll add the note but it is a problem with all our filters as we separate the implementation from the interface.
@dantegd good question! Perhaps it is for yourself and Ben (I can't find his @) to answer? I'll explicitly reject it for now. |
No description provided.