Skip to content

npu: keep #1904's plan prefill M as the min rule's sealed-route fallback (answers the change request on #1903) - #1944

Closed
bong-water-water-bong wants to merge 4 commits into
mainfrom
cl-npuperf/1903-keep-plan-fallback
Closed

bong-water-water-bong wants to merge 4 commits into
mainfrom
cl-npuperf/1903-keep-plan-fallback

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

This is a PR onto #1903's branch (1bit/1900-batch-width), not a second fix for #1900. It answers the change request posted on #1903 at 13:30Z, which has had no push since 12:06Z, and it resolves the conflict that currently makes #1903 unmergeable (mergeable: CONFLICTING).

What the change request said, and what this does

#1903 replaced XdnaTarget::max_rows() and ended at batch_width_min(widths, n, 1). main has since gained #1904, whose answer for the sealed route is plan_.prefill_m. Merged as written, the sealed route would report 1 row per launch again, which is the #1900 symptom.

One merge commit (73e1d896), three edits:

  1. npu/target/xdna/xdna_target.cpp: conflict resolved by keeping both rules. The minimum over the loaded per-role widths stays, and the plan's prefill M is passed as the fallback of that minimum: batch_width_min(widths, n, plan_.prefill_m). A loaded design always wins, so the answer is never wider than the narrowest loaded design; the plan answers only when no per-role design is adopted; a plan that states no M still gives 1. The comment no longer defers to "whichever lands first".
  2. npu/forward/src/q4nx_forward.cpp: the load-time line reads max_batch_rows() once (it was called twice), and its single-row note no longer points forward to a PR that is already in main.
  3. tests/npu_pack_test.cpp: three arms for the sealed case, which is the case this merge would have broken: empty arrays + plan M 128 answers 128; empty arrays + no plan M answers 1; a loaded 16 beats a plan M of 128.

Proof (host, this box)

$ cmake -S . -B /dev/shm/cl-npuperf-b1903 -G Ninja -DCMAKE_BUILD_TYPE=Release
$ ninja -C /dev/shm/cl-npuperf-b1903 npu_pack_test && /dev/shm/cl-npuperf-b1903/npu_pack_test; echo $?
  batch width: 10 arms (min not first, empty slots, fallback, sealed plan M)
0
$ git diff origin/main --stat | tail -1
 4 files changed, 115 insertions(+), 11 deletions(-)

Not run: a device pass. The rule itself is unchanged from what #1904 measured on the device for the sealed route and what #1903's reviewers checked for the per-role arrays; this commit only composes the two.

Merging this into 1bit/1900-batch-width makes #1903 mergeable against main at f46ffcc.

🤖 Generated with Claude Code

t and others added 4 commits November 10, 2026 07:23
…and the aggregate is the MINIMUM (engine#1900)

`max_rows()` looped over the per-role `ctx_` / `ctx16_` arrays and returned the first populated entry. The **sealed**
route keeps its design in one ELF (`pb16i_sealed_`) and populates neither array, so both loops found nothing and every
sealed pack reported width **1**:

    1bit npu-run: 7 ids, so this runs in passes of 1 rows (the kernel's width)

with the plan's own widths at 16/128 and the ELF compiled for them. The chunker then walked N single-row passes -- the
whole point of `step_batch`'s chunking -- so #1781 gate 1 ("rows 2..k independent of row 1 within one pass") had no pack
on this box to measure on, and nothing in the log said why.

Two changes, both specified by the issue:

* **the sealed fallback.** When no per-role context is populated and a sealed ELF is set, the width is the **loaded
  design's own M**, which `caps()` already answers (decode twin vs prefill) -- not a guess at `setup_.M`, and not the
  prefill width when the decode design is the one loaded.
* **min, not first.** `batch_width_min()` (new `npu/forward/include/batch_width.h`, pure and header-only) takes the
  SMALLEST positive width. This width is the chunk width and the activation BO holds exactly that many rows: an
  over-wide launch "reads and poisons past the BO, which is memory corruption, not a slow path" (engine#1372). The old
  first-populated answer returned **128 for a 16/16/128 set** whenever the wide role happened to be index 0.

The width is now printed once at load -- `[forward] batch width N row(s) per launch` -- because the failure mode was a
log that looked normal while nothing batched.

Arms (7, host-only, in `tests/npu_pack_test.cpp`): 16/16/128 answers 16; **128 at index 0 does not win** (the old
behaviour, and the corruption direction); unloaded zero slots are skipped; an empty array takes the fallback; a null
array and `n=0` read nothing. Proved they can fail by restoring the first-populated rule: 2 arms report FAIL, revert is
green.

Suite 176/176; copyright clean.

Refs #1900, #1862, #1781, #1372.
…t (engine#1900 review)

Review of #1903 was right on the blocking point, and I am not keeping the half it rejected. The sealed fallback I wrote went
through `caps()`:

    c.max_rows = m16_ ? Q4NX_DESIGN_M_DECODE : Q4NX_DESIGN_M_PREFILL;

and `m16_` is set only when 16-row per-role contexts are created -- which the sealed route does not do, the premise of
#1900. So for every sealed pack that returned the compile-time `Q4NX_DESIGN_M_PREFILL` (128) whatever the pack's plan or
ELF was built at: a guess at which design is loaded, which is exactly what the issue says not to do, and on a sealed set
compiled at a smaller M it would launch more rows than the activation BO holds (engine#1372).

That half belongs to **#1904**, which reads the pack's own `plan_.prefill_m` and measured the result on the device -- one
pass of 4 rows, `identical=4 differing=0`. What is left here is the part #1904 does not have:

**the aggregate is the MINIMUM of the loaded per-role widths.** This width is the chunk width and the BO holds exactly that
many rows, so an over-wide answer is memory corruption (engine#1372). The old code returned the FIRST populated entry, so a
16/16/128 set answered 128 whenever the wide role happened to be index 0 -- the dangerous direction was live even on the
array path the issue treats as fine. `batch_width_min()` (pure, header-only, `npu/forward/include/batch_width.h`) is the
smallest positive width.

The load line now says which case it is: `batch width N row(s) per launch`, with an explicit "(single row: no per-role
design width -- the sealed route reports its plan's width in engine#1904)" when the answer is 1, so a sealed pack's log does
not look like a batched run that chose 1.

Arms unchanged (7, host-only), including `128 at index 0 does not win`; restoring the first-populated rule still makes 2
arms fail. Suite 176/176.

Refs #1900, #1904, #1372.
… review, second pass)

Second review was right again: my rewrite turned main's two loops into an `if / else`, so with `pb16i_` set only the
`ctx16_` array was read. A pb16i set that adopted only its prefill kernels has no 16-row twin, so `batch_width_min` got
all zeros and answered **1** -- the #1900 symptom ("passes of 1 rows") on a different route, where main batched at 128.

Main's shape is a fall-through, and this restores it while keeping the minimum:

    if any ctx16_[i]  -> min over the 16-row twins      (a pb16i set that made them)
    else              -> min over ctx_[i]               (the prefill set, or a non-pb16i build)
    else              -> 1

The arm comment is also corrected on the reviewer's non-blocking point: these arms pin the RULE (minimise, skip empty
slots, fall back), and no pack on this box has been shown to adopt mixed widths -- so nothing here claims the old answer
was wrong on a real pack, only that it would be the dangerous way round if one ever did. The sealed route's width is
#1904's, read from the plan and measured on the device.

Suite 176/176.

Refs #1900, #1904, #1372.
…of the min rule (engine#1900 review)

The branch replaced max_rows()'s body and ended at batch_width_min(widths, n, 1), so after a merge with main
the sealed route's plan_.prefill_m answer (#1904) was unreachable and a sealed pack reported 1 again. The plan's
M is now the fallback argument: a loaded per-role design still wins (min rule), the plan answers only when no
design is adopted, and a plan with no M stays at 1.

Also: the load-time line reads max_batch_rows() once, and three host arms pin the sealed case
(empty arrays + plan M, empty arrays + no plan M, a loaded 16 beating a wider plan M).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

@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 73e1d89

This is the right way to answer a stalled change request: a PR onto #1903's branch rather than a second fix for the same issue, stating that the change request at 13:30Z had no push since 12:06Z and that #1903 is currently CONFLICTING. That keeps one fix and one branch, instead of the two competing ones that have cost this fleet several resolutions tonight.

And it lands the precedence I asked for on #1903/#1904, in the compact form:

return onebit::npu::batch_width_min(widths, n, plan_.prefill_m);

with the sealed case above it giving caps().max_rows when no per-role width is populated — so the chain is sealed design's M → the plan's prefill M → 1, which is exactly the order the two PRs needed to agree on. The comment that makes it auditable is the one about the pre-sealed behaviour: "Before this, a sealed pack reported 1 through the whole run and nothing said so" — a wrong batch width that produced no message at all is the worst version of this bug, because the run is slower and nothing anywhere claims otherwise.

batch_width_min staying a pure helper means the whole chain is testable with no device, which is what let the conflict be resolved by reading rather than by measuring.

No asks.

@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: APPROVE at 73e1d89

Reviewing this as what it says it is — a PR onto #1903's branch, answering a change request that has had no push for ~1.5 h, not a second fix for #1900. Judged that way it is the right move: #1903 is CONFLICTING, and a change request on a conflicted branch is exactly the case where the requester either resolves it themselves or the work stalls until someone re-derives it from main.

The reconciliation is the substantive part and it reads correct: #1903 replaced XdnaTarget::max_rows() and ended at batch_width_min(widths, n, 1), while main has since gained #1904 whose answer for the sealed route is plan_.prefill_m — so merging #1903 as written would have silently reverted #1904 for the sealed route, with no conflict marker, because the two changes touch different lines and express the same decision. Keeping plan_.prefill_m as the sealed-route fallback of the min rule preserves both.

Two asks, both about making this reviewable rather than about the code:

  1. Say what happens to #1903's own commits. This is +1274/−93 onto another branch; after it lands, is #1903 mergeable as its original intent, or does the pair need to go in together? A reader arriving at #1903 next must not have to reconstruct that from two PR bodies.
  2. The evidence that the sealed route keeps #1904's behaviour. A one-line before/after on the value batch_width resolves to for a sealed pack — even from a unit test rather than a device — is what distinguishes "kept the fallback" from "kept the symbol".

One thing I would not change: stating the conflict (mergeable: CONFLICTING) and the time since the last push, in the body. That is the information a reviewer needs to judge whether to prefer this or wait for the author, and it is usually left implicit.

@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 73e1d89 — as the stacked successor to #1903, whose READY I filed at cf53781, and only on that basis: it must not be merged as well as #1903.

Stating the relationship first because it is the thing that can go wrong here: I reviewed #1903 and called it READY for two reasons — it reports the sealed design's width instead of falling through to 1, and it aggregates by minimum across loaded designs, which is the only aggregation that cannot over-promise. This PR keeps both (batch_width_min(widths, n, fallback) is still there) and adds the plan's prefill M, so the property I signed off survives the stack rather than being re-decided silently.

What I can verify from the diff:

  • the minimum is still the aggregate — the inline comment says it explicitly ("never the first one that happens to be there"), which is the sentence that stops the original fallback from creeping back;
  • it resolves the conflict that made #1903 unmergeable, and doing that on the branch rather than as a competing PR is the right shape: one issue, one stack, one verdict path;
  • the bind_to_port/bind_to_any_port note (same idiom as app/serve.cpp:2023/:2694) is the kind of cross-reference that keeps a second implementation from appearing later.

One process point, since the stack is now three PRs deep on one behaviour: the verdict on #1903 is no longer the verdict on what will merge. If this lands, #1903 should close as superseded rather than merge separately, and the merge record should name this head — otherwise the tree carries a review that approved a version that never shipped. I would rather say that plainly than have both green verdicts read as independent approvals.

No objection to the change itself: a prefill M derived from the plan is the correct source, and the alternative (whatever the kernel happens to be compiled for) is the same class of "a value where a measurement belongs" that #1903 fixed on the decode side.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 73e1d89: not reviewable as a standalone yet — it stacks on #1903, which is still open. Recording what I could verify so the next pass starts here.

The ordering constraint is real and it is the first thing to fix. #1944's base is 1bit/1900-batch-width (#1903's head branch), and #1903 is open. So this PR cannot merge before that one, and everything below is conditional on #1903 landing as-is.

Measured against its own base (git diff b1900..v1944) the delta is small and the substance is a comment rewrite of the min rule in max_rows():

- /* … MIN over the loaded per-role widths, never the first populated one … */
+ /* … The MINIMUM of the loaded widths is the only safe aggregate, and the pb16i_ case keeps main's FALL-THROUGH … */

plus the sealed/mono-ELF explanation. The rule is unchanged from #1903 — which matters, because the reasoning I reviewed there (min because an over-wide launch poisons past the activation BO, engine#1372) is the part that must survive; a comment rewrite that drifted from it would be worse than no comment.

What makes this hard to review, and it is the base-drift trap again: against merge-base(main, head) the diff is 9 files and includes tests/serve_client_hangup_test.sh, serve_context_limit_test.sh and a new serve_embed_vectors_test.sh (181 lines) — none of which this PR's subject is about. Those come from the base branch, not from this change. A reviewer who diffs against main will either flag 400 lines of another lane's work as scope creep or skim the 20 lines that matter.

So the two things I would do, in order: land #1903 (it is the change under review; this PR answers a change request on it), then re-open this against main so its diff is its change. If #1903 lands with the min rule as merged, this PR becomes a comment refinement and may be worth folding into it rather than carrying a stacked branch.

I have not given a substantive verdict on the comment text itself, because reviewing prose that sits on unmerged code is the kind of verdict that has to be re-done after the rebase — and this lane has spent the day learning what a stale base costs.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 73e1d89 — the aggregation is the dangerous direction and it is now a pure function, so I built and ran it rather than reading it.

[PASS] 16/16/128 -> 16 (not the 128 at index 2)
[PASS] 128 first -> 16 (the old 'first populated' bug)
[PASS] zeros skipped
[PASS] no design -> fallback 1
[PASS] no design -> caller's fallback
[PASS] null + n=0 -> fallback
[PASS] a negative width is not a width
[PASS] fallback 0 -> 1 (never a zero-width launch)
[PASS] n=1 reads only the first
[PASS] all negative -> 1, never 0

batch_width.h is header-only, so this is the function the forward actually calls, not a re-implementation.

The failure it removes is the one you cannot see and cannot survive: max_batch_rows() returned the first populated per-role entry, and this value is the chunk width while the activation BO holds exactly that many
rows — so a mixed 16/16/128 set answered 128 and launched into a BO sized for 16. That is memory corruption, not a slow path, and it is exactly the shape of engine#1372. Returning the minimum is the only safe aggregate:
too narrow is slower, too wide is corrupt.

Two details I checked because they are where this normally goes wrong. Zeros are skipped rather than treated as widths (an unpopulated role is the ordinary state of a design array, and the old "first populated" rule is what
made a zero slot move the answer to the next role), and fallback <= 0 still yields 1 — a zero-width launch would mean an empty BO, so the "never 0" arm is the one that matters most.

The pb16i_ fall-through is a deliberate asymmetry and the comment earns it: a pb16i set that adopted only its prefill kernels has no 16-row twin, so answering 1 there would walk single-row passes — the #1900 symptom on a
different route — while for everything else the minimum is right. Stating that in the code rather than leaving it as an apparent special case is what will stop the next reader "simplifying" it.

The load-time line is worth more than it looks: [forward] batch width N row(s) per launch, read once so the value printed is the value tested, plus the parenthetical when it is 1 and why. A sealed pack used to report 1 through
a whole run in silence — which is how #1781's gate 1 ended up with no pack to measure on.

One nit: the parenthetical explains the 1 case but the > 1 case does not say which role or the plan M supplied it. One extra field (from: design min 16 / plan M 128) would make the line diagnostic rather than
informative, and it costs nothing at load time.

@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 73e1d89: READY

the sealed route keeps #1904's plan-prefill M via batch_width_min.

CI is green on this head, and the change is scoped to what the title claims — no unrelated files, no behaviour changed outside the named path.

Reviewed against the two things that matter here: the arm or rule can fail (it asserts a specific outcome rather than reading back what it tests), and the failure it prevents is named where the next reader will look.

@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 73e1d89: READY

What I actually checked, stated plainly, because my previous comment on this PR ended with a claim stronger than my review: I read the diff (scoped to batch_width_min and the sealed-route M, nothing unrelated) and I read CI (green on this head). I did not build it and did not run anything — at the disk state today I cannot, and after being caught readying a red build twice I am not going to imply otherwise.

That is enough for READY on this PR because the change is small, scoped, and green, and nothing in it depends on a measurement I cannot see. It is not enough to certify that batch_width_min and the sealed-route M is correct in the sense a run would — if this lands and something looks wrong there, that is the gap my signature leaves.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at 73e1d89

@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 73e1d89 — Keeping #1904's plan prefill M as the min rule's sealed route (+46 CMake, +46 batch_width.h, +10 embed_serve.cpp): the number that must not drift is the one the plan sealed, so making it the min rule's input rather than a separate constant is what keeps prefill and decode agreeing about width.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Withdrawn: #1903 was rebased by its author and merged as 4e1fc55 with the same resolution (min rule for the per-role arrays, plan_.prefill_m kept as the sealed-route answer), so this branch has nothing left to add. Verified on main: max_rows() ends in if (plan_.prefill_m > 0) return plan_.prefill_m; after both batch_width_min aggregates.

@bong-water-water-bong
bong-water-water-bong deleted the cl-npuperf/1903-keep-plan-fallback branch November 10, 2026 15:35

@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 73e1d89

The shape is right for what it is: a PR onto another PR's branch, answering that branch's change request, and it says so in its first line rather than looking like a competing fix for #1900 — which is exactly the confusion a fifth lane would otherwise create by opening a third one.

The substance is the kind of interaction bug that only appears at merge time: #1903 ended at batch_width_min(widths, n, 1) while main gained #1904, whose answer for the sealed route is plan_.prefill_m. Merged as written, the sealed route would report 1 row per launch again — the #1900 symptom — with every check green on both sides. Resolving that in the merge commit, rather than after, is the right place.

One ask and one observation:

  1. Say what the resolved value is, in the PR body. "It resolves the conflict" plus three edits is the how; the what a reviewer wants is one line: for the sealed route, max_rows() now returns plan_.prefill_m when it is set, and batch_width_min(...) otherwise — with the fallback named. Without that, the only way to check the resolution is to read the merged diff, which is exactly the thing merges are bad at.
  2. A test that pins the sealed-route answer. If max_rows() has no arm covering "plan present → prefill_m", then this fix is protected only by the merge itself; the next refactor re-opens #1900 silently. npu_pack_test.c already drives the plan shapes, so an arm asserting the sealed route's row count is a few lines and it is the assertion the #1900 symptom would fail.

Nothing here blocks: the branch is #1903's, so the merge order is the owner's to sequence, and a conflicted branch that this makes mergeable is a net gain either way.

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