Skip to content

[fp4_gemm_4wave] MXFP4 GEMM perf optimize - #990

Open
benenzhu wants to merge 7 commits into
ROCm:mainfrom
benenzhu:fp4-gemm-4wave-perf
Open

[fp4_gemm_4wave] MXFP4 GEMM perf optimize#990
benenzhu wants to merge 7 commits into
ROCm:mainfrom
benenzhu:fp4-gemm-4wave-perf

Conversation

@benenzhu

@benenzhu benenzhu commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Brings the gfx950 4-wave MXFP4 GEMM to within ~1-3% of aiter's hand-written assembly.

Performance

Measured against aiter's f4gemm_bf16_per1x32Fp4_BpreShuffle_256x256 on MI355X, and against the kernel as it stood before this branch (main @ 421935c).

M=N K before this PR aiter vs before vs aiter
8192 8192 4679 TF 5033 TF 5096 TF +7.57% -1.23%
8192 16384 5078 TF 5443 TF 5520 TF +7.20% -1.38%
16384 8192 4639 TF 5004 TF 5136 TF +7.87% -2.58%
16384 16384 5052 TF 5495 TF 5590 TF +8.78% -1.70%

(TFLOPS, higher is better.) Still slows than aiter asm in prologue and epilogues

Compare scripts: python3 tests/kernels/test_fp4_gemm_4wave.py --vs-aiter
All three run in the same process, alternating blocks over the same 5 rotating input sets (to clear the L2 & LLC cache), warmup 500 + 3x500 timed iterations under a single event pair.

Main changes

Roughly in descending order of impact:

  1. Serpentine 2x2 MFMA emission order so consecutive MFMAs reuse XDL operands.
  2. Scale also goes throught LDS, so we can issue buffer_load_b128...lds now.
  3. g2s uses the m0 set-once + s_add idiom, and the wave-uniform LDS base is readfirstlane'd once instead of per load.
  4. Epilogue: C stores use dwordx4 stores via MFMA operand swap + permlane16,
  5. epilogue with the stores interleaved into the tail MFMAs. Back-to-back buffer_stores queue on L1, so spacing them out behind MFMAs is worth more than issuing them earlier.
  6. Prologue: pinning M/N and the wave count at compile time removes both the software integer divides and the second s_waitcnt lgkmcnt(0), cutting the instructions ahead of the first buffer_load from 272 to 88.

Note:

  1. This has a FP4_DMA_INTRINSIC to turn off the inline asm of buffer_load_lds. But it can't use inc_m0 learned from https://github.com/carlushuang/gcnasm/blob/master/async_copy/main.hip.cc#L50-L53, and will be slightly slower.

Behavior change

BLOCK_M / BLOCK_N must be 256 now.
Also we can passin optional MN=(M, N) to make M/N compile time constant.

benenzhu and others added 4 commits August 9, 2026 10:48
Brings the gfx950 4-wave MXFP4 GEMM to within ~1-3% of aiter's hand-written
assembly. Measured against aiter's f4gemm_bf16_per1x32Fp4_BpreShuffle_256x256
on MI355X, same process, alternating, 5 rotating input sets (so nothing is
served out of MALL), warmup 500 + 3x500 timed iterations:

  M=N     K       fly     aiter   delta
  8192    8192    5034    5092    -1.13%
  8192    16384   5456    5514    -1.05%
  16384   8192    5002    5146    -2.81%
  16384   16384   5499    5592    -1.66%

Main changes, roughly in descending order of impact:

* MFMA accumulators pinned in AGPR via inline asm, freeing arch VGPR for the
  deeper software pipeline.
* Serpentine 2x2 MFMA emission order so consecutive MFMAs reuse XDL operands.
* Depth-3 scale prefetch carried in VGPR; the per-K-step scale gather is a
  single dwordx4-to-LDS per wave, co-issued inside the MFMA execute shadow.
* g2s uses the m0 set-once + s_add idiom, and the wave-uniform LDS base is
  readfirstlane'd once instead of per load.
* b1 operand carried across K-steps, which erases the hot loop's independent
  lgkmcnt waits (68 -> 8 s_waitcnt).
* Epilogue: dwordx4 stores via MFMA operand swap + permlane16, with the stores
  interleaved into the tail MFMAs. Back-to-back buffer_stores queue on L1, so
  spacing them out behind MFMAs is worth more than issuing them earlier.
* Prologue: pinning M/N and the wave count at compile time removes both the
  software integer divides and the second s_waitcnt lgkmcnt(0), cutting the
  instructions ahead of the first buffer_load from 272 to 88.

Note one behavior change: BLOCK_M/BLOCK_N/mn_aligned are replaced by a single
optional MN=(M, N). The kernel is now specialized to a 256x256 block with an
all-in-bounds epilogue, and the test asserts M % 256 == 0 and N % 256 == 0. The
in-repo test was the only caller of the removed parameters.

fp8_gemm_utils.py is deliberately untouched. The one helper that needed a
change (compute_global_swizzle, to take a compile-time wave count) is 17 lines
of pure arithmetic, so it is inlined here as _global_swizzle rather than
altering a function the fp8 kernels share.

Verified: tests/kernels/test_fp4_gemm_4wave.py passes at 8192^3 and 16384^3,
cos=0.999999 against the torch mxfp4-dequant reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Compress the long rationale comments down to one-liners and drop the
FP4_MAIN_VMCNT / FP4_SEG2_VMCNT / FP4_NO_ALIAS debug env knobs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderfeli

Copy link
Copy Markdown
Collaborator

Awesome! CI failed. @benenzhu

Comment thread kernels/gemm/fp4_gemm_4wave.py Outdated
Comment thread kernels/gemm/fp4_gemm_4wave.py Outdated
Comment thread kernels/gemm/fp4_gemm_4wave.py Outdated
Comment thread kernels/gemm/fp4_gemm_4wave.py Outdated
Comment thread tests/kernels/test_fp4_gemm_4wave.py Outdated
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