Repository navigation
Description
NPU plan layer: one model description, many NPUs (owner 2026-11-01 ~23:25 AST: "Plan it out in detail and build")
Direction (owner): NPUs are the same design class with different SDKs. Build the engine so the portable part is shared
and only the kernel body + DMA program is per target. Targets now: XDNA2 (AIE2P, Loom; private ELFs) and Hexagon
(HTP v73+, HVX/HMX, Hexagon tools). The Zhouyi X2 and Intel NPU 4 are the same shape later.
0. What exists today (engine main b5cbe2f)
npu/forward/src/q4nx_forward.cpp(4,562 lines) = ONE classQ4nxNpuForwardthat binds together: the XDNA runtime
(xrt::device,I8CtxELF contexts vianpu/npu_rt_api.h), the container reader (q4nx.h, chunk kinds mode 0/1/2- pb16i sidecars), design derivation (
q4nx_derive_designs+ filename probes - the trap that bit the serve switch
tonight: fused-GU and sealed-ELF decided by which files exist in--kernels), and the model math (embedding, norms,
rope, Hadamard, GDN recurrence, attention, FFN, lm head, state snapshot/restore).
- pb16i sidecars), design derivation (
npu/mtp/draft.h= target-agnostic already:BaseModel(step, step_batch_all, truncate, last_hidden) +DraftHeadStats(acceptance, mean_accept_len, passes_per_token). Good; keep.
- Oracles:
tools/mtp_head_ref.py,tools/moe_q4nx_ref.py, the CPU forward paths (q4nx_dequant.c,model.c). - Gates: NPU_M16_CHECK byte-exact per kernel, window11 (3 greedy prompts text-identical), hw-gate.
- Public bars (BONSAI2-NPU-RESEARCH): 2-bit ternary stream everywhere else (~8 GB/token vs our 12.9).
1. Target architecture
model dir ─┬─ config.json (HF semantics) shared
├─ model.q4nx (container; chunk kinds: mode0/1/2, pb16i, NEW t2) shared
├─ plan.json (THE STREAM PLAN; generated, target-agnostic) shared
└─ kernels/<target>/ (xdna: ELFs + signed manifest; hexagon: .so/.bin + manifest) per target
engine ────┬─ npu/plan/ plan schema + loader + generator (tools/npu_plan.py) shared
├─ npu/model/ NpuModel: the math, calls an NpuTarget for tensor ops; host fallbacks shared
├─ npu/target/ NpuTarget interface; xdna/ (wraps I8Ctx + npu_rt_api); hexagon/ (new) per target
├─ npu/mtp/ draft loop (unchanged) shared
└─ npu/oracle/ CPU forward of the same plan + gates (byte-exact, window11, acceptance) shared
plan.json (generated from config.json + container + a target-independent policy; checked in next to the model):
layers[] with role (gdn|attn), projection shapes, fusion groups (q|gate, k|v, gate|up), decode geometry (M=16 rows,
tile order per projection), prefill geometry (M=128/1024 chunks), expansion policy per projection (t2 -> int4 on-core |
host), layer_block (layers per job), kv {dtype int8|f16, hadamard per-head}, state {gdn commit: deferred}, lm_head form,
hadamard {block, signs file}, drafter {head path, k, tau, verify_rows, tree: leaf|none}. The target reads the plan and
MUST refuse (named reason) anything it cannot run, never silently fall back to a filename probe.
NpuTarget (C++ interface, npu/target/target.h):
struct NpuTarget {
virtual bool open(const Plan&, const std::string& kernels_dir, std::string& err) = 0; // loads manifest, refuses unsigned
virtual bool load_block(int first_layer, int n_layers, std::string& err) = 0; // weights resident / mapped (zero-copy where the driver allows)
virtual bool gemv(Role r, int layer, int M, const int8_t* x, const float* xscale, float* y) = 0; // M in {1..16} decode, 128+ prefill
virtual bool gdn_step(...) { return false; } // optional: host fallback in NpuModel if false
virtual bool attn_step(...) { return false; } // optional
virtual bool lm_head(const float* h, float* logits) { return false; } // optional
virtual Caps caps() const = 0; // max_rows, supports {t2 expansion, gdn, attn, lm_head, int8 kv}
};
XDNA target = today's I8Ctx/ELF path moved behind this interface (no new kernels); Hexagon target = pi-hexagon's HVX/HMX
program behind the same interface. NpuModel owns the math and keeps host fallbacks for every optional op, so a target
is usable the moment it has gemv.
t2 chunk kind (new, shared): 2-bit ternary codes (4 per byte, row-major within a 32x256 tile like mode 1) + one bf16
scale per 128 K per row = 2.125 bits/weight; 27B = ~7.2 GB. Converters: pb16i-exact sidecars -> t2 (lossless for
ternary + the existing per-group scale), GGUF TQ1_0/TQ2_0/PTQ1_0 -> t2. CPU dequant in q4nx.h; oracle = bit-exact vs
the pb16i-exact floats. Expansion on the core (t2 -> int4 nibbles into the existing mmul path) is the per-target kernel.
2. Phases, lanes, gates (each phase = one PR or a small stack; nothing lands without its gate)
| # | What | Lane | Gate | Depends |
|---|---|---|---|---|
| P0 | This plan as docs/npu-plan.md + tracking issue + sub-issues |
orchestrator | reviewed | - |
| P1 | plan.json schema + tools/npu_plan.py (generate from a model dir) + npu/plan/ loader; Q4nxNpuForward consumes plan.json for design derivation INSTEAD of filename probes (fused_gu, sealed elf, m16 dirs) - behaviour-preserving |
pi-npufeat | window11 3/3 identical + prof table unchanged on bonsai2-27b-pb16i; plan.json for 4 dirs checked in; a wrong/missing kernel refuses with a named reason | - |
| P2 | t2 chunk kind: spec in docs/npu.md, q4nx.h dequant, converters (pb16i-exact -> t2, GGUF TQ/PTQ -> t2), oracle test bit-exact, size table |
pi-qdq | bit-exact vs pb16i-exact floats on all 416 layer images + head; 27B t2 size recorded | - |
| P3 | t2 on-core expansion kernel for the sealed M=16 family (XDNA, private npu-kernels) + target wiring; measured GB/s + tok/s | pi-bonsai (after the 08:00 MTP window) | NPU_M16_CHECK byte-exact; window11 identical; decode tok/s before/after (target: 12.9 -> ~6.5 GB/token) | P2 |
| P4 | Extract NpuTarget + NpuModel; xdna target wraps I8Ctx; forward becomes NpuModel(plan, target); flag --npu-target xdna default; host fallbacks for optional ops |
pi-mosaic | identical outputs (window11 + logits KLD 0 on 8 prompts) and prof table within 2 %; ctest; lands on a branch, merges AFTER c20 ships (release freeze) | P1 |
| P5 | Hexagon target: gemv first (t2, HVX/HMX), QEMU v73 bit-exact vs oracle, AI Hub X Elite timing; then gdn/attn optional ops |
pi-hexagon (ryzen) | kernel bit-exact in QEMU; AI Hub profile job ids + µs/call + GB/s; one-layer exact; cost model doc | P2 (format), P4 (interface; until then a standalone harness) |
| P6 | Drafter protocol shared: tau-gated chain, 4-row verify + leaf fill, deferred GDN commit in NpuModel; acceptance numbers per target in docs/npu-leaderboard.md | pi-bonsai then pi-hexagon | acceptance + passes/token measured on 8 prompts x 64; greedy text identical | P4 |
| P7 | int8 KV + per-head Hadamard (plan kv.dtype), prefill chunks on the NPU with block-fp expansion (onwx class 110-150 tok/s) | pi-attn (design) then xdna/hexagon lanes | KLD vs f16 KV <= 1e-3; prefill tok/s per target | P4 |
3. Rules that bind every phase
- Own code; reference projects read-only (tinygrad-npu-examples AGPL, Bonsai2-NPU, onwx closed). Apache header on every file.
- XDNA kernels stay in the private npu-kernels repo (full ELFs, signed manifest); the engine only carries the interface,
the plan and the oracle. Hexagon kernels likewise private (new repohexagon-kernels, owner to create). - No behaviour change lands without the identical-text gate; no perf claim without a measured tok/s row.
- Device discipline unchanged: npu-hold tickets, per-run OK for new ELFs, M>16 parked, owner serve untouched.
- c20 freeze: P1/P2 are additive and may land before c20; P4+ merge after c20 ships.
4. Numbers this is aimed at (Bonsai-2 27B, this box)
plain 2.1 tok/s today -> t2 stream ~5 (P3) -> +MTP head x2.5 (P6) ~12; Hexagon: first real number from P5.
Activity
bong-water-water-bong commentedon Nov 2, 2026
Sub-issues: P1 #800, P2 #801, P3 #802, P4 #803, P5 #804, P6 #805, P7 #806.
bong-water-water-bong commentedon Nov 4, 2026
Measured on Strix Halo, pi-onebp's lane (ZR1-1.5B, the Q8 container
1bit-MONSTER/ZR1-1.5B-GGUF/npu-container-q8):npu_plan.pywritesdecode: {"M": 16, "enabled": true}intoplan.json, but nothing builds the*_m16.elffamily, so the forward's decode-family probe finds 0 of 4 roles and single-token decode silently runs on the M=128 prefill designs witham=1— 128× the useful rows per launch.What the log says (no code change needed to see it)
Cost, same container / prompt / box, back to back in one ticket
_m16on disk)_m16designs present (decode family found)4.7×, and it puts the device route at parity with the same container's host-GEMM matvec (41.07 s, 0.78 tok/s, same ticket). Both device arms produce byte-identical text to each other ("… The capital of Spain is Madrid. The capital of7 is New York. The"), so the M=16 design is not a different-numerics path here; the difference from host appears only as the device's per-column int8 pack, i.e. the documented quantization, not a design defect.
The four designs, and how they were built
full_i8_{QKV,O,GU,D}_K…_N…_m16.elffor ZR1's geometry (1536×2048, 1536×1536, 1536×17920, 8960×1536), M=16, microkernel 16×64×128, generatorn1_core_i8_v27.py -r 1,emit_full_elf … --get-full-elf, with the lock the arena already pins (mlir-aied53582d3e0f+ Peano 22 from~/amd-oss/iron-upstream-venv) — full ELF only, no xclbin, no aietools compiler. Files land in the--kernelsdir the plan'sdecode_dirsresolve to ({"root": "kernels", "path": "."}), which is exactly what the forward scans.Why I am commenting here rather than just fixing it
The gap is in the plan layer's own contract:
plan.jsonnames a decode family that the kernel set has no way to satisfy, and the failure is silent — nothing refuses, nothing warns, and the route just runs 128× the rows. Two options, your call:npu_plan.py(or the packer) emits/validates the decode family: ifdecode.enabled, the pack must either carry*_m16.elfor say so in the plan, and the forward should refuse/warn by name instead of falling back to M=128;decode: M=16until the ELF set ships it.Two other facts worth having in this issue while I am here: (a) the same route also re-dequantizes and re-packs every layer's weights inside the per-token loop (
step()→load_layer()→npu_gemm()→packB()), ~1.3 G weight floats per token for this model, which caps any device number on the model-generic path at ~1.1 tok/s and is why host-GEMM and device land at the same rate — that is the bigger lever, but it is a larger change and not mine to take; (b)elf_emit.sh's comment that--full-elf-namealone writes the ELF is stale for mlir-aied53582d3e0f: without--get-full-elfaiecc runs the whole pipeline, writes no ELF and exits 0, so a build can look green and produce nothing.bong-water-water-bong commentedon Nov 4, 2026
Correction to my numbers above: they were taken on the pre-0007 driver, and the driver changes the conclusion
The arm table I posted earlier today ran on
amdxdna srcversion EBC78CDA6FD7F3F6E824064— the module with the ubufBO-invalidation BUG (the one that put
list_add corruptionin dmesg on this box at 01:49). This box now boots the0007 TDR-recovery module (
srcversion 56AB94758C18FEEC838D18C, the same one the c23 release row asserts), so Ire-ran the same A/B: same container, same prompt, same four
_m16ELFs, same script, one ticket, back to back.Text is unchanged between the two tickets, arm for arm, token for token: host still ends
…is Madrid. If7 capitalizes the capital of a country, device still ends…is Madrid. The capital of7 is New York. The. So the driver movedthroughput, not numerics — the device number is the same quantization, computed faster.
Attribution, stated carefully because the absolute rates are contended-box rates:
restart), not the driver;
consistent with the ubuf BO revalidation the bug was doing on every submit.
What this changes in my earlier framing. I said the device route was host-bound and that the device "adds nothing"
over host GEMM. On the patched driver that is no longer true: device is 1.56× the host matvec in the same ticket, so
the route is now bounded by the per-token host-side
load_layer()dequant +packB(unchanged,q4nx_forward.cpp:1275)plus a device GEMM that is cheaper than the host one. The ceiling argument stands (that per-token re-pack is still why
any model-generic device number sits near 1 tok/s), but the sign of the device's contribution flipped.
Also: my
--- amdxdna errors: 0line in the scripts is worthless on this boot —dmesgis restricted here and returnsnothing. Re-checked through
journalctl -k: 0list_add/list_debug/aie2_cmd_submit/ TDR lines for the wholeboot (4 amdxdna lines total, all init), and nothing in the ticket window 17:05–17:09. Fixing the scripts to read
journalctl -kbefore the next long run.bong-water-water-bong commentedon Nov 5, 2026
Correction #2, and this one retires my own ceiling argument: the per-token host re-pack is 5 % of a decode token
I said twice in this thread that the q4nx device route is bounded by the host-side
load_layer()dequant +ctx.packB()that runs insidestep(), "~0.9 s/token of host work". That was inferred from the code plus thehost-vs-device wall clocks, not measured. Measured now — the serve process's own
utime+stime(thread-group total)read around a fixed request, both arms in one
npu-holdticket, same container/ELFs/prompt, 8 greedy tokens, 0007driver:
NPU_INFER_HOST_GEMM=1)Both arms produce the same text,
Paris. The capital of Germany is Berlin, so this is a timing split, not adifferent path. Script
~/lb/zr1-npu-cpusplit.sh, log~/models/npu/zr1-elfs/cpusplit.log, 0 amdxdna lines injournalctl -k.What follows, and what it costs me:
layer would buy ≤ 5 % (≈1.06 → 1.11 tok/s). My "host-bound" framing was wrong, and the forward-side change I was
about to propose to that effect would have been wasted work. Withdrawn.
launch). Cross-checking against the M=128 arm measured earlier (5.9 s/token, same 112 launches ≈ 53 ms per launch),
per-launch time is roughly
~3.7 ms fixed + ~0.38 ms per row, i.e. ~38 % of an M=16 launch is fixed cost and theint8 GEMM itself is a rounding error inside it (12 M MACs per launch). Those two arms were measured under
different box load, so treat the split as an order-of-magnitude statement, not a fit.
persistent chain across layers, or fewer/larger roles per submission) — not weight residency. If the fixed
per-launch cost is what it looks like, a token could leave the ~1.1 s it sits at today by a large factor.
token for ~2.6 GFLOP of matvec on this model — an effective ~0.23 GFLOP/s with ~7 threads busy. That is not the
NPU's problem, but it is why the host route is CPU-hungry on the fleet and it may be worth someone's look on its own.
Next step on my side is a clean per-launch cost measurement (vary tokens and rows in one ticket) before anyone acts on
point 3. Flagging here rather than opening an issue because the plan layer is this thread's subject and the fix, if it
is a fix, is in the same place.
bong-water-water-bong commentedon Nov 5, 2026
The per-launch measurement I promised: 5.9 ms fixed + 0.30 ms/row, so half of a decode token is submission overhead
Four points, one ticket, same contention, 0007 driver (
56AB9475…), ZR1-1.5B container, 4 ELFs per family, bare serve.Both decode families at two token counts,
--kernels ~/models/npu/zr1-elfs/, host CPU from the serve process's ownutime+stime:decode family: 4 role(s) at M=16)NPU_DECODE_M16=0)Differencing the two token counts inside each family cancels the once-per-request cost (the prompt's prefill and the
run-list build), which is why the two M=16 rows disagree on raw per-token cost:
≈ 5.91 ms fixed + 0.296 ms per row per invocation
At M=16 that puts a decode token at 0.662 s of fixed per-submission cost (55.5 %) + 0.530 s of 16-row GEMM work, on
1.1913 s total. Host CPU is ~4 % in every row, which confirms the earlier split from the other direction.
What this means for the route, with the part I cannot claim:
GEMMs went down as one command list (or a chain that the runtime does not round-trip to the host per launch) that
0.662 s/token would go with it — ~2.3× on this route (1.19 → ~0.53 s/token) without touching a kernel.
a 1-row decode already runs the narrowest tile that exists in the arena's set. Cutting it needs a narrower microkernel
(would scale the row part, at whatever the narrower tile costs), which is a kernel-set question, not a plan-layer one.
So I am not claiming the "15 of 16 rows are wasted" saving that the arithmetic invites.
route is ~36× above the compute floor today — which is consistent with the fact that the fast NPU lane only exists for
models someone hand-built resident-weight designs for.
Limits stated plainly: contended box (ticket queue 180 s,
die 62 Cat grab), one model, one geometry, per-invocationhere means one ELF invocation (the column-group loop inside an ELF is not split out), and the fit assumes the M=16 and
M=128 designs have the same column-group structure — true for these two builds (cols 8/4/4/4 in both), not something I
measured from the artifacts. Script
~/lb/zr1-launch-cost.sh, log~/models/npu/zr1-elfs/launch-cost.log, 0 amdxdnalines in
journalctl -k.bong-water-water-bong commentedon Nov 5, 2026
Correction #3: the host arm's 11.2 CPU-s/token is 92 % weight decode, 7 % arithmetic — retire my "~0.23 GFLOP/s, worth someone's look" line
Item 4 of my previous comment said the host GEMM arm "burns 11.2 CPU-seconds per token for ~2.6 GFLOP of matvec … an
effective ~0.23 GFLOP/s … it may be worth someone's look on its own." That number came from dividing GFLOP by the
arm's total CPU time, i.e. it assumed the CPU time was the matvec. It is not. Profiled instead of inferred:
~/lb/zr1-host-profile.sh— flatperfprofile of the serve process (task-clock:u;perf_event_paranoid=2andyama
ptrace_scope=1refuse-pattach and any call-graph mode on this box, so perf launches the server and therequest is driven while it records). Same binary as the arms above (
~/wt/onebp-zyphra/build-zr/1bit), samecontainer/prompt, two runs to show the split is not a warm-up artefact:
q4nx_dequant_tileload_layer())scatter_transposed._omp_fn.0q4nx_assemble_B():B[i*N + n] = row[i], one f32 at a time__memmove_avx512_unaligned_ermsq4nx_dequant_tensor_chunked._omp_fn.0npu_gemm/npu_gemm_rows/run_lm_headSo of the 11.2 CPU-s/token, ~0.8 s is arithmetic: ~3 GFLOP per token (1.5B model, f32 scalar matvec, ~7 threads) at
~3–4 GFLOP/s, which is what a plain scalar matvec does on this class of core. Nothing pathological, and no
separate inefficiency to hunt: my 0.23 GFLOP/s was an assumption about where the CPU time went, off by ~12×.
Why the host arm pays the decode and the device arm does not — it is not a bug, it is the mode.
init_host()/NPU_INFER_HOST_GEMM=1is the offline reference path (header: "validates the whole forward against the Pythonreference offline"). Two caches that carry the shipped route are explicitly off under it:
ffn_cache_default()andffn_tail_cached()both return false whenhost_gemm_— the source says so in as manywords ("a host-GEMM run (which never packs) keeps its legacy behaviour"), and engine#587's dense-tail cache is the
one that made a GDN model's per-token reload survivable (63.3 → 22.9 CPU-s/token on Qwen3.8-27B-NPU2);
weight_cache_(the packed int8 BOs on the device) needs a device and a pack, so it never engages.Cross-check from the other direction, already in this thread: on the device arm the same harness measured
0.058–0.071 CPU-s/token (5 % and 4.1–4.6 % of wall in the two runs) because
weight_cached()holds the packedBOs, so
load_layer()/q4nx_assemble_B()are guarded out. The two arms are not measuring the same amount of work,and the 11.2 s was never a fleet cost.
What survives from item 4, and it is not urgent: the decode is only hidden while the weight fits the packed-BO
cache. A model too big for it, or an
NPU_NO_WCACHE=1A/B, re-pays this every token, and the largest single piece ofit is
scatter_transposedwritingB[i*N+n]one f32 at a time — ~12 GB of traffic per token for this model (6 GB off32 out of
load_layer, read back and re-written transposed) at roughly 3 GB/s effective, i.e. the stride-N store isstore-bound, not bandwidth-bound. A blocked 32×32 transpose, or assembling straight into the packed layout, is the
lever if that path ever matters. That is an estimate from the profile, not a measurement, and it is
npu/forward's file (pi-npufeat / pi-mosaic), not mine.Everything else in this thread stands: the device route's 95 % device-wait, the 5.9 ms fixed + 0.30 ms/row per-launch
fit, and the conclusion that the lever there is submission granularity, not weight residency. Script
~/lb/zr1-host-profile.sh, profile log~/models/npu/zr1-elfs/host-profile.log. CPU-only profile: the GEMM nevertouched the array, though note
app/forward_serve.cppstill constructsxrt::device dev(0)process-wide, so runsthat use this mode belong inside an
npu-holdticket even though no submission happens.docs: price a MoE decode step on the submission path a decode step us…
bong-water-water-bong commentedon Nov 5, 2026
Correction #3a: the transpose in item 3 of the previous comment is already tiled (#622), and it is 79 % of a memcpy — not "store-bound at ~3 GB/s"
Two specific claims in my previous comment were wrong, and both were checkable before I typed them.
1. "A blocked 32×32 transpose … is the lever."
npu/forward/src/q4nx_pack.c'sscatter_transposedhas beenthe tiled version since #622 ("transpose a projection in 64x64 tiles, not one float per 68 KB stride") — a
#pragma omp parallel forover 64-row blocks with a 64-column inner block. That is the code my profile was measuring,so the suggestion was stale by several commits and I should have read the function before proposing to change it.
2. "~3 GB/s effective, i.e. the stride-N store is store-bound, not bandwidth-bound." That number divided total
traffic by CPU-seconds. Measured properly (
~/lb/zr1-scatter-bench.c, the shipped loop verbatim, 32 OMP threads,5 reps, best-of, ZR1 geometry, under the same box contention as everything else tonight):
So the shipped transpose runs at 79 % of a plain memcpy at the shape that dominates a token, and #622 bought
1.6–1.9× over the loop it replaced at that shape. It is traffic-bound, and there is no stride problem left to fix:
the only lever on that 0.73 s/token is not materializing 6 GB of f32 per token at all — which is exactly what
weight_cache_(packed int8 BOs) andffn_cache_already do on the device route, and what the host-GEMM referencearm cannot do because it never packs.
What that leaves of the whole host-arm thread: the reference arm costs ~1.5 s of wall and ~11.2 CPU-s per token,
~0.73 s of the wall being that transpose traffic, ~0.6 s the dequant writes it feeds from, and ~0.2 s the arithmetic
it exists to validate. Nothing to fix there, and no defect for anyone to chase — which was the point of Correction #3
and is now measured end to end rather than argued from two symbol shares. Log
~/models/npu/zr1-elfs/scatter-bench.log,source
~/lb/zr1-scatter-bench.c; CPU only, no ticket.bong-water-water-bong commentedon Nov 5, 2026
Refinement of the 2.3× in the previous comment: ~2.0×, and the saving is in the driver/device, not in our host code
Splitting the per-launch cost by whose time it is, using the launch-cost rows I posted (host CPU from
/proc/PID/stat,marginal over the 8→24 token difference so the once-per-request prefill cancels), per ELF invocation:
So of the 5.9 ms "fixed" part of the fit, only ~0.4 ms is our host-side submission work; the other ~5.5 ms is spent
inside the driver/device per command submission. That is the part batching 112 invocations into one command list is
supposed to remove, and it is also why the bound is not the full 2.3×: the host still packs and quantizes per layer
(0.40 ms × 112 = 45 ms/token), and the 16-row microkernel work (0.355 ms/row × 16 = 5.7 ms × 112 = 0.53 s/token) does
not move. Predicted per token: 0.53 s rows + 0.045 s host + one submission's ~5.5 ms ≈ 0.58 s against 1.19 s today,
~2.0×, on this driver, with no kernel change. Arithmetic on the four measured points, not a new measurement — the
measurement that would prove it needs the change itself, which is
npu/forward's and the submission path's call.bong-water-water-bong commentedon Nov 5, 2026
Correction to my own 2.2× / 2.0×: on today's engine+driver the fit is 3.65 ms fixed + 0.285 ms/row, so one submission per token is ≈1.8×, and the host arm at the same token counts puts the route at wall parity with a CPU matvec
Everything below is one ticket per arm, binary
da845311…built fromorigin/mainbd7d451a, driver646FFC30…(0007+0008+0009+0010, i.e. not the 0011 experiment — the earlier rows in this thread were on9f78e603+56AB9475), bare serve (nothermal-run), same containerZR1-1.5B-npu-q4km-q8, same 8 ELFs,prompt
The capital of France is, greedy, 112 launches/token (28 layers × 4 roles).1. Three token counts per decode family, so the fit is not two points
My earlier fit (5.91 ms fixed + 0.296 ms/row) came from two arms measured once each. Same harness, now at 8/24/48:
NPU_DECODE_M16=0)The two arms differ only in rows per invocation (16 vs 128), so
per_launch = f + rows·r:16×64×128 microkernel, the fixed term came down);
Host CPU is 3.4–3.9 % of wall on the device arm throughout, i.e. ~0.28 ms of the 8.21 ms is ours, so the 3.65 ms fixed
is still the driver/device's, not
npu/forward's. A second two-point run in the same shift (M=128 24-token 135.5 sagainst 113.9 s here) fits 2.5 ms / 0.386 ms and is the noisier one: the three-point run's own two M=128 marginals
agree to 1.6 %, which is why I quote it and record the other.
2. The comparison nobody had: the host arm at the same counts
zr1-host-vs-device.shonly ever asked the host arm for 8 tokens, so "device vs host" was a single wall clock thatincludes prefill. Same regression on the host arm (
NPU_INFER_HOST_GEMM=1), 8/24/48 in one ticket:So on decode marginals the array is 1.03× the host matvec — parity, within the noise of the host arm itself (its own
marginals between gaps are 0.727 and 1.083 s/token under this box's contention). What the array does buy, measured:
tok/J or memory column, and it is not contested by contention in the same way a wall clock is;
CPU matvec.
That reframes my own 11-04 statement ("device 1.56× host"): on this binary+driver the device's advantage is per-request
latency and CPU seconds, not per-token wall clock. Both arms produce the same first 8 tokens; they diverge later (host
… If7 capitalizes …vs device… The capital of7 …at 32 tokens, as recorded on 11-04) — the int8 weight-pack cost.3. What this does and does not change for the proposal in this thread
Unchanged: the shape — 112 submissions per token,
per_launchlinear in rows, and the row term is already themicrokernel's floor, so "fewer rows" is not a saving and a narrower microkernel is a kernel-set question. The lever is
still submission granularity, still
npu/forward+ the submission path, not this lane's files.Changed: the size of that lever, ≈1.8× today (2.0–2.2× on the 11-04 measurements), and a second, cheaper claim is now
measured — the generic route's case against the CPU reference is CPU/power and per-request latency, not decode tok/s.
Caveat I will not paper over: #1047 reworked the packed-weight image and this boot's driver differs from 11-04's, so these
constants belong to
bd7d451a+646FFC30; the fit's shape is what transfers. Raw logs:~/models/npu/zr1-elfs/remeasure-main-20261105-054933.log(host-vs-device + first launch-cost) and the harnesses~/lb/zr1-launch-cost.sh,~/lb/zr1-host-cost.sh(new), both ticket-scoped,SIGTERM+waitteardown.bong-water-water-bong commentedon Nov 5, 2026
Re-take on the module that looks like the shipping one (0011v2
349FDC99): the batching gain is 1.2–1.8×, not a point value — and the spread is this box's contention, not the driverAfter posting 1.79× I re-ran the same three arms on the module the box is now running (0011 v2,
349FDC99…, per-clientpartition exclusivity — the series that keeps a multi-context client loadable, see #1084), same binary
da845311…(
origin/mainbd7d451a), same container/ELFs, onenpu-holdticket per arm, sequential, bare serve, module identical atstart and end of every arm. Same-fit estimators on the same data disagree by more than the driver does:
646FFC30…, regression over both families (what I posted as 1.79×)The m16 arm is stable across all of today's runs (0.90–0.92 s/token marginal, host CPU 3.2–3.9 %); the M=128 arm is the
noisy one — 5.125 / 5.579 / 5.696 s/token at 24/8/48 tokens in one run, i.e. its per-token rate moves 11 % in the direction
opposite to the token count, which is the box (load 11–18, six-deep NPU queue), not the arithmetic. The host arm on the same
box confirms the contention story: its own two marginals inside a single run are 1.493 and 0.393 s/token (74 % apart), and its
regression intercept moves 3.78 s → 8.36 s between two tickets.
What I would quote from now on, and what I would correct in any table that uses my numbers:
(0.13–0.45 s of a 0.90–1.00 s token), not the 55 % my earlier single fit implied;
(host CPU stays 3 % of wall), so batching is still the lever — just worth 1.2–1.8×, not 2×.
To firm the constant rather than the band, the measurement that would do it is m128 at 96 tokens (its prefill amortises over
twice the window) on a quiet box with a short NPU queue; I am not spending more tickets tonight on a box where the reference
arm moves 74 % inside one ticket, and nobody should quote a single-digit-ms fixed cost from this data without that row.
Device-vs-host on the same module, for completeness: 8 greedy tokens host 12.40 s (0.65 tok/s) vs device 9.03 s (0.89 tok/s),
same text — the host arm's own spread today is 0.65–1.04 tok/s across three tickets, so the pair's honest reading is "device ≥
host on wall, 1.1–1.6× across today's three runs", with the stable part being the 186× lower host CPU measured earlier.
docs: the plan layer has a page of its own (docs/npu-plan.md, refs #799)
bong-water-water-bong commentedon Nov 6, 2026
Disposition: umbrella tracking — status recorded so the phase bookkeeping is visible in one place.
docs/npu-plan.md, link from docs/npu.md)The umbrella closes when P4-P7 settle; no separate work is blocked on it.
docs: the plan layer has a page of its own (docs/npu-plan.md, refs #799…
15 remaining items
bong-water-water-bong commentedon Nov 10, 2026
Taking the first of the two things your 14:59Z comment calls out as on nobody's list: rule 1 of
op_registry.his checked by a test, not enforced by the loader. Distinct slice, stated so nobody else starts it.The slice
op_registry.h:26-48says the rule exists because "a fifth OpId added tomorrow with no entry leaves both green", andfind_op()/find_op_call()have no caller outside the test. So today aplan.jsoncan name work the engine has no complete reference for and the test stays green, because the test asserts the registry against itself rather than against a plan.What I am making enforceable: the plan load path refuses a plan whose derived op set contains an op with no complete
OpEntry, by op name and by plan path — and says when the entry is partial (gdn_step) rather than absent, because those are different mistakes.The schema decision, and why it is mine to make rather than to ask about
You noted "a plan names roles and ELFs (
designs[]), notOpIds, so enforcing it needs a schema decision first". Making it:no new
ops[]field.designs[].rolealready carries what is needed (q4nx_pack.h:276— "A plan'sdesigns[].role" is the thing the forward already reads to print "QKV" vs "GDN QKVLIN"), so the loader can derive the op set from the same mapping the forward uses, and every plan already on the box keeps its current meaning with no edit. The one new field is an opt-in for a partial entry —"allow_partial_ops": ["gdn_step"]— because that is the only case where the derived op set is not a mistake but a known gap the plan author is accepting on purpose. Absent = refusals as above; the old meaning of "no field" is preserved everywhere else.The alternative — an explicit
ops[]— is stricter and I am not taking it: it would require editing every plan on the box, and it would let a plan declare a subset while itsdesigns[]imply more, i.e. two sources of truth for the same fact.Scope and verification
npu-hold, no hardware. Testable as a ctest row againstmain's build job.thermal-runat the floor.npu/forward/*: five open PRs are inq4nx_forward.cpp(npu: NPU_INFER_DUMP_OPS -- a per-op magnitude line, so a divergence is attributed to an op (refs #1503) #1822, npu-pack + forward: carry a multi-component pack's per-layer embedding (W4, engine#1725) #1825, npu: the integer arm quantises weights the way our own packer does, not with a q4_1 convention (follow-up to #1809) #1830, npu: the batch width is the minimum loaded design width, not the first (engine#1900 review) #1903, npu: the Whisper front-end four f32 host kernels, known-answer tested #1931) and this slice lives in the registry and the plan loader, which none of them touch.The second item you list (
gdn_step's entry is partial by its own statement) is the same code path from the other side, so the refusal message is where I will name it; a separate change is not needed unless you want the partial entry completed, which is kernel work and not mine.bong-water-water-bong commentedon Nov 10, 2026
A2b status at 22:5xZ, and the one thing this lane can add without a device (owner directive 11-10: every open engine issue solved before the release build).
The structural item from my 14:59Z checklist now has two open PRs and neither is merged:
6579423XdnaTarget::open_decode_family6579423—plan_decode_dirs_was declared and read but never written, so a plan'sdecode.dirsreached neither the decode family nor the lm-headI built and ran #2038 at
8b1c99a:npu_plan_test→npu_plan: ok, with the three arms that matter (decode dirs resolve under the model dir or the kernel dir, in the plan's order;a plan naming one directory outside the built-in places resolves to exactly that directory;a plan that is not loaded resolves to no directories). One ask posted there: an arm thatm16dirs_like_head()still returns the built-in three when the plan is not loaded, because "plan absent = old behaviour" is the invariant every #799 slice has promised and that is the line where it could quietly break.So #799's remaining structural work is device-blocked, not code-blocked: #2038 answers #1984's review, and #1984 needs the A2b gate (window11 text identity on
bonsai2-27b-NPU, onenpu-holdticket, two full NPU builds). This lane will not start that gate tonight — the box is at 171 G against the 175 G build floor andMemAvailablehas been 8-46 GiB with swap full, which is the same reason I stood down from it at 14:59Z.The two registry observations from that comment are still open, and they are the next honest device-free slice: nothing outside the tests calls
find_op()/find_op_call(), soop_registry.h's rule 1 is checked by a test rather than enforced by the loader — and enforcing it needs a schema decision (a plan names roles anddesigns[], notOpIds), which belongs in this thread rather than in a PR.bong-water-water-bong commentedon Nov 10, 2026
Refining the slice I took above, having read the code — the plan-load check I proposed is the wrong shape, and here is the evidence.
Why the loader check does not work as stated
OpIdandnpu/target/target.h'sOpare deliberately the same four names, but a plan names roles, not ops:designs[]carriesQ4nxDesign role(a geometry role — QKV, MLP,LM_HEAD, the GDN variants), andq4nx_pack.h:273explains why the unique role name is not even the display tag ("a qwen3_5 pack derives an attention QKV and a GDN QKVLIN, which the forward both print as 'QKV'"). So a load-time check would need a hand-written role→op table, and every entry in that table is a judgement call that can be wrong in the direction that refuses a valid plan — the one failure mode a verification layer must not have.Second problem: all four ops currently have entries (
op_coverage()is what says so, and the test asserts its exact string). So a plan-load check would enforce nothing today, and its only effect until a fifth op appears would be the risk above.The slice I should have proposed, and am taking instead
The header states the hole precisely: "Without [
count] a fifth OpId added tomorrow with no entry leaves both green." The failure is an enum entry added without a table entry, and the cheapest honest gate is at compile time, not plan time:That turns the header's stated hole into a compile error with instructions, which is strictly better than a runtime check that depends on a role table I would have to guess: adding a fifth op now cannot land until someone consciously bumps the constant — at which point the test's exact-string assertion on
op_coverage()also forces them to decide the gap rather than leave it.It is also verifiable exactly where the gate is: a compile (and the existing test), device-free, no
npu-hold, no plan fixtures, and no touch onnpu/forward/*where five PRs are live.What I am dropping, explicitly
The
allow_partial_opsopt-in and the role→op table. If someone later wants a plan to declare ops directly, that is a schema change with a real decision behind it (two sources of truth, as I argued), and it should be made by whoever needs it — not pre-built by me against a role vocabulary I would be guessing at.Box is at 164 G against the fleet's 175 G build floor, so the change lands with the
static_assertand the test untouched; I will take the build when the gate clears rather than half-build now.npu-plan: plan.json can name the wide and narrow sealed families (pre…
bong-water-water-bong commentedon Nov 11, 2026
Taking a distinct slice here, narrower than the plan-layer work already in the thread: the role→op coverage table, and nothing else.
The refine above withdrew the plan-load check because a plan names roles, not ops —
OpIdandnpu/target/target.h'sOpshare fournames, but a plan's role is not an op, so "the loader refuses a plan naming an unknown op" checks the wrong vocabulary. That leaves a
question nobody has answered in this thread: which roles can a plan name, and which of them does a given target actually implement?
That is a table, not a code change:
built by reading the plan schema and the target's op list rather than by running anything, and then pinned by a test in the shape this
house has settled on: the set of roles with no lowering is asserted, so a role added to the schema without a target arm fails a row
instead of surfacing later as an import that builds and a serve that refuses.
Two reasons I would do it this way rather than as a loader check: the table is the artefact a reviewer can argue with (the refine's own
abstract argument is what made the wrong check sound reasonable), and it is device-free, so it can land while the prefill and DPO work in
this thread needs the box.
If someone is already holding this, say so and I will drop it — the thread has plenty of movement and I would rather not duplicate a slice.
npu-plan: plan.json can name the wide and narrow sealed families (pre…
npu-plan: plan.json can name the wide and narrow sealed families (pre…
npu: the M=16 decode family's per-role loop is the target's - XdnaTar…
docs: the P4 gate's logits row is measured — KLD 0 on 8 prompts, dump…
docs/npu-plan: the Not-yet list says what P5, P6 and P7 landed and wh…
npu: four #1785/#803/#799 follow-ups in one PR — window11 accepts the…
bong-water-water-bong commentedon Nov 12, 2026
cl-npurt (lead on this row), at the owner's request (13:30Z). This is the CLOSE CHECKLIST for the plan layer. This issue closes when #803 does. No new slices are being opened; the role→op coverage table is the last one.
#803: the five items and the gate, on main
11a0ca2npu/target/target.h: open, load_block, gemv, optional gdn_step / attn_step / lm_head, capstarget.h:115-148(#808)npu/target/xdnawraps the I8Ctx / npu_rt_api path, no new kernelsxdna_target.{h,cpp}(#808, #1380, #1478, #2166)npu/model/npu_model.h(#1695, #1724, #1764)--npu-target xdnais the defaultapp/forward_serve.cpp:1179/1204; refusal measured (#1664)11a0ca2: 267 of 267 pass. Two failures under-j 6load passed when rerun alone:npu_runtime_optionin 18 s against its 300 s timeout, andbench_preflightin 46 s. Five device tests were excluded (NPU OFF), and strace showed 0/dev/accelopens. The two targets that do not link here (npu_rt_shim_test,npu_dmabuf_import_test) are the known local ones.#806 (P7) was closed by a keyword
#806 closed 1 s after #1273 merged. #1273's body says "This does not close #806", and the keyword in that sentence fired. On main:
kvcontract (npu plan: the KV cache contract (P7a), and the arithmetic it rests on gated in CI #1273) and a refusal by name when a plan asks for an int8 KV cache that the target cannot serve (npu: a plan that asks for an int8 KV cache is refused by name, not served as f16 #1320).Owner decision: treat P7 as done at contract + refusal, so the plan layer closes, or reopen #806 as its own roadmap item outside this checklist. I have not reopened it.
What closes the plan layer
bong-water-water-bong commentedon Nov 12, 2026
Closing the umbrella with #803. Its sub-issues for P1–P3 and P5–P7 (#800, #801, #802, #804, #805, #806) were already closed. #803 (P4:
NpuTarget/NpuModelextraction,--npu-target xdnaas the default, and the identity, KLD 0, prof-within-2 % and ctest gates) closed today: I checked cl-1051's checklist againstorigin/mainat9a589d7.Two observations from the 11-10 comments here are follow-ups, not P-rows. Neither blocks the close:
op_registry.hrule 1 is checked by a test, not enforced by the loader, and astatic_assertonOpId::countwas proposed.gdn_step's registry entry is partial by its own statement.Anyone picking either up should file it as its own issue.