Repository navigation
perf(conv): vectorized staging, per-shape block tiles and fp32 forward reads in the WMMA kernels - #14
Merged
Merged
Conversation
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.
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.
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).fdbc9acis a test-only fix to a dW/dB tolerance. No commit is mechanical.RTX 3050,
benchmarks/cifar10_breakdown.py(synthetic data, batch 256):d920273d89b37ea6654630646072Total −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 8atomicAdds. An#ifonC % 8picks the path per shape, so C_in = 3 keeps the scalar loop. The shared tiles arealignas(32), and_forward_gpucopies 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_gpupassedinputs.astype(float16, copy=False)straight to the kernel.astypekeeps 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 onmain.xp.ascontiguousarrayfixes 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, andchoose_dweight_split_kalready 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 (newfrag_layout_b_col; dweight's A operand uses the same trick). A and B vectorize under the sameC_OUT % 8condition. dinput kernel time roughly halved again (conv 03: 1.44 → 0.78 ms).fp32 forward input (
0646072)conv_forward_wmmatakeshalforfloatinput (a template type, now part of the forward cache key, seeforward_cache_key).floatis converted as it's gathered, rounding exactly asastype(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
Conv2dforward now give correct output instead of silently wrong output.main's by the double rounding.main.Testing
python -m unittest discover -s tests -t .: 1140 tests, 1069 pass on NumPy and CuPy (69 skipped). The 2 errors aretest_save_replaces_symlink_instead_of_following_iton both backends, a Windows symlink issue that also fails onmain.test_conv_gpu_forward_view_inputs_match_contiguous: the transposed case fails onmain(2048 / 2048 mismatched). The misaligned fp16 view case fails on this branch without the alignment guard (cudaErrorMisalignedAddress).mainwhere they apply):tests/ops/test_conv_vector_staging.py)tests/ops/test_conv_tile_choice.py)test_conv_gpu_forward_input_dtypes_round_like_astype)test_conv_kernel_compile_fallbackreads the forward cache key fromforward_cache_key(), since that key now includes the input dtype.fdbc9ac:test_backward_without_input_grad_keeps_parameter_gradscompares 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.Follow-ups (out of scope)
HIP / gfx1201 is untested. The build of
alignas,uint4,__floats2half2_rnand 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):