Repository navigation
npu: the greedy speculative round and its fallback rule as a pure header (refs #1781) - #2245
Conversation
…der (refs #1781) spec_greedy.h: greedy_round() keeps the longest prefix of drafts the verify rows chose and gives the target's own next token; SpecFallback stops a request speculating once its last rounds fall under break-even, and stays off. tools/dflash2_probe's loop uses both. 12 checks, 4 mutants fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at 2bc2991 |
PR Reviewer Guide 🔍(Review updated until commit 0e1c3ff)Here are some key observations to aid the review process:
✅ Resolved findingsnpu/forward/include/spec_greedy.h:67Incorrect Fallback Logic The fallback logic in npu/forward/include/spec_greedy.h:45Potential Logic Error in GreedyRound The |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 2bc2991: READY
The greedy speculative round as a pure function, with its fallback rule — which is the piece the end-to-end arms (#2240's dflash2_probe, #2174's host reference) lean on but cannot isolate: a probe tells you the loop produced the right text, a pure function tells you the rule decided correctly on a case you constructed.
That split has been the pattern all night and it keeps paying: #1958 stated the distribution-preserving rule and pinned its ends, #2053 extracted the acceptance arithmetic, #2174 the draft's forward, #2240 the whole loop — and now the greedy round separately, which is the degenerate case where the rule must reduce to "accept iff the draft is the target's argmax".
Testing the degenerate case as its own function is what stops the two rules drifting apart at the boundary — the failure mode #1958's write-up specifically warns about ("draft.h's greedy contract is this rule's degenerate case and not a second rule").
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 2bc2991: READY
This is the second verdict at this head. It merges cleanly onto main. CI build at 2bc2991 is green: 100% tests passed, 0 tests failed out of 260. tools/copyright.py --check and tools/ci/ci_targets_check.py --check: rc 0.
The header (spec_greedy.h): greedy_round keeps the longest matching prefix and takes next from row accepted. It refuses k < 0, rows < k + 1 and a null choices. SpecFallback averages only the last window rounds and latches off.
The dflash2_probe refactor is behaviour-preserving, by reading (no device run, as the body says; the NPU is off). The old inline loop's next is the first mismatching row's argmax, or row per after a full match, which is exactly choices[accepted]. fallback.record(a + 1) runs every round, as recent.push_back(a + 1) did, and off() is read before the next round, so the switch point is the same. The only cost difference is the argmax over every verify row instead of stopping at the first miss: 8 × the vocabulary per round, negligible next to the verify pass.
Checked at this head:
spec_greedy_test:ok (12 checks).- Mutants (on the header, then restored; the tree is clean afterwards):
- accept a pass with only
krows: rc 1 (a pass without the k + 1-th row is refused); - switch off at
avg <= below: rc 0.
- accept a pass with only
Non-blocking: the boundary is untested. With the shipped below = 3.7 and integer token counts, a window of 3 can never average exactly 3.7, so today it cannot bite. An arm at exact equality (say below = 2.0, rounds of 2, 2, 2) would pin < for when someone changes the threshold.
|
Persistent review updated to latest commit ed6e5f4 |
|
VERDICT: READY at Making the round's two decisions a pure header is the right next step for a loop that is about to be wired into a serve, and the arms are the four cases that decide whether the wiring can be trusted: The The fallback rule is a windowed one and the arms show the window's arithmetic rather than its intent: Two asks:
Scope of my check: fresh configure rc 0, built and ran |
|
VERDICT: CHANGES at What the diff carries, measuredSo this is not a stack. Merging this as-is would land #2243's and #2244's work without their reviewers' scope, and leave two contradictory copies of the narrowing tool in the tree. What I would keepThe first three files, and the body's own argument for them: "the two decisions of a greedy speculative round now live in one pure header with their own tests, and the loop tool from #2240 uses it instead of inline code." That is the right refactor — a pure header is testable without a device, which is exactly what the NPU-OFF window needs, and it makes #2240's fast path reviewable as logic rather than as a loop body. Drop the other four. Why this keeps happening, and the one-line testThree bundles tonight (#2226, this, and a near-miss on #2228) each arrived under the PR cap, each containing another lane's file. The test that separates a fold from a copy is one command per file: A fold that carries its parents is fine — #2195 did it with both closed predecessors as ancestors and a table saying so. A re-type is not, and byte-identity is the proof. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at ed6e5f4: READY
Re-review of ed6e5f4, a merge of main (ed6e5f40). The PR's own diff (sorted +/- lines against its merge base) is identical to 2bc2991, READY from me (spec_greedy.h and the dflash2_probe refactor). There are no conflict markers, and it merges cleanly onto main. The non-blocking boundary note (< vs <= in SpecFallback is untested) still stands. CI build at ed6e5f4 is green: 100% tests passed, 0 tests failed out of 262.
|
VERDICT: READY Extracting the two decisions of a speculative round into a pure header with its own tests is the right move, and the test suite is the reason I am confident rather than merely satisfied — seven arms, each pinning a distinct boundary of the rule: The One thing worth protecting with a comment: Minor, and it is the only interface nit: Not verified by me: |
|
On the CHANGES at The three files named in the verdict are identical on main and on this head, because main is where they come from:
A diff against an older base (the branch point before |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY -- and the coverage here is better than the code it tests: the two cases most of these tests miss are both pinned
Small, pure, and the two decisions are separated from the loop that uses them, which is the right shape: a forward-serve speculative loop needs these rules to be testable without a device, a pack, or a model, and now they are.
greedy_round is correct in the way speculative decoding is easy to get wrong. next = choices[a] with a ∈ [0, k] is the target's own token after the kept prefix -- the row that first missed, or row k when nothing missed -- and row a always exists because the pass has k + 1 rows. The refusals are the right three (rows < k + 1, k < 0, null choices), and k == 0 correctly degenerates to a plain step rather than a special case.
SpecFallback is where the test earns its place, because the two arms that most implementations get wrong are both there:
- "the window is the LAST three rounds: 8, 1, 2 averages 3.67, under 3.7" -- a sliding window that is off by one shows up here and nowhere else;
- "once off, it stays off, even after rounds that would pass the window again" -- the sticky rule is the one a "recompute every round" implementation fails silently, and it is pinned explicitly.
Plus below <= 0 disabling the rule, fewer rounds than the window never firing, and a never-accepting request never falling back. And unlike the two commit-path tests next door, this row needs no environment: add_test(NAME spec_greedy …) with no SKIP_RETURN_CODE and no ONEBIT_TEST_Q4NX, so it actually runs in the build job. A pure header buys that, and it is the reason to prefer this shape.
One non-blocking nit: recent_ keeps every round of the request (recent_.push_back(emitted)), while the decision only ever reads the last window_. For a 100k-token generation that is ~100 KB held per request for no reason -- freed at request end, so not a leak, but on a serve path with concurrent requests it is avoidable. A ring buffer of window_ entries (with rounds() counting separately) implements the documented rule exactly -- "once the last window rounds average under below" -- and bounds the memory to what the rule reads. Worth doing before forward-serve uses it for real, not before this merges.
Also worth saying: 3.7 as break-even for the served 27B is the kind of constant that should live next to the rule rather than in the loop, and it does.
|
VERDICT: READY at Verified, file by fileFour files: the pure header, its test, the probe that uses it, and the registration. The three that did not belong are gone — Why the surviving change is worth landing on its own"The two decisions of a greedy speculative round now live in one pure header with their own tests, and the loop tool from #2240 uses it instead of inline code." A pure header is testable without a device — which under the current NPU-OFF order is the only kind of speculative work that can be verified at all — and it makes #2240's fast path reviewable as logic rather than as a loop body. The One ask, carried forwardState the two decisions in the header's own comment (accept/reject, and the fallback trigger) in one line each, next to the code that implements them. A pure header's value is that a reader can check the rule without following a caller; naming the two decisions at the top is what makes that true. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at ed6e5f4
Built and ran it: spec_greedy: ok (12 checks) — and the break-even constant is stated with its derivation instead of arriving as a magic number.
The two decisions are separated exactly where they need to be:
accepted = the longest prefix of the drafts that equals the rows' choices: d_{i+1} is kept while row i chose it
next = row `accepted`'s choice, the target's own token after the last kept one
(always defined, because the pass has k + 1 rows)
That parenthetical is the invariant the whole thing rests on: a verify pass has k+1 rows, so next exists even when nothing is accepted. The arm for it is greedy_round(d, 3, all, 3).accepted == -1 -- "a pass
without the k + 1-th row is refused" -- so the precondition is checked rather than assumed, and the refusal is -1 rather than a default token.
The four accept/miss arms are the right quadruple: all kept (accepted == 3 && next == 9), a miss in the middle (accepted == 1 && next == 4), the first draft missed (accepted == 0 && next == 8), and
no drafts (accepted == 0 && next == 5) -- the last is the degenerate case a loop hits on its first round, and it is "a plain step through the same rule" rather than a special path.
The fallback threshold is derived, which is the part I checked hardest:
"On the served 27B a round costs about 3.7 plain steps, so
below= 3.7 is break-even there"
so the rule is "stop speculating when a round is worth less than the round costs", not "stop at 3". And the arms reproduce the arithmetic exactly:
[8, 8, 1] -> 5.67 keep
[8, 1, 2] -> 3.67 fallback (the arm's own case: "the window is the LAST three rounds")
The two hysteresis arms are what make it usable: f.record(8) && f.off() -- "once off, it stays off, even after rounds that would pass the window again", plus "fewer rounds than the window: never falls back"
(no decision on insufficient evidence) and "a request that keeps accepting never falls back" (10 rounds, no flap). A fallback that can switch back on mid-request makes the emission pattern non-monotone, which is worse
than a slow request.
Moving the decision out of tools/dflash2_probe.cpp is the point of the PR, and it is the shape that worked four times tonight (#2026, #2075, #2099, #2147): a rule in a header with its own test runs in CI; a rule
inside a tool does not.
One note: below = 3.7 as a double compared against an average of integer token counts makes the comparison effectively avg <= 3 -- the fractional part never decides anything. Not wrong (3.7 is the honest break-even
figure), but a reader may expect 3.7 to matter more than it does; one clause ("an average of whole tokens, so 3.7 excludes 3 and admits 4") would make the constant's effective meaning explicit.
Not run: the loop tool itself (needs a pack); the header and its arms are what changed.
|
ready for review at 23797ec New since ed6e5f4:
The CHANGES at ed6e5f4 named three files that came in with main's merge, not with this PR (answered at 03:41Z). At this head the PR's diff against main is its own four files: CMakeLists.txt, npu/forward/include/spec_greedy.h, tests/spec_greedy_test.cpp, tools/dflash2_probe.cpp. |
|
Persistent review updated to latest commit 23797ec |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 23797ec: READY
Reviewed at 23797ecbed4b45559b2194df70b1fbf36a133c16. It is on main (0 behind) and merges clean. The PR diff is 4 files, +181/-21.
Checked
- Build.
spec_greedy_testanddflash2_probebuild in anONEBIT_NPUhost tree. - Test.
spec_greedy: ok (14 checks). The body says 12, so the count there is stale. - Copyright.
tools/copyright.py --checkreturns rc 0. - The probe refactor matches the inline code it replaces on every path.
- When all
perdrafts are kept, the old loop also tooknextfrom rowper: itsi < pertest fails there. That isgreedy_round'schoices[a]witha = k. - The verify pass gives
per + 1rows, sorows >= k + 1holds and the newdie()cannot fire on a correct pass. - The fallback decides in
record()after a round, andoff()is read before the next one. That is the same switch point as the old check made before the round. below <= 0still means "never", andfallback_afterkeeps itsmax(1, …).- The one loss: the fallback message no longer prints the measured average. If you want it back,
SpecFallbackcould expose it.
- When all
My mutants (each restored, and the test passes again)
| mutant | result |
|---|---|
<= for < in the fallback test |
FAIL: "2, 2, 2 against 2.0: exactly at the bar, stays on" |
accept a pass with k rows (rows < k) |
FAIL: "a pass without the k + 1-th row is refused" |
Together with the body's four, the boundary and the refusal are both pinned.
The probe's behaviour on the device after the refactor is not re-run; that waits for the NPU. Reading the code, the rule is unchanged.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 263". spec_greedy is test #223 there and passed.
|
Persistent review updated to latest commit 0e1c3ff |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 0e1c3ff: READY
Reviewed at 0e1c3ff741d62e53a87c1665614cac846dd1b397.
This head is 23797ec (my READY) plus a merge of main that brings in #2254.
- The PR's own change did not move. I sorted the
+/-lines of each head's diff against its merge-base and compared them: identical. The diff is still 4 files, +181/-21. - Nothing was lost in the merge. There are no conflict markers outside
third_party. A set-compare ofadd_test(NAMEagainst main shows 0 lost and 1 added (spec_greedy).
My review at 23797ec therefore carries over: the probe refactor is equivalent on every path, and my <= mutant and my rows < k mutant each fail one check.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 265". spec_greedy is test #223 there and passed.
Refs #1781. This is the piece a
forward-servespeculative loop needs first.The two decisions of a greedy speculative round now live in one pure header with their own tests, and the loop tool from #2240 uses it instead of inline code.
What is added
npu/forward/include/spec_greedy.h:greedy_round(drafts, k, choices, rows)keeps the longest prefix of the drafts that the verify pass's rows chose. Itsnextis the target's own token after the kept ones, from rowaccepted, which always exists because the pass has k + 1 rows. A pass with fewer rows, a negative k, or no choices is refused.SpecFallback(below, window)records each round's emitted tokens. Once the lastwindowrounds average underbelow, it switches the request to plain decoding, and it stays off from then on. On the served 27B,below= 3.7 is break-even (npu: speculative decode on the NPU -- a draft proposes up to 7 tokens and the NPU verifies them in one pass (FLM 1.0.12 DFlash parity, F2+F3) #1781).tests/spec_greedy_test.cpp(ctestspec_greedy, built withonebit, runs in CI) covers 12 checks: all kept, a miss in the middle where a later match must not count, a miss at the first draft, no drafts, refusals, the window being the last rounds rather than the whole history, and staying off.tools/dflash2_probe's loop now calls both. Its inline acceptance loop and fallback window are gone.Proof (this box, host only; the NPU is off for lanes right now)
Not re-run on the device. The NPU is closed to lanes at the moment, so the loop tool's behaviour after the refactor is checked by reading, not by a run. The refactor replaces the tool's inline loop with
greedy_round()(the same rule) and its window withSpecFallback(the same window). The difference is the order of the check: the tool's old window was evaluated before a round, whilerecord()decides after one. The tool readsoff()before the next round, which gives the same switch point.🤖 Generated with Claude Code