CUDA kernel audit — correctness, dead code, performance#
Status: in progress
Scope: full sweep of the 46 .cu files and 7 headers shipped as package data, looking for
(a) memory-safety and correctness defects, (b) dead code the previous cleanup missed, and
(c) performance headroom.
Predecessor: cuda-operator-cleanup.md, which removed 102
unreachable @BE entry points, 2 orphan headers and 8 unused cuda_common.h symbols. That
pass audited reachability; it did not look inside the kernels.
Validation environment#
RTX 3080 Ti Laptop GPU (sm_86), driver 596.49, CUDA 13.2 / nvcc 13.1, jax 0.11.0 with
jaxlib CUDA. compute-sanitizer is unavailable on this host (WSL2/WDDM: “Failed to
initialize WDDM debugger interface / Device not supported”), so memory-safety findings are
established with purpose-built canary kernels and standalone harnesses instead.
Validation pitfall (cost an hour, worth recording)#
brainevent is installed in editable mode pointing at the main checkout. A script run
as python /tmp/foo.py puts /tmp — not the CWD — on sys.path[0], so it imports
/mnt/d/codes/projects/brainevent/brainevent, not the worktree. Early “the fix does not
work” results were actually the unfixed main checkout being re-tested. Every GPU validation
command must pass:
PYTHONPATH=<worktree-root> python ...
and the harness should print brainevent.__file__ to prove which tree it exercised.
Deleting ~/.cache/brainevent between runs is also required, because compiled modules are
keyed on a content hash of the .cu source plus flags.
Finding 2 — 52 unreachable @BE entry points remain#
The predecessor pass reduced 749 → 647 annotations. A fresh reachability analysis — extract
every // @BE <name>, extract every kernel_name f-string template from non-test Python,
expand {...} to [A-Za-z0-9_]*, and match — leaves 52 entry points that no Python code
path can name.
Python only ever composes these CSR binary targets: binary_csrm{v,m}_nt_auto{_homo,_hetero},
binary_csrm{v,m}_nt_auto_perm_hetero, binary_csrm{v,m}_{wat,sraw}_hybrid* and
binary_indexed_csrm{v,m}_{wat,sraw}_hybrid_hetero*. The transpose route moved to the
hybrid kernels, which stranded the whole t_warp family:
File |
Dead entry points |
Count |
|---|---|---|
|
|
16 |
|
|
16 |
|
|
8 |
|
|
8 |
|
|
4 |
Removing them also retires the _t_warp_*_kern device kernels, which nothing else launches.
Those kernels additionally call atomicAdd directly on __half* / __nv_bfloat16* rather
than through the arch-guarded atomic_add_f16 / atomic_add_bf16 helpers, so they would
fail to compile below sm_70 / sm_80 — retiring them removes that portability trap too.
binary_indexed_test.py::test_indexed_mm_cuda_kernel_selects_perm_names asserts the dead
names and their DEFINE_ macros are present in the .cu text; those assertions pin dead
code and are updated with the removal.
Finding 3 — CSRMM dense subscripts overflow 32-bit int (correctness)#
The CSRMM kernels index the dense operands as B[indices[j] * n + c] and C[row * n + c].
Both factors are runtime int, so the product is computed in 32-bit and wraps once
k * n (or m * n) exceeds 2^31. Demonstrated standalone at k * n = 2148507648:
int yields -2146460672, size_t yields 2148506624 — a wild pointer, not a slow path.
float_csrmm.cu had the same exposure. 30 subscripts across the four CSRMM sources were
widened by inserting a (size_t) cast on the leading factor; the smem strides
(smem[strip * 32 + lane]) were left alone since both factors there are block-bounded
constants. Full GPU CSR suite: 699 passed.
A tree-wide re-audit then found the same pattern in the JIT connectivity families:
chunk_counts[row * n_chunks + chunk_id] and chunk_offsets[...] in
_jit_{normal,scalar,uniform}/{csr,dt2t}.cu (24 sites). n_chunks defaults to 4, but
chunk_size is user-settable, so row * n_chunks is not bounded by anything the kernel
controls. Widened the same way. JIT GPU suites: 1512 passed, 4 skipped.
Reachability for both is the same and worth stating plainly: the index only overflows once the indexed buffer itself exceeds 2^31 elements, i.e. ≥8.6 GB at 4 bytes (4.3 GB at fp16). That is out of reach on a small card and reachable on an 80 GB one. The fix costs nothing at runtime — the multiply widens to 64-bit, and none of these sites are in an inner loop — so it is worth taking rather than documenting as a limit.
After this pass the only remaining unwidened multiplicative subscripts tree-wide are
shared-memory strides with a literal * 32, bounded by block size by construction.
Finding 4 — CSRMV was missing its warp-per-row tier (performance)#
The defect#
float_csrmv.cu documents and implements a three-tier row mapping — one thread per row
below avg_nnz 8, one warp per row up to 512, one block per row above. The three binary
CSRMV sources only had two tiers:
if (avg_nnz <= 512) { thread-per-row } else { block-per-row }
so every row length from 16 to 512 — the normal range for sparse neural connectivity —
ran the thread-per-row kernel, where the 32 lanes of a warp each walk a different row
and every indices[j] load is uncoalesced.
Git history shows DEFINE_CSRMV_NT_WARP_HOMO existed at da4a812~1. The predecessor
cleanup deleted it as unreachable, which was true but was the wrong repair: the dispatcher
was what needed fixing.
Measured, on sm_86, m = k = 65536, float32 hetero, bool spikes, 50 iterations#
avg_nnz |
thread (ms) |
warp (ms) |
block (ms) |
best |
|---|---|---|---|---|
2 |
0.009 |
0.026 |
0.219 |
thread |
8 |
0.017 |
0.034 |
0.278 |
thread |
12 |
0.030 |
0.034 |
0.278 |
thread |
16 |
0.040 |
0.034 |
0.276 |
warp |
32 |
0.088 |
0.043 |
0.271 |
warp |
64 |
0.209 |
0.076 |
0.272 |
warp |
128 |
0.361 |
0.141 |
0.249 |
warp |
256 |
0.950 |
0.277 |
0.325 |
warp |
512 |
1.904 |
0.547 |
0.545 |
block |
Up to 3.4x on the tier that was missing. The crossovers give the thresholds used: thread below 16, warp to 512, block above.
Launch shape: pack warps, don’t launch 32-thread blocks#
The historical kernel (and float_csrmv.cu today) launched <<<m, 32>>> — one warp per
block. A block occupies a scheduler slot regardless of size, and an SM caps resident
blocks, so 32-thread blocks waste 7/8 of each slot. Packing 8 warps into a 256-thread
grid-strided block instead:
avg_nnz |
|
8 warps/block (ms) |
speedup |
|---|---|---|---|
8 |
0.111 |
0.034 |
3.31x |
16 |
0.111 |
0.034 |
3.25x |
32 |
0.111 |
0.044 |
2.53x |
64 |
0.160 |
0.076 |
2.11x |
128 |
0.145 |
0.143 |
1.02x |
256 |
0.276 |
0.277 |
1.00x |
512 |
0.545 |
0.550 |
0.99x |
Strictly better at low row lengths, parity above. The same shape applied to the transpose
atomic-scatter kernel (csrmv_t_warp, launched unconditionally): 2.80x at avg_nnz 8,
1.81x at 16, parity through 256, and 0.94x at 512 — net strongly positive.
Fix#
Added
DEFINE_CSRMV_NT_WARP_{HOMO,HETERO}tobinary_csrmv.cuandDEFINE_CSRMV_NT_WARP_PERM_HETEROtobinary_indexed_csrmv.cu, instantiated for all weight/spike dtype pairs (24 new device kernels, no new@BEentry points — thent_autowrappers dispatch to them internally).Rewrote all three dispatchers as the three-tier ladder.
Converted the two existing
float_csrmv.cuwarp kernels from<<<m, 32>>>to the packed grid-strided shape.Added
be_csrmv_warp_grid()tocuda_common.h(shared by all three sources; clamps to[1, 4096]so an empty matrix cannot produce a zero-sized grid).
row is derived as blockIdx.x * warps_per + warp_id, making it warp-uniform. That is
what keeps the __shfl_down_sync reduction converged — the M17 precondition documented in
cuda_common.h. A lane-dependent row bound would silently corrupt the reduction.
Verification#
96 configurations through the public API (7 row lengths spanning all three tiers x 3 weight
dtypes x homo/hetero x bool/float spikes), cuda_raw against jax_raw:
float32 and float64: agree to 1e-5 / 1e-12 relative on every tier.
float16: differs from
jax_rawby up to 3e-2 relative — including on the block tier, which this change does not touch. Resolved against an exact float64 ground truth: the CUDA path is 10-100x closer to exact thanjax_rawin all 8 cases (e.g. 2.4e-4 vs 3.8e-2 atavg_nnz1024). The kernels accumulate in float32; the reference accumulates in float16. This is a reference-precision artifact, not a kernel defect.
End-to-end Python timing could not resolve the improvement on this machine: JAX host dispatch latency floors at ~1.2 ms for this call, well above the 0.03-0.5 ms of kernel time, so the API-level A/B returns noise. The standalone CUDA benchmarks above are the measurement of record.
Regression tests#
test_binary_csrmv_matches_reference_across_dispatch_tiers— 7 row lengths straddling both thresholds x homo/hetero.test_binary_csrmv_warp_tier_handles_ragged_and_empty_rows— empty rows, a row count off the warps-per-block tiling, and wildly varying row lengths; also asserts empty rows are written rather than left uninitialised.test_binary_csrmv_cuda_source_keeps_three_dispatch_tiers— source-level, runs without a GPU. Necessary because a two-tier dispatcher is still correct, so no numeric test can catch the tier being dropped again. Verified to fail against the pre-change source.
Finding 5 — float_csrmm.cu warp kernels kept the unpacked launch shape#
binary_csrmm.cu and binary_indexed_csrmm.cu already launch their warp kernels as
CSRMM_WARP_RPB (4) rows per 128-thread block with a grid-strided loop and a capped
grid.x. float_csrmm.cu was never updated to match and still launched
<<<dim3(m, c_blocks), 32>>> — one warp per block — for both its NT and transpose warp
kernels. Same defect as Finding 4’s launch shape, in the file that is otherwise the
reference implementation.
Measured on sm_86, m = k = 16384, n = 64, float32 homogeneous:
avg_nnz |
|
packed 4/block (ms) |
speedup |
|---|---|---|---|
2 |
0.062 |
0.030 |
2.05x |
4 |
0.055 |
0.031 |
1.77x |
8 |
0.059 |
0.038 |
1.58x |
16 |
0.088 |
0.062 |
1.42x |
32 |
0.142 |
0.111 |
1.28x |
64 |
0.251 |
0.211 |
1.19x |
A win across the entire range the warp tier serves (avg_nnz <= 64).
Both kernels were converted to the packed grid-strided form. The row guard becomes
continue rather than return, since returning inside a grid-strided loop would abandon
that warp’s remaining rows — the same trap as in Finding 4.
CSRMM_MAX_GRID_X and CSRMM_WARP_RPB were duplicated verbatim in two .cu files;
rather than add a third copy they were moved to cuda_common.h alongside the CSRMV
geometry and the local definitions deleted.
Finding 6 — the last five one-warp-per-block launches#
After Findings 4 and 5, five <<<…, 32>>> launches remained: the three warp kernels in
slice_csr_slice_rows.cu (forward homo, forward hetero, backward) and the two in
dt2t.cu (csrmv and csrmm row-warp). All five are the same shape — one row per warp,
warp-stride over that row’s non-zeros, no cross-lane operations — so all five were packed.
Measured on sm_86 (m = 65536, float32) for the streaming-write shape these use:
avg_nnz |
|
packed 8/block (ms) |
speedup |
|---|---|---|---|
8 |
0.077 |
0.031 |
2.50x |
16 |
0.078 |
0.030 |
2.57x |
32 |
0.081 |
0.045 |
1.79x |
64 |
0.110 |
0.074 |
1.48x |
128 |
0.146 |
0.146 |
1.00x |
256 |
0.309 |
0.285 |
1.08x |
512 |
0.565 |
0.711 |
0.80x |
This one is not a uniform win. Packing is 2.5x faster over avg_nnz 8–32, ~1.5x at 64,
parity at 128–256, and roughly 20% slower at 512 — with enough work per row the
one-warp-per-block form’s larger grid schedules better than the capped grid-strided one.
Both kernels’ warp tiers serve avg_nnz 8–511, so a band near the top of that tier
regresses slightly. It was taken anyway: real sparse connectivity sits well below 256
nonzeros per row, so the 2.5x low end dominates in practice.
If that band ever matters, the fix is to select warps-per-block from avg_nnz at launch
rather than fixing it at 8 — deliberately not done here, since it adds a tuning knob for a
case that has no measured workload behind it.
The slice kernels needed one extra care beyond the mechanical rewrite: they carry a
second guard, if (r < 0 || r >= m) return, for out-of-range row indices. Inside a
grid-strided loop that must become continue, or one bad row index would silently drop
every remaining row assigned to that warp.
BE_CSRMV_WARP_GRID was renamed BE_WARP_PER_ROW_GRID (and the helper
be_csrmv_warp_grid → be_warp_per_row_grid) now that slicing and dt2t share it; the
CSRMV-specific name would have been misleading.
Audits that found nothing (recorded so they are not redone)#
Dynamic shared-memory sizing, tree-wide. 12 kernels use
extern __shared__. Every request now matches the largest index the kernel can write._fcn/binary_fcnmm.cuwas the one to check closely — it uses the same(warpid + stride) * 32 + lanestrip indexing that broke CSRMM — and it sizes correctly asred_off + nwarps * 32 * ACC_SIZE.binary_fcnmm.cutree reduction over a non-power-of-two warp count. The reductionfor (stride = nwarps >> 1; stride > 0; stride >>= 1)silently drops the top warps ifnwarpsis not a power of two. Not a live bug: all four launch sites hardcodebsz = 256, sonwarps == 8. Noted because a future block-size change would break it without any test failing.Dispatch-tier gaps beyond Finding 4.
dt2t.cu,plasticity_binary_on_{pre,post}.cuandslice_csr_slice_rows.cuall already implement three-tier dispatch. The CSRMMavg_nnz <= 512two-tier ladders are not the Finding 4 defect: for SpMM the two tiers are warp and block (lanes map to columns), which is the right pair.Warp-reduction convergence and
__syncthreads()divergence: all flagged kernels verified block- or warp-uniform.Unguarded fp16/bf16 atomics in
float_csrmm.cu: compile fine on sm_75 under CUDA 13; sm_60 is not supported by the toolkit. Not a live bug.
Edge cases considered#
Orphaning a device kernel. Removing an FFI wrapper can strand the
__global__kernel it was the sole launcher of.nt_warp_perm/nt_block_permdevice kernels stay: thent_auto_permdispatcher still launches them internally; only their standalone FFI wrappers are unreachable.Shared-memory fix and occupancy. Raising the request from 32 to 1024 bytes (2048 for f64) does not change occupancy on sm_86: at 256 threads/block the limit is 1536/256 = 6 blocks/SM in both cases, confirmed with
cudaOccupancyMaxActiveBlocksPerMultiprocessor. So the fix costs nothing.Cache invalidation. Editing any
.cuchanges its content hash, so the first GPU run after this change recompiles. Expected.CPU suite proves nothing.
.cusources are never compiled unless a GPU kernel is actually lowered. Every change here must be GPU-validated, per the predecessor spec’s takeaway.Warp reduction convergence. The new warp kernels derive
rowfromblockIdx.x * warps_per + warp_idonly. Had the grid-stride bound been lane-dependent, lanes would retire at different trip counts and__shfl_down_syncwould read from exited lanes. Audited all 6 pre-existing warp-reduction kernels the same way; all are warp-uniform.Empty rows in the warp kernel. The thread and block kernels early-return on
start == end; the warp kernel deliberately does not, because an earlyreturninside a grid-strided loop would skip that warp’s remaining rows. Instead the inner loop simply does not execute, the reduction yieldsACC_ZERO, and lane 0 writes it. Covered by the ragged-row test.Zero-sized grid.
be_csrmv_warp_grid()clamps to at least 1.csrmv_t_warphas noavg_nnzguard, so anm == 0matrix would otherwise reach the launch with a zero-sized grid — an invalid configuration. (The pre-change<<<m, 32>>>had the same hole; this closes it.)Threshold asymmetry with
float_csrmv.cu. The binary sources switch to the warp tier atavg_nnz16 whilefloat_csrmv.cuuses 8. The measured crossover for the binary kernels is between 12 and 16 (0.030 vs 0.034 at 12; 0.040 vs 0.034 at 16). The float threshold was left at 8: converting its warp kernel to the packed shape only makes the warp tier faster, so an unchanged threshold stays on the safe side of the crossover.