Skip to content

[Kernel][Perf] Enable the gmem->LDS async copy for f16/bf16 preshuffle GEMM - #1008

Open
JohnQinAMD wants to merge 3 commits into
mainfrom
feat/preshuffle-async-copy-f16
Open

[Kernel][Perf] Enable the gmem->LDS async copy for f16/bf16 preshuffle GEMM#1008
JohnQinAMD wants to merge 3 commits into
mainfrom
feat/preshuffle-async-copy-f16

Conversation

@JohnQinAMD

@JohnQinAMD JohnQinAMD commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

use_async_copy=True was rejected for anything but fp8/int8. That was never a hardware limit — buffer_load_dwordx4 … lds moves 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, 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>)

Enabling it 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, not a bandwidth one.

Stacked on #1007. Without the mma_kloop fix in that PR the DMA is worth
+0.9% rather than +8.2%.

Changes

  1. Byte-type the global view so the copy is i8 → i8 and the
    byte-denominated index math 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>; those agree only at
    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 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 with
    Swizzle<3,3,3> vs 417 with the derived value. Only the DMA path needs
    the two sides to agree, so only the DMA path changes.

  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.

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 × preload sweep measured in one process against the same torch reference (1615 TF/s), median of 20 timed iterations after 5 warmups:

compile_preshuffle_gemm(
    N=N, K=K, tile_m=tm, tile_n=256, tile_k=64, in_dtype="bf16", out_dtype="bf16",
    xcd_swizzle=4, preload=(p, p), use_async_copy=<False|True>,
)                       # tm in {128, 160, 192}, p in {2, 4, 8}
Before (sync only) After (DMA available)
best of sweep 1385 TF/s (128×256×64, preload 4) 1499 TF/s (160×256×64, preload 2) +8.2%

Testing

pytest tests/kernels/test_preshuffle_gemm.py -c tests/pytest.ini -k async_copy_2byte -v
#   4 passed

pytest tests/kernels/test_preshuffle_gemm.py -c tests/pytest.ini -q
#   154 passed, 10 skipped

ruff check kernels/gemm/preshuffle_gemm.py tests/kernels/test_preshuffle_gemm.py
#   All checks passed!

Adds test_preshuffle_async_copy_2byte_dtypes (fp16/bf16 × tile_k 64/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 existing sync_copy/async_copy parametrization 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 --check reports one pre-existing violation in test_preshuffle_gemm.py that this PR neither adds to nor fixes.

Not tested on gfx942 — buffer_load_lds lowering is gfx950-only and the new test skips accordingly.

Breaking Changes

None. use_async_copy and preload both default to previous behaviour, and the swizzle change is scoped to the DMA path, so no previously reachable configuration changes.

yanyuan.qin@amd.com and others added 2 commits August 13, 2026 22:33
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>
Copilot AI lite review requested due to automatic review settings August 13, 2026 22:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 explicit sched_vmem/sched_dsrd hint 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_copy is still gfx950-only (buffer_load_lds lowering). Without an explicit arch check here, callers can enable use_async_copy=True on gfx942/other arches and hit a much less actionable legalization/codegen failure later in compilation. Add a clear early ValueError when use_async_copy is 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.

Comment on lines +195 to 199
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>
@JohnQinAMD

Copy link
Copy Markdown
Contributor Author

Thanks — both addressed in 15c0fa7.

  • Arch guard: use_async_copy now raises early off gfx950. Worth noting this was already reachable for 8-bit before this PR (there was no arch check, only is_8bit), but enabling f16/bf16 makes it much easier to hit, so it belongs here.
  • preload validation: rejects anything that is not a pair of non-negative ints, with test_preshuffle_preload_validation covering wrong arity, negatives and non-tuples.

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 test job shows as SKIPPED on both PRs, so no GPU test has run in CI. All my measurements and test runs are on ROCm 7.2.3 / PyTorch 2.11 rather than the 7.14 / 2.12 the CI image uses — I tried reproducing on rocm/pytorch:rocm7.14_... with the PyPI flydsl wheel but the codegen needs the custom LLVM that CI builds (lld invocation failed). If someone can trigger the GPU job on linux-flydsl-mi355-8, that would validate this on the supported toolchain.

@JohnQinAMD

Copy link
Copy Markdown
Contributor Author

Correction to the performance figures, after re-measuring on freshly built trees.

The +8.2% in the description isolates only the use_async_copy toggle — both sides of that A/B already had preload=, which this PR also introduces. Measured against the actual base (#1007), the PR is worth considerably more:

tree (bf16, M=54560 K=N=8192, best of a tile_m × preload sweep) TF/s
upstream ac227c3 923
#1007 934
#1007 + this PR 1509 (+61% over #1007)

Decomposed: exposing preload= is worth ~+57% on its own, because bf16 previously received no sched_vmem/sched_dsrd hints at all (_TILE_PRELOAD_TABLE is gated on is_8bit); the DMA then adds +8.2% on top of that.

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 preload= out if you'd prefer it reviewed separately — it is independent of the other three changes.

Reference points measured in the same process: torch 1618, aiter ASM 1603.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants