HRX on the Radeon iGPU#
On Strix Halo, HRX (AMD's ggml-hrx with our Loom kernels) reads Qwen3.8-27B prompts at 335 tok/s and decodes ZAYA1-8B at about 90 tok/s. It is the engine's GPU route: a 14,435-token prompt runs at 265 tok/s, and Qwen3.8-27B decodes at 97% of the previous Vulkan figure.
It is one llama.cpp build with two GPU backends, ggml-hrx and ggml-vulkan (Vulkan leaves the engine in
stages, RFC #213). Its single llama-server exposes both devices on Strix Halo:
HRX0: AMD Radeon 8060S Graphics (Node 1) (gfx1151)
Vulkan0: AMD Radeon 8060S Graphics (RADV STRIX_HALO)
1bit serve runs GGUF models on either device with it (1bit serve):
--device |
Device | Serves |
|---|---|---|
hrx (and auto for GGUF) |
HRX0 |
AMD's ggml-hrx: the engine's GPU route |
vulkan |
Vulkan0 |
upstream llama.cpp's Vulkan build, while it is still in the engine (RFC #213: it is leaving) |
Pinned sources, kept current#
| Submodule | Source | Pinned to |
|---|---|---|
third_party/llama.cpp |
1bit-MONSTER/llama.cpp, branch 1bit/hrx-vulkan-patched |
AMD's hrx-graph-develop-v2 (ggml-hrx on ggml-org llama.cpp) plus our commits ("Our patches") |
third_party/hrx-system |
ROCm/hrx-system | libhrx, loomc and the Loom tools |
- Where the pair comes from. The two pins are the pair AMD's integration repo, ROCm/ggml-staging-automation, builds and tests together.
- How it stays current.
.github/workflows/bump-hrx.ymlruns daily. When AMD moves its pair, it:- syncs the fork (
masterto ggml-org,1bit/hrx-vulkanto AMD's pin); - rebases our commits onto AMD's pin and moves
1bit/hrx-vulkan-patchedthere, after tagging the previous tippatched-<sha>so every commit the engine has pinned stays reachable (a rebase conflict fails the run: rebase by hand); - opens a PR here moving both submodules, listing the rebased commits.
- syncs the fork (
- The token it needs. The secret
HRX_BUMP_TOKENis a fine-grained token with Contents read/write on1bit-MONSTER/llama.cppand1bit-MONSTER/engine, and Pull requests read/write on1bit-MONSTER/engine. - Validation. CI builds that PR without HRX, so run the checks below on Strix Halo before merging.
Fixed: --device hrx answered wrongly on mid-length prompts#
Measured 2026-09-24, Qwen3-0.6B Q4_K_M, greedy chat through llama-server on HRX0:
before the fix, prompts of 404 to 1,733 tokens came back as garbled text, while 146
tokens and 2,167 tokens or more read correctly. Prompt processing was right at every
length; decoding one token at a time went wrong once the KV cache held more than 256
tokens.
Cause: above 256 KV tokens the single-query flash attention kernel
(flash_attention_decode_split_f32_f16_wmma.loom) combines its per-block partial
results with a cooperative reducer. That reducer runs four subgroups over eight query
rows per KV head, and it checked only that a row's query head exists, not that the row
belongs to this KV head. With fewer than eight query heads per KV head (Qwen3-0.6B has
2), the spare subgroups reduced stale rows and wrote them over the next KV head's output.
The reducer used at 256 tokens and below already stays inside its own rows. The fix
(fork commit 79788e9, on 1bit/hrx-vulkan-patched) adds that bound to both copies of
the kernel.
After the fix, fed the same tokens as Vulkan (8 decode steps each), the relative logit
difference is 0.01 to 0.03 at 248, 250, 256, 300, 400, 1,200 and 2,040 tokens, the same
as below 256 before; and the chat answers match Vulkan's at every length from 404 to
2,844 tokens. test-backend-ops -o FLASH_ATTN_EXT cannot check it on this pin: 12 of
5,141 cases pass with and without the fix, because HRX0 refuses the test's empty NONE
graph node.
Fixed: MoE models with more than 128 experts (Qwen3.6-35B-A3B)#
Before this pin, Qwen3.6-35B-A3B (256 experts) failed on HRX0 in two ways:
- Prompt batches faulted. Every batch of 2 or more tokens faulted the GPU
(HSA_STATUS_ERROR_MEMORY_FAULT).
- Decode was wrong. Single-token decode ran at about 41 tok/s, but its output was
wrong: KLD 15.3 against Vulkan, top-1 0%.
There were two causes:
- The router assumed 128 experts.
dispatch-moe-router.cppwrote the expert partition table in the 128-expert layout (7-bit expert ids) for every expert count. Themul_mat_idkernels that read the table decode a 9-bit layout. So experts 128-255 got no partitions, and an expert with 2 or more tokens read past its row. Fork commit96049a2picks the layout by expert count (dispatch_registration/common/dispatch-moe-routing-layout.h). Nothing changes up to 128 experts. - Fused-only ops went to the CPU. Our earlier claim rule accepted only nodes that
run standalone. So the router chain, L2_NORM, SOFTPLUS, GATED_DELTA_NET and the
per-head RMS_NORM/ROPE, which HRX runs only inside fused patterns, went to the CPU.
The 35B then split per layer (KLD 2.63), and Qwen3-Coder-30B-A3B could not decode.
Fork commit
9a7aad6(fused-context-claim.h) claims those nodes when their fused pattern's producers are present and their shapes fit.
Measured on Strix Halo (llama-bench, fa on, -r 3; KLD against Vulkan):
| model | metric | before | this pin |
|---|---|---|---|
| Qwen3.6-35B-A3B Q8_0 | pp512 | 141 | 909 |
| Qwen3.6-35B-A3B Q8_0 | pp2048 | 156 | 1028 |
| Qwen3.6-35B-A3B Q8_0 | tg128 | 14.1 | 35.3 |
| Qwen3.6-35B-A3B Q8_0 | KLD vs Vulkan | 2.63 | 0.0046 |
| Qwen3-Coder-30B-A3B Q4_K_M | pp512 | 1114 | 2040 |
| Qwen3-Coder-30B-A3B Q4_K_M | tg128 | fails | 89.4 |
| Qwen3-0.6B Q4_K_M | pp512 / tg128 | 21938 / 322.5 | 21789 / 323.8 |
test-backend-ops -b HRX0: 790/790.
Fixed: decode-split multipass gap above 2048 (engine#124)#
The multipass decode-split reducer (reduce_completed.multipass, used above
key_value_token_capacity 2048) finished with a scalar output pass: each workitem owned
one output channel and walked every KV block serially, recomputing
expf(partial_max - maximum) once per (block, element). Only half the workgroup is live
at value_head_size = 128, so decode above 2048 stayed about 16% below the <=2048
corridor. Fork PR
1bit-MONSTER/llama.cpp#30 computes
the per-block scale once into a per-row workgroup (LDS) stage and runs one vectorised,
all-rows output pass (4 channels per workitem). The per-channel block order is
unchanged, so the reduce is bit-identical.
Measured on Strix Halo (Qwen3-Coder-30B-A3B-Instruct Q4_K_M, -dev HRX0,
llama-bench -p 0 -n 8 -r 5, median of 6 interleaved runs):
| depth | blocks | before | this pin | delta |
|---|---|---|---|---|
| 2000 (<=2048 control) | 32 | 66.06 | 68.82 | +4.2% |
| 2100 | 33 | 58.19 | 67.15 | +15.4% |
| 3000 | 47 | 50.84 | 60.89 | +19.8% |
| 4800 | 76 | 41.83 | 51.02 | +22.0% |
d2100/d2000 moves 0.881 -> 0.976, so the boundary cliff is gone. 1.24 GB of
decode-path logits (llama-perplexity -c 2049 -b 1 --save-all-logits) are byte-identical
to the previous pin, the buried code word is exact at 4700 tokens, and no GPU faults
were observed.
MoE decode and the kernel cache (llama.cpp #32, #33)#
- MoE decode. With one to four tokens, a
MUL_MAT_ID(a MoE expert matmul) runs as a GEMV over the routed experts' rows (common.mul_mat_id.decode_f32_wave64), not the batched WMMA kernel, which read a single token's expert at ~45 GB/s. HRX0 decode: ZAYA1-8B 46.3 -> 62.0 tok/s, GLM-4.7-Flash 22.1 -> 25.3; ZAYA's teacher-forced check is unchanged (69/96) and batch-1 perplexity stays within 0.1-0.35% of the CPU backend.GGML_HRX_DISABLE_DISPATCH=mul_mat_id.decodeturns it off for comparison. Found with HRX's dispatch profiler:HRX_PROFILE_MODE=dispatch HRX_PROFILE_FILE=x.prof, read withiree-profile executable x.prof(built from hrx-system with Bazel). - Kernel cache. HRX compiles each kernel specialization on first use, and the specialization
includes the batch's token count, so each new prompt-length remainder compiles a new set. The
compiled kernels are kept in
~/.cache/1bit/hrx-jit(GGML_HRX_JIT_CACHE_DIR), keyed on every compile input and the compiler library, so later processes skip the compiles: ZAYA1-8B's first 5.4K-token prompt in a new process 4.46 -> 3.43 s, first short prompt 0.47 -> 0.22 s, with identical output.GGML_HRX_JIT_CACHE=0turns it off; it is off while a Loom sanitizer is set. Deleting the directory is always safe. - Fused residual scale (llama.cpp #35). ZAYA scales both residual branches twice per layer,
(x + bx) * sx + (r + br) * sr, which ran as up to nine ADD / MUL dispatches per layer. Each side now runs as one dispatch (common.res_scale_pair.*,ops/res_scale_pair_f32.loom): 3,366 -> 3,165 dispatches per token, bitwise the same output, ZAYA1-8B HRX0 decode 67.0 -> ~69.6 tok/s and pp512 2,109 -> 2,183.GGML_HRX_DISABLE_DISPATCH=res_scale_pairturns it off. At decode most of a ZAYA token is small dispatches and the ~1.8 us gaps between them, not matmuls; the profiler (HRX_PROFILE_MODE=dispatch) shows which ones. Profile throughllama-server:llama-benchunder the profiler fails. - ZAYA decode fusions (llama.cpp #36-#40). ZAYA1-8B HRX0 decode 67 -> ~90 tok/s (Vulkan 93),
each step measured in interleaved A/B runs:
- PR #36 skips CONTs of fresh contiguous results in
src/models/zaya.cpp(views keep theirs: on HRX their consumers take slower strided paths), bitwise the same output: +2.4%. - PR #39 runs the CCA convolution (Q/K concat, conv-state update, depthwise conv, grouped 2-tap conv
and biases, ~35 graph nodes) as one dispatch (
zaya.cca_conv.decode_f32): 72 -> 81 tok/s. - PR #40 runs the CCA query/key mixing and per-head norms (~30 nodes) as one dispatch
(
zaya.cca_qk_norm.decode_f32): 81 -> 90 tok/s. PRs #39 and #40 accumulate in their own order, so output is not bitwise the same; teacher-forced top-1 against the FP32 model stays 69/96 and batch-1 perplexity moves 33.31 -> 33.49 -> 33.45 (CPU backend 34.30). Both match decode only (one token, one sequence). #37 and #38 remove runtime integer division from #35's and #32's kernels, which newer Loom rejects. Each new.loomfile carries a reference kernel andcheck.casedifferentials that pass underiree-test-loom --sanitizer=access.GGML_HRX_DISABLE_DISPATCH=zaya.ccaturns the two ZAYA kernels off for comparison.
- PR #36 skips CONTs of fresh contiguous results in
Ternary Bonsai (PrismML's Hadamard-folded GGUFs)#
PrismML's Ternary-Bonsai-2-27B is Qwen3.8-27B
trained to weights of -1, 0 and +1, with one fp16 scale per 128 weights. It runs on HRX0,
straight from PrismML's file:
1bit serve -m Ternary-Bonsai-2-27B-PTQ1_0.gguf # --device auto picks hrx for this file
- The weights (llama.cpp #62). PrismML's
PQ2_0 (ggml type 142) and PTQ1_0 (143) are native types in our HRX build, with the layouts of
PrismML's llama.cpp fork. Our CPU decode of every tensor of both 27B files matches PrismML's own
build bit for bit. On
HRX0the K-quant decode kernels read them directly, so one copy of the weights is resident:
| Ternary-Bonsai-2-27B | weights | GPU peak over idle | tg128 (tok/s) | pp512 (tok/s) |
|---|---|---|---|---|
| Q4_0 + packed ternary (below) | 14.13 GiB | 21.8 GiB | 15.3 | 13.1 |
| PQ2_0 | 6.70 GiB | 10.1 GiB | 15.8 | 14.2 |
| PTQ1_0 | 5.53 GiB | 8.9 GiB | 14.3 | 14.2 |
Balanced power mode, llama-bench -fa 1, medians of 3. Against the CPU logits of the
bit-identical Q4_0 copy (3 x 512) all three give the same KLD, 0.000129 at decode (-ub 8) and
0.002051 at -ub 512. Files in these types run on --device hrx only, with or without
prism.hadamard keys (Ternary-Bonsai-1.7B has none). For another route,
tools/ternary_to_q4_0.py writes the same weights as Q4_0 (q = trit + 8 with the group's own fp16
scale in each of its four blocks); every group is decoded back and compared before the file is
kept, and tests/ternary_to_q4_0_test.py checks it against PrismML's own encoder.
- Packed ternary decode (llama.cpp #54).
For a file stamped onebit.ternary_q4_0, 1bit serve sets GGML_HRX_TERNARY_Q4_0=1. HRX0's
K-quant decode kernels then read those Q4_0 weights from a 2-bit copy made at load (68 bytes per
256 values: the two group scales and the trits), 2.125 bits per weight instead of 4.5. The load
checks that every block is ternary and refuses the model otherwise. Prompt batches still use the
Q4_0 weights, so both copies stay resident: about +4 GiB of GPU memory for Bonsai-2-27B (21.3 GiB
peak over idle instead of 17.3), roughly 30% of the file. Bonsai-2-27B decode goes from 9.3 to
15.4 tok/s (balanced power mode); prompt speed is unchanged. Against CPU logits the packed path is
closer than the Q4_0 one (KLD 0.00013 against 0.0019). GGML_HRX_TERNARY_Q4_0=0 turns it off.
- The rotation. Bonsai stores almost every matmul weight rotated by a 1024-point
Walsh-Hadamard transform with fixed signs (prism.hadamard.* keys), and the activations have to
be rotated to match. Our llama.cpp does that (src/llama-hadamard.{h,cpp}, llama.cpp #34,
following PrismML's implementation): a plain F32 matmul against the rotation matrix before each
folded weight, the inverse after the token-embedding lookup, and a check on the first graph that
no folded weight is used without it. Upstream llama.cpp would load the file and answer garbage,
so 1bit serve sends a prism.hadamard file, or one in PrismML's own types, to hrx only
(tests/prism_route.sh).
- The rotation on the GPU (llama.cpp #64).
The rotation is a MUL_MAT that ggml hints as GGML_HINT_SRC0_IS_HADAMARD. The dense HRX matmul
takes at most 2048 rows, so at a 512-token prompt the 17408-wide FFN input (8704 rows of 1024)
ran on the CPU in every layer. hadamard_f32.loom now computes the normalized transform per row
(log2(n) butterfly stages, the matrix never read), as the CPU, Vulkan, CUDA and Metal backends do
for this hint. Ternary-Bonsai-2-27B (balanced mode): pp512 13.5 -> 90.9 tok/s, and decode
15.8 -> 19.0 tok/s, since the decode rotations no longer go through a dense WMMA kernel.
tests/test-hrx-hadamard checks six shapes against the CPU and an exact product, four times each.
- Checked. Against the F16 model on the CPU in PrismML's own fork, wikitext-2 running
perplexity on HRX0 is 7.962 / 10.408 / 10.171 at chunks 5 / 10 / 15 (CPU: 7.958 / 10.382 /
10.142), the difference Q4_0's 8-bit activations make. Files without the keys build the same graph
as before.
Prompt matmuls on the q8_1 x4 kernel (llama.cpp #55)#
On Qwen3.8-27B UD-Q4_K_XL, 94% of HRX prompt time went to the generic F32 WMMA matmuls,
which work in 32-token tiles. An iree-profile dispatch trace of a 1.8K-token prompt split it
as mul_mat_swiglu 51%, mul_mat_add 25% and mul_mat 18%. HRX already has a q8_1 x4
prefill kernel, but two routing rules kept this model's projections, mostly Q5_K and IQ4_XS,
away from it:
- Its matcher took Q5_K and IQ4_XS weights only when q8_1 activations already existed, and never for a matmul feeding a GLU. Q4_K was allowed both. Q5_K and IQ4_XS now get the Q4_K policy.
- The generic fused
mul_mat_swiglu(priority 290) outranks the q8_1 x4 matcher (285) and claimed every FFN gate/up projection. For prompt chunks of 256-2048 tokens in multiples of 256, whose gate and up are Q4_K, Q5_K or IQ4_XS, it now declines. The two projections then take the q8_1 x4 kernel and the GLU runs as its own op.
GGML_HRX_Q8_PREFILL_RELAX=0 restores the old routing.
Measured on Strix Halo, llama.cpp 6e42b51, Qwen3.8-27B UD-Q4_K_XL, 2026-10-01:
| metric | old routing | this pin |
|---|---|---|
pp512 (llama-bench, -b 512 -ub 512) |
98.5 | 334.6 |
| pp2048 | 98.1 | 309.5 |
1bit serve --device hrx, 14,435-token prompt |
90.7 tok/s | 264.7 tok/s (same text) |
| KLD vs BF16 logits (wikitext-2, 20 x 512) | 0.00717 | 0.00712 |
| same top token | 96.13% | 96.27% |
test-backend-ops -o MUL_MAT -b HRX0: 287/287. Decode does not change, since both rules apply
only to chunks of at least 256 tokens. Since
llama.cpp #61, a Q5_K or IQ4_XS chunk that is not a
multiple of 256 sends its 256-aligned head to the q8_1 x4 kernel too, and only the tail goes to the
generic kernel. Q4_K remainders still go to the generic kernel: its packed Row64 weight layout cannot
also be resident raw. Measured against 6e42b51 (3 interleaved runs, -b 512 -ub 512):
| metric | 6e42b51 |
6e42b51 + #61 (a36d547) |
|---|---|---|
| pp400 | 67.4 | 103.4 |
| pp1862 | 172.1 | 234.6 |
KLD vs 6e42b51 (20 chunks of 400 = 256 + 144 tokens) |
0.00215, same top token 97.19% |
test-backend-ops -o MUL_MAT -b HRX0: 287/287. GGML_HRX_Q8_PREFILL_RELAX=0 turns this off too.
Hadamard-rotated Q4_0 files (llama.cpp #58)#
The files tools/hadamard_q4_0.py writes (onebit.hadamard_q4_0 = 32, Lean (ROCmFP4, ROCmI4))
run on HRX with 1bit serve --device hrx.
- How it works.
src/llama-hadamard.cppreads the stamp and treats it as PrismML'sprism.hadamardtransform with block 32 and no signs. The activations of the rotated Q4_0 weights get the same rotation, as a matmul against the 32-point Walsh-Hadamard matrix, so nothing in the HRX backend changes. - What it skips. A Q4_0 lookup table such as
token_embdis written in the plain basis and stays plain. A stamped weight the model doesn't load (the MTP layer without--mtp) is skipped. - Accuracy. Qwen3.8-27B-Q4_0-H32 on
HRX0, wikitext-2 40 x 512 against the BF16 logits: PPL 6.021, KLD 0.0292, same top token 92.5%. Without the rotation the same file gives PPL 550,134. - Speed (before #55). pp512 63.5, pp2048 62.4, tg64 11.65 tok/s. HRX still sends Q4_0 prompt matmuls
down its generic path: #55 routes Q4_K, Q5_K and IQ4_XS only. Until Q4_0 gets the same routing,
--device autokeeps these files on the lean ROCm route.
Server memory: the graph program cache cap (llama.cpp #63)#
HRX builds a graph program for every new graph shape (each new prompt or batch length) and kept every
one: a long-running llama-server with prompts of varying length grew its GPU memory without bound
(ZAYA1-8B: about 1 GiB per request, 24.6 GiB after 28 requests). Since
llama.cpp #63 the cache holds at most
GGML_HRX_GRAPH_PROGRAM_CACHE programs (default 64, 0 = unbounded): when a new program takes it over
the limit, HRX waits for the stream and drops every other program. On ZAYA1-8B, 40 requests of varying
length peak at 8.7 GiB instead of growing; greedy answers are identical, and llama-bench pp512/tg128
is unchanged. A repeated prompt length right after a flush costs 1.75 s instead of 1.12 s, since its
program is rebuilt.
Known issues on HRX0#
- Several sequences per batch fail.
llama-perplexitywithn_seq> 1 stops on an unsupported 3-D MUL_MAT; use-b 512. On Qwen3.5 / Qwen3.8 (Bonsai too) the gated delta net runs one sequence per batch, soservegives these models one slot on HRX and refuses--parallel(--parallel Nunder "Not yet" below). -fa offfails. A SET_ROWS into the non-flash-attention V cache is rejected.- Decode-split flash attention is on.
flash_attention_decode_split_next_q8used to give nondeterministic attention on HRX0 (#140) and to fault Qwen3-Coder-30B-A3B, so1bit serve --device hrxturned it off (#148). Since llama.cpp00adc2b(see the #123 section below) it is correct, and it is as fast or faster:
| model (Q4_K_M), tg tok/s | ctx 0 | ctx 512 | ctx 2100 |
|---|---|---|---|
| Qwen3-0.6B, decode-split on | 286 | 254 | 140–158 |
| Qwen3-0.6B, off | 296–306 | 220–223 | 118–122 |
| Qwen3-Coder-30B-A3B, on | 78–85 | 81 | 42–62 |
| Qwen3-Coder-30B-A3B, off | 78–81 | 65–69 | 47–49 |
| ZAYA1-8B, on | 39–41 | 40–43 | 38–42 |
| ZAYA1-8B, off | 39–42 | 39–40 | 34–36 |
(llama-bench -p 0 -n 32/64, runs interleaved on a shared strixhalo, 2026-09-27.)
ONEBIT_HRX_DECODE_SPLIT=0 turns it off; a GGML_HRX_DISABLE_DISPATCH you set yourself wins.
The prefill split below is not affected by either: HRX0 only prefills whole ubatches there, and decoding is Vulkan's.
Prefill on HRX, decode on Vulkan#
HRX prefills faster than Vulkan and Vulkan decodes faster, on the same GPU.
1bit serve --device vulkan --prefill-device hrx uses both: the HRX build's
llama-server decodes on Vulkan0, and a second copy of the model on HRX0 runs
the prompt prefix. The two contexts share one KV cache with no copy: HRX owns it
in its device memory and exports it as a dma-buf (HSA), Vulkan maps the same
memory, and only the KV cell metadata (positions, sequences) moves between them.
- What runs on HRX: the prompt prefix in whole ubatches (HRX prefill halves on
a partial one), from
--prefill-min-tokenstokens (default 1024; below that the split does not pay). The rest, and all decoding, run on Vulkan. - Needs: flash attention on both sides (
servepasses-fa on), no LoRA, no multimodal. The weights are loaded twice (once per device). - Layout: the shared region is cut into chunks of at most 1 GiB, each its own dma-buf: Vulkan reads garbage from one buffer past about 4 GiB (a 40960-token Qwen3-0.6B cache), and both sides must compute the same layout.
Measured on Strix Halo, llama-server on Vulkan0, prefill / whole request:
| Model | Prompt | Vulkan alone | HRX prefill + Vulkan |
|---|---|---|---|
| Qwen2.5-7B Q4_K_M | 2048 | 1541 / 4314 ms | 1078 / 3890 ms (-10%) |
| Qwen2.5-7B Q4_K_M | 8192 | 7347 / 10376 ms | 4646 / 7668 ms (-26%) |
| Qwen3-0.6B Q4_K_M | 8192 | 1289 / 2267 ms | 1089 / 2032 ms (-10%) |
Decode on the shared KV is within 3% of Vulkan with its own KV. Against Vulkan alone, teacher-forced over 64 tokens, the split's mean KL is 0.0002-0.0006 nats (max 0.007) and the top token agrees on 64-65 of 65 positions; Q4_K_M against BF16 is 0.063.
Our patches#
1bit/hrx-vulkan is AMD's commit unchanged; 1bit/hrx-vulkan-patched adds:
- Zero-copy KV sharing (the section above): dma-buf export in ggml-hrx
(
ggml-hrx-dmabuf.cpp), dma-buf import in ggml-vulkan (ggml-vulkan-dmabuf.inc),llama_kv_share_next/llama_kv_share_from/llama_kv_cells_copyin llama (llama-kv-share.cpp), andONEBIT_PREFILL_DEVICEin llama-server (server-prefill.cpp). - IQ3_XXS matmul on HRX. A matrix-vector kernel (
kernels/hrx/mul_mat_vec_iq3xxs_f32.loom) and its matcher (common/dispatch-mul-mat-iq3-xxs.cpp), ported from thefeat/hrx-port-ae91949work. - Honest op claims. ggml-hrx used to claim every op of a declared type, so
ggml's scheduler handed it nodes it could not dispatch and the graph failed
(
unsupported HRX node) with no CPU fallback. It now claims a node only when its dispatcher can execute it, except nodes already in HRX memory (KV cache views), which cannot move. Leaf (NONE) nodes count as covered. GET_ROWS for batched IQ3_S gives wrong values, so those nodes are left to the CPU. IQ4_NL and IQ4_XS were left to the CPU too until llama.cpp #41 (below). - Kernels for ops HRX sent to the CPU (llama.cpp #24):
a batched F16 matmul (
ggml_grouped_mul_mat_f16_f32, e.g. ZAYA's grouped convolution, whose weights the loader can now place on HRX), and short-row kernels insmall_rows_f32.loom: SOFT_MAX without a mask, SUM_ROWS, ARGSORT, GET_ROWS for narrow rows (strided ids, as top-k views are), CONT of strided views and broadcast-only REPEAT. Each matcher claims only what the existing kernels do not.test-backend-ops -b HRX0passes every case of these ops. ZAYA1-8B's decode graph goes from 641 graph splits to 1: 25.5 to 47.9 tok/s (tg128). - Prompt matmuls on the q8_1 x4 kernel (llama.cpp #55): Q5_K and IQ4_XS prefill matmuls get Q4_K's q8_1 x4 policy, and the generic fused SwiGLU leaves those prompt chunks to it. On Qwen3.8-27B UD-Q4_K_XL, pp512 goes 98.5 to 334.6 tok/s (section above).
- Remainder chunks on the q8_1 x4 kernel (llama.cpp #61): Q5_K / IQ4_XS chunks not a multiple of 256 send their aligned head to it; pp400 67.4 to 103.4 tok/s (same section).
- Prompts past 32K context (llama.cpp #59):
V stays row-major past
copy_transpose_f16's 32768-row range. pp512 at depth 32768 failed before (JITrow_count33280 violatesrange) and runs at 227.4 tok/s now (depth 16384 unchanged at 269); one 34,816-token wikitext chunk gives PPL 6.2150. - Mixed-format SwiGLU pairs (llama.cpp #60):
the K-quant SwiGLU decode kernels stage a separate codebook for gate and for up, so Unsloth's sub-4-bit
UD files no longer fall back to the generic path for pairs that mix formats. Qwen3.8-27B UD-IQ2_S
tg128 3.12 to 8.32 tok/s; UD-Q4_K_XL unchanged (11.88 / 11.81); KLD 0.00013 vs the previous build,
test-backend-ops -b HRX0996/996. - PrismML's PQ2_0 / PTQ1_0 types (llama.cpp #62): native ggml types 142 / 143 with a CPU reference and HRX decode on the K-quant kernels; Q1_0 decode moves onto them too (Ternary Bonsai).
- The Hadamard rotation on HRX (llama.cpp #64):
a kernel for the
GGML_HINT_SRC0_IS_HADAMARDMUL_MAT (Ternary Bonsai). - MXFP4 weights (llama.cpp #65): MXFP4 in the
shared dequantizer (17 bytes per 32 values, the E8M0 scale built exactly as ggml does).
tests/test-hrx-mxfp4checks GET_ROWS on HRX0 bit for bit against ggml's own decoder;test-backend-ops -b HRX01051/1051. MUL_MAT_ID now takes only input sizes that are a multiple of 256, which its kernels declare (until #66 below). - gpt-oss on HRX (llama.cpp #66, #67, #73): MUL_MAT_ID takes input sizes that are a multiple of 32 (gpt-oss-20b's experts are 2880 wide), with a decode-loader stride fix that this exposed (rows were read 3072 apart); ADD_ID and the clamped SWIGLU_OAI run on HRX in our own kernels; and a placement guard (#73) keeps those two ops with their MUL_MAT_ID when the experts run on the CPU, where the mix gave wrong values (root cause open, engine #286). gpt-oss-20b MXFP4, balanced mode: pp512 25.8 -> about 1000 tok/s, tg128 12.6 -> about 35, text correct, KLD vs CPU 0.029.
- Attention sinks (llama.cpp #68): gpt-oss's per-head sink logits run on HRX as an exact post-correction of AMD's unchanged flash-attention output, O x sigmoid(LSE - sink), in our own dispatch; an output that overlaps an input's storage is refused. gpt-oss-20b greedy text is identical to the CPU-sink path; pp512 990 +/- 22, tg128 35.0 +/- 5.5 tok/s.
- PrismML tile bytes (llama.cpp #74): the low-token SwiGLU sized PQ2_0 / PTQ1_0 weight rows from a tile size of 0, so every row read row 0. Ternary-Bonsai-1.7B PQ2_0 decode perplexity 1,225,624 -> 20.65 (CPU 20.70); the 27B files took another kernel and were unaffected.
- TQ1_0 / TQ2_0 (llama.cpp #69): ggml's ternary types in the shared dequantizer and on the K-quant decode kernels. Ternary-Bonsai-1.7B, HRX0 vs CPU: KLD 0.000523, same top token 98.67%; pp512 / tg128 TQ1_0 3542 / 113, TQ2_0 4100 / 156 tok/s.
- MLA attention past 512 tokens (llama.cpp #70): the V transpose assumed contiguous rows, but MLA (deepseek2, GLM) stores V with the QK head stride, so prompts of 512+ tokens read misaligned rows. GLM-4.7-Flash Q4_K_M: PPL 315,664 -> 5.916 (CPU 5.959).
- Hadamard files: qwen3next (llama.cpp #71):
ssm_bais folded too, and a stamped file with any other unfoldable Q4_0 weight is refused by name. - Masked keys in flash attention (llama.cpp #72): KV rows past a sequence's end hold whatever an earlier request or rejected draft wrote, and the F16 WMMA made P = 0 x V depend on that V's sign, so identical requests gave different logits (up to 0.13 on Qwen3-0.6B, 13.5 on GLM-4.7-Flash). Masked rows now reach P x V as exactly +0; Qwen3-0.6B and Qwen3.8-27B repeat bit-identically. Cost: pp512 -4.7% on Qwen3-0.6B, the rest within noise.
- A cap on the graph program cache (llama.cpp #63):
GGML_HRX_GRAPH_PROGRAM_CACHE, default 64 (section above). - Hadamard-rotated Q4_0 files (llama.cpp #58):
llama loads the engine's
onebit.hadamard_q4_0files throughllama-hadamard. Qwen3.8-27B-Q4_0-H32 on HRX vs BF16 (wikitext 40 x 512): PPL 6.021, KLD 0.0292, same top token 92.5%. Q4_0 prompt matmuls still use the generic kernels.
Measured on Strix Halo (Qwen3-0.6B, perplexity over 8 x 512 wikitext tokens):
| File | Vulkan0 | HRX0 on AMD's commit | HRX0 with our patches |
|---|---|---|---|
| Q4_K_M | 22.53 | 22.52 | 22.52, same speed (21691 / 324 tok/s) |
| UD-Q4_K_XL | 22.43 | fails | 22.41 |
| UD-Q2_K_XL | 36.26 | fails | 36.46 |
| UD-IQ2_M | 55.44 | fails | 55.58 |
test-backend-ops -b HRX0: 791 OK, 0 failed (on AMD's commit, about 1150 fail).
Correct is not fast: the UD files run their IQ4_XS and sub-4-bit layers on the CPU
(UD-Q4_K_XL 6303 / 246 tok/s, UD-Q2_K_XL 974 / 76), so they belong on Vulkan0.
1bit-MONSTER/llama.cpp also holds
1bit/hrx2-archive, the previous build (AMD's abandoned ggml-hrx2 plus our Q4NX
kernels). It is kept for a later port of that work to ggml-hrx. Its zaya
architecture has been ported to this branch (ZAYA1, Vulkan (upstream)).
The Vulkan route has its own upstream llama.cpp pin (latest release), so new
architectures do not wait for AMD's pair: see Vulkan (upstream). This build
still has a Vulkan backend, which --device vulkan uses when ONEBIT_VULKAN is
off.
The GPU's matrix units (WMMA)#
The Radeon 8060S (gfx1151, RDNA 3.5) has RDNA3's six matrix instructions, all on 16×16×16 tiles.
The rates come from AMD's
matrix instruction calculator (commit
2ef9189), for example
uv run --with tabulate python3 matrix_calculator.py -a rdna3 -i v_wmma_i32_16x16x16_iu4 -d -w 32:
| Instruction | Inputs | Per WGP per clock | Cycles |
|---|---|---|---|
v_wmma_f32_16x16x16_f16 / _bf16 |
f16 / bf16, f32 accumulate | 1024 FLOPs | 32 |
v_wmma_f16_16x16x16_f16, v_wmma_bf16_16x16x16_bf16 |
same, 16-bit accumulate | 1024 FLOPs | 32 |
v_wmma_i32_16x16x16_iu8 |
int8 × int8 | 1024 ops | 32 |
v_wmma_i32_16x16x16_iu4 |
int4 × int4 | 2048 ops | 16 |
- int8 runs at the f16 rate. Converting activations to int8 saves memory traffic, not matrix time. Only int4 × int4 doubles the rate.
- WMMA does not co-execute with other vector instructions.
- In wave32, A and B are held twice, in lanes 0–15 and again in 16–31.
-A -R(or-B,-C,-D) prints the register and lane of every element. - This box reports 40 compute units (20 WGPs) at up to 2,900 MHz (
rocminfo). That gives a theoretical peak of about 59 TFLOPS for f16 and 119 TOPS for int4. These are computed from the table, not measured. They bound prefill; decode is limited by memory bandwidth.
HRX's WMMA kernels (e.g. flash_attention_decode_split_f32_f16_wmma) load and store fragments
through the kernel compiler, which places them in these layouts. The fragment sizes match the
table: 16 f16 inputs and 4 f32 results per lane in wave64.
Build#
git submodule update --init --depth 1 third_party/hrx-system third_party/llama.cpp
cmake -B build -G Ninja -DONEBIT_HRX=ON # needs TheRock at /opt/rocm-therock (ONEBIT_HRX_TOOLCHAIN)
cmake --build build --target onebit
- One external project. llama.cpp's ggml-hrx builds hrx-system itself
(
HRX_SOURCE_DIR) with TheRock'samdclang. IREE_ROCM_PATHis not passed. It would switch hrx-system to "package" mode, which needs TheRock's aqlprofile-sdk headers, and our/opt/rocm-therockdoes not ship them. Left unset, hrx-system fetches its pinned HSA/AQL headers.- The HSA runtime. HRX dlopens it at run time, and the distro's
libhsa-runtime64rejects theHSA_AMD_AGENT_INFO_PM4_EMULATIONprobe on gfx1151, so HRX then registers no device. CMake finds TheRock'slibhsa-runtime64.so.1(ONEBIT_HRX_LIBHSA), and1bit servepasses it to llama-server asIREE_HAL_AMDGPU_LIBHSA_PATH, unless it is already set. Without a build path it searches/opt/rocm-therock. To run llama-server or llama-bench by hand, export that variable yourself.
Verified (2026-09-23, llama.cpp f1a0aca, hrx-system 51b1739)#
ctest passed, re-run after the first daily bump (#13). At the time the check
was tests/hrx_lemonade_e2e.sh, through the engine's former embedded Lemonade:
unsloth/Qwen3-0.6B-GGUF:Q4_0 answered "Paris" on Vulkan0 and on HRX0. The
same build now passes tests/serve_e2e.sh on both devices through 1bit serve.
llama-bench, Qwen3-0.6B, pp512 / tg128 tok/s, against the previous build (April
llama.cpp with ggml-hrx2 on HRX20):
| Device, file | previous build | this build |
|---|---|---|
| Vulkan0, Q4_K_M | 10627 / 306 | 14097 / 349 |
| Vulkan0, UD-Q4_K_XL | 10668 / ~300 | 13943 / 324 |
| HRX, Q4_K_M | 1046 / 68 | 21990 / 314 |
| HRX, UD-Q4_K_XL | 837 / 83 | 17906 / 293 |
| Vulkan0, BF16 | n/a / 115 | 4504 / 144 |
| HRX, BF16 | n/a / 40 | 7646 / 101 |
HRX prefill is now faster than Vulkan's.
MoE router expert-id fault fix (engine#123)#
llama_decode could die with HSA_STATUS_ERROR_MEMORY_FAULT (ret = -3) on MoE
models (Qwen3-Coder-30B-A3B). It was reported against the decode-split
multipass path, but it is not in flash attention: the fault is a wild weight
read in qwen3_moe_routed_gate_up_swiglu_q4k_q8.
router_top8_f32.loom seeded each lane's argmax with best_id = 0x7FFFFFFF and
only replaced it on an ordered ogt or the equal-value tie-break. A lane whose
candidate logits are unordered (NaN) or below -FLT_MAX therefore published the
sentinel verbatim into route_ids; consumers treat the id as bounded via
index.assume (a hint, not a check) and compute expert * expert_stride in 32
bits, so 0x7FFFFFFF * 884736 mod 2^32 = 0xFFF28000 (~4.29 GB) walked off the
end of the weights buffer. The fix seeds the argmax with the lane's own first
expert id. Upstream PR: 1bit-MONSTER/llama.cpp#25,
merged as 895d63f, which the engine pins.
Measured on HRX0 (Qwen3-Coder-30B-A3B-Instruct-Q4_K_M, llama-bench -p 0 -n 8,
quiet box) with the fault made observable by aligning the three decode-split
partial transients to 256 B instead of 4096 B: pre-fix -d 2100 5/5 faults,
post-fix -d 2100 0/5, -d 3000/-d 4800 0/3, <=2048 path 0/3, and greedy
output identical to the CPU backend. The alignment change is a repro stress knob
and is not part of the fix.
What the fix does not cover: the fault needs router logits that are NaN, and
the fix routes such a lane to a valid expert instead of faulting. Since
llama.cpp #26 that case is loud: a router row
with no ordered logit publishes NaN, and llama-server refuses to sample NaN logits (it logs
"HRX returned NaN logits" and aborts) instead of decoding a wrong but plausible token. On the
author's repro 6 of 13 samples used to decode ' Paris???…' silently; they now abort. Healthy
runs are unchanged (Qwen3-Coder-30B and ZAYA1-8B perplexity on HRX0 identical to before).
GGML_HRX_FA_PARTIAL_ALIGN (default 4096) sets the decode-split partials' alignment, a test knob
that reproduces the fault's layout; the investigation's artifacts are archived privately.
Root cause of the NaNs (engine#123,
#140), fixed in llama.cpp 00adc2b
(#28,
#29). In every decode-split reduce_fused
variant the last workgroup writes the attention output to global memory, and pack_completed_q8
then reads it back with other waves to build next_q8. The barrier between the two was
kernel.barrier<workgroup>, which fences LDS only (no store wait, no buffer_gl0_inv), so a pack
wave could quantize the output slot's previous contents. The f32 output was right and next_q8
was wrong, and NaN when the stale bytes decoded as NaN. The window opens when a dispatch is
preempted, and any process's KFD queue eviction (page compaction, KSM, memory pressure) does that,
which is why the rate followed those settings. It was never the cause. The fix fences both memory
spaces (barrier<global> then barrier<workgroup>). With a separate process forcing queue
evictions: Qwen3-Coder-30B (2113-token prompt, partial alignment 256) went from 1 of 8 correct to
8 of 8 with no NaN, and greedy Qwen3-0.6B from 13-34 divergent per ~177 requests to none.
Not yet#
- IQ4_NL / IQ4_XS on HRX (llama.cpp #41).
AMD's IQ4 nibble packers remapped every table index (XOR 12) for a lowering that does not apply on
gfx1151, in
motifs/dequant.loomand again in the Q5_K/IQ4_XS prefill kernel, so IQ4 was declined and ran on the CPU. #41 fixes both copies and the q8-plane SwiGLU path they exposed. Qwen3.8-27B UD-Q4_K_XL: 91 graph splits -> 1, wikitext PPL 5.8294 (CPU 5.8288), decode 7.25 -> 9.0 tok/s (Vulkan 12.5);test-backend-ops -b HRX0passes the IQ4 MUL_MAT and GET_ROWS cases. - K-quant decode kernels (llama.cpp #42). One-token projections on Q4_K, Q5_K, Q6_K, IQ4_NL, IQ4_XS and Q8_0 now read the GGUF blocks directly with f32 activations: the FFN gate/up pair fused with SwiGLU, and plain projections with the residual add folded in. Mixed-quant files (Unsloth UD) used to take generic kernels at ~105-176 GB/s; these run at ~200-224. Decode, tg128 against the Vulkan build on the same box:
| Model | HRX | Vulkan | |
|---|---|---|---|
| Qwen3.8-27B UD-Q4_K_XL | 12.15 | 12.48 | 97.3% (was 9.0) |
| ZAYA1-8B Q4_K_M | 94.0 | 93 | ~100% (was ~90) |
| Qwen3-0.6B Q4_K_M | 326.6 | 357 | 91.4% (was 320.8) |
| Qwen3-Coder-30B-A3B Q4_K_M | 90.6 | 93.7 | 96.6% (unchanged) |
llama.cpp #43 added Q3_K and IQ3_S (IQ3_S keeps
its 512-entry grid in workgroup memory), which covers every weight type of the UD-Q4_K_XL 27B.
Decode-shaped wikitext PPL (-ub 1) on the 27B: 7.1595, CPU 7.1475.
- HRX decode no longer spins a CPU core
(llama.cpp #44). The per-token stream wait
sat in ROCr's signal wait, which returned from the KFD event ioctl on every completed command
(~900 per 27B token) without sleeping: a full core for the whole token, ~10 W of package power,
and a 90-93 C APU during 27B decode. Long expected waits (over 20 ms) now sleep through 80% of
the wait; shorter ones are unchanged. 27B decode: 71-76 C and ~125 W instead of 83-93 C and
133-137 W, 0.4% slower; Qwen3-0.6B and ZAYA1-8B unchanged. ONEBIT_HRX_BLOCKING_WAIT=1 turns it
off. Model load and the first request still burst to ~88 C for a couple of seconds (host weight
conversions and first-use GPU work share the package power limit).
- Lossless SwiGLU for every FFN layer and MTP verify batches
(llama.cpp #45,
#46). The K-quant SwiGLU kernel now wins
over AMD's int4 lowrow path (weights repacked to int4 at first use, activations quantized to
int4), which also ran slowly under llama-server: 27B llama-server decode 10.7 -> 12.0 tok/s,
KLD against CPU logits 0.00167 (AMD kernels 0.00318). #46 adds 2-8 token variants for speculative
verify batches: Qwen3.8-27B with its MTP head (1bit serve --mtp, draft 3, p-min 0.75) decodes
22.5 / 17.7 / 19.0 tok/s on code / prose / translation prompts (was 20.1 / 16.4 / 17.6; Vulkan
34.6 / 24.3 / 24.5) with the same draft acceptance. A 4-token step still costs ~1.7x a 1-token
step (f32 multiply-adds per token); int8 activations, as Vulkan does, are the next step.
- NaN after an MTP rollback on Qwen3.5/3.8
(llama.cpp #52). With --mtp, the gated
delta-net kernel publishes one recurrent-state snapshot per verify token so a rejected draft can
roll back. It formed each snapshot's decay ratio c_t/c_s by dividing two exponentials. When a
strongly forgetting head drove both to 0, that gave 0/0 = NaN, and the next verify resuming from
that snapshot returned NaN logits ("NaN logits at vocab index 0"). It depends on the prompt, and
showed up on Qwen3.8-27B Q8_0, UD-Q5_K_XL and UD-Q6_K files. The same bug caused an AMDGPU memory
fault on a repeated request without MTP. The ratios are now formed in log space, as the main
recurrence already did. Q8_0 --mtp now runs clean on prose and code (15.7 / 20.2 tok/s, balanced power
mode, 85 W); UD-Q4_K_XL --mtp speed and output are unchanged (also measured in balanced mode).
- 1bit serve --device hrx --parallel N. With one KV stream per slot, the K/V tensors are
4-D and HRX's flash attention falls back to the CPU in every layer (Qwen3-4B, 4 slots: 39 tok/s
aggregate, below one stream). serve now passes -kvu (one shared KV cache) and turns off AMD's
Qwen attention path (qwen.attention), which derives its mask from positions and let the slots'
answers bleed into each other under -kvu. Qwen3-4B 4 slots: 153 tok/s aggregate (single stream
76; Vulkan 216); Qwen3-Coder-30B-A3B 34 -> 74; ZAYA1-8B 47 -> 71. Hybrid gated delta-net models
(qwen35, qwen35moe, qwen3next: Qwen3.5/3.8) are refused with --parallel on HRX: there is no
multi-sequence GATED_DELTA_NET kernel yet (llama.cpp #48
fixed the two failures before it).
- Fast sub-4-bit kernels on HRX. IQ3_S has a decode kernel (#43) but not a fast prefill one. Q2_K
(llama.cpp #49), IQ2_XXS/IQ2_XS
(llama.cpp #50) and IQ3_XXS/IQ2_S
(llama.cpp #51) and IQ1_S/IQ1_M
(llama.cpp #53) run on HRX0: in the shared
dequantizer (prefill WMMA, generic decode, GET_ROWS; IQ2_S was already there) and the K-quant
decode kernels. Before them, llama.cpp's load-time buffer check (a 512-token matmul) failed, and
every such weight went to the CPU. On Qwen3-4B, with KLD against the Q8_0 model equal to the CPU's:
- Q2_K: 709 MiB CPU_REPACK and 217 graph splits -> all on HRX0 and 73 splits; pp512
686 -> 1300 tok/s, tg128 47 -> 75.
- IQ2_XXS: pp512 59 -> 715 tok/s, tg128 23.0 -> 39.5.
- IQ2_XS: pp512 87 -> 268 tok/s, tg128 28.0 -> 30.2.
- IQ3_XXS: pp512 2.8 -> 250 tok/s (mixed quant; 510-560 with every tensor IQ3_XXS), tg 1.5 -> 8.9
(mixed) and 33.4 (pure), balanced power mode.
- IQ2_S: tg 1.5 -> 11.3 tok/s (pure); its prefill is unchanged.
- IQ1_S: pp512 70.8 -> 81.7 tok/s, tg128 14.1 -> 18.6 (balanced; the first decode run in a process
includes about 18 s of kernel JIT for the IQ1 grid). IQ1_M: pp512 70.8 -> 80.4, tg128 14.1 -> 18.1.
Some batched IQ2_XXS matmul shapes are still declined and run on the CPU. Mixed files decode
slower than pure ones: a SwiGLU gate/up pair whose two formats need different grids shares one
grid buffer, so it is declined and takes the generic path.
- Q4NX on HRX. Our Q4NX kernels lived in ggml-hrx2 and are not in ggml-hrx.
They are kept on 1bit/hrx2-archive until they are ported.
- First-request cost. Lemonade's telemetry for the first short HRX chat shows
18 tok/s against llama-bench's 303. It is probably first-use kernel
compilation, not steady state, but this is not yet separated.
- CI. CI has no AMD GPU and no TheRock, so ONEBIT_HRX is exercised on Strix
Halo only.