Skip to content

Commit 6c8e7c3

Browse files
vlluviacursoragent
andcommitted
[Perf] Choose the RDNA3 GEMM tile from the shape
rdna3_f16_gemm builds whatever tile it is handed and defaults to 128x128x32. That tile is right once the problem fills the grid, but it cuts only 4 workgroups at 256x256 and 16 at 512x512, so on a 96-CU part most CUs idle no matter how good the inner loop is. Choosing the tile from the shape is worth up to 3.0x there. rdna3_f16_gemm_autotune owns that decision in two layers. pick_tile is a heuristic fitted to a sweep of every feasible tile on 27 shapes; it needs no GPU and no measurement, and it is what a call resolves to with nothing configured, so the wrapper benchmarks nothing by default. Above it sits the shared autotuner: FLYDSL_AUTOTUNE=1 sweeps feasible_tiles for real, and the result can be frozen into an offline artifact. The heuristic defaults to 64x64x64 rather than the widest tile that covers the machine. Measured on gfx1100 it is fastest on 16 of the 27 shapes and holds 50-59 TFLOP/s throughout, where 128x128x32 swings between 40 and 72. Taking the widest covering tile cost up to 37% and averaged 6.5%; against the per-shape fastest tile this averages 0.6%, worst case 8.1%. Two limits worth knowing. NUM_CU is hard-coded for gfx1100, so the thresholds do not transfer to a gfx11 part with a different CU count, and shapes outside the fitted set are extrapolation -- the search exists for both cases. And _graph_bench, which captures a CUDA graph to get under the ~90us launch overhead that would otherwise swamp these kernels, still reads the multi-wave tiles a few us high below about 50us, so a tuned result for a short kernel is a hypothesis to confirm rather than a fact. feasible_tiles doubles as the search space: anything it excludes does not divide the shape, cannot fill the prefetch pipeline, or does not fit in LDS, so benchmarking it would only measure a build failure. Points the gfx11 benchmark path at the wrapper so its numbers reflect the chosen tile rather than the default. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2ee7106 commit 6c8e7c3

5 files changed

Lines changed: 655 additions & 11 deletions

File tree

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright (c) 2025 FlyDSL Project Contributors
3+
4+
"""Tile selection for the RDNA3 WMMA GEMM.
5+
6+
``rdna3_f16_gemm`` builds whatever tile it is handed and defaults to 128x128x32.
7+
That tile is right once the problem fills the grid, but it cuts only 4
8+
workgroups at 256x256 and 16 at 512x512, so on a 96-CU part most CUs idle no
9+
matter how good the inner loop is. Choosing the tile from the shape is worth up
10+
to 3.0x there, and this module owns that decision in two layers:
11+
12+
* ``pick_tile`` — a heuristic fitted to a sweep of every feasible tile on 27
13+
shapes. It needs no GPU and no measurement, and it is what a call resolves
14+
to with nothing configured, so the wrapper benchmarks nothing by default.
15+
* the shared autotuner — ``FLYDSL_AUTOTUNE=1`` sweeps ``feasible_tiles`` for
16+
real on the GPU in hand, and the result can be frozen into an offline
17+
artifact so other machines with the same device fingerprint skip the search.
18+
19+
The second layer earns its keep mainly where the first cannot reach: ``NUM_CU``
20+
is hard-coded for gfx1100, so the thresholds do not transfer to a gfx11 part
21+
with a different CU count, and shapes outside the fitted set are extrapolation.
22+
It is also the least settled part of this — see ``_graph_bench`` for why its
23+
verdict on the shortest kernels should not be taken at face value. The default
24+
path does not benchmark and is unaffected.
25+
26+
The tile is not a Constexpr argument of one compiled entry point: it decides the
27+
block shape, the wave grid and the LDS budget, so each candidate is a separate
28+
module. The dispatcher below therefore takes the tile as ordinary keyword
29+
arguments and looks the built module up in a cache, the same shape of
30+
indirection ``conv3d_implicit_autotune`` uses.
31+
"""
32+
33+
import functools
34+
35+
import torch
36+
37+
from flydsl.autotune import Config, autotune, do_bench
38+
from kernels.gemm.rdna3_f16_gemm import K_PAD, WAVE_SIZE, WMMA_K, WMMA_M, WMMA_N, create_wmma_gemm_module
39+
40+
# gfx1100 (W7900) exposes 96 CUs. Used only to decide when a tile is too coarse
41+
# to fill the machine; being off by a little just shifts one ladder step.
42+
NUM_CU = 96
43+
44+
# LDS budget per workgroup. K_PAD comes from the kernel so the feasibility check
45+
# below cannot drift from the allocation it is predicting.
46+
LDS_BYTES = 64 * 1024
47+
48+
# Named by the block tile they produce: (reg_m, reg_n, reg_k, waves_m, waves_n).
49+
TILE_128x128x32 = (4, 4, 2, 2, 2)
50+
TILE_128x64x32 = (4, 2, 2, 2, 2)
51+
TILE_64x64x64 = (2, 2, 4, 2, 2)
52+
TILE_32x64x64 = (2, 2, 4, 1, 2)
53+
TILE_32x32x64 = (2, 2, 4, 1, 1)
54+
55+
# Tile ladder, widest first.
56+
#
57+
# 128x64x32 exists only on the small-K ladder: with large K a workgroup runs long
58+
# enough that the deeper k-tile (fewer barriers, twice as long for the gmem
59+
# prefetch to land) pays off, while with small K the per-workgroup prologue and
60+
# epilogue dominate and the wider tile amortizes them.
61+
#
62+
# The ladder is the feasibility-ordered search space; pick_tile does not walk it
63+
# in order. 32x64x64 is here only as a fallback for shapes the others cannot
64+
# divide -- it was not the fastest tile on any of the 27 shapes swept.
65+
_LADDER_LARGE_K = [TILE_128x128x32, TILE_64x64x64, TILE_32x64x64, TILE_32x32x64]
66+
_LADDER_SMALL_K = [TILE_128x128x32, TILE_128x64x32, TILE_64x64x64, TILE_32x64x64, TILE_32x32x64]
67+
68+
69+
def _tile_workgroups(M, N, K, cfg):
70+
"""Workgroup count for this tile, or None if the shape cannot use it."""
71+
reg_m, reg_n, reg_k, waves_m, waves_n = cfg
72+
block_m = WMMA_M * reg_m * waves_m
73+
block_n = WMMA_N * reg_n * waves_n
74+
block_k = WMMA_K * reg_k
75+
threads = waves_m * waves_n * WAVE_SIZE
76+
if M % block_m or N % block_n or K % block_k:
77+
return None
78+
if K // block_k < 2: # the prefetch pipeline needs at least two k-tiles
79+
return None
80+
# Every thread must carry a whole 8-element vector of both tiles.
81+
if (block_m * block_k) % (threads * 8) or (block_n * block_k) % (threads * 8):
82+
return None
83+
if 2 * (block_m + block_n) * (block_k + K_PAD) * 2 > LDS_BYTES: # 2 buffers, 2 bytes/elem
84+
return None
85+
return (M // block_m) * (N // block_n)
86+
87+
88+
def _ladder_for(K):
89+
return _LADDER_SMALL_K if K <= 1024 else _LADDER_LARGE_K
90+
91+
92+
def feasible_tiles(M, N, K):
93+
"""``(tile, workgroup count)`` for every ladder tile this shape can run, widest first.
94+
95+
Also the search space the autotuner sweeps: anything excluded here does not
96+
divide the shape, cannot fill the prefetch pipeline, or does not fit in LDS,
97+
so benchmarking it would only measure a build failure.
98+
"""
99+
return [(cfg, wgs) for cfg in _ladder_for(K) if (wgs := _tile_workgroups(M, N, K, cfg)) is not None]
100+
101+
102+
def pick_tile(M, N, K):
103+
"""Tile for this shape, fitted to a sweep of every ladder tile on 27 shapes.
104+
105+
64x64x64 is the default rather than the widest tile that covers the machine.
106+
Measured on gfx1100 it is the fastest tile on 16 of the 27 shapes and holds
107+
50-59 TFLOP/s across the whole range, where 128x128x32 swings between 40 and
108+
72 depending on how its much coarser grid happens to land. Taking the widest
109+
covering tile cost up to 37% (1664x1664x1024) and averaged 6.5%.
110+
111+
Three exceptions, in order:
112+
113+
* 128x128x32 once the grid is worth at least ~2.5 workgroups per CU. Its
114+
compute intensity wins outright there, by 5-11% over 64x64x64.
115+
* 128x64x32 (small-K ladder only) when it lands near one workgroup per CU.
116+
That is the narrow band around 1024x1024, where it leads by 13-28%.
117+
* 32x32x64 when 64x64x64 cannot fill a quarter of the machine and K is
118+
long enough for the idle CUs to dominate: 256x256x4096, worth 23%.
119+
120+
Against the per-shape fastest tile this averages 0.6%, worst case 8.1% at
121+
1152x1152x1024, where 128x128x32's grid happens to land well.
122+
"""
123+
feasible = dict(feasible_tiles(M, N, K))
124+
if not feasible:
125+
return _ladder_for(K)[0]
126+
127+
if feasible.get(TILE_128x128x32, 0) >= 2.5 * NUM_CU:
128+
return TILE_128x128x32
129+
if NUM_CU <= feasible.get(TILE_128x64x32, 0) <= 1.5 * NUM_CU:
130+
return TILE_128x64x32
131+
if TILE_64x64x64 in feasible:
132+
starved = feasible[TILE_64x64x64] < NUM_CU / 4 and K >= 2048
133+
if starved and TILE_32x32x64 in feasible:
134+
return TILE_32x32x64
135+
return TILE_64x64x64
136+
return list(feasible)[-1]
137+
138+
139+
# Launches to capture per graph. Enough that replay is dominated by the kernel
140+
# rather than by graph launch, small enough to keep capture cheap.
141+
_GRAPH_LAUNCHES = 20
142+
_REPLAYS_PER_ROUND = 8
143+
144+
_TILE_FIELDS = ("reg_m", "reg_n", "reg_k", "waves_m", "waves_n")
145+
146+
# Launcher for a call signature whose tile the autotuner has already resolved.
147+
# The tuner re-derives its cache key from scratch on every call — fingerprinting
148+
# the environment, toolchain and device — which costs more host time than a
149+
# small GEMM takes on the GPU, and under FLYDSL_AUTOTUNE=1 it re-runs the whole
150+
# search per call. Consulting it once per signature keeps steady-state dispatch
151+
# as cheap as calling the built module directly.
152+
_resolved = {}
153+
154+
155+
def _tile_config(tile):
156+
return Config(**dict(zip(_TILE_FIELDS, tile)))
157+
158+
159+
@functools.lru_cache(maxsize=None)
160+
def _build(M, N, K, in_dtype, out_dtype, rounding, reg_m, reg_n, reg_k, waves_m, waves_n):
161+
launch_fn, _, _, _ = create_wmma_gemm_module(
162+
M,
163+
N,
164+
K,
165+
in_dtype=in_dtype,
166+
out_dtype=out_dtype,
167+
rounding=rounding,
168+
reg_m=reg_m,
169+
reg_n=reg_n,
170+
reg_k=reg_k,
171+
waves_m=waves_m,
172+
waves_n=waves_n,
173+
)
174+
return launch_fn
175+
176+
177+
def rdna3_gemm_dispatch(
178+
C,
179+
A,
180+
B_T,
181+
M,
182+
N,
183+
K,
184+
in_dtype="bf16",
185+
out_dtype="bf16",
186+
rounding="rn",
187+
reg_m=None,
188+
reg_n=None,
189+
reg_k=None,
190+
waves_m=None,
191+
waves_n=None,
192+
stream=None,
193+
sr_seed=0,
194+
):
195+
"""Run the GEMM on one tile. Unset tile fields fall back to ``pick_tile``.
196+
197+
The stream is resolved here rather than by the caller so that this stays
198+
capturable: under ``torch.cuda.graph`` the current stream is the capture
199+
stream, and enqueueing onto a stream captured before then aborts the capture.
200+
"""
201+
if stream is None:
202+
stream = torch.cuda.current_stream()
203+
# Resolve before the cache key so a partially specified tile and the fully
204+
# spelled-out one it means share a single built module.
205+
tile = tuple(
206+
auto if given is None else given
207+
for auto, given in zip(pick_tile(M, N, K), (reg_m, reg_n, reg_k, waves_m, waves_n))
208+
)
209+
launch_fn = _build(M, N, K, in_dtype, out_dtype, rounding, *tile)
210+
# A search calls this once per candidate and then once more on the winner,
211+
# so the last write is the config the tuner settled on.
212+
_resolved[(M, N, K, in_dtype, out_dtype, rounding)] = launch_fn
213+
return launch_fn(C, A, B_T, stream, sr_seed)
214+
215+
216+
def _default_config(
217+
C=None,
218+
A=None,
219+
B_T=None,
220+
M=None,
221+
N=None,
222+
K=None,
223+
in_dtype="bf16",
224+
out_dtype="bf16",
225+
rounding="rn",
226+
**_kwargs,
227+
):
228+
return _tile_config(pick_tile(M, N, K))
229+
230+
231+
def _search_configs(
232+
C=None,
233+
A=None,
234+
B_T=None,
235+
M=None,
236+
N=None,
237+
K=None,
238+
in_dtype="bf16",
239+
out_dtype="bf16",
240+
rounding="rn",
241+
**_kwargs,
242+
):
243+
candidates = [_tile_config(tile) for tile, _wgs in feasible_tiles(M, N, K)]
244+
return candidates or [_default_config(M=M, N=N, K=K)]
245+
246+
247+
def _graph_bench(fn, warmup=5, rep=25):
248+
"""Fastest observed ms per launch, timed by replaying a captured graph.
249+
250+
The stock ``do_bench`` pays one launch plus one full sync per measurement,
251+
about 90us of host time on this kernel. That is longer than the kernel runs
252+
on any shape small enough for the tile to be worth choosing, so all the
253+
candidates measure alike and the search ends up ranking dispatch noise.
254+
Capturing the launches amortises that overhead away and makes a sweep
255+
reproducible to within a percent.
256+
257+
It is still not trustworthy on the shortest kernels. Measured in isolation,
258+
512x512x2048 runs at 28.6us on 64x64x64 and 31.0us on 32x32x64; measured
259+
here the multi-wave tiles read about 5us high and the ranking inverts, so a
260+
forced sweep of that shape emits an artifact for the slower tile. Treat a
261+
tuned result for a sub-50us kernel as a hypothesis to confirm, not a fact.
262+
263+
Falls back to the stock timer if the kernel turns out not to be capturable.
264+
"""
265+
for _ in range(warmup):
266+
fn()
267+
torch.cuda.synchronize()
268+
269+
graph = torch.cuda.CUDAGraph()
270+
try:
271+
with torch.cuda.graph(graph):
272+
for _ in range(_GRAPH_LAUNCHES):
273+
fn()
274+
except Exception:
275+
return do_bench(fn, warmup=warmup, rep=rep)
276+
277+
for _ in range(3):
278+
graph.replay()
279+
torch.cuda.synchronize()
280+
281+
# Time a batch of replays per round so the graph launch is amortised too,
282+
# and keep the fastest round. This runs on shared nodes where a neighbour
283+
# can inflate a whole round two- or threefold; taking the median carries
284+
# those rounds into the result, taking the minimum drops them.
285+
rounds = max(3, rep // _REPLAYS_PER_ROUND)
286+
best = float("inf")
287+
for _ in range(rounds):
288+
start = torch.cuda.Event(enable_timing=True)
289+
end = torch.cuda.Event(enable_timing=True)
290+
start.record()
291+
for _ in range(_REPLAYS_PER_ROUND):
292+
graph.replay()
293+
end.record()
294+
torch.cuda.synchronize()
295+
best = min(best, start.elapsed_time(end) / (_REPLAYS_PER_ROUND * _GRAPH_LAUNCHES))
296+
return best
297+
298+
299+
_gemm_tuner = autotune(
300+
configs=_search_configs,
301+
key=["M", "N", "K", "in_dtype", "out_dtype", "rounding"],
302+
default=_default_config,
303+
do_bench=_graph_bench,
304+
artifact_name="rdna3_f16_gemm",
305+
)(rdna3_gemm_dispatch)
306+
307+
308+
def rdna3_gemm_autotuned(
309+
C,
310+
A,
311+
B_T,
312+
in_dtype="bf16",
313+
out_dtype="bf16",
314+
rounding="rn",
315+
stream=None,
316+
sr_seed=0,
317+
):
318+
"""``C = A @ B_T.T`` on the tile chosen for this shape."""
319+
M, K = A.shape
320+
N = B_T.shape[0]
321+
M, N, K = int(M), int(N), int(K)
322+
323+
launch_fn = _resolved.get((M, N, K, in_dtype, out_dtype, rounding))
324+
if launch_fn is not None:
325+
return launch_fn(C, A, B_T, torch.cuda.current_stream() if stream is None else stream, sr_seed)
326+
327+
# Make the caller's stream current instead of passing it down, so the
328+
# dispatcher picks it up while a benchmark capture still gets its own.
329+
launch_stream = torch.cuda.current_stream() if stream is None else stream
330+
with torch.cuda.device(A.device), torch.cuda.stream(launch_stream):
331+
return _gemm_tuner(
332+
C,
333+
A,
334+
B_T,
335+
M=M,
336+
N=N,
337+
K=K,
338+
in_dtype=in_dtype,
339+
out_dtype=out_dtype,
340+
rounding=rounding,
341+
stream=None,
342+
sr_seed=sr_seed,
343+
)

tests/kernels/benchmark_common.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -239,22 +239,31 @@ def _bench_flydsl_torch(*, op: str, M: int, N: int, dtype: str, warmup: int, ite
239239
# arch.
240240
from flydsl.runtime.device import get_rocm_arch as _get_arch
241241

242-
if str(_get_arch() or "").startswith("gfx11"):
243-
from kernels.gemm.rdna3_f16_gemm import create_wmma_gemm_module
244-
else:
245-
from kernels.gemm.rdna_f16_gemm import create_wmma_gemm_module
246-
247242
K = N # square by default; caller can override via config
248243
torch_dtype = torch.bfloat16 if dtype == "bf16" else torch.float16
249-
launch, *_ = create_wmma_gemm_module(M, N, K, in_dtype=dtype, out_dtype="bf16")
250244
A = torch.randn(M, K, dtype=torch_dtype, device="cuda")
251245
B_T = torch.randn(N, K, dtype=torch_dtype, device="cuda")
252246
C = torch.zeros(M, N, dtype=torch.bfloat16, device="cuda")
253-
return bench_gpu_us_torch(
254-
lambda: launch(C, A, B_T, torch.cuda.current_stream()),
255-
warmup=warmup,
256-
iters=iters,
257-
)
247+
248+
if str(_get_arch() or "").startswith("gfx11"):
249+
# Through the autotune wrapper, so the number reflects the tile
250+
# chosen for the shape rather than the kernel's 128x128x32 default.
251+
# With nothing configured that resolution still benchmarks nothing,
252+
# and warmup absorbs the one-off dispatch through the tuner.
253+
from kernels.gemm.rdna3_f16_gemm_autotune import rdna3_gemm_autotuned
254+
255+
def run():
256+
rdna3_gemm_autotuned(C, A, B_T, in_dtype=dtype, out_dtype="bf16")
257+
258+
else:
259+
from kernels.gemm.rdna_f16_gemm import create_wmma_gemm_module
260+
261+
launch, *_ = create_wmma_gemm_module(M, N, K, in_dtype=dtype, out_dtype="bf16")
262+
263+
def run():
264+
launch(C, A, B_T, torch.cuda.current_stream())
265+
266+
return bench_gpu_us_torch(run, warmup=warmup, iters=iters)
258267

259268
if op == "wmma_fp8_gemm":
260269
from kernels.gemm.rdna_fp8_preshuffle_gemm import (

0 commit comments

Comments
 (0)