Repository navigation
serve: logprobs normalise over the tokenizer's bound, not the head's padded width (Fixes #2272) - #2273
Conversation
…padded width (Fixes #2272) A padded head stores rows past the vocabulary (engine#2026: Qwen3 has 151,936 rows against 151,669 ids), and those rows are not tokens. logprobs_of() normalised over every element of the row it was given, so every reported logprob was divided by a partition function that included the padding's mass -- a model-dependent shift, not a constant, and invisible to any comparison between two paths that share the helper. sample_token() already bounded itself with token_bound(); this makes the logprob report do the same, including the top_logprobs candidate list, which could otherwise offer an id the tokenizer does not have. Measured: with the bound ignored (a mutant), two pre-existing arms and both new ones fail -- FAIL bounded greedy sample picks the best real id FAIL the bound holds with a mask that allows padding FAIL a padded row: the logprob is the vocabulary-only softmax, not the padding's FAIL a padded row: no id at or past the vocabulary appears in top_logprobs and with it: npu_sampling: ok. The new arms compute the expected value from a vocabulary-only softmax built in the test, not through the helper, so they cannot agree with a wrong helper by construction.
PR Reviewer Guide 🔍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 at 1adceaf: READY
Reviewed at 1adceaf5746604ac0429457df2e93259381f9590. It is on main (0 behind) and merges clean. The diff is 3 files, +40/-7.
Checked
- The bounded overload is consistent.
logprobs_of(logits, vocab, chosen, top_n)appliestoken_bound()to every place a padded row could leak in: themax_lscan, the normaliser, thechosenrange check and thetop_logprobscandidates.vocab <= 0falls back to the full width. - The one call site is fixed. Main's only call (
forward_serve.cpp:562) now passese.tok->size(), the boundsample_token()already gets. - Tests.
npu_sampling_testreports ok in anONEBIT_NPUhost tree, andtools/copyright.py --checkreturns rc 0. - Mutant. I normalised over
logits.size()again, the bug itself. It fails "a padded row: the logprob is the vocabulary-only softmax, not the padding's". Restored, and the test passes again.
Note for whoever lands second of this PR and #2266/#2267 (not blocking here). The body says this fix "covers both paths". On main that is true: there is one call site. But #2267, still open on #2266's branch, adds a pointer overload, logprobs_of(const float* row, size_t width, int chosen, int top_n), and calls it with logits.size(), the head's full width. Its second argument is the width, where this PR's second argument is the vocabulary bound.
So after both land, the speculative path's logprobs still normalise over the padding unless the merge gives the pointer form the same bound (e.tok->size()). The two branches already conflict in app/npu_sampling.h and app/forward_serve.cpp (git merge-tree pr2273 pr2267), so this will surface as a hand resolution.
A safer shape is a single pointer overload taking (row, width, vocab, chosen, top_n). Then a caller cannot pass the width where the bound belongs.
My READY on #2267 at d56e13a said its speculative logprobs equal the plain loop's. They do, but both were normalised over the padding, which is this issue. I missed that both paths share the defect.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 270".
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 1adceaf
Built and ran it: npu_sampling: ok -- and the defensive shape of token_bound() is what I checked first, because a bound is where an off-by-one becomes a wrong answer rather than a crash:
inline size_t token_bound(size_t width, int vocab) {
return (vocab > 0 && size_t(vocab) < width) ? size_t(vocab) : width;
}It clamps in the safe direction only: a vocab larger than the row leaves the width alone (the caller's row is the authority), a vocab of 0 or negative leaves it alone, and only a genuinely narrower bound clamps. So a caller who passes the wrong (too large) vocabulary gets today's behaviour rather than an out-of-bounds read -- the failure mode this must not have.
The two arms are the defect and its visible consequence:
check(std::fabs(bounded.logprob - float(-log_z)) < 1e-6f, …)
check(!padding_reported, "a padded row: no id at or past the vocabulary appears in top_logprobs")
The second is the one worth having: the padding shifts every logprob, and it puts ids the tokenizer does not have into top_logprobs. Only the second is visible to a client; the first is invisible to any comparison between two paths sharing this helper -- which is exactly what the docstring says:
"those rows are not tokens. Normalising over them shifts every reported logprob by the padding's mass, which is model-dependent rather than a constant, and invisible to any comparison between two paths that share this helper"
"Model-dependent rather than a constant, and invisible to any comparison" is the clearest statement of why this class is dangerous that I have read tonight: a constant offset would cancel in a difference; padding mass does not, so the error survives every test that compares two paths.
And the concrete numbers are there: engine#2026's Qwen3 head, 151,936 rows against 151,669 ids -- 267 padding rows, from a defect I reviewed two days ago now showing up as a second symptom in a different layer. That is worth noticing: #2026 fixed the reader refusing a well-formed padded head, and this fixes what happens after it stops refusing.
One note: with n == 0 the function returns out (default). A vocab of 0 now reports a default logprob rather than a refusal -- worth one arm ("vocab 0 reports nothing rather than a logprob of 0"), since 0.0 is a plausible-looking value for a broken call.
Ran: npu_sampling_test. Not run: a live serve's logprobs response (no NPU ticket; the helper is host-side and that is where it is tested).
#2273 (Fixes #2272 on the plain path) and this PR both bound logprobs_of by the tokenizer. Kept the pointer form (the speculative loop's rows) and the vector form with vocab required; dropped main's 3-argument vector form, which would let a padded-head caller skip the cut. Both test blocks kept. Host ctest 275/275 (device tests excluded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #2272. Host-side only — no device, no NPU ticket.
The defect
logprobs_of()normalised over every element of the row it was given:A padded head stores rows past the vocabulary (engine#2026 — Qwen3: 151,936 rows against 151,669 ids), and those rows are not tokens. So the chosen id was always real while the softmax it was divided by included the padding's mass.
sample_token()in the same header already bounds itself withtoken_bound(); only the report did not.Why it hid: the shift is systematic per model rather than random, so it reads as a calibration difference, and every path-vs-path comparison — speculative vs plain, before vs after a refactor — agrees with itself while both are wrong.
The change
logprobs_of(logits, vocab, chosen, top_n)takes the tokenizer's bound and uses the header's owntoken_bound()idiom for the normaliser, themax_lscan and thetop_logprobscandidate list.e.tok->size()— the same boundsample_token()is given three lines away, which is the consistency that was missing.Verified by a mutant, not by reading
With the bound ignored:
and with it,
npu_sampling: ok. The two new arms build their expected value from a vocabulary-only softmax constructed in the test, not through the helper, so they cannot agree with a wrong helper by construction — which is the only kind of check that can catch a defect living in a shared function. The padded fixture gives the padding the largest logits (50.0f), so the shift cannot be mistaken for rounding.Found while reviewing #2267, which passes an explicitly full-width row on the speculative path; that review's CHANGES stands and this fix covers both paths.