HRX hardware gate#
ci.yml runs on a GitHub runner with no GPU, so it cannot see an HRX kernel or pin that computes
the wrong thing. Eight HRX correctness bugs were found by hand within ten days: #308, #309, #310, #313, #318, #319, #369
and #370. The hardware gate checks every HRX pin bump, and every PR that asks for it, on the Strix Halo box before merge.
When it runs#
.github/workflows/hw-gate.yml, job hw_gate, on the self-hosted runner strixhalo-engine:
- A labelled PR. Add the label
hw-gateto a PR from this repository. Adding the label starts the job, and every later push runs it again. - Every PR that bump-hrx opens (branch
bump-hrx/*). - By hand. Actions -> hw-gate -> Run workflow, with a ref (a branch, tag, SHA or
pull/N/head). The run posts the commit statushw-gateon the commit it checked, so a PR shows the result.
PRs from forks never run there: the runner is the owner's box.
What it checks#
scripts/hw-gate.sh builds this checkout's HRX pair (third_party/llama.cpp and
third_party/hrx-system) with the configuration of cmake/hrx.cmake, plus test-backend-ops
and llama-perplexity, with the private HIP kernels compiled in as the release builds them: the
gpu-kernels add-on at the commit config/gpu-kernels.config pins, fetched by
scripts/fetch-private.sh with the runner box's gh login and passed as
GGML_HRX_HIP_ADDON_DIR. A llama.cpp pin and a gpu-kernels pin that do not compile together fail
the build step (2026-10-20: gpu-kernels be68256d used HipSetRowsArgs, which the pinned
llama.cpp did not have yet), and so does a build whose libggml-hrx.so lacks the add-on. A box
that cannot read gpu-kernels stops with exit 2; HW_GATE_PRIVATE=0 gates the public plumbing
only. On a failed build the job log shows the compiler's error lines without their source
excerpts, since the log is public and the add-on's sources are not. It then runs six checks:
| check | what | catches |
|---|---|---|
| ops | one test-backend-ops -b HRX0 -j 4 run over MUL_MAT, MUL_MAT_ID, FLASH_ATTN_EXT, GATED_DELTA_NET, SSM_CONV, GET_ROWS, ROPE, RMS_NORM, SOFT_MAX |
a kernel that disagrees with the CPU (#310, #313, #370, the IQ ports) |
| fa-decode-q8 | the add-on's ggml-hrx-fa-decode-bench on its q8_0 KV decode kernel against a double-precision reference: K q8_0 with V f16 (-ctk q8_0 alone) and with V q8_0, Hq/Hkv 16/8 and 32/8, n_kv 1031, 2048 and 4103, six runs in one hold |
a mixed K/V type bug test-backend-ops cannot see: it has no FLASH_ATTN_EXT case with K and V of different types (gpu-kernels #11 read f16 V dims 0..31 only) |
| kat | tools/kat.py check on five models (below), against tests/golden/hw-gate/kat-<model>.json |
wrong answers, empty replies, NaN logprobs, nondeterminism, cached-prefix and multi-ubatch bugs, drift from the release pin (#308, #309, #315, #318, #319, #369) |
| kld | llama-perplexity --kl-divergence on wiki.test.raw, 512 tokens x 8 chunks, against base logits stored at the release pin |
small numeric drift that greedy answers do not show |
| needle | tests/hrx_long_prompt.py: the 320-record vault-code needle (7.1K tokens on Qwen3, 7.4K on ZAYA1) on Qwen3-0.6B and ZAYA1-8B |
#319, #369 |
| placement | every graph the server scheduled for each model (load, warmup, the kat prompts, the needle) is all on HRX, read from ggml-hrx's placement report | a pin that leaves a node on the CPU, which 1bit serve now refuses to load by default (1bit serve) |
The models (MODEL_TABLE in the script, files under ~/models):
| name | file | covers | kat repeats | needle |
|---|---|---|---|---|
| qwen3-0.6b | Qwen3-0.6B-Q4_K_M.gguf | small dense K-quant | 3 | yes |
| zaya1-8b | zaya1-8b-Q4_K_M.gguf | ZAYA1 (16 experts, 1 active per token), K-quant | 2 | yes |
| qwen3.8-27b | qwen38/UD-Q4_K_XL/Qwen3.8-27B-UD-Q4_K_XL.gguf | gated delta net + attention hybrid, Unsloth dynamic mix | 2 | no |
| qwen3-coder-30b-a3b | Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf | qwen3moe: 128 experts, 8 active per token (MUL_MAT_ID with top-8 routing) | 2 | no |
| qwen3-4b-iq4xs | Qwen3-4B-IQ4_XS.gguf | an IQ quant (IQ4_XS matmul and GET_ROWS) | 2 | no |
| qwen3-0.6b-kq8 | Qwen3-0.6B-Q4_K_M.gguf, served with -fa on -ctk q8_0 |
a q8_0 K cache with an f16 V cache | 2 | yes |
| qwen3-0.6b-kvq8 | Qwen3-0.6B-Q4_K_M.gguf, served with -fa on -ctk q8_0 -ctv q8_0 |
q8_0 K and V caches | 2 | yes |
The last two rows give llama-server extra arguments (the fifth column of MODEL_TABLE). Their kat
needle (about 4.7K tokens) and the 7.1K-token needle decode past 1,024 KV cells, where the add-on's
split decode kernel (fa_decode_q8_d128) takes over from its prompt kernel; the short kat cases
decode through the prompt kernel with one query. Both bugs of gpu-kernels #11 showed only on that
path: a launch parameter registered on the wrong kernel (every long decode failed with "compiled
ABI does not match manifest", HTTP 500) and an f16 V read that covered dims 0..31 only. The rows
run kat, the needle and placement but not kld, since a KLD base is stored per model file and
recorded with the default f16 cache.
kat repeats every case and fails on any of: - a missing expected answer; - an empty reply or a NaN logprob; - two different texts for the same request; - text that leaves the golden before token 8; - a logprob more than 0.5 from the golden.
KLD thresholds live per model in tests/golden/hw-gate/manifest.json (limits). The defaults are:
- mean KLD at most 0.01;
- KLD p99 at most 0.2;
- the same top token at least 95% of the time.
A rerun at the recording pin gives 0 on every one.
The placement check reuses engine #390's report. The kat server runs with GGML_HRX_REPORT=2
(one line per graph) and -lv 4 (llama-server drops the library's INFO lines below it), and with
GGML_HRX_STRICT=0, so a node off HRX is counted rather than aborting the server and losing the
kat. tools/hw_gate_manifest.py placement then reads the server log the way serve does:
placement_observer: placement: 512 nodes, 0 off HRX (none), 1 graph splits
Any graph with a node off HRX fails the check, with the worst graph's counts and op types
(20 of 354 nodes off HRX (MUL_MAT x 20), 5 graph splits; 1 of 41 graphs had nodes off HRX). A
log with no report at all fails too: the check saw nothing. With --phases placement alone the
server answers one short chat request instead of the kat, so a prompt graph and decode graphs are
scheduled. The judge's verdicts are in tests/hw_gate_test.py (ctest hw_gate_judges).
One test-backend-ops case is a known intermittent failure (KNOWN-ISSUES.md, engine#370:
MUL_MAT(type_a=f32,type_b=f32,m=1,n=1,k=2048), about 1 run in 40). When that is the only
failing case, the case runs alone three more times and the check passes if all three are clean;
the summary says so.
Box etiquette#
The gate shares the box with other work. It takes the GPU nine times: once for all the ops, once
for the fa-decode-q8 runs, and once per MODEL_TABLE row (the server for kat and the needle, then
llama-perplexity for the KLD, in one hold).
Before the first of them it takes the fleet's GPU token, ~/GPU-TOKEN: the file long GPU jobs
(Hyperloom, agents' benchmark holds) write their name into while they own the GPU. The gate waits
while another job holds it, up to HW_GATE_TOKEN_WAIT seconds (default 3600), logging the holder;
then it writes hw-gate <run> pid <pid> <time> into it and removes it on exit, only if the content
is still its own. A wait that times out fails the run with exit 2 and names the holder. Two gate
runs on 2026-10-20 (#481) died with "server failed (rc 137)" while Hyperloom held the token and
loaded models of its own. Hyperloom's own token_take waits while a scripts/hw-gate.sh process
exists, so the two take turns. HW_GATE_TOKEN=none skips the token for a caller that already
holds it.
Each of those steps:
- Lock. It holds ~/npu.lock (flock) only while it runs, so other GPU work keeps its turn between steps.
- Heat. It waits for Tctl to drop to 75 C, then runs under ~/lb/thermal-run.sh, which freezes it at 78 C and kills it at 93 C.
- Memory. It runs under scripts/mem-guard.sh (kills the step below 12 GB MemAvailable). Before a step starts, tools/mem_budget.py estimates it. A step that would take GTT to 95 GiB or more (the engine#315 fault zone), or past MemAvailable, is refused. The gate then releases the lock and retries every minute, up to ten times.
- The PR-Agent model. coder-llm (Vulkan, :8090) loses its device when long HRX dispatches starve it. The gate runs on strixhalo-engine, the runner that also runs the pr-agent jobs, and a runner takes one job at a time, so no review is generating during the gate. Before each step the gate also waits for coder-llm's slots to be idle. Afterwards it reports any new ring ... timeout in the kernel log.
The build runs under thermal-run too, and resumes after a thermal kill. Build trees and ccache
persist under ~/.cache/hw-gate, so a PR that does not move the pins rebuilds in seconds.
Goldens: where they live#
- kat goldens are in git:
tests/golden/hw-gate/kat-*.json. They are the text and per-token logprobs of every case, a few KB per model. - KLD base logits are not in git. They are 0.4-1.6 GB per model (
llama-perplexitystores the whole vocabulary for every scored token), past GitHub's file limit, and only the gate box reads them. They live on that box under~/.cache/hw-gate/kld/<model>.kld.tests/golden/hw-gate/manifest.jsonpins each one: its sha256, the model file fingerprint (size + sha256 of the first 64 MiB), the text's sha256, and the pins it was recorded at. The gate refuses a base, model or text that does not match. A private repository would add a token and a multi-GB download to every run for no gain while the gate has one box.
Record them again only at a release pin, after the gate passes there:
scripts/hw-gate.sh --phases build,kat,kld --record # writes kat-*.json, the .kld files and manifest.json
and commit the changed tests/golden/hw-gate/ files in the same PR as the pin they belong to.
Running it by hand#
scripts/hw-gate.sh # everything, on this checkout
scripts/hw-gate.sh --phases ops --models qwen3-0.6b # a subset
scripts/hw-gate.sh --bin /path/to/llama.cpp/build/bin # check binaries built elsewhere (no build)
The settings are environment variables at the top of the script: HW_GATE_STORE, HW_GATE_MODELS,
HW_GATE_LOCK, HW_GATE_OPS, HW_GATE_MEM_FLOOR and the others. Logs and summary.md go to
$HW_GATE_OUT (default ~/.cache/hw-gate/runs/<time>). Exit code: 0 pass, 1 a check failed, 2
the box could not run the gate (lock timeout, a model or base file missing, memory refused).
Measured#
Strix Halo, 2026-10-12, at the release pin (main d2a56a4, llama.cpp 0e255d77, hrx-system 98d05d94), on a box shared with about ten other GPU jobs:
| step | holds the GPU lock |
|---|---|
| build, incremental (no pin change) | 6 s, no lock |
| build, cold (llama.cpp + hrx-system, 4 cores under thermal-run) | about 20 min, no lock |
| ops: 611/611 cases pass | 269 s |
| Qwen3-0.6B: kat (3 repeats) + needle | 67 s |
| ZAYA1-8B: kat (2 repeats) | 172 s |
| Qwen3.8-27B: kat (2 repeats) | 213 s |
| KLD: 0.6B / 8B / 27B, 4,096 tokens each | 6 / 24 / 29 s |
That is about 13 minutes of GPU time in all.
Strix Halo, 2026-10-20, performance power mode, main 55929b5 (llama.cpp 00f2a68d, hrx-system 82804197) plus this coverage change, a quiet box (3 s waiting in all):
| step | holds the GPU lock |
|---|---|
| ops: 611/611 cases pass | 20 s |
| Qwen3-0.6B: kat + needle + KLD | 20 s |
| ZAYA1-8B: kat + needle + KLD (the needle: 7,421-token prompt at 1,207 tok/s, about 8 s) | 70 s |
| Qwen3.8-27B: kat + KLD | 184 s |
| Qwen3-Coder-30B-A3B (MoE): kat + KLD | 38 s |
| Qwen3-4B IQ4_XS: kat + KLD | 31 s |
363 s of GPU time in all; the MoE model, the IQ model and the ZAYA1 needle add about 77 s of it.
Waiting for ~/npu.lock costs more than the gate
itself: flock is not first-come-first-served, and with about ten jobs queueing, each acquisition
waited 10 to 60 minutes. The summary reports both numbers (waiting, holding).
What the goldens record at the release pin:
- Qwen3-0.6B misses one short needle (varied-720w: "25"). The check holds that case to the golden's text instead of the answer.
- On varied-1620w, the cached path answers differently from a fresh request at a near-tie: top-2 margin 0.19, a WARN.
- ZAYA1-8B and Qwen3.8-27B answer every case.
The MoE and IQ rows (qwen3-coder-30b-a3b, qwen3-4b-iq4xs) were recorded on 2026-10-20 at
llama.cpp 00f2a68d / hrx-system 82804197 (manifest.json recorded_at). Both answer every kat case.
Their HRX base logits against a CPU run of the same file (--device none) give:
| model | mean KLD | KLD p99 | same top token |
|---|---|---|---|
| Qwen3-Coder-30B-A3B Q4_K_M | 0.0054 | 0.043 | 96.7% |
| Qwen3-4B IQ4_XS | 0.0054 | 0.057 | 96.4% |
| Qwen3-0.6B Q4_K_M (the release-pin base, for comparison) | 0.0080 | 0.039 | 95.8% |
That is the usual GPU-vs-CPU rounding gap of a 4-bit file, not a wrong kernel.
Not covered yet#
- Lock fairness.
~/npu.lockis a plain flock, so a job that re-queues quickly can starve the gate for an hour. A first-come-first-served queue, or a reserved window for the gate, would bound the wall time. The gate's own GPU time is 6 to 13 minutes. - Not exercised:
1bit serveitself (the gate talks to llama-server with serve's HRX flags),--parallel,--mtp/--dflash, vision, and contexts past 8K. - One copy of the KLD bases on the box. A spare copy of the release-pin set is on ryzen
~/backups/hw-gate-kld-0e255d77; the MoE and IQ bases are in~/backups/hw-gate-kld-00f2a68d. - An intermittent bug that fires about once in 40 runs (#370's class) can pass a single gate run. kat repeats every case and the ops step re-runs a known flake, but only repeated runs catch such a bug reliably.