Skip to content

perf(conv): vectorized staging, per-shape block tiles and fp32 forward reads in the WMMA kernels - #14

Merged
AlexanderSoftCode merged 5 commits into
mainfrom
perf/conv-wmma-staging
Oct 30, 2026
Merged

AlexanderSoftCode merged 5 commits into
mainfrom
perf/conv-wmma-staging

Conversation

@AlexanderSoftCode

Copy link
Copy Markdown
Owner

Context

PR #10 left the bigger conv kernel rewrite waiting on an RTX 3050 knockout. That knockout showed the tensor cores busy only ~20 % of the time; the rest went to scalar 2-byte tile staging and half-empty 64-wide tiles on 32-channel layers. The PR follows the plan from that knockout, one commit per step: d920273 (16-byte staging), d89b37e (per-shape block tile), a665463 (dinput weight tile), 0646072 (fp32 forward input). fdbc9ac is a test-only fix to a dW/dB tolerance. No commit is mechanical.

RTX 3050, benchmarks/cifar10_breakdown.py (synthetic data, batch 256):

ms/step
main 42.69
+ d920273 33.90 −20.6 %
+ d89b37e 31.28 −7.7 %
+ a665463 30.04 −4.0 %
+ 0646072 29.78 −0.9 % (within noise)

Total −30 %. Conv 03 (the largest layer) forward 3.27 → ~1.1 ms, backward 6.55 → 2.3 ms.

16-byte staging (d920273)

Every element of every A/B tile was its own 2-byte global load, LDS store and div/mod index decomposition, 16 k-deep per iteration. In the conv 11 knockout the gather alone was 36–48 % of each kernel. When the channel count is a multiple of 8, an 8-aligned k (or n) chunk stays inside one channel run, so a lane now moves 8 channels as one uint4: forward A and B, dweight A and B, and dinput A. In forward, each A lane's pixel (s, h, w) is decomposed once before the K loop. In dweight, the bias is summed in 8 per-lane registers and flushed with 8 atomicAdds. An #if on C % 8 picks the path per shape, so C_in = 3 keeps the scalar loop. The shared tiles are alignas(32), and _forward_gpu copies an input that is non-contiguous or not 16-byte aligned.

render_*_source(shape_meta, vectorize=False) renders the scalar staging, so tests can compare the two paths directly.

Non-contiguous forward inputs

On main, _forward_gpu passed inputs.astype(float16, copy=False) straight to the kernel. astype keeps the source's memory order, so a transposed or otherwise non-contiguous view reached a kernel that assumes C-contiguous NHWC, and the output was silently wrong. The new test gets 2048 / 2048 mismatched outputs on main. xp.ascontiguousarray fixes it, and the same step adds the 16-byte alignment check.

Per-shape block tile (d89b37e)

The block tile was 64 × 64 for every shape. On a 32-channel GEMM N axis (C_out for forward/dweight, C_in for dinput), half its warps had no columns, and all 512 threads still staged the half-zero tile. choose_block_tile_n() now returns 32 for N ≤ 32 channels. dweight's M axis (K_TOTAL) takes a 32-row tile up to 32 filter indices (conv 00, K_TOTAL = 27: 0.45 → 0.30 ms). The tiles depend only on the shape, so the kernel cache keys don't change. The block size follows the tile, and choose_dweight_split_k already scales its slot count by it.

Conv 03 with the 32-wide tile: forward 1.21 → 0.74 ms, dweight 1.29 → 0.77 ms, dinput 2.36 → 1.44 ms (interleaved A/B in one process).

dinput weight tile (a665463)

After d920273, dinput's remaining cost was its scalar weight gather. The weights are contiguous along c_out, which is the GEMM k axis in dinput. So the tile is now staged transposed (Bs[n][k]), 8 c_out per lane, and read back with a col-major B fragment (new frag_layout_b_col; dweight's A operand uses the same trick). A and B vectorize under the same C_OUT % 8 condition. dinput kernel time roughly halved again (conv 03: 1.44 → 0.78 ms).

fp32 forward input (0646072)

conv_forward_wmma takes half or float input (a template type, now part of the forward cache key, see forward_cache_key). float is converted as it's gathered, rounding exactly as astype(float16) does. The plan expected this to win on every conv, but that was measured with the scalar prototype. With 16-byte staging, each element is read about F_H × F_W times, so doubling its bytes costs more than one cast pass at copy bandwidth: convs 03–16 take 3.38 ms with fp32 reads vs 3.27 ms with the cast. choose_forward_input_dtype() therefore reads fp32 directly only when the gather is scalar (C_in % 8 != 0; conv 00: 0.53 → 0.47 ms). Other shapes keep the cast.

Behavior changes

  • Non-contiguous inputs to a GPU Conv2d forward now give correct output instead of silently wrong output.
  • Forward rounds fp64 inputs to fp32 before fp16, matching what backward's cast already did. In rare cases the forward output can differ from main's by the double rounding.
  • dW/dB use a different split-K slice count on 32-wide tiles, so their rounding changes (~1e-5 relative, same order as the existing run-to-run atomic noise). Forward and dX are bit-identical to main.
  • None to seeding or serialization.

Testing

  • python -m unittest discover -s tests -t .: 1140 tests, 1069 pass on NumPy and CuPy (69 skipped). The 2 errors are test_save_replaces_symlink_instead_of_following_it on both backends, a Windows symlink issue that also fails on main.
  • test_conv_gpu_forward_view_inputs_match_contiguous: the transposed case fails on main (2048 / 2048 mismatched). The misaligned fp16 view case fails on this branch without the alignment guard (cudaErrorMisalignedAddress).
  • New parity tests (they guard the new paths rather than catch old bugs, so they also pass on main where they apply):
    • vectorized vs scalar staging: forward and dX bit-exact, dW norm-relative (tests/ops/test_conv_vector_staging.py)
    • 32-wide vs 64-wide tile at C = 8/24/32 and K_TOTAL = 27 (tests/ops/test_conv_tile_choice.py)
    • fp32 and fp64 forward inputs vs fp16, on both gathers (test_conv_gpu_forward_input_dtypes_round_like_astype)
  • test_conv_kernel_compile_fallback reads the forward cache key from forward_cache_key(), since that key now includes the input dtype.
  • fdbc9ac: test_backward_without_input_grad_keeps_parameter_grads compares dW/dB against the tensor's scale (_assert_gemm_close, 1e-5) instead of a per-element rtol, which split-K atomic ordering could trip.
  • GPU: everything above ran on an RTX 3050 6GB Laptop (sm_86) with CuPy 14.2. Laptop clocks drift ±10–20 % between runs, so kernel comparisons were interleaved A/B in one process.

Follow-ups (out of scope)

  • HIP / gfx1201 is untested. The build of alignas, uint4, __floats2half2_rn and the rocWMMA col-major B fragment, and whether each step also wins on the RX 9070 XT, need a run .

  • Next kernel lever. Knockout after this PR (RTX 3050, share of base time):

    conv 11 base MMA gather barriers
    forward 0.90 ms 27 % 23 % 26 %
    dweight 0.93 ms 36 % 10 % 10 %
    dinput 1.19 ms 30 % 27 % 19 %

@AlexanderSoftCode
AlexanderSoftCode merged commit 5b63dc4 into main Oct 30, 2026
5 checks passed
@AlexanderSoftCode
AlexanderSoftCode deleted the perf/conv-wmma-staging branch October 30, 2026 03:03
Every A/B tile element was its own 2-byte global load, LDS store and
index decomposition. When the channel count is a multiple of 8, an
8-aligned k (or n) chunk stays inside one channel run, so a lane now
moves 8 channels as one uint4:

  forward  A (8 c_in of one pixel, whose (s, h, w) is decomposed once
           before the K loop) and B (8 c_out of the weight tile)
  dweight  A (8 c_in, filter decomposition still hoisted) and B (8
           c_out of dY); the bias sums in 8 per-lane registers and
           flushes with 8 atomicAdds into s_bias
  dinput   A (8 c_out of dY of one tap; the B weight gather is still
           scalar)

A #if on C_IN/C_OUT % 8 picks the path per shape, so C_in = 3 (the
first CIFAR-10 conv) keeps the scalar loop. The shared tiles are
alignas(32), and _forward_gpu copies its fp16 input if it is
non-contiguous or not 16-byte aligned (an fp16 slice); every other
operand is a fresh allocation.

