Repository navigation
npu-pack: read a THREE-input MatMulNBits, whose implicit zero point is 2^(bits-1) (#1503) - #1790
Conversation
…s 2^(bits-1) (engine#1503)
Qwen3-VL-4B spells its projections as three-input nodes with no qzeros tensor and names them `<proj>.MatMul.weight_Q4`
next to `<proj>.MatMul.weight_scales`:
MatMulNBits inputs = (activations, ...attn.q_proj.MatMul.weight_Q4, ...attn.q_proj.MatMul.weight_scales)
attrs = K 2560, N 4096, bits 4, block_size 32
weight_Q4 [4096, 80, 16] UINT8 N x (K/32) x 16 B (32 quants, 2 per byte)
weight_scales [4096, 80] FLOAT one f32 scale per (row, 32-column block)
`walk_graph` required `ins.size() >= 4`, so it recorded **no matmuls at all** for that pack: `npu-pack info` printed
`matmulnbits_ops 0`, `plan()` fell through to "the graph carries no weights", and the import wrote the embedding and nothing
else -- measured on the real pack as `imported 1 tensors, 0 q4_1 tiles`.
Three changes, and the third is the one that decides correctness:
1. accept three inputs (data, weight, scales) and take `zeros` only when the node has it;
2. resolve both spellings -- `<proj>.MatMulNBits.qweight` (the 1.8/phi-4 form) and `<proj>.MatMul.weight_Q4` -- in the
projection census and in the weight/scales lookup;
3. a node with **no qzeros** takes the implicit zero point `2^(bits-1)`, applied by `implicit_zp_zero()` after the decode.
That is ONNX Runtime's own MatMulNBits rule when `qzeros` is absent, and it is corroborated inside this collection: the
*embedding* component of the same family (GatherBlockQuantized, also no zeros) decodes to ORT's output at cosine
1.00000000 / max|d| 0 at zero point 8, against 0.317 at zero point 0 (engine#1725).
**Measured on the real pack** (`amd/Qwen3-VL-4B-Instruct_rai_1.9.0_npu`, 2.9 GB, on disk under `~/scratch-packs`):
imported 147 tensors, 47480 q4_1 tiles into ~/scratch-packs/qwen3vl4b.q4nx
self-check: the head reproduces the pack's own embedding (as imported, bf16) corr 0.993981 rel_err 0.092550
controls: nibble-swapped corr -0.0699, no zero-point corr 0.3333 (both must fail)
parameters: fp32 scale/zero exact for 100.00% of 32-column groups
The head is tied to the embedding after all, so the pack's own reference grades the decode -- at 0.993981, with the
nibble-swap control failing as it must and the **no-zero-point control at 0.3333**, which is the direct measurement that the
implicit 2^(bits-1) is the right convention for this family.
Test: a crafted three-input node with `[N, K/32, 16]` u8 + `[N, K/32]` f32 asserts that the walker records it (1 of 1), counts
it as carrying no qzeros, and takes K/N/block from the node's attributes --
3-input MatMulNBits: 1 matmul(s), 1 without qzeros, K 64 N 32 block 32
all npu-pack checks passed
Not in this PR: the RoPE question. The same pack ships `cos_cache`/`sin_cache` that no plain theta reproduces (best 0.5918 at
3.2e6 against a declared 10000) while the forward computes RoPE from theta -- its own issue, not a reader symptom.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 87a0dcf
A missing tensor was being read as a missing feature. ins.size() >= 4 recorded no matmuls at all for a pack that names (activations, weight_Q4, weight_scales) with no qzeros — so info printed matmulnbits_ops 0, and the importer wrote only the embedding. The pack is amd/Qwen3-VL-4B-Instruct_rai_1.9.0_npu, bits 4, block_size 32, and a three-input MatMulNBits is a real spelling, not a truncation: a missing qzeros means the implicit zero point, which implicit_zp_zero() then applies.
The correlation claim is carried and it is two-sided against a named reference:
1.00000000andmax|d| 0at zero point 8, against0.317at zero point 0 (measured on [the pack's own embedding])
That is the shape the gate asks for: a number, a reference, and the wrong branch shown failing rather than asserted. It also matches what #1661 established independently for the same family, so the constant is consistent across two PRs written against different packs.
Built and ran at this head:
3-input MatMulNBits: 1 matmul(s), 1 without qzeros, K 64 N 32 block 32
all npu-pack checks passed
The arm asserts the census (matmul recorded, counted as no-qzeros, K/N/block) — so the "0 matmuls" regression cannot come back silently.
One thing worth adding, and it is small: the census function is what info calls, and the arm exercises the census rather than the trace — good — but the PR's own statement is that the failure was visible as matmulnbits_ops 0. A one-line assertion that the imported model's matmul count is non-zero for a three-input pack would tie the census to the write path, which is where the embedding-only output actually happened.
Not verified: the Qwen3-VL pack itself (not on this box); the 1.00000000 / 0.317 figures are the author's measurement, quoted with their zero-point conditions.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 87a0dcf: CHANGES
Checked at 87a0dcf by reading the diff and inspecting the container the body reports (~/scratch-packs/qwen3vl4b.q4nx/model.q4nx, header parsed). The walker change (accept 3 inputs, zeros only when present) is right, and so is the head: corr 0.993981 against the pack's own embedding, with both controls failing. But no layer projection was imported, and the container says it is complete.
1. Every projection lookup misses because of a missing dot. decode_rows falls back to init_of(proj + "MatMul.weight_Q4") / (proj + "MatMul.weight_scales"), and every caller passes proj without a trailing dot (a + "q_proj", m + "gate_proj", … at :3272-3285). So the name looked up is …attn.q_projMatMul.weight_Q4, which doesn't exist. The census in plan() strips ".MatMul.weight_Q4" with the dot, so the two disagree. decode_rows returns false for all 36 × 7 projections. Measured on the author's container:
147 tensors: model.embed_tokens.weight 1, lm_head.weight 1, model.norm.weight 1,
input_layernorm 36, post_attention_layernorm 36, q_norm 36, k_norm 36
tensors containing "_proj": 0 __metadata__.incomplete: []
And "47480 q4_1 tiles" is exactly the head alone: 151,936 × 2,560 / (32 × 256) = 47,480. The self-check passes because it only grades the head, which goes through a separate path.
2. With the dot fixed, the projections would decode with zp 0. The implicit zero point is applied only in the lm_head path (if (!have_zp) implicit_zp_zero(q, mm.bits)). decode_rows looks up only proj + ".MatMulNBits.qzeros", gets nullptr, and decode_matmulnbits then uses zp = 0 for every group. That's the case your own control measures at corr 0.3333. The comment "(implicit zero point, applied below)" in decode_rows describes code that isn't there. Apply implicit_zp_zero(q, bits) there when zt == nullptr (bits from the node, or 4).
3. A projection that fails to decode is dropped silently. decode_rows(...) returns false, the callers ignore it, and incomplete stays empty, so a model directory with no weights for 36 layers says it is whole. That's the outcome the importer's "a refusal writes nothing / --allow-incomplete records the rest" contract exists to prevent (docs/npu.md). Every decode_rows that returns false should go into the refusal list.
Test: the new arm checks only the walker's census (1 matmul, 1 without qzeros, K/N/block). Please add an import arm on a crafted three-input projection that asserts:
- the
…_proj.weighttensor exists in the container; - its zero plane equals
-8·scale; - a missing projection lands in
incomplete.
And re-run the real import, reporting the projection count and a per-projection correlation against a named reference: the pack's own .MatMul.weight_Q4 dequantised by ORT's rule, or the upstream checkpoint.
|
VERDICT: READY @ d95f73b (head The defect is the one that matters most in a reader: The zero-point folding is algebraically correct, which is the part I checked twice: const float zp = float(1 << (bits - 1)); // 8 for 4-bit: the implicit ONNX offset
q.zero[i] -= zp * q.scale[i];Under this file's The fixture is sized like the real one: One sentence I would add to the body: the census change ( Reviewed read-only: the diff and the arithmetic. |
|
VERDICT: READY The measurement is the convincing part, and specifically the control that must fail: Two nits, both the "refuse rather than guess" kindvoid implicit_zp_zero(Q4& q, int bits) {
if (bits <= 0 || q.scale.empty() || q.scale.size() != q.zero.size()) return; // <- silently decodes with zp 0
const float zp = float(1 << (bits - 1));
Neither touches the measured result — they are about the pack nobody has shipped yet. |
|
VERDICT: READY at The bug, and why the old gate hid it
The implicit zero is right, and the guard cannot swallow it
The evidence that makes it load-bearingReal pack: 147 tensors / 47,480 tiles, self-check corr 0.993981 against the embedding — and the no-zero-point control at 0.3333. That control is the whole review: it says what happens if the correction is left out, so the fix is measured rather than asserted, and the crafted arm pins the mechanism without the 3 GB pack. One nit
Not run: the C++ arms (disk 154 G, floor 175 G). They are device- and pack-free. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 87a0dcf — not on quality: this change is already inside #1794, and merging both would apply the same work twice.
Measured, not guessed: both diffs add the same block at the same position in npu/pack_import.cpp —
#1790 line 6 + // implicit zero point, which `implicit_zp_zero()` below applies.
#1794 line 6 + // implicit zero point, which `implicit_zp_zero()` below applies.
#1790 line 24 + // ── The implicit zero point ───────────────────────────────────────
#1794 line 40 + // ── The implicit zero point ───────────────────────────────────────
and both rewrite the walker so a three-input MatMulNBits (<proj>.MatMul.weight_Q4 + .weight_scales, with the implicit zero point 2^(bits-1)) is recorded rather than dropped. #1794 is a superset: it carries this plus the nested rope_parameters.rope_theta lift.
So: close this one and let #1794 carry the 3-input work, or rebase this to drop the shared hunks and keep only whatever is genuinely extra (I could not find anything extra in this diff — its own test arm set is the 3-input arms that #1794 also adds). Two PRs for one walk_graph change is exactly the pattern that cost the fleet four duplicate fixes on 11-08, and here the duplicate would be silent: both are "additive" so the second merge would either conflict or double-count the node.
The substance is good, for the record, and I would say the same on #1794: the measurement that settles it — "1.00000000 and max|d| 0 at zero point 8, against 0.317 at zero point 0" — is what makes this a fix rather than a guess, and the implicit-ZP convention is documented against ONNX Runtime's own behaviour rather than invented.
… dot (engine#1503)
`decode_rows()` builds a weight name as `proj + ".MatMulNBits.qweight"`, which assumes `proj` ends WITHOUT a dot -- true
when it comes from a role name, false when it comes from a plan source string (`graph:model.layers.N.attn.q_proj`). With no
dot the three-input spelling was looked up as `...q_projMatMul.weight_Q4`, never found, and the import silently wrote only
what it could decode:
before: imported 147 tensors, 47480 q4_1 tiles <- 36 layers x 4 norms + embedding + head
after : imported 399 tensors, 491000 q4_1 tiles <- + 36 layers x 7 projections
The pack then served on the device for the first time (all four `kernels-4b-b8` ELFs resolved, prompt 1.37 s, 8 tokens in
4.90 s), and its answer was **garbage** -- which is the next question, not this one: the container is now complete, so a
host-GEMM run of the same prompt separates "our weights/rotary" from "the kernels".
Refs #1790, which fixed the node spelling, the implicit zero point and the census; this is the last link between the plan and
the writer.
… (#1794) * npu-pack: read a THREE-input MatMulNBits, whose implicit zero point is 2^(bits-1) (engine#1503) Qwen3-VL-4B spells its projections as three-input nodes with no qzeros tensor and names them `<proj>.MatMul.weight_Q4` next to `<proj>.MatMul.weight_scales`: MatMulNBits inputs = (activations, ...attn.q_proj.MatMul.weight_Q4, ...attn.q_proj.MatMul.weight_scales) attrs = K 2560, N 4096, bits 4, block_size 32 weight_Q4 [4096, 80, 16] UINT8 N x (K/32) x 16 B (32 quants, 2 per byte) weight_scales [4096, 80] FLOAT one f32 scale per (row, 32-column block) `walk_graph` required `ins.size() >= 4`, so it recorded **no matmuls at all** for that pack: `npu-pack info` printed `matmulnbits_ops 0`, `plan()` fell through to "the graph carries no weights", and the import wrote the embedding and nothing else -- measured on the real pack as `imported 1 tensors, 0 q4_1 tiles`. Three changes, and the third is the one that decides correctness: 1. accept three inputs (data, weight, scales) and take `zeros` only when the node has it; 2. resolve both spellings -- `<proj>.MatMulNBits.qweight` (the 1.8/phi-4 form) and `<proj>.MatMul.weight_Q4` -- in the projection census and in the weight/scales lookup; 3. a node with **no qzeros** takes the implicit zero point `2^(bits-1)`, applied by `implicit_zp_zero()` after the decode. That is ONNX Runtime's own MatMulNBits rule when `qzeros` is absent, and it is corroborated inside this collection: the *embedding* component of the same family (GatherBlockQuantized, also no zeros) decodes to ORT's output at cosine 1.00000000 / max|d| 0 at zero point 8, against 0.317 at zero point 0 (engine#1725). **Measured on the real pack** (`amd/Qwen3-VL-4B-Instruct_rai_1.9.0_npu`, 2.9 GB, on disk under `~/scratch-packs`): imported 147 tensors, 47480 q4_1 tiles into ~/scratch-packs/qwen3vl4b.q4nx self-check: the head reproduces the pack's own embedding (as imported, bf16) corr 0.993981 rel_err 0.092550 controls: nibble-swapped corr -0.0699, no zero-point corr 0.3333 (both must fail) parameters: fp32 scale/zero exact for 100.00% of 32-column groups The head is tied to the embedding after all, so the pack's own reference grades the decode -- at 0.993981, with the nibble-swap control failing as it must and the **no-zero-point control at 0.3333**, which is the direct measurement that the implicit 2^(bits-1) is the right convention for this family. Test: a crafted three-input node with `[N, K/32, 16]` u8 + `[N, K/32]` f32 asserts that the walker records it (1 of 1), counts it as carrying no qzeros, and takes K/N/block from the node's attributes -- 3-input MatMulNBits: 1 matmul(s), 1 without qzeros, K 64 N 32 block 32 all npu-pack checks passed Not in this PR: the RoPE question. The same pack ships `cos_cache`/`sin_cache` that no plain theta reproduces (best 0.5918 at 3.2e6 against a declared 10000) while the forward computes RoPE from theta -- its own issue, not a reader symptom. * npu-pack: read rope_theta from its nested rope_parameters block (engine#1503) Qwen3-VL-4B declares its rotary base ONE LEVEL DEEPER than any spelling this reader knew: text_config.rope_parameters = { mrope_interleaved: true, mrope_section: [24,20,20], rope_theta: 5000000, rope_type: default } with no `rope_theta` at the top level and none at `text_config.rope_theta`, so `read_dims`' 10000 default applied and the importer said what that costs: WARNING: this pack ships precomputed RoPE caches that no plain theta explains (best 0.5918 at 3.2e+06, declared 10000) **The caches are not unexplained -- the theta was not read.** Measured against the pack's own `cos_cache` (262144 x 64, external data, row 1 of position 1): theta 10000: max|cos - expected| 0.088090 theta 5000000: max|cos - expected| 0.000000 <- the pack's own value, exactly theta 3200000: max|cos - expected| 0.005052 So this fixes a **wrong-attention** bug, not a cosmetic warning: 10000 against 5e6 changes every rotary angle. The lift has the same shape and the same guards as the `text_config` one next to it: gaps only, never overriding, and the source string names the block it came from (`+ config.json rope_parameters`). With it, the importer's own check passes: note: rope_theta 5e+06 verified against the pack's cos_cache (corr 1.0000) dims.rope_theta = 5000000.0 Test: a crafted config with the nested block asserts theta 5000000 and the source naming, plus the never-override arm (a top-level 10000 wins over the nested 5e6) -- rope_parameters: theta 5000000 nested, top-level still wins all npu-pack checks passed * npu-pack: the weight lookup normalises the projection name's trailing dot (engine#1503) `decode_rows()` builds a weight name as `proj + ".MatMulNBits.qweight"`, which assumes `proj` ends WITHOUT a dot -- true when it comes from a role name, false when it comes from a plan source string (`graph:model.layers.N.attn.q_proj`). With no dot the three-input spelling was looked up as `...q_projMatMul.weight_Q4`, never found, and the import silently wrote only what it could decode: before: imported 147 tensors, 47480 q4_1 tiles <- 36 layers x 4 norms + embedding + head after : imported 399 tensors, 491000 q4_1 tiles <- + 36 layers x 7 projections The pack then served on the device for the first time (all four `kernels-4b-b8` ELFs resolved, prompt 1.37 s, 8 tokens in 4.90 s), and its answer was **garbage** -- which is the next question, not this one: the container is now complete, so a host-GEMM run of the same prompt separates "our weights/rotary" from "the kernels". Refs #1790, which fixed the node spelling, the implicit zero point and the census; this is the last link between the plan and the writer. * npu-pack: apply the implicit zero point to the PROJECTIONS too -- and the 4B pack answers "Paris" The head path and the projection path share `decode_matmulnbits`, but only the head path applied `implicit_zp_zero()` when a node carries no qzeros. So every projection of a three-input pack was decoded with zero point 0 while the head was decoded with 2^(bits-1) -- a container that imported completely (399 tensors, 491,000 tiles), served on the device, and answered gibberish on **both** paths, which is what made it look like a kernel or rotary fault: device, before: "West什么呢ưởng中国联通nioUnnamed violateIOD" host, before: "chu cele隐ntlinskyDupturnstilefarewandMspacad在市场上" After applying the same rule in `decode_rows()`: host : " Paris. The capital of Spain is Madrid. The capital of" (12 tokens, 74.6 s = 0.16 tok/s) device : " Paris. The capital of Spain is Madrid" (8 tokens, 5.10 s = 1.57 tok/s, prompt 1.47 s) Both paths now answer the known-answer prompt, and the device is ~10x the host-GEMM rate on the same container -- which also shows the first gibberish was never the kernels. The lesson worth keeping: a decode convention applied at one call site and not its twin produces a *plausible* container with one consistent self-check (head-vs-embedding still read 0.993981, because both suffered the same defect) and garbage text everywhere. The control that would have caught it earlier is the host-GEMM run: gibberish there too means the container or the forward, never the device. * npu-pack: the external-data container carries a nested mRoPE declaration My own review of #1794. Reading the theta from `text_config.rope_parameters` fixes the number, but the container is what the forward reads, and the forward's `value_for_top()` (`q4nx_semantics.c:121`) returns only depth-1 keys. `import_pack()` starts its config from `load_geometry()`, which the lift fills -- so that writer already carries `mrope_interleaved`/`mrope_section`. `import_external()` (the 1.9 external-data genre, Qwen3-VL-4B's) starts from an EMPTY `json c` and writes a fixed key list, so the declaration was dropped and the pack would be served as plain RoPE: right theta, wrong axis layout, bit-identical for a text-only prompt and ~0.29 rad off once image tokens are in it (the failure `q4nx_semantics.c` documents for engine#1311). So this copies those two keys from `load_geometry()` in the external-data writer, only when declared. No arm: the crafted external-data fixture in `npu_pack_import_test` REFUSES by design ("this importer has no writer for that layout yet", engine#1503), so `import_external()` is not reachable from it -- I tried an arm, hit the refusal, and removed it rather than leaving a test that cannot fail. The path is exercised only by the real 1.9 pack leg, so the assertion belongs there or in a future external-genre writer test; saying that is better than an arm that proves nothing. Verified: full build, `npu_pack_import_test` -> `all npu-pack checks passed`. Refs #1794, #1311, #1503 --------- Co-authored-by: pi-npufeat <npufeat@strixhalo> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Refs #1503. Qwen3-VL-4B spells its projections as three-input nodes with no qzeros tensor:
walk_graphrequiredins.size() >= 4, so it recorded no matmuls at all for that pack:infoprintedmatmulnbits_ops 0,plan()fell through to "no graph weights", and the import wrote the embedding and nothing else — measured asimported 1 tensors, 0 q4_1 tiles.Three changes, and the third decides correctness:
zerosonly when the node has it;<proj>.MatMulNBits.qweight(1.8/phi-4 form) and<proj>.MatMul.weight_Q4— in the projection census and the weight/scales lookup;2^(bits-1)viaimplicit_zp_zero(). That is ONNX Runtime's own MatMulNBits rule whenqzerosis absent, and it is corroborated inside this collection: the embedding component of the same family (GatherBlockQuantized, also no zeros) decodes to ORT's output at cosine 1.00000000 / max|d| 0 with zero point 8, against 0.317 at zero point 0 (npu-pack: the gemma-4 embedding component is f32 and unquantised, so the 1.9 packs stop at the reader's shape (4-bit table + scales) #1725).Measured on the real pack (2.9 GB, on disk under
~/scratch-packs):The head is tied to the embedding after all, so the pack's own reference grades the decode: 0.993981, with the nibble-swap control failing as it must and the no-zero-point control at 0.3333 — the direct measurement that the implicit
2^(bits-1)is this family's convention.Test: a crafted three-input node with
[N, K/32, 16]u8 +[N, K/32]f32 asserts the walker records it (1 of 1), counts it as carrying no qzeros, and takes K/N/block from the node's attributes:Not in this PR: the RoPE question. The same pack ships
cos_cache/sin_cachethat no plain theta reproduces (best 0.5918 at 3.2e6 against a declared 10000) while the forward computes RoPE from theta — that needs its own answer before any token is trusted, and it is not a reader symptom.