Repository navigation
perf(batchnorm): fuse ReLU/LeakyReLU into BatchNorm at finalize - #19
Merged
Merged
Conversation
Every BatchNorm in the CIFAR-10 model feeds a ReLU, which costs its own read + write pass in each direction (0.28 + 0.44 ms per step on the RX 9070 XT). The BatchNorm kernels now take a slope alpha and apply z = fmaf(x - mean, scale, beta) out = z > 0 ? z : alpha * z so alpha = 1 is no activation (the default, and the behavior until now), 0 is ReLU and anything else LeakyReLU. One alpha covers every case, so there is no separate fused code path. The backward recomputes z from x, which it already reads, and uses g = z > 0 ? dy : (alpha != 0 ? alpha * dy : 0) in place of dy in both grad_sums and the elementwise pass. No extra memory and no extra bandwidth. z, the activation and its gradient are one source string (_ACTIVATION), included in the RawKernel prelude and passed to the ElementwiseKernels as their preamble, so the backward's mask is bit-identical to the forward's. As in ReLU's GPU backward, a NaN gradient at an inactive ReLU unit gives 0 rather than NaN. BatchNorm._fuse_activation(alpha) sets the slope and returns True only when the GPU path is bound; _compile_for_device resets it to 1. The slope is fp32 (BatchNorm is precision-exempt), so a fused LeakyReLU under fp16 is slightly more accurate than the unfused one, whose alpha is fp16. With alpha = 1 the CIFAR-10 step time is unchanged (5.913 vs 5.889 ms). Tests: fused ReLU and LeakyReLU against the fallback BatchNorm followed by the activation's own fallback (out, dx, dgamma, dbeta, inference), on inputs that keep every z away from 0 so rounding can't flip a mask; and the NaN case for both slopes.
Model.finalize(input_shape, fuse_kernels=True) now looks for a
BatchNorm directly followed by a ReLU (slope 0) or LeakyReLU (its
alpha), matched on exact types so subclasses never fuse. On CuPy the
BatchNorm applies the activation inside its kernels and the activation
layer is skipped in both directions: Model.forward runs the new
_forward_layers and backward runs _backward_layers, both without the
fused activations.
Fusion is planned in _bind_fusion, which runs at the end of finalize()
and after every to() on a finalized model. Every _compile_for_device
resets BatchNorm to no activation, so a model fused on CuPy and moved
with to('numpy') runs every layer again (otherwise it would silently
skip its ReLUs), and moving it back re-fuses. fuse_kernels=False keeps
every layer on both devices.
self.layers is unchanged, so save(), the manifest, the weight keys and
validate_graph are untouched. fuse_kernels is a runtime choice and is
not saved: load() calls finalize() with the default and fuses again.
A fused activation's `output` attribute is not updated; nothing in the
package reads it (only the loss-fused SoftMax's output is read, and
SoftMax doesn't fuse).
CIFAR-10 on the RX 9070 XT: 5.889 -> 5.119 ms per step (-13.1 %). The
ReLU rows (0.72 ms GPU) drop out of the breakdown and BatchNorm's time
doesn't grow.
Tests, on a Conv -> BatchNorm -> ReLU -> Conv -> BatchNorm -> LeakyReLU
model in strict fp32: fused and unfused give the same outputs,
gradients and Adam step (except the conv biases in front of BatchNorm,
whose true gradient is 0, so Adam scales rounding noise to ~lr); a
fused model moved to NumPy runs every layer and matches the GPU
predictions, then re-fuses on the way back; and save + load keeps the
activations in the manifest and re-fuses.
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
Every BatchNorm in the CIFAR-10 model feeds a ReLU, which costs its own read +
write pass in each direction: 0.28 + 0.44 ms per step on the RX 9070 XT, more
than BatchNorm's forward takes after #18. This PR applies the activation inside
the BatchNorm kernels and skips the activation layer. It follows #18 (Part 2 of
the BatchNorm plan). Two commits: the kernels learn a slope, then the model
uses it. Neither is mechanical.
Kernels: one slope for every case
The BatchNorm kernels take
alphaand computez = fmaf(x - mean, scale, beta),out = z > 0 ? z : alpha * z. alpha = 1 is no activation (the default, whichbehaves exactly as before), 0 is ReLU and anything else LeakyReLU, so there is
no separate fused code path.
The backward recomputes z from x, which it already reads, and uses the
activation's gradient in place of dy in both
grad_sumsand the elementwisepass. No extra memory and no extra bandwidth: one FMA and a select per element.
z, the activation and its gradient are a single source string shared by the
RawKernels and the ElementwiseKernels (through
preamble), so the backward'smask is bit-identical to the forward's.
Model: fusion at finalize
Model.finalize(input_shape, fuse_kernels=True)finds a BatchNorm directlyfollowed by a ReLU or LeakyReLU (exact types, so subclasses never fuse). On
CuPy, BatchNorm applies the activation and the activation layer is skipped in
both directions.
Fusion is re-planned after every
to(). A model fused on CuPy and moved toNumPy would otherwise skip its ReLUs and silently give wrong outputs. Instead
it runs every layer again, and re-fuses when moved back.
model.layers,save(), the manifest and the weight keys are unchanged.fuse_kernelsisn't saved:load()fuses again by default.Results (RX 9070 XT,
cifar10_breakdown.py, median of 3)model.train()Together with #18: 6.691 -> 5.119 ms per step (-23.5 %). With alpha = 1 (no
fusion) the step time is unchanged (5.913 vs 5.889 ms).
Behavior changes
runs as its own layer. The model's outputs are unchanged, but that layer's
outputattribute is no longer updated. Nothing in the package reads it.Pass
finalize(..., fuse_kernels=False)to keep every layer.under
set_precision('float16')it is very slightly more accurate than theunfused one, whose slope is fp16 (0.0099945 for 0.01).
ReLU's GPU backward since perf(relu): fuse the GPU backward into one select kernel #15.
Testing
python3 -m unittest discover tests: 1152 tests pass on NumPy and CuPy (72skipped), at each commit.
Fused ReLU and LeakyReLU against the fallback BatchNorm followed by the
activation's own fallback: output, dx, dgamma and dbeta in training, plus the
inference output. The inputs keep every z away from 0, so rounding can't flip
a mask. Fails if
grad_sumsignores the activation or inference ignores alpha.NaN gradient at an inactive unit: with ReLU, dx, dgamma and dbeta stay
NaN-free and match the unfused GPU chain; with LeakyReLU the NaN propagates.
Fails without the select.
Model level, Conv -> BatchNorm -> ReLU -> Conv -> BatchNorm -> LeakyReLU in
strict fp32:
predictions, then re-fuses on the way back;
These fail if
to()doesn't re-plan fusion, or ifModel.forwardrunsself.layers.The Adam-step check skips the Conv2d biases in front of a BatchNorm. Their
true gradient is 0, so Adam's first step (about lr * sign(g)) turns rounding
noise into ~lr-sized differences. Their gradients are still compared.
Measured on an RX 9070 XT (ROCm). Not yet run on the RTX 3050 (CUDA),
including CUDA compiling the elementwise kernels' new
preamble.LeakyReLU fusion is tested for correctness, but not benchmarked: the CIFAR-10
model only uses ReLU.
Follow-ups (out of scope)