Skip to main content

gemm-optimization

Comprehensive guide to optimizing GEMM (General Matrix Multiply) kernels in FlyDSL on AMD CDNA GPUs. Covers tiling strategy, LDS ping-pong double-buffer, XOR bank-conflict swizzle, A/B data prefetch pipeline, 2-stage software pipelining, MFMA instruction scheduling (hot_loop_scheduler), epilogue strategies (direct store vs CShuffle), TFLOPS/bandwidth calculation, main-loop instruction count analysis, and bottleneck identification from ATT traces. Based on the production preshuffle_gemm kernel. Usage: /gemm-optimization

Jump to install

Source facts

Repository
ROCm/FlyDSL
Last source activity
August 24, 2026 at 06:42
Detected SKILL.md language
English
Stars
280
Forks
121

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
gemm-optimization
description
Comprehensive guide to optimizing GEMM (General Matrix Multiply) kernels in FlyDSL on AMD CDNA GPUs. Covers tiling strategy, LDS ping-pong double-buffer, XOR bank-conflict swizzle, A/B data prefetch pipeline, 2-stage software pipelining, MFMA instruction scheduling (hot_loop_scheduler), epilogue strategies (direct store vs CShuffle), TFLOPS/bandwidth calculation, main-loop instruction count analysis, and bottleneck identification from ATT traces. Based on the production preshuffle_gemm kernel. Usage: /gemm-optimization
allowed-tools
Read Edit Bash Grep Glob Agent
# GEMM Optimization Guide Comprehensive guide to writing and optimizing high-performance GEMM kernels in FlyDSL on AMD CDNA GPUs (MI300X gfx942, MI350 gfx950). Based on the production `kernels/gemm/preshuffle_gemm.py` implementation. --- ## 1. Tiling Strategy ### 1.1 Three-Level Tiling GEMM tiles the output C[M, N] and the reduction K into blocks: ``` C[M, N] = A[M, K] × B[K, N]^T Grid mapping: block_x → M tiles (tile_m rows each) block_y → N tiles (tile_n cols each) Thread mapping (256 threads = 4 waves × 64 lanes): wave_id = tid // 64 ∈ [0, 3] → N dimension partitioning lane_id = tid % 64 ∈ [0, 63] → M + N dimension within wave lane_div_16 = lane_id // 16 → M dimension (4 groups of 16) lane_mod_16 = lane_id % 16 → N dimension within MFMA ``` ### 1.2 Derived Tile Parameters ```python m_repeat = tile_m // 16 # M-direction 16x16 MFMA repeat count n_per_wave = tile_n // 4 # N range per wave (4 waves split tile_n) num_acc_n = n_per_wave // 16 # N-direction 16x16 accumulators per wave k_unroll = tile_k_bytes // a_elem_vec_pack // 64 # K-steps per tile (K64 micro-steps) ``` ### 1.3 Recommended Tile Configurations | Scenario | tile_m | tile_n | tile_k | Data Type | Notes | |----------|--------|--------|--------|-----------|-------| | Small batch (M ≤ 32) | 16 | 64-128 | 256-512 | FP8/INT8 | Memory-bound, large tile_k for reuse | | Medium batch | 64 | 256 | 128 | FP8/INT8/BF16 | Balanced compute/memory | | Large batch (M ≥ 4096) | 128 | 256 | 128 | FP8/INT8 | Compute-dense, needs async copy | | FP4 (gfx950) | 32-64 | 128-256 | 256 | FP4 | MFMA_SCALE instructions | ### 1.4 Tile Size Constraints - `tile_m` must be multiple of 16 (MFMA M dimension) - `tile_n` must be multiple of 64 (4 waves × 16 N per MFMA) - `tile_k * elem_bytes` must be multiple of 64 (K64-byte micro-step) - `tile_m * tile_k * elem_bytes` should fit comfortably in LDS (64KB on gfx942, 160KB on gfx950) - B matrix is pre-shuffled to `(N/16, K/64, 4, 16, kpack_bytes)` layout — tile_k must divide K evenly ### 1.5 MFMA Count Per Tile Total MFMA instructions per tile: ``` MFMA_per_tile = k_unroll × m_repeat × num_acc_n × 2 ↑ 2x K32 per K64 micro-step Example (tile 64×256×128, FP8): k_unroll = 128 / 64 = 2 m_repeat = 64 / 16 = 4 num_acc_n = 256 / 4 / 16 = 4 MFMA_per_tile = 2 × 4 × 4 × 2 = 64 MFMAs Example (tile 64×256×512, FP8): k_unroll = 512 / 64 = 8 MFMA_per_tile = 8 × 4 × 4 × 2 = 256 MFMAs ``` --- ## 2. LDS Ping-Pong Double Buffer (2-Stage Pipeline) ### 2.1 Concept With `lds_stage=2`, the kernel allocates **two separate LDS buffers** for the A tile. While one buffer is used for MFMA computation, the next K-tile's A data is loaded into the other buffer. This hides the global-to-LDS load latency. ``` Time → Buffer PONG: [Compute tile_k=0] [ Load tile_k=2 ] [Compute tile_k=2] ... Buffer PING: [ Load tile_k=1 ] [Compute tile_k=1] [ Load tile_k=3 ] ... ``` ### 2.2 FlyDSL Implementation Declare both A buffers as `fx.Array` fields of an `@fx.struct` and allocate them with `fx.SharedAllocator` (the current LDS API — see `kernels/gemm/preshuffle_gemm.py`, where `a0`/`a1` are the pong/ping A buffers). In the default `static=True` mode the compiler sizes the static LDS globals for you. ```python a_lds_elems = tile_m * tile_k # elements per A buffer @fx.struct class SharedStorage: a0: fx.Array[layout_elem, a_lds_elems, 16] # PONG buffer if lds_stage == 2: a1: fx.Array[layout_elem, a_lds_elems, 16] # PING buffer @flyc.kernel def kernel_gemm(...): lds = fx.SharedAllocator().allocate(SharedStorage).peek() lds_a_pong = lds.a0.view(fx.make_layout((tile_m, tile_k), (tile_k, 1))) lds_a_ping = lds.a1.view(fx.make_layout((tile_m, tile_k), (tile_k, 1))) ``` The legacy `flydsl.utils.smem_allocator.SmemAllocator` path remains for un-migrated kernels but is not recommended for new code. ### 2.3 Main Loop Structure (2-Stage) Each iteration processes **2 K-tiles** (one pong, one ping): ```python def _build_pingpong_body(k_iv, inner_state): accs_in, bt_flat_in, a0pf_in = _unpack_state(inner_state) b_tile_pong_in = _unflatten_b_tile(bt_flat_in) # Phase 1: compute on PONG, prefetch to PING next_k1 = k_iv + tile_k store_a_tile_to_lds(prefetch_a_tile(next_k1), lds_a_ping) # A → PING LDS b_tile_ping = prefetch_b_tile(next_k1) # B → VGPR accs_in, _ = compute_tile(accs_in, b_tile_pong_in, lds_a_pong, a0_prefetch=a0pf_in) hot_loop_scheduler() # instruction hints rocdl.s_waitcnt(num_b_loads) gpu.barrier() a0_prefetch_ping = prefetch_a0_pack(lds_a_ping) # Phase 2: compute on PING, prefetch to PONG next_k2 = k_iv + (tile_k * 2) store_a_tile_to_lds(prefetch_a_tile(next_k2), lds_a_pong) # A → PONG LDS b_tile_pong_new = prefetch_b_tile(next_k2) # B → VGPR accs_in, _ = compute_tile(accs_in, b_tile_ping, lds_a_ping, a0_prefetch=a0_prefetch_ping) hot_loop_scheduler() rocdl.s_waitcnt(num_b_loads) gpu.barrier() a0_prefetch_pong_new = prefetch_a0_pack(lds_a_pong) return _pack_state(accs_in, _flatten_b_tile(b_tile_pong_new), a0_prefetch_pong_new) ``` ### 2.4 LDS Size Budget ``` lds_tile_bytes = tile_m × tile_k × elem_bytes 2-stage total = 2 × lds_tile_bytes + CShuffle epilogue (optional): tile_m × tile_n × 2 bytes Example (64×128, FP8): 2 × 64 × 128 = 16 KB total Example (128×128, FP8): 2 × 128 × 128 = 32 KB total ``` Limits: 64 KB on gfx942, 160 KB on gfx950. --- ## 3. LDS XOR Bank-Conflict Swizzle This section is the GEMM-specific implementation. For the general method -- diagnosing bank conflicts from ATT trace data, choosing swizzle vs padding, and the gfx942 (32-bank) vs gfx950 (64-bank) differences -- see the **lds-optimization** skill. ### 3.1 The Problem A tile stored row-major in LDS with stride = tile_k creates bank conflicts when multiple rows are read simultaneously (threads in the same wave access the same bank for different addresses). ### 3.2 XOR Swizzle Formula ```python def swizzle_xor16(row, col, k_blocks16): """XOR-with-row swizzle at 16-byte granularity.""" rem = row % k_blocks16 return col ^ (rem * 16) ``` - `k_blocks16 = tile_k_bytes // a_elem_vec_pack // 16` — number of 16-byte blocks in K - Applied to both **write** (global → LDS) and **read** (LDS → VGPR) paths - Zero LDS overhead (no extra bytes), ~1 SALU instruction per address ### 3.3 Write Path ```python # In store_a_tile_to_lds(): col_swz_bytes = swizzle_xor16(row_a_local, col_local_bytes, k_blocks16) lds_offset = row_a_local * lds_stride_bytes + col_swz_bytes lds_ptr.store(data, [lds_offset]) ``` ### 3.4 Read Path ```python # In lds_load_packs_k64(): col_base_swz_bytes = swizzle_xor16(curr_row_a_lds, col_base, k_blocks16) lds_offset = curr_row_a_lds * lds_stride_bytes + col_base_swz_bytes a_pack = lds_ptr.load([lds_offset]) ``` **Critical**: swizzle must be consistent between write and read. If one path uses swizzle but the other doesn't, data will be read from wrong positions. --- ## 4. Data Prefetch Pipeline This section is the GEMM-specific pipeline. For the general transformation -- prologue, `range(..., init=...)` loop-carried state, epilogue -- see the **prefetch-data-load** skill, and 10.2 below for the register budget. ### 4.1 A Matrix: Global → LDS Two paths for loading A into LDS: **Synchronous** (default): Global → VGPR → LDS ```python a_regs = prefetch_a_tile(base_k) # buffer_load_dwordx4 → VGPR store_a_tile_to_lds(a_regs, lds_buffer) # ds_write from VGPR → LDS ``` **Asynchronous** (use_async_copy=True): Global → LDS directly ```python prefetch_a_to_lds(base_k, lds_buffer) # raw_ptr_buffer_load_lds (DMA) ``` Async copy bypasses VGPR, reducing register pressure. Available on gfx942/gfx950. ### 4.2 B Matrix: Global → VGPR (Preshuffle) B is pre-shuffled to match MFMA register layout, loaded directly to VGPR: ```python b_tile = prefetch_b_tile(base_k) # buffer_load_dwordx4 → VGPR # b_tile structure: k_unroll × [(packs0[num_acc_n], packs1[num_acc_n])] ``` Each K64 micro-step needs `2 × num_acc_n` i64 values for B (K32 × 2). ### 4.3 A0 Prefetch (Cross-Tile LDS Prefetch) After `gpu.barrier()` completes (LDS is valid), immediately load the first A pack from LDS into VGPR registers, overlapping with upcoming VMEM loads: ```python a0_prefetch = lds_load_packs_k64(row_a_lds, col_offset_base_bytes, lds_buffer) ``` This hides the first `ds_read` latency (~20-40 cycles) behind the global loads that follow. ### 4.4 Pipeline Timeline ``` Iter i: 1. [VMEM] Load A(i+1) → PING LDS, Load B(i+1) → VGPR 2. [MFMA] Compute tile(i) using PONG LDS + B(i) VGPR 3. [SCHED] hot_loop_scheduler() — interleave MFMA with pending loads 4. [SYNC] s_waitcnt + barrier — wait for PING LDS to be valid 5. [LDS] A0 prefetch from PING — ds_read first pack Swap PING ↔ PONG, repeat for i+1 ``` --- ## 5. Instruction Scheduling (hot_loop_scheduler) ### 5.1 Purpose The `hot_loop_scheduler()` inserts `rocdl.sched_*` hints between the MFMA compute phase and the next iteration's loads. These hints tell the compiler how to interleave different instruction types to maximize pipeline utilization. ### 5.2 Scheduling Primitives | Hint | Meaning | Maps to | |------|---------|---------| | `rocdl.sched_barrier(0)` | Full scheduling barrier — no reordering across | Compiler fence | | `rocdl.sched_mfma(N)` | Allow N MFMA instructions | `v_mfma_*` | | `rocdl.sched_dsrd(N)` | Allow N LDS read instructions | `ds_read_*` | | `rocdl.sched_dswr(N)` | Allow N LDS write instructions | `ds_write_*` | | `rocdl.sched_vmem(N)` | Allow N global memory instructions | `buffer_load_*` | ### 5.3 Standard Schedule Pattern (gfx942, sync copy) ```python def hot_loop_scheduler(): mfma_group = num_acc_n mfma_total = (k_unroll * 2) * m_repeat * mfma_group mfma_per_iter = 2 * mfma_group sche_iters = mfma_total // mfma_per_iter # Prologue: pre-load first 2 LDS packs, interleave with first few MFMAs rocdl.sched_dsrd(2) # 2 ds_read for a0_prefetch rocdl.sched_mfma(1) rocdl.sched_mfma(1) # Main schedule: each iteration = 1 VMEM + mfma_group MFMAs + 1 ds_read + mfma_group MFMAs dswr_tail = num_a_loads dswr_start = max(sche_iters - dswr_tail - 2, 0) for sche_i in range_constexpr(sche_iters): rocdl.sched_vmem(1) # 1 global load (B tile or A tile) rocdl.sched_mfma(mfma_group) # N MFMA instructions rocdl.sched_dsrd(1) # 1 LDS read (A data) rocdl.sched_mfma(mfma_group) # N more MFMAs if sche_i >= dswr_start - 1: rocdl.sched_dswr(1) # LDS write (next A tile, tail end) rocdl.sched_barrier(0) # fence ``` ### 5.4 Key Scheduling Insights 1. **MFMA instructions dominate**: they form the backbone of the schedule 2. **LDS reads (ds_read) interleave with MFMAs**: one ds_read per 2×mfma_group MFMAs 3. **Global loads (VMEM) interleave**: one buffer_load per scheduler iteration 4. **LDS writes (ds_write) go at the tail**: they overlap with the last MFMAs of the current tile, landing before the `gpu.barrier()` at iteration boundary 5. **dswr_start** ensures LDS writes are scheduled early enough to complete before the barrier, but late enough to not interfere with compute ### 5.5 Async Copy Schedule (gfx950) For async copy, the scheduler uses `_build_scheduler()` to evenly distribute ds_read and VMEM loads across all MFMAs: ```python dsrd_schedule = _build_scheduler(num_ds_load - dsrd_preload, mfma_total)
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub