Skip to content

Promote filtering_rate to base search_params; honor it in brute_force - #2120

Open
maxwbuckley wants to merge 5 commits into
NVIDIA:mainfrom
maxwbuckley:filtering-rate-base-search-params
Open

Promote filtering_rate to base search_params; honor it in brute_force#2120
maxwbuckley wants to merge 5 commits into
NVIDIA:mainfrom
maxwbuckley:filtering-rate-base-search-params

Conversation

@maxwbuckley

Copy link
Copy Markdown
Contributor

Summary

  • filtering_rate previously lived on cagra::search_params only. Moving it to the base cuvs::neighbors::search_params makes it inheritable by every algorithm's search_params and removes the duplicate-per-algorithm pattern that would follow if other prefiltered paths wanted the same knob.
  • Plumbs brute_force::search_params through detail::search and brute_force_search_filtered. When params.filtering_rate >= 0, the hint is used directly as sparsity and the per-search bitset_view::count(res) popcount + host sync is skipped on the dense path. The sparse CSR path still needs an exact non-zero count to size the matrix, so popcount runs lazily there.
  • Algorithms that don't use the hint (ivf_flat, ivf_pq, hnsw) ignore it; their search_params inherit the field but nothing reads it.

Closes #1960.

cc @cjnolet — this is the cleanup for the popcount-per-search overhead from #1960.

Why

Both cagra::search (with default filtering_rate=-1.0) and brute_force::search (always) call bitset_view::count(res) on every search to derive selectivity. That's a GPU reduction kernel + host stream sync per call. Documented measured impact on RTX 5090 at 1M/d=128/50% pass rate is ~18% latency overhead for CAGRA; brute_force had no escape hatch at all because its search_params was an empty derivation of the base.

brute_force::search_params was already empty (struct search_params : cuvs::neighbors::search_params {};), so the field arrives via inheritance — no algorithm-specific addition needed.

What changed

File Change
cpp/include/cuvs/neighbors/common.hpp Add float filtering_rate = -1.0 to base search_params with docstring
cpp/include/cuvs/neighbors/cagra.hpp Remove duplicate field; inherited from base
cpp/src/neighbors/brute_force.cu Forward params through the 4 macro search() entry points
cpp/src/neighbors/detail/knn_brute_force.cuh Add params arg to detail::search and brute_force_search_filtered; honor hint when >= 0, lazy popcount on CSR path
cpp/tests/neighbors/brute_force_prefiltered.cu Add ResultWithFilteringRateHint test cases for bitmap and bitset fixtures (float and half) reusing the existing parameter matrix

Test plan

  • cpp/src/neighbors/brute_force.cu compiles cleanly
  • cpp/tests/neighbors/brute_force_prefiltered.cu compiles cleanly
  • Run NEIGHBORS_TEST to confirm ResultWithFilteringRateHint matches Result across all parameter cases
  • Run existing CAGRA tests to confirm no regression from removing the field declaration (inheritance path)

Notes

  • ABI: base search_params was sizeof == 1 (empty); now sizeof == 4 (one float). All algorithm-specific search_params inherit, so their layout grows by 4 bytes. Appropriate for a release-cycle change.
  • No C/Python binding changes needed — filtering_rate is currently only referenced from C++.
  • Behavior identical when filtering_rate < 0 (default): same popcount, same downstream logic.

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented May 23, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added optional filtering_rate hint parameter to neighbor search operations. Users can now provide a sparsity/selectivity hint to optimize filtered search performance by avoiding unnecessary GPU synchronization and computation.
  • Bug Fixes

    • Consolidated filtering_rate field to shared base search parameters to eliminate duplication across search algorithms.

Walkthrough

This PR introduces an optional filtering_rate hint in the base search_params struct to avoid expensive GPU popcount + stream synchronization for filter selectivity auto-detection. CAGRA and brute_force implementations are updated to inherit and thread the hint through, using it to defer exact nnz computation when provided. Tests validate the hint path.

Changes

Filtering Rate Hint Optimization

