Files
tilelang/testing/python/issue/test_tilelang_issue_2928.py
T
Diao Han 78a4ff6587 [BugFix][Hopper] Select the widest legal WGMMA N instead of gcd (#2931)
* [BugFix][Hopper] Select the widest legal WGMMA N instead of gcd

block_N extents that are legal single WGMMA widths but not powers of two
(96, 112, 160, 176) were decomposed into n32 / n16 instructions, because
the instruction width came from gcd(warp_col_tiles, 256). wgmma.h already
instantiates every multiple of 8 up to 256, so only the width selection
was the constraint.

Select the widest legal width that tiles warp_col_tiles exactly, keeping
gcd as the fallback. Widening is restricted to multiples of 16: the
N % 16 == 8 class already miscomputes (#2593), and the integer instruction
tables only instantiate multiples of 16 (plus 8 and 24). The result is
never narrower than before, so no existing shape regresses.

The sparse generator carried the same line and now shares the helper.

* [Testing] Cover T.gemm_sp at the widened WGMMA N values

The width selector is shared with the sparse generator, so assert the emitted
wgmma_sp instruction and the sparse numeric result for block_N in {96, 112,
160} as well. Verified as a real regression: with the selector reverted the
sparse path emits n32 / n16 / n32 exactly like the dense one.
2026-08-11 17:20:51 +08:00

143 lines
5.3 KiB
Python

"""Regression test for GitHub issue #2928.
A ``block_N`` that is itself a legal Hopper WGMMA width (96, 112, 160, ...)
must lower to a single ``m64nNk16`` instruction. Deriving the instruction
width from ``gcd(warp_col_tiles, 256)`` decomposed those extents into n32 / n16
atoms and gave up most of the tensor-core throughput.
"""
import re
import pytest
import torch
import tilelang
import tilelang.language as T
import tilelang.testing
from tilelang.cuda.intrinsics.macro.wgmma_macro_generator import select_wgmma_inst_n
@pytest.mark.parametrize(
"warp_col_tiles, expected",
[
# Legal single-instruction extents: keep the whole width.
(64, 64),
(96, 96),
(112, 112),
(160, 160),
(176, 176),
(256, 256),
# ``N % 16 == 8`` is a separately broken class (issue #2593); the width
# selection is left exactly as it was.
(24, 8),
(40, 8),
(88, 8),
# Past the 256 ceiling: widest legal divisor, which gcd cannot reach.
(384, 192),
(512, 256),
],
)
def test_select_wgmma_inst_n(warp_col_tiles, expected):
assert select_wgmma_inst_n(warp_col_tiles) == expected
def _matmul_nt(M, N, K, block_M, block_N, block_K):
dtype, accum_dtype = "float16", "float"
@T.prim_func
def main(
A: T.Tensor((M, K), dtype),
B: T.Tensor((N, K), 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):
A_shared = T.alloc_shared((block_M, block_K), dtype)
B_shared = T.alloc_shared((block_N, block_K), dtype)
C_local = T.alloc_fragment((block_M, block_N), accum_dtype)
T.clear(C_local)
for k in T.Pipelined(T.ceildiv(K, block_K), num_stages=2):
T.copy(A[by * block_M, k * block_K], A_shared)
T.copy(B[bx * block_N, k * block_K], B_shared)
T.gemm(A_shared, B_shared, C_local, transpose_B=True)
T.copy(C_local, C[by * block_M, bx * block_N])
return main
def _emitted_shapes(kernel, op):
"""Distinct ``m, n, k`` operands emitted for ``op`` (``wgmma_ss``, ``wgmma_sp_ss``)."""
return sorted(set(re.findall(rf"{op}<[^>]*?, (\d+, \d+, \d+),", kernel.get_kernel_source())))
@tilelang.testing.requires_cuda_compute_version_eq(9, 0)
@pytest.mark.parametrize("block_N", [96, 112, 160])
def test_gemm_emits_single_wide_wgmma(block_N):
M, K, block_M, block_K = 128, 128, 64, 64
N = block_N * 2
kernel = tilelang.compile(_matmul_nt(M, N, K, block_M, block_N, block_K))
assert _emitted_shapes(kernel, "wgmma_ss") == [f"64, {block_N}, 16"]
torch.manual_seed(0)
a = torch.randn(M, K, dtype=torch.float16, device="cuda")
b = torch.randn(N, K, dtype=torch.float16, device="cuda")
c = torch.empty(M, N, dtype=torch.float16, device="cuda")
kernel(a, b, c)
torch.testing.assert_close(c, (a.float() @ b.float().T).half(), rtol=1e-2, atol=1e-2)
def _matmul_sp_nt(M, N, K, block_M, block_N, block_K, e_factor):
dtype, accum_dtype, meta_dtype = "float16", "float", "int16"
@T.prim_func
def main(
A_sparse: T.Tensor((M, K // 2), dtype),
E: T.Tensor((M, K // e_factor), meta_dtype),
B: T.Tensor((N, K), 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):
A_shared = T.alloc_shared((block_M, block_K // 2), dtype)
E_shared = T.alloc_shared((block_M, block_K // e_factor), meta_dtype)
B_shared = T.alloc_shared((block_N, block_K), dtype)
C_frag = T.alloc_fragment((block_M, block_N), accum_dtype)
T.clear(C_frag)
for k in T.Pipelined(T.ceildiv(K, block_K), num_stages=2):
T.copy(E[by * block_M, k * block_K // e_factor], E_shared)
T.copy(A_sparse[by * block_M, k * block_K // 2], A_shared)
T.copy(B[bx * block_N, k * block_K], B_shared)
T.gemm_sp(A_shared, E_shared, B_shared, C_frag, False, False, True)
T.copy(C_frag, C[by * block_M, bx * block_N])
return main
@tilelang.testing.requires_cuda_compute_version_eq(9, 0)
@pytest.mark.parametrize("block_N", [96, 112, 160])
def test_gemm_sp_emits_single_wide_wgmma(block_N):
# Imported inside the sm_90 gate so collection never depends on examples/.
from examples.gemm_sp.sparse_utils import compress, get_e_factor, randn_semi_sparse
M, K, block_M, block_K = 128, 32, 128, 32
N = block_N * 2
e_factor = get_e_factor(T.float16, T.int16)
kernel = tilelang.compile(
_matmul_sp_nt(M, N, K, block_M, block_N, block_K, e_factor),
out_idx=[3],
pass_configs={tilelang.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True},
)
# Sparse WGMMA doubles K per instruction: 512 bits / 16 bits per element.
assert _emitted_shapes(kernel, "wgmma_sp_ss") == [f"64, {block_N}, 32"]
torch.manual_seed(0)
a = randn_semi_sparse(M, K, dtype=torch.float16, device="cuda")
b = torch.randn(N, K, dtype=torch.float16, device="cuda")
a_sparse, e = compress(a, meta_dtype=torch.int16)
c = kernel(a_sparse, e, b)
torch.testing.assert_close(c.float(), a.float() @ b.float().T, rtol=1e-2, atol=1e-2)
if __name__ == "__main__":
tilelang.testing.main()