mirror of
https://github.com/tile-ai/tilelang.git
synced 2026-10-26 07:22:54 +08:00
* [BugFix][Hopper][Blackwell] Read the WGMMA/UMMA K-panel stride from the layout
A shared operand sliced along MN silently computed wrong results whenever
block_K spanned more than one swizzle atom:
T.gemm(a_s, b_s[0:64, :], c0, transpose_B=True) # wrong past the first K atom
When block_K exceeds one swizzle atom the shared layout splits K into panels
stored panel-major -- a (128, 128) bf16 tile decodes as
(128,(64,2)):(64,(1,8192)) -- so stepping to the next K panel moves by the
*buffer's* MN extent. The atom offset formulas instead reconstructed that step
as `mn_extent * swizzle_atom_elems` using the extent of the operand, which is
smaller for a slice. The slice origin was already layout-derived and correct, so
only K atoms past the first were affected: the two halves of a split buffer read
each other's rows, in bounds and without any diagnostic.
block_K == 64 was correct only by accident -- at 16-bit dtypes that is exactly
one 128B atom, so `ki // k_atom_size` is always zero and the bad multiplier was
multiplied away. MN-major operands never reach the term at all.
Take the panel stride from the decoded layout instead of reconstructing it:
cute.restrict already returns the sublayout carrying it, and slicing does not
change a stride. Expose it as `k_panel_elems` on WGMMADescriptorParams /
TCGEN05DescriptorParams, None when the layout has a single K panel or the
operand is MN-major -- exactly the cases where the term is multiplied by zero,
so those keep the previous expression and behaviour.
The same defect was duplicated across all seven atom emitters: dense Hopper
(ss/rs), sparse Hopper (ss/rs) and Blackwell (ss/ts/block-scaled).
Verified on H200 (sm_90a):
- Whole-buffer path is unchanged: across 120 K-major layouts (fp16/bf16/fp8_e4m3/
fp32 x 5 MN extents x 6 K extents), k_panel_elems is either None or exactly
equal to the old reconstruction, for both descriptor builders. The fix is a
strict generalization of the path that already worked.
- New test_tilelang_kernel_gemm_sliced_operand.py cross-checks a sliced operand
against one-buffer-per-slice over operand x majorness x block_K; it fails on
the four K-major block_K>64 combinations before this change and passes after.
- A layout-level assertion covers the Blackwell descriptor, which has no numeric
coverage here for lack of the hardware.
Closes #2964
* [BugFix][Hopper][Blackwell] Refuse a multi-panel K mode with no constant stride
Review follow-up on the K-panel stride decode.
The decode returned None whenever the K mode was not a canonical 2-tuple, and
every consumer then fell back to `mn_extent * swizzle_atom_elems` -- the exact
reconstruction this series removes. The fallback is only sound for a single-panel
layout, where `ki // k_atom_size` is always zero, and nothing checked that. So a
swizzled multi-panel layout that failed to decode would silently re-enter the
wrong-code path: the same shape as the original defect, where a formula's
precondition was assumed but never enforced.
Move the decode into a shared `decode_k_panel_elems` used by both descriptor
builders, and make it assert instead of returning None when a swizzled operand
spans more than one panel without exposing a constant panel stride. Panel count
is rounded up, so a K extent that is not a whole number of atoms cannot slip
through as single-panel.
Also tighten the accept condition: reading `stride[-1]` as the panel step is only
valid once the leading sub-mode is confirmed to be the contiguous atom. On a
panels-first `(2, atom):(panel_stride, 1)` ordering the old condition would have
returned 1 -- a silently wrong step rather than a refusal.
Consumers now read the step through `k_panel_stride(mn_extent)` on the params
dataclass, so the contract and its single-panel default live in one place
instead of being restated at eleven call sites.
New test_tilelang_cuda_wgmma_operand_layout.py covers the decode directly
(hardware-independent): canonical multi-panel strides, the single-panel and
MN-major None cases, and each refusal -- panels-first, flat multi-atom, a
three-sub-mode K, and a partial-atom extent.
Re-verified on H200: the whole-buffer invariant is unchanged by the tightening
(120 K-major layouts x 2 builders, 60 single-panel, 0 disagreements), every
multi-panel K slice is still accepted with the buffer-derived stride, and the
full suite set passes (174 passed, 40 skipped).
179 lines
7.6 KiB
Python
179 lines
7.6 KiB
Python
"""A shared operand that is a slice of a wider buffer must match the same GEMM
|
|
written with one buffer per slice.
|
|
|
|
Regression coverage for the WGMMA K-panel stride: a K-major operand whose
|
|
``block_K`` spans more than one swizzle atom is stored as (MN, (atom, panels)),
|
|
and stepping from one K panel to the next moves by the *buffer's* MN extent. The
|
|
offset formulas used to reconstruct that step from the operand's own extent,
|
|
which is smaller for a slice, so ``T.gemm(A_s, B_s[0:64, :], ...)`` silently read
|
|
the wrong panel for every ``ki`` past the first atom. ``block_K == 64`` (bf16 =
|
|
one 128B atom, single panel) never exercised the term and stayed correct.
|
|
"""
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
import tilelang
|
|
import tilelang.language as T
|
|
import tilelang.testing
|
|
|
|
ACCUM = T.float32
|
|
|
|
|
|
def _matmul_sliced(M, N, K, block_M, block_N, block_K, dtype, split, operand, transposed, sliced):
|
|
"""Split one GEMM into two half-width ones over a shared operand.
|
|
|
|
``operand`` selects which operand is split ("A" splits M, "B" splits N),
|
|
``transposed`` gives that operand's ``transpose_`` flag (which decides whether
|
|
the split axis is the buffer's leading or trailing one), and ``sliced`` picks
|
|
the shape under test (one buffer consumed as two slices) versus the reference
|
|
(one buffer per half).
|
|
"""
|
|
trans_a = operand == "A" and transposed
|
|
trans_b = operand == "B" and transposed
|
|
a_shape = (K, M) if trans_a else (M, K)
|
|
b_shape = (N, K) if trans_b else (K, N)
|
|
split_full = block_N if operand == "B" else block_M
|
|
rest = split_full - split
|
|
|
|
def a_tile(extent):
|
|
return (block_K, extent) if trans_a else (extent, block_K)
|
|
|
|
def b_tile(extent):
|
|
return (extent, block_K) if trans_b else (block_K, extent)
|
|
|
|
@T.prim_func
|
|
def main(
|
|
A: T.Tensor(a_shape, dtype),
|
|
B: T.Tensor(b_shape, dtype),
|
|
C: T.Tensor((M, N), dtype),
|
|
):
|
|
with T.Kernel(T.ceildiv(N, block_N), T.ceildiv(M, block_M), threads=128) as (bx, by):
|
|
C0 = T.alloc_fragment((split, block_N) if operand == "A" else (block_M, split), ACCUM)
|
|
C1 = T.alloc_fragment((rest, block_N) if operand == "A" else (block_M, rest), ACCUM)
|
|
T.clear(C0)
|
|
T.clear(C1)
|
|
|
|
if operand == "B":
|
|
A_s = T.alloc_shared(a_tile(block_M), dtype)
|
|
if sliced:
|
|
B_s = T.alloc_shared(b_tile(block_N), dtype)
|
|
else:
|
|
B_s0 = T.alloc_shared(b_tile(split), dtype)
|
|
B_s1 = T.alloc_shared(b_tile(rest), dtype)
|
|
else:
|
|
B_s = T.alloc_shared(b_tile(block_N), dtype)
|
|
if sliced:
|
|
A_s = T.alloc_shared(a_tile(block_M), dtype)
|
|
else:
|
|
A_s0 = T.alloc_shared(a_tile(split), dtype)
|
|
A_s1 = T.alloc_shared(a_tile(rest), dtype)
|
|
|
|
for ko in T.Pipelined(T.ceildiv(K, block_K), num_stages=0):
|
|
if operand == "B":
|
|
if trans_a:
|
|
T.copy(A[ko * block_K, by * block_M], A_s)
|
|
else:
|
|
T.copy(A[by * block_M, ko * block_K], A_s)
|
|
if sliced:
|
|
if trans_b:
|
|
T.copy(B[bx * block_N, ko * block_K], B_s)
|
|
T.gemm(A_s, B_s[0:split, :], C0, trans_a, trans_b)
|
|
T.gemm(A_s, B_s[split:block_N, :], C1, trans_a, trans_b)
|
|
else:
|
|
T.copy(B[ko * block_K, bx * block_N], B_s)
|
|
T.gemm(A_s, B_s[:, 0:split], C0, trans_a, trans_b)
|
|
T.gemm(A_s, B_s[:, split:block_N], C1, trans_a, trans_b)
|
|
else:
|
|
if trans_b:
|
|
T.copy(B[bx * block_N, ko * block_K], B_s0)
|
|
T.copy(B[bx * block_N + split, ko * block_K], B_s1)
|
|
else:
|
|
T.copy(B[ko * block_K, bx * block_N], B_s0)
|
|
T.copy(B[ko * block_K, bx * block_N + split], B_s1)
|
|
T.gemm(A_s, B_s0, C0, trans_a, trans_b)
|
|
T.gemm(A_s, B_s1, C1, trans_a, trans_b)
|
|
else:
|
|
if trans_b:
|
|
T.copy(B[bx * block_N, ko * block_K], B_s)
|
|
else:
|
|
T.copy(B[ko * block_K, bx * block_N], B_s)
|
|
if sliced:
|
|
if trans_a:
|
|
T.copy(A[ko * block_K, by * block_M], A_s)
|
|
T.gemm(A_s[:, 0:split], B_s, C0, trans_a, trans_b)
|
|
T.gemm(A_s[:, split:block_M], B_s, C1, trans_a, trans_b)
|
|
else:
|
|
T.copy(A[by * block_M, ko * block_K], A_s)
|
|
T.gemm(A_s[0:split, :], B_s, C0, trans_a, trans_b)
|
|
T.gemm(A_s[split:block_M, :], B_s, C1, trans_a, trans_b)
|
|
else:
|
|
if trans_a:
|
|
T.copy(A[ko * block_K, by * block_M], A_s0)
|
|
T.copy(A[ko * block_K, by * block_M + split], A_s1)
|
|
else:
|
|
T.copy(A[by * block_M, ko * block_K], A_s0)
|
|
T.copy(A[by * block_M + split, ko * block_K], A_s1)
|
|
T.gemm(A_s0, B_s, C0, trans_a, trans_b)
|
|
T.gemm(A_s1, B_s, C1, trans_a, trans_b)
|
|
|
|
if operand == "B":
|
|
T.copy(C0, C[by * block_M, bx * block_N])
|
|
T.copy(C1, C[by * block_M, bx * block_N + split])
|
|
else:
|
|
T.copy(C0, C[by * block_M, bx * block_N])
|
|
T.copy(C1, C[by * block_M + split, bx * block_N])
|
|
|
|
return main
|
|
|
|
|
|
def run_sliced_operand(M, N, K, block_M, block_N, block_K, dtype, split, operand, transposed):
|
|
torch_dtype = getattr(torch, dtype)
|
|
trans_a = operand == "A" and transposed
|
|
trans_b = operand == "B" and transposed
|
|
|
|
torch.manual_seed(0)
|
|
a = torch.randn((K, M) if trans_a else (M, K), device="cuda", dtype=torch_dtype)
|
|
b = torch.randn((N, K) if trans_b else (K, N), device="cuda", dtype=torch_dtype)
|
|
ref = (a.T if trans_a else a).float() @ (b.T if trans_b else b).float()
|
|
|
|
outputs = {}
|
|
for sliced in (False, True):
|
|
kernel = tilelang.compile(
|
|
_matmul_sliced(M, N, K, block_M, block_N, block_K, dtype, split, operand, transposed, sliced),
|
|
out_idx=[2],
|
|
target="cuda",
|
|
pass_configs={tilelang.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True},
|
|
)
|
|
outputs[sliced] = kernel(a, b).float()
|
|
|
|
# The two shapes issue the same instructions over the same values, so they
|
|
# agree bit-for-bit; the fp32 reference only bounds the accumulation error.
|
|
torch.testing.assert_close(outputs[True], outputs[False], rtol=0, atol=0)
|
|
torch.testing.assert_close(outputs[True], ref, rtol=1e-2, atol=1)
|
|
|
|
|
|
@tilelang.testing.requires_cuda
|
|
@tilelang.testing.requires_cuda_compute_version_ge(9, 0)
|
|
@pytest.mark.parametrize("operand", ["A", "B"])
|
|
@pytest.mark.parametrize("transposed", [False, True])
|
|
@pytest.mark.parametrize("block_K", [64, 128, 256])
|
|
def test_gemm_sliced_shared_operand(operand, transposed, block_K):
|
|
"""block_K 128/256 put more than one swizzle atom on the K axis."""
|
|
run_sliced_operand(
|
|
M=256,
|
|
N=256,
|
|
K=512,
|
|
block_M=128,
|
|
block_N=128,
|
|
block_K=block_K,
|
|
dtype="bfloat16",
|
|
split=64,
|
|
operand=operand,
|
|
transposed=transposed,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
tilelang.testing.main()
|