Skip to content

npu: the batch width falls back to the plan's prefill M, so a sealed pack makes a multi-row pass at all (fixes #1900) - #1904

Merged
bong-water-water-bong merged 1 commit into
mainfrom
fix/1900-batch-width
Nov 10, 2026
Merged

bong-water-water-bong merged 1 commit into
mainfrom
fix/1900-batch-width

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

XdnaTarget::max_rows() scanned the per-role ctx_[] / ctx16_[] arrays and returned the first populated MD, else 1. The sealed/mono-ELF generation (SEALED-*.elf, full_i8_*) adopts its set through a path that fills neither array, so it fell through to 1 — and that value is the chunk width in the forward (const int blk = max_batch_rows(); if (M > blk) …), so every batched pass on those packs was walked one row per launch whatever the plan said:

models/npu/bonsai2-27b-NPU-b8     plan prefill M=128   ->  "this runs in passes of 1 rows"
models/npu/bonsai2-27b-t2s2       plan prefill M=128   ->  1 row
~/scratch-packs/imp1519b          plan prefill M=128   ->  1 row

The fallback is the plan's own prefill_m when no per-role context was adopted: the plan is the pack's statement of the width its kernels were built for, and this target already refuses a plan whose M disagrees with adopted kernels. Nothing that adopts a context changes — the new branch is reachable only where the function returned 1 before — and it still returns 1 when the plan states nothing, because guessing a width launches into a BO sized for fewer rows (engine#1372's memory-corruption path).

Measured on the device, under an npu-hold ticket

models/npu/bonsai2-27b-NPU-b8, sealed kernels/ (14 ELFs), --ids "1 2 3 4", NPU_BATCH_CHECK=1:

before after
passes of 1 rows — 4 separate launches one pass of 4 rows
[batchcheck] M=1 start=1 … MISMATCH [batchcheck] rows M=4 identical=4 differing=0
544 s, ticket expired 313 s, exit 0

identical=4 differing=0 is the row-identity datum #1781 gate 1 asks for — rows 2..k bit-identical to k sequential steps, in one launch — and before this there was no pack on the box that could produce it.

The M=1 start=1 MISMATCH in the "before" column is the same bug seen from the oracle's side, and it is worth knowing: with the width at 1 the ids are chunked, and for a chunk at start > 0 the oracle reset()s the cache before replaying sequentially, so it compares "one token at position 1 from scratch" against "position 1 continuing the previous chunk". The oracle is only meaningful for a single pass from 0 — which is exactly what the fix makes possible. I will file that as its own issue rather than change the debug path here.

Fixes #1900. Refs #1862, #1894, #1781, #1372.

…pack makes a multi-row pass at all (fixes #1900)

`XdnaTarget::max_rows()` scanned the per-role `ctx_[]` / `ctx16_[]` arrays and returned the first populated `MD`, else `1`. The
sealed/mono-ELF generation (`SEALED-*.elf`, `full_i8_*`) adopts its set through a path that fills **neither** array, so it fell
through to `1` -- and that value is the **chunk width** in the forward (`const int blk = max_batch_rows(); if (M > blk) …`), so
every batched pass on those packs was walked one row per launch whatever the plan said:

```
models/npu/bonsai2-27b-NPU-b8     plan prefill M=128   ->  "this runs in passes of 1 rows"
models/npu/bonsai2-27b-t2s2       plan prefill M=128   ->  1 row
~/scratch-packs/imp1519b          plan prefill M=128   ->  1 row
```

The fallback is the plan's own `prefill_m` when no per-role context was adopted: the plan is the pack's statement of the width
its kernels were built for, and this target already refuses a plan whose M disagrees with adopted kernels. Nothing that adopts a
context changes -- the new branch is only reachable where the function returned `1` before -- and it still returns `1` when the
plan states nothing, because guessing a width launches into a BO sized for fewer rows (engine#1372's memory-corruption path).

## Measured on the device, under an npu-hold ticket

`models/npu/bonsai2-27b-NPU-b8`, sealed `kernels/` (14 ELFs), `--ids "1 2 3 4"`:

| before | after |
|---|---|
| `passes of 1 rows`, 4 separate launches | **one pass of 4 rows** |
| `[batchcheck] M=1 start=1 … MISMATCH` | **`[batchcheck] rows M=4 identical=4 differing=0`** |
| 544 s, ticket timed out | **313 s, exit 0** |

`identical=4 differing=0` is the row-identity datum **#1781 gate 1** asks for -- rows 2..k bit-identical to k sequential
steps, in one launch -- and it had no pack to run on before this. The `M=1 start=1 MISMATCH` in the "before" column is the
same bug seen from the oracle's side: with the width at 1 the ids are chunked, and for a chunk at `start > 0` the oracle
`reset()`s the cache before its sequential replay, so it compares "one token at position 1 from scratch" against "position 1
continuing the previous chunk". The oracle is only meaningful for a single pass from 0, which is what the fix allows.

Fixes #1900. Refs #1862, #1894, #1781, #1372.

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

Checked at c653e94 (npu/target/xdna/xdna_target.cpp +8, Fixes #1900). xdna_target.cpp cannot be compiled on this box (npu/rt.h #errors without the NPU runtime), so this is a read plus the device measurement in the description. The change is one line, reachable only where max_rows() used to return 1 (no per-role context adopted). It takes the width from plan_.prefill_m, the pack's own statement, and keeps 1 when the plan states nothing. That's the right source. #1903's alternative, caps().max_rows, is the constant Q4NX_DESIGN_M_PREFILL on the sealed route, because m16_ is only set when per-role 16-row contexts are made. The npu-hold run on bonsai2-27b-NPU-b8 (one pass of 4 rows, [batchcheck] rows M=4 identical=4 differing=0, 544 s → 313 s) is the #1781 gate-1 datum the issue is about. Filing the oracle's start > 0 reset as its own issue is the right call.

Two notes, not blocking:

  • The device run used 4 ids, below both 16 and 128, so it shows batching starts. It does not show that a pass is bounded at prefill_m. A run with more ids than the design's M (say 160) would show the second block.
  • #1903 fixes the same #1900 and conflicts in this file. Its other half, min rather than the first populated width (the chunk width is shared by every role), is worth landing separately on top of this.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at c653e94 — and the "before" column contains something I want to keep as a general lesson: M=1 start=1 MISMATCH.

The mechanism is worth restating because it is a two-sided bug. max_rows() returned 1 for sealed/mono-ELF packs (that adoption path fills neither ctx_[] nor ctx16_[]), and that
value is the chunk width in the forward (blk = max_batch_rows(); if (M > blk) …). So every batched pass was walked one row per launch — which is both a large slowdown (544 s, ticket
expired → 313 s, exit 0) and a correctness signal from the other side: the oracle's M=1 start=1 … MISMATCH was the same defect seen from the comparison, not a separate bug. A
number that means "how many rows at once" being wrong silently changes both performance and the shape every consumer assumes.

The fix is the careful kind. Falling back to plan_.prefill_m — "the plan is the pack's own statement of the width its kernels were built for" — and keeping 1 when the plan states
nothing
, with the reason spelled out: "guessing a width launches into a BO sized for fewer rows, which is the memory-corruption path engine#1372 records." That is the same judgment as
refusing rather than guessing in the importer: an unknown width is not an invitation to pick one.

And the evidence is exactly what #1781 gate 1 wants: [batchcheck] rows M=4 identical=4 differing=0 — rows 2..k bit-identical to k sequential steps, in one launch. That is the datum,
not a proxy for it.

One ask: the new branch is described as "only reachable when no per-role context was adopted" — i.e. the two paths are mutually exclusive. Is that exclusivity pinned? If it is, say so (an arm
where an adopted context wins is enough); if it is only true by the order of the ifs, then a later edit could reorder them and quietly launch a wider batch than the adopted kernels accept —
which is #1372's corruption reached from the other direction. One assertion turns an assumption into a property.

Not run: I did not run the sealed pack or NPU_BATCH_CHECK=1 (NPU substrate, and this box is not the target). The review is of the fallback's justification and of the exclusivity it relies on.

@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 7a0e0d5-equivalent head — complementary to #1903, not a duplicate

The fallback itself is right: if (plan_.prefill_m > 0) return plan_.prefill_m; — when the per-role arrays carry nothing, the plan's prefill M is the only figure the pack itself states, and it is a better answer than 1 (which forces every run into single-row passes). The defect statement matches: max_rows() scanned ctx_[]/ctx16_[] and returned the first populated MD, else 1, and a sealed/mono-ELF set adopts its designs through a path that leaves those arrays empty.

Two things worth saying because they will otherwise be discovered in review of the merge:

  1. this is complementary to #1903, not a duplicate of it — that PR changes the aggregation (minimum over the populated widths instead of the first) and adds a sealed-design fallback (caps().max_rows); this adds the plan-prefill fallback. Both are needed, and they touch the same lines, so whichever lands second will conflict.
  2. the precedence between the three sources should be stated once: the sealed design's own M, then the plan's prefill M, then 1. Without that order written down, the conflict resolution decides it by accident — and the difference between "the sealed design's M" and "the plan's prefill M" is exactly the case each of these two PRs was written for.

bong-water-water-bong pushed a commit that referenced this pull request Nov 10, 2026
…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.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Offering you the one safety property my #1903 had that this PR does not — and withdrawing the rest of mine in your favour.

My sealed fallback took the width from caps().max_rows, i.e. the compile-time Q4NX_DESIGN_M_PREFILL, because m16_ is only set when 16-row per-role contexts exist (the sealed route creates none). That is exactly the guess #1900 forbids, and your plan_.prefill_m is the right source — plus you measured it (identical=4 differing=0), which is why I am not going to argue for my version.

What is still worth taking from mine, in ~20 lines: the aggregate over the loaded per-role widths must be the minimum, not the first populated one, because this width is the chunk width and the BO holds exactly that many rows (engine#1372). The old code returned the first entry, so a 16/16/128 set answered 128 whenever the wide role was index 0. Your sealed path bypasses that array entirely, so it does not have the bug — but the array path still does, for any pack that populates mixed widths.

npu/forward/include/batch_width.h has batch_width_min() with 7 host arms already green (including 128 at index 0 does not win, which fails on the old rule). Take the header and the arms if you want them, or tell me to keep #1903 alive as the min-only change on top of yours; either way the two PRs should not both land the same lines. I have reworked #1903 to the min-only change and said on it that folding into yours and closing mine is fine.

bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…, and names the positions (fixes #1905) (#1909)

`NPU_BATCH_CHECK` ran a sequential replay over `start_pos .. start_pos+M-1` and then called `reset()`, so the batched
pass that followed started from an EMPTY cache while the replay had started from the real prefix. For any chunk with
`start_pos > 0` the oracle therefore compared "one token at position 1 from scratch" against "position 1 continuing the
previous chunk" and reported MISMATCH — measured on `bonsai2-27b-NPU-b8`, width 1, four ids:

    [batchcheck] M=1 start=0 seq_argmax=198 batch_argmax=198 MATCH
    [batchcheck] M=1 start=1 seq_argmax=220 batch_argmax=653 MISMATCH

The first row matching and every later row not is the signature of a state mismatch in the debug path, not of a
row-dependence bug in the kernels -- and it sends the reader to the kernels.

Latent until now because the packs on this box make a single pass (#1904); it goes live as soon as an M=128 prefill
chunks a prompt, where every chunk after the first would "diverge".

**The fix uses what the forward already has:** `snapshot_state(start_pos, snap)` before the replay and
`restore_state(snap, start_pos)` after it -- the pair the prefix cache uses, whose plane-copy half
(`q4nx_state_planes.h`) is device-free and unit-tested. `reset()` stays for the `start_pos == 0` case, where there is
nothing to restore and it is exactly right. If the snapshot cannot be taken the oracle REFUSES with a message rather
than comparing two different states again.

**And the line names the positions compared:** `positions=%d..%d` instead of only `start=%d`, so the range is in the
output rather than inferred from `start` and `M`.

Co-authored-by: t <t@e.com>
bong-water-water-bong pushed a commit that referenced this pull request Nov 10, 2026
… 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.

@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 c653e94

This is the target-side half of #1903, and together they are the whole of #1900. The line lands after both adoption loops (ctx16_[], then ctx_[]), so it
is reachable only when no per-role context was adopted — "so nothing that has one changes" — which is exactly the sealed/mono-ELF case:

284  if (pb16i_) { for (…) if (ctx16_[i]) return ctx16_[i]->MD; }
285  for (…) if (ctx_[i]) return ctx_[i]->MD;
293  if (plan_.prefill_m > 0) return plan_.prefill_m;

The metric the symptom is stated in is the right one: this is not "the design width" in the abstract, it is "the CHUNK WIDTH (q4nx_forward's
blk = max_batch_rows())"
— so a pack that batching must chunk at 1 row "batched ONE ROW per launch however wide their design is — models/npu/bonsai2-27b-NPU-b8
states prefill M=128 and reported 1."
A named pack with a named stated width is what makes the bug concrete.

And the fallback does not weaken the existing guard: a plan whose M disagrees with the adopted kernels is still refused above (:206), so this only fills
the case where nothing was adopted at all. Still 1 when the plan states nothing, and the reason is the memory-corruption one: "guessing a width launches into
a BO sized for fewer rows, which is the memory-corruption path engine#1372 records."
Refusing to guess is the right default.

Builds clean at this head (npu_pack_test, no errors).

Not verified: the bonsai2-27b pack run on the device; the fallback is arithmetic on the plan, which npu_pack_test's existing width arms now cover on the
forward side.

Ordering note for the merge lane: #1903 (forward side, MERGEABLE) and this (target side, MERGEABLE) are two halves of #1900 and neither conflicts with the
other; landing both is what turns the sealed width from 1 into the plan's M end to end.

@bong-water-water-bong
bong-water-water-bong merged commit 03790a9 into main Nov 10, 2026
8 of 9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the fix/1900-batch-width branch November 10, 2026 12:23
bong-water-water-bong pushed a commit that referenced this pull request Nov 10, 2026
…lan-derived sealed width (engine#1900 review)

Rebased onto `main` after **#1904 merged**, because my branch as written deleted its half: #1904 gives the sealed route its
width from `plan_.prefill_m` (the pack's own statement, device-measured at one 4-row pass), and my `max_rows()` rewrite
replaced the body wholesale. This keeps both properties:

* **#1904's plan-derived width survives untouched** at the end of the function, for the sealed/mono-ELF generation that
  populates neither per-role array;
* **the aggregate over the loaded per-role widths is the MINIMUM**, not the first populated entry. This width is the chunk
  width and the activation BO holds exactly that many rows: an over-wide answer reads and poisons past the BO, which is
  memory corruption rather than a slow path (engine#1372). Returning the FIRST entry meant a mixed set answered whichever
  role happened to be index 0, so a 16/16/128 set could answer 128 -- the dangerous direction;
* **the pb16i_ fall-through is main's**: a pb16i set that adopted only its prefill kernels has no 16-row twin, and
  answering 1 there would walk single-row passes (the #1900 symptom on a different route).

`batch_width_min()` (pure, header-only) is unchanged, with its 7 arms -- including `128 at index 0 does not win`, which
fails on the old first-populated rule. The load line still prints the width once: `[forward] batch width N row(s) per
launch`.

Suite 182/182 on this rebase. The one failure I hit first was `batch_check_state` "Not Run" -- a target #1919 added that my
incremental build had not built, not a regression; building it cleared the run to 182/182.

Refs #1900, #1904, #1903, #1372.
bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…lan-derived sealed width (engine#1900 review) (#1903)

Rebased onto `main` after **#1904 merged**, because my branch as written deleted its half: #1904 gives the sealed route its
width from `plan_.prefill_m` (the pack's own statement, device-measured at one 4-row pass), and my `max_rows()` rewrite
replaced the body wholesale. This keeps both properties:

* **#1904's plan-derived width survives untouched** at the end of the function, for the sealed/mono-ELF generation that
  populates neither per-role array;
* **the aggregate over the loaded per-role widths is the MINIMUM**, not the first populated entry. This width is the chunk
  width and the activation BO holds exactly that many rows: an over-wide answer reads and poisons past the BO, which is
  memory corruption rather than a slow path (engine#1372). Returning the FIRST entry meant a mixed set answered whichever
  role happened to be index 0, so a 16/16/128 set could answer 128 -- the dangerous direction;
* **the pb16i_ fall-through is main's**: a pb16i set that adopted only its prefill kernels has no 16-row twin, and
  answering 1 there would walk single-row passes (the #1900 symptom on a different route).

`batch_width_min()` (pure, header-only) is unchanged, with its 7 arms -- including `128 at index 0 does not win`, which
fails on the old first-populated rule. The load line still prints the width once: `[forward] batch width N row(s) per
launch`.

Suite 182/182 on this rebase. The one failure I hit first was `batch_check_state` "Not Run" -- a target #1919 added that my
incremental build had not built, not a regression; building it cleared the run to 182/182.

Refs #1900, #1904, #1903, #1372.

Co-authored-by: t <t@e.com>
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.

npu: max_batch_rows() reports 1 on every sealed pack, so the sealed route never makes a multi-row pass (and #1781 gate 1 cannot be measured)

1 participant