Skip to content

perf(brute_force): drop the duplicate bitset/bitmap→CSR conversion on the SDDMM path - #2505

Merged
rapids-bot[bot] merged 4 commits into
NVIDIA:mainfrom
maxwbuckley:perf/sddmm-drop-duplicate-csr-conversion
Oct 20, 2026
Merged

rapids-bot[bot] merged 4 commits into
NVIDIA:mainfrom
maxwbuckley:perf/sddmm-drop-duplicate-csr-conversion

Conversation

@maxwbuckley

@maxwbuckley maxwbuckley commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

The problem

On the sparse (SDDMM) branch of brute_force_search_filtered, the filter is converted to CSR twice per search:

auto csr = raft::make_device_csr_matrix<DistanceT, IdxT>(res, n_queries, n_dataset, nnz_h);
std::visit([&](const auto& actual_view) { actual_view.to_csr(res, csr); }, *filter_view);   // (1)
...
raft::sparse::linalg::masked_matmul(res, queries, dataset_view, actual_view, csr_view);     // (2)

masked_matmul builds a sparsity-preserving CSR over the caller's structure view and runs bitset_to_csr on it, which writes through to the same indptr/indices cuVS just filled:

auto C_matrix = raft::make_device_csr_matrix<output_t, index_t>(handle, compressed_C_view);
raft::sparse::convert::bitset_to_csr(handle, mask, C_matrix);

So (2) recomputes (1), identically.

The change

Drop cuVS's to_csr call and let masked_matmul do the conversion once.

Both conversions really were running

nsys over 21 SDDMM-path searches (1M x 128, 0.5% selectivity), launch counts before → after:

kernel before after
calc_nnz_by_rows_kernel 42 21
fill_indices_by_rows_kernel<check_nnz=false> 21 0
fill_indices_by_rows_kernel<check_nnz=true> 21 21
repeat_csr_kernel 42 21
exclusive-scan (DeviceScanKernel) 42 21
CSR value fill 42 21
cusparse::sddmm_ker 21 21
csr_to_coo_kernel 21 21

Every conversion kernel halves while sddmm_ker and csr_to_coo are unchanged, i.e. no actual work is removed. check_nnz=false is the sparsity-owning instantiation, which only cuVS's to_csr could produce; it disappears entirely.

A host sync goes with it: to_csr on a sparsity-owning matrix reads nnz back to the host to size the matrix, which the sparsity-preserving path inside masked_matmul never does. 8-byte device-to-host copies over 29 searches drop from 50 to 29.

Why relying on masked_matmul is safe

Same problem run twice, once with C's structure pre-filled correctly and once with a valid but deliberately wrong pattern (same row counts, every column index 0):

m=4   nnz=112  path=faster_dot_on_csr  indptr MATCH  indices MATCH  values MATCH
m=16  nnz=448  path=sddmm             indptr MATCH  indices MATCH  values MATCH

Bit-identical on both dispatch branches, so masked_matmul unconditionally derives C's sparsity from the mask. This is deliberate in RAFT — bitset_to_csr carries a constexpr bool check_nnz = is_device_csr_sparsity_preserving_v<csr_matrix_t>; branch for exactly this case — but it is not documented: masked_matmul marks C [inout] only with respect to its values, and the sparsity-preserving CSR it constructs is documented as "allowing the values in the sparsity to change but not the sparsity itself". Happy to hold this PR if reviewers would rather a RAFT doc fix landed first.

(An earlier revision of this description said RAFT's masked_matmul test depends on the overwrite. It does not — the test builds C's structure from the same mask it passes in, so it cannot discriminate. Hence the experiment above.)

Measurements

Paired A/B, arms alternating within every round and every (N, dim) block so drift hits both equally: 10 rounds x 200 reps per config, separately built libcuvs.so swapped via LD_LIBRARY_PATH, dedicated idle RTX 5090 (sm_120, CUDA 12.9, WSL2), fp16 / L2Expanded / k=64 / 100 queries / shared bitset. CIs are Student-t over the 10 paired round-medians.

N dim selectivity baseline this PR speedup saving (95% CI) t
1M 128 0.1% 0.383 ms 0.238 ms 1.61x +136 us [93, 180] 7.1
1M 128 0.5% 0.548 ms 0.407 ms 1.35x +138 us [106, 170] 10.9
1M 128 1.0% 0.708 ms 0.579 ms 1.22x +133 us [115, 151] 12.7
1M 128 3.0% 1.409 ms 1.272 ms 1.11x +139 us [123, 155] 18.4
1M 1024 0.1% 0.468 ms 0.323 ms 1.45x +146 us [109, 183] 9.3
1M 1024 0.5% 0.783 ms 0.663 ms 1.18x +129 us [105, 153] 12.3
1M 1024 1.0% 1.239 ms 1.082 ms 1.15x +147 us [125, 168] 14.8
1M 1024 3.0% 4.643 ms 4.467 ms 1.04x +169 us [124, 214] 6.7
10M 128 0.1% 0.750 ms 0.589 ms 1.27x +158 us [134, 182] 15.9
10M 128 0.5% 2.491 ms 2.338 ms 1.07x +152 us [139, 165] 26.5
10M 128 1.0% 4.707 ms 4.531 ms 1.04x +201 us [140, 261] 8.2
10M 128 3.0% 14.084 ms 13.760 ms 1.02x +335 us [252, 419] 7.9

All 12 SDDMM-path configs improve with the interval excluding zero. The saving is a roughly fixed ~150 us per search rather than a ratio, so it is largest where the search is cheapest — which post-#2321 is exactly where the sparse path lives, below ~2000 passing rows.

Null control. Dense-path configs cannot be affected and are flat: +1.8 us [-11.3, +15.0] at 1M/128/25%, -57.6 us [-134.0, +18.9] at 1M/1024/25%, +96.7 us [-277.8, +471.1] at 10M/128/25%. All three span zero. 10M x 1024 is drift-dominated on this host (±2 ms swings in both arms, including the control) and is excluded rather than reported.

The saving is not the removed sync. Removing the other per-search sync (bitset_view::count, fed instead from an environment variable, results bit-identical) is worth median +0.0 us, 0 of 15 configs significant — a sync on an already-idle stream costs nothing. The win is the removed conversion work plus the pipeline bubble from draining the stream immediately before a block of host-side cuSPARSE setup.

Correctness

RAFT-side follow-ups, not in this PR

  • masked_matmul allocates nnz * sizeof(output_t) for C_matrix's element buffer that nothing ever reads — both sddmm and faster_dot_on_csr write through C.get_elements(). Memory-only, no measurable latency change.
  • masked_matmul should document that it overwrites C's sparsity pattern from the mask, and its test should pass a structure that differs from the expected one so it actually covers the behaviour relied on here.

…n the SDDMM path

brute_force_search_filtered builds the CSR structure from the filter via to_csr,
then hands the same CSR to raft::sparse::linalg::masked_matmul, which converts
the mask into that structure again. masked_matmul's temporary is
sparsity-preserving, so it shares indptr/indices with the CSR cuVS just filled
and writes identical values over it. Every SDDMM-path search ran the conversion
twice.

nsys over 21 SDDMM searches (1M x 128, 0.5% selectivity, RTX 5090), before ->
after: calc_nnz_by_rows 42 -> 21, repeat_csr 42 -> 21, exclusive-scan 42 -> 21,
value fill 42 -> 21, and fill_indices_by_rows<check_nnz=false> -- the
sparsity-owning instantiation only cuVS's to_csr could produce -- 21 -> 0.
sddmm_ker and csr_to_coo stay at 21, so no real work is removed.

Dropping cuVS's call also removes a host sync per search: to_csr on a
sparsity-owning matrix reads the computed nnz back to the host and syncs the
stream, while masked_matmul's conversion runs on a sparsity-preserving matrix
and takes neither branch. 8-byte device-to-host copies per 29 filtered searches
fall from 50 to 29 -- exactly the 21 removed readbacks.

Paired A/B on a rebuilt libcuvs.so, 10 alternating rounds x 200 reps, dedicated
idle RTX 5090, fp16/L2Expanded/k=64/100 queries. The saving is a roughly fixed
~150 us per search, so it is largest where the search is cheapest:

  N     dim   sel     baseline    patched    speedup   saving (95% CI)
  1M    128   0.1%    0.383 ms    0.238 ms   1.61x     +136 us [ 93, 180]
  1M   1024   0.1%    0.468 ms    0.323 ms   1.45x     +146 us [109, 183]
  1M    128   1.0%    0.708 ms    0.579 ms   1.22x     +133 us [115, 151]
  10M   128   0.1%    0.750 ms    0.589 ms   1.27x     +158 us [134, 182]
  10M   128   3.0%   14.084 ms   13.760 ms   1.02x     +335 us [252, 419]

All 12 SDDMM-path configs improve with the interval excluding zero (t = 6.7 to
26.5). Three dense-path configs act as a null control and are flat, as they
must be: +1.8 us, -57.6 us, +96.7 us, every interval spanning zero.

Top-k ids and distances are bit-identical to the previous build across 14
configs spanning both dispatch paths, and the prefiltered brute-force tests
pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maxwbuckley
maxwbuckley requested a review from a team as a code owner August 25, 2026 17:14
@copy-pr-bot

copy-pr-bot Bot commented Aug 25, 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.

@maxwbuckley

Copy link
Copy Markdown
Contributor Author

@lowener would you mind taking a look? This is the same corner of knn_brute_force.cuh as #2321 and #2128, which you reviewed.

It sits inside the sddmm branch whose entry conditions #2321 rewrote, and it does not touch that dispatch — the gather and dense paths are unchanged, and the three dense configs in the table are there as a null control precisely to show that.

The one thing worth a reviewer's eye is the assumption the change rests on: that masked_matmul populating the CSR structure from the mask is contract, not incidental. RAFT's own masked_matmul test passes a C whose structure is pre-filled from a CPU reference and relies on it being overwritten, which is what convinced me, but you would know better than I do whether that is something cuVS should depend on or whether the conversion belongs on the cuVS side with the RAFT one removed instead. Either direction removes the duplicate; I picked the one that needs no RAFT release.

(No permission to add reviewers from a fork, hence the mention.)

@maxwbuckley

Copy link
Copy Markdown
Contributor Author

@lowener friendly bump on this one when you have a moment. It's a single-file change in knn_brute_force.cuh: it drops the to_csr call on the SDDMM path that masked_matmul immediately redoes on the same buffers, so the filter is converted to CSR once per search instead of twice.

It still needs labels (improvement, non-breaking) and a /ok to test for CI to run. Happy to merge main in first if you'd prefer a fresh base. Thanks!

@lowener lowener added improvement Improves an existing functionality non-breaking Introduces a non-breaking change C++ labels Oct 13, 2026
@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/cuvs/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: 92a83781-567c-4fe0-9382-f75a1025449b
📥 Commits

Reviewing files that changed from the base of the PR and between dd10082 and 003d518.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/cuvs/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: 5434be9d-3a32-40fb-bbb9-477db0b4a9a6
📥 Commits

Reviewing files that changed from the base of the PR and between 5c74852 and dd10082.

📒 Files selected for processing (1)
  • cpp/src/neighbors/detail/knn_brute_force.cuh

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 10 remain after this review.

📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Sparse filtered searches no longer convert the filter to an intermediate format before matching. Available search options and public interfaces remain unchanged.

The SDDMM filtered-search path removes the filter-view conversion to CSR before masked_matmul. It asynchronously zeroes the CSR value buffer before the call and leaves CSR structure population to masked_matmul.

Changes: SDDMM Filtered Search

Layer / File(s) Summary
CSR structure population
cpp/src/neighbors/detail/knn_brute_force.cuh
The path no longer converts the filter view to CSR before masked_matmul. It asynchronously zeroes the CSR value buffer before the call. masked_matmul populates the CSR structure.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~8 minutes

Change: Refactor

Suggested reviewers: lowener, achirkin

Merge Risk: ⚪ Minimal · up to dd100

The filtered-search change has no identified issue that needs to be fixed before merge.

Pre-merge checks: Passed 5
Passed Passed checks (5 passed)
Check name Status Explanation
Title check Passed The title clearly identifies the main change: removing the duplicate bitset/bitmap-to-CSR conversion on the SDDMM path.
Description check Passed The description directly explains the duplicate conversion, the implementation change, performance measurements, correctness checks, and documented follow-up considerations.
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

// masked_matmul fills the CSR structure from the mask itself, so it is left uninitialized
// here; converting first would only run the same conversion twice.
auto csr = raft::make_device_csr_matrix<DistanceT, IdxT>(res, n_queries, n_dataset, nnz_h);
std::visit([&](const auto& actual_view) { actual_view.to_csr(res, csr); }, *filter_view);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

An initialization of csr.get_elements() might be needed to be on the safe side, sddmm might read it as it is not specified in cuSparse docs that it is not read when beta=0.
raft::linalg::zero(csr.get_elements().data(), csr.structure_view().get_nnz(), stream)

cuSPARSE does not document that SDDMM leaves the output values unread
when beta == 0, so zero the freshly allocated CSR values instead of
handing it uninitialized memory. Uses cudaMemsetAsync with a size_t
byte count because raft::linalg::zero takes an int, which nnz can exceed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maxwbuckley

Copy link
Copy Markdown
Contributor Author

@lowener Good call, thanks. The values are now zeroed before masked_matmul (dd10082). I used cudaMemsetAsync with a size_t byte count rather than raft::linalg::zero, since that takes an int count and nnz here is IdxT (int64), which can exceed it for large filters.

Locally on sm_120 the brute-force suites in NEIGHBORS_TEST pass (266 tests, including all 180 prefiltered bitmap/bitset cases that go through the SDDMM path).

@lowener lowener left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@lowener

lowener commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

/ok to test 003d518

@lowener

lowener commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

/merge

@rapids-bot
rapids-bot Bot merged commit c30aebc into NVIDIA:main Oct 20, 2026
118 of 119 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants