Skip to main content

prefetch-data-load

Apply prefetch optimization to FlyDSL kernel loops: pre-load the first iteration's data before the loop, issue async loads for the next iteration inside the loop body, and swap buffers at the loop tail via runtime loop-carried values. This overlaps data load latency with compute instructions. Use when a kernel has a loop where buffer_load feeds into MFMA/compute and load latency is exposed. Usage: /prefetch-data-load

Ir a la instalación

Datos de origen

Repositorio
ROCm/FlyDSL
Última actividad en el origen
22 de septiembre de 2026 a las 11:02
Idioma detectado de SKILL.md
inglés
Estrellas
285
Forks
122

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
name
prefetch-data-load
description
Apply prefetch optimization to FlyDSL kernel loops: pre-load the first iteration's data before the loop, issue async loads for the next iteration inside the loop body, and swap buffers at the loop tail via runtime loop-carried values. This overlaps data load latency with compute instructions. Use when a kernel has a loop where buffer_load feeds into MFMA/compute and load latency is exposed. Usage: /prefetch-data-load
allowed-tools
Read Edit Bash Grep Glob Agent
# Prefetch Data Load Optimization Apply software prefetch (double-buffering) to overlap async data loads with compute in FlyDSL GPU kernel loops. **API note.** The worked examples below are transcribed from the PA decode kernel and are *schematic*: they demonstrate the loop structure — prologue, loop-carried state, epilogue — not a copy-pasteable kernel. The loads are still spelled as raw `buffer_ops.buffer_load`, which now lives in `kernels/common/buffer_ops.py` (moved out of `flydsl.expr` in #880); for new code use `fx.rocdl.make_buffer_tensor` + `fx.copy` instead. The MMA is shown in the current atom form (`mma = fx.make_mma_atom(...)`, then `fx.gemm(mma, d, a, b, c)`); the raw `rocdl.mfma_*` intrinsics take `(result_type, operands)`, not `(a, b, acc)`. See the **kernel-code-cleanup** skill for the full mapping. ## Core Principle GPU global memory loads (`buffer_ops.buffer_load`, `buffer_load_dwordx4`) are **asynchronous** -- the load instruction returns immediately and the hardware fetches data in the background. The data is only needed when a subsequent instruction actually **consumes** it. If we issue the load early enough, the data arrives by the time we need it, effectively hiding the load latency behind compute work. **Without prefetch** (load latency fully exposed): ``` for i in range(N): data = load(ptr + i) # <-- stall: wait for data result = compute(data) # <-- cannot start until load completes ``` Timeline: ``` |--load--|--stall--|--compute--|--load--|--stall--|--compute--| ``` **With prefetch** (load overlapped with compute): ``` # Pre-load first iteration BEFORE the loop next_data = load(ptr + 0) for i in range(N): # Swap: the prefetched data becomes current data = next_data # Issue load for NEXT iteration (async, non-blocking) if i + 1 < N: next_data = load(ptr + i + 1) # Compute using CURRENT data -- overlaps with next load result = compute(data) ``` Timeline: ``` |--load₀--|--compute₀ + load₁--|--compute₁ + load₂--|--compute₂--| ``` The total time drops from `N * (load + compute)` to roughly `load + N * max(load, compute)`. ## FlyDSL Implementation: `range(..., init=...)` with Loop-Carried Prefetch In FlyDSL kernels, Python-level `for _pi in range(N)` gets traced into N flat copies that LLVM re-rolls. This makes the `data = next_data` swap **invisible** to MLIR — both variables alias the same SSA value, so LLVM hoists loads as loop-invariant. **Solution**: Use FlyDSL's runtime `range(..., init=...)` (loop-carried values) to create genuine SSA phi nodes. See the `flydsl-kernel-authoring` skill, section "Runtime Loops with Loop-Carried Values", for the full pattern and three critical pitfalls. ### Transformation Steps Given a loop like: ```python for i in range(START, END): # === LOAD PHASE === offsets = compute_offsets(i) data_A = buffer_ops.buffer_load(rsrc_A, offsets, vec_width=4) data_B = buffer_ops.buffer_load(rsrc_B, offsets, vec_width=4) # === COMPUTE PHASE === fx.gemm(mma, acc, transform(data_A), transform(data_B), acc) # d, a, b, c ``` Apply the following transformation using `range(..., init=...)`: #### Step 1: Prologue — load first iteration before loop ```python offsets_0 = compute_offsets(START) next_data_A = buffer_ops.buffer_load(rsrc_A, offsets_0, vec_width=4) next_data_B = buffer_ops.buffer_load(rsrc_B, offsets_0, vec_width=4) init_state = [_unwrap(v) for v in [next_data_A, next_data_B, acc]] ``` #### Step 2: Runtime loop with loop-carried state ```python _start = fx.Int64(0) _stop = fx.Int64(N - 1) # N-1 iterations; last handled in epilogue _step = fx.Int64(1) for iv, state in range(_start, _stop, _step, init=init_state): # Swap: prefetched -> current data_A = state[0] data_B = state[1] acc = state[2] # Prefetch next iteration (async, non-blocking) offsets_next = compute_offsets(iv + 1) next_data_A = buffer_ops.buffer_load(rsrc_A, offsets_next, vec_width=4) next_data_B = buffer_ops.buffer_load(rsrc_B, offsets_next, vec_width=4) # Compute using current data (overlaps with next load) fx.gemm(mma, acc, transform(data_A), transform(data_B), acc) results = yield [_unwrap(v) for v in [next_data_A, next_data_B, acc]] ``` #### Step 3: Epilogue — process last iteration ```python data_A = results[0] data_B = results[1] acc = results[2] fx.gemm(mma, acc, transform(data_A), transform(data_B), acc) ``` ### Handling auxiliary data (block tables, scales) Any offset calculations, block table lookups, or scale factor loads needed for the *next* iteration's data should also be carried as loop state: ```python init_state = [_unwrap(v) for v in [ next_data_A, next_data_B, next_block_id, next_scale, acc ]] for iv, state in range(_start, _stop, _step, init=init_state): data_A, data_B, block_id, scale, acc = state # Prefetch next iteration next_block_id = load_block_table(iv + 1) offsets_next = compute_offsets(iv + 1, next_block_id) next_data_A = buffer_ops.buffer_load(rsrc_A, offsets_next, vec_width=4) next_data_B = buffer_ops.buffer_load(rsrc_B, offsets_next, vec_width=4) next_scale = buffer_ops.buffer_load(rsrc_scale, next_block_id, vec_width=1) # Compute with current data fx.gemm(mma, acc, transform(data_A) * scale, transform(data_B), acc) results = yield [_unwrap(v) for v in [ next_data_A, next_data_B, next_block_id, next_scale, acc ]] ``` ### PA Decode Kernel Example (verified, 112us, 0.75x vs Gluon) State inventory (15 values carried across iterations): - 8 x `vector<4xi32>` — K data (4 tiles x 2 loads) - 1 x `i32` — partition_start - 2 x `i32` — block table values (phys_block/page_off or phys_0/phys_1) - 2 x `f32` — running_max, running_sum (online softmax) - 2 x `vector<4xf32>` — PV accumulators ```python # Pack/unpack helpers def _pack(kv_flat, part_start, bt_vals, rmax, rsum, acc_pv): raw = kv_flat + [part_start] + bt_vals + [rmax, rsum] + acc_pv return [v.ir_value() if hasattr(v, 'ir_value') else v for v in raw] def _unpack(state): kv_flat = list(state[0:8]) kv = [[kv_flat[t*2], kv_flat[t*2+1]] for t in range(4)] return kv, state[8], list(state[9:11]), state[11], state[12], [state[13], state[14]] # Prologue pf_0 = issue_bt_k_loads(partition_0) init_state = _pack(flatten(pf_0['kv']), pf_0['part_start'], ...) # Runtime loop (bounds MUST be a typed DSL integer like fx.Int64, not Python ints!) for iv, state in range(fx.Int64(0), fx.Int64(N - 1), fx.Int64(1), init=init_state): kv, part_start, bt, rmax, rsum, acc = _unpack(state) rmax, rsum, acc = compute_qk_softmax_pv(kv, part_start, bt, rmax, rsum, acc) pf_next = issue_bt_k_loads(next_partition(iv + 1)) results = yield _pack(flatten(pf_next['kv']), pf_next['part_start'], ...) # Epilogue: compute last partition, write output kv, part_start, bt, rmax, rsum, acc = _unpack(results) compute_qk_softmax_pv(kv, part_start, bt, rmax, rsum, acc) write_output(rmax, rsum, acc) ``` **ISA result**: 8 K-prefetch `buffer_load_dwordx4` appear at the END of the loop body (after PV MFMA), overlapping with the MFMA pipeline drain. The prologue has 8 K loads before the loop. The epilogue has 8 V loads only (no K loads needed). ### Three Critical Pitfalls 1. **Loop bounds must be a typed DSL integer such as `fx.Int64(...)`, NOT a Python int.** A plain int makes the AST rewriter unroll the loop and silently ignore `init=`. If you write `range(0, 15, 1, init=...)`, the AST rewriter treats the constant bounds as a Python `range` and unrolls; only plain Python-int bounds are unrolled, so a typed bound still produces a runtime `scf.for` (the rewriter index-casts non-Python-int bounds into `scf.for`). Use `fx.Int64(0)`, `fx.Int64(15)`, `fx.Int64(1)` instead. 2. **Prefer internal types; unwrap only at hard boundaries.** Most loop-carried values can remain `fx.Int32`, `fx.Float32`, or `fx.Vector`. Prefer these concrete types over wrapping a raw value in `ArithValue` directly -- note `fx.Vector` subclasses `ArithValue`, so this is about which constructor you reach for, not about avoiding the base class. If a low-level helper explicitly expects raw `ir.Value`, unwrap at that boundary. 3. **Build LDS views at the top of the kernel, not inside the runtime loop.** Allocate shared memory with `fx.SharedAllocator().allocate(...).peek()` and build each `.view()` once up front. A view created inside the `scf.for` body is defined in the loop scope; using it in the epilogue (outside the loop) causes an SSA dominance error. Building it once at the top makes it dominate both the loop and the epilogue. ## Applicable Patterns This optimization applies whenever you see this pattern in a kernel: | Signal | Description | |--------|-------------| | `for ... in range(N)` loop with `buffer_load` followed by MFMA | Load-then-compute in a loop body | | Block table lookup inside loop | `buffer_load(block_table_rsrc, idx)` followed by `buffer_load(cache_rsrc, page_id * stride)` | | KV cache iteration | Paged attention, flash attention, any tiled GEMM with paged memory | | Scale factor loads | FP8 per-token quantization scales loaded per KV block | ## Compiler Constraints FlyDSL kernels compile to GCN ISA where `s_waitcnt` insertion is controlled by the **compiler**, not by the programmer. You cannot directly eliminate `s_waitcnt` instructions. Instead, prefetch restructures the code so the compiler places `s_waitcnt` after enough compute work to hide the latency. ### Register Budget **Always check register headroom before adding prefetch buffers:** On CDNA3 (gfx942 MI300X/MI308), VGPRs are tracked as two **physical** files that share **one combined 512-entry occupancy budget** per SIMD: - **arch_vgpr** (up to 256 per SIMD): used by VALU, VMEM loads, LDS ops, and prefetch buffers - **accum_vgpr / AGPR** (up to 256 per SIMD): used by MFMA result writeback Prefetch buffers physically live in **arch_vgpr** and MFMA accumulators in **accum_vgpr**, but occupancy is governed by their **sum** (`arch_vgpr + accum_vgpr`), so growing prefetch buffers *does* compete with MFMA accumulators for the shared 512 budget and can cost occupancy. ```python # Estimate arch_vgpr cost of prefetch buffers: # - Each buffer_load_dwordx4 = 4 arch_vgpr per load # - 8 K-cache loads = 8 x 4 = 32 arch_vgpr for one buffer set # - Double-buffering = 2 x 32 = 64 arch_vgpr (but one set is reused) # - Net additional arch_vgpr ~ 32 (the "next" buffer) # # On MI300X (gfx942): arch_vgpr + accum_vgpr share ONE combined 512 budget/SIMD # Occupancy = 512 / (arch_vgpr_alloc + accum_vgpr_alloc) waves per SIMD # (combined-pool model — NOT 256/max; that was gfx908/CDNA1 only) # # Example: arch=148, accum=148 -> combined 296 -> 512//296 = 1 wave # Adding 32 arch_vgpr -> combined 328 -> still 1 wave (safe) # To reach 2 waves you need combined (arch+accum) <= 256 # arch+accum > 512 -> SPILL (exceeds the combined per-SIMD budget) ``` **Critical thresholds (gfx942, combined arch+accum budget):** | Combined arch_vgpr + accum_vgpr | Max Waves/SIMD | Impact | |--------------|---------------|--------| | <= 128 | 4 | High occupancy | | <= 170 | 3 | Good occupancy | | <= 256 | 2 | Moderate occupancy | | <= 512 | 1 | Minimum occupancy | | > 512 | **SPILL** | Register overflow -> severe perf regression | **How to check current VGPR allocation** (from rocprofv3 database): ```sql SELECT ks.KernelName, ki.arch_vgpr_count, ki.accum_vgpr_count FROM rocpd_kernel_dispatch kd JOIN rocpd_info_kernel_symbol ks ON kd.kernel_symbol_id = ks.id JOIN rocpd_info_kernel ki ON kd.kernel_id = ki.id WHERE ks.KernelName LIKE '%target_kernel%' LIMIT 5; ``` **NOTE**: `maxnreg` has been removed (it never reached LLVM). Forcing `accum_vgpr=0` to free register space for prefetch was never viable anyway: it pushes MFMA results through arch_vgpr via `v_accvgpr_read` spills, measured at a 4.5x GPU kernel regression. Target occupancy with `waves_per_eu`. ### What Prefetch Can and Cannot Do
Ver en GitHub
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub