Skip to content

forward-serve: /v1/completions takes the prompt as token ids, so a route parity run measures the forward - #938

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/completions-ids
Nov 4, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/completions-ids

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

closes #937

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 forward llama.cpp
prompt tokens 10, 5, 7, 24, 13 11, 6, 8, 25, 14 (exactly one short)
identical continuations 0/5 — first divergence at token 0,0,3,0,1
reference's token at those points — near-certain: −0.0, −0.0017 logprob

The forward's counts equal llama.cpp /tokenize with add_special:false on every prompt, i.e. the difference is the BOS token and nothing else — never quantization noise.

Change. 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.

After — same model, same prompts, ids to both engines:

  • prompt tokens 11, 6, 8, 25, 14 == 11, 6, 8, 25, 14
  • 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 / 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 — checked over HTTP on the running server:

{"prompt":[]}            400 bad request: prompt: an empty token-id array cannot be evaluated
{"prompt":[1,"2"]}       400 bad request: prompt[1] is not an integer token id
{"prompt":[999999999]}   400 bad request: prompt[0] = 999999999 is outside the vocabulary (0..151664)
{"prompt":{"a":1}}       400 bad request: prompt must be a string or an array of token ids
{"prompt":[151646,785]}  200 text_completion, prompt_tokens 2

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_ids is false for the chat route, so its BOS behaviour is unchanged).

Tests. tests/npu_prompt_ids_test.cpp pins the acceptance rules and every refusal, device-free and model-free; npu_prompt_ids: ok, 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 (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.

…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.
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 9f78e60)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

937 - Partially compliant

Compliant requirements:

  • The PR implements support for token IDs in the prompt field for /v1/completions
  • The PR ensures that when token IDs are provided, no tokenization or BOS insertion occurs
  • The PR includes proper validation of token IDs to prevent out-of-vocabulary errors
  • The PR updates documentation to reflect the new capability

Non-compliant requirements:

  • The PR does not include a comprehensive test suite for all edge cases of token ID handling

Requires further human verification:

  • The actual performance and correctness of the implementation on the target hardware (Zyphra/ZR1-1.5B) needs to be verified
  • The parity run results should be confirmed to match the claimed improvements
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Prompt ID Handling Logic

The new logic for handling prompt IDs introduces a conditional path that may be error-prone. Specifically, when prompt_is_ids is true, the prompt variable is not used but the code still references it in the lambda capture, which could lead to confusion or potential issues if the variable is accessed elsewhere in the future. The code should ensure that prompt is only captured in the lambda when it's actually needed.

auto run = [&e, &req, prompt, prompt_ids, prompt_is_ids, max_tokens, chat, &opts, gram, &stats](
               const std::function<bool(const std::string&, const std::vector<Emitted>&)>& on_text) {
    std::string finish = "stop";
    // The tokenizer is const and keeps no per-call state, so it runs before the lock.  A caller
    // that sent ids already stated the positions: they are used exactly, with no BOS inserted
    // on top (inserting one there would re-introduce the one-token prompt difference an id
    // prompt exists to remove).
    std::vector<int> ids = prompt_is_ids ? prompt_ids : e.tok->encode(prompt);
Validation Clarity

The validation logic in prompt_ids_from_body throws exceptions with detailed messages, which is good for debugging. However, the error messages could be more specific about what constitutes a valid token ID range, especially for edge cases like the maximum vocabulary size. Consider adding a note about the expected range to improve clarity for users.

if (id < 0 || (vocab > 0 && id >= vocab))
    throw std::runtime_error("prompt[" + std::to_string(i) + "] = " + std::to_string(id) +
                             " is outside the vocabulary (0.." + std::to_string(vocab - 1) + ")");

@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 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_ids is 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()
    plus if (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 prompt string (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:

  1. add_dependencies(npu_sampling_test npu_prompt_ids_test) copies the existing ci.yml workaround 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.
  2. prompt stays empty when the ids path is taken. Nothing reads it downstream today (stats.prompt_tokens comes
    from ids.size()), but any future log or trace that prints prompt would print nothing for an id request.
  3. 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.

@bong-water-water-bong bong-water-water-bong added the tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied label Nov 4, 2026
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

CI is now green at 660cfce67 (build + Analyze ×5 + CodeQL + pr_agent SUCCESS, hw_gate SKIPPED, mergeStateStatus CLEAN), so the tprs-ready label is on. The VERDICT: READY above stands at this head.

…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 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 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.

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 9f78e60

@bong-water-water-bong
bong-water-water-bong merged commit 54df1d4 into main Nov 4, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/completions-ids branch November 4, 2026 01:27
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

CI is green at 9f78e6030 (build + Analyze ×5 + CodeQL + pr_agent SUCCESS, hw_gate SKIPPED), so the READY above stands at this head and the tprs-ready label is current.

bong-water-water-bong added a commit that referenced this pull request Nov 4, 2026
…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>
bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…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 ...
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…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>
bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…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.
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Review effort 3/5 tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied

Projects

None yet

Development

Successfully merging this pull request may close these issues.

forward-serve: a route parity run against llama.cpp cannot be given token ids, so it measures the prompt (not the forward)

1 participant