perf(brute_force): drop the duplicate bitset/bitmap→CSR conversion on the SDDMM path - #2505
Conversation
…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>
|
@lowener would you mind taking a look? This is the same corner of It sits inside the The one thing worth a reviewer's eye is the assumption the change rests on: that (No permission to add reviewers from a fork, hence the mention.) |
The problem
On the sparse (SDDMM) branch of
brute_force_search_filtered, the filter is converted to CSR twice per search:masked_matmulbuilds its own CSR over the caller's structure view and callsbitset_to_csr/bitmap_to_csron it:Because
C_matrixis sparsity-preserving, it sharesindptr/indiceswith the CSR cuVS just filled. So (2) recomputes the identical structure over the top of (1). The second conversion is not an implementation detail we are relying on by accident — it ismasked_matmul's documented job, and RAFT's ownmasked_matmultest passes aCwhose structure is pre-populated from a CPU reference and depends on it being overwritten.This removes cuVS's call and lets
masked_matmuldo the conversion once.Evidence that both conversions really ran
nsysover 21 SDDMM-path searches (1M x 128, 0.5% selectivity, RTX 5090, sm_120), launch counts before → after:calc_nnz_by_rows_kernelfill_indices_by_rows_kernel<check_nnz=false>fill_indices_by_rows_kernel<check_nnz=true>repeat_csr_kernelDeviceScanKernel)cusparse::sddmm_kercsr_to_coo_kernelEvery conversion kernel halves.
sddmm_kerandcsr_to_cooare unchanged, i.e. no actual work is removed. Thecheck_nnz=falseinstantiation is the sparsity-owning one, which only cuVS'sto_csrcall could produce; it disappears entirely, leavingmasked_matmul's sparsity-preserving conversion.A host sync goes with it
to_csron a sparsity-owning matrix copies the computednnzback to the host and syncs the stream before sizing the matrix. The conversion insidemasked_matmulruns on a sparsity-preserving matrix and takes neither branch. 8-byte device-to-host copies over 29 filtered searches drop from 50 to 29 — exactly the 21 removed readbacks.Measurements
Paired A/B on a rebuilt
libcuvs.so, 10 alternating rounds x 200 reps per config, dedicated idle RTX 5090, fp16 / L2Expanded / k=64 / 100 queries / shared bitset. CIs are Student-t over the 10 paired round-medians.All 12 SDDMM-path configs improve with the interval excluding zero.
The saving is a roughly fixed ~150 us per search, not a ratio, so it is largest where the search is cheapest — which is where the SDDMM path is supposed to live. Post-#2321 the sparse path is entered below ~2000 passing rows, i.e. squarely in the region where a fixed 150 us is a large fraction of the total.
Null control. Dense-path configs cannot be affected by this change and are flat, as they must be:
Every interval spans zero. An earlier 3-round pass showed apparent ±2 ms swings at 10M x 1024 in both arms including the null control, so that config is drift-dominated on this host and is excluded rather than reported.
What the saving is not
I initially read the ~150 us as the removed host sync. It is not. Feeding the popcount from an environment variable to remove the other per-search sync (
bitset_view::count), with results bit-identical, is worth median +0.0 us, 0 of 15 configs significant. A sync on an already-idle stream costs nothing —count()runs before anything is queued. The win here is the removed conversion work plus the pipeline bubble from draining the stream immediately before a block of host-side cuSPARSE setup, which the sync into_csrsits right in front of.Correctness
Top-k ids and distances are bit-identical to the unpatched build across 14 configs (dim ∈ {128, 1024} x selectivity ∈ {0.1%, 0.5%, 1%, 3%, 5%, 10%, 25%}), spanning both dispatch paths.
NEIGHBORS_TEST --gtest_filter='*Prefiltered*': 156/156 pass (4 suites x 39 parameterisations). To be precise about what that run was: it is the branch I benchmarked on, whose checkout predates #2321, so it has 39 parameterisations wheremainhas 45. The 6 extra ones onmainare the ones #2321 added to reach the gather and dense paths, which this change does not touch. The change itself compiles clean againstmain.Compiles clean against
mainwith the build's own flags, which include-Werror=all-warnings.Related, not included
masked_matmulalso allocatesnnz * sizeof(output_t)forC_matrix's element buffer that nothing ever reads — bothsddmmandfaster_dot_on_csrwrite throughC.get_elements(). That is a RAFT-side fix and is memory-only (no measurable latency change), so it is not part of this PR; happy to open it against RAFT if useful.Method
RTX 5090 (sm_120), CUDA 12.9, WSL2, dedicated idle GPU. Each arm is a separately built
libcuvs.soswapped viaLD_LIBRARY_PATH; arms alternate within every round and every (N, dim) block so drift hits both equally.