Skip to content

mtp: leaf fill cannot buy acceptance in this verifier — P6's last plan item, settled with a test - #1305

Merged
bong-water-water-bong merged 2 commits into
mainfrom
npu/mtp-leaffill
Nov 7, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
npu/mtp-leaffill

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

#805 (plan P6) lists one protocol item that was never built: "spare verify rows filled with the drafts' 2nd/3rd candidates". This PR settles it — it should not be built as specified, and the evidence is a test rather than an argument.

Why it cannot work here

The loop's acceptance rule is argmax(logits[i-1]) == draft[i] (npu/mtp/draft.h:317). A draft is accepted iff it is the base's own argmax at that position, and the argmax that grades depth i is produced by row i-1 of the same batch. Two consequences:

  1. a candidate at the same position that is not the argmax is refused with certainty, whatever carries it — so 2nd/3rd candidates at a depth can only add rows that are certain to be rejected;
  2. a correct candidate deeper in the chain is never scored while its parent is refused, and an alternative at a depth cannot even be placed in the same batch (it would be graded by its parent's row, which does not exist yet) — it needs another batch, i.e. exactly the pass the loop already pays to re-draft.

Measured — tests/mtp_draft_test.cpp [16], toy base given a real two-level ranking

rank-2 candidate at every depth ................ accepted 0/92
the same head, argmax moved onto the runner-up
  (the control that makes that zero mean something) accepted 20/20
right at depths 2..k, wrong at depth 1 ......... accepted 0, reached_at[1] == 0
the same head at k=8 (M=16 rows) ............... accepted 0 of 184 rows

The control is the point: the identical head against the identical base is accepted every time once the argmax moves, so 0/92 is a measurement of the acceptance rule, not a broken harness. And widening the batch to the full M=16 changes nothing, because the batch's rows are the chain, not candidate tests.

A real tree verifier (per-row positions + attention mask) would be a different design, and with a recurrent GDN state carried row by row it would also need a state per branch — the "no per-token state banks" constraint this same plan imposes. The two levers that did move this loop are already recorded: the hidden variant (post-final-norm) and the drafter's stream cursor.

Contents

Verified

./mtp_draft_test                                   -> all checks passed (existing checks + [16] + the control)
ctest -R 'mtp_draft|mtp_depth_pool|npu_leaderboard' -> 3/3 passed
tools/copyright.py --check (tree staged)            -> rc 0
g++-16 -std=c++26 -fsyntax-only tools/mtp_acceptance.cpp (CI's flags) -> rc 0

refs #805.

…n item, settled with a test

#805 (plan P6) lists "spare verify rows filled with the drafts' 2nd/3rd candidates" as the one protocol item never
built. It should not be built as specified: the loop's acceptance rule makes it worthless, and the measurement is now a
test rather than an argument.

The rule is `argmax(logits[i-1]) == draft[i]` (npu/mtp/draft.h:317). A draft is accepted iff it IS the base's own
argmax at that position, and the argmax that grades depth i is produced by row i-1 of the same batch. So:

  1. a candidate at the SAME position that is not the argmax is refused with certainty, whatever carries it;
  2. a correct candidate DEEPER in the chain is never scored while its parent is refused, and an alternative at a depth
     cannot even be placed in the same batch -- it would be graded by its parent's row, which does not exist yet, so it
     needs another batch, i.e. exactly the pass the loop already pays to re-draft.

Measured (tests/mtp_draft_test.cpp [16], toy base given a real two-level ranking so "2nd candidate" is a thing it can
answer for):

  rank-2 candidate at every depth:        accepted 0/92
  the same head with the argmax moved
  onto the runner-up (the control):       accepted 20/20      <- the zero above is about the argmax, not the harness
  right at depths 2..k, wrong at depth 1: accepted 0, reached_at[1] == 0
  the same head at k=8 (M=16 rows):       accepted 0 of 184 rows  <- batch width was never the constraint

A real tree verifier (per-row positions + attention mask) would be a different design and would also need a GDN state
per branch, which this plan forbids ("no per-token state banks"). docs/npu.md now states the determination next to the
break-even paragraph, and the stale "batch it harder, #805's leaf fill" comment in tools/mtp_acceptance.cpp says what is
true instead.

Verified: `./mtp_draft_test` -> all checks passed (the 40-odd existing checks, [16]'s 8, and the control);
`ctest -R 'mtp_draft|mtp_depth_pool|npu_leaderboard'` 3/3; `tools/copyright.py --check` rc 0 with the tree staged;
`g++-16 -std=c++26 -fsyntax-only` on tools/mtp_acceptance.cpp rc 0.

refs #805.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1305 ready for review at 919dff8

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Preparing review...

@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: READY at 919dff8475e65c0ac3a3f91f293e3b883e1d0a2e. The determination is correct for this verifier, and the test measures it with a control that makes the zero mean something.

The rule, read in the code: npu/mtp/draft.h:313-319: draft 0 is checked against the last step's logits, draft i>0 against row i-1 of the batch, if (argmax(lg, V) != d[i]) break;. It is a linear chain that stops at the first mismatch. The loop is greedy by construction (draft.h:44: emitted tokens identical to plain greedy decode), so a non-argmax candidate is rejected with certainty, and nothing below a refused parent is ever graded. Both consequences in the body follow directly.

Measured here: g++-16 -std=c++26 -O2 -fopenmp tests/mtp_draft_test.cpp npu/mtp/mtp_head.cpp → all checks passed, rc 0. Section [16] prints exactly the body's numbers:

PASS  leaf fill (1): the base's rank-2 candidate at the SAME position is refused every time
PASS  leaf fill (1) control: with the argmax on the runner-up, the same head is accepted every time
INFO leaf fill (1) control: same head, argmax moved -- accepted 20/20
INFO leaf fill (1): rank-2 candidate at every depth, accepted 0/92
PASS  leaf fill (2): a correct depth-2 candidate is never scored while depth 1 is refused
PASS  leaf fill (2): widening the batch (k=8, M=16 rows) buys exactly nothing when the parent is refused
INFO leaf fill (2): correct deeper candidates, accepted 0 at k=4, 0 at k=8 (of 92 / 184 rows)

copyright.py --check rc 0. 3 files, +139/−3; the tools/mtp_acceptance.cpp change is comment-only. (My local -fsyntax-only of it stops at nlohmann/json.hpp, a submodule this worktree doesn't have checked out. main's copy stops at the same line, so that's the environment, not the PR.)

Nits, not blocking

  • tools/mtp_acceptance.cpp:489-492: the new sentence ends …cost is the base or the head. (P6's "leaf fill" … [16].), but the next, untouched line still begins // the head. Measured here…, so the comment reads "…the base or the head. (…) the head. Measured here". Drop the leftover the head. on that line.
  • The doc says "this verifier", which is right. One word, "greedy", in the bold lead would stop a reader from carrying the conclusion to a sampling verifier (speculative sampling can accept non-argmax drafts).

refs #805 is right: this closes the leaf-fill item as "not this design" and leaves the tree-verifier question named.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant