Repository navigation
forward-serve: /v1/completions takes the prompt as token ids, so a route parity run measures the forward - #938
Conversation
…ute parity run measures the forward Root cause of an unmeasurable row, not a bug in the forward. `/v1/completions` built its prompt from a string only, and `app/forward_serve.cpp` prepends BOS on the chat route alone (`if (chat && e.add_bos && e.bos_id >= 0)`), while llama.cpp's `/completion` prepends BOS by default. Two engines on the same weights were therefore fed two different prompts, and the comparison measured the prompt construction: Zyphra/ZR1-1.5B, container weights both sides, 5 raw prompts, greedy 64 tokens prompt tokens forward 10, 5, 7, 24, 13 llama.cpp 11, 6, 8, 25, 14 (exactly one short) those counts equal llama.cpp `/tokenize` with add_special:false on every prompt, i.e. the difference is the BOS token and nothing else; identical continuations 0/5, first divergence at token 0,0,3,0,1, and the reference's chosen token at those points is near-certain (-0.0, -0.0017 logprob), so this was never quantization noise. llama.cpp's `/completion` takes `prompt` as text or as an array of token ids. This adds the id form to `/v1/completions` (app/npu_prompt_ids.h): supplied ids are used exactly, nothing is tokenized and no BOS is inserted on top - the caller is stating the prompt, not asking for it to be derived. Same model, same prompts, ids to both engines: prompt tokens 11, 6, 8, 25, 14 == 11, 6, 8, 25, 14 (identical) byte-identical text 3/5; identical token sequences 2/5, over 64 generated tokens each the 2 real divergences are `.\n\n` (reference, margin 0.39 and 0.32 logprob) against `.\n` (forward, 1.13 and 1.30 under the reference's own distribution): whitespace variants at a near-tie, i.e. the residual is host-GEMM arithmetic, not the prompt. Refusals are 400s that name the position, never a clamped or dropped value: an id outside the vocabulary (the embedding lookup indexes by id), a non-integer element, an empty array, an object. OpenAI's array-of-strings batch form is refused by the same rule. Ollama-style `/v1/completions` callers that send a string are untouched, and so is the chat route (`prompt_is_ids` is false for it). tests/npu_prompt_ids_test.cpp pins the acceptance rules and every refusal, device-free; it passes, and `tools/copyright.py --check` is clean. Measured with ~/lb/zr1-route-compare.py against `1bit forward-serve` (host GEMM) and llama.cpp on the container's own weights.
PR Reviewer Guide 🔍(Review updated until commit 9f78e60)Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY on content at 660cfce67. The CI build and two Analyze jobs are still running at this head, so
the tprs-ready label stays off until they are green; nothing in this review depends on them.
The parser, built and run here (header-only, no CMake, no model, no device — which is itself the claim):
$ g++ -std=c++17 -I app -I npu -I <nlohmann> tests/npu_prompt_ids_test.cpp -o /tmp/npu_prompt_ids_test
$ /tmp/npu_prompt_ids_test
npu_prompt_ids: ok rc=0
The assertions are the interesting ones: a string returns false and leaves the vector untouched; an id array is
kept in order including BOS 151646 (the whole reason for the shape); one id is accepted; missing / empty /
[1,"2"] / [1,2.5] / [1,true] / [1,-1] / [1,151936] / {"prompt":{"a":1}} are each refused naming the
position; 151935 (last id) is accepted; and vocab <= 0 disables the range check.
The wiring, read against the rest of the handler. e.tok->size() is the right bound — int(id_to_text_.size()),
"one past the largest id, added tokens included" (npu/tokenizer.h:48) — so a special token like BOS is legal and an
id equal to the size is refused, which is what the 400s say. Three things I checked because they are where this
shape usually leaks:
- No BOS on the id path, and the chat route is untouched:
if (!prompt_is_ids && chat && e.add_bos …)
(forward_serve.cpp:218),prompt_is_idsis only ever set on the non-chat branch (:161-163). - The context guard still applies to ids: the string heuristic is skipped, but
room = max_context - ids.size()
plusif (room < 1) throw "prompt longer than the context"(:223-224) runs before the lock, so a huge array is
a 400, not a crash. - The prefix cache is keyed on the ids, not on the now-empty
promptstring (e.prefix->lookup(ids)at
:236,insert(ids, …)at:283). Had it been keyed on the text, every id prompt would have collided on""
and a parity run would have been comparing a resumed state — that is the one place I expected to find a bug and
it is correct.
Refusal messages come out as the body's examples (bad request: prompt[1] is not an integer token id,
prompt[0] = 999999999 is outside the vocabulary (0..151664)), since the parser throws inside the try that
prefixes bad request: .
Not verified here, and it is the PR's own evidence: the before/after parity table, the live 400/200 calls over
HTTP, and the "reference's token was near-certain" logprob column. Those need both servers on the container weights
(~/lb/zr1-route-compare.py, forward-serve host-GEMM + llama.cpp with tools/q4nx_to_gguf.py write), and starting
1bit forward-serve on this box can want the NPU device that the owner's Bonsai serve holds — I did not take that
lock for a review. The unit test plus the handler reading are what I can stand behind; the table is the lane's.
Nits, none a gate:
add_dependencies(npu_sampling_test npu_prompt_ids_test)copies the existingci.ymlworkaround with its#635
comment. Fine, but it makes the new test's build depend on the sampling test's target; if that ever changes,
this one silently stops being built too.promptstays empty when the ids path is taken. Nothing reads it downstream today (stats.prompt_tokenscomes
fromids.size()), but any future log or trace that printspromptwould print nothing for an id request.- The docs example is
"prompt": [151646, 785, 6722, 315, 9625, 374]with no note that 151646 is this model's
BOS — a copy-paste against another container is an out-of-vocabulary 400 rather than a silent wrong prompt,
which is the right failure, but a half-line would save the confusion.
|
CI is now green at |
…y on the ids path Both from the review of 660cfce, neither a gate: - the docs example's id array starts with Zyphra/ZR1-1.5B's BOS (151646). Another container's BOS is a different id, and a pasted one lands on the out-of-vocabulary 400 rather than a silently different prompt; the paragraph now says so. - `prompt` is deliberately empty when the request carried ids (nothing reads it: token count, context guard and prefix-cache key all come from `ids`). Stated at the declaration, with the warning that any future request log must handle the case rather than print an empty string.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9f78e6030. This head is comments and docs only (git diff 660cfce67 9f78e6030: +9/−5, two
files, zero statements changed), so the review above carries unchanged — the parser test still builds and runs
standalone (npu_prompt_ids: ok), and the wiring (!prompt_is_ids && chat && e.add_bos, ids path keyed into the
prefix cache, room < 1 for oversized id arrays) is untouched.
Both nits I raised are addressed exactly: the prompt variable now carries a comment at its declaration that it is
empty on the ids path and that nothing reads it (with the "do not log it as the request's text" warning — that was
the point, it is a trap for the next person, not a bug), and docs/npu.md now states that 151646 is
ZR1-1.5B's BOS and that pasting another container's is the out-of-vocabulary 400 rather than a silently
different prompt. Nit 1 (add_dependencies(npu_sampling_test npu_prompt_ids_test)) is still there, which is fine —
it is the same workaround the other device-free tests use.
CI at this head is re-running (mergeStateStatus BLOCKED, build and Analyze queued); the label stays on, and I
will confirm at this head when it settles.
|
Persistent review updated to latest commit 9f78e60 |
|
CI is green at |
…ent ChatML (#963) (#987) app/forward_serve.cpp renders npu::chatml() for every container (#832), so a family whose tokenizer has no <|im_start|>/<|im_end|> is prompted with text its own template never produces and answers 200 anyway: on Zyphra/ZR1-1.5B's tokenizer the same request is 23 prompt tokens against Qwen3's 9, and the marker alone is 6 pieces. chatml_supported() (app/npu_chatml.h) reads the container's own tokenizer once at load; a container without both anchors now gets a 400 on /v1/chat/completions naming its real bos/eos specials and pointing at /v1/completions with token ids (#938), which is how such a container is served correctly. /v1/completions and /v1/systemone are untouched, so the refusal is per request rather than a load failure. Measured, same requests, before and after (host GEMM, npu-hold, amdxdna errors 0): B chat 200 content "<|" -> 400; B streamed 200 SSE -> 400; B completions ids 200 -> 200; Qwen3 chat 200 prompt_tokens 9 -> unchanged. 5 new ctest rows on two real tokenizer fixtures. Necessary condition, not a proof the template is ChatML -- packing chat_template is the real fix and a container-format decision (option 2 of #963), left to the owner. Co-authored-by: pi-npufeat <pi-npufeat@1bit.local>
…order pi-npufeat's review of #1107: packed_grid()'s tie-break was right by loop order, not by design -- with equal least excess a container that admits both (ntr, ntc+1) and (ntr+1, ntc) would decode transposed, silently. Two grids with the same excess are now a refusal, and the grid arithmetic moves out of the forward's static helper into q4nx_dequant.c as q4nx_packed_grid() so the contract is testable: the unit test pins the two real ZR1 geometries (down_proj 1536x8960 -> 1536x9216 columns-padded, gate_proj 8960x1536 -> 9216x1536 rows-padded), an on-grid container (0, no padding), an ambiguous 2-tile grid (0), a grid smaller than the model's (0) and a size no chunk kind divides (0). Re-measured end to end on the same ids prompt (#938), host arm, one ticket per arm: our v2 pack, unpatched 200 'is\nOkay, so I have this' our v2 pack, patched 200 'is\nOkay, so I have this' <- unpadded path untouched ZR1 padded repack, unpatched 500 forward step failed: dequant failed ZR1 padded repack, patched 200 'is\nOkay, so I have this' <- same text as our own pack MLP tensor 256 B short, patched 500 MLP dequant <tensor>: ... chunk 5119 B is not a kind ...
…wn (#1097) (#1107) * npu: read a weight grid the container padded wider than the model's own (engine#1097) The GGUF-on-NPU repack pads intermediate_size up to the converter's 512-column grid: IM 8960 becomes 9216, so a ZR1-1.5B MLP weight holds 1728 tiles where the model's dimensions imply 1680 (28 layers x 3 MLP tensors, measured on the repacked container). Every chunk-kind check still passes -- the chunks are the right kind and the declared bytes are the tiles that kind needs -- and the container loads. Every request then answers 500 "forward step failed: dequant failed" in 6-9 ms, because dequant_any() derives the chunk size from the model's own grid: cb = 8847360/1680 = 5266 B, which is not a chunk kind, so the tensor falls through to the legacy non-chunked decoder. data_size pins the container's grid exactly -- (rows_p/32)*(cols_p/256)*chunk_bytes for one of the three chunk kinds -- so q4nx_dequant_tensor_packed() reads the container's grid and writes only the model's [rows, cols]. Nothing downstream moves: buffers keep their width, scatter_transposed() keeps K as the source row stride, and the design K stays q4nx_pad_k(IM), so no measured device row changes. The grid is taken only when it dominates the model's geometry with a small excess (<= 64 tiles) and one chunk kind divides data_size, so a container that fits no grid still fails instead of decoding garbage. ZR1-1.5B repack (1728 tiles), unpatched: 500 dequant failed in 6-9 ms ZR1-1.5B repack (1728 tiles), patched: 200, and layer-0 down_proj decodes to corr +0.9994 against this lane's own q4nx pack of the same GGUF Qwen3-0.6B repack (384 tiles, on grid): 200 " Paris. ..." on both binaries -- unpadded path untouched our own ZR1 packs, ids prompt: byte-identical text on both binaries ("is\nOkay, so I have this") a container whose MLP tensor is 256 B short: still refused, and now by name: "MLP dequant model.layers.0.mlp.down_proj.weight: data_size 8601344 at geometry 1536x8960 = 1680 tiles (chunk 5119 B is not a kind, and no padded grid fits): the container and config.json disagree" The bare "dequant failed" cost this lane an afternoon, so load_layer()'s deq() names the tensor, its data_size, the geometry it tried, the resulting chunk size and what it means. tests/npu_padded_grid_test.{sh,cpp} pin the contract with synthetic Q8 tiles (no model, no device, no numpy): a padded grid decodes to exactly the tight grid's values at the container's stride, an equal grid is the shipped decoder, and a smaller grid / short buffer / unknown chunk / off-grid geometry are refused. Self-compiling like the other layout contracts, so ctest needs no build target and CI cannot report "Not Run"; the CI target list is unchanged (a lane cannot edit the workflow). * npu: refuse an ambiguous padded grid instead of resolving it by loop order pi-npufeat's review of #1107: packed_grid()'s tie-break was right by loop order, not by design -- with equal least excess a container that admits both (ntr, ntc+1) and (ntr+1, ntc) would decode transposed, silently. Two grids with the same excess are now a refusal, and the grid arithmetic moves out of the forward's static helper into q4nx_dequant.c as q4nx_packed_grid() so the contract is testable: the unit test pins the two real ZR1 geometries (down_proj 1536x8960 -> 1536x9216 columns-padded, gate_proj 8960x1536 -> 9216x1536 rows-padded), an on-grid container (0, no padding), an ambiguous 2-tile grid (0), a grid smaller than the model's (0) and a size no chunk kind divides (0). Re-measured end to end on the same ids prompt (#938), host arm, one ticket per arm: our v2 pack, unpatched 200 'is\nOkay, so I have this' our v2 pack, patched 200 'is\nOkay, so I have this' <- unpadded path untouched ZR1 padded repack, unpatched 500 forward step failed: dequant failed ZR1 padded repack, patched 200 'is\nOkay, so I have this' <- same text as our own pack MLP tensor 256 B short, patched 500 MLP dequant <tensor>: ... chunk 5119 B is not a kind ... --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…s not a #938 parity row Review nit fixed where it bites: bench_npu.sh whitelists bench flags and rejected --endpoint before it touched the device (exit 2, '"*)' arm), so the new switch was reachable only by calling the python directly. The usage block gains the example. Also records in --endpoint's help the reviewer's note: text is tokenized server-side, so a completions row is not #938 parity -- that needs ids, which the gate (c) scripts send and this harness cannot without a tokenizer.
…disabled (#1140) * onnx: bench_npu_serve.py can measure a container whose chat route is disabled The harness that reproduces AMD's model_benchmark layout drove /v1/chat/completions only, so a container with no ChatML tokens -- which the chat route refuses by design (#963) -- had no AMD-unit number at all. --endpoint completions sends the prompt to /v1/completions, which renders no template (#938) and reports the same usage/timings. Default stays chat, so earlier rows stand. Also: count a chunk's `text` in stream mode (the completions chunk shape), and report a refusing server's body instead of raising HTTPError, since that body is what names the route to use. closes #1125 * onnx: the wrapper forwards --endpoint, and the help says a text row is not a #938 parity row Review nit fixed where it bites: bench_npu.sh whitelists bench flags and rejected --endpoint before it touched the device (exit 2, '"*)' arm), so the new switch was reachable only by calling the python directly. The usage block gains the example. Also records in --endpoint's help the reviewer's note: text is tokenized server-side, so a completions row is not #938 parity -- that needs ids, which the gate (c) scripts send and this harness cannot without a tokenizer. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
closes #937
Root cause of an unmeasurable row, not a bug in the forward.
/v1/completionsbuilt its prompt from a string only, andapp/forward_serve.cppprepends BOS on the chat route alone (if (chat && e.add_bos && e.bos_id >= 0)), while llama.cpp's/completionprepends BOS by default. Two engines on the same weights were therefore fed two different prompts, and the comparison measured the prompt construction:The forward's counts equal llama.cpp
/tokenizewithadd_special:falseon every prompt, i.e. the difference is the BOS token and nothing else — never quantization noise.Change. llama.cpp's
/completiontakespromptas text or as an array of token ids; this adds the id form to/v1/completions(app/npu_prompt_ids.h). Supplied ids are used exactly, nothing is tokenized and no BOS is inserted on top: the caller is stating the prompt, not asking for it to be derived.After — same model, same prompts, ids to both engines:
.\n\n(reference, margin 0.39 and 0.32 logprob) against.\n(forward, 1.13 / 1.30 under the reference's own distribution): whitespace variants at a near-tie, i.e. the residual is host-GEMM arithmetic, not the promptRefusals are 400s that name the position, never a clamped or dropped value — checked over HTTP on the running server:
An out-of-vocabulary id is refused because the embedding lookup indexes by id. OpenAI's array-of-strings batch form is refused by the same rule. String prompts and the chat route are untouched (
prompt_is_idsis false for the chat route, so its BOS behaviour is unchanged).Tests.
tests/npu_prompt_ids_test.cpppins the acceptance rules and every refusal, device-free and model-free;npu_prompt_ids: ok, andtools/copyright.py --checkis clean. Measured with~/lb/zr1-route-compare.pyagainst1bit forward-serve(host GEMM) and llama.cpp on the container's own weights (tools/q4nx_to_gguf.py write).Not measured here: the device path (ZR1's four ELFs do not exist on this box) and the chat route for a non-ChatML checkpoint, which is the separate template issue.