Repository navigation
npu forward: read the batched prefill's GEMM outputs at the kernel's N stride (engine#855 part 2) - #885
Conversation
…N stride (engine#855 part 2) #854 derives each GEMM design's N on the kernel's column grid (q4nx_pad_n, 32*4*cols = 512), so npu_gemm_rows() writes M rows of the *design's* N. The batched prefill read those buffers back at the REAL width instead -- O[m*H], D[m*H], gv/uv[m*im], ACT[m*2*im] -- so row 0 was right and every later row was read k*(padded-real) floats early. On a prompt of >1 token that corrupts every position after the first, which is exactly the shape of #855: with the container's contents fixed (#849), position 0 matched a numpy reference to 6 digits and every later position did not. Models whose hidden/intermediate are already on the grid (Qwen3-0.6B/1.7B/4B, the 27B) are unaffected, because for them q4nx_pad_n() is the identity -- which is why this went unseen. Measured on the OGA 0.5B container (AMD's pack), host-GEMM, greedy, raw completion "The capital of France is": batched (default) "意为:" / "意为: - 带有" <- before batched, this commit " Paris. The capital of the United States is Washington" sequential (NPU_NO_BATCH=1) " Paris. The capital of the United States is Washington" and the chat prompt "What is the capital of France?" returns AMD's own oracle string, "The capital of France is Paris." The engine's own diagnostic agrees: NPU_BATCH_CHECK=1 printed `seq_argmax=12095 batch_argmax=36589 MISMATCH` before, and the two paths return the same text now.
PR Reviewer Guide 🔍(Review updated until commit 8cc5f1d)Here are some key observations to aid the review process:
|
The same class as the previous commit, in the path a QKV2 pack takes: qh0/qh1/kvk/kvv are written by npu_gemm_rows() as M rows of their design's N (padded, engine#854) but were read at the real widths nhd/nkv, so row 0 was right and every later row was shifted. The size guard follows the buffers to the same stride, so a short return is still caught. Correct by construction, and the identity whenever nothing is padded: the design's N is literally the row width npu_gemm_rows() assigned, so this cannot be wrong for an on-grid geometry (it compiles to the previous arithmetic there). No QKV2 pack is imported on this box, so this one is reasoning + the existing tests (4/4) rather than an end-to-end text, and that is stated rather than glossed.
|
Added Why this is safe without an end-to-end run: the design's N is the row width Scope of the class, for the record: I swept every |
|
Persistent review updated to latest commit ebc5c30 |
1 similar comment
|
Persistent review updated to latest commit ebc5c30 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES — one of the five sites is right for one branch and wrong for the other. Everything else in the diff is
correct and I checked it.
What is right, verified at this head (ebc5c305): O and D are read at q4nx_pad_n(H) (they are npu_gemm_rows
outputs, so their rows are at the design's N); gv/uv at gg->N/gu->N; the QKV2 split's qh0/qh1 at gq->N and
kvk/kvv at gk->N, with the four short-buffer checks updated to the same widths (those vectors are resized by
npu_gemm_rows, so the checks are consistent). Identity on the grid is the right claim: q4nx_pad_n(1024) = 1024, and the
NPU lane builds clean with ctest -R "npu_design_grid|npu_forward_loader|npu_pack_import|npu_q4nx" 4/4.
The item — ACT needs two strides, not one:
3353: std::vector<float> av((size_t)M * (size_t)im * 2); // host-packed, REAL width
3369: av[(size_t)m * 2 * im + i] = gv[(size_t)m * gg->N + i];
3371: av[(size_t)m * 2 * im + im + i] = uv[(size_t)m * gu->N + i];
3375: ACT.swap(av); // ACT now holds rows at 2*im
...
3384: const size_t act_w = (size_t)q4nx_pad_n(2 * im);
3385: act_row.assign(ACT.begin() + (size_t)m * act_w, …);
ACT reaches that read from two branches with different layouts:
- fused (
i_gu >= 0):npu_gemm_rows(…, ACT, 2, "ffn_gate_up", …)writes it directly, so its rows are at the design's
N — reading atact_wis correct there, and that is the branch the header comment describes; - split (
i_g/i_u):ACTis swapped fromav, which is packed at the real width — reading atact_wis wrong
there. Whenpad_n(2*im) > 2*im, rowmstartsm × (act_w − 2*im)floats beyond its data, and the last row reads past
the end of the vector (an OOB read of(M−1) × (act_w − 2*im)floats).
It is latent on this box, which is why 4/4 tests and the OGA text are green: every intermediate_size in the containers
here is 512-aligned (2*im = 1536 / 6144 / 7168 / 34816, so act_w == 2*im), and so is the OGA pack's 4864. The first
container whose im % 256 != 0 — a plain im = 896-style or any non-multiple — takes the split branch and gets shifted
rows plus a read past the buffer, with no test in this repo covering it.
Two ways to fix, either is fine by me: pack the split branch at the design stride (std::vector<float> av((size_t)M * act_w)
and av[m * act_w + …), or keep av at real width and use 2*im for the split branch / act_w for the fused one. The
comment should then say which layout it is describing, because today it says "for the same reason as O and D" and the
reason does not hold for the swapped buffer.
Not reproduced by me: the end-to-end table. The body says it was run on the #842 + #844 + #849 stack, and none of those
three are on main (a container with a padded N cannot be imported from main alone), so the text claim is yours. The
stride reasoning above is checked against the source at this head, and the O/D/gv/uv/QKV2 fixes are the same
argument applied where it holds.
…oo (review of #885) ACT is read once, after the if/else, at the fused design's N (pad_n(2*im)) -- but it reached that read from two branches with different layouts: the fused branch by construction (npu_gemm_rows writes M rows of the design's N), and the split branch via a buffer packed at the REAL width (2*im). With pad_n(2*im) > 2*im that read puts row m at m*(act_w - 2*im) floats past its data and the last row past the end of the vector. Latent rather than live on this box, which is why every test and the OGA text stayed green: every intermediate_size in the containers here is 512-aligned (2*im = 1536/6144/7168/34816, and the pack's 4864), so act_w == 2*im. The first container with im % 256 != 0 takes the split branch and gets shifted rows plus an out-of-bounds read. Fixed by giving both branches the same layout instead of teaching the read two strides: the split branch now sizes its packed buffer at act_w (zero-filled, so the pad columns match what assemble_B leaves in the fused case) and indexes it at act_w. The comment says which layout it is describing rather than pointing at O/D, whose reason does not hold for a swapped buffer.
…nels) — #855's launcher ABI The OGA pack's kernels are a different launch family from the int8 per-tile one I8Ctx was written for: their runtime sequence declares (arg0 = the tensor's whole q4_1 tile stream, arg1 = K bf16 activations, arg2 = N f32 outputs), and its .dynsym symbols are per core, so arg0 and arg2 are 1/cores of the stream and of the output while arg1 is the whole activation row. Driving them with the int8 launcher is what made every array run of this pack either fault (IO_PAGE_FAULT bursts, 2026-11-02 20:35) or come back degenerate. The family is detected from the artifact, not from a config knob: q4nx_gemv_family() decides from the three declared widths (the same artifact-level information #869's guard reads), and I8Ctx then * sizes the activation BO K*2 (bf16) and the output BO N*4 (f32), * takes the weight BO as the tensor's own 5,120-byte q4_1 tiles ((K/256)*(N/32) of them), * launches (weights, activations, outputs), * converts the activation row f32 -> bf16 (round-to-nearest-even) and reads the result back as f32, skipping the int32 canary (its sentinel would land in an f32 buffer). The forward builds that weight image by copying the container's chunks -- row-tile-major, k-chunk inner, the order the vendor packer and the bit-exact 2026-11-01 bench both use -- so every tile keeps its own bf16 scale and zero; nothing is re-quantised. Fused designs (QKV, gate|up) concatenate their parts in the design's N order and pad the tail with zero row-tiles. The head uses its own tile tensor; a tied bf16 table has no tile stream and is refused by name rather than quantised here. The family has no M dimension (its _m16 ELFs are byte-identical to the one-row design), so npu_gemm_rows/run_lm_head_rows fall back to per-row launches: correct, and slower for prefill -- that is #671 item 2's m128 work, stated rather than hidden. Device-free verification, because the rule is where the first attempt went wrong: * q4nx_gemv_family() unit cases in tests/npu_elf_args_test.c: the five pack shapes classified from their measured declarations (QKV 245,760/2,048/1,536 at K=1024 N=1536 ... head 24,330,240/2,048/ 152,064), plus six near misses that must not match -- the int8 per-tile family (512/1,024/4,096), a full-width stream, a byte-count output, int8-width activations, K off the 256 grid, a zero stream. 11 declarations, all classified correctly. * the same rule run over the real artifacts (a harness calling the exact function init_elf uses): all five pack ELFs -> gemv, the 27B's int8 per-tile ELF -> not gemv. * ctest 5/5 (npu_elf_args, npu_design_grid, npu_forward_loader, npu_pack_import, npu_q4nx), copyright.py --check rc 0, and the host-GEMM path unchanged on the corrected container ("The capital of France is Paris.", AMD's own oracle string). Depends on #885 for the batched row strides; stacked on it rather than on main because the per-row fallback fills the same M*N buffers #885 fixed the callers to read.
|
Fixed at
Your latent-only read is accepted as stated: every Re-verified at this head: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
At 591c51d3. The item I blocked on is fixed the right way — one layout, one stride, rather than a per-branch rule:
3348 const size_t act_w = (size_t)q4nx_pad_n(2 * im); // declared before BOTH branches
3359 std::vector<float> gv, uv, av((size_t)M * act_w, 0.0f); // zero-filled at the design width
3379 av[(size_t)m * act_w + i] = gv[(size_t)m * gg->N + i];
3380 av[(size_t)m * act_w + im + i] = uv[(size_t)m * gu->N + i];
3392 act_row.assign(ACT.begin() + m * act_w, … + 2 * im);
So the split branch now hands ACT rows at exactly the stride the fused branch produces (npu_gemm_rows writing M rows of
the design's N), the single read below is correct for both, and the OOB I measured is gone by construction: av is sized
M * act_w, every write is inside it, and the pad columns are zero rather than uninitialised. The rest of the PR is
unchanged and was already verified: O/D at q4nx_pad_n(H), gv/uv at gg->N/gu->N, qh0/qh1/kvk/kvv at
gq->N/gk->N with the size checks to match.
Built at this head and ran the lane's tests: ctest -R "npu_design_grid|npu_forward_loader|npu_pack_import|npu_q4nx|npu_elf_args" → 5/5 passed. That is the same set your
run covers, and it is the identity case (pad_n(2*im) == 2*im for every container here), so the value of the fix is in the
im % 256 != 0 case that no container on this box exercises — worth saying in the commit message, since the test suite
cannot distinguish the two layouts.
Two follow-ups, neither blocking this PR:
- #891 is stacked on this branch (
base = 1bit/855-batched-nstride), so after this merges it needsgit rebase origin/mainbefore it can land — same trap as #862/#872 earlier tonight. - The
ACTcomment now explains both branches, which is what the previous version was missing; if the fused branch's pad
columns are ever non-zero (assemble_Bwith a padded weight), the "matching what assemble_B leaves" sentence will be the
place to update.
|
Persistent review updated to latest commit 6f9a995 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
At 6f9a99562 (merge of main into 591c51d3d; 1 of 1 files blob-identical). Build success at this head.
The fix is unchanged and is the one-layout form I asked for: act_w = q4nx_pad_n(2 * im) declared before both branches,
the split gate/up branch's av sized M * act_w and zero-filled with writes at m * act_w, so the single read below is
correct for the fused and the split branch alike. Verified by building this tree: ctest -R "npu_design_grid|npu_forward_loader|npu_pack_import|npu_q4nx|npu_elf_args" 5/5.
Please merge this before #891, which is based on this branch (1bit/855-batched-nstride): once it lands, #891 needs
git rebase origin/main (it carries this branch's commits) and I re-verdict it at the rebased head — my check there is a
real-ELF classification harness plus npu_elf_args_test, both seconds.
…nels) — #855's launcher ABI The OGA pack's kernels are a different launch family from the int8 per-tile one I8Ctx was written for: their runtime sequence declares (arg0 = the tensor's whole q4_1 tile stream, arg1 = K bf16 activations, arg2 = N f32 outputs), and its .dynsym symbols are per core, so arg0 and arg2 are 1/cores of the stream and of the output while arg1 is the whole activation row. Driving them with the int8 launcher is what made every array run of this pack either fault (IO_PAGE_FAULT bursts, 2026-11-02 20:35) or come back degenerate. The family is detected from the artifact, not from a config knob: q4nx_gemv_family() decides from the three declared widths (the same artifact-level information #869's guard reads), and I8Ctx then * sizes the activation BO K*2 (bf16) and the output BO N*4 (f32), * takes the weight BO as the tensor's own 5,120-byte q4_1 tiles ((K/256)*(N/32) of them), * launches (weights, activations, outputs), * converts the activation row f32 -> bf16 (round-to-nearest-even) and reads the result back as f32, skipping the int32 canary (its sentinel would land in an f32 buffer). The forward builds that weight image by copying the container's chunks -- row-tile-major, k-chunk inner, the order the vendor packer and the bit-exact 2026-11-01 bench both use -- so every tile keeps its own bf16 scale and zero; nothing is re-quantised. Fused designs (QKV, gate|up) concatenate their parts in the design's N order and pad the tail with zero row-tiles. The head uses its own tile tensor; a tied bf16 table has no tile stream and is refused by name rather than quantised here. The family has no M dimension (its _m16 ELFs are byte-identical to the one-row design), so npu_gemm_rows/run_lm_head_rows fall back to per-row launches: correct, and slower for prefill -- that is #671 item 2's m128 work, stated rather than hidden. Device-free verification, because the rule is where the first attempt went wrong: * q4nx_gemv_family() unit cases in tests/npu_elf_args_test.c: the five pack shapes classified from their measured declarations (QKV 245,760/2,048/1,536 at K=1024 N=1536 ... head 24,330,240/2,048/ 152,064), plus six near misses that must not match -- the int8 per-tile family (512/1,024/4,096), a full-width stream, a byte-count output, int8-width activations, K off the 256 grid, a zero stream. 11 declarations, all classified correctly. * the same rule run over the real artifacts (a harness calling the exact function init_elf uses): all five pack ELFs -> gemv, the 27B's int8 per-tile ELF -> not gemv. * ctest 5/5 (npu_elf_args, npu_design_grid, npu_forward_loader, npu_pack_import, npu_q4nx), copyright.py --check rc 0, and the host-GEMM path unchanged on the corrected container ("The capital of France is Paris.", AMD's own oracle string). Depends on #885 for the batched row strides; stacked on it rather than on main because the per-row fallback fills the same M*N buffers #885 fixed the callers to read.
|
Persistent review updated to latest commit e8bf018 |
|
Persistent review updated to latest commit d5b3992 |
…nels) — #855's launcher ABI (#891) The OGA pack's kernels are a different launch family from the int8 per-tile one I8Ctx was written for: their runtime sequence declares (arg0 = the tensor's whole q4_1 tile stream, arg1 = K bf16 activations, arg2 = N f32 outputs), and its .dynsym symbols are per core, so arg0 and arg2 are 1/cores of the stream and of the output while arg1 is the whole activation row. Driving them with the int8 launcher is what made every array run of this pack either fault (IO_PAGE_FAULT bursts, 2026-11-02 20:35) or come back degenerate. The family is detected from the artifact, not from a config knob: q4nx_gemv_family() decides from the three declared widths (the same artifact-level information #869's guard reads), and I8Ctx then * sizes the activation BO K*2 (bf16) and the output BO N*4 (f32), * takes the weight BO as the tensor's own 5,120-byte q4_1 tiles ((K/256)*(N/32) of them), * launches (weights, activations, outputs), * converts the activation row f32 -> bf16 (round-to-nearest-even) and reads the result back as f32, skipping the int32 canary (its sentinel would land in an f32 buffer). The forward builds that weight image by copying the container's chunks -- row-tile-major, k-chunk inner, the order the vendor packer and the bit-exact 2026-11-01 bench both use -- so every tile keeps its own bf16 scale and zero; nothing is re-quantised. Fused designs (QKV, gate|up) concatenate their parts in the design's N order and pad the tail with zero row-tiles. The head uses its own tile tensor; a tied bf16 table has no tile stream and is refused by name rather than quantised here. The family has no M dimension (its _m16 ELFs are byte-identical to the one-row design), so npu_gemm_rows/run_lm_head_rows fall back to per-row launches: correct, and slower for prefill -- that is #671 item 2's m128 work, stated rather than hidden. Device-free verification, because the rule is where the first attempt went wrong: * q4nx_gemv_family() unit cases in tests/npu_elf_args_test.c: the five pack shapes classified from their measured declarations (QKV 245,760/2,048/1,536 at K=1024 N=1536 ... head 24,330,240/2,048/ 152,064), plus six near misses that must not match -- the int8 per-tile family (512/1,024/4,096), a full-width stream, a byte-count output, int8-width activations, K off the 256 grid, a zero stream. 11 declarations, all classified correctly. * the same rule run over the real artifacts (a harness calling the exact function init_elf uses): all five pack ELFs -> gemv, the 27B's int8 per-tile ELF -> not gemv. * ctest 5/5 (npu_elf_args, npu_design_grid, npu_forward_loader, npu_pack_import, npu_q4nx), copyright.py --check rc 0, and the host-GEMM path unchanged on the corrected container ("The capital of France is Paris.", AMD's own oracle string). Depends on #885 for the batched row strides; stacked on it rather than on main because the per-row fallback fills the same M*N buffers #885 fixed the callers to read. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
|
Persistent review updated to latest commit 8cc5f1d |
… in the rebase The forge q4_1 gemv family landed in npu_gemm/npu_gemm_rows after this branch started, so the phase that claims to move those bodies has to say where it put them: the tile image is model state, the launch is the target's, and GemvArgs::tiles is the one seam between them. Doc only; no code.
…ixes #803) Same content as the 13-commit stack on npu/plan-p4-target, re-applied onto origin/main 7153bed as ONE commit: the branch had accumulated fifteen "merge origin/main" commits, one per re-gate, and the repo squashes on merge anyway. npu(p4): extract the XDNA launch layer behind NpuTarget (engine#799 P4 / #803) docs(npu-p4): the Apache-2.0 notice tools/copyright.py requires npu(p4): consume P1's plan loader in NpuTarget::open instead of duplicating the derivation npu(p4): the LM head falls back through ctx_for(), so a set with no M=16 twins still runs npu(p4): the pb16i role split is the target's state, so setup asks it instead of assigning npu(p4): the kernel family is the forward's to name, so open() takes it npu(p4): the rotation is one implementation, so the target calls hadamard.h too npu(p4): the head's ternary flag is head_pb16i_, not a self-reference npu(p4): the rebase put a_k in the wkey/sc slots, so pass all three as main does docs(npu-p4): name the gemv family's boundary in A1, now that #885 is in the rebase npu(p4): the m16 debug comparison takes its difference at int64 width npu(p4): the target headers take npu-relative includes, so CI's syntax target compiles them chore: re-trigger CI Rebased rather than merged this time, because the merge caravan is what has been voiding the gate: each "merge origin/main" moved the head, so fourteen pairings were spent keeping a table honest against a tree that kept moving. The split's own files are byte-identical to the reviewed ones (blob check in the PR): npu/target/target.h, npu/target/xdna/*, npu/forward/include/q4nx_lh.h, docs/npu-p4-target-split.md. The three that differ are the three main changed: CMakeLists.txt (main's test registrations), and q4nx_forward.{h,cpp}, which now carry main's newer forward work (#1243's AVX2/FMA attention, #1266's MoE container) *on top of* the split -- verified by grepping the moved-in symbols in the rebased file, not by trusting the clean merge. Nothing in here changes numerics: the device and launch layer moved, the 38 launch sites became role-based calls, and the model math stayed where it was.
…ixes #803) Same content as the 13-commit stack on npu/plan-p4-target, re-applied onto origin/main 7153bed as ONE commit: the branch had accumulated fifteen "merge origin/main" commits, one per re-gate, and the repo squashes on merge anyway. npu(p4): extract the XDNA launch layer behind NpuTarget (engine#799 P4 / #803) docs(npu-p4): the Apache-2.0 notice tools/copyright.py requires npu(p4): consume P1's plan loader in NpuTarget::open instead of duplicating the derivation npu(p4): the LM head falls back through ctx_for(), so a set with no M=16 twins still runs npu(p4): the pb16i role split is the target's state, so setup asks it instead of assigning npu(p4): the kernel family is the forward's to name, so open() takes it npu(p4): the rotation is one implementation, so the target calls hadamard.h too npu(p4): the head's ternary flag is head_pb16i_, not a self-reference npu(p4): the rebase put a_k in the wkey/sc slots, so pass all three as main does docs(npu-p4): name the gemv family's boundary in A1, now that #885 is in the rebase npu(p4): the m16 debug comparison takes its difference at int64 width npu(p4): the target headers take npu-relative includes, so CI's syntax target compiles them chore: re-trigger CI Rebased rather than merged this time, because the merge caravan is what has been voiding the gate: each "merge origin/main" moved the head, so fourteen pairings were spent keeping a table honest against a tree that kept moving. The split's own files are byte-identical to the reviewed ones (blob check in the PR): npu/target/target.h, npu/target/xdna/*, npu/forward/include/q4nx_lh.h, docs/npu-p4-target-split.md. The three that differ are the three main changed: CMakeLists.txt (main's test registrations), and q4nx_forward.{h,cpp}, which now carry main's newer forward work (#1243's AVX2/FMA attention, #1266's MoE container) *on top of* the split -- verified by grepping the moved-in symbols in the rebased file, not by trusting the clean merge. Nothing in here changes numerics: the device and launch layer moved, the 38 launch sites became role-based calls, and the model math stayed where it was. One row of the gate was a citation rather than a check, and is now a check: §4 item 4 asks for `--npu-target xdna` as the default with an unknown name refused by name, and A1 had no such option at all -- the name appeared only in a comment in npu/target/target.h and in the design doc, so the earlier "PASS: from the A1 design row" was a claim about the document. The flag now exists: `forward-serve --npu-target <name>` (defaulting to XdnaTarget::kName), `serve` carries it through to that child, and an unknown name is refused **before the device is opened**, which is what makes the row measurable without a ticket.
…ixes #803) Same content as the 13-commit stack on npu/plan-p4-target, re-applied onto origin/main 7153bed as ONE commit: the branch had accumulated fifteen "merge origin/main" commits, one per re-gate, and the repo squashes on merge anyway. npu(p4): extract the XDNA launch layer behind NpuTarget (engine#799 P4 / #803) docs(npu-p4): the Apache-2.0 notice tools/copyright.py requires npu(p4): consume P1's plan loader in NpuTarget::open instead of duplicating the derivation npu(p4): the LM head falls back through ctx_for(), so a set with no M=16 twins still runs npu(p4): the pb16i role split is the target's state, so setup asks it instead of assigning npu(p4): the kernel family is the forward's to name, so open() takes it npu(p4): the rotation is one implementation, so the target calls hadamard.h too npu(p4): the head's ternary flag is head_pb16i_, not a self-reference npu(p4): the rebase put a_k in the wkey/sc slots, so pass all three as main does docs(npu-p4): name the gemv family's boundary in A1, now that #885 is in the rebase npu(p4): the m16 debug comparison takes its difference at int64 width npu(p4): the target headers take npu-relative includes, so CI's syntax target compiles them chore: re-trigger CI Rebased rather than merged this time, because the merge caravan is what has been voiding the gate: each "merge origin/main" moved the head, so fourteen pairings were spent keeping a table honest against a tree that kept moving. The split's own files are byte-identical to the reviewed ones (blob check in the PR): npu/target/target.h, npu/target/xdna/*, npu/forward/include/q4nx_lh.h, docs/npu-p4-target-split.md. The three that differ are the three main changed: CMakeLists.txt (main's test registrations), and q4nx_forward.{h,cpp}, which now carry main's newer forward work (#1243's AVX2/FMA attention, #1266's MoE container) *on top of* the split -- verified by grepping the moved-in symbols in the rebased file, not by trusting the clean merge. Nothing in here changes numerics: the device and launch layer moved, the 38 launch sites became role-based calls, and the model math stayed where it was. One row of the gate was a citation rather than a check, and is now a check: §4 item 4 asks for `--npu-target xdna` as the default with an unknown name refused by name, and A1 had no such option at all -- the name appeared only in a comment in npu/target/target.h and in the design doc, so the earlier "PASS: from the A1 design row" was a claim about the document. The flag now exists: `forward-serve --npu-target <name>` (defaulting to XdnaTarget::kName), `serve` carries it through to that child, and an unknown name is refused **before the device is opened**, which is what makes the row measurable without a ticket.
…ixes #803) Same content as the 13-commit stack on npu/plan-p4-target, re-applied onto origin/main 7153bed as ONE commit: the branch had accumulated fifteen "merge origin/main" commits, one per re-gate, and the repo squashes on merge anyway. npu(p4): extract the XDNA launch layer behind NpuTarget (engine#799 P4 / #803) docs(npu-p4): the Apache-2.0 notice tools/copyright.py requires npu(p4): consume P1's plan loader in NpuTarget::open instead of duplicating the derivation npu(p4): the LM head falls back through ctx_for(), so a set with no M=16 twins still runs npu(p4): the pb16i role split is the target's state, so setup asks it instead of assigning npu(p4): the kernel family is the forward's to name, so open() takes it npu(p4): the rotation is one implementation, so the target calls hadamard.h too npu(p4): the head's ternary flag is head_pb16i_, not a self-reference npu(p4): the rebase put a_k in the wkey/sc slots, so pass all three as main does docs(npu-p4): name the gemv family's boundary in A1, now that #885 is in the rebase npu(p4): the m16 debug comparison takes its difference at int64 width npu(p4): the target headers take npu-relative includes, so CI's syntax target compiles them chore: re-trigger CI Rebased rather than merged this time, because the merge caravan is what has been voiding the gate: each "merge origin/main" moved the head, so fourteen pairings were spent keeping a table honest against a tree that kept moving. The split's own files are byte-identical to the reviewed ones (blob check in the PR): npu/target/target.h, npu/target/xdna/*, npu/forward/include/q4nx_lh.h, docs/npu-p4-target-split.md. The three that differ are the three main changed: CMakeLists.txt (main's test registrations), and q4nx_forward.{h,cpp}, which now carry main's newer forward work (#1243's AVX2/FMA attention, #1266's MoE container) *on top of* the split -- verified by grepping the moved-in symbols in the rebased file, not by trusting the clean merge. Nothing in here changes numerics: the device and launch layer moved, the 38 launch sites became role-based calls, and the model math stayed where it was. One row of the gate was a citation rather than a check, and is now a check: §4 item 4 asks for `--npu-target xdna` as the default with an unknown name refused by name, and A1 had no such option at all -- the name appeared only in a comment in npu/target/target.h and in the design doc, so the earlier "PASS: from the A1 design row" was a claim about the document. The flag now exists: `forward-serve --npu-target <name>` (defaulting to XdnaTarget::kName), `serve` carries it through to that child, and an unknown name is refused **before the device is opened**, which is what makes the row measurable without a ticket.
Root cause
#854 derives every GEMM design's N on the kernel's column grid (
q4nx_pad_n: 32 rows × 4 cols × 4 = 512), sonpu_gemm_rows()writes M rows of the design's N. The batched prefill path read those buffers back at the real width:Row 0 is always right; row m is read
m × (padded − real)floats early. So on any prompt longer than one token, every position after the first is corrupted — exactly the signature reported in #855: with the container's contents correct (#849), position 0 matches a numpy reference to 6 digits and positions ≥1 do not (layer-0 residualm=0 0.142969 6.0both,m=10.113883 vs 0.162738).Models already on the grid (Qwen3-0.6B/1.7B/4B, the 27B) are unaffected because
q4nx_pad_n()is the identity for them — that is why this went unseen and why the generic path still answered sanely.Measured before/after
OGA 0.5B container (AMD's
Qwen2.5-0.5B-Instruct_rai_1.8.0_npu_4K, imported), host-GEMM, greedy:The capital of France is意为: - 带有 …Paris. The capital of the United States is WashingtonNPU_NO_BATCH=1), for referenceParis. The capital of the United States is Washingtonand the chat prompt
What is the capital of France?returnsThe capital of France is Paris.— AMD's own oracle string (~/ogaload/window/oracle.json). The engine's built-in oracle agrees:NPU_BATCH_CHECK=1printedseq_argmax=12095 batch_argmax=36589 MISMATCHbefore; the two paths now produce the same text.Localisation, so the credit is right: the divergence was not in the projections or the attention math. q/k/v (pre-RoPE) and
attention()'s own output are byte-identical between the batched and sequential paths at every position of the prompt (temporary instrumentation insideattention(), removed); only the post-attention buffers differed, which is what pointed at the row strides.Verification
Release/ninja/gcc-16 configure off currentmain(23d6972d), withONEBIT_NPU: builds clean;ctest -R "npu_design_grid|npu_forward_loader|npu_pack_import|npu_q4nx"→ 4/4 pass (the fix is the identity for every on-grid geometry, so nothing else moves).tools/copyright.py --check→ rc 0.#842 + #844 + #849stack, because a container with a padded N cannot be produced frommainalone (npu-pack importstill refuses the MLP + final norm there). The fix itself does not depend on those PRs.Relation to #855
Together with #879 (the importer's zero points paired even/odd like the codes, without which the container itself was wrong) this fixes #855 end to end: the container's weights, the sequential path and the batched path now all agree with AMD's own output on this pack. Not marked
closes #855because #879 has to land for the container half.