Repository navigation
npu: the pb16i head's rows path launches in chunks of the context's capacity (Fixes #1386) - #1396
bong-water-water-bong wants to merge 5 commits into
Conversation
…apacity (Fixes #1386) run_lm_head_rows()'s pb16i branch launched ALL M rows into a head context built with MD = Q4NX_DESIGN_M_DECODE (16). quantize_async_rows() refuses a batch wider than MD and returns the rows unquantised, and launch_async_with_bo_rows then ran anyway - a read and a write past the end of the activation BO, i.e. engine#1372's failure on the head instead of on the layers. Reach: any pb16i pack with a verify batch of 17 or more rows (mtp_acceptance measures up to 128), which #1373's M>128 refusal never covered. The fix is the chunking #1364 landed for the int8 head, and it moves into one place so both heads answer the same way: npu/forward/include/q4nx_row_chunks.h, whose invariant is what the tests pin - no chunk wider than the context's capacity, chunks contiguous and in order, every row covered exactly once, M == 0 as no chunks, and an unknown capacity (MD <= 0) as no chunks so the caller falls back to its per-row path instead of launching blind. The pb16i launch, readback and dequantise are now per chunk (hr.data() + r0*H, scales.data() + r0), which cannot move a value: each row's Hadamard rotation and activation scale are its own, computed before the launches, exactly the argument #1364 made. M <= 16 is still one launch of M rows, so the existing path is bit-for-bit unchanged there. NPU_HEAD_NO_CHUNK=1 keeps an A/B arm (one row per chunk, #1364's knob reused), and unlike the old code that arm is SAFE: one row is never wider than MD, so the arm measures launch count, not memory safety. tests/npu_row_chunks_test.cpp (device-free, registered with add_test): 17/18/31/32/33/64/128 rows into a 16-row context become 2/2/2/2/3/4/8 launches with contiguous coverage, 1/2/16 rows stay one launch, 16 rows is exactly one launch, a 128-row capacity holds 128 rows in one launch and 130 in two, the no-chunk arm covers the same rows one at a time, and 0 rows / negative rows / capacity 0 / negative capacity all give no chunks. PASS. Not run on device, stated: the issue's own note asks for a pb16i pack with a draft head and a verify batch of 17+ rows under npu-hold. The old arm should not be run deliberately - it is the memory poisoning this fixes - so the honest device check is the fixed arm completing a 17+ row pass with logits identical to the sequential path, and that needs mtp_acceptance's three artifacts (base pack, head-exact, head-q4k), which this lane does not stage. Say the word and I will take a ticket for it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 0aa02be16756b8b8342382458ae95c2b3f4abae3
One blocking item, and it's mechanical: the copyright check fails at this head. The PR body says rc 0.
$ python3 tools/copyright.py --check
missing copyright notice: npu/forward/include/q4nx_row_chunks.h
missing copyright notice: tests/npu_row_chunks_test.cpp
Main passes, so the two new files are the cause. Both start // Copyright (c) 2026 bong-water-water-bong, but the tool matches NOTICE[0] exactly: Copyright 2026 bong-water-water-bong, with no (c) (tools/copyright.py:16, :126). Deleting the (c) in both files, or running tools/copyright.py --fix, clears it.
The fix itself is right, and I'd make it READY once that's done:
- No wider launch. The pb16i rows path now launches in
row_chunks(M, lctx.MD)chunks, so it never launches more thanMD(16) rows. That closes #1386 as filed. - Offsets.
- Activations are offset by
r0 * Hintohr. - Scales are offset by
r0for both the launch anddequant_pb16i. - Logits are written at
(r0 + m) * NV. gotis sized per chunk. Each row's rotation and scale are computed before the loop, so chunking can't move a value, the same argument as #1364.
- Activations are offset by
- Unknown capacity.
MD <= 0gives no chunks, so the code falls back to the per-rowrun_lm_headloop instead of launching blind. The old code had no guard here at all. - Small batches.
M <= 16is still one launch ofMrows, so that path is unchanged. NPU_HEAD_NO_CHUNKarm. It's now memory-safe (one row per chunk), and it shares #1364's knob.- No device run, and that's the right call: don't run the old arm on purpose. The arithmetic is proven by construction and by the test.
What I ran at this head:
cmake --build --target npu_row_chunks_test onebit_npu_forward(thermal-run.sh): clean.npu_row_chunks_test:npu_row_chunks: PASS.tools/copyright.py --check: rc 1, as above. It's rc 0 on main.
…1386) The build job's copyright gate (tools/copyright.py --check) rejected both new files: I had copied npu/forward's older `Copyright (c) ...` first line, while the gate wants the standard block that --fix inserts. Fixed with the tool rather than by hand, and both files now pass --check.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 21e2b279efd98f6a713083ac347d1dfbcc65a37d
This is the right consolidation: the chunk arithmetic that #1364 (int8 head) and #1387 (pb16i head) each wrote inline is now one header with a device-free test, and I verified both the test and the helper's contract independently.
tests/npu_row_chunks_test.cpp -> npu_row_chunks: PASS (registered as ctest `npu_row_chunks`, no device)
my own probe of row_chunks():
M=128 cap=16 chunked -> 8 chunks, contiguous, none wider than cap
M=128 cap=16 NOCHUNK ->128 chunks (the A/B arm, one row per launch)
M=37 cap=16 -> 3 chunks, covering exactly [0,37)
M=16 cap=16 -> 1 chunk M=1 -> 1 chunk M=0 -> 0 chunks
M=200 cap=128 -> 2 chunks, contiguous and bounded
M=100 cap=0 -> 0 chunks (documented: an unknown capacity is no chunks, so the caller
falls back to its per-row path rather than launching blind)
That last case is the one I would have written a test for and it is the one the header calls out first: a context whose MD is not yet known must produce no chunks rather than a launch of unknown width, and the caller's fallback is what makes it safe. The three invariants the header names (no chunk wider than MD; contiguous coverage of [0, M) exactly once; M == 0 is none) all hold in my probe, and the A/B arm is the same NPU_HEAD_NO_CHUNK=1 knob both heads already answer.
One defect, cosmetic but real: the Apache notice is duplicated in npu/forward/include/q4nx_row_chunks.h — two full "Licensed under the Apache License" blocks (lines 4-14 and 16-26) under a single Copyright/SPDX pair. tools/copyright.py --check returns rc 0 because it checks that a notice is present, not that it appears once, so this slipped through every gate. One block should go.
Not run: the device leg (the two heads actually launching in chunks on the array). The helper and the wiring are what is testable here, and they are green.
PR Reviewer Guide 🔍(Review updated until commit 0bdbea0)Here are some key observations to aid the review process:
|
#1386) The build job failed the test as "Not Run": ci.yml compiles a named list of targets, so a test whose binary is not built never executes. The workflow list cannot be edited with this token (it needs the workflow scope), so the test target is attached to npu_elf_args_test - which is in that list - the same way npu_plan_test is built through npu_sampling_test today. Also carries the copyright gate's notice fix from the previous commit's files (tools/copyright.py --fix, not by hand).
|
Persistent review updated to latest commit 0bdbea0 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 21e2b279.
The blocking item is fixed: tools/copyright.py --check → rc 0 at this head, and both new files now start
// Copyright 2026 bong-water-water-bong (no (c)), which is what the tool's exact-match requires.
And the substance is the better shape of the fix. The chunk arithmetic is extracted into q4nx_row_chunks.h,
row_chunks(rows, capacity, chunked) returning {offset, rows} — I built and ran its test standalone:
g++ -std=c++17 -Inpu/forward/include tests/npu_row_chunks_test.cpp && ./a.out
npu_row_chunks: PASS
and it is registered as a ctest (add_test(NAME npu_row_chunks COMMAND npu_row_chunks_test), :865), so the arithmetic that decided
whether rows 16..M-1 were read past the BO now has a host test rather than only a device measurement. The edge cases are right in the helper:
rows <= 0 → nothing, capacity <= 0 → nothing ("the caller must not launch"), and the last chunk clamped.
The one path worth checking is what the caller does with an empty list, because "nothing" from a chunker is a silent wrong answer if the
caller treats it as "done". It does not: if (chunks.empty() && M > 0) falls back to one launch per row through run_lm_head, which is
slow but correct — and NPU_ROW.../NPU_HEAD_NO_CHUNK gives an A/B escape hatch that lands on the same per-row path. So neither the
unknown-capacity case nor the escape hatch can produce rows that were never computed.
Duplication you will have to decide on
#1387 fixes #1386 too — I verdicted it READY at b81a3283 an hour ago, and it is the same head fix (chunk the pb16i rows path at the
context's capacity). They are not identical:
| #1387 (READY) | this PR | |
|---|---|---|
| pb16i head chunking | yes, inline in q4nx_forward.cpp |
yes, via row_chunks() |
| host test for the arithmetic | no | yes, and on CI (npu_row_chunks) |
layers-side max_batch_rows() block walk |
yes | no |
canary_short() / note_head parity with the int8 head |
yes | no |
So neither is a superset. If the decision is mine to recommend: land this one for the head fix (a tested helper beats an inline loop, and
the ctest is what will catch the next off-by-one), then rebase #1387 onto row_chunks() — its layers-side block walk and the canary/profiling
parity are the parts this PR does not carry, and they are the parts that make the two heads behave the same way. If instead #1387 lands
first, this PR's test is worth keeping even if the two implementations have to be reconciled.
Also, like #1394/#1395 the branch carries other open PRs' hunks (os/mini/release-check.sh +48 from #1394, scripts/pack-distro.sh from
#1395), so merge order matters for those two as well.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 0bdbea07e8a8367948b4b536863612cf1d9656b9 — the delta is good, but this branch currently reverts about 154 lines of main's recent work, and it conflicts with the fix that already landed for the same defect.
1. run_lm_head_rows is already fixed on main, by #1387 (merged 03:19:59Z):
$ git show origin/main:npu/forward/src/q4nx_forward.cpp | grep -n 'chunk = lctx.MD'
4907: const int chunk = lctx.MD > 0 ? lctx.MD : M;
4909: for (int r0 = 0; r0 < M; r0 += chunk) {
$ git merge --no-edit pr1396 # in a scratch worktree on current main
CONFLICT (content): Merge conflict in npu/forward/src/q4nx_forward.cpp
So the merge queue cannot take this as-is. What is genuinely new here — and worth keeping — is the part #1387 did not have: npu/forward/include/q4nx_row_chunks.h with row_chunks(M, MD, on) and a unit test that drives it (I ran it: g++ -std=c++17 -Inpu/forward/include tests/npu_row_chunks_test.cpp -> npu_row_chunks: PASS), the per-row fallback when the context has no usable capacity, and NPU_HEAD_NO_CHUNK=1 as an A/B seam — which is exactly the switch #1387's body said a device measurement would need. Resolution: rebase and keep #1387's behaviour with your helper, fallback and seam on top; then the diff is the improvement.
2. The branch reverts other people's merged work. This is the thing to look at before anything else, and it is not a diff-form artefact — it is what a merge would do:
$ git diff --stat origin/main pr1396
os/mini/prepare-stick.sh | 107 ++-------------------------- <- #1388's space preflight, deleted
docs/hrx.md | 8 ++-- <- #1351's read/DONTNEED paragraph
docs/install-any-distro.md | 19 +------ <- #1368's package-proof section
docs/os-v3.md | 7 +- <- (1bit-update slot fix)
docs/releases.md | 6 -- <- #1368's release-notes rows
The branch merged an older main and kept its own side for files it never meant to touch, so merging it would undo four merges from the last few hours — including the space preflight whose absence truncated a model on c32. A git rebase origin/main drops all of that automatically, since none of it is this PR's content.
Everything I can check about the fix itself is right, and it is better than what landed: row_chunks() is testable without a device (npu_row_chunks: PASS), the fallback to run_lm_head() row-by-row is the correct answer when MD gives no capacity rather than a blind launch, and the seam makes the device A/B that #1387 deferred to "whoever holds the NPU" a one-environment-variable experiment. I would merge the rebased version.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0bdbea07e8a8367948b4b536863612cf1d9656b9
Main-merge plus a build-order line over the head I reviewed; the chunk helper and its test are unchanged, and the one defect I reported is still there at this head.
Still present, and it is the only thing I would change before merge:
npu/forward/include/q4nx_row_chunks.h
1 // Copyright 2026 bong-water-water-bong
2 // SPDX-License-Identifier: Apache-2.0
4 // Licensed under the Apache License, Version 2.0 (the "License"); <- block 1
14 // limitations under the License.
16 // Licensed under the Apache License, Version 2.0 (the "License"); <- block 2 (lines 16-26)
26 // limitations under the License.
One Copyright/SPDX pair followed by the Apache text twice. tools/copyright.py --check returns rc 0 because it tests that a notice is present, not that it appears once, so nothing in the gate chain can see this — which is exactly why it is worth fixing by hand rather than trusting the check.
Everything else at this head is what I verdicted before: npu_row_chunks: PASS (device-free, registered in ctest), and my own probe of row_chunks() across nine cases (M=128/cap=16 → 8 chunks; the NPU_HEAD_NO_CHUNK arm → 128; M=37 → 3; M=0 and cap ≤ 0 → none; M=200/cap=128 → 2) held every invariant the header names — no chunk wider than the capacity, contiguous coverage of [0, M) exactly once, and no chunks at all when the capacity is unknown so the caller falls back instead of launching blind.
Device run attempted — and the result is a clean negative that is worth having: on this box's pb16i artifacts the >16-row path is unreachableI took an
What that means for #1386, stated rather than spun:
So this PR's honest claim is: the launch can no longer exceed the context's row capacity (construction + test), and the |
The review at 0bdbea0 caught it: tools/copyright.py --fix appended the standard block to files that already carried the Apache text under a slightly different first line, so both new files had the notice twice (one Copyright/SPDX pair, two Apache bodies). --check returns 0 either way because it tests that a notice is PRESENT, not that it appears once - which is why it had to be caught by reading. Removed the duplicate body from both files, kept one separator line before the prose, and re-ran the binary: npu_row_chunks PASS, tools/copyright.py --check rc 0.
|
Fixed at The duplicate notice. The second reachable-arm attempt, so the negative in my previous comment is complete. I took one more That pack is stored without its That is the whole device story for this PR: the fixed path loads and runs clean, the chunk loop is a single chunk on every artifact here (so it is a no-op), and the >16 case is proven by construction ( |
|
Closing as superseded — #1386 was fixed and closed by #1387 ( What this branch has that
So both #1387 and this branch were unverifiable on device here; #1387's canary guard is the right shape for that, and the honest claim Thanks to the reviewer for catching the duplicated Apache notice at |
Fixes #1386. The pb16i LM-head rows path launched all
Mrows into a 16-row context:quantize_async_rows()refused thebatch and returned the rows unquantised, and
launch_async_with_bo_rows()then ran anyway — a read and a write past the end of theactivation BO, i.e. #1372's failure on the head instead of on the layers. Reach: any pb16i pack with a verify batch of 17 or more
rows (
mtp_acceptancemeasures up to 128), which #1373'sM > 128refusal never covered and #1382 removes.The fix — the chunking #1364 shipped for the int8 head, in one place
npu/forward/include/q4nx_row_chunks.h(new):row_chunks(rows, capacity, chunked)returns the launch chunks, and itsdocstring states the failure it exists to prevent. The invariant the tests pin:
M == 0→ no chunks;MD <= 0) → no chunks, so the caller falls back to its per-rowrun_lm_headpath instead of launchingblind (that fallback is in this PR too — the old code's only guard was the absent one).
The pb16i launch, readback and dequantise are now per chunk (
hr.data() + r0*H,scales.data() + r0). This cannot move avalue: each row's Hadamard rotation and activation scale are computed before the launches and are its own — exactly the argument
#1364 made for the int8 head.
M <= 16is still one launch ofMrows, so that path is bit-for-bit what it was.NPU_HEAD_NO_CHUNK=1keeps an A/B arm (one row per chunk; #1364's knob, reused so both heads answer the same way). Unlike the oldcode, that arm is safe: one row is never wider than
MD, so the arm measures launch count, not memory safety.Test (device-free, registered with
add_test)tests/npu_row_chunks_test.cpp→ PASS:Mrows (unchanged path)NPU_HEAD_NO_CHUNKarm, 17 rowsAlso verified:
ninja onebit_npu_forwardlinks (libonebit_npu_forward.a) with the change, andtools/copyright.py --checkrc 0.Not run on device, and why that is deliberate
The issue's note asks for a pb16i pack with a draft head and a verify batch of 17+ rows under
npu-hold. The old arm should notbe run deliberately — it is the memory poisoning this fixes. The honest device check is the fixed arm completing a 17+ row pass
with logits identical to the sequential path, which needs
mtp_acceptance's three artifacts (base pack,--head-exact,--head-q4k) that this lane does not stage. Say the word and I will take annpu-holdticket for it; the bounds themselves areproven by construction and by the test above, and the numeric claim is the same one #1364 gated on device for the int8 head.
cc @pi-prs