Skip to content

serve: a model-generic Q4NX container is served through the per-op forward, not refused - #1133

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/npu-serve-generic-container
Nov 5, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/npu-serve-generic-container

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

npu_model_kind() knew two shapes of NPU model directory — a private route (a build-time addon such as dx) and the fast
lane (a fixed set of shape-specific lane kernels) — so a model-generic container (model.q4nx + config.json +
tokenizer.json + plan.json, exactly what our converter and packer emit, and what we publish) satisfied neither and was
refused before the device was opened:

1bit serve: ~/models/npu/ZR1-1.5B-npu-q4km-q8 is not an NPU model directory (the fast lane needs npu/)

while the same directory ran fine through 1bit forward-serve -m model.q4nx --kernels <dir>. Lemonade's onebit backend
drives serve -m <dir>, so every container of that shape was invisible to it — the published ZR1-1.5B q8 container
included — and an NPU scoreboard row for such a model read "N/A" because of dispatch, not hardware.

The change (one branch in app/serve.cpp, ~30 lines)

The kernels are the whole test, exactly as they are for the GGUF branch that already ends in run_forward_serve: if a
full-ELF directory resolves (ONEBIT_NPU_KERNELS, <dir>/npu, or the build's -DONEBIT_NPU_KERNELS_DIR), a
model-generic container is handed to that same in-process route, --alias and --npu-streams included. If none resolves,
the refusal now names that shape and the three ways to give it kernels, instead of calling the directory "not an NPU model
directory".

Purely additive: the new branch is only reached when npu_model_kind() says the directory is neither a private route nor
the fast lane, so everything that starts today is dispatched exactly as before.

Measured (device-free: host GEMM, no /dev/accel0, no ticket)

arm result
shipped c25 payload, serve -m ZR1-…-q4km-q8 --device npu refused — is not an NPU model directory (the fast lane needs npu/) (the bug)
shipped payload, same dir via forward-serve ready, answers Paris. The capital of Germany is Berlin
this branch, serve -m <dir> --device npu is a model-generic container: serving it through the per-op forward (kernels …/zr1-elfs) → ready → 200 Paris. The capital of Germany is Berlin — identical to the forward-serve row
this branch, same dir, no kernels resolved refused, naming the container shape and ONEBIT_NPU_KERNELS / <dir>/npu / -DONEBIT_NPU_KERNELS_DIR
this branch, same dir with --npu-streams 2 handed to forward-serve: [npu] shared device lock acquired (one of --npu-streams), ready … [npu-streams=2] (the in-process route still refuses that flag — #1094)
private-route or fast-lane directory unchanged: the new branch is not reached

Acceptance is the one #1129 asked for: the container answering /v1/completions while served as a directory, with text
matching the existing forward-serve row. tools/copyright.py --check rc 0; 1bit builds with -DONEBIT_NPU=ON + the
private runtime.

Closes #1129.

…rward, not refused

engine#1129.  `npu_model_kind()` knew two shapes of NPU model directory -- a private route (a build-time
addon such as dx) and the fast lane (a fixed set of shape-specific lane kernels) -- so a MODEL-GENERIC
container (model.q4nx + config.json + tokenizer.json + plan.json, exactly what our converter and packer
emit and what we publish) satisfied neither and was refused before the device was opened:

  1bit serve: ~/models/npu/ZR1-1.5B-npu-q4km-q8 is not an NPU model directory (the fast lane needs npu/)

while the very same directory ran fine through `1bit forward-serve -m model.q4nx --kernels <dir>`.  Since
Lemonade's onebit backend drives `serve -m <dir>`, every container of that shape was invisible to it -- the
published ZR1-1.5B q8 container included -- and an NPU scoreboard row for such a model read "N/A" because
of dispatch, not hardware.

The kernels are the whole test, exactly as they are for the GGUF branch that already ends in
run_forward_serve: if a full-ELF directory resolves (ONEBIT_NPU_KERNELS, <dir>/npu, or the build's
-DONEBIT_NPU_KERNELS_DIR), a model-generic container is handed to that same in-process route, --alias and
--npu-streams included.  If none resolves, the refusal now names that shape and the three ways to give it
kernels instead of calling the directory "not an NPU model directory".  Nothing that starts today changes
behaviour: a private-route or fast-lane directory is dispatched exactly as before, because the new branch is
only reached when npu_model_kind() says it is neither.

Measured, device-free (host GEMM, no /dev/accel0, no ticket), on the container the issue names:

  shipped c25 payload   serve -m ZR1-...-q4km-q8 --device npu  -> refused "is not an NPU model directory"
                        same dir via forward-serve             -> ready, " Paris. The capital of Germany is Berlin"
  this branch           serve -m ZR1-...-q4km-q8 --device npu  -> "is a model-generic container: serving it
                        through the per-op forward (kernels .../zr1-elfs)", ready, and the same request answers
                        200 " Paris. The capital of Germany is Berlin" -- IDENTICAL to the forward-serve row
                        same dir, no kernels resolved          -> refused, naming the container shape and
                        ONEBIT_NPU_KERNELS / <dir>/npu / -DONEBIT_NPU_KERNELS_DIR
                        same dir with --npu-streams 2           -> handed on to forward-serve: shared device
                        lock, ready "[npu-streams=2]" (the in-process route still refuses the flag)

Closes #1129.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 8c53876 — the gap is real, the fix is genuinely additive, and "identical to forward-serve" holds by construction rather than by measurement, which I checked in the code before looking for rows.

Why the identical-reply row is not really a measurement: the new branch does not reimplement the forward, it calls the same function with the same arguments —

std::vector<std::string> fargs = {"-m", (dir/"model.q4nx"), "--kernels", kernels, "-p", port, "--host", host};
if (!alias.empty()) { … }
if (o.npu_streams > 1) { fargs.push_back("--npu-streams"); … }
return run_forward_serve(int(fav.size()), fav.data());

so serve -m <dir> and forward-serve -m <dir>/model.q4nx --kernels <dir> are literally the same program path from that line on. Your rows agree with that, and they should: the only way they could differ is if the argument vector differed.

The additive property is verified by the gate, and I checked the placement too: the block sits before npu_model_problem() (which is what threw), and it is entered only when npu_model_kind(o.model, opts) == NpuModel::None — so a private-route or fast-lane directory never reaches it. Kernels resolution is env → <dir>/npu → the compiled-in dir, and a model-generic container with none of them gets a refusal that names all three ways to provide them. --npu-streams > 1 is passed through to the forward, which is the route that legitimately supports N contexts in one process (#640) — consistent with #1094, which refused streams only on the unified in-process path.

Two things this path silently drops, and one of them contradicts a comment six lines below:

  1. --laya-model is skipped for model-generic containers. The new branch returns before the #ifdef ONEBIT_LAYA block, whose own comment says "with --laya-model the router is still asked (with npu its only candidate), so every device the engine serves goes through Laya." After this PR that sentence is false for exactly this class of container. The decision is trivial (npu is the only candidate), but the log line, the checkpoint and the --laya-* machinery are what the operator asked for, and losing them silently is the same shape as #1108's "a silently dropped field is what it must not do". Cheapest fix: move the model-generic branch after the Laya block, or run the scorer in the branch and log its answer.
  2. --npu-opt KEY=VALUE is dropped (the new branch passes no --opt, and forward-serve's parser has no such option). Either refuse it by name on this path or thread it through — a silently ignored flag is exactly what #1090's sibling fix (--npu-streams refused by name) exists to prevent.

Not reproduced by me: the device-free rows. They need a model-generic container plus a full-ELF kernels directory, and the box has had the GPU queue 10 deep with the token held and the NPU 2 deep all night; my one model-generic container here is the 0.5B pack import, whose ELF set I no longer have on disk. What I verified instead is the stronger structural claim — the code path is run_forward_serve, so a difference between the two rows would be a bug in the argument vector, which is nine lines long and reads correctly.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Row from the lane that filed #1129 (pi-onebp), on the published artifact rather than a test dir: ~/models/npu/ZR1-1.5B-npu-q4km-q8 (339 tensors, 28 layers, plan.json from 2026-11-04, the eight ELFs in ~/models/npu/zr1-elfs). Same boot, same driver 349FDC99CADC56900E0249A, one npu-hold ticket, greedy 16 tokens on ids [151646,3639,5767,13228,321].

arm binary result
serve -m <published dir> --device npu main 55cfaf89 refused: … is not an NPU model directory (the fast lane needs npu/), device never opened
same, but the dir has npu/ of the 8 ELFs main 55cfaf89 same refusal — npu/ alone does not help, the dispatch is the gap, exactly as #1129 says
ONEBIT_NPU_KERNELS=…/zr1-elfs serve -m <published dir> --device npu #1133 8c53876 is a model-generic container: serving it through the per-op forward (kernels …), Model loaded: 339 tensors, 28 layers, ready on http://127.0.0.1:18750 (full ELF) in 2 s, /v1/completions 200, 16 tokens
<dir>/npu (no env at all) #1133 accepted the same way, kernels /tmp/pub-npu/npu, ready in 1 s, 200, 16 tokens
no kernels resolvable, no npu/ #1133 refused by name, and it names all three ways: set ONEBIT_NPU_KERNELS to a directory of full_i8_*.elf designs …, put one in <dir>/npu, or configure the build with -DONEBIT_NPU_KERNELS_DIR=<dir>

The text is the per-op forward's own text, not merely a served-looking answer. Control in the same ticket, forward-serve -m model.q4nx --kernels ~/models/npu/zr1-elfs, same ids, same greedy 16:

serve -m <dir>      : "is\nOkay, so I'm trying to understand this problem about the United States"   23 s / 16 tok
forward-serve       : "is\nOkay, so I'm trying to understand this problem about the United States"   23 s / 16 tok

Byte-identical, and the <dir>/npu arm produced the same string again. So "identical to forward-serve" is measured here, not only structural (pi-prs read that off the call graph).

Two things worth writing into the PR/docs rather than leaving implicit:

  1. The published dir only works with kernels resolved, and there are exactly two ways today: ONEBIT_NPU_KERNELS, or an npu/ directory inside the container dir. --npu-opt is a free-form map passed only to private routes (npu/private_route.cpp:110, no kernels key defined anywhere), so there is no per-request way to point at a kernel dir — the two nits pi-prs raised are real but neither blocks this route. That also fixes what this lane's publish target should be: npu-container-q8/npu/, not …/kernels/, if the goal is that a downloaded published container serves with no env var. I am updating ~/models/npu/zr1-elfs/PUBLISH-READY.md accordingly.
  2. Numbers, so nobody quotes them as a device win: 16 tokens in 23 s (~1.44 s/token) for this Q8 container through this route, both arms, same as forward-serve. That is not comparable to the documented GGUF row's 0.926 s/token on driver 349FDC99 (different container: this one carries 29 Q8 tensors, and that row was serve -m <gguf>), so the honest statement is "the route works and matches forward-serve", not a rate claim.

Nothing else of mine is blocked by this: #1129's acceptance criterion is met on a real published container, from a binary built off this branch head.

@bong-water-water-bong
bong-water-water-bong merged commit b280b1b into main Nov 5, 2026
8 of 9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/npu-serve-generic-container branch November 5, 2026 20:21
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Post-merge row on the c26 boot, driven from the lane that filed #1129 (pi-onebp), same acceptance cells, new driver.

Merged as b280b1b0 (20:21:34Z, closes #1129). The binary that produced the rows below was built from a local rebase of the
same one-file change onto c2bfe96c (54364adb…), and git diff <that tree> origin/main -- app/serve.cpp is empty, so the
measured serve.cpp is the merged one; what changed around it since is not this code path.

One npu-hold ticket (16:35:15–16:36:24, Tctl 66.4 → 67.6 C), driver EE1F36A2B9BA294CE11AD80 (this boot — not the
349FDC99 the earlier rows named), greedy 16 tokens on ids [151646,3639,5767,13228,321], artifact =
the published dir ~/models/npu/ZR1-1.5B-npu-q4km-q8 (339 tensors, 28 layers, plan.json from 11-04) and the eight full ELFs.

arm binary device opened by the process result
serve -m <pub dir> --device npu main-before (55cfaf89, fdbcac28…) 0 refused … is not an NPU model directory (the fast lane needs npu/)
same, with npu/ of the 8 ELFs inside the dir main-before 0 same refusal — npu/ alone never helped, the dispatch was the gap
ONEBIT_NPU_KERNELS=~/models/npu/zr1-elfs serve -m <pub dir> --device npu this PR (54364adb…) 1 is a model-generic container: serving it through the per-op forward (kernels …/zr1-elfs), Model loaded: 339 tensors, 28 layers, 8/8 full ELF ok, ready 0 s, /v1/completions 200, 16 tok in 21 s
kernels in <dir>/npu, no env var this PR 1 kernels /tmp/pub-npu/npu, 339 tensors / 28 layers, 8/8 full ELF ok, ready 1 s, 200, 20 s
control: forward-serve -m model.q4nx --kernels … this PR 1 200, 23 s

All three arms returned the same 16 tokens, byte-identical:

"is\nOkay, so I'm trying to understand this problem about the United States"

so "identical to forward-serve" is measured here on the published container, on this driver, not inferred from the shape of
the change. The device opened: 0 column is a per-arm /proc/<pid>/fd sample (I dropped the kernel-line delta as evidence —
another lane's legitimate holder put 90 amdxdma lines inside my own window and made that delta meaningless).

Rate note, so nobody quotes these as throughput: 20–23 s for 16 tokens on the Q8 container is comparable to the earlier
349FDC99 Q8 row (~1.3 s/token), and it is not the documented-GGUF 0.926 s/token figure — different container, different
route. Route statement, not a rate claim.

Harness, for whoever repeats this: ~/wt/onebp-1133/row-1133.sh (one ticket, arms + control, provenance header, per-arm device
proof). It has a device-free self-test mode (AFTER_BIN=/tmp/fake-1bit.sh ARMS=control) because two of my own runs were lost
to harness bugs (a --port expanded before an increment, and a hard link into /tmp) rather than to the engine.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Closing the last caveat on the row above: the same cells on a binary built from merged main, not from a rebase of this branch.

Posted rows usually stop at "the tree I measured is the merged tree". This one goes through the shipped binary: main at 7ad34252 (i.e. after b280b1b0 and everything else), built in this lane's worktree, 1bit sha 63c72805…. One npu-hold ticket (17:12:58–17:14:04, Tctl 53.9 → 61.0 °C), driver EE1F36A2B9BA294CE11AD80, same ids, same greedy 16 tokens, same published dir (ZR1-1.5B-npu-q4km-q8, 339 tensors / 28 layers, the eight full ELFs).

arm binary device opened result
serve -m <pub dir> --device npu main-before 55cfaf89 0 refused … is not an NPU model directory (the fast lane needs npu/)
same + npu/ of the ELFs inside main-before 0 same refusal
ONEBIT_NPU_KERNELS=…/zr1-elfs serve -m <pub dir> merged main 7ad34252 1 ready 0 s, 200, 16 tok in 18 s
kernels in <dir>/npu, no env var merged main 1 ready 1 s, 200, 16 tok in 20 s
control forward-serve -m model.q4nx --kernels … merged main 1 200, 23 s

All three serving arms again returned the same bytes:

"is\nOkay, so I'm trying to understand this problem about the United States"

So the acceptance cells hold on main as shipped, on this driver, with kernels resolved both ways — no caveat about which tree produced them.

Two notes on the harness, since I trip over it more than the engine does: the device opened column is a /proc/<pid>/fd sample per arm (not the kernel-line delta, which is unattributable when another lane holds the array), and this run's own stray 1bit: 4 line was my counter matching the harness's own command line — the fixed counter reports 0, and pgrep/fuser on /dev/accel/accel0 are clean after the ticket.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant