Repository navigation
npu forward: drive the forge q4_1 GEMV ELF family (the pack's own kernels) — #855's launcher ABI - #891
Conversation
PR Reviewer Guide 🔍(Review updated until commit 980e3de)Here are some key observations to aid the review process:
|
8740e0c to
a273cfe
Compare
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
The family rule is the part that has to be right, and I checked it against the real artifacts rather than only the
unit cases. Built at this head (ONEBIT_NPU=ON, own runtime) and ran a harness of my own over every ELF on the box that
belongs to either family, calling q4nx_gemv_family(decl, K, N) with K/N taken from the file names:
full_i8_QKV_K1024_N1536.elf K=1024 N=1536 decl=245760/2048/1536 family=gemv
full_i8_GU_K1024_N9728.elf K=1024 N=9728 decl=1556480/2048/9728 family=gemv
full_i8_LMHEAD_K1024_N152064.elf K=1024 N=152064 decl=24330240/2048/152064 family=gemv
full_i8_O_K1024_N1024.elf K=1024 N=1024 decl=163840/2048/1024 family=gemv
full_i8_D_K4864_N1024.elf K=4864 N=1024 decl=778240/9728/1024 family=gemv
full_i8_QKV_K5120_N14336_m16.elf K=5120 N=14336 decl=512/1024/4096 family=not-gemv
full_i8_G_K5120_N17408_m16.elf K=5120 N=17408 decl=512/1024/4096 family=not-gemv
Five pack ELFs in, five gemv out; the 27B's int8 per-tile ELFs stay out. That is the body's claim, reproduced on the files
themselves, and the declared widths are exactly the numbers in the PR (245,760/2,048/1,536 … 24,330,240/2,048/152,064).
Reproduced the repo gates at this head too: ./npu_elf_args_test → gemv family: 11 declarations classified, all cases
ok (including the six near misses), and ctest -R "npu_elf_args|npu_design_grid|npu_forward_loader|npu_pack_import| npu_q4nx" → 5/5.
In the code, the three things that decide whether a launch is correct rather than merely accepted:
- argument order:
if (gemv) return (*kext)(wbo, *bA, *bC);against the int8 family's(*kext)(*bA, wbo, *bC)— one
line apart, which is where the old code went wrong; - BO shapes:
bA = mkbo(gemv ? KD * 2 : MD * KD)(bf16 rows) andgemv_w_bytes(K, N) = (K/256)*(N/32)*5120, i.e. the
container's own tile stream — nothing re-quantised, every tile keeps its bf16 scale and zero; - dtype conversion:
f32_to_bf16_bits()on the activation row, with the int32 canary skipped on this family (its
sentinel would land in an f32 buffer).
Two things to carry, neither blocking:
- Merge order: this is stacked on #885 (
base = 1bit/855-batched-nstride), which I have just passed READY at
591c51d3. Land #885 first; then this needsgit rebase origin/main(it carries #885's commits, exactly the #862/#872
pattern) and I re-verdict the rebased head in one command — my check is the harness above plusnpu_elf_args_test. - The array leg is not in this PR and the body says so plainly: the
/dev/accel/accel0device was held by another
lane's private serve with no~/NPU-TOKEN, so the five-prompt run is owed on #855. Everything above is the load-time and
artifact-level half, which is the half that was wrong twice before (#869 refused it, the first attempt still refused at
load because the core count was assumed to be four).
The per-row fallback for batched paths is stated with its cost (correct, slower for prefill, #671 item 2 owns the m128
work), and a tied-bf16 head is refused by name rather than quantised on the fly — both the right failure modes.
|
Persistent review updated to latest commit a273cfe |
…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.
a273cfe to
980e3de
Compare
|
Persistent review updated to latest commit 980e3de |
8cc5f1d
into
1bit/855-batched-nstride
…N stride (engine#855 part 2) (#885) * npu forward: read the batched prefill's GEMM outputs at the kernel's 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. * npu forward: the QKV2 split reads its four GEMMs at the design's N too 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. * npu forward: the split gate/up branch packs ACT at the fused stride too (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. * npu forward: drive the forge q4_1 GEMV ELF family (the pack's own kernels) — #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> --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY, on the content and on one operational catch that decides how it can land.
Content re-verified at 980e3de2c. One commit, 6 files, +332/−13 — the same shape I verdicted at a273cfe4e, and the lines that carried that verdict are unchanged at this head, in npu/forward/engine-src/npu_engine_i8ctx_inc.h:
:256 return q4nx_gemv_family(declared, KD, ND) != 0; // the family rule, from the ELF's own .dynsym
:474 if (gemv) return (*kext)(wbo, *bA, *bC); // the argument order this family needs
:524 static inline uint16_t f32_to_bf16_bits(float v) // and the bf16 activation conversion
:536 Ag[k] = f32_to_bf16_bits(v);
Rebuilt at this head and ran the unit test: npu_elf_args_test → 8 ok (three argument widths are found, widths match the declared symbols, a narrow tile-family ELF reads back as declared, and the five refusal cases), then gemv family: 11 declarations classified, all cases ok. The real-ELF half of my earlier review still stands — five OGA pack ELFs classify gemv, both 27B int8 per-tile ELFs classify not-gemv — because the classifier and its call site are the same functions I exercised, and the artefacts have not changed.
The catch: this PR's head branch no longer exists.
$ git ls-remote https://github.com/1bit-MONSTER/engine 'refs/heads/1bit/855*'
(no output)
$ git ls-remote https://github.com/1bit-MONSTER/engine | grep '^980e3de2c'
980e3de2c5354448768c76a31a307fc858e16a0f refs/pull/891/head
1bit/855-gemv-launcher is not on the remote at all; the commit survives only as refs/pull/891/head. Two consequences, and the second is the one that matters:
git fetch origin 1bit/855-gemv-launcherfails ("couldn't find remote ref"), so anyone reproducing this needsrefs/pull/891/head— which is fine, it is how I built and ran the test above.- This PR cannot be rebased. It is based on
1bit/855-batched-nstride(#885), so it must be rebased after #885 lands — and that rebase has nowhere to push to. Landing it on today's base instead would carry #885's commits with it. So either the lane pushes a fresh branch and retargets this PR, or a replacement PR is opened after #885 merges, and this one is closed as landed-by.
That is not a code objection — the code is ready — but it is a decision that has to happen before the merge, not after.
…8 tiled head refuses it (engine#855) (#910) * npu forward: the gemv family runs the head as ONE launch, and the int8 tiled head refuses it #891 taught I8Ctx the forge q4_1 GEMV family, whose weight BO holds the container's own tile stream (gemv_w_bytes) instead of an int8 KD*ND image. Two head paths did not know that: * run_lm_head() packs int8 B tiles with packB_into(), which memsets KD*ND bytes into a BO that make_weight_bo() now sizes as a tile stream. On the 0.5B pack's head that is 9,961,472 B into 6,225,920 B -- a 3.7 MB heap overwrite. Measured on the array (ASan, npu-hold ticket): the memset at npu_engine_i8ctx_inc.h:459 writes 6144 B BEFORE the 38 MB head tile vector and glibc aborts the first request with "munmap_chunk(): invalid pointer"; the non-ASan build died the same way with no message. * no gemv head context was ever built for a non-pb16i container, so a pack container had no path to the head at all. The head now loads the LMHEAD ELF at N = the vocabulary (padded vocabulary first, then the raw vocab: the pack's 151936 is 152064 on the gemv column grid) and runs run_lm_head_gemv(), which already existed. Its tile stream is the container's own, at the REAL vocabulary width, so the columns past the vocabulary are zero tiles -- the same rule the fused designs use for a padded tail -- and only the first NV logits are read. run_lm_head() also refuses a gemv context by name rather than packing an int8 image into a tile BO, so a container without a head ELF gets a diagnosis instead of a heap overrun. Measured on the array, same reproducer (0.5B pack container, kernels-m16-seq, /dev/accel/accel0): before: "munmap_chunk(): invalid pointer" on the first request, process dead after : ASan build, 0 reports; serve answers; 0 amdxdna errors, 0 IO_PAGE_FAULT The text is not right yet ("!!!!"): the projections' weight stream is still the container's Q4NX tile order, while the forge gemv design wants DX4m-repacked tiles in its own per-column/step/core order (addons/npu-dx/forge/design_gemv.py, bench/forge_bench.cpp). That is #855's remaining work, recorded there; this commit removes a heap corruption and the missing head path, and does not claim gate (a). * review: bound the gemv head copy by the destination, not the source The tail past the container's head tiles is zero-fill by construction, so the copy must be clamped to the BO-sized destination (want); clamping to the source (have) was correct only because of the data_size >= have guard above it. * npu forward: the gemv head must BE a q4_1 tile stream, and its ELF comes from the plan's decode dirs Two review threads on #910, both right: 1. run_lm_head_gemv() only refused a head SMALLER than the expected stream, so a BF16 or Q8 head of at least that size was copied into the tile buffer and uploaded as q4_1 tiles -- invalid logits instead of a refusal, which is the exact case the function's own comment claims to refuse by name. The dtype is now checked ("q4_1" or "I8") and the length has to be the stream size exactly, either at the real vocabulary width or at the padded grid width, with a message naming what it found. 2. The head ELF lookup ignored plan_.decode_dirs while the projection lookup honours it, so a plan that places decode ELFs under e.g. <model>/kernels-m16-seq loaded every projection and then failed initialisation with "no head ELF is on disk". m16dirs_like_head() now takes the plan's resolved directories (cached in plan_decode_dirs_) and prefers them, keeping the built-in guesses as the no-plan fallback. Verified: `ninja` rc 0; `ctest -R "npu_elf_args|npu_design_grid|npu_forward_loader|npu_pack_import|npu_q4nx"` 5/5; copyright rc 0. The refusal's negative case needs a gemv container carrying a non-tile head, which this lane does not have on disk -- said as much on the thread rather than implying a device run. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: agent <agent@local>
What this is
The OGA pack's kernels are a different launch family from the int8 per-tile one
I8Ctxwas written for. Their runtime sequence declares(arg0 = the tensor's whole q4_1 tile stream, arg1 = K bf16 activations, arg2 = N f32 outputs), and the.dynsymsymbols backing those arguments are per core —arg0andarg2are1/coresof the stream and of the output,arg1is the whole activation row. Driving them with the int8 launcher is what made every array run of this pack either fault (the threeIO_PAGE_FAULTbursts of 2026-11-02 20:35) or return degenerate tokens; #869 turned that into a load-time refusal, and this PR makes the launch itself correct.The family is detected from the artifact, not configured
q4nx_gemv_family(declared, K, N)decides from the three declared widths — the same artifact-level information #869's guard already reads. The core count is inferred fromarg0rather than assumed to be four, which is exactly why the first attempt still refused at load.I8Ctxthen:K*2(bf16) and the output BON*4(f32);(K/256)*(N/32)of them;(weights, activations, outputs);The forward builds that weight image by copying the container's chunks — row-tile-major, k-chunk inner, the order the vendor's packer and the bit-exact 2026-11-01 bench both use — so every tile keeps its own bf16 scale and zero and 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 instead of being quantised here.The family has no M dimension (its
_m16ELFs are byte-identical to the one-row design), sonpu_gemm_rows/run_lm_head_rowsfall back to per-row launches: correct, and slower for prefill — that gap is #671 item 2's m128 work, stated rather than hidden.Verification (device-free, because the rule is where the first attempt went wrong)
q4nx_gemv_family()cases intests/npu_elf_args_test.c— the five pack shapes classified from their measured declarations (QKV245,760/2,048/1,536at K=1024 N=1536 … head24,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.ctest -R "npu_elf_args|npu_design_grid|npu_forward_loader|npu_pack_import|npu_q4nx"5/5,tools/copyright.py --checkrc 0.The capital of France is Paris.The array leg
Not in this PR: it needs an
npu-holdticket, and at the time of writing/dev/accel/accel0is held by another lane's private serve with no~/NPU-TOKEN(reported to the orchestrator and pi-bonsai). A bounded waiter is armed to run the same five-prompt script the moment the device is free; the result will be reported on #855 either way, and a follow-up commit here if the launch needs a correction the device shows up.Stacked on #885, because the per-row fallback fills the same
M*Nbuffers whose readers #885 fixed.