Repository navigation
[BugFix][Hopper][Blackwell] Read the WGMMA/UMMA K-panel stride from the layout instead of the operand extent #2965
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
LeiWang1999
merged 2 commits into
tile-ai:main
from
bigSheep123:fix/wgmma-k-panel-stride-sliced-operand
Sep 2, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
71 changes: 71 additions & 0 deletions
71
testing/python/cuda/test_tilelang_cuda_wgmma_operand_layout.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| """Unit tests for the WGMMA/UMMA K-panel stride decode. | ||
|
|
||
| ``decode_k_panel_elems`` is the single source of truth for how far an atom offset | ||
| steps to reach the next K swizzle-atom panel. Reconstructing that step from the | ||
| operand's MN extent is wrong for an operand that is a slice of a wider buffer, so | ||
| the decode must either return the layout's own stride or refuse — never fall back | ||
| silently, which is the wrong-code path the decode exists to remove. | ||
| """ | ||
|
|
||
| import pytest | ||
|
|
||
| import tilelang.testing # noqa: F401 (registers the test main) | ||
| from tilelang.layout import SwizzleMode, cute | ||
| from tilelang.cuda.intrinsics.macro.wgmma_macro_generator import decode_k_panel_elems | ||
|
|
||
| ATOM = 64 # elements per 128B swizzle atom at 16-bit dtypes | ||
| B128 = SwizzleMode.SWIZZLE_128B | ||
| NONE = SwizzleMode.NONE | ||
|
|
||
|
|
||
| def _k_mode(shape, stride): | ||
| return cute.make_layout(shape, stride) | ||
|
|
||
|
|
||
| def test_canonical_multi_panel_returns_the_layout_stride(): | ||
| """(atom, panels):(1, panel_stride) -- the step is read off the layout.""" | ||
| assert decode_k_panel_elems(_k_mode([ATOM, 2], [1, 8192]), True, B128, ATOM, "WGMMA") == 8192 | ||
| assert decode_k_panel_elems(_k_mode([ATOM, 4], [1, 8192]), True, B128, ATOM, "WGMMA") == 8192 | ||
| # The stride is the buffer's, so a taller buffer gives a larger step for the | ||
| # same operand shape -- exactly what a sliced operand needs. | ||
| assert decode_k_panel_elems(_k_mode([ATOM, 2], [1, 16384]), True, B128, ATOM, "WGMMA") == 16384 | ||
|
|
||
|
|
||
| def test_single_panel_and_mn_major_need_no_step(): | ||
| """None is only returned where ``ki // k_atom_size`` is always zero.""" | ||
| # Flat K mode: one atom wide, so there is no second panel to step to. | ||
| assert decode_k_panel_elems(_k_mode(ATOM, 1), True, B128, ATOM, "WGMMA") is None | ||
| # MN-major operands never reach the panel term. | ||
| assert decode_k_panel_elems(_k_mode([ATOM, 2], [1, 8192]), False, B128, ATOM, "WGMMA") is None | ||
| # Unswizzled layouts have no panels at all. | ||
| assert decode_k_panel_elems(_k_mode(128, 1), True, NONE, 128, "WGMMA") is None | ||
|
|
||
|
|
||
| def test_panels_first_ordering_is_rejected_not_misread(): | ||
| """A (panels, atom) K mode must not have its trailing atom stride read as the step. | ||
|
|
||
| Taking ``stride[-1]`` without checking that the leading sub-mode is the | ||
| contiguous atom would return 1 here — a silently wrong panel step. | ||
| """ | ||
| with pytest.raises(AssertionError, match=r"spans 2 swizzle-atom panels"): | ||
| decode_k_panel_elems(_k_mode([2, ATOM], [8192, 1]), True, B128, ATOM, "WGMMA") | ||
|
|
||
|
|
||
| def test_multi_panel_without_a_constant_stride_asserts(): | ||
| """Refusing beats falling back to the extent-based reconstruction.""" | ||
| # Flat but two atoms wide: no panel sub-mode to read a stride from. | ||
| with pytest.raises(AssertionError, match=r"spans 2 swizzle-atom panels"): | ||
| decode_k_panel_elems(_k_mode(2 * ATOM, 1), True, B128, ATOM, "UMMA") | ||
| # Three sub-modes: panel spacing is not expressible as one scalar step. | ||
| with pytest.raises(AssertionError, match=r"spans 4 swizzle-atom panels"): | ||
| decode_k_panel_elems(_k_mode([ATOM, 2, 2], [1, 8192, 16384]), True, B128, ATOM, "WGMMA") | ||
|
|
||
|
|
||
| def test_partial_atom_extent_still_counts_as_two_panels(): | ||
| """A K extent that is not a whole number of atoms must not read as single-panel.""" | ||
| with pytest.raises(AssertionError, match=r"spans 2 swizzle-atom panels"): | ||
| decode_k_panel_elems(_k_mode(ATOM + 16, 1), True, B128, ATOM, "WGMMA") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| tilelang.testing.main() |
178 changes: 178 additions & 0 deletions
178
testing/python/kernel/test_tilelang_kernel_gemm_sliced_operand.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| """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() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggest adding
192here: bf16 gives three K panels — a non-power-of-two panel count with theki // k_atom_sizemultiplier reaching 2, which {64, 128, 256} never produces. I ran both K-major combos atblock_K=192(M=256, N=256, K=384) on an H100 through this file's ownrun_sliced_operandand they pass bit-for-bit — cheap to pin.