Skip to content

mtp: the draft loop's cost is 45 % verify batch / 28 % step / 18 % head, measured per phase instead of inferred - #1042

Merged
bong-water-water-bong merged 2 commits into
mainfrom
npu/mtp-phase-split
Nov 5, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
npu/mtp-phase-split

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

What this does

Stats gains four phase timers — ms_step, ms_batch, ms_draft, ms_commit — accumulated with steady_clock::now() around each phase of the draft loop, and the live report prints one split line per prompt plus one for the run under MTP_PHASE_SPLIT=1.

Measurement only: nothing here feeds back into a decision the loop makes. Emitted text, acceptance, per-depth tables and the drafter's stream are bit-identical to the same binary without the flag (p0: 44/80, chain 3.20, 90/65/35/30, TEXT IDENTICAL, 0 mismatches).

Why it is needed

This lane's cost model for the draft route was assembled by subtraction across tickets: "two passes per iteration" came from counting calls, and the price of each term was inferred from arms measured at different load. Two candidate changes have been weighed on that model — remove the plain step, or widen the batch — and both are decided by the difference, so the four terms are now measured in one window.

Measured (bonsai2-27b-pb16i, sealed M=16 pb16i ELFs, one npu-hold claim, amdxdna errors 0)

Prompt 0, 64 tokens, k=4, tau=0, 20 iterations, box load 53-57, plain decode 0.479 s/token:

phase ms share
step (the single-token pass) 11477.6 28.1 %
batch (verify, M=5) 18334.9 44.9 %
head (chain + lm head per draft) 7466.3 18.3 %
commit (commit_verify + drafter advance) 3554.7 8.7 %
total = draft phase 40833.4 2042 ms/iteration

The two decisions this settles

1. Folding the plain step into the verify batch cannot buy the 25 % it was priced at. A one-pass loop must draft from the previous committed row (npu/mtp/draft.h:246 is what produces the base hidden that :261 hands to MtpDrafter::draft(), and npu/mtp/mtp_head.cpp:367 chains from it). That first draft is then a self-regressive head step — depth-2 quality, 65 % here, instead of depth-1's 90 %. On measured terms:

2042 - 574 (step) + 87 (one extra batch row) + 81 (one extra head step) = 1636 ms/iteration
chain 1 + 0.65 + 0.35 + 0.30 + 0.25 = 2.55 tokens/pass
1636 / 2.55 = 642 ms/token   vs   2042 / 3.20 = 638 ms/token today

A dead heat, so the change is not carried in this PR. One earlier arm corroborates the direction: k=6 bought chain 3.25 for about 0.84 units of cost more than k=4 (depth 5 hit 20 %, depth 6 hit 0 %).

2. The batch, not the loop's shape, is where a draft-route win has to come from. Verifying five rows costs 917 ms/iteration while a pb16i launch runs its rows M-independently, so that 45 % is per-row host work. For the serve switch that means: the draft route's remaining lever is step_batch_all's per-row cost and the base step, not another loop rearrangement.

Cost of the flag

steady_clock::now() twice per iteration, ~40 ns against a 2 s iteration; the accumulators are four doubles in Stats. It prints nothing unless MTP_PHASE_SPLIT is set, so CI and the toy test are unaffected (ctest -R mtp_draft green, tools/copyright.py --check clean).

…ad, measured per phase instead of inferred

The loop's cost model was assembled by subtraction across tickets: "two passes per iteration" (one plain step
plus one M=verify batch) came from counting calls, and everything after that was arithmetic.  Counting calls is
not the same as pricing them, and the two candidate changes this lane has been weighing are decided by exactly
that difference -- removing the plain step, or widening the batch -- so the four terms now get measured in the
same run.

`Stats` gains ms_step / ms_batch / ms_draft / ms_commit (steady_clock::now() around each phase, always
accumulated, printed only under MTP_PHASE_SPLIT=1) and the live report prints one split line per prompt plus one
for the run.  Measurement only: nothing here is read back into a decision the loop makes, and the emitted text,
acceptance and the drafter's stream are unchanged.

Measured on bonsai2-27b-pb16i (sealed M=16 pb16i ELFs, one npu-hold claim, amdxdna errors 0), prompt 0, 64
tokens, k=4, tau=0, 20 iterations, load 53-57:

  step 11477.6 ms (28.1 %) + batch 18334.9 ms (44.9 %) + head 7466.3 ms (18.3 %) + commit 3554.7 ms (8.7 %)
  = 40833.4 ms of draft phase = 2042 ms/iteration, chain 3.20 tokens/pass

Two things follow, and both are decisions rather than observations.  First, the plain step is 28 % of the
draft phase, so folding it into the verify batch cannot buy the 25 % it was priced at: a one-pass loop has to
draft from the previous committed row, whose first draft is a self-regressive head step -- depth-2 quality
(65 % here) instead of depth-1 (90 %) -- and 2042 - 574 (step) + 87 (extra row) + 81 (extra head step) over a
2.55 chain is 642 ms/token against today's 638.  Second, the batch is the single largest term (45 %): verifying
five rows costs 917 ms/iteration while the same array launches M-independent rows, so the per-row host work is
where a draft-route win would have to come from -- not from the loop's shape.

No acceptance claim is made or moved by this commit: p0 still reports 44/80, chain 3.20 tokens/pass, per-depth
90/65/35/30, TEXT IDENTICAL, 0 stream mismatches.
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 365400b)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Phase Timing Accuracy

The phase timing logic in run function uses lap(tp) to accumulate time for each phase, but the timing starts only after the initial prompt processing and may not capture the full duration of each phase, especially for the first iteration. This could lead to underreporting of actual phase durations.

// Phase timing: always accumulated (steady_clock::now() is ~20 ns against a ~0.5 s pass), printed by the
// caller when it wants the split.  Measurement only -- nothing here is read back into the loop's decisions.
using Clock = std::chrono::steady_clock;
auto lap = [](Clock::time_point& t0) {
    const auto t1 = Clock::now();
    const double ms = std::chrono::duration<double, std::milli>(t1 - t0).count();
    t0 = t1;
    return ms;
};
Clock::time_point tp = Clock::now();
Phase Split Reporting

The phase split reporting in mtp_acceptance.cpp aggregates timing data across prompts, but does not account for potential differences in workload or hardware state between prompts, which could skew the reported percentages.

// The whole run's split, so the serve switch and any loop change are priced on one window's measurement.
if (phase_split && tot_ms_step + tot_ms_batch + tot_ms_draft + tot_ms_commit > 0) {
    const double tot = tot_ms_step + tot_ms_batch + tot_ms_draft + tot_ms_commit;
    const double pc = 100.0 / tot;
    std::printf("   phase split (all prompts): step %.1f%% + batch %.1f%% + head %.1f%% + commit %.1f%%"
                "  = %.1f s of draft phase; of that, base passes %.1f%%\n",
                tot_ms_step * pc, tot_ms_batch * pc, tot_ms_draft * pc, tot_ms_commit * pc, tot / 1000.0,
                (tot_ms_step + tot_ms_batch) * pc);
}

@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 14d982051

Measurement-only, and I checked that it is: the four accumulators are written and printed, and nothing reads them back —

grep for ms_step|ms_batch|ms_draft|ms_commit in npu/mtp/draft.h, excluding the declarations, phase_ms() and the += sites
  -> nothing

and the header parses and runs standalone, so the change is not entangled with the tool that prints it:

g++ -std=c++17 -fsyntax-only -I. (a TU that includes npu/mtp/draft.h)   -> OK
onebit::npu::mtp::Stats{}.phase_ms()                                    -> 0.000

The placement of the five lap() calls matches the phases the body claims — two ms_step (the loop pays two single-token passes per iteration), one ms_draft (the head's own chain), one ms_batch (verify batches, array + host), one ms_commit (snapshot restore + replay) — and tp is seeded once before the loop, so no phase double-counts its predecessor's tail.

The reason given for measuring all four in one window is the right one and worth keeping in the commit message: "two passes per iteration" only prices a loop change if the passes carry the cost, and subtracting one phase across two runs is exactly how a number ends up attributed to the wrong thing.

Two notes:

  1. lap() is unconditionally accumulated with the justification "steady_clock::now() is ~20 ns against a ~0.5 s pass". That is true of a real pass, but MTP_PHASE_SPLIT also runs on the 16-token acceptance arms where a step can be ~10 ms — still negligible (4 clamps ≈ 80 ns), but if this ever lands in a hot path (per-token, not per-pass) it should be gated like NPU_PHASE_PROF is.
  2. The claim "bit-identical to the same run without the flag" is the one thing I could not check without a device; it follows from nothing being read back, which is what I verified, so I am satisfied — but if you have the two acceptance logs, quoting the text/acceptance rows side by side is cheaper for the next reviewer than trusting the argument.

@bong-water-water-bong bong-water-water-bong added the tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied label Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 365400b

@bong-water-water-bong
bong-water-water-bong merged commit 5fc2ede into main Nov 5, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the npu/mtp-phase-split branch November 5, 2026 01:08
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…table stops freezing on its first call (#1056)

A batched verify pass is 45 % of the MTP draft loop (#1042) and 43 % of that
phase was unattributed, because the phase instrument only existed for the
per-token path: prof_add() folds the pack / quant+launch / wait / readback
split into g_ph only for M == 1 launches (acc == prof_dec_), while a verify
batch's launches are M > 1 and land in prof_pre_.

- g_in_batch (set for the batched body of step_batch() by a scope that clears
  it on every return path) makes those launches feed the same g_ph.
- step_batch() now prints one [bs] line per pass with [ph]'s partition:
  wall | embed | layers (gemm pack / ql / wait / rb / sum / host, launches) |
  epilogue (head + its own split) | tail.  tail is what the partition misses.
- The ranked [lh] table and its UNATTRIBUTED row move out of step() into
  lh_dump_layer_host(what, unit) and run for the batch too, so both residuals
  are read the same way; step()'s own output is unchanged.

Fixed on the way, and it was already visible in a log: the TOP-LEVEL
PARTITION table's row array was `static`, so it initialised once and every
later line reprinted the first call's numbers -
ticket-batchprof-p0-before-20261104-142823.log shows load_layer 8587.41 ms on
every token while the live ACCOUNTED row moves 654 -> 690 ms, with rows
reading 1300 % of "gemm-free host".  It is now a plain local.

prof_dump() returns early unless NPU_PROFILE is set: the launch timers are
shared now, and the per-role table is not what a phase run asks for.

Measurement only: nothing is read back, and with the environment unset no
code path changes.  Host-verified: ninja mtp_draft_test mtp_acceptance rc 0
(the pre-existing -Wstringop-overflow at q4nx_forward.cpp:2037 only),
ctest -R mtp_draft 1/1, tools/copyright.py --check clean.  The device
confirmation is NOT done: this box's NPU array went unusable at 22:28
("aie2_create_context: NPU array is unusable until the driver is reloaded")
and the ticket that was to take the numbers stopped at ELF init with
device error 102, 10 s in, before any compute - see TMTPLOOP-STATE.md.

Co-authored-by: pi-mtploop <pi-mtploop@1bit.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Review effort 3/5 tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant