Skip to content

[ROCm][Kernel] W4A16 skinny GEMM: hand-asm kernel for gfx1151#1045

Draft
mgehre-amd wants to merge 17 commits into
gfx11from
matthias.aiesw37245-w4a16
Draft

[ROCm][Kernel] W4A16 skinny GEMM: hand-asm kernel for gfx1151#1045
mgehre-amd wants to merge 17 commits into
gfx11from
matthias.aiesw37245-w4a16

Conversation

@mgehre-amd

@mgehre-amd mgehre-amd commented Jul 1, 2026

Copy link
Copy Markdown

Hand-editable AMDGCN implementation of the W4A16 (int4 weight / bf16
activation) skinny GEMM, tuned for gfx1151. It targets the [128,64,32]
8-warp tile, so all four Qwen3-VL-4B-AWQ decoder GEMMs are routed to it at
deep prefill -- each ~1.2x faster than the Triton path. The dispatcher is
also extended to route down_proj (K=9728, N=2560) to this tile instead of
its previous tall-K default (which the hand-asm did not cover).

Key techniques

Cumulative impact, measured by editing the raw Triton AMDGCN dump one
technique at a time and benchmarking each state (gate_up 19456x2560,
M=1322, gfx1151, clock-pinned, median of 3, TFLOP/s). Correctness held at
every step (baseline cos=1.000000, final cos=0.999995).

step technique added TFLOP/s delta
0 baseline (raw Triton dump) 23.8 --
1 + magic-constant dequant 25.3 +6.3%
2 + software-pipelined loads 27.1 +7.0%
3 + exact descriptors (drop OOB guards) 27.1 +0.0%
4 + WMMA SRC0/SRC1 bank split 27.2 +0.4%
5 + scalar-derived load offsets 27.1 -0.1%
total +14.1%

The two real wins are the dequant rewrite and software pipelining. Steps
3-5 are perf-neutral at this operating point (the loop is occupancy-bound,
so their savings are hidden) but are kept for other reasons: exact
descriptors remove page-fault risk, and the bank split + scalar offsets
free VGPR-bank cycles and in-loop VALU that would otherwise cap a future
deeper-pipelined version. (Steps are listed in applied order; this swaps
the numbering of "scalar offsets" and "WMMA banks" relative to a purely
logical grouping.)

1. Magic-constant bf16 dequant -- +6.3% (no int->float convert, no v_perm)

The stock int4->bf16 dequant masks each nibble, subtracts 8, and runs
v_cvt_f32_i32 per element. Instead, OR the 4-bit nibble into a fixed bf16
bit pattern so the raw value already reads as bf16(128 + n); a paired
constant supplies bf16(-136). A single v_dot2_bf16_bf16 with the
broadcast (scale, scale) then computes scale * ((128 + n) - 136) = scale * (n - 8) directly -- fusing the -8 offset, the scale multiply, and
the pair-accumulate into one op, and packing two K into one register via
op_sel (which also removes the v_perm_b32 repack).

; before: mask + (-8) + int->float, per element
v_and_b32_e32     v61, 15, v59
v_add_nc_u32_e32  v84, -8, v61
v_cvt_f32_i32_e32 v84, v84
...
v_dot2_bf16_bf16  v92, v86, v83, 0        ; then v_perm_b32 to repack halves
v_perm_b32        v86, v90, v86, 0x5040100

; after: OR nibble into a bf16 magic constant, dot2 does -8 + scale
v_and_or_b32      v84, v59, v92, 0xc3084300   ; -> (bf16(128+n), bf16(-136))
v_dot2_bf16_bf16  v94, v84, v124, 0            ; = scale*(n-8), low half
v_dot2_bf16_bf16  v94, v85, v124, 0 op_sel:[0,0,0,1]   ; second K -> high half

2. Software-pipelined global loads -- +7.0%

The loop-top s_waitcnt vmcnt(0) was stalling on global loads issued in
the same iteration -- ATT attributed ~40% of stall cycles to it, and the
activation load alone has ~250-cycle exposed latency. Prefetch iteration
k+1's weight/scale/activation tiles into a second register set while
iteration k's LDS stores and WMMAs run, so the wait is (mostly) already
satisfied by the time the loop reaches it.

; before: load and immediately consume within the same iteration
.LBB0_2:                                 ; loop header
    buffer_load_b128 v[51:54], v52, s[12:15], 0 offen
    ...
    ds_store_b128    v42, v[51:54]        ; waits on the load just issued

