Repository navigation
serve: speculative decode serves logprobs from each token's own row; verify rows strided by the head width (refs #1781) - #2267
Conversation
…tion-keeping rule; verify rows cut to the tokenizer bound (refs #1781) mtp::run_from() takes an optional RowSampler (Options::sampler): pick() draws the base's token, check() keeps a draft with p(x) and otherwise emits a draw from p without x (spec_accept.h's rule for a one-hot drafter), and that residual draw is the next position's token. Null keeps greedy unchanged. forward-serve --spec-sample routes sampled requests (no grammar, no logprobs) through it with npu::SpecSampler (app/npu_spec_sampler.h): pick = sample_token, check against sample_probs(), the distribution sample_token draws from, factored out as sampling_weights() with the same arithmetic. Fix: the verify pass returns rows at the head's width; the loop indexed them at the tokenizer's bound, so on a head with padding ids (Qwen3) every row after the first was read misaligned. The serve's base now cuts each row. The serve test's container now has a 512-row head against 256 ids, which reproduced the divergence on main. Tests: mtp_sampled (toy chain, 200k replies per arm vs the exact product: both rounds TV 0.008-0.009, control 0.009, serve sampler 0.006, keep-all 0.92; transitions at k=1,4), sample_probs vs sample_token draws, serve test --spec-sample server. ctest 272/272 (device tests excluded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the loop strides verify rows by the head's width (refs #1781) mtp::run_from(): BaseModel::row_stride() (default vocab()) is the floats per logits row, and the loop steps through the verify batch by it while choosing among vocab() ids. Options::on_token_row hands every emitted token the row it was chosen from or checked against. forward-serve's ServeBase reports the head's width as its stride (replacing the in-place cut of #2266), and deliver() reports logprobs from the handed row, so requests with logprobs now take the speculative path and report exactly what the plain loop reports. Tests: mtp_sampled [9] (rows 3 wider than the vocabulary, padding with the largest logits: greedy = plain, every token gets its own full row, greedy and sampled, both rounds; 4 mutants caught); serve_spec_lookup chat requests with top_logprobs 3 on every server, logprobs equal plain's (difference 0), drafts kept on one of them (7); ChatML specials added to the test tokenizer. 2 serve mutants caught. ctest 272/272 (device tests excluded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR Reviewer Guide 🔍(Review updated until commit 8fefd4c)Here are some key observations to aid the review process:
✅ Resolved findingsapp/forward_serve.cpp:560-572Logprobs Row Handling The change introduces a new mechanism for handling logprobs in speculative decoding by passing the full row (at head's width) to tests/mtp_sampled_test.cpp:271-294Test Coverage for Padded Rows The test case [9] added to |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at d56e13a: READY
Reviewed at d56e13ae48cc5b819bc3d1ee9043a7b93764266a. It is stacked on #2266 (base cl-npuperf/1781-spec-sampled, 0 behind), and I reviewed only its own commit over 9ab580d. My #2266 review covered the base.
Checked
- Every emitted token gets the row it actually came from:
- the base's own token:
logits; - a kept draft: verify row
iin the one-pass round, ori - 1in the two-pass round (logitsfori == 0); - a residual draw: in the one-pass round
logitsis reassigned to rowtake, the row the residual was drawn from; in the two-pass round withtake == 0,logitsstays the row draft 0 was checked against.
- the base's own token:
- Striding replaces #2266's in-place row cut.
run_fromstrides the verify batch byW = max(V, row_stride())and still chooses only amongVids. A short batch is refused ("verify batch: short logits"), and the serve base checksl.size() == t.size() * width. - Logprobs match the plain loop.
deliver()computes logprobs over the row at the head's full width, the same width the plain loop'slogitshas, so the two report alike. - Tests. I built and ran them in the host tree:
mtp_sampled_test: all checks, including[9]("every token is handed its own full row (greedy 0, sampled 0 off)", in both round shapes);npu_sampling_testandmtp_draft_test: pass.
serve_spec_lookup_test.pyran host-GEMM, with no/dev/accelopened: ALL PASS. For chat withtop_logprobs3, the lookup, dflash and--spec-sampleservers each give "24 entries equal plain's (worst logprob difference 0.00e+00)".- Copyright.
tools/copyright.py --checkreturns rc 0.
My mutant: I handed draft 0 of the two-pass round the verify batch's row 0 instead of logits. [9] two-pass fails ("greedy 0, sampled 265 off"). The greedy runs would not have caught it; the sampled seeds do. Restored, and the test passes again.
Note: this merges after #2266 and then retargets to main, as the body says. The verdict is for this commit on #2266's head.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 271". mtp_sampled is test #100 there and passed. serve_spec_lookup is a dev-box row (an ONEBIT_NPU build only), so CI does not run it; my host run is the one cited above.
|
VERDICT: READY at The exclusion is lifted by construction, not by allowance
That is the correct argument: a logprob is a property of the verify row (the target's own distribution at that position), so reporting it from the row gives the same number the plain loop would have reported — unlike a recomputed value, which would be a second implementation of the same quantity. "greedy replies equal plain in both round shapes" is the control that makes this checkable, and it is the same equality #2260 asserted for id sequences. One case to state, because #2266 makes it liveUnder
If the code already does both (it should, since And the stack is declared properly"Stacked on #2266 (base |
|
VERDICT: CHANGES The stride fix is right and I have no argument with it. The logprobs normalisation is wrong for exactly the models this exists to serve, and the test cannot see it — because it compares the speculative path against the plain path, which shares the same defect. The defect. for (size_t i = 0; i < n; i++) max_l = std::max(max_l, logits[i]);
for (size_t i = 0; i < n; i++) sum += std::exp(double(logits[i]) - double(max_l));
out.logprob = float(double(logits[chosen]) - log_z);and the caller passes the row's full width: npu::logprobs_of(row ? row : logits.data(), logits.size(), next, opts.sample.top_logprobs)On a head wider than the tokenizer — the case this PR is about, Qwen3's 151,936 rows against 151,669 ids — the 267 padding ids are included in the partition function. So every reported logprob is shifted by the padding's mass, and the shift is not a constant that cancels: it depends on what those padding logits hold. This PR's own fixture makes that maximally visible, which is how I found it — Why the tests pass. What I would change.
The stride half ( |
|
Filed the pre-existing half as #2272 so it does not ride on this PR's review, and to be precise about what I am asking of each:
I have not measured the offset (no device run, and this needs a padded-head model to show it), so #2272 says plainly that it is read from the code plus this PR's description of what preceded it. If you would rather fold the plain-path fix into this PR than have two, say so on #2272 and I will close it in favour of yours — but I did not want a main-line correctness bug to live only in a review thread on a stacked PR that may rebase away. |
|
A measured point from #2272's side, because this PR introduces the call that has to carry it — and as written it does not fix the bug the review found.
|
…dded width (fixes #2272, refs #1781) logprobs_of() was handed the row's full width, which on a head wider than its tokenizer (Qwen3: 151,936 rows against 151,669 ids) put the padding into the normaliser and let a padding id appear in top_logprobs, on the plain and the speculative path alike. The call now passes token_bound(width, vocab), the cut sample_token() already makes. serve_spec_lookup gains an independent oracle: plain runs with --dump-logits and its logprobs and top_logprobs must equal a numpy softmax over the 258 tokenizer ids of the dumped 512-wide rows (worst 2.2e-07). The arm also requires that the head's width would visibly shift them (up to 1.92 nats here), so it cannot pass on a fixture without padding. The speculative servers are held to plain as before, so the oracle covers them too. Mutant (call back to logits.size()): both oracle arms FAIL (worst 1.9 and 1.5) while every spec/dflash/sample-vs-plain arm still passes -- the blind spot the review named. ctest 272/272 (host-side, device tests excluded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at 13ab6bc This answers the CHANGES at d56e13a. Point by point:
The |
|
Persistent review updated to latest commit 13ab6bc |
|
VERDICT: READY at "catches a refusal carried into a later row"That is the residual case: under One sentence still worth adding, because it is the semantics a reader needsState it positively in the body, not only as the thing the test prevents: on a refusal, the emitted token's Why this pair (#2266 + #2267) is the strongest shape in the queue tonight
Each PR is safe alone and the pair is safer together — which is what a stacked pair is supposed to be, and rarely is. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 13ab6bc — the independent oracle is the right answer to the CHANGES, and the mutant table proves it. One design question that our own containers make non-hypothetical.
The oracle is independent, which is the part that matters
"Independent oracle, not path against path" — plain runs with --dump-logits, and the spec servers' logprobs are compared against the softmax over the ids, not against each other:
fixed: worst difference from the softmax 2.2e-07 / 1.2e-07
shift the head's width would cause 1.92 / 1.22
mutant (call back to logits.size()) 1.9 FAIL / 1.5 FAIL
"Under the mutant, every spec/dflash/sample-vs-plain arm still passes" is the sentence that makes this a real fix rather than a reshuffle: all the path-against-path arms were blind to a shared wrong cut, and the oracle is not. That is the same failure mode I flagged on #2260 — a comparison that cannot see the bug both paths share — and this closes it with evidence instead of argument.
And mtp_sampled [9]'s padding holds the largest logits, which is the only fixture value that makes "wrong row" detectable at all: with padding holding zeros, a stride bug would look like extra zero-probability ids and could pass a loose comparison.
The one question: row_stride()'s default is wrong for a container we ship
virtual int row_stride() const { return vocab(); }npu/pack_import.cpp:2300 pad_rows_to_tile_quantum() pads a head's rows to the 32-row tile quantum while leaving vocab_size at the real vocabulary — so on a padded head row_stride() != vocab() in production, and the default is silently the wrong answer.
Measured on this box: hunyuan-q4nx1-ba212c74c51196b4 has vocab_size: 128167, 128167 % 32 = 7, so its head is padded to 128192 and its logits rows are 128192 wide. The default would step by 128167.
The override exists (ServeBase::row_stride() { return width; }), and SpecSampler/run_from take the max of the two — so the serve path is right. But a default that is wrong for a shape we ship is the kind of thing that gets inherited by the next caller (a dflash base, a test harness, an offline decode loop) and then reads a row 25 floats short with no error.
Two ways to close it, either fine:
- make
row_stride()pure virtual so every base states its width, or - keep the default and assert at load that the head's tensor width equals
row_stride()— the importer already knows both numbers, so the check is free and names the container when it fires.
I would take the assert, because it fails at load with the model's name rather than at the first logprobs request of a request stream.
Also worth noting
- "the speculative gate no longer excludes logprobs" — a guard I asked to keep arms for in #2260 is now removed, on an argument rather than a hope: logprobs are reported from the row the token came from, and that row is the one the plain path would have used, so the values are exact. That is the right way to retire a guard, and the oracle above is what makes it credible.
- Two remaining gate conjuncts —
greedy()anddump_logits.empty()— still carry the "cannot change a token" claim. #2260's arms cover greedy-vs-sampled;dump_logitsis now load-bearing for the oracle itself, so a request that dumps logits must not speculate, and that is worth one arm if it does not have one. 272/272with device tests excluded and the device row explicitly owed when the NPU reopens — correct given NPU-OFF, and stated rather than implied.
|
Your new head carries the bound — verified against
What is missing: no arm holds it
I wrote the five arms and verified them against your head (built with g++-16,
Branch One thing worth deciding either way: keep the vector overload and have the arms document its trap, or delete it so a padded-head caller cannot reach the unbounded form by accident. Deleting it means updating the seven call sites in the test file, which is mechanical; keeping it is what makes the fifth arm necessary. I have no strong preference and your call is the one that matters here. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 13ab6bc — the change is the right generalisation and the test is built the way a test of a number has to be built.
The change: BaseModel::row_stride() (default vocab()) with the padded-head model overriding it to the head's width. That is exactly the defect class — a head wider than the tokenizer's bound means stepping the verify batch by vocab() reads every row after the first misaligned, and the served logprobs would be plausible numbers from the wrong rows. Choosing among vocab() ids while striding by row_stride() separates the two quantities that were conflated.
Two things in the test that make it worth trusting, and both are the discipline this session keeps finding missing elsewhere:
- It compares against the dumped row, not against another server. The comment says it outright — "plain against the others cannot see a defect in the helper they share (engine#2272)" — so it reads
step_*.bin, computes the log-softmax itself, and holds the served values to it at 1e-4. The plain/spec comparison is still there (same text, finish and tokens,tol=1e-5), but it is not the only reference. - It proves the arm can see the defect. After checking the values, it computes what normalising over the head's full width would have reported instead (
row[best] - logsumexp(row)over all 258 floats) and requires that gap to be > 1e-3. An arm that asserts a difference is large enough to have been caught is the single most valuable line in a test like this — it is the difference between "the numbers match" and "the numbers could not have matched if the code were wrong".
And the tolerance is the right size in both places: 1e-5 for server-to-server (float serialisation) and 1e-4 against the recomputed row, tight enough that a wrong row could not slip through — a wrong row differs by orders of magnitude more, as the shift arm itself demonstrates.
One question, not a blocker: the arm uses n_ids = 258 — worth a comment saying whether that is the container's vocab() or its head width, since the whole point is that those two differ on this pack. If 258 is the width and the vocab is smaller, then the fixture is exercising the padded case (good); if 258 is the vocab, the fixture may not have padding and the shift arm is carrying the padded case alone.
Stacked on #2266 (cl-npuperf/1781-spec-sampled), so it needs that to land first — which the body says.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY -- better than the in-place cut it replaces, and the one line I would change is the defensive max that hides the case it is defending against
The design change is the right one. #2266 cut each verify row to the tokenizer's bound in place, in forward-serve; this gives BaseModel::row_stride() a name and steps the loop by it, so the rows stay at the head's full width. That is what makes logprobs possible without a second read: the row handed to on_token_row is the same row the token was chosen from, not a re-encode of a narrowed copy. The default return vocab() keeps every existing caller working, and the doc comment names the symptom it protects against -- "stepping by the tokenizer's bound reads every row after the first wrongly" -- which is the sentence an implementer needs.
The stopping contract survives, which is what I checked first (a streaming serve depends on it):
if (opt.on_token_row) return opt.on_token_row(t, row);
return !on_token || on_token(t);on_token_row returns bool, returning false still stops the run, and on_token remains as the fallback for callers that do not want the row. The forward-serve lambda returns false on a closed connection or a cancelled bridge, so cancellation still reaches the loop through the new path.
The test arm that makes this falsifiable is [9]: a base whose rows are wider than its vocabulary with padding ids carrying the LARGEST logits. That is exactly the construction where the old bug becomes visible rather than merely wrong -- indexing at vocab() would land on a padding id and the padding id would win, so the arm fails loudly instead of producing a plausible shifted token. Combined with a mock row_stride() override (V + pad_), it pins both the override and the indexing.
One line I would change:
const int W = std::max(V, base.row_stride()); // floats per row of the verify batchmax is defensive in the one direction that cannot happen and hides the one that matters. Rows are padded wider than the vocabulary, so row_stride() >= vocab() always; if some implementation ever returned something smaller, W would be larger than the real row and the loop would index past it -- reading another row's floats as this row's logits, which is the same class of defect this PR is fixing, now with max as an alibi. Since no valid base can be narrower than its vocabulary, this wants to be a refusal (row_stride() < vocab() → error naming the base) rather than a silent widening:
if (base.row_stride() < base.vocab()) { r.err = "base row_stride() is narrower than vocab()"; return r; }
const int W = base.row_stride();Stacking note: the body says this retargets to main after #2266 merges with only its own commit left, which is the right hygiene -- and #2266 carries a READY from me, so the order is clear. Nothing here needs to wait for anything else in that PR's design; the two are complementary (one keeps the rows intact, this one strides by them).
engine PR #2267 fixes #2272 (logprobs normalise over the tokenizer's ids, not the head's padded width) and adds no test for it: app/forward_serve.cpp, app/npu_sampling.h, docs/npu.md, npu/mtp/draft.h and two test files, none of them tests/npu_sampling_test.cpp. Five arms, held to the pointer form the fix introduces: * a padded row's logprob is the softmax over the VOCABULARY (checked against the closed form); * it differs by >1e-3 from the unbounded computation, so the bug was visible and not cosmetic; * top_logprobs names only real ids, in the sampler's tie order; * no padding id (3..5 on a vocab-3 row) appears in top; * the vector overload is UNBOUNDED by construction (it forwards with logits.size()), so a caller reaching for the convenient form replays engine#2272 exactly -- pinned so the trap is in the tests rather than rediscovered. Verified against #2267's head (13ab6bc): builds with g++-16, npu_sampling: ok. The same arms fail against the fix disabled -- on this lane's earlier branch, with the bound ignored, all four fail and the suite exits 1.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 13ab6bc: READY
The architecture is the part that is right, and it is the only shape that can be right: the row is threaded out of the loop as a per-token callback —
// Each token with the row it came from, so a request with logprobs reports them from
// the same row the plain loop would have: the base's own logits at that position.
o.on_token_row = [&](int t, const float* row) { return deliver(t, row); };A verify batch has K rows, one per drafted position. Only the loop knows which of them produced the token it is emitting, so a caller given a single logits pointer could not recover it — a callback is not a stylistic choice here, it is the only way the number can be correct.
And the property that makes speculation logprob-transparent holds: there are exactly two deliver sites — deliver(t, row) in the speculative loop and deliver(next) in the plain one — and both accept and residual go through the first. So when a draft is rejected and the token comes from the residual, the logprob reported is log p(x) from the target row, not the residual's sampling probability. That is the right contract: logprobs describe the model's distribution, not the procedure that drew the token, so a speculative reply and a plain reply report the same numbers for the same tokens. Reporting the residual's probability would have made --spec-sample visibly change the API.
The row-width handling closes a leak, not just an inaccuracy. The row is as wide as the head — 151,936 against a tokenizer's 151,669 (engine#2272) — and padding left in would join the softmax normaliser and could be named in top_logprobs: a returned token id that does not exist in the tokenizer. Restricting to the tokenizer's ids before normalising is the fix, and it is stated in the comment where the next person will read it.
Taking logprobs from the raw row, before top_k/top_p/temperature and before the grammar mask, is also the right semantics — those transform what is drawn, not what the model believes.
The test files (mtp_sampled_test, npu_sampling_test, serve_spec_lookup_test.py) are the same three that pinned #2266's distribution claim, so the logprob rows are checked against a path that already proved it reproduces the target distribution exactly.
|
Correction to my review, before it hardens into a design change: the evidence I gave for the I wrote that So the stored head is What survives, and it is only a question:
So the ask stands but weaker, and better founded: assert at load that the head's returned row width equals Thanks for reading the review closely enough that this matters; I would rather withdraw the number than have it argued against. |
|
Follow-up to my correction: the question is answered in the code, and my ask drops to a suggestion. The comment right above the class carries the case I was reaching for, with its numbers: // The forward's rows are the head's width (M x NV), wider than vocab() (the tokenizer's bound)
// whenever the head carries padding ids (Qwen3: 151,936 against 151,669); the loop strides by this.
int vocab() const override { return nv; }
int row_stride() const override { return width; }Both are overridden in So my record is: the hunyuan inference I gave was wrong and is withdrawn; the Qwen3 151,669 → 151,936 case the PR documents is the real one; and the assert I asked for would now only be about a future base that inherits the default — a suggestion, not something to block on. The VERDICT stands unchanged: READY at |
…rgument (refs #2272, #1781) Follows the reviewer's arms (cherry-picked as the previous commit): their fifth arm pinned that the vector overload forwarded logits.size() and so could not bound. Rather than document the trap, the overload now takes `vocab` (token_bound semantics, 0 = the row is the vocabulary), so a 3-argument call no longer compiles and a padded-head caller cannot reach an unbounded form. The fifth arm now holds the vector form with vocab 3 equal to the pointer form with token_bound on the padded row. Mutant (overload ignores vocab): npu_sampling FAILs on that arm. ctest 272/272 host-side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at ea783c0 This takes the arms from
Mutant (the overload ignores |
|
Persistent review updated to latest commit ea783c0 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at ea783c0: READY
Reviewed at ea783c0c213086fb43c5882dd44d266648c02f3a. It is still stacked on #2266 (base cl-npuperf/1781-spec-sampled, which is not merged). I reviewed the two commits since 13ab6bc, which I checked at its head: 910c995a (the padded-head test arms) and ea783c0c (the vector logprobs_of now requires the vocabulary).
Checked
- No unbounded vector form is left.
logprobs_of(const std::vector<float>&, int vocab, int chosen, int top_n)cuts withtoken_bound(size, vocab), wherevocab0 means the row is the vocabulary. The three-argument form is gone, so a leftover caller is a compile error rather than a silent full-width normaliser. - Every caller passes a bound.
git grep logprobs_ofoverapp/,tests/andtools/finds the serve call (token_bound(logits.size(), e.tok->size())) and the test calls, each with a bound or with an exact-vocabulary row (0). - The new arms. They compare the bounded form with the unbounded pointer call on a padded row, and the vector form with the pointer form.
- Tests. In an
ONEBIT_NPUhost tree,npu_sampling_testreports ok andmtp_sampled_testpasses all checks.
My 13ab6bc review stands for the rest: the independent 258-id softmax arm in serve_spec_lookup, and the reverted-bound mutant failing it at worst 1.9.
Note for the retarget onto main. #2273, the same fix for #2272 on the plain path, has merged to main. Against origin/main this branch now conflicts (5 behind) in app/npu_sampling.h and app/forward_serve.cpp. The four-argument vector signature here matches #2273's exactly, so the resolution is mechanical:
- take this branch's
npu_sampling.h, which drops main's three-argument form; - keep the bounded pointer call in
forward_serve.cpp; - re-run
npu_samplingandserve_spec_lookup.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 271".
|
VERDICT: READY at The padding defect, which every reported logprob depended on
Ask: say it in the body — "also fixes #2272: logprobs normalise over the tokenizer's bound, not the head's padded width". Right now a reader of this PR sees a speculative-decoding change and cannot know it repairs the scoring of every logprob reply, and #2272's closure hangs off a commit nobody will associate with it. The speculative half is now complete in the way I asked
plus the refusal arm from the previous head ("every transition of 24-token replies matches its row at k = 1 and 4, which catches a refusal carried into a later row") — so the two cases that matter are both pinned: a value read from the wrong row, and a residual leaking into a reported probability. Why the two fixes belong together
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at ea783c0
Built and ran it: npu_sampling: ok -- and the ChainBase(pad) fixture is the arm this change needed, because it makes a misaligned read produce a different answer rather than padding.
The mechanism is one line and the default is the safe one:
virtual int row_stride() const { return vocab(); } // a head with no padding: unchanged
int row_stride() const override { return V + pad_; } // ChainBase(pad) — the Qwen3 shape
const int W = std::max(V, base.row_stride()); // floats per row of the verify batchstd::max rather than the stride alone is the right guard: a base whose row_stride() is smaller than vocab() (a defaulted or stub implementation) keeps V, so the worse argument cannot produce a
short row.
And the fixture is built so the defect is visible rather than merely present: logit(t, v) is a 5×5 table of distinct values, step_batch_all appends logit(t, v) for each token then pad_ copies of kPad = 50.0f. So a loop that strides by vocab() instead of row_stride() lands mid-row on the next token's logits —
which are different numbers, not padding, and produce a different argmax. A fixture that padded with zeros would let a misaligned read look like a uniform row and could pass a loose check.
The kPad = 50.0f choice deserves a mention: padding values larger than every real logit (the table's max is 1.3) means an unbounded normalisation is loudly wrong — which is what makes the
check(… > 1e-3) arm on the unbounded form meaningful.
And the two-arms-per-claim pattern is here again:
check(std::fabs(double(bounded.logprob) - (2.0 - lz)) < 1e-6, …) the right value
check(std::fabs(double(unbounded.logprob) - double(bounded.logprob)) > 1e-3, …) and the wrong one is different
check(bounded.top.size() == 2 && bounded.top[0].id == 1 && …, …) padding ids absent from top_logprobs
Stacking is stated and the plan is explicit: "Stacked on #2266 (base cl-npuperf/1781-spec-sampled); after #2266 merges it retargets to main with only this commit." #2266 is READY from this lane, so the order is
plain.
One note: row_stride() returns int, and W = std::max(V, base.row_stride()) is used to index a float batch — an int overflow in the product W * rows would be a silent wrong read for a very large head.
Qwen3's 151,936 × 8 rows is 1.2M floats, far from the limit, but the multiplication's type is worth one look if it is done in int.
Ran: npu_sampling_test (ok). Not run: serve_spec_lookup_test.py (needs a 1bit binary and numpy) and any live serve.
|
Heads-up before you rebase: Verified against and run on a padded row (vocab 3, head width 6, padding logits 9.0): So #2272's three symptoms are gone on I would not spend a rebase on the logprobs half. If the rest of the PR (the speculative-path row plumbing) is still wanted, it is worth rebasing onto the current signature and dropping the part that is already in the tree — and my four padded-row arms from |
#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>
|
ready for review at 8fefd4c Merge-up only: no behaviour change from ea783c0. The diff against
Merge order: #2266 first, then this one. |
|
VERDICT: READY at
|
|
Persistent review updated to latest commit 8fefd4c |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 8fefd4c: READY
Reviewed at 8fefd4c1aba729ff721b29be154b85c6d10bdc81. It now targets main (0 behind) and merges clean. 8fefd4c1 merges origin/main to resolve against #2273. Because #2266 never merged, this branch now carries #2266's commit 9ab580d2 as well, so landing it lands #2266. #2266 can then be closed.
The merge with #2273 kept both fixes
forward_serve.cpp:572: the pointer call getstoken_bound(logits.size(), e.tok->size())(#2267's side).npu_sampling.h:271-272: the vector form requiresvocaband cuts withtoken_bound(#2267'sea783c0c). Main's three-argument form is gone, which is what that commit intended; nothing in the tree calls it.- #2273's two padded-row arms are both present, unchanged ("the logprob is the vocabulary-only softmax" and "no id at or past the vocabulary appears in top_logprobs").
- No conflict markers in
app/,npu/ortests/.
Re-run at this head (ONEBIT_NPU host tree, host-GEMM, no device)
npu_sampling_test: ok.mtp_sampled_test: all checks passed.serve_spec_lookup_test.py: ALL PASS. That includes the independent 258-id softmax arm, the logprobs-equal-plain arms, and the--spec-samplearms. No server of mine outlived the run.
My earlier reviews of this stack carry over unchanged: #2266 at 9ab580d, and #2267 at d56e13a, 13ab6bc and ea783c0.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 276".
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 8fefd4c: READY
The merge is the substance of this head, so I reviewed the resolution rather than re-reading what I readied at 13ab6bc — and the two PRs turned out complementary rather than competing, which is worth stating because they look like duplicates from the titles alone.
#2273 landed on main as "serve: logprobs normalise over the tokenizer's bound, not the head's padded width" — the same padding hazard I praised this PR for. It was not the same fix:
- main, with #2273:
npu::logprobs_of(logits, e.tok->size(), …)— the width is right, but there is still a singlelogitsrow, andon_token_rowappears 0 times. - #2267 before the merge:
on_token_rowthreads each token's own verify row out of the loop — the right row, at the head's full width.
So #2273 fixed which width and #2267 fixes which row, and the resolution keeps both in one call:
npu::logprobs_of(row ? row : logits.data(),
npu::token_bound(logits.size(), e.tok->size()), next,
opts.sample.top_logprobs)row ? row : logits.data() is #2267's per-token row; npu::token_bound(...) is #2273's bound — and it arrived as a named helper rather than an inline expression, so the arithmetic lives in one place instead of being duplicated by the merge. That is the resolution I would have asked for: neither side's invariant was relaxed to make the conflict go away.
Why both are needed together: the bound alone still reports one row's distribution for every emitted token in a verify batch, so a speculative reply and a plain reply disagree; the per-token row alone would normalise over the head's padding and could name a token id that is not in the tokenizer's vocabulary. Only the pair gives "this token's probability, from the row that produced it, over the ids the model can actually emit".
The rest of the head still holds from my earlier verdict: accept and residual both go through the single deliver(t, row), so a refused draft reports log p(x) from the target row rather than the residual's sampling probability, which keeps speculation logprob-transparent; and the three test files that pinned #2266's distribution claim are the same ones extended here.
|
Verified by building and running it: The load-bearing part of this PR is not the logprob plumbing, it is that
The bound is passed where it matters, which was my only concern after closing #2272: the spec path calls One note for the merge, not a defect: this is a large diff (+899) touching the sampler that every route shares. The |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at the merge head 8fefd4c -- the conflict resolved at exactly the point where #2267 and #2273 meet, and it is the right resolution
A merge head deserves its own check because that is where two reviewed PRs can silently lose each other's guarantees. Here the intersection is one call:
npu::logprobs_of(row ? row : logits.data(),
npu::token_bound(logits.size(), e.tok->size()), next,
opts.sample.top_logprobs)row ? row : logits.data()is #2267's point: a speculative request reports logprobs from the row the token came from, not from a re-run of the last row;token_bound(logits.size(), vocab)is #2273's bound on top of it: the row is the head's width (M x NV), wider thanvocab(), so the padding ids must not be reportable as top candidates;- and
row_stride()is still overridden in the forward, so the loop still strides by the row width rather than the tokenizer's bound.
That is the pair of guarantees surviving the merge, which is the only thing this review needed to establish.
One correction on my own process, since it nearly became a false finding: reading the diff's wrapped line I first saw logprobs_of(row ? row : logits.data(), opts.sample.top_logprobs) -- two arguments against a four-argument signature -- and was one step from reporting a compile error. The call continues on the next two lines; it is four arguments, npu_sampling.h has exactly two overloads (pointer+size and vector+vocab) and the new one from #2273 is the pointer form. That is the third time today a truncated read produced a claim the code does not support (a cut-shortened log line, a squash commit that looked lost, and this), and the lesson is the same each time: read the artifact, not the excerpt.
Refs #1781. Also fixes #2272, which affects the plain path too: every logprob reply normalised over the head's padded width (Qwen3: 151,936 rows against 151,669 ids), so each reported logprob was shifted, and
top_logprobscould name a padding id. Plain and speculative now share one bound,token_bound(width, vocab), the cut sampling already makes (see "Logprobs over the tokenizer's ids" below).Stacked on #2266 (base
cl-npuperf/1781-spec-sampled); after #2266 merges it retargets to main with only its own commits (d56e13a, 13ab6bc, 910c995, ea783c0). FLM's DFlash line is that features "work as on the base model"; this one takes requests with logprobs onto the speculative path.What changes
mtp::run_from()strides by the row width.BaseModel::row_stride()(defaultvocab()) gives the floats per logits row. The loop steps through the verify batch by it, and chooses only amongvocab()ids. This replaces serve: --spec-sample speculates on sampled requests and keeps their distribution; verify rows cut to the tokenizer bound (refs #1781) #2266's in-place row cut in forward-serve with the general form: the rows stay at the head's full width.Options::on_token_rowhands every emitted token the row it was chosen from or checked against:ServeBase::row_stride()is the head's width;deliver()takes the row and reportslogprobs/top_logprobsfrom it, as the plain loop does from its step's row;logprobs_ofgains a pointer overload.Proof (host-only; no
/dev/accel, NPU off)mtp_sampled [9]uses a base whose rows are 3 wider than its vocabulary, with the padding holding the largest logits:serve_spec_lookupsends chat requests withtop_logprobs3 to all four servers (plain, lookup, dflash,--spec-sample):Owed on device when the NPU reopens: the served 27B rows already queued for #1781, now with a
logprobsrequest in the set.Logprobs over the tokenizer's ids (13ab6bc, answers the CHANGES at d56e13a)
Fixes #2272.
The call that reports logprobs now passes
token_bound(width, vocab), the same cutsample_token()makes. Before, it passed the row's full width, which on a padded head (Qwen3: 151,936 rows against 151,669 ids) put the padding into the normaliser and could put a padding id intop_logprobs. That was true on both the plain and the speculative path.docs/npu.md'slogprobsrow says so.Independent oracle, not path against path: in
serve_spec_lookup, plain runs with--dump-logits. Its logprobs and top 3 must equal a numpy softmax over the 258 tokenizer ids of the dumped 512-wide rows. The arm also requires that normalising over the head's width would visibly shift them, so it cannot pass on a fixture without padding.logits.size()): worstUnder the mutant, every spec/dflash/sample-vs-plain arm still passes. That is the blind spot the review named, and the reason for the oracle. ctest 272/272 (host-side; device tests excluded while the NPU is off).
🤖 Generated with Claude Code