Repository navigation
serve: forward-serve speculative decoding (--spec-lookup K, --spec-draft <dir>), token for token the plain reply (refs #1781) - #2260
Conversation
…mpt-lookup drafter, token for token the plain reply (refs #1781) - npu/mtp/prompt_lookup.h: PromptLookupHead, a drafter with no model: the up-to-K tokens that followed the latest earlier occurrence of the stream's last 3..1 tokens. One-pass (needs_last_hidden() false). - npu/mtp/draft.h: the one-pass round calls commit_rows() even when the base keeps no hidden rows (a token-only drafter follows the stream through it; before, it never saw a commit); Options::consume_last (false skips running the reply's last token, as forward-serve's plain loop does). - app/forward_serve.cpp: --spec-lookup K / --spec-fallback X. A greedy request without grammar, logprobs or logit dump decodes through mtp::run_from(); every token, on either loop, goes through one deliver() (end tokens, grammar, stop sequences, hold-back). Host-GEMM verifies 16 rows. One [spec] log line per request. Tests: tests/prompt_lookup_test.cpp (the lookup by hand; through mtp::run: text = plain, stream = committed text, fewer passes on a repeating answer); mtp_draft [18] consume_last; tests/serve_spec_lookup_test.py (ctest serve_spec_lookup, ONEBIT_NPU builds): fabricated container served host-GEMM plain and with --spec-lookup 4, equal text/finish/completion_tokens over budgets, stop sequences that cut the reply and a seeded sampled request; spec path taken on exactly the greedy requests. Mutants: no greedy gate, deliver() result ignored, host rows 1, no commit_rows without rows -- each fails a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at eadff3a |
PR Reviewer Guide 🔍(Review updated until commit d9cdfbb)Here are some key observations to aid the review process:
✅ Resolved findingsnpu/mtp/prompt_lookup.h:50-58Prompt Lookup Implementation The |
…e speculative drafter (refs #1781) - app/forward_serve.cpp: --spec-draft <dir> [--spec-draft-k K]. The package is loaded, decoded and checked against the target before ready (width, target layers); the forward's tap sink is emptied when a request takes the engine; DFlashHead drafts with the target's head (DFlash 2's selector when the package has one, else the bounded argmax). --spec-draft and --spec-lookup are refused together. - npu/dflash_head.h: dflash2_select(), the DFlash 2 candidate selector as one function. - 1bit links npu/dflash_forward.cpp and npu/dflash_draft.cpp. tests/serve_spec_lookup_test.py: a third server, --spec-draft on a generation-1 package the script writes (tests/dflash_draft_test.cpp's synthetic layout, sized to the fabricated target): all 16 replies equal the plain server's, the speculative path on exactly the 15 greedy requests, every one of two or more tokens drafted. Mutants (sink not emptied per request, taps never set, a pick that never proposes) each fail it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at d9cdfbb (adds --spec-draft: a DFlash package as the drafter, same equality test with a third server) |
|
Persistent review updated to latest commit d9cdfbb |
|
VERDICT: READY at "Used only where it cannot change a token"
Four exclusions and each is load-bearing:
Naming these as preconditions rather than caveats is the difference between a feature that is safe by construction and one that is safe if the user reads the docs. The test asserts both directionsFrom the ctest:
That trio is the pattern I have been asking for all night in smaller pieces: equality where it must hold, engagement where it must happen, and abstention where it must not. And the honest gap is stated, with its reason
Two asks
|
|
VERDICT: READY at That pair of lines is the whole design argument, and it is a pair rather than a claim: on an answer that does not repeat, the drafter proposes and nothing is accepted, so the run costs exactly plain decode's 48 passes — the feature is a no-op where it cannot help. On a repeating answer, 30 acceptances turn 48 passes into 18. A speculative path that imposed any overhead in the no-help case would be a tax on every request; this one is measurably free. The gating is the part I would have insisted on, and it is stated as a closed list: a request decodes through Three things I want on the record as right:
Two asks:
Scope of my check: fresh configure rc 0, built and ran |
|
VERDICT: READY The serve integration is gated and tested the way an optimisation of the decode loop has to be, and the two properties that matter are both asserted rather than assumed. Output equivalence, token for token. Path selection, not just equality. The test also requires the spec server to take the speculative path on exactly the greedy requests and to draft. That is the vacuity guard, and it is the same lesson as #2247 an hour ago: two servers agreeing proves nothing if the second never speculated. Here a silent non-take fails the test instead of passing it. The gate itself is a list of correctness reasons rather than conveniences: greedy (acceptance is exact only against the target's argmax), no grammar (its mask is per token and per state), no logprobs (the spec path has no per-token distribution to report), no And the drafter choice makes this reviewable during a freeze: One nit, not blocking: the test builds a "generation-1 DFlash package ... after tests/dflash_draft_test.cpp's synthetic one", i.e. the format now has two hand-built producers. If a third appears, a shared helper would stop them drifting apart in a way that only shows up when one of them is wrong. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at d9cdfbb: READY
The safety claim is structural, and I verified it at the two call sites rather than taking the description's word: line 642 in the speculative loop and line 670 in the plain loop both call the same deliver(t), so end tokens, grammar and stop sequences see every token identically whichever path produced it. That is the property that makes speculation safe here — not a statistic about acceptance rates.
Why this is the right kind of speculative decoding to build first: prompt/ngram lookup drafts tokens that literally occurred earlier in the prompt, and every draft is then verified against the model and only the matching prefix accepted. So there is no quality trade-off at all — acceptance is exact by construction, and a wrong draft costs one verify batch and no correctness. A learned drafter has to earn that property; this one has it for free.
Three things I checked specifically:
- The test exercises the case that would break — the body's "stop sequences cut out of" is real: the same prompts go to both configurations with stop sequences in play, not just a happy-path token count.
[spec]names which drafter ran (dflashvslookup) alongside rounds/accepted/plain/fallback. A perf line that said only "spec on" would make the two paths indistinguishable in a log.- Off by default (
--spec-lookup 0), which is right for anything that changes the token loop: it must be enabled deliberately to change behaviour.
One thing worth a follow-up test rather than a blocker: the stop-sequence interaction is covered, but a grammar-constrained request run with and without --spec-lookup compared byte-for-byte is the strongest possible statement of "cannot change a token", and there is no such row yet. The shared deliver() makes it very likely to pass; a row would make it known rather than likely.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d9cdfbb with one ask — three of the four guards around a correctness invariant have no arm, and the invariant is the whole reason this path is allowed to exist.
The claim that carries the PR
// engine#1781: the speculative path, only where it cannot change a token.
(e.spec_lookup > 0 || e.draft) && opts.sample.greedy() && !gram && !want_lp && e.dump_logits.empty()Four conjuncts, and the code comments the reasoning rather than just the mechanism: greedy means each token is the bounded argmax, so a draft that the verifier rejects cannot change what the plain path would have produced. That is the correct argument, and the fix that makes it true elsewhere is the right shape too — "one token path: both loops hand every token to one deliver()", so end tokens, grammar, stop sequences and the stop hold-back cannot diverge between the two loops. A speculative path that had its own delivery code would eventually differ from the plain one in a case nobody tested.
What is measured, and it is the part I trust most
4 prompts x budgets 1, 5, 24 (15 greedy requests) + 1 seeded temperature 0.8 request
speculative path taken on EXACTLY the 15 greedy requests — never on the sampled one
texts equal to the plain server on every request
6 of 33 drafts accepted on this random model <- honest: a random model accepts little
3 stop sequences cut out of the plain replies (finish "stop", at 8 to 12 tokens)
prompt_lookup_test: 30 drafts accepted at k = 7 on a repeating answer; 18 base passes vs plain decode's 48
mtp_draft [18]: consume_last = false gives the same text and skips exactly the one pass after a reply ending on a stop
"exactly the 15" is the right form: it is a statement that can fail in both directions — too many requests and the guard leaked, too few and the path never ran. And publishing "6 of 33 accepted" on a random model, rather than a favourable prompt, is the number that tells a reader what this costs when the drafter is guessing.
My ask: !gram, !want_lp and e.dump_logits.empty() are unexercised
The test varies greedy vs sampled, budgets and stop sequences. It never sends a request with a grammar, with logprobs, or with dump_logits set. So three of the four conjuncts could be deleted and every arm above would still pass — and the consequence of deleting any of them is a changed token on a request whose contract says it cannot change, which is precisely the failure this guard exists to prevent.
The arms are small, because the assertion is the same shape as the sampled one:
- a request with a grammar → spec path not taken, text equals plain (a grammar constrains the argmax, so the verifier's accept is no longer "canonical");
- a request asking for logprobs → not taken (the verifier would have to report the draft's logprobs, which it does not);
dump_logitsset → not taken (the dumped rows would be the verify batch's, not the plain path's).
The pattern already exists in the file — the sampled request asserts "[spec]" not in logs for that server — so this is three more entries in the same table, not new machinery. I would rather see the guard's four conjuncts each kill the path once than see another budget variant.
Small notes
--spec-draftand--spec-lookuprefused together — right call: two drafters on one request is a case with no measured answer, and refusing names that.consume_lastdefaulttrue— "so existing callers see no change" — is the correct default for a new option, and themtp_draft [18]arm pins the one pass it saves.- Prefix-cache reuse ("a request that resumed from a saved prefix has no tap rows for the positions it skipped") is the subtlest line in the description and it is handled explicitly rather than discovered later. Worth a sentence in the reply about what the fallback head does at those positions, because that is the case a future reader will break.
Refs #1781 (the serve half of "the loop inside forward-serve"; follows #2253, #2255, #2245, #2257).
What
forward-serve --spec-lookup K(off by default;--spec-fallback Xadds the fallback rule). A request that is greedy, has no grammar, asks for no logprobs, and runs on a server without--dump-logitsdecodes throughmtp::run_from()after the usual batched prefill. Every other request takes the plain loop, unchanged.npu/mtp/prompt_lookup.h:PromptLookupHeadneeds no model. It proposes the up toKtokens that followed the latest earlier occurrence of the stream's last 3, 2 or 1 tokens. That pays where a reply repeats its prompt (code being edited, quoting) and proposes nothing otherwise. It is the first drafter the serve can use without a draft package, and it makes the serve path testable on the host. The DFlash drafter (npu: the DFlash draft as mtp::run()'s drafter (DFlashHead, refs #1781) #2255) plugs into the same call.deliver()(end tokens, grammar, stop sequences, the stop hold-back,on_text). It is the old loop body moved, not rewritten, so speculation cannot treat a token differently.sample_token()takes when greedy.Options::consume_last = false), as in the plain loop.[spec]line per request (rounds, drafts accepted, plain steps, fallback).forward-serve --spec-draft <dir>(--spec-draft-k K, default the package's block minus one) serves the same path with a DFlash draft package as the drafter (DFlashHead, #2255):ready(the widths must match, and its target layers must exist).dflash2_select(), now one function innpu/dflash_head.h), else the bounded argmax.--spec-draftand--spec-lookuptogether are refused.1bitnow linksnpu/dflash_forward.cppandnpu/dflash_draft.cpp.Two fixes in
npu/mtp/draft.h, found by these tests:commit_rows()even when the base keeps no hidden rows. Before, a token-only drafter never saw a commit, so every lookup after the first ran on a stale stream;Options::consume_lastis new (defaulttrue, so existing callers see no change).Proof
tests/serve_spec_lookup_test.py(ctestserve_spec_lookup,ONEBIT_NPUbuilds, where forward-serve exists): a fabricated two-layer qwen3 container with a 256-token byte-level tokenizer, served host-GEMM twice, plain and with--spec-lookup 4. No device is opened: understrace -e openatthe whole run shows 0 opens of/dev/accel*or amdxdna. Text,finish_reasonandcompletion_tokensare equal on all 16 requests:stop, at 8 to 12 tokens);temperature 0.8request.The speculative path was taken on exactly the 15 greedy requests, with 6 of 33 drafts accepted on this random model, and never by the plain server or on the sampled request.
A third server runs
--spec-drafton a generation-1 DFlash package the script writes (tests/dflash_draft_test.cpp's synthetic layout, sized to the fabricated target). All 16 replies equal the plain server's. The speculative path was taken on exactly the 15 greedy requests, and every one of two or more tokens drafted (782 drafts in all). The acceptance means nothing on synthetic weights; what the test pins is the equality and the alignment of the tap sink. The three servers together make 0 opens of/dev/accel*or amdxdna understrace -e openat.tests/prompt_lookup_test.cpp(ctestprompt_lookup, host):mtp::run(), the text is plain decode's, and the head's stream is the committed text;mtp_draft[18]:consume_last = falsegives the same text and skips exactly the one pass after a reply that ends on the base's own token.Mutants, each failing a test:
greedy()gate (the sampled request decodes speculatively)serve_spec_lookup: 3 failuresdeliver()'s false ignored (stop sequences no longer end the reply)serve_spec_lookup: 4serve_spec_lookup: 2commit_rows()without hidden rowsprompt_lookup: 9--spec-draft: tap sink not emptied per request (warm-up's rows misalign every request)serve_spec_lookup: 2--spec-draft: tap layers never setserve_spec_lookup: 2--spec-draft: a pick that never proposesserve_spec_lookup: 2Full ctest of an
ONEBIT_NPUbuild with the device tests excluded, at d9cdfbb: 271 of 271 passed.Not measured: tok/s on the device. The NPU is off for now. The rows owed are the served 27B against plain decode, on a code-edit prompt and a prose prompt: with
--spec-lookup 4 --spec-fallback 2, and with--spec-draft ~/scratch-packs/dflash2-27b.🤖 Generated with Claude Code