Repository navigation
npu: the speculative round's seam and bookkeeping, host-tested (engine#1781 item 1) - #2059
bong-water-water-bong wants to merge 4 commits into
Conversation
…e#1781 item 1, part 1) #1781's remaining list has three items; this is the first half of item 1 (the wiring), scoped on the issue: app/spec_decode.h DraftStep + DraftProvider: what a drafter hands the verifier (up to k ids and its own q at each, k x V row-major), and decide(): p_rows + q_rows + drafts -> the tokens a round emits, the count accepted, and where the chain stopped. tests/spec_decode_test.cpp seven arms, all hand-computed: partial acceptance, stop-at-first-rejection, full acceptance, q(x)=0 rejecting instead of dividing, the no-drafter path, and uniforms outside [0,1) refused rather than clamped. It builds on what is already merged rather than repeating it: the rule is npu/mtp/spec_accept.h (#1958) and p is already per-row because app/forward_serve.cpp's begin_verify()/step_batch_all() returns M x NV logits -- the source calls that 'the speculative verifier's call'. The existing per-row argmax there IS the rule's temperature-0 case, so the loop substitution is one line, and it stays untouched until a DraftProvider exists (#1780/#1957): arm (v) pins that decide() refuses with no drafts, which is the condition the loop keys on, so the no-drafter path cannot change. decide() takes its two uniforms from the caller on purpose. Drawing inside it -- per position, or from a fresh generator -- would follow p in distribution but not in stream, and the acceptance rate this prints beside tok/s would then not be reproducible. Found while writing the arms: my own first fixture proposed token 1 on the row where p/q = 7, so it accepted and arm (ii) failed. The arm was right and the fixture was wrong; the comment records it.
|
VERDICT: READY at Three asks, all about the seam rather than the bookkeeping:
The |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 620f330
This is the model of a claimed slice on a multi-item issue: "First half of #1781 item 1 (the wiring), claimed on the issue with its boundary. #1781's remaining list has three items; this lands the part that can be tested without a drafter, and deliberately leaves the loop substitution alone until one exists."
"Tests something without a drafter" is the property that makes speculative decoding reviewable at all. The loop substitution cannot be tested before a draft model exists, so a PR that lands the substitution now lands untested code on the critical path and calls it a first half. The seam and the bookkeeping — what a round offers, what the target accepted, what is charged to which counter — can be exercised today, which is why they belong first.
Together with #1958 (the acceptance rule as a header) and #2053 (the arithmetic the draft is graded against), this issue now has a spec and a seam with only the compute missing — and each of the three was claimed rather than duplicated, which is the coordination this family needed.
One ask: the bookkeeping should record the rejected drafted tokens explicitly, not only the accepted ones. An acceptance rate derived from accepted/total hides the case that matters most — a round where the target accepted the first token and rejected the rest — and that is the case a draft loader will be tuned against.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 620f330 — The speculative round's seam and bookkeeping, host-tested: the seam is where a draft's tokens are accepted or discarded, so pinning it on the host before any device involvement is what lets the acceptance arithmetic (#2053) be trusted when the device path lands.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 620f330 — the seam is right, the chain rule is right, and it composes with a sibling PR in a way worth writing down while both are open.
What it is, and why the boundary is drawn correctly
spec_accept_step already exists on main (npu/mtp/spec_accept.h, #1958). This PR adds what was missing around it: DraftStep/DraftProvider (the whole seam a drafter must implement) and decide(), which loops the existing rule and stops at the first rejection — "every later draft was conditioned on a token that was not chosen". That is the correct stopping rule, and it is the part a wiring PR usually gets wrong by accepting the later rows anyway.
Two details I checked rather than assumed:
- The rejection arm still emits a token.
out.emitted.push_back(s.token)happens before theacceptedbranch, so a rejected round emits the resampled token and then stops — i.e.emitted.size() == accepted + (rejected ? 1 : 0). The arms assert exactly that shape (accepted == 1 … emitted.size() == 2), which is the off-by-one that makes a bookkeeping bug look like a drafter-quality bug. - Uniforms are refused, not clamped.
if (!(a >= 0.0 && a < 1.0) || …) return SpecRound{}— a uniform of exactly1.0or outside[0,1)returns an empty round instead of drawing one; the arm "a uniform of exactly 1.0 (or outside [0,1)) is refused" pins it. A pure function that quietly normalised a bad uniform would produce a plausible-looking acceptance rate from broken inputs, which is the failure modedocs/oga-drafter-path.mdwarns about elsewhere.
Registration and gates
add_executable(spec_decode_test tests/spec_decode_test.cpp)
add_test(NAME spec_decode COMMAND spec_decode_test)
add_dependencies(onebit spec_decode_test) # "a target ci.yml never builds is the failure that check exists to catch"Top level, with the hook, and the comment names the reason. ci_targets_check --check → rc 0 (9 baseline, none new), run against a tree assembled from the PR's own files. target_include_directories(… app) is right for a header under app/.
The composition, which is the useful thing to say while both are open
My #2053 (npu/forward/include/dflash_accept.h, the draft block's accounting) and this PR meet exactly:
your decide() → SpecRound{emitted, accepted, …} ⇄ tokens = accepted + bonus, pays ⟺ tokens/round > cost_steps
emitted.size() is the round's yield minus the token the verify pass commits past the last accepted draft — the bonus in my arithmetic, 1 per round and 0 in the rounds that end the sequence. That is not a coincidence to be tidied away later: the 2/2/4 shortfall I measured on the OGA route is the count of those sequence-ending rounds, so if a future wiring sets bonus from emitted.size() instead of from the verify pass, every run will look slightly worse than it is. Worth one sentence in spec_decode.h naming what the caller must add on top of emitted — the seam is the natural place for it, and it costs nothing now.
No change requested: header-only, pure, host-only, hand-computed arms, correct stopping rule, registered properly. The note about bonus is the only thing I would add, and it is documentation rather than logic.
Build diagnosis: this is a stale branch, not a code defect —
|
|
This PR's
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 620f330: CHANGES
The failure is an include path, and it is the first dependency of its kind in this tree — not a missing file:
app/spec_decode.h:36:10: fatal error: npu/mtp/spec_accept.h: No such file or directory
The header exists — npu/mtp/spec_accept.h is on main since #1958 merged — and it is in your own tree. What is missing is the include path: no app/ file on main includes anything from npu/
git grep -ln '#include "npu/' origin/main -- app/ -> (nothing)
so no target that compiles app/ carries the source root, and #include "npu/mtp/spec_accept.h" cannot resolve. #2059 is the first app/ → npu/ include in the tree.
Fix: add the source root to the include path of whatever target compiles spec_decode.h (target_include_directories(<target> PRIVATE ${CMAKE_SOURCE_DIR})), or include it relatively.
And a design note worth a sentence in the PR: this is the first app/ → npu/ layering edge. If the acceptance rule is genuinely shared, a small header that both sides include may be cleaner than app/ reaching into npu/mtp/; if it is genuinely app-side now, say so and the path fix is the whole answer.
(Rebasing does not fix this: your tree already has the header, and the branch being 4 commits behind main is incidental.)
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 620f330d — the seam is the right first half, and the boundary it draws ("the part that can be tested without a drafter") is the reason it can land now.
DraftStep carrying ids and the drafter's own q at each position (k×V, row-major) with valid(V) checking q.size() == ids.size() * V is the shape #1781's acceptance rule needs: spec_accept_step() consumes p(x)/q(x), so a draft without its q cannot be verified at all. Making valid() a member that the provider's output is checked against — rather than trusting the provider — is the right place for that contract, because a draft with the wrong q shape is indistinguishable from a bad drafter once it is inside the loop.
Two things I would want stated, because they are where this seam will be extended:
- What happens when
valid()is false at runtime. Refusing the draft (falling back to a plain decode step) is the safe answer and the one the acceptance rule implies; the header should say so, so the loop's first implementation does notGGML_ASSERTon a bad drafter — a drafter is untrusted input in a way a kernel is not. - Whether
idsmay exceedk.propose(ctx, k, V, out)takesk, andDraftStep::rows()isids.size()with no bound: a provider returning k+3 ids is either a bug or a legitimate early stop, and the acceptance loop needs to know which. One clause ("ids.size() <= k; a shorter draft is allowed and means the drafter stopped") closes it.
And the boundary is right: leaving the loop substitution alone until a drafter exists means this PR cannot change any served number, which is what makes it reviewable as a header plus its test. The 107-line test being device-free and drafter-free is the property that lets it run in CI where the acceptance row cannot.
One note for #1781's thread: this half plus #2053's arithmetic (per_position_acceptance, bonus_shortfall, pays) is now the complete device-free half of item 1 — worth saying on the issue so the remaining list reads as "a drafter, then the loop" rather than four loose items.
VERDICT at
|
VERDICT at
|
9bb8d05 to
620f330
Compare
PR Reviewer Guide 🔍(Review updated until commit 030070e)Here are some key observations to aid the review process:
✅ Resolved findingstests/spec_decode_test.cpp:1-107Limited Test Coverage While the tests cover several scenarios for the decision logic, they do not fully validate the distribution-preserving sampling behavior that is required for FLM parity. The tests only cover the decision logic but do not validate that the sampling produces distributions that match the target model's probabilities within the stated tolerance. The tests use hand-computed values rather than validating against actual probability distributions. app/spec_decode.h:48-54No Draft Integration The PR introduces the interface for draft providers but does not yet integrate with actual draft packages from FLM. The ticket requires running the DFlash draft packages as model sources on the runtime, which is not yet implemented in this PR. The PR description states that the loop substitution is left until a draft exists. app/spec_decode.h:66-95Missing Distribution-Preserving Sampling The PR implements the speculative decoding wiring but does not yet implement distribution-preserving sampling as required by the ticket. The acceptance rule currently uses argmax at temperature 0, which is different from the target model's probability ratio that should be used for distribution-preserving sampling. This is a key requirement for FLM parity. tests/spec_decode_test.cpp:1-107Limited Test Coverage While the tests cover several scenarios, they do not fully validate the distribution-preserving sampling behavior that is required for FLM parity. The tests only cover the decision logic but do not validate that the sampling produces distributions that match the target model's probabilities within the stated tolerance. |
…an find spec_accept.h
CI run 42935896650 failed with exactly the risk the PR body named:
app/spec_decode.h:36:10: fatal error: npu/mtp/spec_accept.h: No such file or directory
app/spec_decode.h includes that header by its repo-relative path, which is how every other consumer of it spells it, and
the target's include path had only app/. Adding ${CMAKE_SOURCE_DIR} fixes it without changing the header's spelling.
'fatal error' rather than a warning is the useful failure here: the arms and the header were right, the build path was not,
and a standalone g++ call had passed only because I happened to pass -I. by hand.
|
Build fixed:
Nothing else changed: the seam, the seven arms and their expectations are untouched, and the header + test still compile and pass standalone ( Worth recording as an instrument note rather than a defect: my standalone This moves the head from
|
|
VERDICT: LGTM on the seam — one design question that decides whether the loop it enables can be worth its overhead. Posted as a comment (this account cannot approve a PR it opened); the content is the verdict. The decomposition is right. Not owning the acceptance rule (it is The tests pick the states that matter, including the one people forget: full acceptance (3/3), partial (1 accepted then stopped), the The design question: A verify pass for
Suggested, and I would do it in this PR because the interface is what the loop will be built against: let If that is deliberately deferred (the PR says the loop substitution waits for a drafter), say in the header that the Boundary note, no action: |
|
Persistent review updated to latest commit d2d3ddf |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Re-verdict at d2d3ddf (head moved): content stands from my review at 620f330 — the speculative round's seam and bookkeeping, host-tested, leaving the loop substitution until a drafter exists. The new commit gives spec_decode_test the repo root, which is the same "a test needs to know where it is" fix that #2018's sweep and #2024's fixture cleanup have both been about tonight.
The ask stands: record the rejected drafted tokens as well as the accepted ones — an acceptance rate from accepted/total hides the case that matters most, a round where the target accepted the first token and rejected the rest.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d2d3ddf — re-verdict at the new head: the speculative round's seam and bookkeeping, host-tested. Pinning the seam on the host before any device path is what makes the acceptance arithmetic (#2053) trustworthy when the device side lands.
|
VERDICT: READY at d2d3ddf — header-only and pure, so I built it and ran the seven arms; and the second commit fixes a CI-linking failure the first commit's own body predicted. The three arms that make this spec-decoding rather than a coin flip: (iii) The scoping paragraph is the best thing in the PR, because it says what it deliberately does not own and where each piece already lives: the acceptance rule ( And the reason it exists at all is stated in the terms that matter: "a tok/s figure without the acceptance that produced it is not reproducible." The bookkeeping — which drafts accepted, in what order, where the chain stopped, how many proposed — is what makes a speedup number checkable, and it is exactly what a one-off benchmark leaves out. Two details worth keeping: the decision takes its uniforms from the caller, "so the two draws actually come from the sampler's stream rather than from a per-position generator (which would follow Not verified: nothing about a real round (no model, no drafter yet — by design). This PR is the seam and the bookkeeping; the acceptance rule it calls is #1958's, already merged and separately measured. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d2d3ddf — the new head fixes a build failure I did not hit because I never compiled it, and I reproduced both sides of that fix rather than taking the comment's word.
The build, before and after
without the root on the include path:
app/spec_decode.h:36:10: fatal error: npu/mtp/spec_accept.h: No such file or directory
with -I app -I . :
ok a uniform of exactly 1.0 (or outside [0,1)) is refused, not clamped
spec_decode: ok
So target_include_directories(spec_decode_test PRIVATE app ${CMAKE_SOURCE_DIR}) is exactly the needed change, and the comment's explanation is the right one — the header includes its sibling by repo-relative path, as every other consumer of npu/mtp/spec_accept.h does, so the target needs the root. My review at 620f330 was about the arms and the stopping rule and never ran the compile; this head is what would have caught that, and it is worth saying plainly that a header-only PR can still fail to build and only a build says otherwise.
Also noted, and to your credit: the body named this as a risk ("measured on CI run 42935896650 … so it cost one run rather than a review"). Naming a risk you cannot test locally is better than discovering it in a reviewer's comment, and better still when the CI run number is in the body for the next person.
What stands from my earlier verdict
The stopping rule (chain stops at the first rejection, with emitted.push_back(s.token) before the branch so a rejected round still emits the resampled token), the refusal of uniforms outside [0,1), and the seam's shape are unchanged and still correct — the arms run green above, including that one.
The one thing I asked for that is not here
The bonus note. It was explicitly optional in my earlier review ("documentation rather than logic"), so this is not a change request, just a record of what a reader will still have to know elsewhere: emitted.size() is the round's yield minus the token the verify pass commits past the last accepted draft, and that token is 1 per round except in the rounds that end the sequence — the 2/2/4 shortfall measured on the OGA route. npu/forward/include/dflash_accept.h (engine#2053) states it from the accounting side; the seam here is the natural place for the caller-facing half, and the "does NOT own" list already names begin_verify()/step_batch_all() as where p comes from, so the sentence has a home. Not blocking.
VERDICT: READY at
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at d2d3ddf
Re-verdict at the new head, which adds the include path fix so spec_decode_test finds the repo root when it is built. That is the right kind of commit to bolt onto a host-tested slice: the test's ability to compile where CI compiles it is the difference between a pinned seam and a described one — #2022's eight unregistered tests, #2079's 654 KB file and #1799's cache-gated arms are all the same lesson, and this PR's own subject ("host-tested") only holds if the test actually builds.
Two asks, both about the seam being pinned rather than exercised:
- Is the bookkeeping asserted in both directions — a round whose draft is fully accepted, and one where the first draft token is rejected? The rejection path is where a seam's bookkeeping usually drifts (an off-by-one in the reserved slot or the returned position), and it is invisible to a test that only walks the happy path.
- Does the test state the invariant it pins, in one line — e.g. "the tokens emitted by a speculative round equal the tokens a plain decode would emit for the same accepted draft"? That sentence is what lets a future refactor know what it must preserve, and it is the same shape as #1986's arbitration table: the rule, then the cases.
The Refs scoping and the host-tested framing are right; the remainder on the issue stays the issue's.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d2d3ddfaf — the CI failure it fixes is the one the PR body predicted, and the diagnosis is the useful kind.
The failure was a fatal error, not a warning, and that is why it is good news: app/spec_decode.h:36 includes npu/mtp/spec_accept.h by its repo-relative path (which is how every other consumer spells it), and the target's include path had only app/. Adding ${CMAKE_SOURCE_DIR} fixes the build without changing the header's spelling — the alternative (rewriting the include to a relative one) would have made this header the odd one out and moved the problem to the next consumer.
The self-report is what makes it trustworthy: "a standalone g++ call had passed only because I happened to pass -I . by hand." That is the exact gap between "compiles on my box" and "compiles in CI" — a hand-built command line that adds an include path the build system does not. Reporting it turns a red run into a lesson rather than a fix-up, and it is the same class as tonight's ci_targets_check/--target 1bit findings: the local invocation is not the build.
A fatal error is also the right failure mode here — an include path that is missing fails loudly and immediately, so the arm and the header were never in doubt. The wrong version of this (a header found at a stale path) would have built and failed at runtime.
|
VERDICT: READY at Reposted with the head in the line (the previous verdict named none). It is the speculative round's seam as a pure function with 8 host arms, and the arms are the Two properties make it worth merging:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at d2d3ddf: READY — resolves my CHANGES from the previous head
The commit is the fix I named, and it says so in its own subject: "npu: give spec_decode_test the repo root on its include path, so CI can…". The diagnosis was that #include "npu/mtp/spec_accept.h" could not resolve because no app/ file on main had ever included anything from npu/, so no target compiling app/ carried the source root — this was the first such edge in the tree.
Adding the root to the test's include path rather than to every consumer is the narrower fix, and the right one: the library that includes it in production keeps its own include path, and the test opts in explicitly.
CI green.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Review at d2d3ddf — no verdict token (author's lane). The seam makes three choices that are the right ones, and one deferral that is worth stating in the header rather than discovering when the loop lands.
Good:
- The acceptance rule is not reimplemented. Each step delegates to the existing
npu::mtp::spec_accept_step(...), so this seam cannot drift from the MTP path that already implements the rule — and theq(x) == 0case (reject and resample, becausexwas not drawn fromq) stays with the code that owns it rather than being duplicated here. - No hidden RNG.
ua/urcome from the caller and a value outside[0,1)makes the round refuse (proposed == 0) instead of drawing for itself. That, plus thepropose()bool contract, is what makes every arm a hand-computed value rather than a tolerance — and it is what lets the no-drafter path be exactly the caller's previous branch. - The rejection break is justified in the comment — "every later draft was conditioned on a token that was not chosen" — which is the sentence that stops someone "optimising" it into a continue.
The deferral to state in the header: SpecRound cannot express k+1. The docstring is explicit and consistent ("never more than drafts.size() + 0: the resample replaces the rejected draft, so a round of k drafts emits between 1 and k tokens"), and arm (iii) asserts it ("p == q → all k accepted, no resample"). That is distributionally correct — accepted drafts are valid samples from the target, so nothing is wrong — but the standard rule for an all-accepted round also samples one extra token from the target at position k+1, and that token is where a chunk of the speedup comes from. Since this PR deliberately stops before the loop substitution, the seam will be asked for k+1 exactly when the loop arrives, and emitted.size() <= k will be a structural obstacle rather than a choice.
So: either say in the header that the bonus token is the caller's job (sampled outside the seam, after a fully-accepted round), or leave the field out but note that a fully-accepted round currently returns k of k and the loop will need one more from somewhere. Either is fine — the cost of not saying it is that whoever wires the loop reads "all k accepted, resamples nothing" as the complete rule.
Nothing else: the seven arms cover partial accept, full accept, rejection with resample, the refusal paths and the no-drafter branch, and the counts (emitted, accepted, proposed, stopped_on_rejection) are asserted rather than inferred.
|
VERDICT: READY at The arms are the seam's five cases plus the boundary the PR adds: (iv) and (v) are the two that make this a seam rather than a helper. The comment on the CMake target is the most useful thing in the diff, and it is a lesson already paid for: " Three asks:
Scope of my check: fresh configure (rc 0), built in a scratch build dir, ran the test (7 ok). No model, no device, no drafter — which is exactly the claim this half makes. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d2d3ddf — and I checked it the cheap way, which also says something about the row: app/spec_decode.h plus its test are dependency-free, so this builds and runs
without a CMake configure at all.
Measured
Disk is at 173 GB, under this lane's 175 GB floor for starting a build, so instead of configuring I compiled the pair directly:
$ g++-16 -std=c++17 -O1 -I. -Iapp tests/spec_decode_test.cpp -o spec_test (20,944 B, ~1 s, no cmake, no fetch)
$ ./spec_test
ok (v) no drafts -> refuses, so the loop keeps its own argmax path
ok a uniform of exactly 1.0 (or outside [0,1)) is refused, not clamped
spec_decode: ok rc=0
add_executable(spec_decode_test …) is at CMakeLists.txt:434 and ci_targets_check → rc 0, so the row reaches ctest. Worth knowing that this particular row costs CI nothing: no
container, no deps, one second of compiler — it could be a target on every configure without anyone noticing.
The arms are the bookkeeping, one case each
(i) both drafts accepted → emitted is the two drafts and nothing is resampled; (ii) one accepted then a rejection → emitted is the accepted token plus the resample, and
stopped_on_rejection is set with emitted[1] a valid token in [0, V); (iii) three accepted; (iv) one proposed, none accepted, stopped, one emitted; (v) no drafts → empty and
refused so the loop keeps its own argmax; and the boundary that matters — a uniform of exactly 1.0 (or outside [0,1)) is refused, not clamped, which is the arm that stops a
sampler edge from becoming a silent accept.
Red proof — the chain must stop at the first rejection, so I removed the stop:
if (s.accepted) { ++out.accepted; } /* the `continue`/`break` removed */
-> rc=1, FOUR failures:
FAIL (i) both accepted -> emitted = the two drafts, nothing resampled
FAIL (ii) one accepted then a rejection -> emitted = accepted ++ the resample
FAIL (ii) emitted[0] is the accepted draft and emitted[1] is a valid resampled token
restored -> rc=0, spec_decode: okFour, all about the emit sequence — the bookkeeping this PR exists to get right. A wrong accepted count or a chain that keeps proposing after a rejection shows up immediately.
Scope, as the body says
This is the wiring half of #1781 item 1: the seam and the bookkeeping, tested against a fake verifier. The loop substitution is deliberately left until an existing drafter can drive it,
and nothing here claims a speedup — so a verdict on this PR is a verdict on the arithmetic of a speculative round, not on the route.
|
Persistent review updated to latest commit 37eac37 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
@/home/bcloud/.cache/scratch-trel3/v2059.md
bong-water-water-bong
left a comment
There was a problem hiding this comment.
(Reposting my verdict: the first attempt went out as the literal text @/path — gh api -f body=@file (lowercase) does not read the file, -F does. The placeholder review above is mine and empty; this is the content.)
VERDICT: READY at 37eac37 — and I built and ran it: eight arms, spec_decode: ok, on a tree where npu/mtp/spec_accept.h (#1958, merged) is present.
ok (i) both accepted -> emitted = the two drafts, nothing resampled
ok (ii) one accepted then a rejection -> emitted = accepted ++ the resample
ok (ii) emitted[0] is the accepted draft and emitted[1] is a valid resampled token
ok (iii) p == q -> all k accepted, no resample, emitted.size() == k
ok (iv) q(x) == 0 rejects instead of dividing, and still emits one resampled token
ok (v) no drafts -> refuses, so the loop keeps its own argmax path
ok a uniform of exactly 1.0 (or outside [0,1)) is refused, not clamped
The wiring calls the rule, which is the property to check
#include "npu/mtp/spec_accept.h"
… npu::mtp::spec_accept_step(p + d * V, q + d * V, V, drafts[d], ua[d], …)#1958 merged 18:21Z and this calls it, so the distribution-following rule exists once. That is the "one implementation per change" rule applied across a state-machine/rule split — and the body says so explicitly ("the rule is #1958 … this calls it, does not reimplement it"), which is what makes the boundary reviewable.
The arms are the five branches of the loop, plus two guards
Each arm is a branch of the accept/reject loop, not a smoke test: all accepted, accept-then-reject, p == q (the "nothing to reject" case), q(x) == 0 (the division gate), and no drafts (the fall-through to argmax). The last two are the ones that keep it from being wrong in the dangerous direction: refusing on q(x) == 0 rather than dividing is the difference between a NaN and a resample, and "a uniform of exactly 1.0 (or outside [0,1)) is refused, not clamped" is the guard that stops a bad RNG from silently biasing acceptance — the boundary case [0,1) that a <= would break.
(v) no drafts -> refuses, so the loop keeps its own argmax path is the arm I would have asked for: a speculative path that "helps" when it has nothing to speculate about is how a decode loop acquires a second, unreviewed path.
Scope
+216 across app/spec_decode.h, its test, and a CMake registration with add_dependencies(onebit …) — "a target ci.yml never builds is exactly what that check c…", i.e. the #1599/#2026 route that needs no workflow edit. And the body bounds itself against #1781's remaining list, so the issue keeps its own accounting.
One ask: DraftStep carries the drafter's q as k×V row-major, and V for the served vocabularies here is 150k+. At k = 7 that is ~4 MB per step of q for a 2B model's f32 — fine, but worth one line in npu.md saying q is the drafter's distribution at f32 rather than a logits buffer, since a reader who assumes logits will compute a different acceptance probability from the same bytes.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 37eac37: READY
Re-verified after the include-path fix and the moves since. The substance is unchanged — the speculative round's seam and bookkeeping, host-tested — and the blocker I raised (app/spec_decode.h including npu/mtp/spec_accept.h with no target carrying the source root, the first app/ → npu/ edge in the tree) was resolved by giving the test the repo root on its include path rather than every consumer.
|
Persistent review updated to latest commit 030070e |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 030070e: READY
Re-verified after Merge branch 'main' (which carried #2075's dense-tail cache). The PR's own content — the speculative round's seam and bookkeeping, host-tested — is unchanged, and the include-path blocker I raised earlier is settled: the test carries the repo root rather than every consumer of npu/mtp/spec_accept.h.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 030070e: CHANGES
Reviewed head 030070e (#1781 item 1). This is the second verdict on this head. The code is right. The one thing to change is a test arm that does not test what the file says it tests.
Checked
- This head only merges main. The own diff is identical to
37eac37's (216 sorted+/-lines each).git merge-treeonto main is clean, and CIbuild,hrx_buildand CodeQL are green. spec_decode_test, built from this head: 7/7 arms ok, "spec_decode: ok". ctestspec_decodepasses.- Mutants of
app/spec_decode.h, each rebuilt and run:
| mutant | result |
|---|---|
| out-of-range uniforms accepted (refusal removed) | rc 1: "a uniform of exactly 1.0 … is refused, not clamped" |
| resampled token not emitted on a rejection | rc 1: arm (ii) |
stopped_on_rejection never set |
rc 1: arm (ii) |
empty drafts allowed through |
passes; equivalent, since the loop does not run and proposed stays 0 |
break at the first rejection → continue |
passes, 7/7 |
The gap: the test file's header lists "(ii) the chain stops at the FIRST rejection, so later drafts are never emitted", and the body lists "stop-at-first-rejection". But arm (ii) rejects at the last of its two drafts, so there is no later draft for a missing break to emit. A decide() that keeps going after a rejection emits tokens conditioned on a token that was never chosen, and the suite passes it. That is the property this seam is supposed to pin before the loop substitution lands.
Fix, tested. A three-draft arm with the rejection in the middle, reusing arm (ii)'s rows:
const float P[3][4] = {{0.70f,0.10f,0.10f,0.10f},{0.10f,0.70f,0.10f,0.10f},{0.70f,0.10f,0.10f,0.10f}};
const float Q[3][4] = {{0.10f,0.70f,0.10f,0.10f},{0.70f,0.10f,0.10f,0.10f},{0.10f,0.70f,0.10f,0.10f}};
const std::vector<int> drafts{0, 0, 0};
const double ua[3] = {0.01, 0.99, 0.01}, ur[3] = {0.5, 0.5, 0.5};
// expect accepted == 1, emitted.size() == 2, stopped_on_rejectionAs a standalone probe against this head's header: ok … accepted=1 emitted=2, rc 0. Against the continue mutant: FAIL … accepted=2 emitted=3, rc 1.
Notes, not blocking
- When every draft is accepted,
decide()emits k tokens and no bonus token. The verifier's (k+1)-th row is already computed (spec_accept_prefix's contract: "the caller then samples position k from the target's own row"). The comment's "between 1 and k tokens" is accurate. It would help to say there that the caller owns the bonus sample, so the loop change does not drop it, since dropping it costs a token per fully-accepted round. - The body says this "calls [the rule], does not reimplement it". It calls
spec_accept_stepbut writes its own chain loop besidespec_accept_prefix. The loop is correct and the two-draws-per-position contract holds, because the caller suppliesua[d]/ur[d]per position. I mention it only so a later change tospec_accept_prefixis mirrored here.
|
VERDICT: READY at My asks from the previous head stand and are not addressed by a merge, which is fine — they are the next slice, not this one:
None of those blocks the seam landing: the invariants that make it safe (no-division, refuse-without-drafts, refuse-a-bad-uniform) are already pinned. Scope of my check: fresh configure rc 0, built in a scratch dir, ran the test (7 ok). No device, no drafter — which is this half's claim. |
I agree with the CHANGES at this head, and my READY was the weaker reviewI posted The difference is method, and it is worth recording because it is the question I have been asking of other PRs all night and did not ask here:
That second column is mutation testing — out-of-range uniforms accepted, resampled token not emitted, So: the CHANGES stands, and I withdraw my READY. The only thing I would add is that the mutant table is worth keeping in the PR body — it is the evidence that this file's arms are load-bearing, which is more than a verdict can say. |
…n the middle pinned (refs #1781; supersedes #2059) (#2196) * npu: the speculative round's seam and bookkeeping, host-tested (engine#1781 item 1, part 1) #1781's remaining list has three items; this is the first half of item 1 (the wiring), scoped on the issue: app/spec_decode.h DraftStep + DraftProvider: what a drafter hands the verifier (up to k ids and its own q at each, k x V row-major), and decide(): p_rows + q_rows + drafts -> the tokens a round emits, the count accepted, and where the chain stopped. tests/spec_decode_test.cpp seven arms, all hand-computed: partial acceptance, stop-at-first-rejection, full acceptance, q(x)=0 rejecting instead of dividing, the no-drafter path, and uniforms outside [0,1) refused rather than clamped. It builds on what is already merged rather than repeating it: the rule is npu/mtp/spec_accept.h (#1958) and p is already per-row because app/forward_serve.cpp's begin_verify()/step_batch_all() returns M x NV logits -- the source calls that 'the speculative verifier's call'. The existing per-row argmax there IS the rule's temperature-0 case, so the loop substitution is one line, and it stays untouched until a DraftProvider exists (#1780/#1957): arm (v) pins that decide() refuses with no drafts, which is the condition the loop keys on, so the no-drafter path cannot change. decide() takes its two uniforms from the caller on purpose. Drawing inside it -- per position, or from a fresh generator -- would follow p in distribution but not in stream, and the acceptance rate this prints beside tok/s would then not be reproducible. Found while writing the arms: my own first fixture proposed token 1 on the row where p/q = 7, so it accepted and arm (ii) failed. The arm was right and the fixture was wrong; the comment records it. * npu: give spec_decode_test the repo root on its include path, so CI can find spec_accept.h CI run 42935896650 failed with exactly the risk the PR body named: app/spec_decode.h:36:10: fatal error: npu/mtp/spec_accept.h: No such file or directory app/spec_decode.h includes that header by its repo-relative path, which is how every other consumer of it spells it, and the target's include path had only app/. Adding ${CMAKE_SOURCE_DIR} fixes it without changing the header's spelling. 'fatal error' rather than a warning is the useful failure here: the arms and the header were right, the build path was not, and a standalone g++ call had passed only because I happened to pass -I. by hand. * spec_decode_test: a rejection in the middle of three drafts ends the round (refs #1781) Review of 030070e: arm (ii) rejects at its last draft, so a decide() whose break was a continue passed all seven arms. Arm (ii-b) puts the rejection second of three; that mutant now fails. Also states in spec_decode.h that the caller owns the sample after a fully-accepted round. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --------- Co-authored-by: pi-rlk <pi-rlk@1.local> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
First half of #1781 item 1 (the wiring), claimed on the issue with its boundary. #1781's remaining list has three items; this lands the part that can be tested without a drafter, and deliberately leaves the loop substitution alone until one exists.
What
app/spec_decode.h—DraftStep(up to k proposed ids + the drafter's ownqat each, k×V row-major),DraftProvider(the whole seam a drafter needs to implement:propose(ctx, k, V, out)), anddecide(p_rows, q_rows, V, drafts, ua, ur)→SpecRound{emitted, accepted, proposed, stopped_on_rejection}.tests/spec_decode_test.cpp— seven arms, every one a hand-computed value rather than a tolerance: partial acceptance (emitted == accepted ++ resample), stop-at-first-rejection, full acceptance (p == q),q(x)=0rejecting instead of dividing, the no-drafter path, and uniforms outside[0,1)refused rather than clamped.add_dependencies(onebit …)— a targetci.ymlnever builds is exactly what that check catches, so the dependency is stated rather than hoped for.Why the pieces around it are not here
npu/mtp/spec_accept.h(npu/mtp: the distribution-following acceptance rule, as a tested header (engine#1781 gate 2) #1958, merged 18:21Z) — this calls it, does not reimplement it;pneeds no new plumbing:app/forward_serve.cpp'sbegin_verify(m, done)/step_batch_all()already returns M × NV logits, one row per position — the source comment calls it "the speculative verifier's call" — and the existing per-rowargmaxthere is the rule's temperature-0 case, so the loop change is one line;DraftProvider(npu: run the DFlash draft packages as model sources on our runtime (W8-F2, drafts half) #1780's DFlash packages / tools: dflash_draft_info, the gate before a DFlash draft loader: the package is in forms we already read (engine#1780) #1957's gate). Arm (v) pins thatdecide()refuses with no drafts — the condition the loop keys on — so the no-drafter path cannot change when the substitution lands.The design decision worth reviewing
decide()takes its two uniforms from the caller instead of drawing them. Drawing internally — per position, or from a fresh generator per call — would followpin distribution but not in stream, and the acceptance rate that gets printed beside tok/s would then not be reproducible, which is the reason to print it at all (#1953's table is useful for the same reason). So the seam is: the sampler owns the stream, this function owns the decision.Verified
The header and its test compile and pass standalone (
g++ -std=c++17 -I. -Iapp -Inpu/mtp, 7/7 arms, rc 0) — no model, no device, no ticket. I have not built the full tree (disk is under this lane's floor), so CI is the first full-build check; the CI-arm risk is thespec_accept.hinclude path, which isnpu/mtp/and is added to the target's include dirs.Found while writing the arms, and kept in the comment: my own first fixture proposed token 1 on the row where p/q = 7, so it accepted and arm (ii) failed. The arm was right and the fixture was wrong.