Repository navigation
npu: bound every token pick by the tokenizer, not only the grammar's (Fixes #2017) - #2036
Conversation
…ixes #2017) The logits row is the container's vocab_size wide, and vocab_size can exceed the tokenizer: Qwen3-0.6B has 151,936 against 151,669 ids. Only the grammar path masked the ids in between (#1182). The unconstrained sample_token() and npu-run's greedy loop could still pick one, so the reply emitted nothing and never ended. token_bound() caps both picks at the tokenizer's size. sample_token() takes it as `vocab`, and argmax_token() is npu-run's per-row argmax. The head and the verifier's M x NV block are unchanged. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at c3efffe
This is a "Fixes" on a premise that did not hold, and saying so is more valuable than the fix: "The issue's premise does not hold on main: the head's tile padding never reaches the logits block, because c_.NV = cfg.vocab_size. pi-split showed this on the [issue]"
c_.NV = cfg.vocab_size is exactly the invariant the padded-head work established from the other side — the padding exists to make the tile arithmetic work, and the vocabulary's own number is what the sampler bounds. So the reported symptom (a padded row reaching logits) cannot happen on main, and the PR lands the bound that makes the intent explicit rather than emergent.
That is the right form of a defensive fix: it does not change behaviour, it makes the reason the behaviour is safe visible at the point where a future change would break it. The bound belongs to the tokenizer's vocabulary, not only the grammar's, because a grammar bounds tokens by its own rules and would happily admit an id the tokenizer cannot decode.
One ask: since the premise did not hold, record on #2017 that the observed symptom was not reproducible on main and what was actually measured — otherwise the issue closes as "fixed" and the next person to see a padded id in logits starts from a clean slate instead of from this.
|
ready for review at c3efffe. All CI is green: build plus ctest, where |
|
VERDICT: READY at Why that sentence is the reviewThe issue asked for a bound on token picks and assumed the head's padding could reach the logits. That is the shape I would want: fix the code where the invariant is accidental, state that the issue's premise does not hold, and keep the bound as the guard that survives the invariant changing. A fix that silently adopted the premise would have added a redundant clamp and taught the next reader something false about the head. Two things to keep in the body
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at a289fc8: READY
The new head is a merge of main, and the PR's own diff is identical to c3efffe (verdicted READY). A three-way merge onto current main is clean. I checked the content myself at this head:
npu_sampling_test: ok.- The bound's arms can fail. With
token_bound()returning the row width (no bound), the test fails "the bounded argmax picks the best real id", "bounded greedy sample picks the best real id" and "the bound holds with a mask that allows padding". Restored, it's ok. app/forward_serve.cppcompiles at this head (NPU-private: single-file compile with an existing NPU build's command, pointed at this tree).
token_bound(width, vocab) keeps the whole row when there's no tokenizer (vocab <= 0) and never widens past the row. sample_token and the new argmax_token both take their range from it. The premise correction is right too: the head's tile padding never reaches the logits (c_.NV = vocab_size), and the gap that does reach a pick is vocab_size past the tokenizer's ids (151,936 against 151,669 on Qwen3). That's #1182's class, on the two picks that had no bound.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
Fixes #2017, in the form the thread left open. The issue's premise does not hold on
main: the head's tile padding never reaches the logits block, becausec_.NV = cfg.vocab_size. pi-split showed this on the issue and I re-checked it at8e9e871.What does reach a consumer is the same failure one level up. The logits row is
vocab_sizewide, andvocab_sizecan exceed the tokenizer's vocabulary.config.jsonvocab_sizetokenizer.jsonids (max id + 1, added tokens included)The ids in between are #1182's padding positions. Before this PR, only the grammar path masked them (
allowed[i]fori < e.tok->size()). Two picks had no bound:npu::sample_tokenwithout a grammar. This is the served route, atapp/forward_serve.cpp:491.1bit npu-run's per-row greedy loop,v < nvwithnv = all.size()/m. This is the loop the issue names.Change
token_bound(width, vocab)inapp/npu_sampling.hreturns the smaller of the row width and the tokenizer's size. Avocabof 0 or less means there is no tokenizer, and the whole row is kept.sample_token(..., int vocab = 0)draws only from ids below the bound, with or without a mask. forward-serve passese.tok->size().argmax_token(row, width, vocab)is the greedy pick for npu-run. It bounds by the container'stokenizer.jsonwhen one exists.M x NVblock are unchanged.logprobs_ofstill reports the raw distribution, which is its documented contract.docs/npu.md: one paragraph next to npu: a grammar-constrained reply can never stop - the mask drops EOS, so every json_schema/json_object/tool reply pads to max_tokens and reports finish_reason 'length' #1182's.Proof
tests/npu_sampling_test.cpp::test_vocab_boundruns the issue's own gate. A 32,032-wide row has every real logit negative and the padding at exactly0.0. The arms check four things:app/forward_serve.cppcompiles cleanly with-fsyntax-onlyusing an NPU-configured build's flags. My tree's config does not build that file, so CI and a reviewer's NPU build are the full link. There was no device run: the change is host-side id selection only.🤖 Generated with Claude Code