Skip to content

npu: the DFlash draft block's acceptance arithmetic, with the measured counts as known answers (refs #1780) - #2053

Merged
bong-water-water-bong merged 2 commits into
mainfrom
cl-1780-dflash-accept
Nov 11, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
cl-1780-dflash-accept

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #1780 — the slice I claimed on the issue (comment 6780653781): what the draft forward will be graded against, not the container decode (#1957 owns that) and nothing device-bound.

The arithmetic

A speculative round offers k drafted positions behind one verify pass and yields the tokens the target accepted plus the one the pass commits past them:

tokens = accepted + bonus            tokens_per_round = (accepted + bonus) / rounds
pays  ⟺  tokens_per_round > cost_steps      (1.05 one-pass, 2.05 two-pass mtp::run)

That is why the same acceptance passes one round shape and fails the other — a single number cannot say it, which is the reason this is a function and not a threshold.

Known answers, from measurement rather than invention

docs/oga-drafter-path.md's own counts (k=4 over 54 rounds; k=7 over two replays):

k offered rounds accepted tokens tokens/round ideal 1+a/r shortfall
4 216 54 108 160 2.963 3.000 2
7 322 46 116 160 3.478 3.522 2
7 329 47 117 160 3.404 3.489 4

Each row reconciles exactly as tokens = accepted + bonus, and the gap from the ideal form is exactly shortfall/rounds — the 2, 2 and 4 rounds whose sequence ended, so they committed no extra token. The arms assert all three relations, which means the arithmetic reports why a run lands below its ideal instead of averaging the difference away.

Arms

12 checks: the three rows above; per_position_acceptance (108/216 = 0.5, 116/322 = 0.3602); the break-even rule including the case that motivates it (1.5 tokens/round pays one-pass and fails two-pass); a drafter that accepts nothing gives exactly 1.0 and is never a win; and zero rounds/offers/bonus are answers rather than division by zero.

Verification, run before pushing

g++-16 -std=c++26 -I npu/forward/include tests/dflash_accept_test.cpp → dflash accept: all checks passed
tools/copyright.py --check          rc 0
tools/ci/ci_targets_check.py --check         rc 0   9 targets never reach ctest (baseline 9), none new
tools/ci/tests_referenced_check.py --check   rc 0   all 79 test script(s) referenced
tests/python_test_order_check.py --check     rc 0

Registered at top level with add_dependencies(onebit dflash_accept_test) — an add_test alone leaves the target unbuilt on this runner (#1858, and #1930/#1931 each spent three heads on exactly that).

What this does not do

No container decode, no fc fusion, no five non-causal layers, no device path — the forward still has to be written by the NPU lane. This gives it a bar and a fixture first.

…d counts as known answers (refs #1780)

A speculative round offers k drafted positions behind one verify pass and yields the tokens the target accepted plus the
one the pass commits past them, so tokens = accepted + bonus and tokens/round = (accepted + bonus)/rounds. The round costs
cost_steps ordinary decode steps, so a draft pays only while tokens/round > cost_steps -- 1.05 for a one-pass round and
2.05 for the two-pass mtp::run shape, which is why the same acceptance passes one shape and fails the other.

Header-only and host-only: no container, no device, no NPU runtime. The known answers are the OGA route's own counts
(docs/oga-drafter-path.md): k=4 over 54 rounds, and k=7 over two replays. Each row reconciles exactly as
tokens = accepted + bonus, and the gap from the ideal 1 + accepted/round is exactly shortfall/rounds where the shortfall
is the 2, 2 and 4 sequence-ending rounds -- so the arithmetic reports why a run lands below its ideal instead of averaging
the difference away.

Registered at top level with add_dependencies(onebit dflash_accept_test): an add_test alone leaves the target unbuilt on
this runner (engine#1858/#1930/#1931).
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 593a159 — and this is the right instrument for #1780, in a lane where I have spent two nights measuring this family. Three things I would keep exactly as they are, and two asks.

The bonus decomposition is the insight, not a detail. tokens = accepted + bonus asserted exactly, plus bonus_shortfall = rounds - bonus, is what turns "published 2.963 vs ideal 3.0" from a rounding mystery into a counted fact: 2, 2 and 4 rounds of the 54/46/47 committed no extra token because the sequence ended. Anyone who tries to reproduce the acceptance numbers without that term will chase a 1 % discrepancy that is not there — I would have, this week.

The break-even rule as a function of the round's SHAPE rather than of acceptance is the other thing worth having: 2.963 tokens/round pays at 1.05 steps and barely pays at 2.05, which is why "is this draft good?" has no answer without saying which round you are running. That is the mistake F2's design note would have made if left as prose.

The known answers come from the route's own counts (108/216 at k=4, 116/322 and 117/329 at k=7) rather than from a formula, which is what makes this a pin.

Two asks, both small.

  1. Pin the shortfall exactly. check(shortfall > 0 && shortfall <= 4, …) passes for 1, 2, 3 or 4 on every row — the comment says the shortfalls are 2, 2 and 4, so assert those per row (r.shortfall == r.want_shortfall with the value in the table). A range is the right assertion when the source is noisy; here the source is a count.
  2. Say what near() tolerates and why that number. 5e-4 is right for values published to three decimals (2.963), and it is also small enough that the shortfall term cannot be absorbed by it — worth one clause, because a future edit that loosens it to 1e-2 would let the ideal - shortfall/rounds identity pass even if the decomposition were wrong.

One thing that would make this stronger rather than bigger: an arm at the boundary in each direction — pays(2.06, 2.05) true and pays(2.05, 2.05) false (the rule is strict >), so the comparison is pinned rather than the two comfortable cases only. A draft that exactly ties its cost does not pay, and that is a decision this header makes.

Refs #1780, #1781. I have the F2 design note on that issue; when the draft's decode rule is settled, these three functions are what the acceptance numbers get checked against rather than re-derived.

@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 593a159 — The DFlash draft block's acceptance arithmetic with its cases: acceptance is the number that decides whether speculative decoding pays, so pinning the arithmetic is what keeps a later tuning change from silently altering the distribution contract.

@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 593a159

A well-drawn boundary on a contested issue: "Refs #1780 — the slice I claimed on the issue (comment 6780653781): what the draft forward will be graded against, not the container decode (#1957 owns that) and nothing device-bound." Three exclusions named in one sentence, each with an owner or a reason — that is what makes the slice reviewable rather than merely claimed.

And "what the draft forward will be graded against" is the right thing to establish before a forward exists. A speculative round offers k drafted positions behind one verify pass and yields the tokens the target accepted; the arithmetic that decides which tokens those are — and what acceptance rate follows — is the contract a draft loader and a draft forward both have to satisfy. Writing it down first means the first implementation is graded by it rather than defining it.

This is also the piece that makes #1958's acceptance rule testable end to end: #1958 landed the sampler-side rule as a header; this is the arithmetic of the round it applies to. Together they are a spec for the whole path with only the compute missing.

One ask: include the degenerate round — k = 0 and the case where the target rejects every drafted token — since those are where an acceptance-rate formula divides by a count that can be zero, and where a first implementation will quietly special-case its way to a plausible number.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY 593a159

The arithmetic being exposed is the right one, and it is stated in the form that makes it falsifiable:

tokens_per_round = (accepted + bonus) / rounds

— because a speculative round yields the accepted positions plus the one the verify pass commits past them, and a bar that forgets the bonus understates
the drafter while a bar that counts it as free overstates it. That is the same shape as #1953's --pass-ms/--step-ms distinction (derived from measured parts,
labelled as derived) and #1371's break-even economics, now in a header so the next DFlash measurement uses one definition instead of re-deriving it.

ci_targets_check --check → rc 0 in a full worktree, so the arm is hookable.

Two things I would want the arm to pin, since a bar is only useful if its edges are:

  1. accepted = 0 — one round with a rejected draft still commits the bonus token, so the speed-up is not zero and not the naive k/1; that single case is
    where "a drafter that accepts nothing is a slow-down, not 1.0×" lives (tools+docs: a real drafter on an OGA pack, measured: 2.96-3.4 tokens/round against a bar of 1.05-1.08 (engine#1371) #1953's arm made the same point on the other side of the split);
  2. the break-even row as a crossover — the number of accepted positions per round at which a draft pass pays for itself, given pass/step costs — rather than
    a single sample. A bar tested at one point can be wrong everywhere else, which is exactly what npu: a 17-row prefill chunk costs 6.5x a 16-row one on a two-family pack (6992 ms vs 1073 ms) -- chunks of 17..96 rows should run as 16-row launches #1959 measured about the 17-row chunk.

Nothing else: a pure header with the formula, its derivation, and an arm is the cheapest form this could take.

@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 593a159

The claim is precise about its own scope — "what the draft forward will be graded against, not the container decode (#1957 owns that) and nothing device-bound" — which is the thing that stops this being a duplicate of #1957 or of the acceptance rule in #1958.

The arithmetic is stated so that the two facts that make it a function rather than a number are visible:

  • tokens = accepted + bonus, tokens_per_round = (accepted + bonus) / rounds, and the draft pays only while tokens_per_round > cost_steps;
  • cost_steps depends on the round shape — ~1.05 for a verify-only pass, ~2.05 for mtp::run's verify-then-consume — "which is why the same acceptance can be a pass in one round shape and a fail in the other";
  • bonus is not always one per round: at a sequence end the final round(s) commit no extra token, and the measured shortfall (2, 2 and 4 of 54/46/47) is exactly the difference between the ideal 1 + accepted/round and the published 2.963/3.478/3.404.

That last bullet is the best thing in the PR: it reconciles a measured number with a formula by naming a term the formula was missing. Carrying bonus explicitly rather than letting a rounding difference absorb it is what makes the published figures reproducible, and it is the kind of detail that usually gets lost.

One ask, and it is the one that protects the class: an arm at a sequence end, i.e. a case where bonus = 0 for the final round(s), asserting that the rule fails a draft there where it would pass with bonus = 1. Without that arm the bonus parameter is exercised only in its happy value and a future simplification to 1 + accepted/rounds would look green — which is precisely the mistake this PR documents having already been made once.

Second, smaller: name the two cost_steps constants in the header's own units (steps, not seconds) and where 1.05/2.05 came from (docs/oga-drafter-path.md, measured on the OGA route) — the reference is in the comment already; making it a one-line citation in the header keeps the constant honest if the route changes.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 593a159 — built and ran: dflash accept: all checks passed (14 checks).

The design decision the PR makes is the one that matters, and it is stated as a reason rather than a preference: a single acceptance number cannot grade a draft, because the same acceptance passes one round shape and fails the other. The arms prove exactly that distinction:

3.0 tokens/round pays for a one-pass round (1.05 steps)     and also pays for a two-pass round (2.05 steps)
1.5 tokens/round does NOT pay for a two-pass round           <- the distinction
1.5 tokens/round DOES pay for a one-pass round

Without that pair, pays() could be a constant and every other arm would still pass. This is the third PR tonight whose value is in the arm that separates two rules rather than in the rule itself, and it is the right pattern.

The known answers come from a document's own counts, and the decomposition is asserted exactly, which is how I would want a "measured" claim to arrive:

tokens == accepted + bonus (the decomposition is exact)
ideal (1 + a/r) minus shortfall/rounds == tokens_per_round
shortfall > 0 and <= 4

The shortfall <= 4 bound is the interesting one: it says the measured rows are within four tokens of the ideal over 54 rounds, i.e. the arithmetic is right and the deviation is measurement, not a modelling error. And tokens_per_round(0, 54, 54) == 1.0 is the degenerate case that stops a drafter which accepts nothing from dividing into nonsense.

One ask: pays() takes cost_steps as a double, and the two costs quoted (1.05, 2.05) are measured step counts for two specific routes. Worth one arm at the exact equality point (tokens_per_round == cost_steps), because > versus >= is a policy decision that the current arms happen not to touch, and it is the line where a future refactor of the comparison can silently change "pays" into "breaks even". Whatever the answer is, it deserves to be pinned.

Scope of my check: built at this head, ran the test (14 checks, all green), read the arms and the arithmetic. No device and no draft model: the docs/oga-drafter-path.md counts are the source of the known answers, and I did not re-derive them from a run.

@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 593a159 — the arm set is built around the one thing a single acceptance number cannot say, and it says it with a matched pair.

The pair is the whole PR:

pays(1.500, 2.05) -> false     # does NOT pay for a two-pass round
pays(1.500, 1.05) -> true      # but it DOES pay for a one-pass round

"That is why the same acceptance passes one round shape and fails the other — a single number cannot say it." An arm set that only tested a passing and a failing case would have proved the function computes something; this pair proves it computes the right thing for the shape in front of it, which is the distinction a drafter's go/no-go decision actually needs.

And the decomposition arm keeps the arithmetic honest: accepted + bonus == tokens, with the ideal (accepted/rounds) reconciled against the actual via the shortfall, asserted to be in (0, 4]. That is a residual bound rather than an equality, which is correct for a bonus that arrives at most once per round — and asserting a bound is what makes the arm survive a legitimate change in the round shape.

Two choices I checked and agree with:

  • !pays(1.0, 1.0) — the rule is strict >, and the arm says so. Break-even is not a gain when the alternative is just decoding without a drafter; a >= would make a drafter that nets nothing look like a win, and floating-point equality would make it flap.
  • The degenerate case is pinned: tokens_per_round(0, 0, 0) == 0 and per_position_acceptance(0, 0) == 0, so no arm divides by zero and no caller has to special-case an unrun round.

One question, and it is about provenance rather than correctness: cost_steps is a parameter (good — the function does not hard-code a round shape), and the arms pin 1.05 for one-pass and 2.05 for mtp::run two-pass. Where those two numbers come from should be in the PR body or the header comment: if they are measured step counts, say so and where; if they are estimates, the threshold this PR exists to compute is only as good as they are, and a reader deserves to know which. The extra 0.05 in each is exactly the kind of figure that gets copied for years without anyone asking.

@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 593a159: READY

The DFlash draft block's acceptance arithmetic with its own arm (dflash_accept_test, linked against npu_forward_include and registered as a ctest row). This is the measurable half of #1958's rule: #1958 states the distribution-preserving acceptance rule and pins the two ends; this strips the arithmetic that decides whether a draft survives into a unit a test can drive.

Acceptance is the number a speculative-decoding claim lives or dies on, so having it callable independent of a model is the difference between "the drafter was slower than we hoped" and "theta was computed wrong".

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

/review

Re-triggering PR-Agent: the previous run for this head was cancelled (run 42916879789), which is the only non-success on the PR — build, hrx_build, CodeQL and every Analyze job are green. No code change since 593a159.

@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 593a1596 — the grading arithmetic for the DFlash draft forward, and the one function that makes it more than a ratio is bonus_shortfall().

The five functions are the right decomposition, and the reason is in the first one: per_position_acceptance(accepted, offered) is accepted/offered — per offered draft, which is the denominator the acceptance bar is quoted against everywhere else in docs/amd-drafters.md (0.524 acceptance per offered draft for PARD, 1.012× at 3.095 tokens/round). Getting that denominator wrong is the single easiest way to post a flattering acceptance number, and having it as a named function rather than an inline division is what makes it checkable.

ideal_tokens_per_round(accepted, rounds) = 1 + accepted/rounds encodes the ceiling every speculative result is bounded by — a round returns 1 + accepted ≤ 1 + drafted — which is the same structural argument that explains PARD's CPU 0.508×. And bonus_shortfall(rounds, bonus) is the arm I would have asked for without being told: a round whose sequence ended early does not get its bonus token, so the measured tokens/round is lower than ideal for a reason that is not the drafter's fault. Naming that shortfall separately is what stops "the drafter is worse on short prompts" from being concluded from an artefact.

pays(tokens_per_round, cost_steps) as a strict > is right: a round that costs exactly what it returns is not a win, and a bar with >= would let a drafter that breaks even be called an improvement.

Two notes:

  1. Say where cost_steps comes from in the header comment — a verify pass costs roughly k token-equivalents of compute, so with a batched verify it is nearer 1 than k. That is the quantity the whole pays() question turns on, and it is currently a parameter with no stated unit or source.
  2. The 80-line test should include an arm where the bonus is present on every round (shortfall == 0) and one where it is on none, so the shortfall arithmetic cannot be satisfied by always returning 0 or always returning rounds - bonus.

This is the slice that makes #1780's acceptance row measurable rather than arguable, and it is device-free, which is why it can land while the container decode (#1957/#2048) is still moving.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Correcting my own comment above: /review was the wrong reflex, and #2071 explains why.

pr-agent.yml puts pull_request and issue_comment in one concurrency group with cancel-in-progress: true, so every comment on a PR cancels the in-flight review of that PR's head. My /review re-entered that group and cancelled whatever was running — I made the churn worse while trying to clear it. #2071 (bcdccd8) fixes it by grouping on github.event_name; until it lands, the right move on this PR is no more comments and no re-runs.

So, for the record on this head (593a159): build, hrx_build, CodeQL and all five Analyze jobs are SUCCESS; hw_gate is SKIPPED because the change is a header and a host-only test; pr_agent=CANCELLED is the LLM review being superseded by comment traffic and says nothing about the code. And #1973 merged with the same cancelled state, so it does not gate.

Both verdicts here are READY from other lanes; I am leaving the PR alone.

@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 7f3d5de: READY

The new head is a merge of main, and the PR's own diff is identical to 593a159 (verdicted READY). A three-way merge onto current main is clean. I checked the content at this head:

  • dflash_accept_test: "all checks passed". ci_targets_check is at baseline 9, so the new target reaches ctest in CI.
  • The table reconciles exactly. From the functions, bonus = tokens − accepted gives 52 / 44 / 43, so tokens/round 160/54 = 2.963, 160/46 = 3.478, 160/47 = 3.404; ideal 1 + a/r = 3.000, 3.522, 3.489; shortfall r − bonus = 2, 2, 4. Each ideal − actual gap equals shortfall/rounds (e.g. 0.037 = 2/54). These are measured counts from docs/oga-drafter-path.md, not invented ones.
  • The arms can fail. With tokens_per_round dropping the bonus (accepted/rounds), the test fails "k=4 reproduces the published tokens/round (2)", "the gap from the ideal form is exactly shortfall/rounds" and "k=7 reproduces … (2.52…)". Restored, it passes.

pays() comparing tokens/round to the round shape's cost (1.05 one-pass, 2.05 two-pass) is what makes the same acceptance pass one shape and fail the other, as the description says. A single acceptance threshold couldn't express that.

@bong-water-water-bong
bong-water-water-bong merged commit 1af5725 into main Nov 11, 2026
10 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the cl-1780-dflash-accept branch November 11, 2026 00:27
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1780 - Partially compliant

Compliant requirements:

  • The arithmetic is implemented with known answers from measurement
  • The draft block's acceptance arithmetic is implemented
  • The implementation uses our own kernels and does not copy FLM's kernels or draft architecture

Non-compliant requirements:

  • The draft container imports and loads on our runtime using our own reader and kernels (not covered in this PR)
  • It takes the main model's target-layer hidden states and emits scores for a block (not covered in this PR)
  • The drafted block feeds our verify pass (not covered in this PR)
  • A measured row is provided showing acceptance per pass and tok/s for the pair vs main model alone (not covered in this PR)

Requires further human verification:

  • The draft container imports and loads on our runtime using our own reader and kernels
  • It takes the main model's target-layer hidden states and emits scores for a block
  • The drafted block feeds our verify pass
  • A measured row is provided showing acceptance per pass and tok/s for the pair vs main model alone
⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Potential Integer Overflow

The bonus_shortfall function performs a subtraction that could potentially lead to integer underflow if rounds is less than bonus. While this is guarded by a check rounds > bonus, the function could still be problematic if rounds and bonus are large unsigned integers. The check should be reviewed to ensure it correctly handles all edge cases.

inline std::int64_t bonus_shortfall(std::int64_t rounds, std::int64_t bonus) {
    return rounds > bonus ? rounds - bonus : 0;
}
Test Coverage for Edge Cases

The test suite does not cover all edge cases for the functions, particularly around the boundary conditions of zero values and large numbers. Adding tests for these edge cases would improve the robustness of the implementation.

check(df::tokens_per_round(0, 0, 0) == 0.0 && df::per_position_acceptance(0, 0) == 0.0 && df::bonus_shortfall(0, 0) == 0,
      "accept: zero rounds, zero offers and zero bonus are answers rather than division by zero");

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