; after: prologue prefetches iter 0; loop body prefetches iter k+1
    ; --- prologue: prefetch iter 0 ---
    buffer_load_b128 v[115:118], v126, s[12:15], 0 offen
.LBB0_2:                                 ; loop header
    ds_store_b128    v42, v[115:118]      ; consumes the PREVIOUS prefetch
    ; --- prefetch iter k+1 (overlaps ds_store B + ds_load B + WMMA) ---
    buffer_load_b128 v[115:118], v126, s[12:15], 0 offen

3. Exact buffer-descriptor sizing (drop in-loop OOB guards) -- +0.0% (fault-safety)

The Triton output uses an "unbounded" descriptor (num_records = 0x7ffffffe) and guards every in-loop global load with a v_cmp +
v_cndmask that rewrites the address to 0x80000000 for OOB lanes. That
is 3 VALU ops + a compare per load, every iteration. Instead, set each
buffer's num_records to its exact byte size in the prologue; the hardware
then clamps OOB accesses to 0 with no fault, and the per-iteration guards
are deleted.

; before: unbounded descriptor + per-load address guards in the loop
s_mov_b32        s14, 0x7ffffffe
...
v_cmp_gt_i32_e64 s3, s26, v125
v_cndmask_b32_e64 v125, 0x80000000, v39, s2
buffer_load_b32  v123, v125, s[16:19], 0 offen

; after: exact per-buffer num_records in prologue, bare loads in the loop
s_lshl_b32       s18, s37, ...   ; weight num_records = N*stride_bn*4 (bytes)
s_lshl_b32       s10, s38, ...   ; scale  num_records = N*num_groups*2 (bytes)
buffer_load_b32  v123, v39, s[16:19], 0 offen   ; no guard; HW clamps OOB

4. WMMA SRC0/SRC1 VGPR bank separation -- +0.4% (within noise)

All 8 WMMAs had SRC0 (weight frag) and SRC1 (activation frag) starting in
the same VGPR bank (reg mod 4 == 3), which costs extra cycles per WMMA on
gfx11. The register file is fully packed (128/128 VGPRs at the 12-wave
boundary), so rather than add registers, the activation fragments are
shifted down by one register (v51-82 -> v50-81) to land in bank 2, and the
freed collision (the weight LDS load address) is relocated to the now-free
v82. VGPR count and occupancy are unchanged.

; before: SRC0 and SRC1 both start in bank 3 (reg%4==3)
ds_load_b128            v[51:54], v43
v_wmma_f32_16x16x16_bf16 v[25:32], v[83:90], v[51:58], v[25:32]

; after: SRC1 shifted to bank 2 (v50..), distinct from SRC0/SRC2 banks
ds_load_b128            v[50:53], v43
v_wmma_f32_16x16x16_bf16 v[25:32], v[83:90], v[50:57], v[25:32]

5. Scalar-derived load offsets -- -0.1% (within noise)

Each iteration bumped the per-lane VGPR base pointers with v_add
(v36 += 4, v39 += 16, v40 += 64). Since the per-K stride is uniform
across lanes, compute the byte offsets once as scalars and pass them in the
instruction's soffset field instead, removing the in-loop address VALU.

; before: VGPR pointer bumps every iteration
v_add_nc_u32_e32 v39, 16, v39
v_add_nc_u32_e32 v40, 64, v40
buffer_load_b32  v123, v39, s[16:19], 0  offen
buffer_load_b128 v[115:118], v40, s[12:15], 0  offen

; after: uniform K stride precomputed in SGPRs, used as soffset
s_lshr_b32 s37, s5, 1     ; weight K byte offset = s5/2 (4-bit packed)
s_lshl_b32 s38, s5, 1     ; act    K byte offset = s5*2 (bf16)
buffer_load_b32  v123, v39, s[16:19], s37 offen
buffer_load_b128 v[115:118], v40, s[12:15], s38 offen

Benchmark results (per-GEMM)

Qwen3-VL-4B-Instruct-AWQ-4bit decoder GEMMs, M=1322, group_size=32,
symmetric, bf16, on gfx1151 (Strix Halo). Clock-pinned, TFLOP/s (higher is
better), median of 3 reps. "Triton (before)" is the pre-PR production path.

GEMM shape (N x K) Triton (before) hand-asm (PR) speedup
gate_up_proj 19456 x 2560 22.1 27.0 1.22x
qkv_proj 6144 x 2560 21.9 26.8 1.23x
o_proj 4096 x 2560 22.5 27.2 1.21x
down_proj 2560 x 9728 18.6 27.4 1.47x
per-layer (aggregate) 21.1 27.1 1.28x

