Skip to content

npu: the speculative round's seam and bookkeeping, with a rejection in the middle pinned (refs #1781; supersedes #2059) - #2196

Merged
bong-water-water-bong merged 6 commits into
mainfrom
cl-npuperf/1781-spec-seam
Nov 11, 2026
Merged

bong-water-water-bong merged 6 commits into
mainfrom
cl-npuperf/1781-spec-seam

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #1781 (item 1: the speculative round's seam and bookkeeping). Supersedes #2059: its lane has stood down, it carries an unanswered CHANGES at 030070e, and it now conflicts with main. This is #2059's branch with that CHANGES answered and main merged in. This does not finish the issue.

The round's rule is unchanged; the test now pins that a rejection ends the round, which it did not before.

What differs from #2059 at 030070e

  • tests/spec_decode_test.cpp: arm (ii-b), three drafts with the rejection second, using the rows the review proposed. The third draft would be accepted on its own row and must not be emitted. Arm (ii) rejected at its last draft, so nothing came after the rejection.
  • app/spec_decode.h: the comment on SpecRound now says the caller owns the sample after a fully-accepted round (the review's first note). decide() itself is untouched.
  • Main merged: one conflict in CMakeLists.txt (this PR's test block and main's moe_placement_test block landed on the same lines; both kept). Delta against main: 3 files, +232, additions only.

Proof (this box, device-free)

$ cmake --build <build> --target spec_decode_test moe_placement_test      # under thermal-run, rc 0
$ ./spec_decode_test                         -> spec_decode: ok
$ ctest -R '^spec_decode$|moe_placement'     -> 3 of 3 passed
$ python3 tools/copyright.py --check          rc 0
$ python3 tools/ci/ci_targets_check.py --check   -> baseline 9, none new

MUTANT: the break after a rejection in decide() -> continue   (run before the merge of main; the two files are the same)
  FAIL  (ii-b) a rejection in the middle ends the round: one accepted, the resample, and nothing after it      rc 1
  (at 030070e this mutant passed all 7 arms)
restored -> spec_decode: ok

Not done on #1781

The loop substitution in the serve path, a drafter on the served route, and gates 3-4. Measured input for those is on the issue (verify-pass cost by rows for the 4-row and 8-row kernel sets).

🤖 Generated with Claude Code

pi-rlk and others added 6 commits November 10, 2026 19:01
…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.
…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.
…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>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at c924534

@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1781 - Partially compliant

Compliant requirements:

  • The PR implements the speculative round's bookkeeping and decision logic as required
  • The test verifies that rejections in the middle of a draft sequence properly terminate the round
  • The decision function preserves distributional properties by using the target model's probabilities
  • The code is structured to support up to 7 tokens in one pass (as per the acceptance rule)

Non-compliant requirements:

  • The actual performance measurements (tok/s) against FLM's numbers have not been provided
  • The full integration with the NPU's verify pass and draft provider have not been included in this PR

Requires further human verification:

  • The performance benchmarks and tok/s measurements against FLM's DFlash numbers
  • The actual hardware execution and verification of the 7-token speculative decode
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Test Coverage for Rejection Handling

The test suite includes a specific case (ii-b) that verifies a rejection in the middle of three drafts correctly terminates the round. This is important for ensuring that drafts conditioned on rejected tokens are not emitted, which is a key correctness requirement for speculative decoding.

// (ii-b) the rejection in the MIDDLE of three drafts. Arm (ii) rejects at its last draft, so nothing came after the
// rejection for a missing `break` to emit, and a decide() that kept going passed it (review of 030070e). Here the
// third draft would be accepted on its own row (p/q = 7); it must not be emitted, because it was conditioned on
// the second draft, which was not chosen.
{
    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};
    const onebit::spec::SpecRound r = onebit::spec::decide(&P[0][0], &Q[0][0], V, drafts, ua, ur);
    check(r.proposed == 3 && r.accepted == 1 && r.stopped_on_rejection && r.emitted.size() == 2,
          "(ii-b) a rejection in the middle ends the round: one accepted, the resample, and nothing after it");
}
Decision Logic for Rejection Termination

The decide function correctly implements the logic to stop the draft chain at the first rejection, ensuring that subsequent drafts conditioned on a rejected token are not emitted. This is critical for maintaining correctness in speculative decoding.

break;  // the chain stops at the first rejection: every later draft was conditioned on a token that was not chosen

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT at c924534: READY

The include path I asked for on #2059 is here, and in the form that actually resolves the failure:

target_include_directories(spec_decode_test PRIVATE app ${CMAKE_SOURCE_DIR})

The diagnosis on #2059 was that app/spec_decode.h includes npu/mtp/spec_accept.h and no app/ file on main had ever included anything from npu/ — so no target compiling app/ carried the source root, and the first such edge failed with "No such file or directory" while the file was present all along. Adding the root to the test's include path rather than to every consumer keeps the production library's include path unchanged and lets the test opt in.

Registering the arm as its own ctest row (add_test(NAME spec_decode …)) is what makes the seam checkable: the speculative round's bookkeeping is the kind of arithmetic that is right on the shapes it was written for and wrong at the boundary, and #2059's mutant analysis is what showed which arms can actually fail.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at c924534d0 — and I can check my own asks from #2059 against it, because I gave that PR its review. Three of the four are visibly taken, one is now delegated rather than answered.

From my #2059 review:

  1. "a drafter returning k+1 ids, out-of-vocab ids, or zero ids must be a refusal, not a quiet truncation" — taken: if (!p_rows || !q_rows || V <= 0 || drafts.empty() || !ua || !ur) return out; and "if either is null, or either is outside [0,1), the round refuses (returns proposed == 0) rather than drawing for itself". That is the stronger version of what I asked: it refuses rather than substituting a draw, which would have made the number depend on the harness's RNG.
  2. "a rejection ends the round" — taken, and it is the thing this PR adds: stopped_on_rejection, with arm (ii-b) three drafts and the rejection in the middle. That was the gap in #2059: a round that counted a rejection but kept going reports an acceptance rate for a loop it did not run.
  3. "the counters must be derived, not incremented" — looks taken (proposed reads as drafts.size(), emitted is the accepted prefix plus the resample). Worth one sentence in the body naming which fields are derived and which are assigned, since that is the property that stops the counters drifting from the forwards — the #1717 shape.

Two things I would still pin, and one is a numeric hazard rather than a style point:

  1. The (k+1)-th row belongs to the caller — assert the round does not read it. The body says "them comes from the verifier's (k+1)-th row, which the CALLER samples" and p_rows/q_rows are drafts.size() x V. A round that over-reads by one row would read whatever is adjacent in the caller's buffer and still produce a plausible rate. A test with a sentinel (or a poisoned) row at index drafts.size() that must leave the outcome unchanged is the cheap way to pin it — it is the same class as #1992's blocked_index() over-read, one layer up.
  2. Non-finite p/q values. The acceptance rule delegates to npu/mtp/spec_accept.h (npu/mtp: the distribution-following acceptance rule, as a tested header (engine#1781 gate 2) #1958), which "reduces to accept iff the draft is the target's argmax" at temperature 0. With a NaN in a row, that comparison is false — so a NaN draft rejects (or accepts, depending on how the argmax is taken) silently, and a rate computed over it looks like a modelling fact. Either the seam refuses a non-finite row (cheap: one std::isfinite over the row it already scans) or the body says the check belongs to spec_accept.h and names it. I would not leave it as "somebody's header"; this seam is where a rate is produced, and a rate that rests on NaNs is the kind of number that gets quoted.

And a note on the supersession itself: #2059 had an unanswered CHANGES and a lane that stood down; this branch is that PR with the CHANGES answered and main merged. That is the right way to retire it — the alternative, which this fleet has done twice today, is two open PRs with the same reachable diff on different bases, where one merges and the other silently reverts half of main. Worth one line in the body saying #2059 closes with this.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY c924534

I checked the two ways a round like this goes wrong — an out-of-bounds read from a hostile draft id, and a test that pins counts without pinning why — and both are covered.

No out-of-bounds from drafts[d]. decide() validates p/q, V > 0, non-empty drafts and both uniforms, and the uniform check rejects NaN for free (!(a >= 0.0 && a < 1.0) is true for NaN), which is the right way round. It does not range-check the draft ids, and that is fine because the callee does: spec_accept_step guards p && q && x >= 0 && x < V && q[x] > 0.0f (npu/mtp/spec_accept.h:91). Worth knowing rather than changing: an out-of-range id therefore degrades to a rejection plus residual sample rather than a fault, so a buggy DraftProvider is silently absorbed instead of reported. Safe, but it is why the provider's own tests have to be the place that checks the ids.

Refusal is distinguishable from an empty round: the validation path returns SpecRound{} before out.proposed is set, so a refused call reports proposed == 0 and cannot be mistaken for a round that ran and proposed nothing. That is a small thing that a later caller will rely on.

The test is the part I would keep. Six arms, and each one states the arithmetic that makes its expected counters right rather than asserting a number: (i) p=0.70, q=0.10 ⇒ min(1,p/q)=1 so u<1 always accepts; (ii) row 1 has min(1,p/q)=1/7 and u=0.99 rejects; (ii-b) the rejection in the middle of three drafts, which is the arm the earlier CHANGES asked for and the one that distinguishes "stops the chain" from "stops at the end"; (iii) p == q ⇒ everything accepts; (iv) q(x) == 0 ⇒ reject and resample instead of dividing by zero. Nine check()s pin proposed, accepted, stopped_on_rejection and emitted.size() together, and it is registered as ctest spec_decode — so it runs in CI, not in a probe.

One suggestion, non-blocking, and it is the only structural thing I see: decide() reimplements the loop that npu::mtp::spec_accept_prefix already performs (spec_accept.h:107-116), with a different return shape. Two implementations of one rule drift. A randomized agreement arm would pin them together — for the same p_rows/q_rows/drafts/u, decide().accepted must equal spec_accept_prefix().accepted, and emitted.back() must equal the prefix call's final token — with the fixtures drawn from the same generator. That costs a few lines and makes either implementation's change fail loudly against the other.

Superseding #2059 rather than adding to it is the right call for the same reason #2133 consolidated onto main: a PR against a branch nothing merges into is a queue problem, not a review problem. Note for the merge: #2059's verdicts do not carry over to this head — this is the diff that needs one, which is why I am posting it here.

@bong-water-water-bong
bong-water-water-bong merged commit 5c6c262 into main Nov 11, 2026
10 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the cl-npuperf/1781-spec-seam branch November 11, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant