Skip to content

gemm: add fp8 per-tensor grouped GEMM forward (M-grouped/MoE) - #887

Open
kyle-256 wants to merge 3 commits into
mainfrom
dev/kyle/grouped_gemm
Open

gemm: add fp8 per-tensor grouped GEMM forward (M-grouped/MoE)#887
kyle-256 wants to merge 3 commits into
mainfrom
dev/kyle/grouped_gemm

Conversation

@kyle-256

Copy link
Copy Markdown

Motivation

FlyDSL currently ships dense fp8 GEMMs (kernels/gemm/fp8_gemm_4wave.py /
fp8_gemm_8wave.py) but has no grouped (M-grouped / MoE) GEMM. MoE models
need a grouped GEMM where several per-expert matmuls with a shared K but
different row counts run as one launch. This PR adds the fp8 per-tensor
grouped GEMM forward (NT: out = a @ b^T), reusing the existing dense fp8
GEMM primitives so it stays consistent with the rest of kernels/gemm.

Technical Details

New kernel kernels/gemm/fp8_grouped_gemm.py:

  • Layout. a = [M_total, K] fp8 (the groups concatenated along M),
    b_T = [G, N, K] fp8 (per-group weights), out = [M_total, N],
    group_offs = [G+1] int64 splitting M_total into G groups. group_offs
    is read as an int32 view (a free reinterpret; low word at i32[2*idx]).
  • CPU-sync-free grouping. The grid is over-launched to a host upper bound
    (ceil(M_total/BLOCK_M) + G) * n_blocks (no host read of the group lengths).
    Each WG computes the true total_tiles on-device via an O(G) scan and
    s_endpgms when its tile id is past the end (one tile / WG, full-device
    default). The same scan maps a tile id → (group_idx, local tile).
  • int64-safe per-group addressing. A/B loads fold the group element offset
    (m_start*K / group_idx*N*K) into the i64 SRD base, so in-tile offsets stay
    int32 even for A/B beyond 2^31 elems / 4 GB; the SRD num_records bound clamps
    the last over-read to 0. The C store passes c_rows = group_offs[group_idx+1]
    (the absolute group-end row) so a partial M-tile's extra rows (which belong to
    the next group) are dropped by the row-band SRD bound — no spill across groups.
  • Reuse. The shared LDS coop-loaders (G2SLoader / S2RLoader), global
    swizzle, and barriers come from fp8_gemm_utils. The grouped-specific
    primitives — i64-rebased SRD, row-band scalar store, in-place AGPR MFMA,
    K-tail mask, L2-reuse tile swizzle, and XCD remap — are local to the new file.
  • Scales are scalar per-tensor (a_scale, b_scale).
  • Ported from Primus-Turbo, authored with FlyDSL.

Public entry: grouped_gemm_fp8_forward(a, b_T, a_scale, b_scale, group_offs, out_dtype=torch.bfloat16), with a compile cache keyed by the static dims
(N, K, G, out_fp16, cbsz, blgp).

Also adds tests/kernels/test_fp8_grouped_gemm.py (correctness + perf; a torch
per-group reference and a CLI benchmark entry).

Test Plan

Run on gfx950 (MI355X) with flydsl 0.2.2:

pytest tests/kernels/test_fp8_grouped_gemm.py -q

The parametrized cases deliberately cover the group-handling edge paths:
aligned groups, ragged (non-BLOCK_M-multiple) groups, an empty group
(M_g = 0), a single-token group (M_g = 1), and a large-K shape — so the
on-device group scan, K-tail masking, and partial-M-tile / group-boundary clamps
are all exercised. Correctness is checked against a torch per-group reference
with verify_output(rtol=0.1, atol=0.1).

Test Result

5/5 pass:

case shape (group_ms / N / K) TFLOPS
aligned [256,256,256,256] / 512 / 512 41.7
ragged [300,128,700,45] / 1024 / 768 126.4
empty group [1024,0,512,1536] / 2048 / 2048 972.5
single token [1,255,4096] / 4096 / 1024 1050.2
large-K [2048,2048] / 4096 / 7168 2707.8
======================== 5 passed, 1 warning in 14.55s =========================

Submission Checklist

Copilot AI review requested due to automatic review settings July 23, 2026 06:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new fp8 per-tensor M-grouped (MoE-style) GEMM forward kernel to kernels/gemm, plus a GPU correctness/perf test harness to exercise grouped edge cases (ragged groups, empty group, single-token group).

Changes:

  • Introduces kernels/gemm/fp8_grouped_gemm.py: a grouped NT fp8 GEMM forward kernel that over-launches the grid and maps tiles to groups on-device, with i64 SRD rebasing and row-band bounded stores.
  • Adds a public Python entrypoint grouped_gemm_fp8_forward(...) with a compile cache keyed by (N, K, G, out_fp16, cbsz, blgp).
  • Adds tests/kernels/test_fp8_grouped_gemm.py covering correctness and reporting TFLOPS across representative grouped shapes.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
kernels/gemm/fp8_grouped_gemm.py New grouped fp8 GEMM forward kernel + Python entrypoint/compile cache.
tests/kernels/test_fp8_grouped_gemm.py New grouped fp8 GEMM correctness + perf harness and benchmark CLI.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +753 to +758
assert a.ndim == 2 and b_T.ndim == 3, f"a{tuple(a.shape)} b_T{tuple(b_T.shape)}"
M_total, K = a.shape
G, N, K_b = b_T.shape
assert K == K_b, f"K mismatch a={K} b_T={K_b}"

out = torch.empty((M_total, N), device=a.device, dtype=out_dtype)
Comment on lines +759 to +763
# The kernel reads group_offs as int64 low-words via a free int32-view (no
# .to(int32) cast); int32 callers are upcast to int64 once.
_go64 = group_offs if group_offs.dtype == torch.int64 else group_offs.to(torch.int64)
go32 = _go64.view(torch.int32)
out_fp16 = out_dtype == torch.float16
Comment thread kernels/gemm/fp8_grouped_gemm.py Outdated
Comment on lines +767 to +772
key = (N, K, G, out_fp16, cbsz, blgp)
launch = _GROUPED_FWD_CACHE.get(key)
if launch is None:
launch = compile_fp8_grouped_gemm(
K=K, G=G, out_fp16=out_fp16, cbsz=cbsz, blgp=blgp
)
Comment on lines +208 to +220
gSA = fx.rocdl.make_buffer_tensor(A_scale, max_size=False, num_records_bytes=4) # 1 fp32
gSB = fx.rocdl.make_buffer_tensor(B_scale, max_size=False, num_records_bytes=4) # 1 fp32
self.sa_div = fx.logical_divide(gSA, fx.make_layout(1, 1))
self.sb_div = fx.logical_divide(gSB, fx.make_layout(1, 1))
self.scale_atom_1 = fx.make_copy_atom(fx.rocdl.BufferCopy32b(), fx.Float32)
self.reg_f32_1 = fx.make_rmem_tensor(fx.make_layout(1, 1), fx.Float32)

def _load_scalar(self, div):
fx.copy(self.scale_atom_1, fx.slice(div, (None, fx.Int32(0))), self.reg_f32_1)
return Vec(fx.memref_load_vec(self.reg_f32_1))[0]

def store(self, c_frag, base_row, base_col):
scale = self._load_scalar(self.sa_div) * self._load_scalar(self.sb_div)
ok = verify_output(out.to(torch.float32), c_ref, rtol=0.1, atol=0.1)
assert ok, f"correctness failed for G={G} M_total={M_total} N={N} K={K}"

_, us = run_perftest(lambda _o: _launch(), out, num_iters=num_iters, num_warmup=num_warmups)
@kyle-256
kyle-256 force-pushed the dev/kyle/grouped_gemm branch 2 times, most recently from fa9c760 to be9f88d Compare July 23, 2026 08:14
Adds a grouped (M-grouped / MoE) fp8 per-tensor GEMM forward kernel
(NT: out = a @ b^T) under kernels/gemm, plus a correctness+perf test.

The grid is over-launched to a host upper bound; each WG computes the
true tile count via an on-device O(G) scan (no host read of group lens)
and s_endpgm's when past the end. The same scan maps a tile id ->
(group, local tile). Per-group A/B addressing folds the group element
offset into the i64 SRD base (int64-safe > 4GB), and the C store passes
the absolute group-end row so partial-M tiles clamp cleanly at group
boundaries. Scales are scalar per-tensor.

Reuses the shared LDS coop-loaders / global swizzle / barriers from
fp8_gemm_utils; grouped-specific primitives are kept local. Ported from
Primus-Turbo, authored with FlyDSL.

Verified on gfx950 (MI355X) with flydsl 0.2.2: 5/5 cases pass
(aligned, ragged, empty-group, single-token, large-K).
The rebase onto main was textually clean and semantically broken. main moved
buffer_ops out of the DSL -- python/flydsl/expr/buffer_ops.py is gone and the module
now lives in the repo's own kernels/common/ -- so `from flydsl.expr import buffer_ops`
fails at import, and the whole kernel with it.

The four entry points this kernel uses (extract_base_index, _get_buffer_flags,
create_buffer_resource_from_addr, buffer_store) came across with identical signatures,
including buffer_store's mask= and offset_is_bytes=, so only the import moves.

Checked every other `from flydsl...` in the file against the tree: all still resolve;
this was the only casualty.
Rebasing onto main moved this kernel from flydsl 0.2.4 to 0.3.x, and it started returning
numbers uncorrelated with the reference -- logits diff 0.98, i.e. not drift, wrong data --
while compiling and running clean.

The cause is the inline-asm MMA's accumulator constraint. asm mode "2" emits
v_mfma_f32_16x16x128_f8f6f4 with "=a,v,v,0": an AGPR-class output tied to srcC. On 0.2.4 that
was correct; on 0.3.x it silently miscompiles, and the shape of the damage is distinctive --
with B = I, so the output must equal A, every fourth output row comes back entirely zero and
the rest are ~25% populated. Four accumulator slots per lane map to four consecutive rows, so
that is one whole accumulator slot lost.

Isolated to the constraint: the operand types entering the asm are right (vector<8xi32>,
vector<8xi32>, vector<4xf32> against a vector<4xf32> result), dropping the
amdgpu-agpr-alloc="128,128" passthrough does not help, and the identical asm with "=v"
(mode "3") is correct. So acc_mode defaults to "vgpr" -- still the in-place asm accumulate
this kernel is built around, just in the VGPR file -- and "agpr" now asserts instead of
returning wrong numbers, since nothing about the failure is visible at compile time.

_raw -> as_ir_value on the asm operands is not the fix; it is the unwrap main moved to in
#913 when it ported the parent fp8_gemm_4wave, and it is what the passing configuration runs.

Measured on gfx950 with flydsl 0.3.2: 5 passed, and 2751 TFLOPS on 2x2048/n4096/k7168 against
2708 for the intrinsic MMA (agpr_inplace=False) -- the same within single-run noise, so the
asm path keeps its reason to exist.

Worth recording for whoever hits this next: the parent kernel does not use inline-asm MFMA at
all, so this path had no coverage in main's 0.2.4 -> 0.3.x ports. Everything else about the
kernel survived the jump.
@kyle-256
kyle-256 force-pushed the dev/kyle/grouped_gemm branch from be9f88d to afd0f6d Compare September 5, 2026 08:44
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