Conversation
`view.to_gpu()` moves a normalized view onto an NVIDIA GPU as a `CudaNormalizedView`, supporting the same `@`/`__rmatmul__` the host view does. Every floating-point array on the device is float32. The transfer keeps the value-compressed layout: it uploads the same major_ptr/values/value_ptr/indices the host holds plus the O(n_rows + n_cols) statistics, never an expanded one-float-per-nonzero copy. That matters more on a device than on a host, where the whole reason to hold a count matrix in VCS form is that the expanded form does not fit. Keeping the layout is what forces custom kernels -- cuSPARSE reads CSR and CSC only, so nothing in CuPy can walk the two-level structure. There are four, the same two binary choices the host kernels make (which axis the statistics hang off, and whether the output's disjoint axis is the major one), generated from shared fragments rather than written out four times. Each is one warp per major slice, grid-strided, with the warp's 32 lanes split between the dense width and the slice's nonzeros so a PARAFAC2-shaped width of 8 does not idle 24 of them. Aligned directions accumulate in registers and reduce with __shfl_down_sync; misaligned ones atomicAdd, which is fast here but makes them nondeterministic in the last bits. Benchmarked against the alternative they exist to beat -- expanding to a CuPy CSR and calling cuSPARSE -- at 60k x 2k, 6M nonzeros, width 16. The kernels win every direction while holding 0.51x-0.62x the device memory, since this is bandwidth-bound: 0.56x-0.86x for `VCSR @ B` and `VCSC @ B`, and 0.17x-0.32x for the `dense @ sparse` directions, which cuSPARSE handles poorly. Ceilings are recorded on the slower of the two development GPUs. Two traps found while making that baseline honest, both of which would otherwise have flattered the kernels: - A VCS slice stores its indices grouped by the value they share, never ascending, so the expanded matrix is not canonical. cuSPARSE measured ~6x slower on it, so `to_cupy_sparse` sorts by default. - `B @ csc` measured ~5x `B @ csr`, so `to_cupy_sparse` grew a `format` parameter and the benchmark pins the baseline to CSR even for a VCSC-backed view. CI gains a `cuda` job on a self-hosted GPU runner, guarded so a forked PR cannot reach it and asserting a device is visible so it cannot silently pass by skipping. The three CPU jobs now pass `--no-extra cuda` to avoid the ~1 GB CuPy wheel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extra labels are another thing to register on each machine and keep in sync with the workflow, and getting them wrong leaves the job queued forever rather than failing. Plain `self-hosted` needs no setup beyond registering a runner. The trade is that the job can now land on a machine with no usable GPU. The "Assert a CUDA device is visible" step already caught that -- it has to exist regardless, since tests/test_cuda.py skips itself when CuPy cannot see a device and the job would otherwise pass green having tested nothing -- so it just becomes the job's only GPU gate rather than a second one. `nvidia-smi` becomes non-fatal so that assert prints the actionable message instead of the step before it dying on "not found". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
view.to_gpu()moves a normalized view onto an NVIDIA GPU as aCudaNormalizedView, supporting the same@/__rmatmul__the host view does. Every floating-point array on the device is float32.The layout stays compressed
The transfer uploads the same
major_ptr/values/value_ptr/indicesthe host holds plus theO(n_rows + n_cols)statistics — never an expanded one-float-per-nonzero copy. That matters more on a device than on a host: the whole reason to hold a count matrix in VCS form is that the expanded form does not fit.Keeping it is also what forces custom kernels. cuSPARSE reads CSR and CSC only, so nothing in CuPy can walk the two-level
major_ptr -> (values, value_ptr) -> indicesstructure.The kernels
Four, the same two binary choices the host kernels in
_vcs_matmulmake — which axis the statistics hang off (VCSR vs VCSC), and whether the output's disjoint axis is the major one — generated from shared fragments rather than written out four times.Each is one warp per major slice, grid-strided. The warp's 32 lanes split two ways:
wwlanes cover the dense width, and the remaining32/wwlane-groups walk the slice's nonzeros in stride, so a PARAFAC2-shaped width of 8 doesn't leave 24 lanes idle on every nonzero. Aligned directions accumulate in registers and reduce with__shfl_down_sync; misaligned onesatomicAddinstead. The host's private-accumulator answer to that direction has no analogue on a GPU and needs none, but the atomics do make those two directions nondeterministic in the last bits.Benchmarks
Against the alternative they exist to beat — expanding to a CuPy CSR and calling cuSPARSE — at 60k × 2k, 6M nonzeros, width 16. Below 1 means the kernel wins.
VCSR @ B(aligned)VCSC @ B(atomics)B @ VCSC(aligned)B @ VCSR(atomics)The kernels win every direction while holding 40–50% less device memory, because this is bandwidth-bound — the layout wins twice.
dense @ sparseis where cuSPARSE does worst. Ceilings are recorded on the slower-ratio device and verified passing on the other.Two traps turned up while making that baseline honest, both of which would otherwise have flattered the kernels:
to_cupy_sparsesorts by default (sort_indices=Falseopts out). The hostto_scipy_sparsehas the same property — pre-existing, unchanged.B @ cscmeasured ~5×B @ csr, soto_cupy_sparsegrew aformatparameter and the benchmark pins the baseline to CSR even for a VCSC-backed view.CI
A
cudajob onruns-on: self-hosted— no extra labels, so there is nothing to register per machine or keep in sync with the workflow. It is guarded so a forked PR can't reach the hardware. The three CPU jobs now pass--no-extra cudato skip the ~1 GB CuPy wheel.Plain
self-hostedmeans the job can land on a machine with no usable GPU. The "Assert a CUDA device is visible" step turns that into a loud failure — and it has to exist regardless, sincetests/test_cuda.pyskips itself when CuPy can't see a device, so without it the job would pass green having tested nothing.nvidia-smiis informational and non-fatal so that assert is what prints the actionable message.Note
This needs at least one self-hosted runner registered on the repo. Until one exists the job will queue. Prefer one runner per machine — two jobs sharing a GPU would make the timing gate noisy.
Testing
import vsparseand the CPU paths are unaffected by a CPU-only install._cuda.pyis rank A with zero duplication.apply_gabout it, rather than silently computing the identity on the GPU.🤖 Generated with Claude Code