Skip to content

perf(relu): fuse the GPU backward into one select kernel - #15

Merged
AlexanderSoftCode merged 1 commit into
mainfrom
perf/relu-backward
Oct 30, 2026
Merged

AlexanderSoftCode merged 1 commit into
mainfrom
perf/relu-backward

Conversation

@AlexanderSoftCode

Copy link
Copy Markdown
Owner

Context

After PR #14, a breakdown of the CIFAR-10 step on the RX 9070 XT showed conv at ~40 % of GPU time and BatchNorm + ReLU at ~42 %. ReLU's backward stood out: it took 0.90 ms, 3× its forward (0.28 ms), though both should cost about one pass over memory. This PR moves the backward into a single elementwise kernel. One commit, e5f2787. It also strips trailing whitespace in activations.py, so review with git diff -w.

ReLU backward

dvalues * (self.output > 0) ran as two kernels. The compare wrote a 1-byte bool mask, and the multiply read it back as a mixed fp32 × bool operand. Timed alone at 256×32×32×32 fp32 (RX 9070 XT):

ms
forward maximum 0.105
backward as written 0.380
↳ out > 0 0.100
↳ d * bool_mask 0.263
d * fp32_mask, for comparison 0.170
fused output > 0 ? d : 0 0.171

Nothing in it branches, so warp divergence wasn't the cost. The time went to the extra pass and the slow bool operand. The fused kernel reaches ~590 GB/s for its 12 B per element, close to the card's bandwidth (the forward reaches ~640 GB/s).

aether/custom_kernels/relu_kernel.py adds the kernel in the same form as loss_kernel.py: a module-level ElementwiseKernel, an is_gpu_relu_backward_available() check and a launch function. There's no kernel cache, because there's nothing to key it on. CuPy already caches the binary per dtype pair. dvalues and output are typed separately (T, U), so an fp16 output with an fp32 gradient doesn't raise. ReLU._compile_for_device binds it as _backward_gpu and keeps the multiply as _backward_fallback. Forward has no GPU kernel, since maximum already runs at bandwidth. It's bound as _forward_fallback, like the other pointer-swap layers, which test_device_migration expects.

RX 9070 XT, benchmarks/cifar10_breakdown.py (synthetic data, batch 256):

ms/step ReLU backward (5 layers)
main 7.26 0.90 ms
this PR 6.78 0.45 ms

That's −6.6 % per step. A second run gave 6.81 ms.

Testing

  • python3 -m unittest discover tests: 1142 tests pass on NumPy and CuPy (65 skipped).
  • test_backward_mixed_dtypes_and_nan: an fp16 output with fp32 dvalues keeps the gradient dtype, and the NaN case has hand-written expected values for each path. On CuPy it fails on main (NaN where 0 is expected).
  • test_device_migration.test_pointer_swap_binds_gpu_paths_after_to_cupy now covers ReLU too.
  • GPU: tests and benchmark ran on an RX 9070 XT (gfx1201, ROCm) with CuPy 14.2.

Follow-ups (out of scope)

  • Not measured on the RTX 3050. The kernel is a plain ElementwiseKernel with nothing vendor-specific, but the speedup there is unconfirmed.
  • Fuse ReLU into BatchNorm's forward and backward. Together they're still ~3.3 ms of the 9070 XT step, mostly separate full passes over the activations.

ReLU's backward, dvalues * (output > 0), ran as two kernels: the
compare wrote a 1-byte bool mask, and the multiply read it back as a
mixed fp32 x bool operand, which on its own took 0.26 ms where an
fp32 x fp32 multiply of the same shape takes 0.17 ms. Nothing branches,
so warp divergence was not the cost; the extra pass and the bool
operand were.

relu_kernel.py adds one ElementwiseKernel, `output > 0 ? dvalues : 0`,
with dvalues and output typed separately (T, U) so a fp16 output with
an fp32 gradient doesn't raise. ReLU binds it in _compile_for_device
and keeps the multiply as _backward_fallback. Forward has no kernel
(maximum already runs at memory bandwidth), but is bound as
_forward_fallback like the other pointer-swap layers.

Being a select, the GPU path turns a NaN gradient at an inactive unit
into 0 instead of NaN * 0 = NaN. The fallback is unchanged.

RX 9070 XT, CIFAR-10 benchmark: 7.26 -> 6.78 ms/step (-6.6%); ReLU
backward over the 5 layers 0.90 -> 0.45 ms.

Also strips trailing whitespace in activations.py.
@AlexanderSoftCode
AlexanderSoftCode merged commit f61a454 into main Oct 30, 2026
5 checks passed
@AlexanderSoftCode
AlexanderSoftCode deleted the perf/relu-backward branch October 30, 2026 05:13
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