down_proj's 1.47x is two effects: routing it to the [128,64,32]x8 tile is
1.22x on Triton alone (18.6 -> 22.6 TFLOP/s), and the hand-asm kernel adds
a further 1.22x (22.6 -> 27.4 TFLOP/s).

Benchmark results (end-to-end)

Full-model prefill on Qwen3-VL-4B-Instruct-AWQ-4bit, gfx1151 (Strix Halo),
hand-asm kernel toggled via VLLM_W4A16_HANDASM. Identical args otherwise:
input-len 2048, output-len 1, num-prompts 10, max-num-batched-tokens 4096
(prefill M ~2858 > 1024, so the hand-asm tile is selected). Clock-pinned.

Metric Triton (off) hand-asm (on) speedup
Median TTFT 1406.3 ms 1208.9 ms 1.16x (-14%)

The ~14% median TTFT gain is well above the ~1% Strix Halo e2e variance.
It is diluted from the ~1.28x per-layer GEMM speedup by attention, norms,
and (in this multimodal run) the vision encoder.

Testing

  • benchmarks/kernels/benchmark_hybrid_w4a16.py --check --batches 1322
    -> cos=0.999994 vs Triton reference on all four shapes, PASS
  • Benchmarked clock-pinned on gfx1151, numbers above.

AI assistance was used for this change.

mgehre-amd added 17 commits July 1, 2026 04:32
Add benchmarks/kernels/benchmark_hybrid_w4a16.py, a CLI entry point for the
dense W4A16 prefill GEMM that reuses the perf regression test's measurement
rig (packed-int4 weights via pack_skinny_int4, cold-weight MALL rotation,
do_bench_cudagraph median timing). The reported time/call matches a real
prefill forward pass and the torch profiler trace.

Defaults reproduce the Qwen3-VL-4B-Instruct-AWQ-4bit fused gate_up_proj GEMM
at the 1322-token prefill shape (M=1322, N=19456, K=2560, group_size=32,
symmetric, bf16): 23.5 TFLOP/s / 5.6 ms on gfx1151, matching the in-model
profile within bench variance.

Changes:
- --model qwen3vl-4b-awq preset sweeps all four decoder GEMMs.
- Per-shape overrides (--k/--n/--group-size/--provider/--batches) for ad-hoc
  shapes; provider follows the test's hybrid-w4a16[-zp][-bf16] convention.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Add an opt-in workflow to directly hand-tune the ISA of the W4A16 skinny
prefill GEMM instead of going through the Triton JIT. The .amdgcn lives in the
source tree, is assembled to a .hsaco at vLLM build time, and is dispatched to
at runtime via a direct HIP module launch when the selected tile/config exactly
matches what the ISA was built for.

Pinned config (Qwen3-VL-4B-AWQ up/gate_proj class on gfx1151): bf16, symmetric,
group_size=32, BLOCK_M=128/BLOCK_N=64/BLOCK_K=32, 8 warps. Verified numerically
correct (cos 0.999996 vs torch reference) at M=1322,N=19456,K=2560.

Changes:
- asm/hybrid_w4a16_skinny_gfx1151.amdgcn: the hand-editable ISA (dumped from
  the Triton kernel) and .meta.json pinning the kernarg ABI + constexpr config.
- asm_w4a16.py: ctypes/libamdhip64 loader that packs the 88-byte kernarg buffer
  per the pinned layout and launches via hipModuleLaunchKernel; assembles the
  .amdgcn on demand (clang) when newer than the cached .hsaco, so the
  edit->rerun loop needs no full rebuild.
- hybrid_w4a16.py: gate the gfx11 dispatch on VLLM_W4A16_HANDASM=1; route to the
  hand-asm kernel only when config_matches(), asserting every pinned field,
  else fall through to Triton unchanged.
- CMakeLists.txt: assemble .amdgcn -> .hsaco at build time on gfx1151 HIP builds.
- pyproject.toml: exclude *.amdgcn from the typos hook (asm mnemonics like
  "offen" are not prose). .gitignore: the generated .hsaco is a build artifact.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Flip VLLM_W4A16_HANDASM to default-on so the hand-tuned AMDGCN kernel is used
for the matching config (bf16, symmetric, group_size=32, tile 128x64x32) without
needing the env var. Set VLLM_W4A16_HANDASM=0 to fall back to the Triton kernel.
The dispatcher still asserts every pinned constexpr/ABI field and falls through
to Triton for any non-matching shape.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
The benchmark only timed the kernel, so a bad hand-asm ISA edit (now the
default path on gfx1151) could report a fast-but-wrong result. Add --check:
run the active kernel and the Triton reference on identical inputs (toggling
VLLM_W4A16_HANDASM) and assert cosine similarity >= --check-tol (default
0.999), exiting non-zero on mismatch. Using Triton as the reference keeps it
general across providers with no dequant-math reconstruction, and it reports
whether the hand-asm path was actually exercised for the shape.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
…151)

Hand-optimize the gfx1151 W4A16 skinny GEMM AMDGCN:

- bf16 magic-constant dequant: replace the int4->fp32->bf16 path (v_cvt_f32_i32
  + software RNE v_bfe/v_add3/v_alignbit) with `v_and_or_b32 ...,0xc3084300`,
  producing bf16(128+nibble) in the low half and bf16(-136) in the high half in
  one op per nibble. The existing v_dot2_bf16_bf16 then computes
  b_raw*scale + (-136)*scale = (nibble-8)*scale with scale broadcast to both
  halves -- folding the -8 offset for free, no int->float conversion and no
  float->bf16 cast. (bf16 128.0 = 0x4300; nibbles 0..15 fit the 7-bit mantissa
  at 2^7 exactly.)
- CU mode + drop buffer_gl0_inv around the LDS-transpose barriers.

End-to-end at M=1322,N=19456,K=2560 (Qwen3-VL-4B-AWQ gate_up): 5.47 -> 5.22 ms
(~4.6% faster) vs the Triton path, cos 0.999995. Per-iter dequant VALU drops
(v_cvt_f32_i32 8->0, v_alignbit 8->0).

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Pack the v_dot2_bf16_bf16 dequant outputs directly with op_sel:[0,0,0,1]
(dest high-half write, preserving low) -- even-K to low, odd-K to high of
consecutive VGPRs -- so the four v_perm_b32 that repacked them before the
ds_store are eliminated. Correct (cos 0.999994); perf-neutral at M=1322 (the
perms were not on the critical path), but removes redundant ops and is the
WMMA-native packing.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Refactor the hand-written bf16 dequant into GAS/llvm-mc .macro definitions
(DQ_EXT0/DQ_EXT for the magic-constant nibble extract, DQ_PACK for the op_sel
dest-packed v_dot2 pair), invoked per nibble/pair. Assembler macros are pure
text expansion, so this is zero runtime cost: the assembled .hsaco is
byte-identical to the pre-refactor kernel (verified: same sha256). Purely a
readability/maintainability improvement -- the quant logic is defined once.

Verified: identical .hsaco, cos 0.999995 via the runtime loader.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
…ization)

Cut the per-iteration nibble-extract shifts from 7 to 3 by materializing the
odd-K nibbles' bf16 directly in the high half of the VGPR. ExLlama packs nibble
pair (K_even, K_odd) at bits (4p, 16+4p); bits 16-19 are the high-half bf16
mantissa LSBs, so `(view & 0x000F0000) | 0x4300c308` yields (bf16(-136) low,
bf16(128+n) high) with NO shift, and v_dot2_bf16_bf16 consumes b_raw via its
high lane (S0.hi*S1.hi) -- same (n-8)*scale. One shift per view (>>4,>>8,>>12)
now covers both nibbles of the pair (DQ_EXT_LO/DQ_EXT_HI macros).

Perf-neutral at M=1322 (~5.24 ms, same as the 7-shift form -- the extra shifts
were already hidden under WMMA/LDS latency, not on the critical path), cos
0.999995. Kept for the leaner op count (the recurring dequant body is now
3 shifts + 8 v_and_or + 8 v_dot2 for 8 elements).

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Move the two dequant nibble masks (0xF / 0xF0000) from per-iteration VGPR
v_mov (v92/v93) to SGPRs (s35/s36) set once in the loop prologue. v_and_or_b32
accepts an SGPR mask + literal OR within the constant-bus limit, and SGPRs
survive the loop (ds_load only writes VGPRs) -- unlike v92/v93, which the WMMA
B-fragment load `ds_load_b128 v[91:94]` clobbers every iteration, forcing the
re-materialization the VGPR form needed. Bumps .amdhsa_next_free_sgpr 35->37
(sgpr_count 37->39) to allocate s35/s36.

Removes 2 VALU v_mov from the loop body; perf-neutral at M=1322 (~5.24 ms),
cos 0.999995. Frees v92/v93 in the loop and leaves the prologue SGPR setup
loop-invariant.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Remove the `v_cndmask v83, 0x3f80, v83, s0` that applied the other=1.0 default
to out-of-bounds (offs_n >= N) scale lanes. It is redundant: the scale load
address is already forced out-of-bounds for those lanes
(`v_cndmask v56, 0x80000000, v56, s0`), so the buffer load returns 0 for them,
and the output store masks those columns out entirely -- the OOB column value
(0 vs 1.0 scaled) is discarded either way. The u16 load zero-extends, so the
following `v_lshl_or v83,v83,16,v83` still broadcasts to (scale,scale).

Validated correct at the aligned shape (N=19456, cos 0.999994) AND a
non-64-aligned shape (N=19457, last tile = 1 valid + 63 OOB columns, cos
0.999994) -- the latter exercises the OOB path directly. One fewer VALU op
per loop iteration.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Move the three nibble-extract shifts to the top of the dequant with distinct
destination registers (reusing the v_dot output regs v95-v97, dead until the
DQ_PACK dot2), then issue all 8 v_and_or against read-only sources into distinct
operand regs. This removes the in-place WAR (the old `DQ_EXT_HI v87,v87` read+
wrote the same reg the LO extract had just read) and the shift->and_or
serialization, so the 3 shifts are independent and the 8 extracts have no
mutual WAW/WAR.

Correct at aligned (N=19456) and OOB (N=19457) shapes, cos 0.999994; ~1% faster
at M=1322 (5.24 -> ~5.18 ms, near the box noise floor).

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Prefetch iteration k+1's weight/scale/activation tiles while iteration k's
LDS stores and WMMA execute, so the loop-top s_waitcnt vmcnt(0) no longer
stalls on a just-issued global load. ATT showed that wait was ~40% of the
loop's stall cycles; the activation load alone has ~250-cycle latency that
was fully exposed each iteration.

Changes:
- Decouple the four global loads into a dedicated register window
  (W->v123, scale->v124, A0/A1->v115-122) so a load destination is never
  also a downstream consumer's source mid-iteration.
- Emit the address/load block once in a prologue (loads iter 0) and once
  more after the dequant dot2 (prefetches iter k+1), overlapping it with
  the B-fragment ds_store/ds_load and the WMMA. The loop counter
  decrement + branch condition move into the relocated in-loop copy.
- The last iteration's prefetch reads one K-tile past the end; addresses
  are already OOB-masked to 0, so it is harmless (correctness unchanged).
- next_free_vgpr/vgpr_count 115->128. 115 already rounded up to the 128
  VGPR granule, so occupancy is unchanged (12 waves/SIMD).

Measured on gfx1151, shape 1322x19456x2560 g32 bf16 (up_proj), interleaved
A/B in one process: median 5.239 -> 4.849 ms/call (~7.4% faster, 25.1 ->
27.1 TFLOP/s). Correctness: cos 0.999994 vs Triton reference.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Pure readability cleanup of the hand-asm kernel, no codegen change.

Changes:
- Remove all 311 .loc debug-line directives (they tracked the original
  Triton source lines and only added noise to the hand-edited ISA; the
  .file directive stays so the assembler is happy).
- Surround the K-reduction loop with blank lines and a header comment
  describing what it iterates over (ceil(K / BLOCK_K) tiles: prefetched
  loads -> int4 dequant -> bf16 WMMA accumulate), and tag the backedge.

Correctness unchanged: cos 0.999995 vs Triton reference.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
…xactly

Remove the four in-loop v_cndmask OOB address guards (and their now-dead
predicate chain) from the software-pipelined prefetch, and instead bound
each read buffer's descriptor to its real size so the hardware clamps any
out-of-bounds lane to 0 without issuing a memory access.

Why the guards are removable: grouped quantization requires group_size to
divide K, and group_size == BLOCK_K == 32, so K is always a multiple of
BLOCK_K -- no K-tail can occur. The only OOB accesses are M-tail rows
(output rows beyond M, masked out by the existing output-store guards) and
the discarded speculative last prefetch.

Why num_records must be set: the descriptors previously used the Triton
"unbounded" sentinel num_records = 0x7ffffffe (~2GB), which only returns 0
when paired with the cndmask sentinel address 0x80000000. Dropping the
cndmasks alone left OOB lanes reading their real (sub-2GB) addresses --
both a latent page-fault risk (it only stayed safe by landing in the
allocator's mapped slack) and a measured slowdown, because those spurious
reads to adjacent memory inflated the loop's s_waitcnt vmcnt(0) wait.

Sizing each descriptor to its exact byte extent fixes both: OOB offsets
now exceed num_records, so the hardware returns 0 with no memory access.

Changes:
- Drop the 4 cndmask guards + dead chain (2 K-bound v_cmp, 3 s_and, dead
  v125=s5+v37 and v36 increment); load direct from the incrementing bases
  (v41 advance reordered after the A1 load to keep the pre-inc address).
- Compute per-buffer num_records in the prologue from dims already live
  there (M=s22, N=s23, K=s26, stride_bn=s5, num_groups=s6): activation
  M*K*2, weight N*stride_bn*4, scale N*num_groups*2. No kernarg ABI change.
  +2 scratch SGPRs (s37/s38), no occupancy change (still 12 waves/SIMD).
- Output-store descriptor and its guards are left untouched.

ATT (s_waitcnt vmcnt(0) stall): guarded 6.35M, naive no-guard 9.58M,
exact-num_records 7.94M. Wall-clock at M=1322 N=19456 K=2560 g32 bf16 is
parity with guarded (4.852 vs 4.854 ms/call, interleaved A/B).
Correctness: cos 0.999994 vs Triton reference.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
…fset

The two activation b128 loads address the two 64-row halves of the
BLOCK_M=128 tile. Their base addresses differ by a loop-invariant constant
(v41 = v40 + 128*K, i.e. 64 rows), and both advanced by the same +64/iter,
so the second pointer (v41) and its per-iteration increment were redundant.

Load A1 by reusing the A0 voffset (v40) with the constant 128*K supplied as
the buffer_load scalar-offset operand (precomputed once as s39 = K<<7).
This removes the v41 prologue init, both v41 increments, and the now-dead
v3 multiply; the loop's lone v40 advance moves after the A1 load so both
loads read the pre-increment base.

Performance is unchanged (4.813 vs 4.818 ms/call, interleaved A/B; the loop
is latency-bound). The benefit is one fewer VALU op per iteration and a
simpler address scheme. Correctness: cos 0.999994 vs Triton reference.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
All 8 WMMAs had SRC0 (weight frag) and SRC1 (activation frag) starting in
the same VGPR bank (reg mod 4 == 3), which costs extra cycles per WMMA on
gfx11. The register file is fully packed (128/128 VGPRs, at the 12-wave
occupancy boundary), so rather than add registers, swap within the file:

- Shift the activation SRC1 fragments down by one (v51-82 -> v50-81) so
  their first registers land in bank 2, distinct from SRC0 (bank 3) and
  the SRC2 accumulators (bank 1).
- Relocate the freed-up collision: the weight LDS load address moves from
  v50 to the now-free v82.

VGPR count and occupancy are unchanged. Performance is within noise
(4.820 vs 4.830 ms/call, interleaved A/B) because the loop is currently
global-load-latency-bound, so the WMMA bank savings are mostly hidden
behind the s_waitcnt vmcnt(0) wait; the benefit will surface once the
prefetch latency is hidden (deeper pipelining). Correctness: cos 0.999994.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
…induction

The weight and activation prefetch pointers were advanced every iteration
with per-lane VGPR adds (v39 += 16, v40 += 64). The increments are uniform
across lanes, so they belong in a scalar SOFFSET rather than the VALU.

Both byte offsets are exact functions of the existing K-element counter s5
(s5 += 32/iter): weight = s5/2 (4-bit packed), activation = s5*2 (bf16),
A1 = activation + 128*K. Compute these three scalars at the top of the
prefetch block (while s5 is still the current iteration's value) and pass
them as the buffer_load scalar offset; v39/v40 now stay fixed at their
iter-0 per-lane base.

Changes:
- Loop: replace the two v_add_nc_u32 pointer increments with s_lshr/s_lshl/
  s_add into s37/s38/s40, used as the weight/A0/A1 soffsets (2 VALU -> 3
  SALU, moving induction off the dequant-heavy VALU path).
- Prologue: drop the now-dead v36/v39/v40 increments (the iter-0 loads keep
  their literal soffsets, which equal the s5=0 offsets).
- +1 scratch SGPR (s40); occupancy unchanged.

Performance within noise (4.837 vs 4.845 ms/call, interleaved A/B) -- the
loop is global-load-latency-bound, so freeing VALU slots is groundwork that
pays off once the prefetch latency is hidden. Correctness: cos 0.999994.

Co-authored-by: Claude

Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
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.

1 participant