Layer / File(s) Summary
Base search_params contract
cpp/include/cuvs/neighbors/common.hpp
search_params gains filtering_rate field (default -1.0) with documentation explaining how algorithms use it to skip auto-detecting filter selectivity, avoiding GPU reduction + sync overhead.
CAGRA and brute_force API adoption
cpp/include/cuvs/neighbors/cagra.hpp, cpp/src/neighbors/brute_force.cu
CAGRA removes local filtering_rate to inherit from base. Brute_force macro-generated search overloads thread params to detail implementation.
Brute_force detail: hint-based lazy nnz
cpp/src/neighbors/detail/knn_brute_force.cuh
brute_force_search_filtered accepts params and uses filtering_rate as hint to defer nnz computation; exact count is computed only when hint is not provided, avoiding popcount cost.
Filtering_rate hint test coverage
cpp/tests/neighbors/brute_force_prefiltered.cu
Bitmap and bitset test classes gain RunWithFilteringRateHint() methods that validate search correctness when explicit hint equals test sparsity; new TEST_P registrations cover float and half types.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

improvement, non-breaking

Suggested reviewers

  • irina-resh-nvda
  • tfeher
  • achirkin
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: promoting filtering_rate from cagra::search_params to the base search_params and implementing its use in brute_force search.
Description check ✅ Passed The PR description is comprehensive and directly related to the changeset, explaining the motivation, changes, and test plan for moving filtering_rate to base search_params.
Linked Issues check ✅ Passed The PR addresses the core objective from #1960 by providing a mechanism to skip per-call popcount when filtering_rate >= 0 is pre-set. Changes include: (1) adding filtering_rate to base search_params, (2) implementing the hint in brute_force with lazy popcount on CSR path, (3) adding tests to validate the hint behavior.
Out of Scope Changes check ✅ Passed All changes align with stated objectives. File modifications are: base search_params API extension, cagra.hpp field removal, brute_force.cu parameter threading, detail/knn_brute_force.cuh hint implementation, and prefiltered tests extension. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
cpp/tests/neighbors/brute_force_prefiltered.cu (1)

1084-1130: ⚡ Quick win

Add one high-sparsity hint case to cover the lazy-nnz CSR branch.

Current inputs top out at sparsity = 0.5, so RunWithFilteringRateHint() never exercises the new sparsity >= 0.9 path where exact nnz is computed lazily. Adding at least one > 0.9 case for bitmap and bitset would close this gap.

🤖 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 `@cpp/tests/neighbors/brute_force_prefiltered.cu` around lines 1084 - 1130, Add
at least one test entry to the selectk_inputs vector that triggers the lazy-nnz
CSR branch by using a sparsity >= 0.9; update the PrefilteredBruteForceInputs
initializer list (selectk_inputs) to include a case with high sparsity (e.g.
0.95) so RunWithFilteringRateHint() exercises the sparsity >= 0.9 path (add one
for bitmap/bitset coverage if your test matrix type selection is driven by the
distance/type field such as cuvs::distance::DistanceType::L2Expanded or
InnerProduct).
🤖 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 `@cpp/src/neighbors/detail/knn_brute_force.cuh`:
- Around line 623-627: The code currently enables hint mode for any non-negative
params.filtering_rate; change this to only enable hint mode when filtering_rate
is in [0.0, 1.0) by replacing the use_hint initialization with an explicit range
check (e.g., const bool use_hint = (params.filtering_rate >= 0.0f &&
params.filtering_rate < 1.0f)); additionally add an explicit validation branch
near that declaration (in the knn_brute_force.cuh scope where use_hint and
params.filtering_rate are used) that treats values >= 1.0f as invalid—either
disable the hint and emit a clear error/exception (e.g., throw
std::invalid_argument or return an error) or log a warning before proceeding—so
out-of-contract inputs do not silently alter path selection.

---

Nitpick comments:
In `@cpp/tests/neighbors/brute_force_prefiltered.cu`:
- Around line 1084-1130: Add at least one test entry to the selectk_inputs
vector that triggers the lazy-nnz CSR branch by using a sparsity >= 0.9; update
the PrefilteredBruteForceInputs initializer list (selectk_inputs) to include a
case with high sparsity (e.g. 0.95) so RunWithFilteringRateHint() exercises the
sparsity >= 0.9 path (add one for bitmap/bitset coverage if your test matrix
type selection is driven by the distance/type field such as
cuvs::distance::DistanceType::L2Expanded or InnerProduct).
🪄 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: 73fd1e69-bb28-41e6-8152-51e18206fd9f

📥 Commits

Reviewing files that changed from the base of the PR and between 4addf4e and 71decfc.

📒 Files selected for processing (5)
  • cpp/include/cuvs/neighbors/cagra.hpp
  • cpp/include/cuvs/neighbors/common.hpp
  • cpp/src/neighbors/brute_force.cu
  • cpp/src/neighbors/detail/knn_brute_force.cuh
  • cpp/tests/neighbors/brute_force_prefiltered.cu

Comment thread cpp/src/neighbors/detail/knn_brute_force.cuh Outdated
@aamijar aamijar added non-breaking Introduces a non-breaking change improvement Improves an existing functionality labels May 26, 2026
@aamijar aamijar moved this to In Progress in Unstructured Data Processing May 26, 2026
`filtering_rate` (a hint of the fraction of items filtered out by the
sample filter) previously lived on `cagra::search_params` only. CAGRA
used it to size `itopk_size` and, when the user left it at its default
of `-1.0`, called `bitset_view::count(res)` on every search — a GPU
popcount reduction + host sync that adds measurable latency.

`brute_force` filtered search also called `bitset_view::count(res)` on
every search to compute `sparsity` (used to choose between the dense
tiled GEMM path and the sparse CSR path), but had no user-facing knob
to skip the auto-detection.

This change:

- Moves `float filtering_rate = -1.0` from `cagra::search_params` to
  the base `cuvs::neighbors::search_params`. `cagra::search_params`
  inherits it; existing code that accesses `params.filtering_rate`
  is unaffected.
- Plumbs `brute_force::search_params` through `detail::search` and
  `brute_force_search_filtered`. When `params.filtering_rate >= 0`,
  the hint is used directly as `sparsity` and the per-search popcount
  is skipped on the dense path. The CSR path still needs an exact
  non-zero count to size the matrix, so popcount runs lazily there.
- Algorithms that don't use the hint (`ivf_flat`, `ivf_pq`, `hnsw`)
  ignore the new base field; their `search_params` inherit it but
  nothing reads it.

Tests: extends `brute_force_prefiltered.cu` with
`ResultWithFilteringRateHint` cases for both bitmap and bitset
fixtures (float and half), reusing the existing parameter matrix and
asserting that results match the auto-detect path.

Closes NVIDIA#1960.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The base search_params docstring already defines the contract: negative means
"auto-detect from the filter", and [0.0, 1.0) is trusted as a hint. The code
only checked `>= 0.0f`, so an out-of-contract value (e.g. a caller passing a
percentage like 50.0, or 1.5) was silently accepted as `sparsity`, forcing the
sparse CSR path plus its popcount -- slower than not setting the hint at all,
with no diagnostic.

Enforce the documented range with RAFT_EXPECTS. NaN is rejected too, since it
compares false against both bounds. Behavior for in-contract values, including
the negative default, is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maxwbuckley

Copy link
Copy Markdown
Contributor Author

@lowener this is number 2 in my series after 1) the dot product normalization constant optimization and before 3) the scatter gather and threshold fixing

PrefilteredBruteForceInputs::sparsity is the density of kept entries
(n_edges = m * n * sparsity), while search_params::filtering_rate is
1 - nnz / (n_queries * n_dataset). RunWithFilteringRateHint() passed the
former as the latter, so the hint was the complement of the true sparsity
and every hinted run took the dense path -- the lazy-nnz CSR branch was
never exercised.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maxwbuckley

Copy link
Copy Markdown
Contributor Author

Pushed ce922ff, which fixes the filtering_rate hint in the prefiltered brute-force tests.

PrefilteredBruteForceInputs::sparsity is the density of kept entries (n_edges = m * n * sparsity, and cpu_convert_to_csr keeps set bits), while search_params::filtering_rate is 1 - nnz / (n_queries * n_dataset). RunWithFilteringRateHint() was passing the former as the latter, so the hint was the complement of the true sparsity. Since the inputs cap out at 0.5 density, every hinted run got a hint below the 0.9 threshold and took the dense path — the lazy-nnz CSR branch was never exercised. It passed anyway because the hint only steers path selection, not correctness.

With the sign fixed, the existing sparsity = 0.01 inputs now hint 0.99 and cover CSR + lazy nnz, while the 0.2/0.4/0.5 inputs still cover dense. That also addresses the CodeRabbit note about missing > 0.9 coverage without adding new inputs, and makes the hint agree with what auto-detection would compute — which is what the method's comment already claimed.

Also bumped the file's SPDX year to 2026; it was the only one of the five touched files still at 2025, so verify-copyright would have failed check-style.

@lowener this PR hasn't had a CI run yet — could I get an /ok to test ce922ff?

@maxwbuckley

Copy link
Copy Markdown
Contributor Author

@lowener merged main in (8ade4ff) — the branch had fallen a long way behind, and it's now up to date with base. The merge was clean and the net diff is unchanged (5 files, +215/-23); I also compile-checked brute_force.cu against the merged tree locally with the project's warnings-as-errors flags.

This PR still hasn't had a CI run at all — could I get an /ok to test 8ade4ff, and a review when you have a moment? This is #2 in the series: #2128 (dot-product normalization) is merged, and #2321 (scatter-gather + dispatch thresholds) is approved and waiting on the same gate. Thanks!

rapids-bot Bot pushed a commit that referenced this pull request Aug 10, 2026
…spatch thresholds (#2321)

## The problem, in one picture

`brute_force_search_filtered` chooses between a sparse (SDDMM) path and a dense post-filter path on one hardcoded test, `sparsity < 0.9f` — it runs SDDMM whenever **≤10% of the dataset passes the filter**.

But SDDMM's cost climbs with the number of passing rows, while the dense path's is flat (it always does the full GEMM, then masks). The two curves cross long before 10%. cuVS rides the sparse path right past the crossover:

![cost of each path](https://raw.githubusercontent.com/maxwbuckley/cuvs/7cabb9c35bf8a4719e88ab955780ea5ee7688501/pr_1_cliff.png)

The pink track is what cuVS actually runs. At 10M×128 it is **12.6× slower than necessary** at the moment just before it finally gives up on SDDMM.

The green curve is the path this PR adds.

## Two things are wrong, not one

**1. The threshold is in the wrong place.** The true SDDMM/dense crossover is ~3%, not 10%. By the time the current rule hands off, SDDMM is already 2.2–4.4× slower than simply going dense.

**2. The threshold is the wrong _kind_.** SDDMM competes against a fixed setup cost, not against work that scales with the dataset, so it stops paying off at an **absolute number of passing rows** — ~10k, roughly constant across the sweep. Expressed as a *fraction*, the correct handoff therefore **falls as the dataset grows**. At 10M rows it is ~0.1%, where the current rule waits until 10%: we keep SDDMM running up to 1,000,000 passing rows when it should hand off at ~10,000. **The error grows with the dataset.**

## Where each path actually wins

![regime map](https://raw.githubusercontent.com/maxwbuckley/cuvs/7cabb9c35bf8a4719e88ab955780ea5ee7688501/pr_2_regimes.png)

Blue is all that SDDMM should own. Everything left of the red line is what it owns today. The band between them — most of 0.1–10% — belongs to neither of the two paths cuVS currently has.

## The change

Three paths, chosen by `select_filtered_search_path()`:

- **`sddmm`** — unchanged; entered only below ~10k passing rows.
- **`gather`** (new) — enumerate the passing rows, compact them (and their precomputed norms) into a dense `[n_pass x dim]` matrix, run the ordinary **unfiltered** search over it, map indices back. Cost scales with `n_pass` instead of `n_dataset`.
- **`dense`** — unchanged; entered above the gather/dense crossover.

Built from existing primitives — `thrust::copy_if` over `bitset_view::test`, `raft::matrix::gather`, `brute_force_knn_impl`, `thrust::gather`. No new kernels.

The gather path applies to **bitset** filters only, which pass the same rows for every query. A **bitmap** passes a different set per query, so there is no single set of rows to compact; bitmaps keep a two-way dispatch, but with the corrected ~3% threshold instead of the flat 10%.

## What it costs today

![cost heatmap](https://raw.githubusercontent.com/maxwbuckley/cuvs/7cabb9c35bf8a4719e88ab955780ea5ee7688501/pr_3_cost.png)

Penalty against an oracle that always picks the fastest of the three paths:

| dispatch rule | mean | p90 | worst |
|---|---|---|---|
| `sparsity < 0.9` (today) | 1.65× | 5.29× | **12.56×** |
| this PR | **1.01×** | 1.00× | 1.49× |

## Tests

`NEIGHBORS_TEST --gtest_filter='*Prefiltered*'`: **180/180 pass.**

Six params were added to `selectk_inputs` at `n_dataset=300000, dim=128`, sized to land on each dispatch path. Since these tests check results against a CPU oracle and would pass just as well if the new path never ran, I instrumented the dispatch and confirmed each one is actually reached:

| test | selectivity | passing rows | path taken |
|---|---|---|---|
| bitset Result/0 | 0.10% | 300 | `SDDMM` |
| bitset Result/1–4 | 7.15% | 21,439 | **`GATHER`** |
| bitset Result/5 | 21.0% | 63,076 | `DENSE` |
| bitmap Result/0 | 0.10% | — | `SDDMM` |
| bitmap Result/1–5 | 7.0–20% | — | `DENSE` |

The four `GATHER` cases cover L2Expanded, InnerProduct, L2SqrtExpanded and CosineExpanded — the last exercising the gathered-norms path — and all agree with the CPU oracle. Bitmaps correctly carry no passing-row count and never gather.

## On the constants

They are empirical, and I want to be plain that they are not derived from first principles. I tried to build a cost model (dense GEMM + gather traffic + select_k) so the `dim` and `n_dataset` dependence would fall out of hardware properties; solving it against the measured crossovers produces a **negative** gather-traffic coefficient, i.e. the model is wrong. So these are fits and I have not dressed them up as anything else.

Instead I kept them **round and checked that they tolerate being wrong**. Perturbing any constant by 2× — roughly the spread I would expect across GPUs — leaves the mean penalty at 1.01–1.05× and the worst case at or below 2.7×. Still far better than the rule it replaces, which is 12.6× worst case *on the hardware it was presumably tuned for*. An earlier revision of this branch used 3-significant-figure power-law fits (`dim^0.4`, `dim^0.6`, …); they scored **identically** (1.01× mean), so the precision bought nothing and I removed it.

If maintainers would rather these be tunable via `search_params`, or auto-calibrated once per device, both are easy from here.

**Method:** fp16, L2Expanded, k=64, 100 queries, shared bitset; selectivity 0.1–60% × `n_dataset` ∈ {200K, 500K, 1M, 3M, 10M} × `dim` ∈ {128, 256, 512, 1024}; 100 reps on an idle RTX 5090 (sm_120), medians (cross-validated against an independent sweep to within 1.6–2.5%).

## Known gaps

1. **The thresholds are calibrated for `dim >= 128` and `n_dataset >= 200K`.** cuVS runs well below both — this repo's own prefiltered tests use `dim` from 1 to 2052 at `n_dataset=8192`. The fits are clamped to the measured range rather than extrapolated, but that corner is unmeasured. All 180 tests pass there; I simply cannot claim the *choice* is optimal.
2. **One pre-existing test config changes path**: `n_dataset=8192, dim=8, 10% density` moves SDDMM → dense. Both paths are correct (the test passes), but whether that flip is a fix or a regression is exactly what (1) has no data for.
3. **Bitmaps were never benchmarked.** The ~3% sparse/dense threshold is carried over from bitset measurements. The cost structure is the same shape, but a bitmap's CSR touches different columns per query, so SDDMM's locality is worse and the true crossover is probably *lower* than 3% — the error is in the safe direction, but it is inferred, not measured.
4. Single GPU architecture.
5. Touches the same function as #2120 (`filtering_rate` → base `search_params`). No conflict against `main` today, but the two will need reconciling if both land.

## Possible follow-up

A bitmap could get a gather path too, by compacting the **union** of all queries' passing rows and applying the per-query mask inside that smaller matrix. It degenerates to exactly this PR's path when the filter is a bitset. Whether it pays off depends on how much the queries' filters overlap, so it needs its own benchmark — out of scope here.

Authors:
  - Max Buckley (https://github.com/maxwbuckley)
  - Corey J. Nolet (https://github.com/cjnolet)
  - Micka (https://github.com/lowener)

Approvers:
  - Micka (https://github.com/lowener)

URL: #2321
…ispatch

NVIDIA#2321 replaced the single `sparsity < 0.9` dense/CSR split with
`select_filtered_search_path`, which chooses between sddmm, gather and dense
and needs two exact quantities: `nnz_h` to size the CSR, and `n_pass` (bitset
row count) to size the compacted dataset.

Resolution keeps this PR's rule that a supplied `filtering_rate` must not cost
a popcount, and adapts it to three paths instead of two:

- The hint now feeds `selectivity` (= 1 - filtering_rate) rather than the old
  `sparsity`, and for a bitset also supplies an estimated `n_pass` so path
  selection has a row count to work with.
- Only the dense path can run on the hint alone. If the chosen path is sddmm
  or gather, popcount lazily and then re-run the decision with the exact
  numbers, so a bad hint can never route into gather with an `n_pass` that
  under-runs `k`. The dense path — the common case, and the one the hint was
  added for — still skips the kernel and the host sync.
- Drops this PR's old lazy-popcount block inside the CSR branch, now
  subsumed by the single lazy count at the dispatch site.

The hint tests inherit NVIDIA#2321's new `selectk_inputs` entries, so
`ResultWithFilteringRateHint` now covers all three paths rather than just
dense and CSR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement Improves an existing functionality non-breaking Introduces a non-breaking change

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

CAGRA search: default filtering_rate=-1.0 adds hidden popcount kernel + host sync on every call

3 participants