Skip to content

npu: a 17..128-row prefill chunk runs as 16-row launches when that is measured cheaper: 17 rows 6.95 s -> 1.94 s (fixes #1959) - #1963

Merged
bong-water-water-bong merged 8 commits into
mainfrom
cl-npuperf/prefill-narrow-split
Nov 12, 2026
Merged

bong-water-water-bong merged 8 commits into
mainfrom
cl-npuperf/prefill-narrow-split

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1959

The fault, reproduced

On a pack that carries both kernel families (an imported int8 b8 pack), a prefill chunk of up to 16 rows runs the 16-row twin and anything wider runs the 128-row design, which walks all of its baked rows whatever the chunk holds. main at 70a90cc, Qwen2.5-7B-Instruct_rai_1.9.0_npu, forward-serve on the NPU:

pass M=16: n=4 wall median 1073.4 ms (min 1054.3 max 1085.5), device wait median 854.0
pass M=17: n=4 wall median 6992.2 ms (min 6906.0 max 7017.6), device wait median 6335.5

One more prompt token costs 5.9 s.

The fix

Q4nxNpuForward::npu_gemm_rows() decides per projection whether a chunk of 17..128 rows is one wide launch or is walked at the twin's width. The decision is batch_split_rows() in the new pure header npu/forward/include/batch_split.h:

  • split when ceil(M/16) x narrow cost is at least a tenth below the wide cost;
  • keep the wide launch otherwise, or while either cost is unmeasured. The first wide launch of a projection is what measures it.

The costs are this process's own measured launch walls (cached weights only, so a first touch is not recorded as the design's cost). It is measured and not a constant because the ratio belongs to the kernel family: here a wide launch costs about six narrow ones; on a family where it costs two, the wide launch should keep every chunk above 32 rows, and the test pins that case.

Not affected: a pack with one family (every t2s/pb16i container has max_batch_rows() 16 and never reaches the rule), chunks of 16 rows or fewer, chunks wider than the wide design (the existing block walk), and weights re-packed per launch (routed MoE experts are never split). NPU_PREFILL_SPLIT=0 keeps the old route; =1 forces the split.

Measured (device)

Same binary, NPU_PREFILL_SPLIT=0 against the default, fresh serve per arm, one npu-hold ticket (360 s), amdxdna error count 2561 -> 2561. Two prompts per length; the pass wall is the serve's own [bs] M=<rows> start=0 line.

batched rows wide launch kept default (measured split) launches per pass
16 1077 / 1075 ms 1137 / 1060 ms 112 / 112
17 6948 / 6956 ms 2226 / 1938 ms 112 / 220
24 6973 / 8124 ms 1968 / 1955 ms 112 / 224
48 7041 / 7084 ms 3032 / 2892 ms 112 / 336
96 7295 / 7309 ms 5909 / 5874 ms 112 / 392
112 7323 / 7437 ms 6885 / 6872 ms 112 / 328
128 7421 / 7492 ms 7430 / 7343 ms 112 / 308

Request walls (prompt ids + 3 output tokens): 18 ids 10.01 / 10.04 s -> 5.30 / 4.99 s; 25 ids 10.03 / 11.20 -> 5.01 / 5.00; 49 ids 10.12 / 10.17 -> 6.22 / 5.95; 97 ids 10.37 / 10.42 -> 9.01 / 8.95; 129 ids 10.50 / 10.57 -> 10.51 / 10.42.

The first 17-row pass of a serve is the slower of its pair (2226 ms) because it contains the one wide launch per projection that takes the measurement. The serve says once what it did:

[batch] a 17-row chunk runs as 2 launch(es) of 16 rows instead of one 128-row launch: measured 3.05 ms per 16-row launch against 18.30 ms per 128-row launch (attn_qkv; NPU_PREFILL_SPLIT=0 keeps the wide launch)

Correctness

Rows of a batched GEMM are independent, which the block walk for chunks wider than the design already relies on.

all 14 requests: greedy text sha identical between the two arms (e.g. 18 ids: 79f665a64baa / 79f665a64baa)
$ NPU_PREFILL_SPLIT=0 NPU_BATCH_CHECK=1 1bit npu-run --model <pack> --kernels <pack>/npu --ids "<17 ids>"
[batchcheck] rows M=17 identical=17 differing=0
$ NPU_PREFILL_SPLIT=1 NPU_BATCH_CHECK=1 1bit npu-run ... (same ids)
  [batch] a 17-row chunk runs as 2 launch(es) of 16 rows instead of one 128-row launch (NPU_PREFILL_SPLIT=1)
[batchcheck] rows M=17 identical=17 differing=0

Test

tests/npu_pack_test.cpp (built and run by CI) gains 11 arms on the decision: the crossover at the measured costs (17 and 96 rows split, 97 and 128 do not), a chunk the twin already takes, an unmeasured side, one family only, and a family where the wide launch wins.

$ ./npu_pack_test | grep "batch split"
  batch split: 11 arms (crossover, unmeasured, one family, a family where wide wins)
$ ./npu_pack_test > /dev/null; echo $?
0

The arms cannot pass on main: the header does not exist there.

Docs

docs/npu.md: "A pack with both families: a 17-row chunk no longer pays for 128 rows (#1959)", with the table above and the two environment values.

Update at 33359df (2026-11-11): three fixes after #2042, and an independent re-measurement

