GitHub ↗
Contents

A drafter on the OGA-pack route: measured as far as the route allows (engine#1371)#

#1371's claim is that the batched verify path is now cheap and greedy-exact, so a drafter on the OGA-pack route should pay, and it asks the drafter lane for acceptance per pass against a bar. Two of the three arms are measured and published (docs/amd-drafters.md: PARD over GGUF, HRX 1.012× at 3.095 tokens/round and 0.524 acceptance per offered draft; CPU 0.508×, which is structural — a round can only return 1 + accepted ≤ 1 + drafted while a batched verify costs ~M token-equivalents). This page is the third arm: the OGA pack itself. On 2026-11-08 no pack could carry a drafter measurement, and the gate table further down records why; on 2026-11-10 one pack serves and the row is measured, in the next section.

2026-11-10: a pack serves, and the drafter row is measured#

The table below is from 2026-11-08, when no OGA pack reached a logit. Since then the 1.9 reader and the b8 kernel families landed, and Qwen2.5-7B-Instruct_rai_1.9.0_npu imports complete and serves. So the row #1371 was opened for exists now: a real drafter, on an OGA pack, against that pack's own costs.

measured
target Qwen2.5-7B-Instruct_rai_1.9.0_npu, imported with --weight-layout b8 (incomplete 0; attention 3584/3584 tiles and MLP 9856/9856 groups byte for byte), served by forward-serve on the NPU
one plain decode step 1021.2 ms (median of 33; 1005.9-1065.5), 0.83-0.94 tok/s per request
one batched pass, 5 rows / 8 rows / 16 rows 1049.1 / 1056.1 / 1073.4 ms (medians of 4): a verify pass of up to 16 rows costs 1.03-1.05 steps
drafter qwen2.5-0.5b-instruct-q4_k_m.gguf on the CPU (--device none -ngl 0), 5.8-6.4 ms per drafted token
acceptance, k = 4 2.963 tokens/round (160 tokens in 54 rounds; 108 of 216 offered drafts accepted)
acceptance, k = 7 3.40-3.48 tokens/round (160 tokens in 46-47 rounds over two replays; 116-117 of 322-329 offered)

Per prompt at k = 4 the rate is 2.22 (prose), 3.33 (code), 3.33 (a list), 3.33 (a short answer); at k = 7 it is 2.50, 4.44, 4.00 and 3.33-3.64. No prompt accepted everything or nothing, so neither of the tool's two warnings applies.

What that acceptance is worth, from those measured parts:

round shape k round cost bar (tokens/round to break even) measured tokens/round projected
one pass (verify only) 4 1072 ms 1.05 2.963 2.82x
one pass (verify only) 7 1101 ms 1.08 3.404 3.16x
two passes (mtp::run: verify, then the step that consumes the committed token) 4 2093 ms 2.05 2.963 1.45x
two passes 7 2122 ms 2.08 3.404 1.64x

The acceptance column is measured; the "projected" column is arithmetic and is not a measured loop. No speculative loop is wired into forward-serve (engine#1781 carries that), so nobody has run these rounds end to end on this pack. What the measurement does settle is the question #1371 asks: on this route the bar is 1.05-1.08 tokens/round for a one-pass round, not the 1.17 measured on the 0.5B pack, because here a batched pass of up to 16 rows costs what one step costs (the device wait is 854-872 ms of either). A 0.5B drafter of the same family clears that bar by 2.8-3.2x on these prompts, and still clears the two-pass bar.

Three limits of the row, so it is not read as more than it is:

  • four prompts of 40 tokens each. It is a first row, not a distribution; code and list text draft better than prose, as on the other arms.
  • acceptance is counted on the draft tokenizer's ids over the target's recorded text (the pair shares the Qwen2.5 vocabulary, and the tool refuses a recording whose prompt does not tokenize to a prefix of prompt + continuation). The drafter is not bit-reproducible between replays: one prompt's k = 7 accept list moved between two runs, which is the 3.40-3.48 range above.
  • the pass costs were taken at the start of a context (rows 0-15). The host share of a pass is 34-53 ms of ~1050, so a longer context moves it little, but that is an expectation and was not measured.

One thing a verify loop on this pack must respect: a pass of 17 rows costs 6992 ms, 6.5 times a 16-row pass, because a chunk wider than 16 rows runs the 128-row kernel family. A verify batch must stay at 16 rows or fewer here.

How it was taken (amdxdna error count unchanged across every ticket):

1bit npu-pack import <pack> -o <out> --weight-layout b8 --kernels <the pack family's kernel set>
# ticket 1 (npu-hold): the target's greedy continuations, through the endpoint forward-serve has
tools/spec_decode_acceptance.py --record-openai http://127.0.0.1:18172 --prompts prompts.json --tokens 40 --out target.json
# no NPU: the drafter proposes from every committed prefix of that recording
tools/spec_decode_acceptance.py --target-recorded target.json --draft http://127.0.0.1:18173 --k 4 \
    --pass-ms 1049.1 --step-ms 1021.2

The replay rests on greedy decode being deterministic, and that was checked rather than assumed: the four continuations were recorded twice, on two separate serves in two tickets, and are identical text for text (4 of 4).

--pass-ms and --step-ms are the serve's own [bs] M=<rows> and [ph] tok walls under NPU_PHASE_PROF=1; a prompt of n+1 token ids produces one n-row pass. tools/oga_pack_probe.sh still imports without --weight-layout b8 and without a kernel set, so it reports this pack as stopping at gate 2; the commands above are the ones that serve it.

The route has three gates, and a pack dies at a different one each time (2026-11-08)#

tools/oga_pack_probe.sh <pack dir> [port] runs gates 1-2 inside one npu-hold ticket (the server it starts is killed by a trap on every exit path) and prints which gate bit. Measured on this box, 2026-11-08, kernel 7.4.0-rc1-1bit:

pack gate 1: npu-pack import gate 2: forward-serve outcome
Qwen2.5-0.5B-Instruct_rai_1.9.0_npu container written, 242 tensors incomplete — "fconst: layer blocks not located: fewer than 6 RMSNorm epsilon headers in the first GiB" not reached the 1.9 pack renamed the .fconst token stream (engine#1410)
Qwen2.5-0.5B-Instruct_rai_1.8.0_npu_4K container written, 48/48 RMSNorm anchors found, 768/768 .fconst tiles byte-exact, 73 tensors incomplete embedding dtype q4_1 but dequant failed the embedding, see below
Llama-3.2-1B-Instruct_rai_1.8.0_npu_4K container written, 161 tensors incomplete missing full ELF /full_i8_QKV_K2048_N3072.elf this pack's shapes have no compiled kernels in this tree
Qwen3-1.7B_rai_1.8.0_npu_4K container written, 56/56 RMSNorm anchors, 281 tensors incomplete missing full ELF /full_i8_QKV_K2048_N4096.elf same, and the shapes that do exist elsewhere on the box are a partial set

So the OGA route is not blocked by the drafter: it is blocked by the base not loading. A drafter measurement on it would be a number about a model that never produced a logit.

The 1.8.0 0.5B pack's actual blocker, with the line#

[INFO]  Model loaded: 511 tensors, 24 layers
  [forward] dims from …/config.json: H=896 NL=24 NH=14 NKV=2 HD=64 IM=4864 NV=151936
1bit forward-serve: forward init failed: embedding dtype q4_1 but dequant failed

The dims are right (that is Qwen2.5-0.5B), so the container parses. What fails is the quantized embedding: the embedding branch of Q4nxNpuForward's init calls dequant_any() (npu/forward/src/q4nx_forward.cpp), and that function accepts exactly three chunk sizes — our 5120-byte q4_1 tile (Q4NX_TILE_BYTES), a Q4_K chunk, or a Q8 chunk — deriving the chunk size from data_size / ntiles; its caller raises "embedding dtype … but dequant failed" when the tensor is none of them. The OGA import writes the embedding as q4_1 in the vendor's layout, whose per-tile byte count is not one of the three, so the call falls through to the packed/tensor decoders and returns non-zero. That is a precise, repeatable diagnosis, not a guess: the same message appears with the same tensor on every attempt. (Line numbers are deliberately not quoted: this page has already been wrong once by naming a line that moved. The function names are stable, and git log -S finds them.)

Two ways out, and they belong to different lanes: teach the import to write the embedding in a chunk our forward already reads (the import is where the vendor layout is known), or teach dequant_any the vendor's q4_1 embedding layout. Either unblocks gate 2; only then does gate 3 — a drafter — mean anything.

What the probe gives the next person#

npu-hold -t 4 -l "oga probe" -- tools/oga_pack_probe.sh models/oga/<pack dir>
# exit 0 the pack served (gate 3 is worth doing)   3 import refused/incomplete   4 forward init failed   5 no health

and a verdict line that names the gate rather than leaving "it does not work". The harness for gate 3 already exists and is in main: tools/spec_decode_acceptance.py (token-exact, per-round accept list) plus tools/spec_decode_acceptance_hrx.sh if the drafter is served on the device, and the bar to compare against is in docs/amd-drafters.md — 1.17 tokens/round for a one-pass loop, ~4 for mtp::run's two-pass round.

View this page's source on GitHub ↗