← Vikram Narra · Writeups
ML Systems  ·  Triton  ·  GPU Kernels

A fused RMSNorm kernel in Triton, and what its bandwidth number actually means

A from-scratch kernel for the normalization in Llama, Qwen, Mistral and Gemma, forward and backward each fused into a single launch and wired into PyTorch autograd. It lands at roughly 6× over eager and 45% of an H100's peak HBM bandwidth. The interesting part of that sentence is the 45%.

5.96×faster than eager
44.9%of HBM3 peak
2HBM trips, not 12
1kernel launch
View the code on GitHub
NVIDIA H100 80GB SXMkernels/rmsnorm.pybench/benchmark.pySLURM 604545 · 604586reproducible end-to-end
01 · The op

What RMSNorm is, and why fusing both passes matters

RMSNorm takes a row x ∈ ℝN, divides it by its root mean square, and scales by a learned gain w:

There is no mean subtraction and no bias, unlike LayerNorm. So the arithmetic per element is a couple of multiplies and one reciprocal square root. That is nothing. The cost is moving x and y through HBM. RMSNorm is memory bound, and for a memory bound op the only thing that moves the wall clock is how many times you touch memory.

The eager PyTorch version touches it many times. This line of math compiles to a chain of separate CUDA kernels: square, mean, add epsilon, rsqrt, two multiplies. Each one reads the activation back from HBM and writes a result out again. On a memory bound op those round trips are the runtime.

eager spelling, six kernels
variance = x.pow(2).mean(dim=-1, keepdim=True)
x = x * torch.rsqrt(variance + eps)
return weight * x

A fused kernel reads each row once, does all the math in registers, and writes the result once. The speedup you should expect is roughly the number of HBM round trips you removed, a prediction to carry into the results. Fusing the backward adds one more win: save one float of normalization scale per row in the forward, and the backward reads it back instead of recomputing the whole reduction. The cost is a tiny O(M) write, rounding error next to the O(MN) activation traffic.

Interactive · where the time goes

Every kernel is an HBM round trip

Same memory bus, same time axis. Press play and watch the fused kernel finish while eager is still grinding through reads and writes.

eager  · 6 kernelsHBM transfers: 0  ·  0.0000 ms
mean
+ ε
rsqrt
× rstd
× w
fused  · 1 kernelHBM transfers: 0  ·  0.0000 ms
read · compute · write
read from HBMwrite to HBM6 fewer trips ≈ 6× faster
02 · Design decisions

Four choices, each with its tradeoff stated

a.One program per row, single launch

Each Triton program owns one row and keeps the whole row resident in registers and SRAM. The forward reads x once and writes y once. The tile width is fixed per call to next_power_of_2(N) so a single tile spans the row, with the tail masked when N isn't a power of two.

forward kernel · hover a line for the why
row  = tl.program_id(0)
One program owns one row. No cross-row coordination.
cols = tl.arange(0, BLOCK_SIZE)
BLOCK_SIZE = next_pow2(N): a single tile spans the row.
mask = cols < N
Masks the tail when N is not a power of two.
x = tl.load(X + row*sx + cols, mask).to(fp32)
Read x once. Upcast to fp32 for the reduction.
mean_sq = tl.sum(x * x, axis=0) / N
The only reduction. Accumulated in fp32, not the storage dtype.
rstd = tl.math.rsqrt(mean_sq + eps)
One reciprocal sqrt, the whole of the "compute".
tl.store(Rstd + row, rstd)
Save the scale so the backward reads it instead of recomputing.
w = tl.load(W + cols, mask).to(fp32)
Gain vector, also upcast.
y = x * rstd * w
All math stays in registers.
tl.store(Y + cols, y.to(dtype), mask)
Write y once. Two HBM touches, start to finish.

The tradeoff. The whole row must fit in one tile, so the design caps the hidden dimension. I guard it at 65536 and raise rather than silently produce garbage. Real hidden dims (2048 to 8192) sit comfortably inside that, but it's a real limit, a very wide last dimension would need a multi-pass variant. I'd rather state the limit than pretend the kernel is general.

b.fp32 reductions regardless of storage dtype