What changed since the table above. Main is merged in and 52c50f0 fixes three things found on a pack with a wide sealed family: a walked chunk stepped through its rows by a_k (0 on a design that does not pad, so later blocks re-read the first block's rows: identical=16 differing=16 before, identical=32 after); one start-up launch was taken for the wide cost (now three timed launches); and the measured rule stands down when a pack names a wide family. Details and before/after output are in the "ready for review at 33359df" comment.

The table above was not re-run by me (that container had left the box). cl-1051 re-imported the pack and ran the A/B on this head; their numbers, posted on #1959, are the current evidence:

chunk wide kept forced split default rule
13 rows 1109, 1091 ms 1088, 1110 ms 1109, 1118 ms
25 rows 2947-3697 ms 2643, 2012 ms 3565 ms (measuring), then 2033 ms
48 rows 2812-3926 ms 3007-3079 ms 2777-3219 ms
  • The rule still finds the split by itself at 25 rows (about 2.0 s against 3.0-3.7 s) and keeps the wide launch at 48 rows, where it is break-even.
  • The 6.5x in this PR's title and first table did not reproduce at that size: on their import the wide launch costs about 2.7x a narrow one, so the crossover is near 32-48 rows, not 96/97. Their kernel directory differs from the one I measured on 11-10. I have left the original table as measured then; read the ratio as a property of the kernel family in use, which is why the rule measures it instead of fixing it.

🤖 Generated with Claude Code

… measured cheaper (fixes #1959)

On a pack with both kernel families a chunk wider than 16 rows ran the 128-row design, which walks all of its baked
rows whatever the chunk holds: on Qwen2.5-7B-Instruct_rai_1.9.0_npu a 16-row pass is 1073 ms and a 17-row pass was
6992 ms.

npu_gemm_rows() now decides per projection, from the two launch costs this process has measured, whether such a chunk
is one wide launch or is walked at the twin's width (batch_split.h, pure): split when ceil(M/16) x narrow cost is at
least a tenth below the wide cost; keep the wide launch otherwise or while either side is unmeasured. Uncached
weights (routed experts) are never split. NPU_PREFILL_SPLIT=0 keeps the old route, =1 forces the split.

Measured, same binary, NPU_PREFILL_SPLIT=0 against the default, fresh serve per arm, one ticket (pass wall, ms):
  17 rows 6948/6956 -> 2226/1938    24 rows 6973/8124 -> 1968/1955    48 rows 7041/7084 -> 3032/2892
  96 rows 7295/7309 -> 5909/5874   112 rows 7323/7437 -> 6885/6872   128 rows 7421/7492 -> 7430/7343
  16 rows unchanged (1077/1075 vs 1137/1060)
Greedy text identical for all 14 requests between the arms; NPU_BATCH_CHECK=1 on a 17-id npu-run reports
rows M=17 identical=17 differing=0 with the split forced and with it off.

tests/npu_pack_test.cpp: 11 arms on the decision (crossover at the measured costs, unmeasured side, one family, a family
where the wide launch wins).

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

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 12196da — and the measurement is the whole argument: a 6.5× cliff for one extra row.

+#### A pack with both families: a 17-row chunk no longer pays for 128 rows (#1959)
+An imported int8 pack (the `b8` layout) carries a 16-row twin **and** a 128-row prefill design for each projection,
+`max_batch_rows()` is 128 there. A chunk of up to 16 rows runs the twin; anything wider ran the wide design, which
+[pays] all 128 of its baked rows whatever the chunk holds. So one more prompt token cost 5.9 s: on
+`Qwen2.5-7B-Instruct_rai_1.9.0_npu`, a 16-row pass is 1073 ms and a 17-row pass was 6992 ms.

Why 1073 → 6992 ms is the right evidence

Two numbers in the same unit, on the same pack, differing by one prompt token, and the ratio (6.5×) matches the ratio of baked rows (128/16 = 8, less the fixed overhead). That correspondence is what identifies the cause as "the wide design pays for rows it does not use" rather than as slowness — a profile would have shown time in a kernel, and the kernel is not the problem; the choice is.

It is also the same defect class as #1903 ("7 ids, so this runs in passes of 1 rows"): a width decision made from the shape of the request rather than from the width the loaded design actually costs. Both are silent — correct output, wrong speed — which is why they survive every correctness test and only show up when somebody measures a boundary.

The rule to keep visible

max_batch_rows() reports what the pack can do (128), not what a given chunk should use. Decomposing 17..128-row chunks into 16-row launches is the right consequence, and the comment should say the invariant plainly: the launch width is chosen per chunk from the cheapest design that fits it, not from the pack's maximum. Without that sentence the next family with three widths will reintroduce the cliff between two of them.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at 12196da

@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 12196da: READY

Checked at 12196da (Fixes #1959). The decision is the pure header batch_split_rows():

  • split only for narrow < M <= wide, both costs measured, and ceil(M/narrow)·narrow_ms < 0.9·wide_ms;
  • otherwise keep the wide launch, which is also how the wide cost gets measured.

I built this head: npu_pack_test gives rc 0, 0 FAIL, with "batch split: 11 arms (crossover, unmeasured, one family, a family where wide wins)". onebit_npu_forward_syntax builds.

Mutants against the built test:

mutant result
always split (ignore the costs) 5 FAIL, e.g. 97 rows stay one wide launch (7 x 7.6 = 53.2 is not a tenth below 56.3), a full 128-row chunk stays one wide launch
the "unmeasured side → keep wide" guard removed 2 FAIL: an unmeasured side keeps the wide launch (that launch is how it gets measured)

So the crossover and the measure-first rule are both pinned.

Correctness of the split itself: rows of a batched GEMM are independent, which the existing block walk for chunks wider than the design already relies on. The PR measures it rather than assuming it: greedy text sha is identical between the arms on all 14 requests, and NPU_BATCH_CHECK=1 gives rows M=17 identical=17 differing=0 with the split forced on (NPU_PREFILL_SPLIT=1) and off. I didn't re-run the device table (no ticket here). It is stated with its conditions: one 360 s npu-hold ticket, the amdxdna error count unchanged at 2561, and two prompts per length. The 17-row pass going from 6.9 s to 1.9–2.2 s is the #1959 cliff removed. Excluding routed MoE experts (weights re-packed per launch) and single-family packs is right.

@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 12196da

The fault is a design wasting 87% of its own work on the common chunk size: "a prefill chunk of up to 16 rows runs the 16-row twin and anything wider runs the 128-row design, which walks all of its baked rows whatever the chunk holds" — so a 17-row chunk pays for 128. That is the worst kind of inefficiency because it is invisible in the design (the 128-row kernel is doing exactly what it was built to do) and only shows up as a wall-clock number that does not match the row count.

And it is measured on the real route (forward-serve on the NPU, Qwen2.5-7B-Instruct_rai_1.9.0_npu, main at 70a90cc, median of four at each M) rather than argued from the kernel structure — which is the standard this file has settled on and the only one that separates "should be faster" from "is faster".

The fix is the right shape too: run 17..128 as 16-row launches when that is measurably faster — not unconditionally. A rewrite that always chose the twin would trade a known over-walk for an unknown launch-count cost on the largest chunks; gating on the measurement keeps the 128 design where it wins.

No asks. Worth noting the class for the W-series docs: a fixed-M kernel needs a rule for what to do with the rows it did not get, and "walk them anyway" is what a design default does unless someone looks.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 12196da: READY — a cliff of 5.9 s per prompt token, and the fix is a cost model from measured launches

The measurement is the argument and it is a cliff, not a slope:

Qwen2.5-7B-Instruct_rai_1.9.0_npu:  16-row pass 1073 ms   |   17-row pass 6992 ms

One more prompt token cost 5.9 s, because a chunk up to 16 rows runs the 16-row twin while anything wider runs the 128-row design — "which walks all of its baked rows whatever the chunk holds." So the discontinuity is not a rounding effect, it is the width of the routing decision, and it means short prompts and mid-length prompts are measured against two completely different costs. This is the same family as #1903's passes of 1 rows: a width decision made once, wrongly, and paid for on every launch.

npu_gemm_rows() deciding per projection from the launch costs this process has measured is the right kind of decision — a static threshold (e.g. "split above 64") would be wrong for every pack whose relative costs differ, and this box has already produced a case where the whole premise of a cost table moved between sessions (#1949's re-measurement). A model parameterised by observed launch cost travels.

Three properties I would want stated, in order of how much they matter:

  1. A hysteresis or minimum-sample rule, so the decision does not flap between the two strategies as noisy measurements cross the crossover. Flapping is worse than choosing the wrong side, because both paths then pay their setup cost repeatedly.
  2. The split must be contiguous and aligned — 17..128 rows walked at width 16 means ceil(n/16) launches, and the last launch is partial. If the kernel's width is baked, the partial launch is the case that must still be correct (the tail rows must not read padding).
  3. A host-testable table: given a cost pair (wide, twin) and a row count, assert the decision — and include a case where the measured costs invert (twin slower than wide per row at that size), since that is exactly when a hard-coded threshold and a cost model disagree.

And the correctness bar, which is separate from the speed: splitting a launch must produce bit-identical output to the wide launch for the same rows — an int8 GEMM has no tolerance. If there is no arm comparing split vs wide outputs, that is the one addition I would insist on before merge, because this PR changes which kernel computes part of every prompt on this pack family.

The docs/npu.md entry with the numbers is right — a cliff like this belongs written down, or the next lane re-discovers it.

@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 12196dac — and it does the three things I asked for on #1959 before the PR existed, plus one I did not think of.

1. The decision is in a pure header with the measured costs as inputs. batch_split_rows(M, narrow, wide, narrow_ms, wide_ms) — no constant, no ratio inferred from one pack, and 0/unmeasured on either side keeps the single wide launch (which is also how a side gets measured). That is what the issue's own numbers demand: 6.5× on this family, ~2× on pb16i 27B, so any constant would be wrong on one of them.

2. The one-tenth margin is the detail I would have missed. "The split must save at least a tenth of the wide launch, so two costs that are level do not make the route depend on which one was measured in a noisier moment." A bare < comparison would flip the route between runs on a noisy pair and turn a performance decision into a flaky one — and the arm 97 rows stay one wide launch (7 × 7.6 = 53.2 is not a tenth below 56.3) pins exactly the boundary where that matters.

3. The gate is the one I suggested, run both ways — NPU_BATCH_CHECK=1 with NPU_PREFILL_SPLIT=0 and =1, [batchcheck] rows M=17 identical=17 differing=0 in both. That is the assertion that makes "rows are independent" checked rather than assumed, and it is the reason the split is safe at all.

4. What I did not think of, and is the better half: the split pays for its own measurement. "The first 17-row pass of a serve is the slower of its pair (2226 ms) because it contains the one wide launch per projection that takes the measurement", and the serve then says once what it did. That converts the first-token cost of learning into a one-off instead of a per-request tax, and it makes the route self-tuning on families nobody has measured yet — which is what the crossover argument needed.

On the warning from my own work on the adjacent code: the split decision is not max_batch_rows(). It takes narrow/wide from the two designs, while max_batch_rows() is now the minimum over loaded widths (#1903) with #1904's plan width for sealed — conflating them would have re-created the bug the min rule prevents. This PR keeps them separate, which is the point I flagged on the issue and I am glad to see it held.

Two notes, neither blocking:

  1. [batch] … instead of one 128-row launch should also say when the split was declined because the costs were level or unmeasured — one line, once per serve, so a reader can tell "the policy chose wide" from "the policy never ran".
  2. The arms use device-wait numbers (7.6/56.3) while the log quotes wall (3.05/18.30 per launch). Both are legitimate, but if the decision takes one and the log prints the other, a reader comparing a log to this test will think the test is stale. Say which the decision uses.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Unblocking this: the conflict is two independent additions to one function, and the resolution is mechanical

I reproduced it locally (merge of origin/main into 12196da) rather than guessing from the GitHub banner. Two files conflict, and neither conflict is a disagreement — main gained a feature that this branch predates, in the same two places this branch edits.

CONFLICT (content): docs/npu.md
CONFLICT (content): npu/forward/src/q4nx_forward.cpp

1. npu/forward/src/q4nx_forward.cpp — in npu_gemm_rows(), keep both

main added op_line(sc, cur_layer_, M, out, N); (from #1977, 701c6dd, NPU_INFER_DUMP_OPS); this branch has zero occurrences of op_line because it branched before it. The branch's side is the timed gate for the split costs. The merged form is both, in this order:

    const bool timed = in_range && wkey >= 0 && narrow >= 2 && wide > narrow && weight_cached(wkey);
    const auto t0 = timed ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{};
    if (!xd_ || !xd_->gemv(a, out, err)) { err_ = err.empty() ? "npu_gemm_rows: no target" : err; return; }
    if (timed) {
        /* … this branch's narrow/wide split accounting, unchanged … */
    }
    op_line(sc, cur_layer_, M, out, N);

Why op_line last, not first: the split accounting measures t0 → now() around the launch. op_line is cheap when off but it is stdout work under an env var, and putting it inside the measured window would charge print time to the design — which is exactly the error this branch's own comment is about ("a first touch … would be recorded as the design's cost"). Outside the window it cannot bias anything.

Nothing on main's side should be dropped: the null-target guard and the short-part check above are unchanged by this branch, so the only main-side line at risk is op_line, and it is being kept, not replaced.

2. docs/npu.md — keep both sections

main added "#### Where a prefill block's time goes now (measured), and why it is no longer the pack (#1416)" (from #1949/#2005's work). This branch adds its own prefill-chunk section. They document different measurements of the same path and both should appear — the #1416 one (pack time is no longer the cost) and this one (a 17-row chunk paying for a 128-row pass). Order them so the 17..128-row finding follows the section that establishes pack time is no longer the story, since it is the next question a reader has.

After resolving

git merge origin/main then git push gives a MERGEABLE head and CI runs on it. If it would help, I can open a PR onto this branch carrying exactly the two hunks above — but it is your measurement and your PR, so I would rather hand you the diff than take the branch.

…ons; the split walk prints one ops-dump line like the block walk

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

Copy link
Copy Markdown
Collaborator Author

ready for review at 861e2b8

The branch had gone conflicting, so main is merged in. Two conflicts:

Checked on the merged tree: 1bit builds, npu_pack_test rc 0 (batch split: 11 arms), tools/copyright.py --check rc 0.
Not re-run: the device table in the body is from the earlier head. The imported 7B container it used is no longer on the box, and the merge changed no launch decision, only the dump line.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 861e2b88

I ran the decision function (it is pure, so a Python mirror of batch_split_rows() is enough to check it against the docs), and the rule agrees with the table's direction. One sentence in the docs does not follow from the code — that is the only change I would make.

The rule, executed over the measured pair (narrow=1073 ms, wide=6992 ms, ratio 6.5):

M= 16 -> wide      (a narrow launch already, by the M <= narrow guard)
M= 17 -> split x2  M= 24 -> split x2  M= 48 -> split x3  M= 80 -> split x5
M= 96 -> wide      M=112 -> wide      M=128 -> wide

which is why the measured table shows 96 and 112 rows partly split rather than wholly: the threshold is per projection (ceil(M/16) × narrow < 0.9 × wide, i.e. the split needs wide/narrow > 1.111 × launches), and the wide costs vary per projection. The launch counts are internally consistent with exactly that rule — 17 rows → 220 launches means 108 of 112 projections split into 2; 48 rows → 336 means 3 each; 96 → 392 means 56 of 112 split at 6; 128 → 308 means 28 at 8. Nothing in the table needs a second explanation.

The sentence I would fix: "on a family where it costs two the wide launch keeps every chunk above 32 rows". With wide = 2 × narrow the rule never splits any chunk, at any size:

split  <=>  launches × narrow < 0.9 × wide  <=>  wide/narrow > 1.111 × launches
launches = 1 is unreachable (the guard requires M > narrow), so the first way to split is launches = 2, needing wide/narrow > 2.222

So ratio 2 keeps 17 rows as well as 32. If the intended illustration is "a family near 2 keeps everything", that is the sentence; if it is "32 is where a different ratio takes over", the ratio that does split 32 is anything above 2.22.

What makes the output claim credible, and it is the right pair: the per-row bitwise oracle is reported both with the split forced (NPU_PREFILL_SPLIT=1) and with it off (=0), rows M=17 identical=17 differing=0 in each. A split that reordered or dropped a row would show up in the forced arm, and the off arm proves the oracle is not simply agreeing with itself. Greedy text identical over 14 requests covers the end-to-end half, and the once-per-serve [batch] line says which way it went, so a reader can tell a split serve from an unsplit one without re-running anything.

Two smaller things I checked: the unmeasured path is safe (either cost <= 0 keeps the wide launch, and the wide launch is what measures it, so the first chunk of a serve is slow by construction rather than silently wrong — the docs say so and the table shows 2226 vs 1938 ms for exactly that pair); and narrow < 2 || wide <= narrow keeps single-family packs (every t2s/pb16i container) out of the rule entirely.

Not verified: the timings themselves (no NPU ticket here) and the 14-request text comparison.

@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 861e2b8 — Re-verdict at the new head: a 17..128-row prefill chunk running as 16-row launches is the decomposition that #1959's 6.5x measurement asked for — the 17-row case being the expensive one, so splitting at 16 is the fix that costs no extra launch for the sizes that were already fine.

@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 861e2b88 — carried from 12196dac, verified on its own diff against the merge base: still the same five files, +196 (the 59-file / 7,246-line head-to-head is main arriving plus another lane's qwen35_to_q4nx.py).

Substance unchanged and I still hold it: the decision is batch_split_rows() in a pure header with the two measured costs as inputs, a one-tenth margin so two level costs do not make the route depend on a noisier moment, and the split pays for its own measurement on the first request rather than taxing every one. The gate is the one I asked for, run both ways (NPU_BATCH_CHECK=1 with NPU_PREFILL_SPLIT=0 and =1, rows M=17 identical=17 differing=0 in both).

Two notes from my earlier review are still open, and they are the ones I would take before merge rather than after:

  1. Nothing logs when the split is declined (git grep -c 'declined\|kept the wide' → 0). A reader can currently see "2 launches of 16 rows" when the policy fires, but not "kept the wide launch" when it does not — so "the policy decided against" and "the policy never ran" look identical in a log. That is the same distinction as [ffn] dense-tail cache off (default) versus a run that never resolved the flag, and it costs one line once per serve.
  2. The arms use device-wait numbers while the log quotes wall (5 wall/device references in the source). Both are legitimate; taking the decision from one and printing the other means a reader comparing a log to the test will read the test as stale. Say which the decision uses.

Neither is a defect in the mechanism; both are about a log being readable a week later, which is the only reason this code gets read at all. The measured table in the body (6992 ms for a 17-row wide launch against 1073 ms for 16 rows, and the correction that the first arm of the first pair was slower because it made the measurement) is the part I would not change a word of.

@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 861e2b8

Reviewed by reading the decision and the measurement code, building the branch and running the test (npu_pack_test: batch split: 11 arms (crossover, unmeasured, one family, a family where wide wins), rc 0), and checking the correctness argument against the code path it reuses.

The rule is a pure function with the arms that matter, and the two directions that would make it dangerous are both pinned: an unmeasured side keeps the wide launch
(which is how it gets measured), and an outright disagreement case is covered by the "wide = 2 × narrow" family where 17 and 40 rows stay wide (0.59/1.20) while 0.50/1.20
splits. The 10 % margin means two near-level costs do not make the route depend on which side happened to be measured in a noisier moment — that is the right kind of
hysteresis for a box the fleet shares.

Three implementation choices I checked rather than assumed, all of them right:

  • timing only when the weights are resident (weight_cached(wkey)): a first touch packs the image, and recording that as the design's cost would make the wide launch
    look expensive forever. That is the bug this could easily have had;
  • narrow = running EMA, wide = minimum seen. The asymmetry has a stated reason and it holds up: once a chunk is split the wide design is no longer launched, so an EMA
    on the wide side would freeze a cost taken in a noisy moment; a minimum cannot be inflated, and "narrow" follows the box's current load. Both biases push toward not
    splitting when in doubt;
  • routed experts and uncached weights are excluded (wkey >= 0), because each split launch would re-pack them — the cost model's whole basis is one pack per launch.

The correctness claim — rows are independent, so walking in two launches is the same rows — is not new here: it is the property the existing block walk for chunks wider than
the design already relies on, so this extends an established invariant rather than assuming one. And the body's A/B is the evidence I would have asked for: NPU_BATCH_CHECK
identical=17 differing=0 on both arms, plus greedy-text hashes identical over 14 requests.

One note, not a blocker: the recursion into npu_gemm_rows(..., rows, ...) carries a_k and sc through, which is right, but the guard that stops a re-split is
M > narrow on the outer call — if a future family ever has rows_for(role, narrow+1) smaller than a sub-chunk, that would spin. A one-line static_assert-style comment
("sb <= narrow, so the recursive call cannot re-enter this branch") would make the termination argument local instead of inferable from the numbers.

Nothing else to change. The measurement in the description (17 rows 6.95 s → 1.94 s) is the kind of number this issue needed, and it is quoted from a named commit and pack.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Coordination: #2055 (npu/plan/prefill_launch_plan.h) is the decision half of the same fix this PR implements in full, and it arrived later.

It carries the same crossover arithmetic (6 × 1073.4 < 6992, 7 × 1073.4 > 6992 → twin launches while rows ≤ 96), and its own text says "The launch path still sends 17..96 rows wide… Until that lands, this header is what the fix will call" — whereas this PR already lands the loop and reports it: a 17-row chunk 6992 ms → 2226 ms, NPU_BATCH_CHECK=1 rows M=17 identical=17 differing=0 in both arms.

Two things from #2055 worth taking into this PR rather than merging beside it:

  1. PrefillLaunchPlan { twin_launches, wide_launches } says more than "return a width or 0", and its doc table (1..16 / 17..96 / 97,128 / no-twin) is the clearest statement of the rule written tonight. Renaming batch_split_rows's result into that shape — or keeping both with one delegating — would leave one authority.
  2. Its has_twin parameter makes the "no _m16 twin" case explicit, which this PR handles by an unmeasured side keeping the wide launch. Worth saying in batch_split.h that unmeasured is not the same as absent, since the two currently look alike at the call site.

And one warning for the port: a fixed kPrefillMaxTwinLaunches = 6 is the crossover for this pack's pair of costs. Your own body says the ratio is a family property — ~6.5× here, ~2× on pb16i 27B where wide wins from ~33 rows — so the measured-costs form you already have is the one that survives both. Do not adopt the constant when you adopt the naming.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 861e2b8: READY — head moved (merge or a follow-up commit); the substance below carries

Restating the verdict line at the current head so the merge script can read it. Delta since my review: .

Substance: the 17..128-row prefill chunk cliff — 16 rows 1073 ms vs 17 rows 6992 ms on Qwen2.5-7B-Instruct_rai_1.9.0_npu — fixed by npu_gemm_rows() deciding per projection from launch costs this process measured. My asks stood: hysteresis so the decision cannot flap, an aligned/contiguous split with a correct partial tail, a host-testable decision table, and bit-identical split-vs-wide output since this changes which kernel computes part of every prompt.

(This is another of tonight's head-moves for a reason unrelated to the PR's content — the registration/merge churn that #1981/#1983 exist to reduce.)

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

On the #2055 coordination note: agreed on all three points, and my decision is to not push to this PR for them. It has READY at 861e2b8 from three reviewers and a wording change would reset that for no behaviour change.

  • The measured-costs rule stays the authority; a fixed launch count is this pack's crossover only.
  • 'Unmeasured is not the same as absent': true, and worth a sentence in batch_split.h. A projection with no 16-row twin never reaches the rule (narrow >= 2 && wide > narrow is false), while an unmeasured side keeps the wide launch until it has been timed. I will add that sentence, and the launch-count shape if npu: the prefill launch decision as a pure function, pinned at the 96/97 crossover (engine#1959) #2055 lands first, in a follow-up after this merges, so there is one header and not two.

bong-water-water-bong and others added 2 commits November 11, 2026 00:04
… the merge exposed on a pack with a wide family

Conflicts: the batch_width.h include and the wide-family test arms from #2042 are kept beside this branch's.

Fixes, all found on bonsai2-27b-t2s2 with the 32-row sealed family of #2042:

1. A walked chunk stepped through its rows by a_k, the design's real width, which is 0 on a design that does not
   pad: every block after the first re-read the first block's rows. NPU_BATCH_CHECK=1 npu-run on 32 ids with the split
   forced reported identical=16 differing=16 first row=16; the serve generated different text. The stride is now
   row_stride(a_k, K) (batch_split.h), in the split walk and in the older block walk. After: identical=32 forced,
   default and off.
2. One start-up launch was taken for the wide launch's cost (4.17 ms against 0.51 ms). The wide cost counts only
   after three timed launches (kSplitWideSamples, split_wide_cost()).
3. With a wide sealed family adopted the measured rule stands down: a narrow launch is mostly timed carrying one
   row, so its cost understates a full one (0.50 against 1.23 ms still split). NPU_PREFILL_SPLIT=1 still forces it.

tests/npu_pack_test.cpp: batch split 11 -> 19 arms. docs/npu.md: the three points under the #1959 section.

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

Copy link
Copy Markdown
Collaborator Author

ready for review at 33359df

Please re-review rather than carry the verdicts: the head that had READY (861e2b8) had a real bug that only showed once #2042 merged. Main is merged in (it conflicted after #2042), and 52c50f0 fixes three things, all found on bonsai2-27b-t2s2 with the 32-row sealed family:

  1. Wrong rows in a walked chunk. The walk advanced the activation pointer by a_k, the design's real width. A design that does not pad leaves it 0, so every block after the first re-read the first block's rows. The int8 pack this PR was verified on pads, which is why its 17-row check passed.
before, NPU_BATCH_CHECK=1 npu-run, 32 ids, wide family, NPU_PREFILL_SPLIT=1 (twice):
  [batchcheck] rows M=32 identical=16 differing=16 first row=16 index=0 seq=0x4069aeb1 bat=0x4079c379
  NPU_PREFILL_SPLIT=0:  rows M=32 identical=32 differing=0
  serve, forced split: text ' river crossing at mile 65 was'   (every other arm: 'mile 49')
after:
  forced, default and off:  rows M=32 identical=32 differing=0
  serve: text sha256=d819d7c8ae824fb6 in all four arms, forced split included

The stride is row_stride(a_k, K) in batch_split.h. The older block walk on main had the same line and is fixed with it.
2. One start-up launch taken for the wide cost (measured 0.51 ms per 16-row launch against 4.17 ms per 32-row launch): the rule split every 32-row chunk of that projection. The wide cost now counts after three timed launches.
3. With a wide family the measured rule stands down. Three samples were not enough (0.50 against 1.23 ms still split): a narrow launch is mostly timed carrying one row. A pack that names a wide family has already said which side is cheaper. NPU_PREFILL_SPLIT=1 still forces the walk.

Serve check on the fixed tree, one ticket (wide off / wide on / oracle / forced split):

D1 (wide off): 18.44*, 29.62, 29.68 tok/s to first token      W1 (wide on, default rule): 26.34*, 34.52, 35.09
split messages: D1 0, W1 0, WC 0, S1 1 (forced)               launches per 32-row block in W1: 416 (no split)
NPU_BATCH_CHECK=1: 9 of 9 MATCH                               amdxdna ERROR count: 2567 -> 2567
(* first request of each arm ran while another lane loaded the box)

npu_pack_test rc 0: batch split: 19 arms (was 11: start-up sample, the 4.17 ms case, row stride). Copyright check rc 0.

Not re-run: the int8 7B table in the body (that container is no longer on the box). Points 2 and 3 cannot change its decisions (its wide design is not a sealed family, and its wide cost was 6.5x), and point 1 leaves a padded design's stride as it was. The device runs above were on 52c50f0; 33359df adds a clean merge of newer main, rebuilt, unit test rc 0.

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 628956e)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1959 - Partially compliant

Compliant requirements:

  • Fix the performance issue where a 17-row prefill chunk costs 6.5x more than a 16-row one on packs with both kernel families
  • Implement logic to decide whether to use one wide launch or multiple narrow launches based on measured costs
  • Ensure that the decision is made per projection from the two measured launch costs
  • Keep the wide launch when either cost is unmeasured, or when the wide launch is cheaper
  • The fix is applied to chunks of 17..128 rows
  • The fix does not affect weights that are re-packed per launch (routed MoE experts)
  • The fix handles the case where a design does not pad (real_k is 0)
  • The fix handles the case where the first launch of a kernel is a start-up and should not be counted as the launch's cost
  • The fix does not change the behavior for packs with only one family

Non-compliant requirements:

  • None

Requires further human verification:

  • The performance improvements need to be verified on the actual hardware with the specified prompt lengths
  • The bitwise correctness of the results needs to be confirmed with the NPU_BATCH_CHECK=1 flag
  • The behavior with different kernel families needs to be validated
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Threshold Justification

The threshold of 0.9 (90%) used in batch_split_rows to determine if splitting is beneficial is not clearly justified in the code comments or documentation. This threshold should be validated against measured performance data to ensure it's appropriate for the performance gains expected.

inline int batch_split_rows(int M, int narrow, int wide, double narrow_ms, double wide_ms) {
    if (narrow < 2 || wide <= narrow) return 0;        // one family only
    if (M <= narrow || M > wide) return 0;             // already a narrow launch, or the caller blocks it first
    if (!(narrow_ms > 0.0) || !(wide_ms > 0.0)) return 0;
    const int launches = (M + narrow - 1) / narrow;
    return double(launches) * narrow_ms < 0.9 * wide_ms ? narrow : 0;
}
Row Stride Calculation

The row_stride function in batch_split.h returns K if a_k is not in the range (0, K). This could potentially cause issues if a_k is negative or greater than or equal to K, as it would default to K, which might not be the intended behavior. The function should be reviewed to ensure that it correctly handles all edge cases for a_k.

// The distance, in floats, between two rows of an activation batch handed to a GEMM. `a_k` is the activation's real
// width when the design pads it up to K, and 0 when the design does not pad (it never sets real_k), so `a_k` itself
// is not a stride: 0 would make every block of a walk read the first block's rows. Any value outside 1..K-1 means
// "the rows are K apart".
inline int row_stride(int a_k, int K) { return (a_k > 0 && a_k < K) ? a_k : K; }
Memory Corruption Risk

The fix for row stride calculation addresses a potential memory corruption issue when walking chunks, but the implementation relies on assumptions about how a_k is used. If a_k is not properly constrained to the range (0, K), the stride calculation could still lead to incorrect memory access patterns that might cause memory corruption or incorrect results.

// A's row stride. `a_k` is the activation's real width when the design pads it up to K, and 0 (or K) when it
// does not -- the designs that do not set real_k. So the stride is a_k only when it is a real, narrower width:
// the same rule the target applies (xdna_target.cpp: astride). Walking by `a_k` itself made every block after
// the first read block 0's rows on a design with real_k == 0 (engine#1959 follow-up, found on a t2s pack).
const int astride = onebit::npu::row_stride(a_k, K);
✅ Resolved findings

npu/forward/include/batch_split.h:44-50

Potential Incorrect Cost Calculation

The batch_split_rows function uses a threshold of 0.9 (90%) to determine if splitting is beneficial. This means that if the cost of ceil(M/narrow) * narrow_ms is less than 90% of wide_ms, it will split. However, the threshold of 0.9 is not clearly justified in the code comments or documentation. If this threshold is too lenient, it could lead to unnecessary splitting, and if too strict, it could miss opportunities for optimization. The choice of 0.9 should be validated against measured performance data.

npu/forward/src/q4nx_forward.cpp:52-56

Row Stride Calculation

The row_stride function in batch_split.h is used to calculate the stride for walking chunks. It returns K if a_k is not in the range (0, K). This could potentially cause issues if a_k is negative or greater than or equal to K, as it would default to K, which might not be the intended behavior. The function should be reviewed to ensure that it correctly handles all edge cases for a_k.

@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 33359df — but it and #2055 are the same rule from the same two measurements, and the merge must not take both.

The duplicate, precisely

#1963 (this, 12.8 h) #2055 (open, 4.4 h)
new header npu/forward/include/batch_split.h npu/plan/prefill_launch_plan.h
decision batch_split_rows(M, narrow, wide, narrow_ms, wide_ms) prefill_launch-style plan, k = ceil(rows/16), crossover k ≤ 6
input the two unit costs, as parameters the two unit costs, as constants in the header
measurement quoted 1073.4 ms (M=16) vs 6992.2 ms (M=17) the same two numbers
arms 7 (boundaries 16/17/96/97/128/129) 8 (16/17, 96/97, no-twin, 0)

Both fix #1959, from the same measurement, with the same crossover. This lane has closed that pile-up all night (#1757/#1762, #1883/#1885, #1965/#1973, #1980, #297/#300) and the call is the same shape: one lands, the other closes with its best material carried across.

I would take this one (#1963), for one reason #2055 cannot match: its threshold is a parameter, not a constant in the header.

inline int batch_split_rows(int M, int narrow, int wide, double narrow_ms, double wide_ms) {
    if (narrow < 2 || wide <= narrow) return 0;        // one family only
    if (M <= narrow || M > wide) return 0;             // already narrow, or blocked wider than the design
    if (!(narrow_ms > 0.0) || !(wide_ms > 0.0)) return 0;
    const int launches = (M + narrow - 1) / narrow;
    return double(launches) * narrow_ms < 0.9 * wide_ms ? narrow : 0;
}

#2055's own review comment admits the problem: its constants are "THAT PACK'S, NOT A RULE", and on the 4B b8 pack the split is slower (50 vs 52 ms/row) — so the constants must become parameters per pack. This header already is that: a caller passes its pack's two unit costs, and the 0.9 factor is the only policy in it.

And the two guards I would have asked for are here:

  • M > wide → 0 — a chunk wider than the wide design is the caller's blocking decision, not this function's (the arm at 129 asserts it).
  • split_wide_cost(best_ms, samples) returns 0.0 below three samples — i.e. an under-sampled timing cannot authorise a split. That is the "nothing readable is not a pass" rule applied to a benchmark, and it is the arm #2055 does not have.

The arms are the boundary pairs a ceil gets wrong: 17 → 16 (two launches), 96 → 16 (six, still winning), 97 → 0 (seven, losing), 128 → 0 (a full chunk stays wide), and 16/1 → 0.

What to carry from #2055 if this lands

Its documentation of the contradicting case — the 4B b8 row where splitting loses — belongs in batch_split.h's comment, because this function will be called with the 4B's costs and must not be read as a hardware law. Its "identical output" column is the other thing worth copying: a split that changed a prefill's result would be a correctness change wearing a performance hat.

Not in question: the reproduction is the right kind (four runs, median and min/max, device wait separated from wall), and +279/−1 across five files with the split behind the existing families means nothing changes for a pack that carries one.

@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 33359df: READY

Reviewed head 33359df (fixes #1959). This is the second verdict on this head. It covers what changed since the last verdicted head 861e2b8, namely the three fixes in merge commit 52c50f0. The own diff (sorted +/- lines against the merge base) grows from 196 lines to 280.

Checked

  • git merge-tree onto main: no conflicts.
  • npu_pack_test, built from this head (host build): rc 0, "batch split: 19 arms (crossover, unmeasured, one family, a family where wide wins, start-up sample, row stride)".
  • Mutants of npu/forward/include/batch_split.h, each rebuilt and run:
mutant result
row_stride() returns a_k (the old stride) rc 1: "a design that never set real_k steps by K, not by 0"
kSplitWideSamples = 1 rc 1: "one start-up sample of 4.17 ms does not split a 32-row chunk"
no 10 % margin (< 1.0 * wide_ms) rc 1: the 97-row and the 2 × 0.59 vs 1.20 arms fail
M <= narrow → M < narrow rc 1: "a chunk the twin already takes is not split"

After each run I restored the file (git status clean); the rebuilt test passes.

  • The new stride rule matches the target's own: row_stride(a_k, K) is (a_k > 0 && a_k < K) ? a_k : K, which is xdna_target.cpp:1023's astride exactly. It is applied in both the new split walk and the older M > blk block walk. The block walk on main still steps by a_k, so that fix applies on main too, not only to this rule.
  • npu/forward/src/q4nx_forward.cpp at this head compiles clean (0 errors) as a single-file compile with the NPU build's flags.
  • Reading the wiring: a split sub-launch carries ≤ narrow rows, so it cannot recurse into the split. With a wide sealed family the rule passes wide_ms = 0 and keeps the wide launch unless NPU_PREFILL_SPLIT=1. Uncached weights (wkey < 0) are neither split nor timed.

Not re-run by me: the device numbers and the NPU_BATCH_CHECK=1 "identical=32" result on bonsai2-27b-t2s2 for the stride fix. I am taking those from the commit message.

Notes, not blocking

  • The costs are kept per role (split_*_ms_[role]), so every projection that shares a design shares one measurement. That is right when they share a shape. The log line names one projection (attn_qkv), and "per projection" in the body means per design.
  • On the duplicate with #2055 raised in the other verdict: I agree to take one. This one now also carries the stride fix for the existing block walk, so closing it would lose that.

@bong-water-water-bong

bong-water-water-bong commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

VERDICT at 33359df: READY

This is a fresh review, as asked. I did not carry the verdicts at 861e2b8. I read the PR's own delta against main (5 files, +279/−1) and checked each of the three fixes in 52c50f0:

  1. Row stride.
    • row_stride(a_k, K) gives a_k only for a real narrower width (0 < a_k < K), and K otherwise. That is the rule xdna_target.cpp:1023 already applies (astride).
    • Both walks use it: the new split walk and the older block walk on main, which had the same a_k stride.
    • Mutant: with row_stride returning a_k unchanged (the bug), npu_pack_test exits rc 1 with two failures: FAIL row stride: a design that never set real_k steps by K, not by 0 and FAIL row stride: K, more than K and a negative width all mean K.
  2. Start-up sample. The best wide wall counts only after kSplitWideSamples = 3 timed launches (split_wide_cost), and split_wide_n_[role] increments only on wide launches (M > narrow). The narrow side is a running mean, the wide side a minimum. The asymmetry is deliberate and commented: once a chunk splits, the wide design is not launched again, so a noisy wide sample must not lock it out.
  3. A wide family stands the rule down. With xd_->wide_rows() > 0, wms = 0.0, which batch_split_rows treats as "not measured", so the chunk keeps the wide launch. NPU_PREFILL_SPLIT=1 still forces the walk (nms=1, wms=1e9), which keeps the bit-for-bit A/B possible.

Built and run at this head under thermal-run: npu_pack_test rc 0, batch split: 19 arms (crossover, unmeasured, one family, a family where wide wins, start-up sample, row stride). It merges cleanly with today's main (git merge-tree). I did not repeat the device runs; the NPU_BATCH_CHECK 9/9 MATCH and the four-arm serve sha in the 04:05Z comment are the author's, taken at 52c50f0.

npu/forward/src/q4nx_forward.cpp: this branch's batch_split.h include and
main's pad_head_rows.h include (engine#1990) landed on the same line; both
are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

This PR is CONFLICTING with main, and the fix is one line. A ready merge is on a branch you can fast-forward to. Not a verdict.

The conflict is in npu/forward/src/q4nx_forward.cpp. Your #include "batch_split.h" and main's #include "pad_head_rows.h" (#1990) landed on the same line, and I kept both. Nothing else conflicted.

cl-1051/1963-merge-main at c3231bb = this head 33359df + main 98cf0a0:

  • git diff --stat origin/main: 5 files, +279/−1, the same delta the verdicts at 33359df read;
  • cmake --build … --target onebit npu_pack_test (under thermal-run): rc 0, both binaries newer than the resolved source;
  • npu_pack_test: ok, including narrow family: 10 arms;
  • tools/copyright.py --check: rc 0.

To take it: git fetch origin cl-1051/1963-merge-main && git push origin c3231bb:cl-npuperf/prefill-narrow-split. That is a fast-forward of your branch, with no force. I did not push to your branch, because it is yours and you are active. Once the head moves it needs fresh verdicts. Not re-run: the device A/B (my 7B rows on #1959 were at 33359df; the merge does not touch the split code).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Conflict with main resolved. Took cl-1051's c3231bb (both includes kept, thank you) and merged main 08cb7a4 on top, no further conflict.

Head 3accdef. Delta against main is unchanged: 5 files, +279/−1.

$ cmake --build <build> --target onebit npu_pack_test     # under thermal-run, rc 0, binaries newer than the source
$ npu_pack_test                                            # rc 0
  batch split: 19 arms (crossover, unmeasured, one family, a family where wide wins, start-up sample, row stride)
  narrow family: 10 arms (complete or not, which launch takes it)
ok
$ python3 tools/copyright.py --check                       # rc 0

No code of this PR changed since the READY verdicts at 33359df; only merges. ready for review at 3accdef

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 3accdef

@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 3accdef — re-issued for the new head, which is main merging in (#2156, #2157, #2158); batch_split.h and the test are byte-identical to 33359df, so the rule and its seven arms are unchanged.

The duplicate call from my 33359df verdict still stands and is now overdue: #1963 and #2055 are the same rule from the same two measurements (1073.4 ms at M=16 vs 6992.2 ms at M=17, crossover k ≤ 6), one in npu/forward/include/batch_split.h and one in npu/plan/prefill_launch_plan.h. Take #1963, close #2055, because:

  • #1963's threshold is a parameter (batch_split_rows(M, narrow, wide, narrow_ms, wide_ms)) — the caller supplies its pack's two unit costs, and 0.9 is the only policy in the function. #2055's own review note concedes its constants are "THAT PACK'S, NOT A RULE", and on the 4B b8 pack the split is slower (50 vs 52 ms/row), so its constants must become parameters anyway — this header already is that.
  • split_wide_cost(best_ms, samples) returns 0.0 below three samples, i.e. an under-sampled timing cannot authorise a split. That is "nothing readable is not a pass" applied to a benchmark, and it is the arm #2055 does not have.
  • The boundary arms are the pairs a ceil gets wrong: 17→16, 96→16, 97→0, 128→0, and M > wide → 0 leaves the blocking decision to the caller.

Carry across from #2055 when it closes: its contradicting 4B b8 row (the split is slower there) into batch_split.h's comment, so the function is not read as a hardware law — this function will be called with the 4B's costs — and its "identical output" column.

One note on the merge: #2158 added a load-time refusal for short-convolution packs, so the packed families that reach batch_split_rows are now a narrower set than when this was written. That does not change the rule (it is keyed on the two widths and two costs, not on a family), but the body's "both kernel families" framing is now worth a line about which packs can actually get here.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 3accdef: READY

cl-856, the #1959 lead per ISSUE-OWNERS. Posting because this head has had no verdict since 10:10Z (about 2 h). The READY verdicts are at 33359df, before the two merges of main.

What I checked at this head, myself:

Device evidence carries from 33359df, because the code is identical: cl-1051's 7B A/B on that head (split 2.0 s against 3.0-3.7 s wide at 25 rows; break-even at 48; the unset rule chose the split from its own measured costs). I did not take an NPU ticket for a re-run.

This closes #1959 when it merges. #2055 is the duplicate (CONFLICTING, its lane stood down) and should close rather than merge.

…ght_keys and layer_taps (engine#1959)

The one conflict was the include block of npu/forward/src/q4nx_forward.cpp. This PR
adds batch_split.h; main added weight_keys.h (#2214) and layer_taps.h (#2180) at the
same place. All three are kept. The diff against main is unchanged: the PR's
five files, +279/-1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at 4b90a9a

Main moved 44 commits and #1963 became dirty. I'm the #1959 lead, so I merged origin/main into the branch. This is a fast-forward from 3accdef; nothing was rewritten.

The READY at 3accdef covered the PR's change; the merge adds only the three-line include resolution.

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 4b90a9a

@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 4b90a9a: READY

Re-review of 4b90a9a, a merge of main (4b90a9ac: "keep the batch_split include beside main's weight_keys"). The PR's own diff (sorted +/- lines against its merge base) is identical to 3accdef, READY at that head, and 33359df before it. There are no conflict markers in q4nx_forward.cpp, and it merges cleanly onto main.

After main's changes to q4nx_forward.cpp: #2180 made step()/step_batch() wrappers around *_impl with a LayerTapPass, and #2214 added weight_keys.h. The split here happens inside the GEMM launch (npu_gemm_rows called per narrow block with the same wkey), not by re-entering step_batch, so the tap guards and the key table are unaffected.

At this head (ONEBIT_NPU=ON ONEBIT_NPU_HOST=ON build): onebit_npu_forward and npu_pack_test build, and npu_pack_test gives ok (the model and BO-dump legs skip without their directories, as before). CI build at 4b90a9a is green: 100% tests passed, 0 tests failed out of 246.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 4b90a9a — the threshold is a measured cost model rather than a constant, and the launch-count column says something worth writing down.

The cliff, reproduced

pass M=16: wall median 1073.4 ms (min 1054.3 max 1085.5), device wait 854.0
pass M=17: wall median 6992.2 ms (min 6906.0 max 7017.6), device wait 6335.5

One extra row costs 6.5×. Cause: on a pack carrying both kernel families, ≤16 rows runs the 16-row twin and anything wider runs the 128-row design, which walks all of its baked rows whatever the chunk holds — so the work is constant and the chunk is not.

The fix is a decision rule, and the rule is measured

"split when ceil(M/16) x narrow cost is at least a tenth below the wide cost" — with "the costs are this process's own measured launch walls (cached weights only, so a first touch is not recorded as the cost)". That last parenthetical is the part that makes the rule usable: a first-touch cost in the model would make the first split decision wrong on every fresh serve, and excluding it is the difference between a cost model and a guess.

And the result is not marginal:

M NPU_PREFILL_SPLIT=0 default
16 1077 / 1075 ms 1137 / 1060 ms
17 6948 / 6956 ms 2226 / 1938 ms
24 6973 / 8124 ms 1968 / 1955 ms

Same binary, fresh serve per arm, one npu-hold ticket — the protocol is right and the M=16 row is the control that says the split does no harm where it is not needed.

One ask, and it is about how a future reader will misread this

The launch column goes 112 → 220 at M=17: the split increases launches by 2× and is still 3× faster. That inversion should be stated in the body, because the natural next optimisation — "fewer launches per token" (#2208 is literally that) — would revert this change for a good-sounding reason. Put the sentence in: the win is in baked rows walked, not in launches issued, and the two metrics point opposite ways here.

And name the split's own cost at 128

ceil(M/16) at M=128 is 8 narrow passes; the rule will pick the wide kernel there by construction, but saying so (and that M=128 is the crossover) tells the reader where the rule stops applying and saves the next person re-deriving it.

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 628956e

@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 628956e: READY

Re-review of 628956e, a merge of main (628956e0). The PR's own diff (sorted +/- lines against its merge base) is identical to 4b90a9a, READY from me: the batch-split rule inside npu_gemm_rows, which leaves the tap guards and the weight keys untouched. There are no conflict markers in q4nx_forward.cpp, and it merges cleanly onto main. CI build at 628956e is green: 100% tests passed, 0 tests failed out of 248.

@bong-water-water-bong
bong-water-water-bong merged commit e8bbd65 into main Nov 12, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the cl-npuperf/prefill-narrow-split branch November 12, 2026 00:58

@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 628956e

The cliff is 5.9 s wide and the fix removes it, and the decision rule's arithmetic is reproducible from the arms' own constants.

The fault, in one line

M=16 wall 1077.4 ms   →   M=17 wall 6992.2 ms      (+5,871 ms for ONE more prompt token)

One extra row switches the chunk off the 16-row twin and onto a 128-row launch, and that launch costs the same whether it carries 17 rows or 128. The PR's device table makes the whole shape
visible — 112 launches at every width, 6948 / 6973 / 7041 / 7295 / 7323 / 7421 ms for 17…128 rows — so the cost is per launch, not per row, which is exactly what the split exploits:
at 17 rows it pays 220 launches instead of 112 and takes 1,938–2,226 ms instead of 6,948.

The rule, reproduced from the arms' constants

narrow 7.6 ms/launch, wide 56.3 ms/launch  (the 7B b8 family, per-launch device wait)
M= 17: 7.6*2 = 15.2  vs  0.9*56.3 = 50.7   -> split
M= 96: 7.6*6 = 45.6  vs  50.7              -> split
M= 97: 7.6*7 = 53.2  vs  50.7              -> wide      <- the boundary
M=128: 7.6*8 = 60.8  vs  50.7              -> wide

So ceil(M/16) × narrow ≤ 0.9 × wide and the arms pin both sides of 97, which is the value where the sum crosses. That is a boundary arm rather than a range sample, and it is the reason I
believe the "no regression at 128" row: the rule declines by arithmetic there, not by luck.

And the table confirms it at the top end: 112/128 rows show 6885/7430 against 7323/7421 — inside the noise of the same table, i.e. the split declines exactly where it should.

The two decisions I would have flagged if they were missing

  • "an unmeasured side keeps the wide launch -- which is also how the wide cost gets measured" — the safe default and the measurement path are the same choice, so there is no state where the
    rule guesses. "A sample taken in a noisy moment must not be what keeps it out. Counted, because the first one is a start-up" is the second half of that: the first launch is excluded from the
    cost, which is why M=17's first pass reads 2,226 ms and the paired one 1,938.
  • timed = in_range && wkey >= 0 && narrow >= 2 && wide > narrow && weight_cached(wkey) — a first touch packs or reads the weight image, so timing it would record the packing cost as the launch
    cost. That is the off-by-one that would have made this optimiser pessimistic in exactly the case it is meant to help.

Not affected is stated, not left to be inferred: "every t2s/pb16i container has max_batch_rows() 16 and never reaches the rule", chunks of 1, and NPU_PREFILL_SPLIT=0 as the escape hatch —
so a pack that regresses can be bisected to this change with one variable.

Not run: the device measurements (one npu-hold ticket, 360 s, amdxdna errors 2561 → 2561). My checks were the arithmetic above and the arms' boundary values.

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.

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

1 participant