[Kernel][Perf] Enable the gmem->LDS async copy for f16/bf16 preshuffle GEMM - #1008
[Kernel][Perf] Enable the gmem->LDS async copy for f16/bf16 preshuffle GEMM#1008JohnQinAMD wants to merge 3 commits into
Conversation
Both bugs are in how the A tile reaches and leaves LDS. Neither produces an
error today: the first returns wrong results, the second returns correct
results slowly.
1. Reject tiles whose A tile is only partially loaded.
num_a_loads is a truncating division, so tile shapes whose per-thread A copy
does not divide into whole 16B loads never fetch the tail of the A tile; the
kernel then computes on stale LDS and returns wrong results at full speed
with no diagnostic:
bytes_per_thread_a = (tile_m * tile_k * elem_bytes) // total_threads
num_a_loads = bytes_per_thread_a // a_load_bytes # remainder dropped
The requirement is tile_m * tile_k * elem_bytes % 4096 == 0. For bf16 at
tile_k=64 that makes tile_m a multiple of 32, so tile_m of 112/144/176/208
load only 48/64/80/96 of the 56/72/88/104 bytes per thread they own. This is
reachable by accident because _TILE_PRELOAD_TABLE advertises (48, 64, 128),
(80, 128, 256) and (112, 64, 256) as tuned entries.
Validate the exact condition on the tile size rather than on either
truncated intermediate.
2. Restore per-k-step A-fragment LDS reads in mma_kloop.
#974 replaced the per-ki copies with a single whole-tile fx.copy. The two are
semantically identical, but the single copy leaves the scheduler no room to
interleave the reads with the MFMAs that consume them. Measured on MI355X
(gfx950), bf16 M=54560 K=8192 N=8192, sync path, single-hunk A/B on ac227c3:
tile_m x tile_n x tile_k whole-tile per-k-step
128 x 256 x 256 303 1011 3.3x
128 x 128 x 256 687 774 1.13x
64 x 256 x 256 863 859 neutral
128 x 256 x 128 1106 1103 neutral
128 x 256 x 64 1388 1381 neutral
The regression is confined to specific large-tile_k shapes; everything else
is within the ~1% run-to-run noise (128x256x64 is the control here, and the
affected cell is stable to +-0.3% over five repeats).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e GEMM
compile_preshuffle_gemm(use_async_copy=True) was rejected for anything other
than fp8/int8. That was never a hardware limit -- buffer_load_dwordx4 ... lds
moves raw bytes and does not care about the element type. It came from the
global-side view being element-typed while the LDS side is byte-typed, so the
copy only legalized, and only indexed correctly, when elem_bytes == 1:
failed to legalize operation 'fly.copy_atom_call' ...
(!fly.copy_atom<buffer_copy_lds<128>, 128>,
!fly.memref<bf16, buffer_desc, 1:0>, !fly.memref<i8, shared, 1:1>)
Three changes:
1. Byte-type the global view so the copy is i8 -> i8 and the byte-denominated
index arithmetic in dma_a_to_lds is correct for elem_bytes != 1.
2. Derive the LDS swizzle from tile_k when the DMA is in use. dma_a_to_lds
computes k_swz from k_blocks16 = tile_k * elem_bytes / 16, while the
non-8-bit branch hardcoded Swizzle<3,3,3>; they agree only when
tile_k * elem_bytes == 128, so tile_k=128 would write a 16-wide swizzle that
the reader unswizzles as 8-wide. The sync path is left on the fixed swizzle:
it writes through the same view so it is self-consistent either way, and it
measurably prefers the fixed one (bf16 tile_k=256: 1011 TF/s vs 417).
3. Add preload=. _TILE_PRELOAD_TABLE is tuned for 8-bit tiles and is consulted
only when is_8bit and is_gfx950, so f16/bf16 emit no sched_vmem/sched_dsrd
hints at all. The argument lets a caller supply them; the default preserves
existing behaviour.
Removing the A-tile register staging drops ds_write from 32 to 0 per k-tile and
raises MfmaUtil from 73.3% to 78.8%. MemUnitStalled is 0.04% on both sides, so
this is an issue-rate win rather than a bandwidth one.
bf16, M=54560, K=8192, N=8192, MI355X (gfx950), best of a tile_m x preload
sweep on each side, one process, torch reference 1615 TF/s:
before (sync only) 1385 TF/s 128x256x64, preload 4
after (DMA available) 1499 TF/s 160x256x64, preload 2 +8.2%
This builds on the mma_kloop fix in the preceding bugfix PR; without it the DMA
is worth +0.9% rather than +8.2%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Enables the gmem→LDS async-copy (buffer_load_lds) path for fp16/bf16 in the preshuffle GEMM kernel on gfx950 by making the DMA source byte-typed, aligning LDS swizzle between writer/reader in the DMA path, and exposing scheduling prefetch controls to callers.
Changes:
- Add
preload=to allow explicitsched_vmem/sched_dsrdhint tuning beyond the existing 8-bit tile table. - Fix A-tile DMA legalization/indexing for 2-byte dtypes by byte-typing the global view and deriving the LDS swizzle when DMA is enabled.
- Expand tests to cover 2-byte async copy and A-tile copy-granularity validation.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| kernels/gemm/preshuffle_gemm.py | Enables fp16/bf16 async gmem→LDS DMA by using byte-typed global views, DMA-consistent swizzle, new preload control, and improved A-tile validity checks. |
| tests/kernels/test_preshuffle_gemm.py | Removes the non-8-bit async-copy skip and adds coverage for fp16/bf16 async-copy correctness and A-tile granularity validation. |
Suppressed comments (1)
kernels/gemm/preshuffle_gemm.py:155
use_async_copyis still gfx950-only (buffer_load_lds lowering). Without an explicit arch check here, callers can enableuse_async_copy=Trueon gfx942/other arches and hit a much less actionable legalization/codegen failure later in compilation. Add a clear earlyValueErrorwhenuse_async_copyis requested on a non-gfx950 arch.
gpu_arch = get_rocm_arch()
is_gfx942 = str(gpu_arch).startswith("gfx942")
is_gfx950 = str(gpu_arch).startswith("gfx950")
use_mfma_scale_128 = is_fp8 and is_gfx950 and (tile_k % 128 == 0)
use_mfma_k32 = is_f16_or_bf16 and is_gfx950
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if preload is not None: | ||
| dsrd_preload, dvmem_preload = preload | ||
| elif is_8bit and is_gfx950: | ||
| dsrd_preload, dvmem_preload = _get_preload(tile_m, tile_n, tile_k) | ||
| else: |
- Raise early when use_async_copy is requested off gfx950. buffer_load_lds only lowers there, and without this the failure surfaces much later as an unactionable legalization error. This was already reachable for 8-bit before this PR, but enabling f16/bf16 makes it far easier to hit. - Validate preload: it is a public argument, so reject anything that is not a pair of non-negative ints rather than unpacking blindly and emitting negative sched_vmem/sched_dsrd counts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — both addressed in 15c0fa7.
Local suite after the change: 159 passed, 10 skipped (the 10 are the pre-existing MXFP4 fp8-A cases). One thing I can't cover from my side: the |
|
Correction to the performance figures, after re-measuring on freshly built trees. The +8.2% in the description isolates only the
Decomposed: exposing So the larger half of this PR's value is the scheduling-hint argument rather than the async copy in the title. Happy to retitle or split Reference points measured in the same process: torch 1618, aiter ASM 1603. |
Summary
use_async_copy=Truewas rejected for anything but fp8/int8. That was never a hardware limit —buffer_load_dwordx4 … ldsmoves raw bytes — it came from the global-side view being element-typed while the LDS side is byte-typed, so the copy only legalized, and only indexed correctly, whenelem_bytes == 1:Enabling it drops
ds_writefrom 32 to 0 per k-tile and raisesMfmaUtilfrom 73.3% to 78.8%.MemUnitStalledis 0.04% on both sides, so this is an issue-rate win, not a bandwidth one.Changes
Byte-type the global view so the copy is
i8 → i8and thebyte-denominated index math in
dma_a_to_ldsis correct forelem_bytes != 1.Derive the LDS swizzle from
tile_kwhen the DMA is in use.dma_a_to_ldscomputesk_swzfromk_blocks16 = tile_k * elem_bytes / 16,while the non-8-bit branch hardcoded
Swizzle<3,3,3>; those agree only attile_k * elem_bytes == 128, sotile_k=128would write a 16-wide swizzlethat the reader unswizzles as 8-wide.
The sync path deliberately keeps the fixed swizzle. It writes through the
same view, so it is self-consistent under any swizzle, and it measurably
prefers the fixed one — bf16
128×256×256, sync: 1011 TF/s withSwizzle<3,3,3>vs 417 with the derived value. Only the DMA path needsthe two sides to agree, so only the DMA path changes.
Add
preload=._TILE_PRELOAD_TABLEis tuned for 8-bit tiles and isconsulted only when
is_8bit and is_gfx950, so f16/bf16 emit nosched_vmem/sched_dsrdhints at all. The argument lets a caller supplythem; the default preserves existing behaviour.
Performance
Environment: MI355X (gfx950, 256 CU), ROCm 7.2.3 / PyTorch 2.11.0 / FlyDSL 0.2.4, base commit
ac227c3(main as of #1006) + #1007.bf16, M=54560, K=8192, N=8192. Before this change bf16 has no DMA path at all, so the baseline is the best sync configuration. Both sides are best-of a
tile_m × preloadsweep measured in one process against the same torch reference (1615 TF/s), median of 20 timed iterations after 5 warmups:Testing
Adds
test_preshuffle_async_copy_2byte_dtypes(fp16/bf16 ×tile_k64/128, where 128 exercises a different swizzle than the 64 the fixed value happened to match). Removing the non-8-bit skip also means the existingsync_copy/async_copyparametrization covers fp16/bf16 for the first time — that is the jump from 126 to 154 passing against #1007.The 10 skips are the pre-existing MXFP4 fp8-A cases.
ruff format --checkreports one pre-existing violation intest_preshuffle_gemm.pythat this PR neither adds to nor fixes.Not tested on gfx942 —
buffer_load_ldslowering is gfx950-only and the new test skips accordingly.Breaking Changes
None.
use_async_copyandpreloadboth default to previous behaviour, and the swizzle change is scoped to the DMA path, so no previously reachable configuration changes.