render_*_source(shape_meta, vectorize=False) forces the scalar
staging. The new tests compare against it: forward and dinput bit for
bit, dweight norm-relative since split-K sums with atomics.

RTX 3050, CIFAR-10 benchmark: 42.7 -> 33.9 ms/step (-21%). Conv 03
forward 3.27 -> 1.51 ms, backward 6.55 -> 3.90 ms.
The block tile was 64 x 64 for every shape. On a 32-channel GEMM N axis
(C_out for forward/dweight, C_in for dinput) half its warps had no
columns, and all 512 threads still staged the half-zero tile.

choose_block_tile_n() returns 32 when that axis is at most 32 channels,
else 64, and dweight's M axis (K_TOTAL) takes a 32-row tile up to 32
filter indices (the first CIFAR-10 conv has K_TOTAL = 27). The tiles
are a function of the shape alone, so the kernel caches keep their
keys; the block size follows the tile (128/256/512 threads), and
choose_dweight_split_k already scales its slot count by it.

Forward and dinput reduce in the same K order at any block shape, so
the new test checks them bit for bit against the 64 x 64 tile; dweight
is compared norm-relative, since its split-K count changes.

RTX 3050, CIFAR-10 benchmark: 33.9 -> 31.3 ms/step (-8%). Conv 03
forward 1.51 -> 1.13 ms, backward 3.90 -> 2.81 ms; conv 00 forward
1.11 -> 0.51 ms.
After the A tile went to 16-byte loads, dinput's remaining cost was its
scalar weight gather. The weights are contiguous along c_out, which is
the GEMM k axis here, so a lane loading 8 c_out writes 8 consecutive k
of one n: the tile is now staged transposed, Bs[n][k], and read back
with a col-major B fragment (frag_layout_b_col, the trick dweight uses
for its A operand).

Both operands vectorize under the same C_OUT % 8 condition (VEC_K);
otherwise both stay scalar and B keeps its [k][n] layout. The B lanes
follow the A lanes, which fits every tile: 128 + 128 of 512 threads, or
128 + 64 of 256.

The vector-staging test already compares dinput bit for bit against
the scalar render, which now covers B too.

RTX 3050, CIFAR-10 benchmark: 31.3 -> 30.0 ms/step (-4%); conv
backward rows 18-22% faster each.
conv_forward_wmma takes its input as half or float (a template type,
part of the kernel cache key) and converts float with __float2half /
__floats2half2_rn as it gathers, which rounds exactly as astype(float16).

Measured on the RTX 3050, reading fp32 only pays off when the gather is
scalar (C_in % 8 != 0, the first CIFAR-10 conv: 0.53 -> 0.47 ms with
the astype counted). The 16-byte gather reads each element ~F_H*F_W
times, so doubling its bytes costs more than one cast pass at copy
bandwidth (convs 03-16: 3.38 vs 3.27 ms summed), the same trade the
backward cast already makes. choose_forward_input_dtype() picks per
shape, and those shapes keep the fp16 cast.

_forward_gpu reads fp16 inputs as is and takes anything else as fp32
first, so fp64 inputs now round like backward's fp32 cast does. The
caller's array is still what backward and the fallback see.

Tests: fp32 and fp64 inputs give the fp16-input output bit for bit on
both gathers, and the vector-staging test runs forward on fp16 and fp32
inputs. The compile-fallback test reads the forward cache key from
forward_cache_key(), since it now includes the input dtype.

RTX 3050, CIFAR-10 benchmark: 30.0 -> 29.8 ms/step (within noise),
conv 00 forward -4%.
test_backward_without_input_grad_keeps_parameter_grads checked dW/dB
with a per-element rtol=1e-5. On the GPU, dW/dB accumulate split-K
slices with atomics, so the summation order varies run to run, and an
entry that cancels to near zero still carries the rounding of its O(10)
partial sums, which a plain rtol rejects.

It now uses _assert_gemm_close (max error over max |reference|) at the
same 1e-5. The check is backend-agnostic, so it runs unchanged on NumPy.
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