Status: current · RTX 4090 (sm_89), CUDA 13.2, /opt/dpcpp-cuda
Batched CSR spmm has two families: the native direct kernel and the cuSPARSE vendor call. This page gives the routing rule, the measurements the tuned/spmm.* tables rest on, the boundaries of the native window, and the vendor defects the test suite found. Selection is flat (Flat kernel selection). Every number is from one RTX 4090 (device 1, 128 SMs, 72 MB L2, 1008 GB/s DRAM roof), CUDA backend, in-order SYCL queue.
| spelling | implementation | can_run (correctness only) |
|---|---|---|
| direct | sycl_spmm::spmm_native_csr | CSR; the body for this transA compiled; one_spmm() shape checks; no heterogeneous B or C; batch >= 1 |
| vendor | backend::spmm_vendor (cuSPARSE, rocSPARSE, netlib) | a sparse vendor library and no heterogeneous dense B or C, minus two known-bad shapes: netlib with any transpose, and on CUDA complex transB = ConjTrans with one row of B (known-defects #17). The complex<double> N/N one-column refusal is gone (defect 13 fixed; spmm.cc:85-90) |
Selection is the first runnable entry of the nearest row of tuned/spmm.<device>.txt, keyed transA:exact transB:exact m:log nrhs:log batch:log. ConjTrans folds to T (src/ops/spmm/spmm.cc:33-39). The last resort is vendor, then direct. BATCHLAS_SPMM_ROUTE takes auto, native, vendor or direct. Any other spelling, or one can_run refuses for the call, throws.
direct has three bodies in src/ops/spmm/kernels/spmm_native.cc: the NoTrans gather (body 1), and a scale plus atomic scatter pair (bodies 0 and 2) for Trans and ConjTrans. The body is chosen in the launcher on transA. transA is part of the coverage variant_key (src/select/coverage.cc:39-45), so gather and scatter stay separable in scripts/route_diff.sh. Scale and scatter do not.
Every row of tuned/spmm.sm_89.txt and tuned/spmm.cpu.txt is transcribed from the predicate that preceded flat selection (untimed; provenance in tuned/README.md); tuned/spmm.sm_120.txt is deep-measured, with 24 transcribed rows left:
| rows | float, double, complex<double> | complex<float> |
|---|---|---|
| transA=N transB=N | direct, then vendor | direct, then vendor |
| transA=N transB=T | direct, then vendor | vendor, then direct |
| transA=T, either transB | vendor, then direct | vendor, then direct |
The ranking is identical at all 125 (m, nrhs, batch) cells of each block (m 1..65536, nrhs 1..64, batch 1..16384). SpmmTranscribedTable.RowsHoldTheTranscribedPreference (tests/spmm_candidates_tests.cc:845) pins it.
The tables have no batch, extent, is_gpu or nnz term. Each absence is a measured decision: the ranking is constant along that axis.
In a vendor-present build the change against the old router moves 65 decisions. All 65 are spmm, all transA = NoTrans, and all vendor:auto to native:direct. complex<float> moves only at transB = NoTrans. The other three types move at all three transB spellings.
Note: scripts/route_diff.sh compare reports 240 changed reached lines for this change. About 110 are backend = AUTO rows from unit-level shapes. experiments/sparse_spmm/route_census.py keys on the decision tuple and gives the 65.
The direct can_run terms (src/ops/spmm/spmm.cc:71-84) are correctness gates only: CSR format (:76), the capability flag of the body that will run (:79-80), one_spmm() (:44-59: matching extents and batch, positive lds, an offset stride of at least m + 1), no heterogeneous dense batch (:81-82), and batch >= 1 (:82). An empty batch returns before selection, so that term is never the reason a call has no route. A CSR view is never heterogeneous in the active_rows_ sense. Per-item nnz variation is handled exactly through the row offsets.
Deliberate absences:
direct needs no workspace (spmm.cc:114-120). spmm_buffer_size runs the same choice as the call, so a direct call never asks the vendor sizer.
benchmarks/spmm_benchmark.cc times K back-to-back spmm calls on one in-order queue, closed by one wait, on the host clock. Named cells are (m, nnz/row, nrhs, batch): L = (1024, 3, 2, 512) lanczos, M = (1024, 16, 12, 512) LOBPCG, S = (2048, 16, 25, 128) LOBPCG.
Reproducibility, worst ratio spread across two passes (t_native/t_vendor):
| pass pair | gate rows in both | worst spread | median spread | rows crossing 1.10 |
|---|---|---|---|---|
| pass1/pass2 | 380 | 1.114 | 1.0034 | 4 (all transA=Trans) |
| bnd1/bnd2 gather plane | 120 | 1.094 | 1.0041 | 0 |
| bnd1/bnd2 banded family | 144 | 1.147 | 1.0040 | 0 |
| bnd1/bnd2 scatter planes | 240 | 1.153 | 1.008 | 7 |
Warning: BATCHLAS_SPMM_ROUTE=vendor over spmm_tests gives 276 passed and 92 failed, all Backend::NETLIB. The 92 are the netlib transpose refusals, which the suite skips unless the pin is native. On Backend::CUDA the same pinned run is 184/184.
Acceptance gate: a clause may move a cell only if worst-of-two-passes t_native/t_vendor <= 1.10 on every cell it moves, at saturation (batch >= 128), with every boundary bracketed by measured rows on both sides.
verdict.txt, over 644 saturated, chk-agreeing rows (626 quiet, 18 admitted on cross-pass reproduction):
| clause | rows moved | verdict | worst-of-two | median | best |
|---|---|---|---|---|---|
| transA == NoTrans, unconditional | 186 | FAILS 1/186 | 1.934 | 0.446 | 0.032 |
| shipped: AND NOT (cfloat && transB != NoTrans) | 176 | passes | 0.968 | 0.445 | 0.032 |
| AND NOT (cfloat && transB != NoTrans && nrhs >= 16) | 183 | passes | 0.968 | 0.444 | 0.032 |
The two nrhs-narrowed variants pass their own grid but were rejected. Their boundary depends on the banded column pattern, which the selection key cannot see. The 468 rows the shipped clause does not move contain 170 measured non-winners.
The one measured non-winner on the gather arm: complex<float>, transB = Trans, banded pattern, m=2048, 16 nnz/row, batch=512 (cfedge1, cfedge2). Ratios t_native/t_vendor, pass 1:
| nrhs | 8 | 9 | 12 | 16 | 17 | 20 | 25 | 32 | 50 |
|---|---|---|---|---|---|---|---|---|---|
| cfloat banded | 0.630 | 0.713 | 0.689 | 1.087 | 1.315 | 1.218 | 1.731 | 1.159 | 1.695 |
| cfloat scattered | 0.670 | 0.856 | 0.888 | 0.963 | 0.979 | 0.793 | 1.000 | 1.019 | 0.953 |
| float banded (control) | 0.406 | 0.404 | 0.404 | 0.545 | 0.360 | 0.368 | 0.385 | 0.934 | 0.571 |
The loss is banded-exclusive: the scattered pattern is at parity (0.79-1.02). That is why the exclusion is stated by type and transB, not by nrhs. By batch (banded, m=2048, 16 nnz/row, nrhs=25, worst of two passes), the loss switches on between batch 4 and 8:
| type | b=1 | b=4 | b=8 | b=16 | b=32 | b=64 | b=128 |
|---|---|---|---|---|---|---|---|
| float | 0.655 | 0.485 | 0.402 | 0.409 | 0.403 | 0.406 | 0.471 |
| double | 0.341 | 0.221 | 0.503 | 0.634 | 0.625 | 0.621 | 0.613 |
| complex<float> | 0.520 | 0.581 | 1.447 | 2.072 | 2.182 | 2.092 | 1.944 |
| complex<double> | 0.335 | 0.432 | 0.688 | 0.676 | 0.681 | 0.681 | 0.669 |
At batch 1-4 the excluded family is a 1.7-1.9x native win that the clause declines. The cost is accepted. The mechanism is unconfirmed: kNCmax<Cx<float>> is 8, so nrhs=25 needs 4 passes with 7 idle lanes, while nrhs=32 needs 4 with none. nrhs=32 measures 1.157-1.159 against 1.714-1.731 at nrhs=25.
The window applies at every batch rung (1 to 16384). The acceptance gate is stated at batch >= 128. A separate small-batch sweep (run_smallbatch.sh; 1,024 timed rows, 210 admitted) covers batch 1-128:
| batch | rows | worst | median | best | over 1.10 | max Δ µs/call (native − vendor) |
|---|---|---|---|---|---|---|
| 1 | 22 | 0.992 | 0.592 | 0.186 | 0 | −0.23 |
| 2 | 27 | 0.981 | 0.287 | 0.170 | 0 | −0.44 |
| 4 | 21 | 1.078 | 0.473 | 0.173 | 0 | +13.46 |
| 8 | 25 | 0.956 | 0.460 | 0.154 | 0 | −3.64 |
| 16 | 25 | 0.966 | 0.499 | 0.154 | 0 | −11.04 |
| 32 | 29 | 0.978 | 0.373 | 0.134 | 0 | −16.17 |
| 64 | 25 | 0.967 | 0.412 | 0.099 | 0 | −23.51 |
| 128 | 30 | 0.964 | 0.305 | 0.066 | 0 | −36.00 |
The one row that costs the caller time: complex<float>, m=4096, 16 nnz/row, nrhs=50, scattered, transB=NoTrans, batch 4, at 1.078 (+13.46 µs per call). It is non-monotonic on its own cell, so it is a launch artefact rather than a structural loss. Inside the gate, this grid contains no measured non-winner at any batch rung. So no batch floor is justified. SpmmTranscribedTable.RowsHoldTheTranscribedPreference checks the ranking at batch 1, 300 and 100000.
Below batch ~64 the timed region is launch latency plus the vendor's per-call host chain. The small-batch ratios show no harm and nothing more. The headline comes from the saturated grid alone.
Lanczos shape, batch 4096 and 8192, transA = NoTrans, footprints 151-1074 MB (2x to 15x the L2, so residency cannot inflate the numbers). Fraction of the roof in brackets:
| type | pattern | cuSPARSE | native gather |
|---|---|---|---|
| float | either | 120-147 GB/s (0.12-0.15) | 910-928 GB/s (0.90-0.92) |
| double | either | 185-235 GB/s (0.18-0.23) | 906-931 GB/s (0.90-0.92) |
| complex<float> | either | 185-236 GB/s (0.18-0.23) | 918-931 GB/s (0.91-0.92) |
| complex<double> | banded | 306-366 GB/s (0.30-0.36) | 915-926 GB/s (0.91-0.92) |
| complex<double> | scattered | 308-360 GB/s (0.31-0.36) | 714-850 GB/s (0.71-0.84) |
The gather runs at the roof. cuSPARSE is 2.3-7.7x below it: 6.2-7.7x for float, 3.9-5.0x for double and complex<float>, 2.3-3.0x for complex<double>.
In the LOBPCG regime (footprint > 288 MB, transA=0) neither arm is near the roof: vendor 0.06-0.16, native 0.09-0.52. The column-major op(B) gather binds both arms. The native kernel is still 1.3-4x faster there (LOBPCG transA=0 ratios 0.253-0.804 on the four types).
Saturation means that a wider batch buys < 10 % per item. The gather at (float, m=1024, 16 nnz/row, nrhs=12) runs 2.369, 0.788, 0.309, then 0.485 µs per item at batch 8/32/128/512. It rises at 512 because the 119 MB footprint leaves the 72 MB L2. Per-item µs, float, m=1024, 3 nnz/row, nrhs=1, banded:
| batch | 1024 | 2048 | 4096 | 8192 |
|---|---|---|---|---|
| footprint | 37.8 MB (L2) | 75.5 MB | 151 MB | 302 MB |
| vendor | 0.308 | 0.304 | 0.306 | 0.306 |
| native | 0.010 | 0.020 | 0.040 | 0.040 |
| ratio | 0.032 | 0.066 | 0.131 | 0.130 |
cuSPARSE is flat from batch 256. The native gather is not saturated until about batch 4096. The batch-512 lanczos ratios (0.032-0.072 for float) are L2-resident. Quote the DRAM-resident figure, 0.13-0.43 (2.3x-7.7x), instead.
transA != NoTrans moves 458 saturated cells. 169 are over the 1.10 gate. Median 1.030, worst 3.011.
| clause | rows moved | verdict | worst | refuting cell |
|---|---|---|---|---|
| transA != NoTrans | 458 | FAILS 169/458 | 3.011 | cdouble m=4096 nnz/row=16 nrhs=50 b=512 tB=0 scattered |
| AND nrhs <= 4 | 204 | FAILS 11/204 | 1.208 | cdouble m=2048 nnz/row=16 nrhs=2 b=512 |
| AND nrhs <= 1 | 60 | FAILS 2/60 | 1.132 | cdouble m=2048 nnz/row=16 nrhs=1 b=1024 |
| AND nrhs <= 2 AND type != cdouble | 111 | passes | 1.023 | fitted to nnz/row (below); rejected |
Every refuting cell is on the scattered pattern. The scatter's nrhs boundary moves with the type and with nnz/row (bnd_scatter_a, m=1024, batch=512, scattered, worst of two passes):
| type \ nrhs | 1 | 2 | 4 | 8 | 12 | 25 |
|---|---|---|---|---|---|---|
| float (3 nnz/row) | 0.180 | 0.323 | 0.590 | 0.779 | 0.880 | 1.095 |
| double (3 nnz/row) | 0.194 | 0.350 | 0.636 | 0.996 | 1.076 | 1.103 |
| cfloat (3 nnz/row) | 0.344 | 0.632 | 0.820 | 0.989 | 1.137 | 1.107 |
| cdouble (3 nnz/row) | 0.390 | 0.683 | 0.968 | 1.118 | 1.105 | 1.289 |
| cdouble (16 nnz/row) | 1.043 | 1.078 | 1.112 | 1.164 | 1.206 | 1.307 |
At m=2048, cdouble with 16 nnz/row loses at nrhs = 1 (1.098/1.101 at b=512, 1.130/1.132 at b=1024). The selection key has no nnz, so no shipped row separates this loss from the cdouble win at 3 nnz/row. The one passing clause needs a type exclusion. It moves 111 cells and has no in-tree C++ caller.
The scatter stays runnable. direct admits every transA, so BATCHLAS_SPMM_ROUTE=direct reaches it. In a vendor-free build the second entry, direct, runs.
Both arms spend > 93 % of wall time in GPU kernels, so the win is a kernel result (nsys_split.txt). Per call, float:
| cell | arm | wall | GPU kernel | host share |
|---|---|---|---|---|
| m=1024 nnz/row=3 nrhs=2 b=512 | native | 0.01196 ms | 0.01124 ms | 6.0 % |
| same | vendor | 0.16239 ms | 0.16461 ms | −1.4 % |
| m=1024 nnz/row=3 nrhs=2 b=4096 | native | 0.20227 ms | 0.20087 ms | 0.7 % |
| same | vendor | 1.25856 ms | 1.26342 ms | −0.4 % |
| m=2048 nnz/row=16 nrhs=25 b=128 | native | 0.33853 ms | 0.34594 ms | −2.2 % |
| same | vendor | 0.86439 ms | 0.91118 ms | −5.4 % |
| same, transA=Trans | native (scatter + scale) | 2.10142 ms | 2.17493 ms | −3.5 % |
| same, transA=Trans | vendor | 1.87837 ms | 1.99502 ms | −6.2 % |
Negative host shares are profiler accounting skew. They mean the per-call host chain is below the comparison's resolution.
cuSPARSE launches three kernels per call (csrmm_alg1_kernel, csr_partition_kernel, matrix_scalar_multiply_kernel) and re-partitions the CSR rows every call. csr_partition_kernel is 36 % of the vendor time at batch 512. The native gather launches one kernel. The transposed arm launches two (scale, then scatter). That row is 1.12x the vendor on the same cell.
The operation is \(C := \alpha\,\mathrm{op}(A)\,\mathrm{op}(B) + \beta C\), with A batched CSR (one strided slab per item) and B, C dense column-major. The three bodies sit behind the direct family.
The three are shipping wrong answers that predate direct. tests/spmm_tests.cc (368 cases: four types, all nine transpose spellings, heterogeneous nnz, empty rows, padded strides, alpha/beta corners) is the first suite to cover these axes.
Four deliberate breaks were run against the transposed path. B4 (scatterBound, a transposed nnz bound that reads padding) was green on all 352 cases the first time.
The cause: PaddingAboveNnzIsNotReadTrans poisoned padding with NaN at an out-of-range column index (2^30). The scatter's range guard (spmm_native.cc:367-375) skips that entry before it is multiplied. The test passed because of the kernel's guard, not the property it names. Controls showed the over-read is real: with the bound broken and the guard removed, the case segfaults (exit 139, double). With the bound correct, 352/352 pass.
The test now poisons with an in-range column and a large finite value, so the over-read lands in C where the backward-error comparison names the (batch, col, row). The out-of-range case is kept as PaddingAboveNnzOutOfRangeIsNotReadTrans, the range guard's only regression test. It does not cover the bound. Rules that followed:
The transposed tolerance denominator is a backward-error scale (sum of |a|*|b|, floored at 1), not |expected|. A transposed case whose backend throws is skipped unless the pin is native (tests/spmm_tests.cc:366-373). A vendor returning a status code instead of throwing is not covered by the skip. cusparse.cc checks no status, so CUSPARSE_STATUS_NOT_SUPPORTED leaves C untouched and is reported as a wrong answer.
Raw data is at the git tag perf-evidence/vendor-independence: git show perf-evidence/vendor-independence:<path>.
| topic | path |
|---|---|
| clause table, every candidate and its refuting cell | experiments/sparse_spmm/verdict.txt (from verdict.py) |
| main grid, two passes | experiments/sparse_spmm/pass1/, pass2/, report_pass12.txt |
| DRAM roof, footprint > 288 MB | experiments/sparse_spmm/roof.txt, roof.py |
| kernel and host split | experiments/sparse_spmm/nsys_split.txt, nsys_split.sh, nsys.log |
| saturation, batch 1024-8192 | experiments/sparse_spmm/sat1/, sat2/, run_satext.sh, tables.txt |
| cfloat nrhs walk | experiments/sparse_spmm/cfedge1/, cfedge2/, run_cfloat_edge.sh |
| nnz/row x nrhs boundaries, scatter batch ladder | experiments/sparse_spmm/bnd1/, bnd2/, scl1/, scl2/, run_boundary.sh, run_scatter_ladder.sh |
| small-batch corner, batch 1-128 | experiments/sparse_spmm/sb1/, sb2/, run_smallbatch.sh, smallbatch.txt, sb_report.py |
| warm-up ramp, route-pin probes | experiments/sparse_spmm/probe/warmup_probe.sh, probe/order_probe.sh |
| route census (65 decisions) | experiments/sparse_spmm/route_census.py, scripts/route_diff.sh captures |
| campaign summary and vendor-defect list | VENDOR_INDEPENDENCE_PLAN.md, "WP8" sections (root file, only at tag perf-evidence/vendor-independence) |
| coverage-instrument limits | VENDOR_FREE_BASELINE.md (root file, only at tag perf-evidence/vendor-independence) |
Reproduction (device 1 exclusive; run_all.sh takes about 21 min per pass, run_smallbatch.sh about 25 min):