Skip to content

npu: bound every token pick by the tokenizer, not only the grammar's (Fixes #2017) - #2036

Merged
bong-water-water-bong merged 2 commits into
mainfrom
cl1077/2017-vocab-bound
Nov 10, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
cl1077/2017-vocab-bound

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

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, because c_.NV = cfg.vocab_size. pi-split showed this on the issue and I re-checked it at 8e9e871.

What does reach a consumer is the same failure one level up. The logits row is vocab_size wide, and vocab_size can exceed the tokenizer's vocabulary.

container config.json vocab_size tokenizer.json ids (max id + 1, added tokens included)
Qwen3-0.6B 151,936 151,669
Qwen3-1.7B 151,936 151,669

The ids in between are #1182's padding positions. Before this PR, only the grammar path masked them (allowed[i] for i < e.tok->size()). Two picks had no bound:

  1. npu::sample_token without a grammar. This is the served route, at app/forward_serve.cpp:491.
  2. 1bit npu-run's per-row greedy loop, v < nv with nv = all.size()/m. This is the loop the issue names.

Change

Proof

tests/npu_sampling_test.cpp::test_vocab_bound runs the issue's own gate. A 32,032-wide row has every real logit negative and the padding at exactly 0.0. The arms check four things:

  • The unbounded argmax and the unbounded greedy sample land on a padding id. These are the controls, and they show the arm can fail.
  • The bounded argmax and the bounded greedy sample choose the best real id (123).
  • 200 draws at temperature 1 never reach a padding id.
  • A mask that allows padding is still bounded.
$ ninja -C build npu_sampling_test && ./build/npu_sampling_test
npu_sampling: ok

app/forward_serve.cpp compiles cleanly with -fsyntax-only using 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

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

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at c3efffe. All CI is green: build plus ctest, where npu_sampling passes with the new test_vocab_bound arms, and every CodeQL analysis. CI does not compile app/forward_serve.cpp, because it has no NPU runtime. So that file's evidence is the -fsyntax-only run against an NPU-configured build's flags, described in the body. A reviewer with an NPU build tree can confirm the full link.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at c3efffe — and it is the good kind: the fix refutes the issue's premise with a fact about main.

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, because `c_.NV = cfg.vocab_size`.

Why that sentence is the review

The issue asked for a bound on token picks and assumed the head's padding could reach the logits. c_.NV = cfg.vocab_size says the logits block is already sized by the declared vocabulary, so the padding stops at the tile boundary and never becomes a selectable token on this path. If that is measured (and the phrase "pi-split showed this on the issue" says it was), then the defect the issue describes is not reachable — and the honest fix is to bound the picker anyway, precisely because the invariant that protects it today is one assignment away from changing.

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 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 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.cpp compiles 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.

@bong-water-water-bong
bong-water-water-bong merged commit e35ee7f into main Nov 10, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the cl1077/2017-vocab-bound branch November 10, 2026 23:12
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

2017 - Partially compliant

Compliant requirements:

  • The fix ensures that token picking is bounded by the tokenizer's vocabulary size
  • Both sample_token and argmax_token functions now respect the tokenizer's vocabulary limit
  • The greedy selection path no longer picks padding positions outside the tokenizer's vocabulary
  • The change addresses the core issue described in the ticket where padding positions were being selected

Non-compliant requirements:

  • The PR does not explicitly mention or verify the specific hardware measurements or performance claims made in the ticket

Requires further human verification:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Token Bound Logic

The introduction of token_bound function and its usage in sample_token and argmax_token functions correctly bounds token picking by the tokenizer's vocabulary size. However, the implementation assumes that the tokenizer size is always less than or equal to the logits width, which may not always be true in all configurations. This could lead to unexpected behavior if a model's vocabulary size is smaller than the tokenizer's vocabulary size.

inline size_t token_bound(size_t width, int vocab) {
    return (vocab > 0 && size_t(vocab) < width) ? size_t(vocab) : width;
}
Tokenizer Size Check

The code checks for the existence of tokenizer.json and sets tok_vocab accordingly. However, it does not validate that the tokenizer's vocabulary size is consistent with the model's configuration or that it properly bounds the logits. This could lead to inconsistencies if the tokenizer file is malformed or if there are discrepancies between the model and tokenizer configurations.

// The tokenizer's size bounds the printed ids (engine#2017); a container without tokenizer.json keeps the whole row.
int tok_vocab = 0;
if (const std::string tj = model_dir + "/tokenizer.json"; std::filesystem::exists(tj))
    tok_vocab = npu::Tokenizer(tj).size();

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

npu: the head's logits width is the PADDED width (32,032 for a 32,016 vocabulary), and one consumer trusts it unbounded

1 participant