Every reduction accumulates in fp32 even when x is fp16 or bf16. The kernel upcasts on load and only rounds back down at the final store. The sum of N squares is exactly where low precision bites: you're adding N positive numbers, the running total grows while increments stay small, and bf16's 8 mantissa bits stop representing small additions, error that is systematic, not averaging out.

free here

Memory bound, not compute bound, the extra fp32 registers aren't the bottleneck, the bus is. A correctness win at no cost.

not always

In a compute bound kernel the register pressure could lower occupancy. That just isn't the situation here.

c.Atomic-free weight gradient

The two gradients, with x̂ᵢ = xᵢ·rstd and dywᵢ = dyᵢ·wᵢ:

dx is a per-row reduction, one program per row, no coordination. dw is the awkward one: it sums one contribution from every row into a single length-N vector. The obvious atomic-add into a shared buffer works, but atomics on the same addresses serialize, and contention worsens as you add parallelism.

Instead, each program gets its own private accumulator of length N and walks a contiguous chunk of rows with a grid-stride loop. No two programs ever write the same address. A small torch.sum reduces the [n_programs, N] partial buffer down to [N].

dw accumulation · private, no atomics
dw_acc = tl.zeros((BLOCK_SIZE,), fp32)
A private accumulator. No shared buffer, no atomics.
for row in range(row_start, row_end):
Grid-stride: this program owns a contiguous chunk of rows.
    x  = tl.load(X  + row*sm + cols, mask)
x and dy read once, shared by dx and dw.
    dy = tl.load(DY + row*sm + cols, mask)
    xhat = x * tl.load(Rstd + row)
rstd reused from the forward, no reduction here.
    dw_acc += dy * xhat
Fold each row in privately. Two programs never share an address.
tl.store(DW_partial + pid*N + cols, dw_acc)
One partial row per program; a tiny torch.sum finishes it.

The program count is capped at the SM count, so the partial buffer stays small. The quiet extra win: dx and dw share the same x and dy loads, so the backward reads each once even though it produces two gradients.

d.Autotuning

Autotuned per N over num_warps{1,2,4,8,16,32} and num_stages{1,2,4}, how many warps cooperate on one row (memory-level parallelism) and how deep the load pipeline is. BLOCK_SIZE is deliberately pinned, not searched, because correctness requires BLOCK_SIZE ≥ N. The honest caveat: the search space is small, it covers the knobs I was confident mattered, which is part of why there's bandwidth left on the table.

The whole thing sits behind a torch.autograd.Function that flattens any […, N] input to [M, N] and reshapes back. So rmsnorm(x, w) is a drop-in differentiable op.

03 · Method

How I measured

The headline metric is not FLOP/s. For a memory bound op FLOP/s is misleading; the number that means something is effective bandwidth as a fraction of what the card can deliver. Timing goes through triton.testing.do_bench, warm up, many reps, median, cold launches discarded, so autotuning and one-time compilation stay out of the measurement.

For bandwidth I count only the activation traffic the kernel is obliged to move. Forward is read x + write y = 2MN elements. Backward is read x, read dy, write dx = 3MN. The weight, the saved rstd, and the partial buffer are O(M+N), negligible, so I leave them out. Padding the byte count with traffic the op doesn't need would inflate the number.

Interactive · the bandwidth model, live

Check the % of peak by hand

The same arithmetic from the post, recomputed as you turn the knobs. Pre-filled with the verified headline measurement.

ROWS · M
HIDDEN DIM · N
DIRECTION
DTYPE
PEAK · DEVICE
MEASURED TIME0.0893 ms
TRAFFIC = 2 × M × N × bytes
= 2 × 4,096 × 8,192 × 2
= 134,217,728 bytes
= 0.1342 GB
EFFECTIVE BANDWIDTH = GB / time
1503.0 GB/s
% OF H100 PEAK (3350 GB/s)44.9%
070% · streaming ceilingpeak

Note: effective bandwidth is a model, counted bytes over measured time, not a DRAM-counter reading. Both fp16 and bf16 store 2 bytes/element, so dtype changes the measured time, not the byte count.

04 · Correctness

Compared against an fp32 oracle, not another kernel

I don't bit-match against an eager fp16 implementation, two correct fp16 kernels can differ in the last bit from rounding order, and matching one spelling tests the wrong thing. Instead I compare against an fp32 oracle: computed end-to-end in fp32, rounded to fp16/bf16 only at the very end. That gives an honest claim, "within X of the true value", rather than "identical to some other code I wrote."

fp16
atol 1e-3 · rtol 1e-2
bf16
atol 1e-2 · rtol 2e-2 · fewer mantissa bits

The backward is checked the same way, analytic Triton gradients against autograd gradients of the oracle. gradcheck wants float64, which Triton doesn't support, so the fp32 oracle comparison is the right tool. The sweep covers hidden dims {2048, 4096, 8192} × row counts {512, 1024, 2048, 4096} × {fp16, bf16}, plus non-power-of-two and 3D shapes, plus the gradient checks.

40
passed · 0 failed
SLURM 604545 · node trig0006 · 1m57s · same shapes as the benchmark
05 · Results

Bandwidth bound at 45% of peak, with half the budget still on the table

NVIDIA H100 80GB SXM · HBM3 · 3.35 TB/s peak · benchmark SLURM 604586 · node trig0024 · 1m12s · forward, M=4096, N=8192

dtype
fused
eager
speedup
GB/s
% peak
bf16
0.0893 ms
0.5322 ms
5.96×
1503.4
44.9%
fp16
0.0970 ms
0.5319 ms
5.48×
1384.1
41.3%
fraction of the 3.35 TB/s bus, bf16 forward
44.9%
70 to 80% · what a well-tuned streaming kernel reaches
0 GB/s3350 GB/s, HBM3 peak
The 6× matches the prediction

Eager is ~six kernels each re-reading the activation; fused reads once and writes once. The measured speedup lining up with the round trips removed says the win is structural, not a measurement artifact.

The 45% is the honest part

Being bandwidth bound is the good news, not stalling on launches, not spending time on arithmetic, limited by the bus, which is the right place to be stuck. But 45% isn't the ceiling.

Two honesty notes. First, 1503 GB/s is an effective bandwidth from a model, bytes counted over time, not a hardware counter. It's a fair model because the byte count is the true minimum traffic, but reconciling it against the DRAM throughput counter is the first thing I'd do to push further. Second, bf16 edges fp16 (1503 vs 1384 GB/s at the same byte count), a conversion-path difference, not anything algorithmic.

So the summary I'd actually stand behind: correct across the sweep, a clean 6× over eager, bandwidth bound at about 45% of peak, with roughly half the bandwidth budget still unclaimed.

06 · Next

What I'd do to push toward peak

Hypotheses for the gap, not a fixed answer, I haven't put this under Nsight Compute yet. The honest first step is to profile, then chase the biggest item. In rough order:

01
Profile first

Pull dram__bytes and achieved DRAM throughput from ncu and compare against the 1503 GB/s model. Disagreement means extra traffic (a reload, poor coalescing); agreement means the kernel is simply latency bound, a different fix.

02
Reconsider one-program-per-row at large N

At N=8192 a bf16 row is 16KB. A single program streaming 16KB may not keep enough requests in flight to hide HBM latency. Try multiple rows per program or a 2D tiling so more warps issue loads concurrently.

03
Check vectorization

Confirm Triton emits 128-bit ld.global.v4 loads on the activation reads. The masked load for non-power-of-two N can block it, but power-of-two shapes should vectorize, if not, that is throughput left on the floor.

04
Widen the autotune space

Today it only sweeps num_warps and num_stages. Adding rows-per-program or tile shape, once the layout supports it, lets the search find the config that actually saturates the bus per shape.

05
Fold the backward’s final reduction

The dw path ends in a separate torch.sum over the partial buffer, a second small launch. A tree reduction in-kernel, or a tiny fused pass, removes it. Small; a tidy-up, not the main lever.

None of these are guaranteed wins, which is the point of profiling first. The gap from 45% to the 70 to 80% a memory bound kernel can reach is real, I can see the candidate causes, and the next stretch of work is measuring which one is actually costing the bandwidth.