[ROCm][Kernel] W4A16 skinny GEMM: hand-asm kernel for gfx1151#1045
Draft
mgehre-amd wants to merge 17 commits into
Draft
[ROCm][Kernel] W4A16 skinny GEMM: hand-asm kernel for gfx1151#1045mgehre-amd wants to merge 17 commits into
mgehre-amd wants to merge 17 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
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_i32per element. Instead, OR the 4-bit nibble into a fixed bf16bit pattern so the raw value already reads as
bf16(128 + n); a pairedconstant supplies
bf16(-136). A singlev_dot2_bf16_bf16with thebroadcast
(scale, scale)then computesscale * ((128 + n) - 136) = scale * (n - 8)directly -- fusing the -8 offset, the scale multiply, andthe pair-accumulate into one op, and packing two K into one register via
op_sel(which also removes thev_perm_b32repack).2. Software-pipelined global loads -- +7.0%
The loop-top
s_waitcnt vmcnt(0)was stalling on global loads issued inthe 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.
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 av_cmp+v_cndmaskthat rewrites the address to0x80000000for OOB lanes. Thatis 3 VALU ops + a compare per load, every iteration. Instead, set each
buffer's
num_recordsto its exact byte size in the prologue; the hardwarethen clamps OOB accesses to 0 with no fault, and the per-iteration guards
are deleted.
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 ongfx11. 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.
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 uniformacross lanes, compute the byte offsets once as scalars and pass them in the
instruction's
soffsetfield instead, removing the in-loop address VALU.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.
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.
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
-> cos=0.999994 vs Triton reference on all four shapes, PASS
AI assistance was used for this change.