Skip to content

npu forward: drive the forge q4_1 GEMV ELF family (the pack's own kernels) — #855's launcher ABI - #891

Merged
bong-water-water-bong merged 1 commit into
1bit/855-batched-nstridefrom
1bit/855-gemv-launcher
Nov 3, 2026
Merged

bong-water-water-bong merged 1 commit into
1bit/855-batched-nstridefrom
1bit/855-gemv-launcher

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

What this is

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 the .dynsym symbols backing those arguments are per core — arg0 and arg2 are 1/cores of the stream and of the output, arg1 is the whole activation row. Driving them with the int8 launcher is what made every array run of this pack either fault (the three IO_PAGE_FAULT bursts 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 from arg0 rather than assumed to be four, which is exactly why the first attempt still refused at load. 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'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 _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 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)

  • unit: q4nx_gemv_family() 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.
  • artifacts: the same rule run over the real files — all five pack ELFs → gemv, the 27B's int8 per-tile ELF → not gemv.
  • repo gates: ctest -R "npu_elf_args|npu_design_grid|npu_forward_loader|npu_pack_import|npu_q4nx" 5/5, tools/copyright.py --check rc 0.
  • no host regression: the host-GEMM path on the corrected container still answers AMD's own oracle string, The capital of France is Paris.

The array leg

Not in this PR: it needs an npu-hold ticket, and at the time of writing /dev/accel/accel0 is 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*N buffers whose readers #885 fixed.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 980e3de)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

869 - Partially compliant

Compliant requirements:

  • The PR implements q4nx_elf_arg_sizes() and q4nx_gemv_family() functions to read and validate ELF argument widths
  • The PR modifies I8Ctx::init_elf() to refuse loading when a declared width exceeds the buffer
  • The PR adds tests in tests/npu_elf_args_test.c with hand-built ELF32 fixtures

Non-compliant requirements:

  • None

Requires further human verification:

  • None

671 - Partially compliant

Compliant requirements:

  • The PR enables loading Ryzen AI OGA NPU model packs
  • The PR parses quantized weights into Q4NX container
  • The PR supports the first pack with correct greedy output

Non-compliant requirements:

  • None

Requires further human verification:

  • None

855 - Partially compliant

Compliant requirements:

  • The PR correctly drives the forge q4_1 GEMV ELF family
  • The PR fixes the launcher ABI issue causing wrong tokens

Non-compliant requirements:

  • None

Requires further human verification:

  • None
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Incorrect buffer size calculation for gemv family

The calculation of buffer sizes for the gemv family in I8Ctx::init_elf() may be incorrect. The code uses gemv_w_bytes(KD, ND) to calculate the weight buffer size, but this function assumes that KD and ND are the full dimensions. However, in the gemv family, KD and ND represent the full dimensions of the kernel, while the actual buffer size should be based on the per-core dimensions. This could lead to incorrect buffer allocation and potential memory issues.

bA = mkbo(gemv ? (size_t)KD * 2 : (size_t)MD * KD);
// pb16i int64 mode returns two int32 planes ([M][2N]); allocate for both modes
// so the tensor's own mode byte, not the geometry, decides what is used.
bC = mkbo(gemv ? (size_t)ND * 4 : (size_t)MD * ND * 4 * (pb16i ? 2 : 1));
Potential memory corruption in gemv weight upload

In I8Ctx::upload_tiles_into(), the code assumes that blob is a valid pointer and that blen is less than or equal to bo.size(). However, if blob is NULL or blen is larger than bo.size(), the code could lead to memory corruption or undefined behavior. The function should add proper checks to ensure that blob is valid and blen is within bounds.

bool upload_tiles_into(xrt::bo& bo, const uint8_t* blob, size_t blen) {
    if (!gemv) return false;
    if (blen > bo.size()) {
        std::fprintf(stderr, "  I8Ctx::upload_tiles_into: %zu B of tiles does not fit a %llu B BO\n",
                     blen, (unsigned long long)bo.size());
        return false;
    }
    uint8_t* p = (uint8_t*)bo.map();
    std::memset(p, 0, bo.size());
    if (blob && blen) std::memcpy(p, blob, blen);
    bo.sync(XCL_BO_SYNC_BO_TO_DEVICE);
    return true;
}
Incorrect handling of activation quantization for gemv family

The quantize_bf16 function in I8Ctx assumes that am is always 1, which is correct for the gemv family. However, the code does not properly handle cases where am is not 1, which could lead to incorrect activation quantization. The function should include a check to ensure that am is 1, and if not, it should return an error or handle the case appropriately.

inline uint16_t* quantize_bf16(const float* A, int am, int ak) {
    if (am != 1) { std::fprintf(stderr, "  I8Ctx: the gemv family takes one row per launch (am=%d)\n", am); return Ag; }
    const int n = ak < KD ? ak : KD;
    for (int k = 0; k < n; k++) {
        float v = A[k];
        if (!std::isfinite(v)) v = 0.0f;
        Ag[k] = f32_to_bf16_bits(v);
    }
    for (int k = n; k < KD; k++) Ag[k] = 0;   // K pad columns read as 0, like the container's
    return Ag;
}

@bong-water-water-bong bong-water-water-bong added the tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied label Nov 3, 2026

@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

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) and gemv_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:

  1. 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 needs git 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 plus npu_elf_args_test.
  2. The array leg is not in this PR and the body says so plainly: the /dev/accel/accel0 device 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.

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

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.
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 980e3de

@bong-water-water-bong
bong-water-water-bong merged commit 8cc5f1d into 1bit/855-batched-nstride Nov 3, 2026
3 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/855-gemv-launcher branch November 3, 2026 12:25
bong-water-water-bong added a commit that referenced this pull request Nov 3, 2026
…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 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, 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:

  1. git fetch origin 1bit/855-gemv-launcher fails ("couldn't find remote ref"), so anyone reproducing this needs refs/pull/891/head — which is fine, it is how I built and ran the test above.
  2. 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.

bong-water-water-bong added a commit that referenced this pull request Nov 4, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Review effort 4/5 tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant