Skip to content

perf(batchnorm): fuse ReLU/LeakyReLU into BatchNorm at finalize - #19

Merged
AlexanderSoftCode merged 2 commits into
mainfrom
perf/batchnorm-fusion
Oct 30, 2026
Merged

AlexanderSoftCode merged 2 commits into
mainfrom
perf/batchnorm-fusion

Conversation

@AlexanderSoftCode

Copy link
Copy Markdown
Owner

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 alpha and compute z = fmaf(x - mean, scale, beta),
out = z > 0 ? z : alpha * z. alpha = 1 is no activation (the default, which
behaves 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_sums and the elementwise
pass. 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's
mask is bit-identical to the forward's.

Model: fusion at finalize

Model.finalize(input_shape, fuse_kernels=True) finds a BatchNorm directly
followed 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 to
NumPy 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_kernels isn't saved: load() fuses again by default.

Results (RX 9070 XT, cifar10_breakdown.py, median of 3)

per step after #18 this PR
model.train() 5.889 ms 5.119 ms (-13.1 %)
ReLU forward + backward, GPU 0.72 removed
BatchNorm forward, GPU 0.404 0.406
BatchNorm backward, GPU 0.842 0.761

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

  • On CuPy, by default, a ReLU or LeakyReLU directly after a BatchNorm no longer
    runs as its own layer. The model's outputs are unchanged, but that layer's
    output attribute is no longer updated. Nothing in the package reads it.
    Pass finalize(..., fuse_kernels=False) to keep every layer.
  • A fused LeakyReLU uses an fp32 slope (BatchNorm is precision-exempt), so
    under set_precision('float16') it is very slightly more accurate than the
    unfused one, whose slope is fp16 (0.0099945 for 0.01).
  • A NaN or inf gradient at an inactive fused ReLU unit gives 0, the same as
    ReLU's GPU backward since perf(relu): fuse the GPU backward into one select kernel #15.
  • NumPy is unaffected.

Testing

  • python3 -m unittest discover tests: 1152 tests pass on NumPy and CuPy (72
    skipped), 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_sums ignores 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:

    • fused and unfused give the same outputs, gradients and Adam step;
    • a fused model moved to NumPy runs every layer and matches the GPU
      predictions, then re-fuses on the way back;
    • save + load keeps both activations in the manifest and re-fuses.

    These fail if to() doesn't re-plan fusion, or if Model.forward runs
    self.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)

  • Inference: fold BatchNorm into the preceding conv's weights at predict time.

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.
@AlexanderSoftCode
AlexanderSoftCode merged commit 34806f4 into main Oct 30, 2026
5 checks passed
@AlexanderSoftCode
AlexanderSoftCode deleted the perf/batchnorm-fusion branch October 30, 2026 08:22
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