Skip to content

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

Open
maxwbuckley wants to merge 1 commit into
NVIDIA:mainfrom
maxwbuckley:perf/sddmm-drop-duplicate-csr-conversion
Open

perf(brute_force): drop the duplicate bitset/bitmap→CSR conversion on the SDDMM path#2505
maxwbuckley wants to merge 1 commit 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.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant