Skip to content

[Kernel][Perf] Tune RMSNorm for gfx1250: BF16 hardware conversion, early loads, small-N kernel choice - #1227

Open
jhinpan wants to merge 3 commits into
mainfrom
perf/gfx1250-rmsnorm-vector-tail
Open

jhinpan wants to merge 3 commits into
mainfrom
perf/gfx1250-rmsnorm-vector-tail

Conversation

@jhinpan

@jhinpan jhinpan commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

#848 vectorized RMSNorm for partial tiles, small N, and N % 8 != 0, and tuned it for gfx942 and gfx950. On gfx1250 its kernels still lose time in five places. This PR fixes them on gfx1250 only. The LLVM IR for gfx942 and gfx950 does not change. On gfx1250 the geomean speedup over main is 1.43x for RMSNorm (159 rows) and 1.57x for fused-add RMSNorm (40 rows).

Motivation

On gfx1250, main @ 1bd7ebd:

  1. BF16 outputs use integer rounding, although gfx1250 has v_cvt_pk_bf16_f32. The integer rounding also turns NaN payloads >= 0x7FFF8000 into ±0.
  2. Rows wait for more than one memory latency. The compiler cannot move a load above a store that may alias it: the plain and small-N kernels load gamma in pass 2, after the first output store, and the fused kernel interleaves its x and residual loads with the residual_out stores. Loads written in pass 1 are not enough: to save VGPRs, the scheduler sinks the gamma loads, and the x load of a partial last tile, below the first wait. Example: RMSNorm 1 x 32768 takes 8.29 µs.
  3. At N = 32768 the bounds checks of full tiles raise the plain kernel to 258 VGPRs. Above 256 VGPRs the kernel needs s_set_vgpr_msb bank switches.
  4. For 16-bit rows with N <= 2048, every M <= 8192 (FWD_MULTI_ROW_THRESHOLD) takes one 256-thread workgroup per row. At N = 512 only 64 of the 256 threads have work. Example: RMSNorm 4096 x 512 takes 4.81 µs, but 16384 x 512, which takes the multi-row kernel, takes 3.58 µs.
  5. Each row reduces its sum of squares with two barriers, the second only to pass on the result of wave 0, and then divides it by N with a correctly rounded division sequence.

Changes

All changes apply on gfx1250 only.

  • rmsnorm_common.has_hw_cvt_pk_bf16_f32() selects v_cvt_pk_bf16_f32 in every RMSNorm builder: forward, small N, fused add, fused-add quant, and backward.
  • The plain and small-N kernels load gamma in pass 1. The plain kernel, and the multi-row small-N kernel when a lane holds more than three vectors (N > 768), issue every x and gamma load before a scheduling barrier (rocdl.sched_barrier(0)). The fused kernel issues every x, residual, and gamma load before the first residual_out store. rmsnorm_common.preload_weight_vec() keeps 16-bit gamma unconverted in registers. FP32 gamma takes two 128-bit loads, as before.
  • The plain and fused kernels reduce the sum of squares after one barrier: every wave reads the partial sums from LDS. The fused kernel no longer reduces a second, unused value. The mean is sum_sq * (1 / N).
  • The plain and fused kernels skip the bounds checks of tiles that lie wholly inside the row. At N = 32768 the plain kernel then uses 252 VGPRs instead of 258, and the fused kernel 238 instead of 254.
  • The small-N module takes the multi-row kernel:
    • at every M when each lane handles at most three 8-element vectors of a row (N <= 768);
    • for M > N when N > 768. For M <= N, one workgroup per row stays faster.
  • New test test_rmsnorm_bf16_output_keeps_nan puts NaN payloads (0xFFFFFFFF, 0x7FFFFFFF, 0x7FC00000) into FP32 weights. It checks the BF16 output of the plain, small-N (one-row and multi-row), partial-tile, and fused-add paths. It runs where the hardware conversion exists. On gfx1250 its 8 cases fail on main and pass with this PR.

Performance (gfx1250, MI450-class, 256 CUs)

BF16 input, weight, and output. Main @ 1bd7ebd and this PR run in one process. Hot device time, median of 25 interleaved samples.

Kernel M x N main (µs) This PR (µs) Speedup
RMSNorm 1 x 7168 2.21 1.41 1.57x
RMSNorm 64 x 7168 3.06 1.83 1.67x
RMSNorm 4096 x 7168 14.33 11.51 1.25x
RMSNorm 64 x 2880 2.47 1.91 1.30x
RMSNorm 32768 x 8192 92.20 69.04 1.34x
RMSNorm 1 x 32768 8.26 2.03 4.06x
RMSNorm 4096 x 32768 100.19 41.18 2.43x
RMSNorm 4096 x 512 4.73 1.82 2.59x
RMSNorm 4096 x 1536 6.67 2.63 2.54x
RMSNorm 4096 x 2048 7.94 3.28 2.42x
Fused add 1 x 7168 3.35 1.74 1.93x
Fused add 64 x 7168 4.35 2.13 2.04x
Fused add 16384 x 7168 74.19 63.03 1.18x
Fused add 64 x 2880 3.34 2.44 1.37x
Fused add 1 x 32768 13.65 2.76 4.95x
Fused add 4096 x 32768 162.85 77.64 2.10x
  • All rows: RMSNorm 0.98x to 4.08x (159 rows, geomean 1.43x, median 1.27x). Fused add 1.005x to 4.95x (40 rows, geomean 1.57x, median 1.50x). The rows include F16, F32, N % 8 != 0, and N from 8 to 32768.
  • One row is below 1.0x: RMSNorm 256 x 384 BF16 (0.98x). Two identical kernel trees, measured the same way over the small-N rows, give 0.87x to 1.07x against each other.
  • F32 rows: 1.01x (RMSNorm 64 x 7168) and 1.03x (64 x 1536).
  • Issuing the loads first and reducing after one barrier (items 2 and 5 above) give 1.036x in geomean for RMSNorm and 1.031x for fused add, and up to 1.22x at M <= 256. Two rows that move more than 512 MB per call lose: RMSNorm 32768 x 8192 by 2.5% and fused add 4096 x 32768 by 3.7%.
  • The multi-row small-N kernel issuing its loads first (bb146e1) gives 1.106x in geomean over the 69 BF16 rows with N >= 1000 against 824a384 (min 0.975x), and 1.15x to 1.60x at M >= 4096 for N = 1536 and 2048. Rows with N <= 768 keep the earlier code.

Small-N kernel choice. Hot µs, one workgroup per row / multi-row kernel, BF16, both kernels of this PR forced:

N M = 64 256 1024 2048 4096
512 1.78 / 1.67 1.84 / 1.68 2.12 / 1.64 2.54 / 1.67 4.80 / 1.78
768 1.80 / 1.71 1.86 / 1.61 1.98 / 1.63 2.58 / 1.69 4.40 / 1.91
1024 1.78 / 2.17 1.84 / 2.04 1.93 / 2.08 3.38 / 2.13 4.46 / 2.37
1536 1.82 / 2.55 1.87 / 2.58 2.08 / 2.64 3.44 / 2.77 6.07 / 3.16
2048 1.63 / 2.94 1.69 / 2.95 2.41 / 3.04 3.27 / 3.20 7.04 / 4.00

Over 131 sweep rows (N = 8 to 2048, M = 1 to 16384), the rule picks a kernel at most 2.4% slower than the faster one (N = 2048, M = 2048). In this sweep the multi-row kernel also skipped its full-tile checks; the PR keeps them there, which changes it by 2 VGPRs. The fixes for items 2 and 5 came after this sweep. They do not change the multi-row kernel, and they make the RMSNorm rows with N <= 2048 and M <= 256 3.9% faster in geomean. bb146e1 then makes the multi-row kernel faster for N > 768 (4096 x 1536: 3.33 to 2.69 µs; 4096 x 2048: 4.10 to 3.32 µs). The rule was not re-tuned after either change.

Comparison with AITER

AITER a6d2b564f (the installed package) on the same gfx1250 part, with the same inputs. Outputs are checked against an FP32 reference. Hot device time, median of 25 interleaved samples. AITER uses its C++ HIP kernel for 16-bit rows with N <= 8192, and opus otherwise (rmsnorm.py). AITER time / this PR time, geomean (range); below 1.0x, AITER is faster:

Rows (BF16) RMSNorm Fused add
2880 <= N <= 8192, M <= 256 0.87x (0.80-0.93), 13 rows 0.95x (0.84-1.06), 10 rows
2880 <= N <= 8192, M >= 4096 0.99x (0.86-1.13), 10 rows 0.99x (0.93-1.23), 7 rows
N <= 2048, M <= 256 0.81x (0.73-0.92), 15 rows
N <= 2048, M >= 4096 2.38x (1.21-4.40), 7 rows
N >= 16384 2.52x (1.60-4.44), 4 rows 2.75x (1.50-6.20), 4 rows
  • AITER is faster on small-M rows. Example: RMSNorm 64 x 7168 takes 1.81 µs here and 1.61 µs in AITER.
  • RMSNorm 64 x 1002 is excluded: AITER's HIP kernel does not write the last N % 8 elements of each row. AITER's opus kernel also returns some wrong rows at N >= 32768 in repeated runs; the rows in this table passed the check.

Testing

  • Unit tests added/updated
  • Performance benchmarks run (gfx1250, tables above)
  • Tested on MI300X: not run. For gfx942 and gfx950 the LLVM IR is identical to main in 20 builder configurations each (plain, small N, fused add, quant, backward; BF16, F16, and F32 inputs; BF16 and FP32 weights).

Dependencies

  • No new third-party dependencies added
  • If new dependencies added, justification provided

Breaking Changes

None.

Related PRs

On gfx1250 the RMSNorm kernels from #848 lose time in four places:

- BF16 outputs use integer rounding, although gfx1250 has
  v_cvt_pk_bf16_f32. The integer rounding also turns NaN payloads
  >= 0x7FFF8000 into +-0.
- The compiler cannot move a load above a store that may alias it. The
  plain and small-N kernels load gamma in pass 2, after the first output
  store, and the fused kernel interleaves its x and residual loads with
  the residual_out stores. Each such load waits one full memory latency.
- At N = 32768 the bounds checks of full tiles raise the plain kernel to
  258 VGPRs, which needs s_set_vgpr_msb bank switches.
- For 16-bit rows with N <= 2048, every M <= 8192 takes one workgroup per
  row. At N = 512 only 64 of its 256 threads have work.

On gfx1250 only:

- has_hw_cvt_pk_bf16_f32() selects the hardware conversion in every
  RMSNorm builder (forward, small N, fused add, fused-add quant,
  backward).
- The plain and small-N kernels load gamma in pass 1. The fused kernel
  issues every x, residual and gamma load before the first residual_out
  store. preload_weight_vec() keeps 16-bit gamma unconverted in
  registers.
- The plain and fused kernels skip the bounds checks of full tiles.
- The small-N module takes the multi-row kernel at every M when each lane
  handles at most three vectors of a row (N <= 768), and for M > N above
  that.

The LLVM IR for gfx942 and gfx950 does not change.
test_rmsnorm_bf16_output_keeps_nan checks that NaN payloads in FP32
weights stay NaN in BF16 outputs on targets with the hardware
conversion.

Signed-off-by: Jin Pan <jin.pan@amd.com>
@jhinpan
jhinpan force-pushed the perf/gfx1250-rmsnorm-vector-tail branch from 986b766 to 0f0ddbc Compare October 9, 2026 02:14
@jhinpan jhinpan changed the title [Kernel][Perf] Keep RMSNorm on the 128-bit path for partial tiles and small N [Kernel][Perf] Tune RMSNorm for gfx1250: BF16 hardware conversion, early loads, small-N kernel choice Oct 9, 2026
@jhinpan
jhinpan marked this pull request as ready for review October 9, 2026 02:16
zhiding512
zhiding512 previously approved these changes Oct 9, 2026
@zhiding512

Copy link
Copy Markdown
Collaborator

gfx1250: RMSNorm 4096×1536 and 4096×2048 (BF16) are slower than main

I benchmarked this PR against its base (main @ 1bd7ebd) on gfx1250 (MI450-class, 256 CUs). Most rows of the table reproduce, for example RMSNorm 1×32768 at 3.4–3.9x and fused add 4096×32768 at 2.1x. The two small-N rows listed at about 2x run slower than main here:

BF16, M × N main (µs) this PR (µs) speedup 3 runs PR description
4096 × 1536 7.79 8.83 0.88x 0.87–0.89x 6.60 → 3.28 µs, 2.01x
4096 × 2048 8.07 10.31 0.78x 0.76–0.80x 7.87 → 4.00 µs, 1.97x

Cause: the new small-N kernel choice. On main, M = 4096 ≤ FWD_MULTI_ROW_THRESHOLD, so these shapes run one workgroup per row. With this PR, NUM_VEC_ROW_ITERS is 6 (N = 1536) and 8 (N = 2048), so MULTI_ROW_MIN_M = N and M = 4096 takes rmsnorm_large_m_small_n_kernel. The slowdown starts exactly at that threshold:

N M = N (one row per workgroup in both) M = N + 1 (this PR switches to multi-row)
1536 1.01–1.02x 0.68–0.72x
2048 1.02–1.06x 0.60–0.63x

Why the multi-row kernel is slow here. In the final ISA it serializes its x loads:

  • N = 2048, in main and in this PR: every x tile is loaded into the same VGPRs (buffer_load_b128 v[2:5]) and converted to FP32 before the next load is issued, so a wave makes 8 dependent trips to memory.
  • N = 1536: main loads the six x tiles into separate VGPRs back to back and waits once. With the gamma preload, this PR loads every x tile into v[0:3], each paired with a gamma load and followed by s_wait_loadcnt 0x1: six trips instead of one. This also makes this PR's multi-row kernel slower than main's where main already uses it (M > 8192): N = 1536 runs at 0.76–0.86x for M = 8193 to 65536.

The compiler is the pinned LLVM (e2a39f504), so the ISA should match yours. The difference is probably memory latency. Here, latency-bound rows run 1.4–2.1x slower than in your table (RMSNorm 1×32768 on main: 14.3–15.8 µs vs 8.61 µs), while bandwidth-bound rows match (32768×8192 on main: 81.8–84.4 µs vs 86.35 µs). Serialized loads cost more as latency grows, so the M > N rule that wins on your machine loses on this one.

Possible fixes, not tested: keep one workgroup per row for N > 768 at M ≤ 8192 on gfx1250, as main does, or make the multi-row kernel issue all its x loads before the first wait. It may also be worth checking the rule on a second gfx1250 machine.

Method and a minimal check
  • main @ 1bd7ebd vs this PR @ 0f0ddbc. FlyDSL built from source with the pinned LLVM.
  • Both versions, plus a second copy of main as an A/A control (0.99–1.01x on these two rows), run in one process on the same buffers. Device time from torch.profiler kernel records, synchronize after each launch, median of 24 interleaved rounds. Two runs on one GPU, one on another.
  • Back-to-back launches without synchronize agree: 0.78–0.81x and 0.66–0.67x.

Run in each checkout:

import torch
from torch.profiler import ProfilerActivity, profile

from kernels.norm.rmsnorm_kernel import rmsnorm_fwd

for M, N in [(4096, 1536), (4096, 2048)]:
    x = torch.randn(M, N, device="cuda", dtype=torch.bfloat16)
    w = torch.rand(N, device="cuda", dtype=torch.bfloat16)
    for _ in range(20):
        rmsnorm_fwd(x, w)
    torch.cuda.synchronize()
    with profile(activities=[ProfilerActivity.CUDA]) as prof:
        for _ in range(100):
            rmsnorm_fwd(x, w)
            torch.cuda.synchronize()
    kernels = [e for e in prof.profiler.kineto_results.events() if str(e.device_type()) == "DeviceType.CUDA"]
    us = sorted(e.duration_ns() / 1e3 for e in kernels)
    print(f"{M}x{N}: {kernels[0].name()} median {us[len(us) // 2]:.2f} us")

On this machine it prints rmsnorm_kernel_1 at 6.17 / 6.57 µs on main and rmsnorm_large_m_small_n_kernel_0 at 7.05 / 8.49 µs with this PR.

@zhiding512
zhiding512 dismissed their stale review October 9, 2026 10:23

performance issues.

On gfx1250 the scheduler sank the gamma loads, and the x load of a
partial last tile, below the first wait of the plain kernel, so each
row waited two or three memory latencies. Issue every x and gamma load
before a scheduling barrier. The plain and fused kernels now reduce the
partial sums after one barrier, with every wave reading them; the fused
kernel no longer reduces a dummy zero; and the mean uses the reciprocal
of N instead of a correctly rounded division.

The LLVM IR for gfx942 and gfx950 does not change.

Signed-off-by: Jin Pan <jin.pan@amd.com>
…N kernel

From four 128-bit vectors per lane on (N > 768 for 16-bit rows), the
multi-row small-N kernel converted each x vector to FP32 before the next
load issued, so a lane waited one memory latency per vector of its row.
At N = 1536 and 2048 one load issued before the first wait, instead of
all 12 and 16 x and gamma loads. Issue the loads first behind a
scheduling barrier, as the one-row kernel does. With three or fewer
vectors the loads already issue together and the barrier only costs
3-5%, so those rows keep the old code.

gfx1250, against 824a384: 4096 x 1536 1.24x, 4096 x 2048 1.24x,
M = N + 1 1.41-1.46x, M = 8193 to 65536 1.15-1.60x; the 69 rows with
N >= 1000 1.106x geomean (min 0.975). gfx942 and gfx950 IR is unchanged.

Signed-off-by: Jin Pan <jin.pan@amd.com>
@jhinpan

jhinpan commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

Your ISA reading was right. bb146e1 makes the multi-row small-N kernel issue every x and gamma load before the first wait when a lane holds more than three vectors (N > 768 for 16-bit rows). Before the first s_wait_loadcnt, N = 1536 now issues all 12 loads and N = 2048 all 16, against one load at 824a384. Rows with three or fewer vectors keep the old code, and the M > N rule is unchanged.

On our gfx1250, with 824a384 and bb146e1 in one process plus an A/A copy (hot device time, median of 25 interleaved samples):

BF16, M × N 824a384 (µs) bb146e1 (µs) speedup
4096 × 1536 3.33 2.69 1.24x
4096 × 2048 4.10 3.32 1.24x
1537 × 1536 2.86 2.03 1.41x
2049 × 2048 3.31 2.27 1.46x
65536 × 1536 39.37 24.66 1.60x

M = 8192 to 65536 gains 1.15x to 1.60x at N = 1536 and 2048, so N = 1536 is now ahead of main there too (65536 × 1536 on main: 34.08 µs). Our machine has lower memory latency than yours, so could you rerun your two rows and the M = N / N + 1 pairs on it? If 4096 × 1536 or 4096 × 2048 still lose to main there, the fallback is your first suggestion: one workgroup per row for N > 768 at M ≤ 8192.

This branch has not been deployed

No deployments
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.

2 participants