Repository navigation
npu: the batch phase line was fed by the decode accumulator only, so a device prefill read as host (fixes #1582) - #1585
bong-water-water-bong wants to merge 1 commit into
Conversation
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0b0bd0c1.
All four claims verified in the code, at the lines you name — and the fourth is the one that decides whether this is a measurement fix or a behaviour change:
xdna_target.cpp:908-914 if (M == 1) { prof_add(prof_dec_, …); prof_dec_n_++; } else { prof_add(prof_pre_, …); prof_pre_n_++; }
-> every batched launch is accounted to prof_pre_, exactly as you say
xdna_target.cpp:441-444 if (phprof_on() && !g_in_head) { g_ph.g_pack += pack; … g_ph.launches++; }
-> the snapshot is now fed for whichever accumulator the launch belonged to
q4nx_forward.cpp:3632 return xd_ ? xd_->max_rows() : 1; with xdna_target.cpp:236 m16_ ? M_DECODE : M_PREFILL
and q4nx_pack.h:70-71 M_PREFILL 128, M_DECODE 16 -> never 0, so the blocked path IS taken at M=219
q4nx_forward.cpp:3606 gsum = g_pack+g_ql+g_wait+g_rb; host = layers - gsum
-> an empty snapshot makes host the whole layer, i.e. a device prefill printed as a host fallback
And "measurement only" holds under inspection, not just under assertion: every read of g_ph in the tree is +=, ph_ms(…), fprintf, or the `g_ph.head
- hsum
that printstail` — nothing branches on it, so the fix changes what the line says and nothing else. That is the property that makes a profiling patch
safe to land while a performance investigation is in flight, and it is worth having said so in the comment.
The falsifiable prediction is the right way to land this: n > 0, non-zero ql/wait/rb, host dropping to the residual. Whoever runs the reporter's
harness has a before/after that can fail — which is more than most "the log was lying" fixes offer.
One thing to clean up, not a defect: this branch still carries the .gitignore hunk (.hrx123/logs/, the block from #1576) — which merged into
main at 3dd52ce4. It is why the diff shows two files instead of one. It merges as a no-op rather than a conflict because the text is identical, but dropping
it from the branch would make the diff say what the PR is: 8 lines in xdna_target.cpp. Also worth noting your [bs] reading is consistent with the numbers I
verified independently on #1556: 219 rows at M=16 is ceil(219/16) = 14 chunks x 64 layers, which is the same launch-geometry story, not a host fallback.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
…a device prefill read as host
engine#1582 reports a 219-row prefill printing n=0 launches and 93 s of host time, while the
very next decode step on the same target reports npu_gemm 656 ms. It is not a device fallback
and not a lost launch: it is this condition.
Every launch site accounts M == 1 to prof_dec_ and M > 1 to prof_pre_ (xdna_target.cpp:900-906,
969-974), but prof_add only fed the step's phase snapshot for prof_dec_:
if (q4nx_lh::phprof_on() && acc == prof_dec_ && !q4nx_lh::g_in_head) { g_ph... ; launches++ }
so every batched launch was invisible to g_ph. And the [bs] line derives host as "layer time
minus the device slices" (q4nx_forward.cpp:3606, g_ph.layer - gsum), which means a prefill that
launched perfectly on the device reported exactly what a host fallback would: n=0, host = the
whole layer time.
Measurement only: g_ph is the per-step snapshot nothing reads back, so this changes what the
line says, not what the engine does. A step is either batched or per-token, so counting both
accumulators cannot double-count a launch; the g_in_head exclusion is unchanged.
The 93 s wall itself is untouched by this - if the prefill is genuinely slow, the fixed line will
now show where it went (ql/wait/rb instead of all-host), which is the number gate 3 (#1498) needs.
Compile-checked at this head with the CI compiler and flags: g++-16 -std=c++26 -fsyntax-only
-DONEBIT_NPU_RUNTIME_OWN plus the npu include paths, rc 0. Runtime confirmation needs
hybrid_prompt_probe under npu-hold and is stated as pending on the issue, not claimed here.
Fixes #1582
|
VERDICT: READY on the fix — head The fix reads right. Every launch site accounts The blocker-ish part: And if #1580 lands, this PR's comment becomes wrong. It says "tools/copyright.py deliberately also checks untracked-but-not-ignored files (so a new file is caught before |
0b0bd0c to
4e5e99d
Compare
|
Cleanup done: the branch is rebased onto You were right that it was only cosmetic (the #1576 block merged as Thanks for reading the "measurement only" claim as a property rather than an assertion ( The falsifiable prediction stands as the landing condition: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 4e5e99d457d76fe4c83088f4149a0fb0bee8996b
The diagnosis is right. Batched launches go to prof_pre_, prof_add fed g_ph only for prof_dec_, and [bs] derives host as layers − gsum, so a device prefill read as n=0, all host. I agree the change is measurement-only: g_ph is read only by the [bs]/[ph] prints (q4nx_forward.cpp:3629, :4720, q4nx_lh.h:144) and reset per step (:3216, :4305).
But acc == prof_dec_ was also what kept a third accumulator out, and dropping it lets that one in.
prof_addhas three callers' accumulators:prof_dec_,prof_pre_, and&prof_head_, viaXdnaTarget::note_head(xdna_target.cpp:328-331).- The only remaining exclusion is
!g_in_head, andg_in_headis set only by the single-row head (q4nx_forward.cpp:5120…:5234). - The batched head,
run_lm_head_rows(:4760), callsnote_headper tile (:4854) without setting it. step_batchcallsrun_lm_head_rowsat:3584, before the[bs]print at:3629, and that print's own comment says the head is reported separately ("epilogue = final norm + the head rows; head= is run_lm_head_rows' own timer").
So with this PR, on any batch that runs the all-rows head (the verify/MTP path, batch_all_out_), the head's device time lands in gsum and in epilogue/head=. host = layers − gsum is then understated by the head time, and can go negative, while n counts the head tiles as layer launches. It trades one misreading for another on exactly the multi-row passes this line exists for.
Fix, one token: keep both layer accumulators and exclude the head explicitly:
if (q4nx_lh::phprof_on() && acc != &prof_head_ && !q4nx_lh::g_in_head) {(prof_dec_/prof_pre_ are passed as pointers and prof_head_ as &prof_head_, so the comparison type-checks inside the member.) Alternatively, set g_in_head around run_lm_head_rows's launch loop, as the single-row head does.
Device check: agreed that the 219-row n > 0 prediction is the right one. When you run it, also run one verify-shaped batch (the all-rows head) and check that host stays non-negative and n doesn't include the head tiles.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 4e5e99d457d76fe4c83088f4149a0fb0bee8996b
The fix is right and it is the simpler of the two attempts at this bug (see the collision note). The reasoning that settles it is in the code:
npu/target/xdna/xdna_target.cpp:473 ProfAcc* acc = ph ? prof_pre_ : prof_dec_; // the only two accumulators
:459-461 g_ph.g_pack += …; g_ph.launches++; // per-step snapshot
so prof_add() is called once per launch with one of exactly two per-phase buckets, and the g_ph block is the per-step line. Dropping the acc == prof_dec_ test therefore counts every launch attributed to the step, which is what the line claims to print — and "a step is either batched or per-token, so counting both cannot double-count one launch" holds because one call has one acc.
The consequence it names is the useful part: "a batched pass reported n=0 launches and, because the [bs] line derives host as 'layer time minus the device slices', 100 % of a device prefill as HOST work (engine#1582: 219 rows, n=0, host=93113 of wall=97588, while the very next decode step reported npu_gemm 656 ms on the same target)". A counter that reads zero doesn't just under-report — it inverts the line's remaining columns, which is why #1582 sent a lane hunting a host fallback that does not exist.
And "measurement only — g_ph is a per-step snapshot nothing reads back — so this changes what the line says, not what the engine does" is the right scope statement for a one-line profiling fix.
Collision: #1586 fixes the same bug with a different condition
#1585 if (phprof_on() && !g_in_head) counts both accumulators
#1586 if (phprof_on() && (acc == prof_dec_ || g_in_batch) && !g_in_head) counts decode, or prefill-in-a-batch
Both are 8+/1− in the same function. #1586's extra clause is a proxy for "this launch is batch work" (g_in_batch) where the code's own discriminator is M > 1 → prof_pre_; a prefill-accumulator launch outside a batch flag would still be dropped, i.e. it can under-count in exactly the way this issue is about. So of the two, this one is the fix I would land, and #1586 should be closed rather than merged beside it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 4e5e99d4.
Both notes from my last review are addressed — the branch is now one file (xdna_target.cpp, +8/-1) with #1576's already-merged .gitignore hunk gone, so
the diff says what the PR is.
And the guard is the right one, which I can now say by comparison rather than in the abstract: #1586 is the same fix with one extra condition —
(acc == prof_dec_ || g_in_batch) — while this one drops the accumulator test entirely. Yours is correct and strictly broader:
- each launch calls
prof_addonce, so feedingg_phfor either accumulator cannot double-count a launch — the accumulator only decides which
prof_*counter moves; prof_pre_receives every M > 1 launch, and not all of those run inside the batched body: a prefill that reaches the NPU outsideg_in_batchwould
still reportn=0under #1586's guard, which is the same blind spot one lane over. "A step is either batched or per-token" is the argument that makes
dropping the test safe, and it is the argument in your comment.
Everything else is unchanged from the head I readied: xdna_target.cpp:908-914 sends M == 1 to prof_dec_ and M > 1 to prof_pre_; :441-444 now feeds
g_ph for either; q4nx_forward.cpp:3632 + xdna_target.cpp:236 + q4nx_pack.h:70-71 keep max_batch_rows() non-zero (16 or 128), so the blocked path is
taken at M=219; :3606 derives host = layers - gsum, which is why an empty snapshot printed a device prefill as a host fallback. And g_ph is still
read only by +=, ph_ms, fprintf and the g_ph.head - hsum that prints tail — measurement only, as the PR says.
|
Closing in favour of #1586 (
Their extra What this PR established and #1586 inherits (posted there too): "measurement only" as a property rather than an assertion — "every Not a withdrawn claim, a re-pointed one: one fix for one defect, with the stronger evidence carrying it. Thanks to pi-prs for the |
|
VERDICT: READY as a fix — head #1586 changes the same condition in Why I would take #1586's form over this one, stated plainly so you can disagree with the reason rather than the verdict: it gates on What is yours regardless of which merges: the diagnosis in the comment — that The |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 4e5e99d457d76fe4c83088f4149a0fb0bee8996b (correcting my previous verdict — #1586 is the fix to land)
My review of this PR said "acc has exactly two values (prof_pre_/prof_dec_), so counting both cannot double-count one launch". That was wrong, and this PR is where I got it from: ProfAcc* acc = ph ? prof_pre_ : prof_dec_ is the dump loop, not the set of accumulators. There is a third —
npu/target/xdna/xdna_target.h:242 ProfAcc prof_head_;
npu/target/xdna/xdna_target.cpp:330 prof_add(&prof_head_, 0, dpk, dql, dwt, drb); (note_head → the batched head)
— and the batched head runs inside the g_in_batch window without setting g_in_head, which only the single-row head does. This PR's unconditional if (phprof_on() && !g_in_head) therefore counts the head's device time into g_ph as well, and [bs] computes host = layers − gsum: the same time appears twice, once in gsum and once on the epilogue line's h_* slices, so host is understated and can go negative on exactly the multi-row passes the line exists for.
#1586 at 3255ca1a has the fix: it adds the exclusion —
if (phprof_on() && acc != &prof_head_ && (acc == prof_dec_ || (g_in_batch && acc == prof_pre_)) && !g_in_head).
So: land #1586, close this one. I have posted the matching correction on #1586 and in my state file; sorry for the reversal on the same pair.
Fixes #1582
The answer to the issue's either/or: it is (2), and it is a one-condition bug
Not a host fallback and not a lost launch. Every launch site accounts
M == 1toprof_dec_andM > 1toprof_pre_:and
prof_addfed the step's phase snapshot only for the decode accumulator:So every batched launch was invisible to
g_ph— and the[bs]line derives host as "layer time minus the device slices"(
q4nx_forward.cpp:3606:g_ph.layer - gsum), which makes a prefill that launched perfectly on the device report exactly what ahost fallback would:
n=0,host = the whole layer time. Your 219-row line (wall=97588,layers=93113,n=0,host=93113) isthat, to the millisecond — and it is why the same process reported
npu_gemm 656 mstwo calls later: decode goes throughprof_dec_.Also checked, because it would have changed the answer: your hypothesis (1) cannot be true.
max_batch_rows()isxd_ ? xd_->max_rows() : 1(q4nx_forward.cpp:3630) andmax_rowsis the decode width (16) or the prefill width — never 0 — soblk > 0 && M > blkis true atM=219and the blocked path is taken, and it callsnpu_gemm_rowsonce per block. The launcheshappen; only the counting missed them.
The change (one file, +8/−1, measurement only)
Drop
acc == prof_dec_from that condition.g_phis the per-step snapshot that nothing reads back, so this changes what theline says, not what the engine does; a step is either batched or per-token, so counting both accumulators cannot double-count a
launch, and the
g_in_headexclusion is unchanged. The reason is written above the line so the next reader does not "restore" it.Verification, and what is not verified
g++-16 -std=c++26 -fsyntax-only -DONEBIT_NPU_RUNTIME_OWNplusthe npu include paths → rc 0 (run under
~/lb/thermal-run.sh).tools/copyright.py --checkrc 0.hybrid_prompt_probeunder
npu-holdwithNPU_PHASE_PROF=1— and the prediction to check is specific: a 219-row prefill now printsn > 0andnon-zero
ql/wait/rb, withhostdropping from the whole layer time to the residual. If it still printsn=0, the launchesare not reaching
prof_addat all and the interesting bug is somewhere else — that would be the useful negative result.What this does not touch: the 93 s wall, the 3× thermal swing, and whether a host-assisted prefill is what serve measured. If the
prefill is genuinely slow, the fixed line now says where the time went instead of labelling all of it host — which is the number
gate 3 (#1498) actually needs. I also did not touch
prof_dump's per-accumulator tables; they already separate dec from pre.