Repository navigation
serve: --spec-sample speculates on sampled requests and keeps their distribution; verify rows cut to the tokenizer bound (refs #1781) - #2266
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>
|
ready for review at 9ab580d |
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 9ab580d: READY
This is the version of speculative decoding that is easy to get wrong, and the PR gets it right by naming the wrong version first. spec_accept.h opens with the trap: "accept iff the draft is the target's most likely token, otherwise emit that" is exact for greedy decode and for nothing else — with a sampler it silently biases the output toward the drafter's proposal. Then it gives the counterexample the tests use, p = [.45,.35,.20] against q = [.10,.80,.10], and the correct rule: accept with probability min(1, p(x)/q(x)), else emit from normalise(max(0, p − q)). That is rejection sampling, and spec_residual_sample is exactly that residual, by inverse CDF.
The strongest structural reason to trust it: at temperature 0 both rows are one-hot, the ratio is 0 or 1, and the general rule degenerates to draft.h's existing "accept iff argmax" rule. So this is not a second acceptance path that has to agree with the first — one rule contains the other, and the greedy path is the limit case rather than a parallel implementation. A two-path design would have been the thing to worry about.
And the one-hot drafter assumption is the load-bearing simplification, stated where it belongs: npu_spec_sampler.h says the acceptance rule is the one for "a drafter whose own distribution is one-hot at its proposal, which is what prompt lookup and a DFlash head are". A lookup drafter proposes a token it read in the prompt; a DFlash head samples one. Either way q is one-hot at the proposal, which is what makes the residual computable without a draft-side distribution.
The tests are the part I would keep: they are calibrated, not hopeful.
- N = 200,000 with a fixed seed, and the comment says the tolerances are bounds on a known sample, with the sampling error over N computed — not a number that happened to pass.
- The arm that matters is a deliberately poor drafter (
q = [.10,.80,.10]againstp = [.45,.35,.20]), where a biased rule shows up strongest: emitted frequencies within 0.006 ofp. mtp_sampled_testbounds the total variation between observed next-token frequencies and the exact distribution, with a control arm — plain sampling (k = 0) must match, so the bound is proven to be a bound before it is applied to the speculative path.- And a negative control: "[6] keeping every draft is a different distribution, and the bound sees it." A test that cannot fail for the wrong algorithm proves nothing; this one is shown to fail for it.
Determinism is handled too: both uniforms of a check come from the request's generator in order, so a seeded request stays reproducible through the speculative path.
One follow-up, not a blocker: [spec] already reports rounds/accepted/plain/fallback, and for a sampled request the acceptance rate is the number that says whether --spec-sample is buying anything — worth it on the same line when this is enabled.
|
VERDICT: READY Two things make this PR reviewable rather than merely plausible, and one of them is that it exposes a bug in #2260 — which I verdicted READY an hour ago. I want to start there. What I missed in #2260, and why. #2260's verify path committed rows indexed at the head's width, while the tokenizer's id space is narrower — The sampled path is tested by distribution, not by equality — and that is the only honest way. Under a sampler the reply follows the same distribution as the plain loop but not the same random stream (the PR says so explicitly, and that is the correct claim), so token-for-token comparison would be wrong. Instead:
The rule itself is the one already audited: Everything is off by default ( One nit, not blocking: a TV bound is a statistical assertion, so |
|
VERDICT: READY at The rule reduces correctly, and the body says why
That is exactly right, and it is worth stating because the general rule ( The test is the shape I would want for any statistical claim
One ask that is a mathematical detail with a silent failure modeConfirm the residual draw is renormalised. And say what #2260's verify path got wrong"Fix: verify rows cut to the tokenizer bound. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9ab580d
Built and ran it: npu_sampling: ok, with the four distribution arms printing their total variation -- and the numbers are the right order:
sample_probs temperature 0.8 tv 0.0026
sample_probs temperature 1.3, top_k 4 tv 0.0019
sample_probs temperature 0.7, top_p 0.8 tv 0.0025
sample_probs temperature 1, top_k 6, top_p 0.9 tv 0.0018
0.0018-0.0026 against a 0.01 bound, i.e. the draw is inside a fifth of the tolerance the arm sets for four different filter combinations. That is the assertion a speculative sampler needs: not "it samples", but "what it draws is what sample_probs() says it should", measured.
And the arm is a pair of conditions, which is what makes it non-vacuous:
check(tv < 0.01 && zero_never, "sample_token draws what sample_probs says: " + cs.name);zero_never -- "every id it zeroes is never drawn" -- is the one that catches a sampler whose distribution is close but which occasionally emits an id its own filter excluded. A TV check alone would pass that: a rare draw into a zeroed slot moves total variation by almost nothing.
The seam is the right shape: Options::sampler is RowSampler*, null means greedy and unchanged, so the entire existing loop's behaviour is preserved by default and the sampled path is additive. pick() and check() are the only two decisions a round makes -- the same two that #2245 put in a pure header, now with a vtable behind them.
And the save is residual: "on a refusal the sampler's residual draw becomes the next position's token, in both round shapes". That is the part a speculative sampler gets wrong, because a refusal has already consumed the target's probability mass; using a fresh draw instead of the residual changes the distribution at exactly the positions where verification failed, which is where it is hardest to notice.
The scope note is honest: "gate 2: sampling that keeps the target's distribution, served; plus a fix to #2260's verify path" -- two things, one of them a fix to somebody else's PR, named rather than folded in quietly.
One note: the four arms vary temperature / top_k / top_p but all use the same id space; a case with a narrow distribution (top_k 1, or temperature 0.05) would pin the degenerate edge where the sampler must be indistinguishable from greedy -- the case where a residual bug is invisible because every refusal is immediately re-drawn from a one-hot.
Ran: npu_sampling_test (ok). Not run: tests/serve_spec_lookup_test.py (needs a 1bit binary argument and numpy).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY -- the distribution claim is tested the only way it can be, and the arm that would have caught a broken rule is present
Sampled speculative decoding is the one place in a serve path where "it looks right" is worthless: a chain that accepts drafts on the wrong probability produces fluent, plausible, wrongly distributed text. So the review is the acceptance rule, the residual, and the test.
The rule (pre-existing npu/mtp/spec_accept.h, used correctly here):
if (u_accept * double(q[x]) < double(p[x])) return {true, x}; // u < p/q, with min(1, .) folded in
return {false, spec_residual_sample(p, q, V, u_resample)}; // normalise(max(0, p - q))Written without the division, so q[x] == 0 needs no special case: 0 < p[x] accepts exactly when the draft was possible at all. The residual sampler accumulates mass and returns the last weighted index when u rounds past the total, which is the right side to err on. And spec_accept_prefix consumes exactly two draws per position examined, accepted or not -- subtle, and most implementations of this get it wrong: a variable draw count makes the RNG stream depend on the accept/reject pattern, so later positions are distinguishable from the plain loop's stream for a reason nobody asked for.
p is the final distribution, not the logits. npu_spec_sampler.h builds it with sample_probs(logits, n, sp_, n), the same function the plain path's sample_token() draws from, so temperature, top-k and top-p are applied once and identically on both paths -- the single requirement that makes "keeps the target's distribution" true at all.
The test is the part I would have argued for and did not have to. It measures total variation against the target, kBound = 0.02 with the three arms landing at 0.008-0.009 -- a bound with a reported margin rather than a round number. And critically:
check(a.tv > 3 * kBound, "[6] a sampler that keeps every draft fails the bound");An arm that keeps every draft -- no rejection correction -- must fail the bound by more than three times it. That is the negative control that makes the other arms mean something: without it, a test that only asserts tv < bound cannot distinguish a correct rule from a vacuous one. Plus k = 1 (short chains, where a round after a fully kept one is common) and k = 4 (refusals deep in a chain), distinct per-row logits so every transition matters, and a same-seed arm for the loop drawing only through the caller's sampler.
The bug fix bundled here is worth calling out separately: rows are M x NV wide but indexed at vocab(), the tokenizer's bound, which is narrower whenever the head carries padding ids (Qwen3: 151,936 against 151,669) -- so each row is cut to its first nv logits before the loop reads row i at i * nv. That is a genuine wrong-rows-read defect, not a cleanup, and it would have shown up as plausible-but-shifted output.
One thing to keep stated for users, and the comment does: --spec-sample follows the same distribution but not the same stream, so a seed gives a different reply than it does without the flag. That belongs in docs/npu.md as prominently as the flag itself, because "same seed, different answer" is otherwise a support ticket.
…verify rows strided by the head width (refs #1781) (#2267) * serve: --spec-sample speculates on sampled requests with the distribution-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> * serve: speculative decode serves logprobs from each token's own row; 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> * serve: logprobs normalise over the tokenizer's ids, not the head's padded 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> * test: the padded-head arms for #2272, which the fix did not ship 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. * sampling: the vector logprobs_of takes the vocabulary as a required argument (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> --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #1781 (gate 2: sampling that keeps the target's distribution, served; plus a fix to #2260's verify path).
What changes
mtp::run_from()under a sampler.Options::sampler(mtp::RowSampler,npu/mtp/draft.h):pick()draws the base's own token;check()decides a draft against the row at its position;forward-serve --spec-sample(off by default, needs--spec-lookupor--spec-draft). Sampled requests with no grammar and no logprobs take the speculative path withnpu::SpecSampler(app/npu_spec_sampler.h):pickissample_token();checkkeeps a draftxwithp(x)and otherwise emits a draw frompwithoutx. That isnpu/mtp/spec_accept.h's rule (npu/mtp: the distribution-following acceptance rule, as a tested header (engine#1781 gate 2) #1958) for a drafter whose distribution is one-hot at its proposal.pcomes fromsample_probs(), the distributionsample_token()draws from: its top_k / top_p / temperature part is factored out assampling_weights(), with the same arithmetic and draws.[spec] lookup sampled ....step_batch_allreturns rows at the head's width, and the loop indexed them at the tokenizer's bound. On a head with padding ids (Qwen3: 151,936 against 151,669), every row after the first was read misaligned. The serve's base now cuts each row.serve_spec_lookup's container now has a 512-row head against 256 ids, and on main's binary it fails: 3 of 4 lookup replies at 24 tokens leave plain greedy.Proof (host-only; no
/dev/accelopened, NPU is off)mtp_sampled(new) runs a toy Markov chain with 200,000 seeded 3-token replies per arm, compared with the exact product of the chain's conditionals. The drafter proposes the second most likely token, so both branches of the rule run.The bound is 0.02. Transitions over 24-token replies (k = 1 and 4) stay within 0.0053 of their row. The base holds exactly prompt + reply after every run.
Other tests:
npu_samplingcheckssample_probs()against 200ksample_token()draws for 4 parameter sets: TV 0.0018-0.0026, zeroed ids never drawn, padding ids excluded.serve_spec_lookupadds a 4th server (--spec-lookup 4 --spec-sample):Mutants, each caught:
[2]/[3]TV 0.25[2]/[3][2]0.35 /[3]0.08[7]at k = 1[8]+ serve cold arm[8][8]sample_probs[8]+npu_samplingsample_probsnpu_samplingFull ctest: 272/272, with the device tests excluded (
-E 'npu_dmabuf_import|_device|_npu_run').Owed on device when the NPU reopens: served 27B
--spec-lookup 4 --spec-sampleagainst plain at temperature 0.7, tok/s and acceptance. That will be one line in the lane's device ticket.🤖 Generated with Claude Code