Skip to content

Add CUDA transfer and matmul kernels for the normalized views - #61

Open
aarmey wants to merge 2 commits into
mainfrom
cuda-normalized-views
Open

aarmey wants to merge 2 commits into
mainfrom
cuda-normalized-views

Conversation

@aarmey

@aarmey aarmey commented Sep 20, 2026

Copy link
Copy Markdown
Member

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.

gpu = vsparse.VCSRArray.from_scipy(counts).normalized("parafac2").to_gpu()
out = gpu @ B      # float32 CuPy array; B @ gpu likewise

The layout stays compressed

The transfer 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: 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) -> indices structure.

The kernels

Four, the same two binary choices the host kernels in _vcs_matmul make — 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: ww lanes cover the dense width, and the remaining 32/ww lane-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 ones atomicAdd instead. 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.

direction RTX 4090 RTX 5080 device bytes vs CSR
VCSR @ B (aligned) 0.56–0.65 0.71–0.86 0.62
VCSC @ B (atomics) 0.86 0.83 0.51
B @ VCSC (aligned) 0.18 0.32 0.51
B @ VCSR (atomics) 0.17 0.23 0.62

The kernels win every direction while holding 40–50% less device memory, because this is bandwidth-bound — the layout wins twice. dense @ sparse is 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:

  • The expanded CSR is never index-sorted. A VCS slice groups indices by the value they share, not ascending, so the result isn't canonical. cuSPARSE measured ~6× slower on it, so to_cupy_sparse sorts by default (sort_indices=False opts out). The host to_scipy_sparse has the same property — pre-existing, unchanged.
  • B @ csc measured ~5× 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

A cuda job on runs-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 cuda to skip the ~1 GB CuPy wheel.

Plain self-hosted means 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, since tests/test_cuda.py skips itself when CuPy can't see a device, so without it the job would pass green having tested nothing. nvidia-smi is 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

  • 164 CUDA tests: all 4 directions × 5 recipes × widths spanning the lane-splitting and chunking boundaries, plus degenerate shapes, zero-width operands, device operands, and a check that the transfer really stays compressed. Max relative error vs the float64 host view is ~1e-6 on a 20k × 2k matrix.
  • Full suite 1492 passed / 61 skipped with CuPy present; 1352 passed with it absent, confirming import vsparse and the CPU paths are unaffected by a CPU-only install.
  • Both benchmark gates clean. ruff, codespell and ty clean. vulture, xenon and jscpd show no new findings — _cuda.py is rank A with zero duplication.
  • An import-time guard fails loudly if a recipe transform is added without teaching the CUDA apply_g about it, rather than silently computing the identity on the GPU.

🤖 Generated with Claude Code

aarmey and others added 2 commits September 20, 2026 07:06
`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>
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