Repository navigation
npu: mtp::run_from() after a batched prefill, the speculation fallback inside the loop, and the probe through both (refs #1781) - #2257
Conversation
…k inside the loop, and the probe through both (refs #1781) - mtp::run_from(base, head, pos, logits, opt): the loop from a base that already ran its prompt; run() is a token-by-token prefill and then run_from(). - Options::fallback_below / fallback_window: SpecFallback (spec_greedy.h) recorded after every round, one-pass and two-pass; once off, plain steps. Stats::fell_back_at. - tools/dflash2_probe loop, DFLASH2_MTP_RUN=1: the same prefill, then run_from() with DFlashHead and the DFlash 2 selector as its pick hook (the selector is one lambda, shared with the hand-written loop). tests/mtp_draft_test.cpp [18]: run_from after the caller's prefill gives run()'s tokens, batches and positions; no prefilled position is refused; a never-landing head falls back after exactly 3 rounds on both rounds with the same text; a perfect head never does; rounds of exactly 2 against a bar of 2.0 keep drafting. Four mutants (off ignored, no record on either round, record take instead of take + 1) each fail it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at 3128fa6 |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 3128fa6: READY
Reviewed at 3128fa6a56082e6e7f404deb63ebbec3c2fee981. It is on main (0 behind) and merges clean. The diff is 4 files, +182/-34.
Checked
- Build and tests.
mtp_draft_test,dflash_head_test,spec_greedy_testanddflash2_probebuild in anONEBIT_NPUhost tree. All three tests pass,spec_greedywith 14 checks.tools/copyright.py --checkreturns rc 0. run()is unchanged. It is now the same token-by-token prefill followed byrun_from(pos, logits), and its empty-prompt and no-vocab refusals are kept.- The emitted count passed to
record()is right in both rounds.- One-pass:
t1plustakedrafts, which istake + 1. - Two-pass:
t1is emitted at the top, thentakeaccepted drafts, so alsotake + 1.
- One-pass:
- The fallback is read where it should be.
fallback.off()sends the round to the plain path before anything is drafted. The defaultfallback_below = 0never turns it off, so existing callers see no change.
My mutants (each restored, and the test passes again)
| mutant | result |
|---|---|
drop run_from's "no prefilled prompt" refusal |
FAIL: "run_from with no prefilled position is refused" |
SpecFallback(opt.fallback_below, 1) (the window ignored) |
FAIL ×2: "a head that never lands stops after 3 rounds (fell back at 1, 1 batches)", in both the one-pass and the two-pass round |
Notes (not blocking)
- Zero-draft rounds are not recorded. A round where the head drafts nothing (
nd <= 0) takes the plain step andcontinues withoutrecord(), in both rounds. So a drafter that keeps returning 0 drafts after paying for its forward never falls back. The probe's selector always returns a token, so this cannot happen today. IfDFlashHeador a future pick hook can stop at the first row, record those rounds as 1 token. - The phase timers no longer include the prefill.
ms_stepand the other phase buckets used to absorb the token-by-token prefill, becausetpstarted before it. Now they don't. That is more accurate, butphase_ms()from before and after this change are not comparable for the prefill share. The probe reportst_prefillseparately. - Owed on the device, once the NPU is back on: the
DFLASH2_MTP_RUN=1path against the hand-written loop and plain decode on the 27B, at k = 3, 5 and 7.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 265". mtp_draft is test #97 there and passed.
|
VERDICT: READY The refactor is behaviour-preserving and the tests prove it rather than assert it, which is the right shape for moving a loop's entry point. The equivalence arm is the one that matters. The fallback is tested for the property that makes it acceptable at all. Every arm asserts The default is off ( One refusal arm, and it is the one that matters: Minor, not blocking: Not verified by me: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 3128fa6
Built and ran: all checks passed, 258 PASS lines (up 6 from #2253's 252), and the fallback arm asserts the state as well as the decision.
The new fallback assertion is the one worth reading:
check(r.ok && same(r.tokens, ref.tokens) && r.stats.fell_back_at == 3 && m.batches() == 3 &&
m.consumed() == (int)P.size() + WANT, msg);Three things at once, and each catches a different bug: same(tokens, ref) (the fallback does not change the output), fell_back_at == 3 (it happened at the round it should have), and
consumed() == P.size() + WANT (the position/state accounting closes -- the kind of number that can be wrong while the text is right). The pair of arms around it -- fell_back_at == -1 for
the case that never falls back, twice, for both the one-pass and two-pass paths -- makes the positive arm meaningful.
run_from()'s refusal is armed rather than assumed: !run_from(c, &hc, 0, vector<float>(V, 0.0f), o).ok -- a call with a zero logits vector is refused, so "starts from a base that has already run its
prompt" does not silently begin from nothing.
And the one-pass/two-pass equivalence is asserted at the stats level, not just the tokens: a.consumed() == b.consumed() alongside same(a.tokens, b.tokens) and
a.stats.batches == b.stats.batches. The premise of the PR is "same work, fewer passes", so the arm has to compare the work counters too -- tokens alone would pass a version that skipped
bookkeeping.
What I like about the shape of this change: it is the third slice of #1781 tonight (#2245's pure header, #2253's one-pass round, this), and each one ends with the probe driven through the new path
(tools/dflash2_probe.cpp +111/-34). A piece that is only reachable from a test is not yet a piece; driving the tool through it is what makes the next consumer's integration boring.
One note: the fallback arm uses fell_back_at == 3, a literal. If the window or the break-even moves, the number moves with it -- worth one comment saying which constant of #2245's rule produces
3 here (three low-emission rounds), so the next person changing the threshold knows this arm is downstream of it.
Not run: the probe against a pack (npu-hold is off); the loop and its arms are what changed.
Refs #1781 (follows #2253, #2255 and #2245).
What
Two pieces
forward-serveneeds from the engine's speculative loop (npu/mtp/draft.h), and the probe driven through them:mtp::run_from(base, head, pos, logits, opt)starts the loop from a base that has already run its prompt: the positions it holds, and the logits after the last of them, which is what a batched prefill returns.run()used to prefill one token at a time and nothing else; it is now that prefill followed byrun_from().Options::fallback_below/fallback_windowapplySpecFallback(npu: the greedy speculative round and its fallback rule as a pure header (refs #1781) #2245) after every round, one-pass and two-pass. Once the rule is off, the run finishes with plain steps, andStats::fell_back_atrecords after which round. The default,fallback_below = 0, leaves it off, so existing callers see no change.tools/dflash2_probe loopwithDFLASH2_MTP_RUN=1runs the same batched prefill, thenrun_from()withDFlashHead(npu: the DFlash draft as mtp::run()'s drafter (DFlashHead, refs #1781) #2255), using the DFlash 2 candidate selector as its pick hook. The selector is now one lambda, shared with the probe's hand-written rounds, so the two paths can be held against each other in one ticket.DFLASH2_FALLBACK/DFLASH2_FALLBACK_ROUNDSmap onto the same options.Proof
tests/mtp_draft_test.cpp[18] (toy base, host only, in CI throughmtp_draft):run_from()after the caller's own prefill givesrun()'s tokens, batch count and positions;Four mutants each fail it:
fallback.off()takeinstead oftake + 1Full ctest of an
ONEBIT_NPUbuild with the device tests excluded: 266 of 266 passed (before the rebase onto39d965f). After the rebase,mtp_draft,dflash_headandspec_greedy(14 checks) pass, anddflash2_probebuilds.Not run: the probe's
DFLASH2_MTP_RUN=1path on the 27B. The NPU is off for now. The run owed is the hand-written loop, this path and plain decode in one ticket, withDFLASH2_K3, 5 and 7. It passes if this path equals the hand-written loop and plain greedy token for token, with its tok/s.🤖 Generated with Claude Code