Strix Halo Lab

Optimization journey · 2026-09-12

Getting Qwen3.8-Next-Flash to prefill on a Radeon 8060S

A 177-billion-parameter hybrid model, 93 GiB of IQ4_NL weights and a 27.5 GB per-layer-embedding table, on an integrated GPU with 256 GB/s of memory bandwidth. This is the commit-by-commit record: what each change does, why it should help, and what it actually measured.

6.07×
prefill, start to finish
98%
of the out-of-tree reference stack
16/16
points measured
16302
operator tests passing

Every step, measured

One llama-bench run per commit, same command line throughout: -p 16384 -n 128 -d 0,40000 -b 24576 -ub 24576 -r 3. The multiplier next to a prefill figure is the ratio against the previous measured point, and anything inside 2% is shown as flat rather than dressed up as a win.

On the batch width The campaign ran at a 24576 ubatch; the installer now defaults to 16384, because it reserves smaller compute buffers and nothing is lost. Measured back to back on the finished stack: 1204.31 ± 2.31 t/s at 16384 against 1199.63 ± 7.12 at 24576, with 24576 about 1% ahead at depth 40 000. Both are above the numbers in this table because the machine was running some 3% faster that evening than during the campaign — which is the reason every comparison here is between points measured in the same session, never across them.

#Step pp16384 @ d0tg128 @ d0 pp16384 @ d40ktg128 @ d40k
00 Starting point c5d692357 191.25 ± 3.25 23.25 ± 0.20
01 Guard the KQ mask write 58a9b386e 195.46 ± 1.91 1.02× 23.01 ± 0.17
02 MTP hyper-connection mixer tensors dda64492f 188.34 ± 3.95 0.96× 22.93 ± 0.26
03 Sparse attention graph and the lazy direct reader ddaf5214b 242.52 ± 1.95 1.29× 24.16 ± 2.07
04 Sparse selected attention kernel df8ad5b10 286.75 ± 1.29 1.18× 24.89 ± 0.05
05 bf16 WMMA dequant GEMM e55085251 372.18 ± 1.85 1.30× 24.43 ± 1.14 348.58 ± 16.37 12.32 ± 1.98
06 Fused hyper-connection combine + norm 90aba037c 393.91 ± 5.94 1.06× 24.46 ± 2.33 372.59 ± 5.00 1.07× 13.82 ± 0.19
07 Fused gate GEMM + stream mix 6ec4a5f0d 413.57 ± 16.05 1.05× 24.90 ± 2.37 402.64 ± 6.79 1.08× 13.67 ± 1.43
08 Keep the streams in bf16 end to end 816667e9d 417.92 ± 2.79 flat 25.71 ± 1.29 416.13 ± 6.12 1.03× 13.22 ± 0.96
09 Gated rms-norm, indexer relu-sum, MoE reduction 2e3ee2afa 438.89 ± 4.05 1.05× 25.50 ± 1.91 424.09 ± 8.64 flat 14.25 ± 0.16
10 Depthwise conv1d and vectorized get_rows 0540c6948 499.52 ± 17.64 1.14× 24.19 ± 0.11 486.81 ± 4.18 1.15× 16.08 ± 0.10
11 Tiled gated delta-net 964c6f2f0 1181.77 ± 1.97 2.37× 26.71 ± 0.17 1081.79 ± 1.36 2.22× 16.44 ± 0.16
12 WMMA lightning indexer scoring f7539cf7b 1176.60 ± 8.99 flat 26.76 ± 0.19 1070.57 ± 2.17 flat 16.30 ± 0.14
13 Head-size-256 WMMA flash attention 03733c23f 1182.30 ± 0.34 flat 26.66 ± 0.25 1074.24 ± 4.21 flat 16.07 ± 0.25
14 MMQ compaction and transposed concat be39c4ff0 1182.91 ± 2.59 flat 25.23 ± 2.87 1071.21 ± 2.06 flat 16.08 ± 0.15
15 TOP_K tie handling, a permute overflow fix, and operator coverage 7abec5c24 1160.02 ± 1.89 flat 26.68 ± 0.47 1072.90 ± 0.83 flat 16.09 ± 0.24
ref Out-of-tree reference stack v46 + model-r 1187.13 ± 31.4029.06 ± 1.45 1082.92 ± 4.4819.02 ± 4.62

What each piece is worth in the finished stack

The table above is the route actually taken, which means each step was measured with whatever was still slow at the time. These arms instead disable one optimization at a time on the final binary — one build, one variable, no build-to-build variation. The two views disagree where an earlier bottleneck was masking something, and that disagreement is informative rather than a problem.

Disabledpp16384 @ d0worth
Everything on
the reference point for this table
1177.49 ± 4.46 baseline
bf16 WMMA dequant GEMM
MoE and dense GEMMs fall back to MMQ’s integer path
828.18 ± 0.83 1.42×
Fused HC combine + norm
combine, residual add and rms-norm run as separate passes
1154.65 ± 1.11 flat
Fused HC gate GEMM + mix
the gate projection round-trips through memory
993.48 ± 2.79 1.19×
bf16 HC streams end to end
the wide HC tensors keep writing their f32 form
1087.54 ± 3.18 1.08×
Gated rms-norm, indexer relu-sum, MoE reduction
three fusions split back apart
1139.78 ± 8.85 1.03×
Depthwise conv1d for PLE and GDN
the concat + transposed cont chains run unfused
1089.63 ± 3.28 1.08×
Sparse attention kernel
the graph still selects blocks, but attention runs dense
690.04 ± 1.25 1.71×
Sparse attention entirely
dense attention over the full context
1184.52 ± 2.39 flat
Maskless KQ path
a dense KQ mask is built and written per ubatch
795.72 ± 1.80 1.48×
Lazy direct PLE reader
rows demand-paged through the mmap instead of pread()
428.90 ± 9.60 2.75×

Why both The bf16 WMMA GEMM measures 1.30× in the walk and more here: in the walk it landed while the stock delta-net recurrence was still the bottleneck, so part of its benefit was invisible. Neither number is wrong; they answer different questions.

Does it still answer correctly at depth?

Throughput is the easy half. Sparse attention only looks at a subset of the keys, so the question a prefill number cannot answer is whether the model can still reach the beginning of a long context. Every correctness check up to this point had been an operator test or a short decode, neither of which engages the sparse path at a length where it matters.

So: a 150,300-token prompt — the llama.cpp documentation tree, concatenated — with a fact planted at character 6,466 and the question asked at the very end, roughly 149,000 tokens later. Greedy decoding, fixed seed, every kernel gate on.

Planted at character 6,466

OPERATIONS NOTE 47-B: the calibration key for the Strix Halo test rig is QUINCE-AMBER-7731, and the rig's thermal ceiling is recorded as 54 degrees Celsius

Answered from 149k tokens away

… the calibration key is QUINCE-AMBER-7731 and the thermal ceiling is 54 degrees Celsius …

Both values verbatim, and the model also summarized what the corpus was — build documentation for a list of backends it enumerated correctly.

At that length 947.2 t/s prefill over the full 150k prompt, 6.9 t/s generation with the context already filled. The decode rate is what a 262144-trained context costs on 256 GB/s of memory, not something the kernels here can fix.

What each step does

In the order they land. Dependencies force some of it — the sparse attention graph has to precede the kernel that consumes it — so this is not strictly biggest-win-first.

00

Starting point

c5d692357

The strix-halo branch rebased onto upstream master, with nothing of our own on top except the restored prop.integrated flag. Lazy per-layer-embedding loading uses the upstream --lazy-mode on path, which demand-pages rows through the mmap.

Why This is the honest baseline: everything here is already in mainline or in the UMA ring buffer stack, so every later number is attributable to one commit.

measurement pending

Caveat The depth-40000 column is empty here, and stays empty until the bf16 GEMM commit, for a concrete reason: upstream’s mm_ids_helper sizes its shared memory as n_tokens × 4 bytes, which is 98 KB for a 24576-token batch against 64 KB of LDS on gfx1151. GGML_ASSERT(nbytes_shared <= smpbo) fires while filling the context. Upstream cannot prefill at this ubatch on this part at all.

01

Guard the KQ mask write

58a9b386e

Four of the thirteen set_input_kq_mask call sites in llama-graph.cpp wrote the mask without checking it was allocated. The other nine already did.

Why A graph that stores K without attending through it never references the mask, so the allocator gives it no buffer and the write aborts. Nothing gets faster here, but nothing later works without it: this is what makes the maskless sparse-attention path reachable at all.

measurement pending

Caveat Neutral on its own, by construction. It is an upstream bug in its own right.

02

MTP hyper-connection mixer tensors

dda64492f

The draft head carries its own hyper-connection mixer. Adds the three tensors, plus --mtp-shared-embd, which leaves the token embeddings, output norm and LM head out of the draft and takes them from the target at load time.

Why A 2.8 GB draft instead of one that repeats the 27.5 GB embedding table.

measurement pending

Caveat Invisible to llama-bench, which runs no draft model. It pays off in the server path.

03

Sparse attention graph and the lazy direct reader

ddaf5214b

Two model-side changes that resist separation. The sparse path: the lightning indexer already picks the top-k key blocks per query, so this builds the attention graph that visits only those — block selection, the packed key/value layouts, compact index metadata, causally-trimmed score bounds, and the indexer cache. The reader: --lazy-mode on-direct serves the rows of a lazy tensor with explicit pread()s from a thread pool instead of demand-faulting them through the mmap.

Why The reader is worth it for two reasons, not one. Demand paging serialises 16384 scattered faults per ubatch in the fault handler; and on requires mmap, which keeps the weights resident twice during load — once as page cache, once in the managed copy an integrated GPU makes under unified memory.

measurement pending

Caveat These two started as separate commits and were merged, honestly rather than tidily: the reader’s consumer sits inside the same graph-input code the sparse path rewrites, as if (ple_reader) { … } else { … } pairs whose else-branch is the non-direct path. Splitting them mechanically produced dangling else branches. The reader’s isolated contribution was instead measured directly — a one-variable A/B on a single fixed binary, -lm mmap -lzm on against -lm none -lzm on-direct: 233.71 ± 5.14 → 395.58 ± 1.97, 1.69×. That figure is from that A/B, not from this table.

04

Sparse selected attention kernel

df8ad5b10

One workgroup of eight waves per (query group, kv head). Four consecutive queries share a sorted union of their selected blocks with a 16-bit membership mask, so 48 rows stay in registers as three WMMA tiles. Each wave owns 32 head dimensions; the K fragment is reused across all three tiles and the owning wave runs the online softmax.

Why Without it the graph still builds block selection and the packed key/value layouts, and then a dense kernel runs over that layout — paying both costs. That configuration measures 1.71× slower than the finished stack, so the graph and the kernel have to travel together. Anyone porting half of this will make things worse.

measurement pending

Caveat And yet the whole sparse path is break-even. Disabling it entirely — graph, indexer, top-k and kernel — measures 0.98× at depth 0, 1.01× at 40 000 and 1.00× at 140 000. The dense alternative is the head-dim-256 WMMA flash-attention kernel, which is fast enough that the lightning indexer’s cost (twelve layers × eighty strips per prefill) cancels what the sparsity saves, even at 140k context. I expected it to pay off at depth and said so; it does not. Dense is also the numerically exact computation, so at equal throughput it is the better choice — though that quality comparison is reasoned, not yet measured by perplexity, and should be before anyone retires this machinery.

05

bf16 WMMA dequant GEMM

e55085251

Dequantizes the weights to bf16 once per graph, caches them, and runs the GEMM on the WMMA units: a tall 384×64 tile for the narrow projections, a fused gate/up + SwiGLU for the MoE, and routed down-projections that write bf16 in place.

Why At prefill batch sizes these GEMMs are compute-bound, and MMQ’s integer path leaves the f16/bf16 WMMA units idle. The f32 vector roof on this part is 29.7 TFLOPS against 59 for bf16 WMMA.

prefill 372.18 t/s at depth 0, 348.58 t/s at depth 40 000

Caveat This commit also carries the route-bounded mmid, which is what makes a 24576-token ubatch possible on gfx1151 in the first place — so it is the point where the depth-40000 column starts having numbers at all.

06

Fused hyper-connection combine + norm

90aba037c

Fuses the combine, the residual add and the rms-norm into one kernel.

Why The combine reads four f32 residual streams and writes the normalized stream plus the next residual — five DRAM passes over the same rows on a part with ~256 GB/s of bandwidth. Fusing leaves one.

prefill 393.91 t/s at depth 0, 372.59 t/s at depth 40 000

07

Fused gate GEMM + stream mix

6ec4a5f0d

The gate projection [320 → 10240] is read only by the stream mix that follows it, so the GEMM, the sigmoid and the mix collapse into one kernel.

Why The gate never reaches memory. This is also the commit whose env gate I initially failed to carry over, which cost a confusing afternoon — see the note on method below.

prefill 413.57 t/s at depth 0, 402.64 t/s at depth 40 000

08

Keep the streams in bf16 end to end

816667e9d

A marking pass over the scheduled graph: a tensor whose every consumer reads the bf16 copy never needs its f32 form written at all. Covers the normalized stream, the gate, the block output, the 16-bit residual chain, the fused SwiGLU output and the routed down-projection.

Why Halves the traffic on the widest tensors in the graph. Needs the allocation dependency that stops an in-place residual sharing a buffer across a mark boundary.

prefill 417.92 t/s at depth 0, 416.13 t/s at depth 40 000

09

Gated rms-norm, indexer relu-sum, MoE reduction

2e3ee2afa

Three small fusions on the same path. The gated rms-norm folds the gate projection into the norm. The indexer’s relu and per-head sum become one kernel. The MoE weighted reduction absorbs the shared-expert merge that followed it.

Why Each is a kernel launch and a memory round trip removed. Individually small; the indexer pipeline runs 12 layers × 80 strips per prefill.

prefill 438.89 t/s at depth 0, 424.09 t/s at depth 40 000

10

Depthwise conv1d and vectorized get_rows

0540c6948

The per-layer-embedding and delta-net short convolutions arrive as a concat plus a transposed cont chain; matching them lets one kernel write the tail directly. get_rows gains a 16-byte same-type copy and a mean-of-four fusion for the embedding gather.

Why The embedding gather runs once per token per layer, so its constant factor is not negligible.

prefill 499.52 t/s at depth 0, 486.81 t/s at depth 40 000

11

Tiled gated delta-net

964c6f2f0

Tiles the recurrence over tokens so the state stays in registers across a tile instead of round-tripping to memory per token, and replaces the generic warp reduction with a DPP/permlanex16 chain: five dependent ds_bpermute round trips become five DPP ops with the same pairing order, so the sum is bit-identical.

Why This is the largest single step in the series — larger than the bf16 WMMA GEMM, larger than sparse attention, larger than everything else put together. Most layers of this model are delta-net rather than attention, and on RDNA the LDS crossbar dominated the per-token latency of the recurrence.

prefill 1181.77 t/s at depth 0, 1081.79 t/s at depth 40 000

Caveat It was very nearly missed. Earlier work measured a delta-net rewrite at roughly 3% end-to-end and dropped it over a 0.21% perplexity cost — a reasonable call, except that it compared a chunked rewrite against this tiled kernel, both already fast. Nobody had measured tiled against stock upstream at a 24576-token ubatch. Walking the series from the real upstream baseline is what exposed it, and it also explains why the hyper-connection fusions look weaker here than they do on the finished stack: every point before this one was bottlenecked on the stock recurrence, which masked everything downstream of it.

12

WMMA lightning indexer scoring

f7539cf7b

Scores the indexer’s query × key grid on WMMA with the keys held in registers, falling back to the existing kernel for shapes it does not cover.

Why Available, but left off by default.

prefill 1176.60 t/s at depth 0, 1070.57 t/s at depth 40 000

Caveat Measured neutral-to-worse in the reference stack, so the gate defaults to off. Kept because the fallback is clean and the shape coverage may widen.

13

Head-size-256 WMMA flash attention

03733c23f

qwen4exp attends with key and value length 256, where the D=256 tile kernel is FMA-bound for prefill batches. Adds the mma_tile_sizes specialization for DV=256, sizes the VKQ accumulator from the tile element type rather than a DV modulus, and adds the tile_Q reuse barrier.

Why This is the model’s own attention path, so it is load-bearing even though the sparse kernel handles the long-context case.

prefill 1182.30 t/s at depth 0, 1074.24 t/s at depth 40 000

Caveat Resolving this against upstream caught a real bug — see the note on method.

14

MMQ compaction and transposed concat

be39c4ff0

MMQ compaction picks the tile from the routed column count rather than the padded maximum. The transposed concat gets a 16-row tile on RDNA3.5.

Why Both target real inefficiencies in the trace.

prefill 1182.91 t/s at depth 0, 1071.21 t/s at depth 40 000

Caveat Measured inside the noise on this workload. Recorded as neutral rather than claimed as a win.

15

TOP_K tie handling, a permute overflow fix, and operator coverage

7abec5c24

Three commits in the branch — ggml: widen ggml_permute’s ne/nb locals, ggml-cuda: handle equal values in the TOP_K radix gather, and the test cases. The TOP_K gather assumed distinct values, so a bucket containing ties could emit the wrong count and drop or duplicate indices.

Why Correctness, not speed — and the first thing in this series to make the model measurably slower.

prefill 1160.02 t/s at depth 0, 1072.90 t/s at depth 40 000

Caveat This step costs 1.9% (1182.91 ± 2.59 → 1160.02 ± 1.89 at depth 0, non-overlapping spreads; depth 40 000 unchanged). Bundled into one commit the regression was invisible, which is the clearest argument in this whole document for splitting commits. Two honest caveats: the three commits were separated after this measurement, so permute and topkequal have no individual figures — the before/after pair brackets all three. Attributing the cost to the TOP_K gather is inference, not measurement: test cases cannot affect runtime and ggml_permute is host-side graph construction, so it is the only candidate left. The indexer runs top-k twelve layers × eighty strips per prefill, which fits.

How this was measured, and what went wrong

The failures were more instructive than the successes, so they are written down too.

Fixed environment

Retained-PM4 HIP and ROCr, unified memory on, gfx1151. No LD_PRELOAD and no kernel selected by anything other than the commit under test. Every run is wrapped in a memory guard that stops it at 8 GiB of headroom rather than letting the OOM killer decide, and takes an advisory GPU lock so two sessions cannot contend.

The reference control

Before concluding anything about a gap, the original out-of-tree stack was re-run on the same machine on the same day. It reproduced within noise of its recorded figure, which is what made the remaining difference attributable to code rather than to a machine that had drifted.

An OOM worth keeping

The first baseline attempt died with 27 GB resident and 151 GB of virtual address space. Under unified memory with everything offloaded, the weights land in managed — anonymous — memory, so all 93 GiB competes directly with the KV cache and the compute buffers. That is the whole reason the lazy direct reader matters twice over.

A false lead

Five hyper-connection diagnostics were missing from our logs and present in the reference, which looked like five broken paths. Four of them only print under a debug flag that had been left out; their absence meant nothing. Only the fifth was real evidence, and it pointed at a single missing env gate worth 1.15×.

A bug caught by splitting

The head-size-256 flash attention work conflicted with upstream retuning. Taking our narrower RDNA3 gate compiled the DKQ=192 case down to NO_DEVICE_CODE while upstream's host-side selector still chose it — a hardware exception, not a wrong number. Deferring that piece to its own commit with its own verification is what surfaced it.

Negative results, kept

MMQ compaction, the transposed-concat tile and the head-size-256 tuning together moved depth-0 prefill by less than the run-to-run spread. The WMMA indexer scorer measured neutral-to-worse and ships with its gate off. They are in the table at their measured value.

Reproducing it

The branch is strix-halo on pwilkin/llama.cpp, rebased on upstream master. The series is 16 commits on top of the rebase point, each one buildable and measurable on its own.

git clone https://github.com/pwilkin/llama.cpp.git && cd llama.cpp
git checkout strix-halo
HIPCXX="$(hipconfig -l)/clang++" HIP_PATH="$(hipconfig -R)" \
  cmake -B build -DGGML_HIP=ON -DGPU_TARGETS=gfx1151 -DCMAKE_BUILD_TYPE=Release
cmake --build build -j

build/bin/llama-bench -m Qwen3.8-Flash-Next-IQ4_NL-00001-of-00009.gguf \
  -dev ROCm0 -ngl 999 -fa on -ctk f16 -ctv f16 \
  -lm none -lzm on-direct -b 24576 -ub 24576 -p 16384 -n 128 -d 0,40000 -r 3