Repository navigation
serve: constrained requests speculate, and speculative decode falls back to plain by default (refs #1781) - #2297
Conversation
…checked in the grammar state after the drafts before it (refs #1781) RowSampler::begin_round() (called after the round's own token is emitted, before its drafts are checked) and Result::sampler_ended (pick() == -1 ends the run cleanly). npu::TokenConstraint gives SpecSampler a committed mask (the state the emitted tokens left) and a round mask (that state advanced past each kept draft); greedy under a constraint keeps a draft iff it is the masked argmax. sample_probs() takes sample_token()'s mask. forward-serve wraps its grammar as ServeConstraint over a mask_for(state) factored out of refresh_allowed(). Tests: mtp_sampled [10] (toy grammar: greedy = constrained plain, 200 sampled replies obey, TV 0.005-0.006 vs the masked product against 0.056 for a stale round state, clean end; 3 loop mutants caught); serve_spec_lookup adds a forced-alternation GBNF and a json_object request on every server (= plain; lookup keeps 11 drafts) and a sampled constrained reply on --spec-sample (stays in its grammar); 3 serve mutants caught. ctest 272/272 (device tests excluded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r round for lookup, 3 for a draft) (refs #1781) An unset --spec-fallback now takes the drafter's default: 2 tokens per round with --spec-lookup, 3 with --spec-draft. An explicit value (0 = never) is kept as given, through a parse-time flag rather than a sentinel value. The server states the value and where it came from at startup. Measured on the device (served bonsai2-27b-t2s2 + the DFlash2 27B draft, prose chat prompt, 48 greedy tokens, one npu-hold ticket, A B A B, byte-equal replies): --spec-fallback 0 1.93, 3.09 tok/s (21 rounds, 26 of 140 drafts kept) default (3) 3.89, 3.86 tok/s (falls back after round 5, 33 plain steps) -> 2.51 -> 3.88 tok/s mean (1.5x). On the code prompt drafts are kept (35 of 35) and the fallback never fires, so the speed-up there (2.5-3.4x over plain) is unchanged. serve_spec_lookup checks the startup line on the lookup, dflash and sampled servers; mutant (default never applied): those three arms FAIL. serve_spec_lookup 85/85; ctest 272/272 host-side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at 4f5326a |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY -- constrained speculation is held to the plain loop and the TV check has its negative control; the default flip needs one visible sentence
The equivalence that matters for grammars is pinned. A constrained request now speculates, with two masks -- the committed state the emitted tokens left, and the round state advanced past each kept draft -- and check() advances the state past a kept draft rather than re-deriving it. The arm that makes that trustworthy is g.tokens == plain.tokens: the constrained speculative run produces the same tokens as the constrained plain loop. Without it, a subtle off-by-one in the grammar-state advance would produce legal text that is not the text the plain path gives, which is the hardest kind of grammar bug to notice.
And the distribution test has the control I would have asked for: tv < kBound && stale > 2 * kBound -- the fresh-mask run within total variation of the target, and a deliberately stale mask failing by more than twice the bound. That is the same structure I credited in #2266 (a sampler that keeps every draft fails the bound): a bound nothing can fail is not a measurement.
serve_spec_lookup_test.py also answers "did it actually take the path" by grepping [spec] lookup grammar out of the log, rather than inferring it from timings -- the same reason I put a COTENANT: line in my own rows: the evidence that the configuration ran is a line in the record, not a difference in the number.
One ask, and it is about the default rather than the code. Making an unset --spec-fallback take the drafter's default (2 tokens per round for lookup, 3 for draft) turns speculation on by default for sampled requests -- and #2266's own note says the speculative path keeps the distribution but not the stream, so a fixed seed now gives a different reply than the plain loop did. That is a user-visible change hiding behind a flag defaulting differently, and docs/npu.md should say it in the same breath as the 1.5x: with speculation on by default, seed N no longer reproduces the pre-change text, though the distribution is unchanged. The --spec-sample paragraph is not the place a user looks when their regression test starts failing.
Not raising: the measured 3.88 tok/s / 1.5x is stated with its comparison, the two-mask design is the correct one for constrained decoding, and the arms cover constrained-plain equivalence, mask freshness (with its control), and the end-token path.
|
VERDICT: READY at What makes this reviewable, in order of how easy each would have been to get wrong:
One question rather than an objection, because I cannot answer it from the diff: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 4f5326a
Built and ran mtp_sampled: all checks passed, 31 passing lines -- and the TV numbers are the assertion this PR lives or dies on.
Constrained speculative decode has one hard requirement: a draft that the grammar would refuse must not change the distribution. The arms state it as a distance:
greedy: the reply equals constrained plain (exact, not "close")
200 sampled replies all obey the grammar (the mask holds under speculation)
TV vs the masked product distribution: 0.005-0.006
a STALE round state gives 0.056 <- the control
0.056 for a stale round state is the arm that makes 0.005 mean something. Without it, a TV of 0.005 could be "the sampler is fine" or "the test is insensitive" -- with it, the measurement separates a correct round from the
specific failure mode (checking drafts against the previous round's mask), by an order of magnitude.
begin_round() running after the round's own token and before its drafts are checked is the ordering that makes it correct, and it is stated as an ordering rather than left to be inferred:
"RowSampler::begin_round() runs after the round's own token is emitted and before its drafts are checked." That is the same class as attn_q vs attn_q_norm (#2097) and the .scale ordering (#2291) -- the fix is a
sequence, so the sequence has to be written down.
The fallback change is measured with the mechanism's own numbers, not with a rate alone:
| prose prompt, A B A B | tok/s | mean |
|---|---|---|
--spec-fallback 0 (never) |
1.93, 3.09 | 2.51 |
| default (3) | 3.89, 3.86 | 3.88 (1.5×) |
"Replies were byte-equal in all four runs", and the reason is given rather than the correlation: "with --spec-fallback 0 the run made 21 rounds and kept 26 of 140 drafts. With the default it fell back to plain after round 5" --
i.e. the win is stopping a losing strategy early, not making drafts better. And the honest counter-case: "on the code prompt, every draft is kept (35 of 35), so the fallback never fires" -- so it costs nothing where speculation works.
0 means never, via a parse-time flag rather than a sentinel is the right call and worth naming: a sentinel (-1 = default, 0 = never) is the shape that makes "the user asked for 0" and "the user said nothing"
indistinguishable, which this session has now found in three other places.
The startup line is asserted per server ("2 for lookup, 3 for dflash, 2 for sampled, all (default)"), so the value and its provenance are visible where an operator looks.
And the mutant count is the proof standard: "3 loop mutants are caught", "3 serve mutants are caught", "a mutant that never applies the default fallback fails 3 arms", plus ctest 275/275.
Not run: the device rows (npu-hold, bonsai2-27b-t2s2 + the DFlash2 27B draft) -- the 1.5× is the author's, and it is reported with the arm ordering (A B A B) and byte-equal replies.
PR Reviewer Guide 🔍(Review updated until commit f2457c7)Here are some key observations to aid the review process:
✅ Resolved findingsapp/npu_spec_sampler.h:58-87Grammar Constraint Handling The implementation of grammar constraints in the |
|
Verified by building and running it — and the first attempt at that was wrong in a way worth recording, because it nearly made me report a false PASS.
That arm does what I would have asked for in review without being asked: it does not merely assert "the replies obey the grammar" — it computes the TV of 200 constrained replies against the masked, renormalised product of Coexistence with #2267 is clean: The near-miss, since it is a new shape of an old trap. My first run used a build directory ( |
|
Persistent review updated to latest commit f2457c7 |
|
VERDICT: READY at The precondition that mattered most
Two things it must therefore get right, and both are worth seeing in the body:
The Two asks
And the default fallback pairing is rightFollowing #2266/#2267 (both on main via #2267) keeps the sampler, the logprob path and the grammar check as one coherent set rather than three independent switches — which is what makes |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at f2457c7: READY
Reviewed at f2457c7f82d8e08e04d6aa1c03d0c74cc0167f06. It is 1 behind main and merges clean. The diff is 7 files, +296/−43.
Checked
- The grammar flow is consistent.
- The round's own token is emitted before
begin_round(), sodeliver()advances the committed grammar state first. begin_round()then copies that committed state, and each kept draft callsadvance_round(x).- A refusal's residual is drawn under the round mask at its position. It becomes the next round's
t1and is delivered, which advances the committed state. ServeConstraintholds the committed state by reference, so the copy is always current.
- The round's own token is emitted before
- Greedy under a mask is exact.
sample_probs(…, mask)filters to allowed tokens before its greedy branch, and keeps the lower id on a tie, assample_token()does. Socheck()keeping x iff it is that one-hot's id gives the constrained plain loop's tokens. forward-serve now attaches the sampler for a constrained greedy request ((greedy && !gram) ? nullptr : &sampler, line 687). - Tests (
ONEBIT_NPUhost tree, host-GEMM, no device):npu_sampling_test: ok;mtp_sampled_test: all checks, including[10];serve_spec_lookup_test.py: ALL PASS. The grammar andjson_objectarms pass on all three servers (lookup, dflash, sample).tools/copyright.py --check: rc 0.
My mutant. I dropped advance_round(x) on a kept draft, so later drafts are checked in a stale grammar state. [10] fails: "one-pass: a constrained greedy reply equals the constrained plain loop", and "one-pass: 200 sampled 24-token replies obey the rule". Restored, it passes.
Note on the default change. --spec-fallback now defaults to 2 (lookup) or 3 (draft) tokens per round. That changes behaviour for every existing --spec-* user without a flag. The body's device rows support it (prose 2.51 → 3.88 tok/s, code unchanged), and the startup line saying "(default)" makes it visible. The device rows themselves are the author's npu-hold runs; I did not re-run them.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 277".
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f2457c7
Head moved from 4f5326a to f2457c7 by a merge of main; the PR's own content is byte-unchanged, and I re-ran the test to say so rather than infer it.
$ git diff --stat 4f5326a f2457c7 -- <the PR's five files>
(empty)
So the READY carries: app/npu_sampling.h, the spec sampler, the draft header, docs/npu.md and serve_spec_lookup_test.py are identical to the head I reviewed, and the merge only brought in main (moe placement's
fixture and the GPT-OSS harmony split).
Re-ran rather than reasoned: cmake --build build1336 --target mtp_sampled_test → 0 errors, and mtp_sampled_test → all checks passed, including PASS [10] two-pass: a constraint with nothing left ends the run after 5 tokens.
Everything in my previous verdict stands: TV 0.005-0.006 against the masked product with the stale-round control at 0.056, the ordering written down (begin_round() after the round's own token, before its drafts are
checked), the A B A B 1.93, 3.09 vs 3.89, 3.86 fallback measurement with byte-equal replies, and 0 = never through a parse-time flag rather than a sentinel.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at the merge head f2457c7 -- both controls survived the merge, which is the only thing a merge review needs to establish
Merge-only commit against main, so the question is not the design (that review stands) but whether the two arms that carry the design's weight came through the conflict resolution intact. They did:
- the mutant is still there --
stale_roundwith its own comment ("the round's state never moves past a kept draft") and theif (!stale_round) round = t;guard, i.e. the arm can still be made to fail on purpose, which is what keepstv < kBoundfrom being vacuous; - the equivalence is still there -- the constrained speculative run is still required to produce the same tokens as the constrained plain loop;
- and
TokenConstraint(committed state advanced bydeliver(), round state advanced past each kept draft, with the class documented as keeping its own copy) is intact behindSpecSampler's new constructor parameter.
Nothing else changes: the default-on --spec-fallback ask from my review stands (a fixed seed no longer reproducing the pre-change text belongs in docs/npu.md beside the 1.5x, not only in the --spec-sample paragraph), and it is not made worse by the merge.
Refs #1781 (speculative decode on the NPU route: constrained requests, and a default fallback). Follows #2266 and #2267, both on main through #2267 (383d670).
What changes
1. Constrained requests speculate (grammar,
json_object).--spec-lookupor--spec-draftset.npu::TokenConstraintgivesSpecSamplertwo masks. The committed mask is the state the emitted tokens left. The round mask is that state advanced past each kept draft.sample_probs()takessample_token()'s mask.RowSampler::begin_round()runs after the round's own token is emitted and before its drafts are checked.Result::sampler_endedends a run cleanly whenpick()returns -1.ServeConstraint, over amask_for(state)factored out ofrefresh_allowed().2. Speculative decode falls back to plain by default.
--spec-fallbacknow takes the drafter's default: 2 tokens per round for--spec-lookup, 3 for--spec-draft.0means never. This uses a parse-time flag, not a sentinel value.docs/npu.mddocuments both changes.Measured proof
Device rows: served
bonsai2-27b-t2s2with the DFlash2 27B draft, 48 greedy tokens. All runs were inside npu-hold tickets, and the amdxdna ERROR count was unchanged across every ticket.--spec-fallback 0(before)--spec-fallback 0the run made 21 rounds and kept 26 of 140 drafts. With the default it fell back to plain after round 5 and decoded the rest in 33 plain steps.Host tests:
mtp_sampled[10] uses a toy grammar.serve_spec_lookupchecks every server (lookup, dflash, sampled):json_objectrequest return the same as plain. Lookup keeps 11 drafts.--spec-samplestays in its grammar.ctest275/275 at this head on current main, with device tests excluded, run under thermal-run.tools/copyright.py --checkis clean.🤖 Generated with Claude Code