Sebastian HaasAI Security Engineer
Research/Case study
Case study · Campaigns AX–BB · August 2026

The kernel that waited for itself

How a matmul kernel for 13–32 parallel sequences on an AMD Instinct MI50 was redesigned, and why the first six variants achieved nothing until an ISA trace showed that the compiler was silently forcing every pipeline back into lockstep.

Hardware: MI50 16 GB (gfx906), 150 W capFork: llama-cpp-gfx906-turboKernel: gfx906/matmul/mmvq-db-gemm.cuhEvidence: BENCHMARKS.md AX–BE
+44%Tokens/s at 16 streams, Qwen3-8B Q4_0 (380 → 549)
+58%Mistral-7B Q4_0 at 16 streams (419 → 664)
1.57×Kernel microbenchmark across four matrix shapes
3 / 2Waves per SIMD: new / old (83 instead of 105–117 VGPRs)
0Failures in 12 large MUL_MAT reference tests
1 · Starting point

A dip at sixteen that nobody had measured

The fork was exceptionally well tuned for a single user: MTP speculation, anchor caching, around 100 tokens/s on an 8B model. What happens with many concurrent sequences had never been measured systematically. A comparison against vLLM (campaign AX) provided the occasion and immediately two findings: the server’s batch scheduler assigned sequence IDs as a LIFO stack, causing llama.cpp with split-KV to divide each decode step into two or three substeps (throughput erratically dropping to one third). And the engine itself had a dip at exactly 16 sequences: B12 354, B16 328, B24 397 tokens/s.

The dip had a simple cause (campaign AY): from 13 columns onward, Q4_0 fell back from the fork’s own mini-GEMM kernel to the generic MMQ path, an old default whose justification no longer held. A switch delivered +17%. But then came the real wall: the profiler showed that the mini-GEMM kernel accounted for 76% of the decode step, and the compiler’s resource report explained why: 105–117 vector registers per thread, allowing only 2 waves per SIMD. Nine tile variants and forced occupancy changed nothing. The kernel had reached the limits of its design.

Calculating the floors, 4.5 GB of weights at 1 TB/s and dp4a work at approximately 55 TOPS, a 16-column step should take about 10 ms. Measured: 32 ms in the kernel alone. That gap was the project.
2 · The ladder

Seven variants, one microbenchmark, many mistakes

A standalone HIP harness (scratchpad/m12-lds-gemm/bench.hip) with Q4_0/Q8_1 quantization, a CPU reference, and a copy of the production kernel (“csw”) as baseline made every variant measurable in seconds across the four real matrix shapes of an 8B model. Times here: microseconds per invocation, q/o projection 4096×4096.

V1
Better

4 waves share K, weights and activations staged in LDS per chunk, 2×4 thread tile.

80 → 54 µs. First attempt, numerically correct, 61 VGPRs, 4 waves/SIMD. Split-K across workgroups (a deterministic second pass instead of float atomics, which gfx906 lacks natively) also keeps narrow K/V projections busy.

V2
Falsified

More rows per thread amortize activation reads.

4 rows: 73 µs; 16 rows: 223 µs. Every additional accumulator costs registers and therefore waves. Hoisting nibble unpacking: no effect; the compiler already did it.

V3
Falsified

Naive prefetch (load the next chunk before computing) hides memory latency.

66 µs, slower than without it. The explanation only emerged in Section 3.

V4
Falsified

Activations from L1/L2 instead of LDS save staging and barriers.

131–240 µs. The 16 row groups read the same activations 16 times; LDS broadcast is irreplaceable.

V5
Diagnosis

Where does the time go? Variants that omit components.

Without activation reads −13%, without weight reads ±0, without dp4a −11%. Staging only, no computation: 34 of 53 µs. Weights arrived at 275 GB/s instead of 1 TB/s: too few bytes in flight at once, followed by a barrier.

V6
Falsified

Weights directly from global memory in the compute loop (only 4 threads share a block).

59–61 µs. The same effective bandwidth. The problem was not where loads happened, but when.

V7
Confirmed

Double buffering with enforced order: loads → compute → stores → barrier.

47–51 µs, and 134 instead of 234 µs on gate/up. Only after compiler reordering was prevented; see Section 3.

q/o projection 4096×4096 80 µs (csw) 51 µs k/v projection 1024×4096 28 µs (csw) 20 µs gate/up 12288×4096 234 µs (csw) 134 µs down 4096×12288 202 µs (csw) 142 µs
Microbenchmark per matrix shape (µs per invocation, median 3×200): production csw versus db-gemm. Sum 545 → 347 µs = 1.57×.
3 · The real opponent

Three ways hipcc forces a pipeline back into lockstep

The fact that prefetch (V3) was slower fit no theory, until the ISA was available (--save-temps, then a trace of only the relevant instructions: G = global load, v = wait for loads, W/r = LDS write/read, d = dp4a, | = barrier, ^ = branch). Three findings, three countermeasures.

Finding 1, Loads sink into branches

G2 v2 W3 ^ G3 v2 W2 v W |      ← Staging loop with if (b < nblk)

Two loads, wait, three loads, wait. A condition in staging split the basic block, and the compiler moved every load to where it was needed, immediately before use. One latency became three. Countermeasure: straight-line staging without if (the dispatch guarantees full chunks) and asm volatile("" ::: "memory") immediately after the loads.

Finding 2, Stores move ahead of compute

^2 G4 r28 ^2 v W v W2 v W2 ^ | ... d128      ← Double buffering, first attempt

The loads for chunk c+1 were at the front, but so were the LDS stores that must wait for them; the dp4a block came afterward. The store does not depend on compute, so it was allowed to move upward. A memory barrier alone did not help: arithmetic is not a memory operation and moves freely across it.

Finding 3, The countermeasure must bind values, not memory

G4 r5 d6 r d2 r d8 r3 d16 ... d16 ^2 v W v W2 v W2 ^ |      ← Target reached
for r, j: asm volatile("" : "+v"(acc[r][j]) :: "memory");   // before the stores

Using the accumulators as input/output operands of an empty asm block forces the compiler to finish all compute before that point; the memory clause keeps stores behind it. Only then were loads, computation, and waiting in the order intended by the design, and V7 became the fastest variant.

The lesson for all further kernel work on this platform: source order is a request, not an instruction. Without an ISA trace, you do not know which program you are measuring.
4 · The design

db-gemm in one paragraph

Division of labor. A workgroup of 4 waves processes 32 rows × 16 columns. The waves share not the columns, but K: in a chunk of 8 blocks, wave w computes blocks w and w+4, each across all 16 columns. Each thread holds 2 rows × 4 columns = 8 accumulators; its columns are cg, cg+4, cg+8, cg+12, keeping the 128-bit LDS reads free of bank conflicts.

Data. Weights and activations reside in LDS per chunk, double-buffered (20.7 KB including an overlaid reduction buffer). Each thread loads exactly one weight block (18 B) and half an activation block: a straight-line block of 7 loads, one wait.

Per-chunk sequence. Issue loads for c+1 → compute chunk c from LDS → bind accumulators → write loads into the other buffer → one barrier. At the end, the four wave partial sums are added through LDS.

Split-K. Narrow matrices (K/V projection: 1024 rows = 32 workgroups) would not fill 64 CUs. The grid gets a second dimension across K partitions; intermediate results go into a partial buffer and are deterministically summed in a second kernel. 83 VGPRs, 3 waves/SIMD.

Variants. 13–15 columns through zero-padding on the 16-column tile; a 32-column tile (2×8 per thread) for 17–32 columns that reads weights only once; a Q8_0 version (34-B blocks, no unpacking) for 13–16 columns.

5 · Benchmarks

Four models against baseline

llama-batched-bench, flash attention enabled, f16 KV, 64-token prompt, 128 generated tokens, with 40–60-second pauses between runs. Baseline = the same build with GFX906_DISABLE_DB_GEMM=1, thus including all earlier improvements. B1 and B8 are unchanged; this kernel does not touch those paths.

0 100 200 300 400 500 600 700 95 98 B1 321 323 B8 334 443 B13 +33% 380 549 B16 +44% 397 517 B24 +30% 464 555 B32 +20% Qwen3-8B Q4_0 Baseline db-gemm
Qwen3-8B Q4_0, tokens/s at B parallel sequences (llama-batched-bench, FA on, f16 KV; baseline = GFX906_DISABLE_DB_GEMM=1).
0 100 200 300 400 500 600 700 111 110 B1 378 380 B8 368 513 B13 +39% 419 664 B16 +58% 457 583 B24 +28% 537 639 B32 +19% Mistral-7B Q4_0 Baseline db-gemm
Mistral-7B Q4_0, tokens/s at B parallel sequences (llama-batched-bench, FA on, f16 KV; baseline = GFX906_DISABLE_DB_GEMM=1).
0 100 200 300 400 500 600 700 95 95 B1 297 297 B8 325 431 B13 +33% 371 547 B16 +47% 408 498 B24 +22% 416 533 B32 +28% Command-R7B Q4_0 Baseline db-gemm
Command-R7B Q4_0, tokens/s at B parallel sequences (llama-batched-bench, FA on, f16 KV; baseline = GFX906_DISABLE_DB_GEMM=1).
0 100 200 300 400 500 600 700 64 64 B1 241 240 B8 237 304 B13 +28% 276 359 B16 +30% 296 296 B24 346 346 B32 Qwen3-8B Q8_0 Baseline db-gemm
Qwen3-8B Q8_0, tokens/s at B parallel sequences (llama-batched-bench, FA on, f16 KV; baseline = GFX906_DISABLE_DB_GEMM=1).
ModelB13B16B24B32
Qwen3-8B Q4_0334 → 443 +33%380 → 549 +44%397 → 517 +30%464 → 555 +20%
Mistral-7B Q4_0368 → 513 +39%419 → 664 +58%457 → 583 +28%537 → 639 +19%
Command-R7B Q4_0325 → 431 +33%371 → 547 +47%408 → 498 +22%416 → 533 +28%
Qwen3-8B Q8_0237 → 304 +28%276 → 359 +30%296 = (off)346 = (off)

Through the server and against vLLM

Through fork-serve (batch scheduler, 32 slots, q8_0 KV), the engine gains translate to: B13 299 → 357, B16 332 → 451 tokens/s. For comparison, the same test against vLLM (gfx906 fork, AWQ): B16 329, B32 383. The decode step at 16 streams fell from 42.6 to 29.0 ms; the kernel’s share from approximately 32 to approximately 18 ms.

Thermals are a first-order measurement error: in a ten-minute series at the 150 W cap, Command-R7B measured 425 tokens/s at B16, and 547 after a 60-second pause. All figures above come from runs with pauses; individual results without pauses are marked as such in BENCHMARKS.md.
6 · Correctness

What correctness was measured against

Reference in the harness: CPU calculation on quantized values, relative tolerance 2·10⁻³, every variant, every shape. Column symmetry: 16 identical activation columns produce bit-identical output columns (csw and db-gemm). test-backend-ops: twelve new large MUL_MAT cases (1024–12288 rows, 13/15/16/24/32 columns, K 4096/12288, Q4_0 and Q8_0), demonstrably using the new path through debug tracing, all OK; the full run reports 0 FAIL. Model: 16 identical greedy sequences produce two text variants rather than one with db-gemm, at a genuine near tie (114 of 125 characters shared), not within matmul. Summation order differs from the old kernel, which may break close logit ties differently.

Coverage evidence instead of assumption: GFX906_MINI_GEMM_DEBUG=1 logs every dispatch with shape, column count, split-K, and groups. On Qwen3-8B, all seven matmul shapes per layer use the new kernel.

8 · Aftermath

The lever list: five gains, three honest zeros

After the kernel, a list of seven further levers remained, ordered by value. All were investigated; the results show how the method works even where the final outcome is no gain.

LeverActionResult
Prefill / MMQLoad Q4_0 and K-quant tiles register-first; LDS stride 36 for b128 readsQ4_0 pp512 978 → 1123 (+15%), Q4_K +7.7%; forced occupancy and register double buffering achieve nothing (compute-bound)
32-column tile / Q8_0Q8_0 32-column tile; forcing 4 waves rejected (spills)Q8_0 B24 +36%, B32 +9%
5–12 columns8-column tile, padded 16-column tile from 9 columnsB8 +7%, B9–12 +6…+53% (B11: 250 → 382)
K% 128Tail chunk with clamped weight index and zeroed activationsNemotron-H shapes (2688/3712) now on db, B16 +4%
K-quantsQ4_K kernel (scales/mins decoded during staging)phi-4 +4…5%: the K-quant MMQ baseline already stages unpacked data; only −15% at kernel level
MoE batchExpert-gather tile for MUL_MAT_ID; matvec threshold 4 → 8 tokensNeutral: six expert layers run on CPU, which caps the step (left opt-in)
VALU floorFour unpacking tricks tested (unsigned dot on raw bytes, dot8-i4, SDWA, fp16 pack)None pays off: scaling is minimal (cvt + 2 FMA), unpacking 3 instructions per int
Attention (long context)FA prefill kernel occupancy 2 → 1 (register-constrained)q8 KV prefill @32k +48%
Server overheadMeasured, not assumed< 1%/step: the “20% gap” was a measurement artifact

The long-context finding: an occupancy trap in attention

All earlier figures use short context. At 32k tokens, the picture changes: prefill attention accounts for 55% of the step. The fork-serve default (q8_0 KV) selected a gfx906 kernel tuned for decode that ran at only half the generic f16 kernel’s speed during prefill (246 versus 485 t/s @32k). The cause was the same occupancy wall as in Section 1, but reversed. Head-dimension-128 configurations for 32/64 query columns ran at target occupancy 2, capping registers and throttling a kernel that wanted more. Occupancy 2 → 1: Prefill +40…48% (16k 397 → 556, 32k 246 → 363 t/s), decode unchanged, 3344/3344 tests green. The lesson mirrors the decode kernel: occupancy is a two-way control. Forcing it up chokes a latency-bound kernel just as forcing it down starves a bandwidth-bound one. Only measurement in both directions reveals which case applies.

A secondary finding with lasting value: with the Q4_K kernel, 13 streams were bimodal (214 versus 168 t/s depending on process startup), unlike baseline. The variable was the split-K scratch-buffer address; aligned to 2 MiB, all 5 of 5 runs reached 214. Without the repetition protocol, this would have been filed under “thermals.”
9 · Limits

The cheapest lever was in the driver, not the code

After weeks of kernel work, it emerged that the card had been running at 150 W all along, although its PowerPlay table permits 190 W, set by a systemd service we had created months earlier. Raising the cap: pp512 +10%, B16 decode +7%, 32k decode +5.7%, in five minutes. Under sustained load, the clock now pins at 1700 MHz at 166–169 W and 75 °C junction. The card no longer even reaches its new limit; it runs at the VBIOS clock ceiling. Memory clock, however, could not be increased: the SMU rejects any state above approximately 1090 MHz and falls back to 800 under load, the read path saturates at approximately 890 GB/s at the fabric anyway, and merely uploading the PowerPlay table costs 1.5–2.5%. We noticed only because we uploaded the unchanged table as a control.

Against current upstream, and the one place it was better

A fresh build of official llama.cpp (b10524) on the same card, with the same models: the fork leads by +20 to +78%, and in long-context decode by a factor of 4. At 32k context, upstream loses 37% of its decode speed, the fork 5%. Exactly one measurement went to upstream: 32k prefill with q8 KV, 489 versus 376 t/s.

The cause was not a better kernel, but a better dispatch decision. Upstream converts the q8 cache to f16 once per attention invocation and runs the f16 kernel: correct for prefill (many columns amortize conversion), catastrophic for decode (14.9 t/s: the entire cache converted per token). We had the mirror image. Combining both, specialized kernel up to 32 columns, conversion above that, yields Prefill 4k/16k/32k: 1050 / 726 / 514 t/s (previously 903/536/376), with decode unchanged. Twenty lines of dispatch logic, +37% at 32k. The lesson: a routing table deserves the same care as the inner loop.

Where kernel work ends and policy begins

Eventually the instruction sequences were tight, and the remaining levers were no longer instructions, but per-request decisions. The clearest example is speculative decoding: a draft head guesses the next token, and the main computation verifies it. Whether it pays off is the product of two measurable quantities: acceptance (code and repetition 90–100%, free prose 49–79%) and the marginal cost of a verification row. For dense, resident models, weights are already in VRAM, so another row costs almost nothing: +15 to +40% across all prompt types. For the MoE model with CPU experts, every row triggers transfers at 14 GB/s, and a rejected draft has a real cost: the same speculation loses 23% on prose there.

Because this can change within a session, the server now decides. A window over the last 16 verified drafts disables speculation when acceptance falls below 70%, with exponentially growing holdoff. Continuous prose is rarely reprobed, but a switch to code is detected. The real trick was not the window, but what gets suspended: suppressing only the verification row recovered one third of the loss (66.9 → 72.1 t/s); only deferring the head pass, collecting pairs and feeding them back in one batch when the gate opens, reached 84.3 t/s, versus 87.1 without speculation.

Multi-stream operation follows different economics, and overlooking that cost us a day: the head runs for the other streams anyway, so a draft costs only its verification row. The 70% threshold calibrated for solo operation therefore blocked streams whose drafts would have been almost free, losing 23% on a good prompt. The solution was to remove the constant entirely: two moving averages of nanoseconds per token, with and without a draft, plus periodic cross-checks. Multi-stream prose: 110 → 142 t/s.

Three measurement traps that produced convincing wrong numbers

While building this controller, we fell into every available trap, and each produced plausible figures.

The cost window was too narrow. The first version measured only the head phase, but draft rows are paid for in the main decode. The controller immediately made the wrong decision (dense/prose: 33.5 instead of 46 t/s), and the number looked entirely normal.

Deferred work landed in the wrong bucket. With the gate closed, head pairs accumulated; the later batch decode was charged to draft mode, poisoning the very comparison the decision relied on (dense/repetition: 60.4 instead of 85.7).

And the most expensive: an A/B test with a fresh process per arm measures cold start, not performance, for an adaptive component. We had measured with clean alternation, a cooling gate, and two runs per configuration, and obtained +1.7%. Only six consecutive runs in the same process revealed what really happens: from run two, acceptance drops to 0.0% (the controller has decided), and throughput rises from 117 to 145 t/s. The real gain was +29%, not 1.7%: the controller had never progressed beyond learning during measurement. We then shortened its time constant (first cross-check after 48 rather than 240 steps) so it also takes effect in short sessions.

What the card delivers today

To finish, all models together, at the 190 W cap, with a cooling gate before every measurement:

ModelSoloParallelWattsUtilizationTj
Lightning 30B-A3B (MoE + offload)85 t/s147 (8 streams)104–16863–76%78 °C
Qwen3.6-27B (dense)43 t/s82 (4 streams)170–18482–96%90 °C
Qwen3.8-27B-Uncensored (dense)42 t/s70 (4 streams)174–18893–98%89 °C
Qwen3.6-35B-A3B (MoE + offload)56 t/s102 (4 streams)105–14785–95%75 °C
Qwen3-8B (dense)98 t/s187 (8 streams)171–18299%86 °C

Two operating classes stand out. Dense models run at the limit: 170–190 W, 93–99% utilization, 85–92 °C junction. MoE models with CPU experts stay at 104–168 W and 59–78 °C; there the GPU waits for the bus. And: utilization is not a throughput metric. Lightning delivers 147 t/s at 76% utilization, while dense 27B delivers only 82 at 96%. The MoE computes with 3 billion active parameters, the dense model with 27. For the MoE models, a second card would therefore be the direct lever, not a larger power budget.

What remains open

Status after the second round: Of eight mapped levers, five are realized (power cap, FA prefill, FA decode, small-kernel chain, q8 prefill dispatch), two closed with evidence (MMQ redesign is compute-bound, memory-clock overclocking has three separate limits), and the W4A4 question is answered rather than implemented: the core runs 1.76 × faster, while quality at +0.81% perplexity sits exactly at our tolerance boundary. Remaining:

The VALU floor. In the finished kernel, pure arithmetic is the largest item at approximately 20 of 47 µs (4096×4096): nibble unpacking and block scaling double the instruction count relative to dp4a operations alone. A further step would require a different weight format or instruction-level tricks.

The second card. The full measurement turned it from a wish list item into an evidence-based recommendation: MoE models run at 54–76% GPU utilization and 104–168 W because the GPU waits for CPU experts. More VRAM means less offload, their direct lever rather than a larger power budget. Dense models are the opposite: they sit at 99% and 190 W.

MoE and K-quants. Both paths are built and tested, but deliver little here: K-quants because the existing tiled baseline is strong, MoE because some experts execute on CPU on a 16-GB card.

Only the batch path. Nothing changes at 1–12 sequences; the fork was already at its floor there (M4/M9/M10). Someone working alone with a model will notice nothing from this campaign. Anyone planning subagents, multiple users, or a multi-card setup gets up to 1.6× more from the same card.

Reproduction

hipcc --offload-arch=gfx906 -O3 -std=c++17 scratchpad/m12-lds-gemm/bench.hip -o bench && ./bench 200
./build/bin/llama-batched-bench -m MODEL.gguf -c 8192 -b 2048 -ub 512 -npp 64 -ntg 128 -npl 1,8,13,16,24,32 -ngl 99 -fa on
GFX906_DISABLE_DB_GEMM=1 ./build/bin/llama-batched-bench ...                     # Baseline
GFX906_MINI_GEMM_DEBUG=1 ./build/bin/test-backend-ops -o MUL_MAT -b ROCm0         # Abdeckung + Numerik
Fork commits bf4d61b69 · cc8c8f8c3 · 264eec177 · 99fa794b9; main repository b6777fb · ac569c5. All raw values in BENCHMARKS.md (AX, AY, AZ, BA, BB).
Back to all research →