ck_tile: fix 2:4-sparse SWMMAC correctness on gfx1201/RDNA4 (3 bugs) + fail→pass repro - #3759
ck_tile: fix 2:4-sparse SWMMAC correctness on gfx1201/RDNA4 (3 bugs) + fail→pass repro#3759The-Monk wants to merge 3 commits into
Conversation
…+ repro Fixes three bugs in the sparse MmaOpFamily compress path that produce wrong results on gfx1201 (v_swmmac_*_iu4): phantom fill on <2-nonzero 2:4 groups, pk_int4_t group-of-4 byte miscount (OOB), and idx-metadata ordering mismatch. Adds a standalone repro that fails on unpatched CK (max_abs_err=352) and passes with the fix (max_abs_err=0). Refs ROCm#3753.
…dormant, Stage 27) All new paths gated behind GGML_HIP_* env vars -> OFF by default. Zero change to default dispatch. Backs the AMD PR ROCm/composable_kernel#3759 §3-4 journey. WINS: - mul_mat_2of4_fp8_mmq: MMQ-grade 2:4-sparse fp8 GEMM (cooperative tiles, LDS staging, ILP>=4). At/ahead of our native-fp8 WMMA MMQ on the 3 dominant GEMM shapes (+1.8% gate-up / +3.9% q-o / +32% down-proj). - mul_mat_dense_fp8_mmq: dense-fp8 MMQ twin. - shuffle-based activation-quantize in mmvq.cu (128-thread, float4 loads, warp-shuffle max-reduction, zero LDS/__syncthreads) -- replaces the 32-thread LDS+sync reducer; ~36us->18us, closed ~40% of the remaining 8% gap. Both kernels now high-90s% of native-fp8 (0.989x same-session). - k/v adaptive tile in mul_mat_2of4_fp8.cu. CORRECTIONS / MEASUREMENT CONTROLS (the honest half): - mul_mat_dense_fp8_v3: dense-fp8 twin used as the sparsity-isolation control. Proves the ISA 2x/4x sparsity ceiling WASHES to ~1% at library grade (2x ISA -> 1.25x crude -> ~1.01x well-tiled). This is the finding sent to AMD. - swmmac24_iu4_fixed: NEGATIVE test. Applying CK's idx swap+XOR-1 to our OWN correct native SWMMAC encoder BREAKS it (err 0 -> 385). Proves the CK bug is compress/packing-convention-specific, NOT universal hardware behavior -- folded into PR #3759 Bug 3 precision note. - int4_24_probe: int4-2:4 probe (FAILING, gated off). Source of the "4x" decomposition: 2x(int4 packing, already in dense wmma_i32_16x16x32_iu4) x 2x(2:4 sparsity, washes) -> net ~1.2x weight-bandwidth only.
|
Thanks for digging this out and writing it up in this much detail — this had been sitting without a Short version: bug 1's fix holds up — I reproduced a failure on the base tree and, for what this This is not a formal approval: I can only speak for gfx1201 and the int8 path. Environment. AMD Radeon RX 9070 XT, Two environment-side notes so the commands reproduce, neither of which is a problem with this PR: 1. The repro can't be built from the PR alone
#include "real_a_tile.h" // REAL_A_16x128: Quark int8 2:4 weight tileoutside the I generated a substitute tile to get something running: same 2. base fails, head passes
The harness wraps 3. The whole effect is bug 1Two more variants — base plus only the
The 4. The test never reaches iu4, so bugs 2 and 3 get no numerical coverage from itI disassembled the code objects of the kernels that actually launch, rather than grepping the That follows from the instantiation: the test uses So the description reasons about all three bugs from the iu4 side, but the committed test only What I could check is the shape side, with a probe that instantiates
Both trees compile, both emit 5. The buildable configuration passes on the unfixed tree — and the reason is the one you identifiedBuilt without
I expected that to be because the synthetic input has no under-filled groups, but that isn't it. I
So under-filled groups are plentiful — the distribution includes 0 — and it still passes on the buggy The practical consequence: once the missing header is supplied in whatever form, the configuration 6. The change isn't scoped to gfx1201, and bug 1's fix demonstrably reaches CDNAThis is my main question before merge, and I don't think it's answerable from gfx1201 alone. Neither changed hunk has an architecture predicate; both branch on struct MmaTransformsDefaultSelector<MmaOp, CompilerTarget,
std::enable_if_t<MmaOp::OpFamily == MmaOpFamily::SPARSE>>
{ using SelectedTransforms = MmaDefaultTransformsSparse<MmaOp::kCompressionRatio>; };with no I checked that rather than inferring it. A compile-only probe that instantiates static_assert(std::is_same_v<
typename MmaTransformsDefaultSelector<MmaOp942, Gfx942Target>::SelectedTransforms,
MmaDefaultTransformsSparse<MmaOp942::kCompressionRatio>>);compiles on both trees at That makes bug 1's fix a behaviour change for gfx942 as well, for inputs whose survivors don't sit The narrower half of this: I found no On CI: the GitHub API reports no check suites and no commit statuses for either 7. Minor, non-blocking
What would let me say more
Happy to re-run any of this on gfx1201. If it would help, I can also try to pin bug 3's mapping |
Address review feedback on the sparse SWMMAC fixes: - generate the adversarial A tile in-source (all six two-survivor position pairs, four single-survivor positions, zero-group; values keyed to (row, group, position)) and make it the DEFAULT build -- fails on the unfixed tree (max_abs_err 112/127/272 at K=32/64/128), passes after the fix; removes the uncommitted real_a_tile.h dependency - demote the slots-0,2 canonical pattern to -DUSE_CANONICAL_PATTERN control (it cannot detect the default bug) - scaffold a pk_int4 case behind -DENABLE_PK4_CASE, explicitly marked unvalidated (host fill layout unproven) pending an independent iu4 metadata-mapping cross-check - reword internal process labels to bug-number references; add ARCH SCOPE note at the packed path (the SWAP+XOR-1 mapping is gfx1201-measured) CCA
|
Thanks for this — running it on your own gfx1201 and bisecting the line both directions is exactly the review this needed, and the §5 finding (the canonical pattern structurally can't detect the bug) plus §1 (missing header) are both fair hits. Fixes pushed in ad79359: §1/§5 — repro now self-contained, and the default config fails-before/passes-after. §4 — iu4 coverage. Agreed this is the real gap, and I'll take you up on the one-hot §6 — intended scope. Bug 1's fix is intentionally architecture-generic: the old §7 — housekeeping. Internal stage labels reworded to bug-number references throughout. Noted on the Your env notes ( |
|
Since you have gfx1201 and clearly care about this corner of the stack — some context on where this PR came from, and the wider map it belongs to. While building a 2:4-sparse fp8/int4 inference path for RDNA4 we kept hitting the same pattern: the capability is in the ISA, but no library exercises it, so the first real user finds the bugs (or the absence). The list of gaps we personally hit and had to fill with hand-written kernels, in case any of it is useful to you or worth more upstream issues: Matrix/dot datapaths:
Verification gaps that compound it — validating new datapath kernels on gfx1201 is disproportionately hard because: device ASAN is unavailable (no Happy to compare notes on any of these, or split out issues where they belong in other repos — the sparse/iu4 items are the CK-relevant ones, which is why they became this PR. |
|
Re-ran everything on All artifacts, logs, compile commands and disassembly for both rounds: 1. The bare iu4 intrinsic route you asked about — it existsThe builtins are on this toolchain. The name needs a __builtin_amdgcn_swmmac_i32_16x16x32_iu4_w32(bool, int, bool, int2, int8, int, bool);
__builtin_amdgcn_swmmac_i32_16x16x64_iu4_w32(bool, int2, bool, int4, int8, int, bool);Seven arguments. I left the parameters unnamed above because I measured what
So arg1 sets
Per-lane operand sizes for wave32: K=32 takes A = 1 dword, B = 2 dwords, Toolchain: AMD clang 23.0.0git, What this is and is not. It's a numeric route to the instruction that To be precise about one thing, since you may want to reuse it: 2.
|
| build | tree | K=32 | K=64 | K=128 | |
|---|---|---|---|---|---|
| default (adversarial) | base 8fc1ac2 |
112 | 127 | 272 | FAIL |
| default | head ad79359d0 |
0 | 0 | 0 | PASS |
-DUSE_CANONICAL_PATTERN |
base 8fc1ac2 |
0 | 0 | 0 | PASS |
-DUSE_CANONICAL_PATTERN |
head | 0 | 0 | 0 | PASS |
| default | head minus bug 1 | 112 | 127 | 272 | FAIL |
| default | base plus bug 1 only | 0 | 0 | 0 | PASS |
All six are run1 == run2 == run3 byte-for-byte. Your 112/127/272 match to the
digit. §1 and §5 are closed from my side: the test is self-contained, the
default configuration is the one that fails on the unfixed tree, and the
canonical control passes on base — preserving the evidence that the old pattern
could not detect this.
Two further things from the same table. The head minus bug 1 build produces
stdout byte-identical to base, and bug 1 only passes — so for this test's
inputs, bug 1's one-line change is both necessary and sufficient for the
observed difference. That's a statement about this test, not about the fix's
full effect. And I disassembled all six builds: each contains 7
v_swmmac_i32_16x16x32_iu8 and zero iu4 instructions, so this table is a
bug-1 regression result and I'm not counting any of it as evidence for bugs 2
or 3.
I also checked that your header delta from ac24ac28d is comment-only: with
comments stripped, both sparse_transforms.hpp (163 lines) and
sparse_mma_pipeline.hpp (206 lines) are byte-identical across the two commits.
So my round-1 four-way bisect carries over to the current head without
re-running it. Hashes are in EVIDENCE_round2.md.
3. Nothing in the tree references the test
test/ck_tile/gfx1201_sparse_swmmac/ contains the .cpp and nothing else — no
CMakeLists.txt — and test/ck_tile/CMakeLists.txt has no add_subdirectory
for it. I grepped the whole head tree: the string gfx1201_sparse_swmmac
appears nowhere outside that directory. No CMake file, no script, no
Jenkinsfile stanza refers to it. So as the PR stands, nothing in the project's
own tooling can find this test — every result in this thread comes from
hand-driven builds, including mine.
I raised add_subdirectory last time as a footnote for the eventual gtest port.
That was too soft: whatever form the test finally takes, something in the tree
has to point at it, or the fix has no standing guard.
I want to be careful about the CI half of this, because I was about to overstate
it. The repository has no .github/ directory at all, so it isn't using GitHub
Actions and the empty check-suites on all three commits are the expected
consequence of that, not evidence of anything. CI here runs from the top-level
Jenkinsfile, which doesn't report into GitHub Checks — meaning I can't see from
outside whether it ran on this PR, what it covered, or whether it has gfx1201
hardware. That's worth asking a maintainer directly, and it's a different
question from the registration gap, which is visible in the tree and cheap to
close.
4. A thought on splitting bug 1 out
Entirely the maintainers' call and yours — I'm a drive-by reviewer with one GPU.
Bug 1 is in good shape: the test's default configuration fails before and passes
after on real hardware, independently reproduced, and the change is a plain
out-of-spec default. I find your §6 argument for keeping it
architecture-generic convincing. My probe result there is narrower than the
claim, though, and I should have said so last round: it shows that a gfx942
compile of the sparse path succeeds and emits v_smfmac_i32_16x16x64_i8. That
establishes the selector reaches gfx942 at compile time. It is not a runtime
result and doesn't generalize across gfx9 by itself.
Bugs 2 and 3 sit differently: the pk4 case is compiled out by default behind
-DENABLE_PK4_CASE, and the executing kernels are iu8-only, so as far as
anything in this thread shows, no number yet distinguishes bug 2 or bug 3 being
present from being absent.
Merging bug 1 now and keeping bugs 2 and 3 in a follow-up until the sweep and a
CK-level pk4 test land would get the confirmed fix into the tree without waiting
on the part that still needs measurement. Splitting inside this PR works just as
well if you'd rather not open another one — the point is only that the
verified part shouldn't have to wait on the unverified part.
5. What's next, and what I can't do
In order: the one-hot metadata sweep proper — full raw idx enumeration against
individually one-hot compressed slots, low and high nibble stimulated
separately, B basis-swept so I can tell which physical B element each result came
from, all 32 lanes and all 8 accumulator dwords collected, K=32 and K=64 swept
independently rather than one extrapolated from the other, with the lane/element
map established by calibration rather than borrowed from CK. Then the same
question end to end through CK's pk4 path, with bug-2-only and bug-3-only revert
mutants, since if those two can mask each other a head-vs-base comparison won't
separate them. Everything lands in the repo above with the raw tables.
Two things I can't close from here. I have no CDNA hardware, so my gfx942 result
stays compile-level; someone with a gfx942 part running the now-self-contained
repro would turn that into a runtime answer. And I can't tell whether this
repository's maintainers want a hardware-specific test registered in the normal
test graph, gated behind an option, or kept standalone — that decision, and what
evidence they'll accept for a target they may not have in CI, has to come from
them. Worth asking explicitly rather than either of us guessing.
On your verification-tooling questions (device ASAN, stochastic PC sampling, the
GL2C EA size-split counters reading zero): I have the same card and would rather
give you measurements than guesses, so I'll try all three and report back —
after the sweep, purely because I don't want to leave that half-finished. I've
subscribed to ROCm/ROCm#6613. I can see why you grouped them with this work; the
absence of device ASAN is precisely why bug 2's class had to be found the hard
way.
|
Sweep done, plus an end-to-end pk4 test. Headline: your bug-3 mapping is Everything I measured is reproducible from 1. The one-hot iu4 metadata sweep — SWAP + XOR-1 confirmedMethod, as offered: bare I pre-registered your rule and its predicted table, hashed and committed, before Result: your transform reproduces the hardware pairing exactly. 384 cells The raw law underneath:
Plain identity. No swap, no XOR. Both halves of your rule are the two places
Substituting both into the identity law reproduces your code line for line, and Adversarial checks, each breaking one way the first pass could have looked like
A correction against my own case: my pre-registered transcription of your On the ARCH SCOPE commentKeep the instruction to re-measure — I was going to argue against it and I was What I'd add is that the constants aren't encoding a gfx1201 oddity — the 2.
|
| include tree | result |
|---|---|
base 8fc1ac2 |
0 failing of 18 |
head ad79359d0 |
1 failing of 18 — SparseTransformsTest.SingleNonZeroPerGroup |
EXPECT_EQ failed at test_amdgcn_sparse_mma.cpp:202 (x9, the compressed values)
compressed out, base: 5 5 6 6 <- what the test expects
compressed out, head: 5 0 6 0 <- what your fix produces
idx unchanged (0x000000bb) in both
MixedSparsityPattern, NonZerosAtSlots{1And3,0And3} and all eight
FullMatrixVerify_* cases pass on both trees; this is the only one that moves.
Caveat on method: googletest isn't installed in my container, so I supplied a
minimal gtest.h shim (TEST, EXPECT_*, ASSERT_*, GTEST_SKIP) rather than
building the CMake target. The test source itself is byte-for-byte upstream, and
the shim clearly does detect failures since it found this one — but I haven't run
the registered target through CMake, so treat this as "the upstream test's own
assertions fail on head", not "the CI job goes red" (which I can't observe
anyway).
Two things follow. The test needs updating as part of this PR, and that diff is
some of the better evidence in the change, since it shows the old behaviour
written down and corrected. And it explains why the two-survivor cases don't
move: bug 1 only bites when a group has fewer than two survivors, which
SingleNonZeroPerGroup is the only case in that file to supply. That's also the
shape of the gap new cases would fill.
It makes the registration point from my last comment concrete too: there's
already a registered home with the right idiom, so the standalone repro could
become extra cases in that file rather than a new directory — no new CMake entry
needed at all if it goes there.
3. End-to-end pk_int4 through CK
ENABLE_PK4_CASE expands to a printf and a TODO; it doesn't instantiate the
packed pipeline and asserts nothing. Grepping test/ and example/, the only
two files referencing the sparse pipeline are your standalone repro and
test_amdgcn_sparse_mma.cpp, and neither instantiates it with pk_int4_t. So as
far as the tree shows, bugs 2 and 3 have no numerical coverage in it, and the
numbers below are the first I can find evidence of for the packed path.
The fill convention you flagged as unproven, resolved. Asked CK's own types
rather than guessed:
- for
pk_int4_tthe register map's vector index enumerates logical 4-bit
elements, not physical bytes — at K=32,num_vector_itemsis 16 while
sizeof(AWarpTensor)is 8, so porting the int8 fill verbatim overruns the
tensor by 2×; - logical element
vgoes to bytev/2, high nibble for evenv; - the coordinate order is pinned without copying it from the code under test:
K ≠ N, so the two components have different ranges. Measured, A and B both
returncoord[0]in[0,15]andcoord[1]in[0,K-1], so B is
B[coord[1] * N + coord[0]]. I had this backwards at first and it produced a
plausible-looking wrong answer, which is why I went back and pinned it from the
ranges.
Logical k landing at raw nibble offset k XOR 1 is the same XOR the sweep
found from the hardware side. Not fully independent evidence — both involve the
same nibble convention — but they're arrived at from opposite ends.
Kill matrix. One independent build and run per shape, so a compile failure at
one shape can't mask another. max_abs_err against a dense int4 CPU reference
over the same logical values; int64 accumulation, exact comparison.
Every mutant row sits on top of the §4 fix, since without it nothing builds at
K≥128 and the rows would not be comparable. The first two rows show that fix's
effect on its own.
| tree | K=32 | K=64 | K=128 | K=256 |
|---|---|---|---|---|
head ad79359d0, unmodified |
0 PASS | 0 PASS | compile fail | compile fail |
| head + §4 fix — the baseline for the rows below | 0 PASS | 0 PASS | 0 PASS | 0 PASS |
| … + bug 2 reverted | 0 PASS | 0 PASS | compile fail | compile fail |
| … + bug 3 fully reverted | 311 FAIL | 451 FAIL | 635 FAIL | 904 FAIL |
| … + bug 3, swap kept, XOR removed | 460 FAIL | 810 FAIL | 1142 FAIL | 2290 FAIL |
| … + bug 3, XOR kept, swap removed | 351 FAIL | 588 FAIL | 887 FAIL | 1397 FAIL |
| … + bug 1's true-zero default reverted, packed path | 64 FAIL | 68 FAIL | 89 FAIL | 132 FAIL |
- Bug 3 is load-bearing, and in these configurations neither half alone
suffices — the CK-side counterpart to §1. (It doesn't rule out some other
equivalent formulation; only that these two partial forms are wrong.) - Bug 1's true-zero default is load-bearing in the packed path too, not only the
scalar path — the same defect §2's existing test records. - Bug 2's mutant survives at K=32 and K=64 and is killed only at K=128 and
K=256, as a compile error. The condition isn't "multi-fragment" as such but
"shapes where the idx word count diverges": at K=32/64 both accountings round
to a single word, so the difference is unobservable there. Whatever form a
regression test for bug 2 takes — end-to-end or a type-level unit test — it
needs a case where those counts differ, or it won't exercise that fix.
The test carries guard bands around every per-lane buffer, verified intact after
the fill so an off-by-a-factor is loud rather than silent; asserts stimulus
coverage rather than assuming it (all eleven at-most-two-survivor group patterns
present — the six two-survivor pairs, four single-survivor, one all-zero — and
the signed end point −8 present in both operands); and uses a B spanning the full
signed range, asymmetric in k and n so a transposed reading can't cancel out.
4. Multi-fragment packed A doesn't compile
At K=128 and K=256 the pk4 pipeline fails to build on head:
sparse_mma_pipeline.hpp:311: 'ATransformResult must match the return type of
ATransform::exec' SparseIdxPack<2> vs SparseIdxPack<1>
checkATransformResult() re-derives the expected type as
decltype(ATransform::execExtVec(std::declval<ExternalAvecRef>()))leaving LogicalADataType at its default. ext_vector_t<pk_int4_t, N> has
signed char as its scalar type, so the default resolves PackedSize to 1 —
while the real call path, exec(), passes ADataType explicitly and gets 2. The
check disagrees with the call it validates. It's hidden at K=32/64 because 8 and
16 two-bit fields both round to one idx word; the counts first diverge at K=128.
static_assert(
std::is_same_v<ATransformResult,
- decltype(ATransform::execExtVec(std::declval<ExternalAvecRef>()))>,
+ decltype(ATransform::template execExtVec<AVecType, ADataType>(
+ std::declval<ExternalAvecRef>()))>,
"ATransformResult must match the return type of ATransform::exec");With that, all four shapes compile and pass, and your int8 tests are unaffected
(adversarial ALL PASS, canonical control ALL PASS). Whether K≥128 is a shape the
packed path is meant to support is your and the maintainers' call — the argument
that it is comes only from your int8 test covering K=128 and the pipeline being a
template over K, which isn't conclusive for a different data type. What is
measured is that the check and the call disagree. I haven't run a compile matrix
over every type/shape combination, so read the diff as "makes the check agree
with the call", not as "verified side-effect-free".
5. What this doesn't show
Mutant kills show the test is sensitive to the code under test; they aren't proof
head is correct. If head and my harness shared a wrong layout assumption, mutants
could still die while a correlated error passed — the range-based determination
of the coordinate order and the CK-free sweep are what reduce that risk, not the
kill matrix. Everything is FragsM = FragsN = 1, gfx1201, one toolchain, wave32,
full EXEC. I still have no CDNA hardware, so bug 1's reach there stays a
compile-level result on my side.
Happy to open a PR against your branch with any of this rather than leave you to
lift it — the pk4 case and the SingleNonZeroPerGroup update being the two that
matter most for someone reviewing without a gfx1201 part.
0. TL;DR for the maintainer (schung-amd)
Three correctness bugs in
ck_tile's SPARSEMmaOpFamilypath produce wrong results on gfx1201/RDNA4 (v_swmmac_*_iu4). Root-caused, fixed (two headers), and reproduced with a committed standalone test that fails on current CK and passes with the fix. We found these while building a production-grade 2:4-sparse fp8/int4 GEMM for LLM inference on RDNA4 — §3-4 give that context, which doubles as validation that the fixed path works end-to-end at scale.1. The three bugs (files:
sparse_mma_pipeline.hpp,sparse_transforms.hpp)Bug 1 —
compress_a_implwrites phantom values on <2-nonzero 2:4 groups.The fallback
ADataType nonzero_elems[2] = {a_vec[i*4+2], a_vec[i*4+3]}seeds the compressed pair from fixed input positions rather than true zero. A 2:4 group with 0 or 1 real nonzeros then reconstructs with a phantom value from a wrong lane (observed as register aliasing / UB). Fix: defaultnonzero_elemsto true{0,0}and fill only real survivors — 0/1/2-nonzero groups all reconstruct exactly.Bug 2 — packed sub-byte (
pk_int4_t) group-of-4 scan treats a byte as one element.The compaction's "nonzero per group of 4" scan operates on bytes, but a
pk_int4_tbyte holds two 4-bit values; a byte is "nonzero" if either nibble is, so the group-of-4 assumption over-counts and writes out of bounds intononzero_elems[2]/[3]. Fix: packed-sub-byte-aware nibble counting (guarded with astatic_assertthat the packed path currently implements onlypk_int4_t).Bug 3 —
compress_a_implemits the two per-group idx metadata fields in an order that mismatches what the hardware reads, requiring a SWAP + XOR-1 to correct.Within CK's own
pk_int4_tpacking, the metadata CK writes has the two idx fields swapped relative to which compressed survivor they govern, plus a XOR-1, versus whatv_swmmac_i32_iu4consumes on gfx1201 — so the sparse result comes out wrong (pos(HIGH-nibble survivor, found first) = idx1_field XOR 1,pos(LOW-nibble survivor, found second) = idx0_field XOR 1). Denseiu4was confirmed correct first (11 seeds) to isolate this to the sparse metadata path. Fix: emit the idx fields with the swap+XOR so the hardware reconstructs the correct positions (Idx0 < Idx1, the documented 2:4 contract, is naturally satisfied).Precision note (so this isn't over-stated): we verified this is specific to CK's compress/packing convention, not a universal "the hardware swaps." An independently-derived encoder in our own driver produces correct metadata without the swap — and applying the swap to it breaks a working encoding. So the fix corrects CK's own ordering to match the hardware; it is not a claim about
v_swmmac_iu4behavior for all encoders.2. The test (committed; fails on current CK, passes with the fix)
test/ck_tile/gfx1201_sparse_swmmac/sparse_swmmac_correctness_repro.cppdrives CK's own machinery for the SPARSEMmaOpFamily— it buildsPipeline::AWarpDstrEncodinginternally (no manual compression math), generates an adversarial 2:4 A tile in-source (each 4-group cycles through all six two-survivor position pairs, all four single-survivor positions, and the zero-survivor group — survivors at every position, values keyed to (row, group, position) so misplacement shows numerically rather than cancelling), runs the SWMMAC GEMM (K=32/64/128, three FragsK tile sizes), and checks against a CPU int64-accumulate reference. The legacy slots-0,2 pattern is retained as an opt-in control (-DUSE_CANONICAL_PATTERN); it cannot detect Bug 1 and passes on both trees.Verification (2026-08-15, head
ad79359d0vs merge-base8fc1ac24e9, identical test source on both trees, 1x R9700 gfx1201, ROCm 7.14):max_abs_err = 112 / 127 / 272at K=32/64/128max_abs_err = 0on all three shapesReproduction (self-contained — no external files):
Historical note: earlier revisions of this description cited
max_abs_err=352from a real Quark-quantized weight tile (REAL_A_16x128,-DUSE_REAL_TILE); that input is superseded by the in-source generator (same root cause, now reproducible from the PR alone) — the 352 figures apply only to that tile.(We compiled with the ROCm-devel clang toolchain directly —
clang++ -x hip --offload-arch=gfx1201; the distrohipccon this box is a stale 5.7-era wrapper unrelated to CK's target toolchain and will not build ck_tile headers. Any current ROCm/HIP clang works the same way.)ASAN attempted, not obtained (honest note, not a blocker): we tried to get a device-sanitizer stack trace on the Bug-1/Bug-2 OOB write for extra evidence (
-fsanitize=address -fgpu-sanitize). Device ASAN needs anxnack+target-ID variant; RDNA4 (gfx1201) does not accept anxnack+/xnack-feature suffix at all (clang++: error: invalid target ID 'gfx1201:xnack+') — HIP device-side ASAN is a gfx9/CDNA-class feature (gfx90a/gfx94x with xnack+), not available on RDNA4 consumer/workstation targets in this ROCm toolchain. We did not chase this further since it's not required to reproduce the bug — the max_abs_err numbers above are a complete, deterministic fail→pass.We'll port this into CK's gtest format for the PR; the standalone repro is included so it's runnable without the full CK test build.
3. Context — why these bugs mattered (the "whole thing")
We hit these building a library-grade 2:4-sparse GEMM for RDNA4 LLM inference (llama.cpp/ggml fork, gfx1201, 2× R9700). The journey, honestly:
v_swmmacmicrobench hits 765 TOP/s fp8-2:4 (2× dense) and 1531 TOP/s int4-2:4 (4×) at ILP≥4 — 88-100% of the R9700 spec.wmma_i32_16x16x32_iu4— gfx1201's dense int4 engine is already K=32-wide. The second 2× washes:swmmac_i32_16x16x32_iu4(749,761 GOP/s) ≈wmma_i32_16x16x32_iu4(765,061) = 0.98×, instruction-for-instruction, because dense int4 already occupies the K=32 slot. The only sparse tensor edge is the K=64 form (1.77×) — the same "2×-K" mechanism fp8-2:4 already showed washing at library grade. So on high-arithmetic-intensity LLM GEMM shapes (compute-bound), structured 2:4 is a ~1.2× weight-bandwidth win (memory-bound regime only), not the tensor 2×/4×. (Correctness first regardless — the perf question is separate, and now answered.)4. Closing the gap — result
The 8% was pinned (by direct rocprofv3 head-to-head) to two things, NOT the big GEMMs (we're at/ahead of our own fp8 MMQ there: +1.8% / +3.9% / +32% on gate-up / q-o / down-proj). Fixing them:
__syncthreads()reduction with the upstream mmq.cuh quantize shape — 128 threads,float4loads, warp-shuffle max-reduction, zero LDS/sync (halved it, 36µs→~18µs). Closed ~40% of the remaining gap; both kernels now high-90s% of our native-fp8 kernel (0.989× same-session).Net: the hand-written 2:4 kernel now reaches ~96–99% of our native-fp8 WMMA MMQ (which we built — none exists upstream), is ahead on the FLOP-dominant GEMMs, and carries the measured ~1% sparsity edge over a comparable dense kernel — a library-competitive RDNA4 2:4 GEMM on top of the correctness fixes.
5. A related RDNA4 iu4 quirk (same class, different instruction — FYI, not part of the fix)
The Bug-3 element-ordering quirk on
swmmac_iu4is not isolated: an independent kernel-authoring effort on this box hit the same class on the densev_dot8_i32_iu4/sudot8 path — "dots mismatched element pairs." RDNA4's iu4 instruction family appears to carry undocumented element-ordering conventions in both the sparse (SWMMAC metadata) and dense (dot8 operand pairing) paths. Documenting these in the ISA/CK would save the next implementer the multi-day reverse-engineering we did. Happy to write up the dense one too if useful.6. Method (for reproducibility)
All numbers: gfx1201 (R9700), ROCm 7.14, GPU-isolated, warm, medians ≥3. Correctness gated by execution (CPU-reference compare), never inspection. The full capability-optimizer method (measure → grade vs the published peak → drive the lever) is what surfaced both the bugs and the microbench-vs-model boundary.