Repository navigation
forward-serve: read eos_token_id as a list, so a Llama container stops at <|eot_id|> - #1052
Conversation
…s at <|eot_id|>
A container whose config.json declares eos_token_id as a list (Llama-3.2:
[128001, 128008, 128009]) was served with no stop ids at all: forward-serve read
that key with json_int(), which answers its default for anything that is not an
integer. The serve's stop set was therefore {-1} plus whatever <|endoftext|>
resolved to by name, so the model's own first stop token <|eot_id|> 128009 was
served as text and generation continued into a second invented turn -- correct
text up to the stop, which is what makes it read as a model defect.
npu/model.cpp and npu/pack_import.cpp already parse both forms; only this path
was scalar.
app/npu_stop_tokens.h reads one id or a list and drops unreadable entries (it is
the container's own config; refusing to load over one bad entry is worse than
stopping on what could be read). ForwardEngine::eos becomes a vector<int>, the
stop test becomes membership, and the #963 chatml-refusal message lists every
eos id. tests/npu_stop_tokens_test.cpp pins the parse, including that 128009 is
in the Llama set -- the assertion that fails on the old code.
Closes the eos defect of #1044.
|
Preparing review... |
PR Reviewer Guide 🔍(Review updated until commit 65cdee6)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 at f50f6003 — the defect is real on the actual pack, the fix is the right one, and the new unit test covers the token that used to leak.
Root cause reproduced against the container itself, not against the fixtures — my probe links the PR's own app/npu_stop_tokens.h and re-implements forward_serve.cpp's json_int() semantics:
config: …/oga/Llama-3.2-1B-Instruct_rai_1.8.0_npu_4K/config.json
key is a list: yes
OLD parse json_int(): eos = -1 <- the serve's only eos id
NEW parse eos_token_ids: { 128001 128008 128009 } contains 128009: yes
config: …/oga/Qwen2.5-0.5B-Instruct_rai_1.8.0_npu_4K/config.json
key is a list: no
OLD parse json_int(): eos = 151645
NEW parse eos_token_ids: { 151645 } <- the single-int form is unchanged
So on the Llama-3.2 pack the old code had {-1, <|endoftext|>} and <|eot_id|> 128009 — the token that ends an answer — was not in it. That is exactly the observed finish_reason: "length" plus an invented second turn, and it cannot happen on the Qwen containers, which is why it read as a model defect.
Tests, built here (-DONEBIT_NPU=ON -DONEBIT_NPU_RT_DIR=…/npu_rt -DONEBIT_NPU_RUNTIME=own, my own worktree):
ctest -R "npu_stop_tokens|npu_prompt_ids|npu_chatml" -> 3/3 passed
./npu_stop_tokens_test -> all stop-token checks passed (rc 0)
(The first attempt showed npu_chatml (Not Run) — that was my target list, not the PR: its binary has to be built for ctest to run it; with npu_chatml_test built it is 3/3.)
The test cases match the six the body claims, and case 1 is the one that matters: it asserts 128009 is in the set for the Llama-3.2 list, which is the token the old parse served as text; case 4 drops a non-integer entry (2.5, "128001") rather than failing the load, which is the right call for a container's own config; case 3 (missing key → empty) preserves the old -1 behaviour exactly.
Read the diff too: std::vector<int> eos with a membership test at the stop site, eos_token_ids_from_config(path) mirroring the json_int(cfg, …) call it replaces, and the #963 refusal message now listing every id instead of one. Nothing else in the stop path moved.
Not verified by me: the before/after served text. There is no Llama-3.2 q4nx container on this box (the OGA pack is the Ryzen AI layout, and forward-serve needs -m <model.q4nx>), so reproducing finish_reason: stop end to end would mean an npu-pack import first. The parse defect and the fix are proven above without it; the served text is your run, and the mechanism is the same one I just measured.
|
Persistent review updated to latest commit 65cdee6 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 65cdee6b (rebase-only; the review stands).
65cdee6b is the same change rebased onto a main that has since taken #1045/#1046/#1047/#1050, so a diff between the two heads shows those PRs. The files this PR owns are byte-identical:
git diff f50f6003…65cdee6b -- app/forward_serve.cpp app/npu_stop_tokens.h CMakeLists.txt tests/npu_stop_tokens_test.cpp
(no output)
and pr_agent is the only red on it, which is the standing pattern while coder-llm is off, not a finding.
Everything in my previous verdict holds: the old parse returns -1 on the real Llama-3.2 OGA container while the new one returns {128001, 128008, 128009}, the Qwen single-int container is unchanged at 151645, and npu_stop_tokens + its two neighbours are 3/3 under ctest.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Follow-up: the served-text half is now verified on this box, so the one thing I said I could not check in my verdict is no longer open.
To get a Llama-3.2 container I imported the OGA pack first — which needs #844's importer (without it: refusing to import … model.norm.weight: the .fconst tail envelope is … not an RMSNorm weight). With #844's npu/pack_import.{h,cpp} applied on top of this branch in a scratch tree, the import completes:
fconst: stride 33640640, second header 5922880; 32/32 RMSNorm anchors found, final norm identical in both .fconst tails
attention: 1280/1280 .fconst tiles survive decode->encode byte for byte; 64 projections imported
MLP: 2560/2560 .fconst groups survive decode->encode byte for byte
text: 5 of 8 tokenizer/chat files copied
and forward-serve loads it with NPU_INFER_HOST_GEMM=1 (no device, no npu-hold ticket):
1bit forward-serve: llama32-q4nx ready on http://127.0.0.1:8099 (full ELF)
The chat route is refused here exactly as #963 intends — and the refusal lists every eos id (eos <|end_of_text|> (128001), <|eom_id|> (128008), <|eot_id|> (128009)), which is this PR's own change visible on a real container. So the request goes through /v1/completions with the pack's own chat-formatted ids (128000 … 128009, 128006, 78191 …), 16 tokens:
before (same binary, e.eos = { json_int(cfg, "eos_token_id", -1) } — the old scalar parse):
finish_reason: length
text: "Paris.<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\nThat's incorrect. The capital of France is"
completion_tokens: 16
after (this PR):
finish_reason: stop
text: "Paris."
completion_tokens: 2
That is the failure in the body exactly — the model's own first stop token served as text, then an invented second turn — and it stops cleanly with the fix. Same prompt ids, same build flags, only the parse differs.
So the PR is now verified end to end on a real Llama-3.2 container, not just at the parse. Nothing in my earlier verdict changes; the npu_stop_tokens 3/3 and the container-config parse evidence stand as well.
Root cause
app/forward_serve.cppread the container's stop ids withjson_int(cfg, "eos_token_id", -1), andjson_int()answers its default for anything that is not an integer. HF writes that key either as one integer (Qwen2.5/Qwen3:
151645) or as a list (Llama-3.2:[128001, 128008, 128009]), so on a list-valued container the serve loaded themodel and then had no stop ids at all — its stop set was
{-1}plus whatever<|endoftext|>resolved to by name.The model's own first stop token,
<|eot_id|>128009, was therefore served as text and generation continued into aninvented second turn.
That is the failure mode that reads as a model defect: every position before the stop is correct, so the text is right
and then turns into control tokens. It cost a session on the Llama-3.2-1B OGA pack (engine#1044, defect 1).
npu/model.cppandnpu/pack_import.cppalready parse both forms, and the unified serve readsModel::dims().eos(a vector); only this path was scalar.
Before / after
Same device-free reproducer (
NPU_INFER_HOST_GEMM=1, no NPU ticket, the pack's own prompt ids, 16 tokens), old binaryvs rebuilt:
Change
app/npu_stop_tokens.h(new): the key as one id or as a list, house pattern next toapp/npu_prompt_ids.h.Unreadable entries are dropped rather than fatal — it is the container's own config, and a serve that refuses to
load over one bad entry is worse than one that stops on what it could read.
app/forward_serve.cpp:ForwardEngine::eosis astd::vector<int>, the stop test is membership, and the forward-serve: the chat route renders ChatML for every container, so a non-ChatML model is prompted with text its tokenizer cannot accept (33 vs 15 tokens, measured) #963chatml-refusal message lists every eos id instead of one.
tests/npu_stop_tokens_test.cpp(new) + CMake wiring: the Llama-3.2 list and specifically that 128009 is in theset (the token the old code served as text), the Qwen single-int form unchanged, a missing key, a mixed list with a
non-integer entry, and the file-path form including a missing file.
Verification
ctest -R "npu_stop_tokens|npu_prompt_ids|npu_chatml"→ 3/3 passed (the two neighbours in the same file areunaffected);
npu_stop_tokens_testprintsall stop-token checks passed, and its 128009 assertion is the one thatfails on the old parse.
tools/copyright.py --checkrc 0.Does not close #1044: this is its defect 1 (ours). The issue's defect 2 — the per-prompt coin flips — is separate
and now measured there: on identical context 15 of 16 positions agree per prompt with exactly one flip each, and the
rope_type: "llama3"gap found while measuring it is filed as #1048.