A standalone HIP host driver for the hand-written f4gemm assembly kernels that ship as pre-built code objects in ROCm/aiter.
D[M, N] (bf16) = alpha * A[M, K] (mxfp4) * B[N, K] (mxfp4)^T + beta * C[M, N]
This directory contains host launch logic only. It re-implements the relevant parts of aiter's csrc/py_itfs_cu/asm_gemm_a4w4.cu with no torch and no aiter dependency, so the kernels can be poked at, verified against a CPU reference, and benchmarked from a plain HIP program.
If you want to read or hand-edit the kernels (see the round-trip section below), these are the references worth having open:
- CDNA4 Instruction Set Architecture (AMD, PDF) — the gfx950 ISA: every instruction's encoding and semantics, the MFMA/MXFP4 tables,
s_waitcntrules, LDS/buffer details. The single most important document. (ISAs for other architectures are collected on the ROCm GPU architecture page.) - LLVM AMDGPU usage — how the LLVM assembler/disassembler talks about AMD GPUs: target IDs (
gfx950:xnack+), code object versions, the.amdhsa_kernel/.amdgpu_metadatadirectives, and ELF note layouts. This is the ground truth for whatco2asm.pyreconstructs and whatclang -x assembleraccepts. - AMD CDNA 4 architecture whitepaper (AMD, PDF) — the machine model: CUs, matrix pipes, LDS, cache hierarchy. Background for why kernels are pipelined the way they are.
- The disassembler/assembler themselves:
llvm-objdump -d --triple=amdgcn-amd-amdhsa --mcpu=gfx950andclang -x assembler(both under/opt/rocm/llvm/bin) — when in doubt, assemble a snippet and disassemble it back.
The .co files are not copied into gcnasm. Point the driver at aiter's tree:
<aiter>/hsa/gfx950/f4gemm/
f4gemm_bf16_per1x32Fp4.csv <- kernel manifest
f4gemm_bf16_per1x32Fp4_BpreShuffle_<tileM>x<tileN>.co
f4gemm_bf16_per1x32Fp4_noBpreShuffle_256x256.co
35 kernels total: 34 pre-shuffled-B tiles plus one non-pre-shuffled 256x256. The manifest CSV is parsed at runtime (columns tile_M, tile_N, splitK, bpreshuffle, knl_name, co_name), so nothing has to be regenerated when aiter adds a tile.
./build.sh # host-only; hipcc, no --offload-arch needed
export AITER_ASM_DIR=/path/to/aiter/hsa # or pass --co-dir directly
./asm_f4gemm.exe -m 512 -n 1024 -k 2048
./asm_f4gemm.exe --list # dump the manifest
./run_tests.sh # full regression sweepOptions: -m/-n/-k, --co-dir DIR, --kernel NAME (mangled symbol or .co file name), --bpreshuffle 0|1, --splitk L (-1 = let the heuristic try 2/4/8/16), --iters N, --no-verify, --list.
Shape constraints enforced by the driver: K % 256 == 0 and N % 16 == 0. M is unconstrained (M = 1 works).
Every argument occupies a 16-byte slot; the kernels s_load them from fixed offsets. Offsets below are from the .co metadata (llvm-readelf --notes), and the "read" column is from the disassembly (llvm-objdump -d --triple=amdgcn-amd-amdhsa --mcpu=gfx950).
| offset | field | size | read by kernel |
|---|---|---|---|
0x00 |
ptr_D |
8 | yes |
0x10 |
ptr_C (bias) |
8 | yes |
0x20 |
ptr_A |
8 | yes |
0x30 |
ptr_B |
8 | yes |
0x40 |
alpha |
4 | yes |
0x50 |
beta |
4 | yes |
0x60 |
stride_D0 |
4 | no |
0x70 |
stride_D1 |
4 | no |
0x80 |
stride_C0 |
4 | yes -- used for both C and D |
0x90 |
stride_C1 |
4 | no |
0xa0 |
stride_A0 |
4 | yes |
0xb0 |
stride_A1 |
4 | no |
0xc0 |
stride_B0 |
4 | yes |
0xd0 |
stride_B1 |
4 | no |
0xe0 |
M |
4 | yes |
0xf0 |
N |
4 | yes |
0x100 |
K |
4 | yes |
0x110 |
ptr_ScaleA |
8 | yes |
0x120 |
ptr_ScaleB |
8 | yes |
0x130 |
stride_ScaleA0 |
4 | yes |
0x140 |
stride_ScaleA1 |
4 | no |
0x150 |
stride_ScaleB0 |
4 | yes |
0x160 |
stride_ScaleB1 |
4 | no |
0x170 |
log2_k_split |
4 | splitK kernels only |
sizeof == 0x174; kernarg_segment_size is 384 (368 for the noBpreShuffle kernel, which stops before log2_k_split).
Note that stride_D0 is dead -- the kernel uses stride_C0 for the D store too, which is why aiter assigns out.stride(0) to stride_C0 and leaves stride_D0 unset. A/B strides are counted in fp4 elements, not bytes: aiter passes tensor.stride(0) * 2 because the tensors are stored as fp4x2 bytes. The kernel does the >> 1 back to bytes itself.
All 35 kernels: 256 threads (4 x wave64), 160 KB LDS (static group_segment_fixed_size, so sharedMemBytes stays 0), 512 VGPRs, 96 SGPRs.
gdx = ceil(N / tile_N)
gdy = ceil(M / tile_M)
gdz = 1, or the K-split count for splitK kernels
The kernel flattens wg_y * gdx + wg_x and re-swizzles it into groups of 32 N-tiles for L2 locality.
select_kernel() ports aiter's get_heuristic_kernel(): for each manifest entry it computes ceil(tiles / num_cu) rounds and picks the fewest rounds, tie-broken by CU occupancy and by the tile_M * tile_N / (tile_M + tile_N) compute-to-memory ratio. One quirk carried over: the 128x512 tile is skipped unless N % 512 == 0.
aiter iterates an unordered_map here, so its tie-breaking is not reproducible run to run. This port iterates the CSV in order, which makes the choice stable.
These mirror what the aiter python side produces, and getting any of them wrong is the usual reason a hand-rolled launch returns garbage.
Row-major, no shuffle. Byte i of a row holds element 2i in the low nibble and 2i+1 in the high nibble. Values are OCP e2m1: {0, .5, 1, 1.5, 2, 3, 4, 6} with the sign in bit 3.
For the BpreShuffle kernels, B goes through aiter's shuffle_weight(w, layout=(16, 16)) on the packed byte buffer:
src.view(N/16, 16, Kp/32, 2, 16).permute(0, 2, 3, 1, 4) # Kp = K/2
src[n0*16 + n1][k0*32 + k1*16 + k2]
-> dst[(((n0 * Kp/32 + k0) * 2 + k1) * 16 + n1) * 16 + k2]
The single noBpreShuffle_256x256 kernel takes plain row-major B instead.
E8M0 is a bare biased exponent: value = 2^(e - 127), with 0 -> 2^-126 and 0xFF -> NaN. One scale per 32 K-elements.
The buffer is first padded to [round_up(rows, 256), round_up(K/32, 8)], then shuffled (aiter's shuffle_scale, the non-guinterleave path):
padded.view(sm/32, 2, 16, sn/8, 2, 4).permute(0, 3, 5, 2, 4, 1)
padded[d0*32 + d1*16 + d2][d3*8 + d4*4 + d5]
-> dst[((((d0 * sn/8 + d3) * 4 + d5) * 16 + d2) * 2 + d4) * 2 + d1]
stride_ScaleA0 / stride_ScaleB0 are the padded column count sn. This driver fills the pad with 0x7F (2^0 == 1.0) so an edge tile can never pick up a NaN scale.
aiter allocates [(M + 31) / 32 * 32, N] and slices [:M] afterwards; stride_C0 = N. A/B rows are over-allocated to a multiple of 256 here so an edge tile can never touch unmapped memory.
beta = 0 and ptr_C = nullptr is the only path aiter's own op tests exercise, and it is what this driver uses. The bias path is wired through the kernarg block but not verified here -- aiter documents bias as f32 while stride_C0 comes from the bf16 output tensor, so the intended element type is ambiguous.
Operands are random fp4 nibbles with exponents drawn from 2^-3 .. 2^3, which keeps every partial product exactly representable in f32. The reference is a threaded f64-accumulate CPU GEMM over the dequantized operands.
Without splitK, the measured error is exactly the bf16 output rounding, max_rel_err = 0.003891 ~= 2^-8, across all 35 kernels.
splitK accumulates in bf16. The splitK epilogue writes each K-chunk's partial sum with buffer_atomic_pk_add_bf16, so the cross-chunk reduction itself runs at bf16 precision -- the error scales with the magnitude of the partials, not of the final element, and heavily-cancelling outputs can be off by a large relative amount. Measured on M=256 N=1024 K=4096:
log2_k_split |
max abs err | as a fraction of max|ref| |
|---|---|---|
| 0 | 103 | 0.28 % |
| 1 | 184 | 0.51 % |
| 2 | 224 | 0.62 % |
| 3 | 293 | 0.81 % |
| 4 | 416 | 1.15 % |
So the check switches to an absolute bound of 2 % of max|ref| whenever gdz > 1. This is a real property of the kernels, not a launch bug; aiter's own op_tests/test_gemm_a4w4.py leaves the splitK path commented out.
MI355X (gfx950, 256 CU), --iters 50, heuristic kernel selection:
| M | N | K | kernel | us | TFLOP/s |
|---|---|---|---|---|---|
| 8192 | 8192 | 8192 | BpreShuffle_256x256 | 259.0 | 4245 |
| 2048 | 8192 | 8192 | BpreShuffle_256x256 | 64.4 | 4270 |
| 4096 | 4096 | 4096 | BpreShuffle_256x256 | 33.5 | 4103 |
| 128 | 16384 | 16384 | BpreShuffle_96x640 | 70.2 | 979 |
run_tests.sh covers 8 heuristic shapes, all 35 kernels at M=300 N=2048 K=1024, and 8 splitK configurations: 51 checks, all passing.
aiter ships only the assembled objects, not the sources. The intended workflow is fully manual:
- Disassemble a
.cointo reassemblable GCN assembly withco2asm.py. - Edit the
.sby hand — that is the whole point. - Reassemble into a new
.cowith clang (co2asm.pyprints the exact command, with the arch and code object version already detected). - Run it through the host driver: put the rebuilt
.coin a directory with a one-line manifest CSV listing only that kernel, and point--co-dirat it — so whatever shape you ask for, the rebuilt object is provably the one that ran.
# 1. disassemble (writes <kernel>.s next to the .co by default; -o to redirect)
python3 co2asm.py /path/to/aiter/hsa/gfx950/f4gemm/f4gemm_bf16_per1x32Fp4_BpreShuffle_256x256.co
# 2. edit the .s ... then
# 3. reassemble
/opt/rocm/llvm/bin/clang -x assembler -target amdgcn-amd-amdhsa \
-mcpu=gfx950 -mcode-object-version=6 \
f4gemm_bf16_per1x32Fp4_BpreShuffle_256x256.s \
-o rebuilt/f4gemm_bf16_per1x32Fp4_BpreShuffle_256x256.co
# 4. manifest listing only the rebuilt kernel, then run it
SRC=/path/to/aiter/hsa/gfx950/f4gemm
head -1 $SRC/f4gemm_bf16_per1x32Fp4.csv > rebuilt/f4gemm_bf16_per1x32Fp4.csv
grep ",f4gemm_bf16_per1x32Fp4_BpreShuffle_256x256.co" $SRC/f4gemm_bf16_per1x32Fp4.csv \
>> rebuilt/f4gemm_bf16_per1x32Fp4.csv
./asm_f4gemm.exe --co-dir ./rebuilt -m 4096 -n 4096 -k 4096The 256x256 preshuffled tile is a good starting point: it is the tile the heuristic picks for every large shape, and the fastest one measured above.
For a quick look at a kernel, raw llvm-objdump is all you need:
/opt/rocm/llvm/bin/llvm-objdump -d --triple=amdgcn-amd-amdhsa --mcpu=gfx950 <file.co>That prints every section it can decode — .text as instructions (each line suffixed with a // <addr>: <encoding> comment) and the 64-byte kernel descriptor in .rodata rendered as .amdhsa_* directives. What it prints is not reassemblable as-is; co2asm.py is the delta between that raw dump and a .s that clang -x assembler accepts and reproduces the original with. Concretely, it:
- Keeps only
.textfrom the instruction dump and strips the// <addr>: <encoding>comment from every line. Kernel entry points (any symbol not namedlabel_XXXX) become.globl+.type @function, with.p2align 8restored. - Separately dumps the descriptor with
llvm-objdump -d -j .rodataand applies the two field fixups described below (.amdhsa_next_free_sgprand.amdhsa_reserve_xnack_mask). - Recovers the metadata note — the msgpack kernarg/LDS/workgroup table — from
llvm-readelf --notesand re-emits the YAML document between.amdgpu_metadata/.end_amdgpu_metadata. - Maps the ELF header to assembler flags: arch from
Flags:(--mcpufor both objdump and clang), andABI Versionto the real code object version (ABI 4→-mcode-object-version=6, see below), then prints the exactclangreassembly command.
A code object holds three things the assembler needs, and each comes from a different tool:
| Piece | Recovered with | Becomes |
|---|---|---|
| instructions + local labels | llvm-objdump -d |
.text |
| 64-byte kernel descriptor | llvm-objdump -d -j .rodata |
.amdhsa_kernel ... .end_amdhsa_kernel |
| msgpack note (kernarg offsets, LDS, workgroup size) | llvm-readelf --notes |
.amdgpu_metadata ... .end_amdgpu_metadata |
The branch targets survive because the objects keep their label_XXXX symbols in .symtab, so llvm-objdump emits real labels rather than raw addresses.
Three things about llvm-objdump's descriptor dump make it not directly reassemblable, all handled in co2asm.py:
.amdhsa_next_free_sgpris not what it looks like. The descriptor only stores the SGPR count granulated by 8, so objdump reports the top of the granule (104) and inverts the encoding assuming zero extra SGPRs. The assembler goes the other way and addsgetNumExtraSGPRs()back before granulating — 6 on gfx8+ when flat_scratch is reserved, which is the default objdump never prints. Feeding 104 straight back is rejected outright (gfx9 addresses at most 102), and clamping to 102 silently lands one granule too high. Subtracting the 6 extras reproduces the encoded byte exactly..amdhsa_reserve_xnack_maskis only legal when the target id names xnack. These objects are built for xnackANY, so the directive is dropped and the target left as plaingfx950— which also keeps the rebuilt ELF feature flags bit-identical. Dropping it is safe precisely because the extra-SGPR count is driven by flat_scratch, not xnack.ABI Versionin the ELF header is not the code object version.ELFABIVERSION_AMDGPU_HSA_V2is 0, V3 is 1, and so on, so theABI Version: 4these objects report means-mcode-object-version=6. Building with4produces a silently different object.
Before modifying anything, it is worth checking the plain round-trip against the original — four checks, all doable by hand:
ORIG=/path/to/aiter/hsa/gfx950/f4gemm/f4gemm_..._256x256.co
NEW=rebuilt/f4gemm_..._256x256.co
RL=/opt/rocm/llvm/bin
# 1. ELF header: arch + feature flags + ABI version must match
diff <($RL/llvm-readelf -h $ORIG | grep -E "ABI Version|Flags:") \
<($RL/llvm-readelf -h $NEW | grep -E "ABI Version|Flags:")
# 2. kernel descriptor: must be byte-identical, it drives SGPR/VGPR/LDS setup
$RL/llvm-objcopy --dump-section=.rodata=o.kd $ORIG /dev/null
$RL/llvm-objcopy --dump-section=.rodata=n.kd $NEW /dev/null
cmp o.kd n.kd
# 3. .text: compare as disassembly, not bytes (see below)
diff <($RL/llvm-objdump -d --triple=amdgcn-amd-amdhsa --mcpu=gfx950 $ORIG | sed 's|//.*||') \
<($RL/llvm-objdump -d --triple=amdgcn-amd-amdhsa --mcpu=gfx950 $NEW | sed 's|//.*||')
# 4. metadata note: kernarg offsets, LDS, workgroup size
diff <($RL/llvm-readelf --notes $ORIG) <($RL/llvm-readelf --notes $NEW)For an unmodified round-trip of the 256x256 tile, all four report identical — the descriptor and metadata are byte-identical and the rebuilt file is the same size as the original (35632 bytes).
.text is compared as disassembly, not as bytes, because 508 bytes genuinely differ. Every one of them is bit 13 and/or 14 of a v_mfma_scale_f32_16x16x128_f8f6f4 first dword (0xd3ac....) — the src2 op_sel / op_sel_hi bits, which are don't-cares for the accumulator operand. Whatever assembled the original set them; LLVM's assembler emits 0 and its disassembler ignores them, so all 3413 instructions decode identically. Nothing else in .text moves.
The rebuilt object was run through the same checks as the original on MI355X:
| rebuilt | original | |
|---|---|---|
M=4096 N=4096 K=4096 |
PASSED, max_abs_err=128, max_rel_err=0.003891 |
PASSED, max_abs_err=128, max_rel_err=0.003891 |
| same, TFLOP/s | 4048 | 3958 |
M=300 N=2048 K=1024 |
PASSED | PASSED |
M=1 N=4096 K=2048 |
PASSED | PASSED |
--splitk 2 / --splitk 4 |
PASSED | PASSED |
Identical error metrics, and the throughput difference is run-to-run noise.
Because rebuilt/ holds a single tile, a wrong --co-dir is not a silent fallback. If the manifest lists a kernel the directory does not contain, the driver names the missing file and exits non-zero rather than letting hipModuleLoad report a bare "file not found":
[f4gemm] no such code object: /tmp/f4probe/f4gemm_bf16_per1x32Fp4_BpreShuffle_96x640.co
[f4gemm] the manifest lists this kernel but --co-dir does not contain it -- point --co-dir at a directory holding it
co2asm.py is not f4gemm-specific — it takes any AMDGPU code object and detects the arch and code object version from the ELF header.
| File | Contents |
|---|---|
f4gemm.hpp |
kernarg struct, manifest parser, tile heuristic, hipModuleLoad wrapper, grid setup |
f4gemm_ref.hpp |
fp4/e8m0 decode, B and scale shuffles, operand generation, CPU reference |
main.cpp |
CLI, buffer setup, verification, benchmark |
run_tests.sh |
regression sweep |
co2asm.py |
code object -> reassemblable .s (any AMDGPU .co, not just f4gemm) |