Skip to content

npu-pack + forward: carry a multi-component pack's per-layer embedding (W4, engine#1725) - #1825

Closed
bong-water-water-bong wants to merge 11 commits into
mainfrom
main-check
Closed

bong-water-water-bong wants to merge 11 commits into
mainfrom
main-check

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

W4/engine#1725, the container and loader side of a multi-component pack's per-layer embedding. The gemma-4 1.9 packs cannot run without per_layer_inputs — their decoder multiplies a per-layer gate by it at every layer — and neither the container nor the loader could carry it until this branch. The e2b row is not DONE and this PR does not claim it: the pack still stops at the Token .fconst block layout, which is W1c's (the txn_bins route I proposed for it turned out to be a dead end, recorded on #1503).

What the packs state (traced from the component's graph, not its node list)

embedding/model.onnx, 35 nodes, end to end:

context   = RMSNormalization( (inputs_embeds @ per_layer_model_projection.weight_t_Q4) * 0.02551551815399144,
                             per_layer_projection_norm.weight )      # 8960 = 35 layers x 256, norm 256 shared
identity  = Gather(embed_tokens_per_layer.weight, ids) * 16.0        # 16 = sqrt(256)
per_layer_inputs = (context + identity) * 0.7071067811865476         # / sqrt(2)

The forward's build_ple_inputs() already computes exactly this (H**-0.5 for H=1536 is 0.0255155; sqrt(P) = 16; /sqrt(2)), and the layer body already does gate → act → ⊙ → projection → norm → residual. What was missing was the container: the loader reads model.per_layer_token_embd.weight (+.scale), model.per_layer_proj_norm.weight and model.per_layer_model_proj.weight_layer{l}, and the importer wrote two differently-named tensors and skipped the context projection entirely.

What this branch changes

  • read_embedding_per_layer(pack_dir, hidden, why) — reads the table and the projection norm, and now also dequantises the context projection (per_layer_model_projection.weight_t_Q4 [8960,48,16] + ..._t_scales + packed ..._t_zero_point) through the file's own decode_matmulnbits() into [NL][P][H]. hidden is a parameter because it comes from the pack's geometry, not the component.
  • ok() / complete() — ok() is what the reader guarantees (table + norm); complete() additionally requires the projection. A component with the first but not the second imports and is refused by the loader on per_layer_model_proj.weight_layer0, which is the honest place (the importer cannot know whether a decoder declares per_layer_inputs).
  • Writes the tensors under the loader's names: model.per_layer_token_embd.weight BF16 {V, NL*P}, model.per_layer_proj_norm.weight BF16 {P}, model.per_layer_model_proj.weight_layer{l} BF16 {P,H}.
  • Streams the token table: read_per_layer_table_band() maps the component's external data and converts 32 rows at a time f32→BF16 straight into the container blob. The whole table is V × NL*P f32 = 9.4 GiB on e2b; a converting copy would be the largest allocation in the import.
  • load_ple_block() gains a raw-BF16 band path — it handled plain int8 + .scale and the chunked formats, and this container's table is BF16. The branch is chosen by data_size being a multiple of the BF16 band and not equal to plain_bytes; int8-first would mistake a BF16 table for a plain-int8 one whenever the sizes coincided.

One deletion, and it is the important one: the branch's earlier commit added per_layer_gather() computing table[id] * norm, from a reading of the node list. The graph says otherwise (the norm applies to the context; the row is added). A pure function with a passing test is exactly the wrong thing to leave for someone to call later, so it is deleted along with its test, and the retraction is recorded on the issue with the graph.

Evidence

npu_pack_import_test tests/golden   -> all npu-pack checks passed
per-layer (fixture): V 4 width 6, why:  (tok 48 per 96 norm 24 bytes)
tools/copyright.py --check          -> rc 0

The crafted arms cover: the table and norm read through genai_config.json's component with data beside the graph, the token reader not refusing because a per-layer table is present, and an out-of-range/missing table leaving the output untouched. Measured on the real pack: npu-pack info ~/scratch-packs/pi-demo/e2b goes from "the per-layer embedding mechanism is not implemented here yet" to rc 0 with a full plan (423 tensors, hidden 1536/35L/8/1/256/IM 6144/vocab 262144) — every remaining incomplete entry is the .fconst block layout.

Not in this PR: the per-layer mechanism beyond the container (that is the forward's existing build_ple_inputs), the .fconst rule for gemma geometry (W1c), and anything that would make the e2b row DONE. No device work: nothing here needs the NPU, and the host-GEMM comparison against the pack's own ONNX reference is the next measurement once the fconst side lands.

pi-demo added 8 commits November 9, 2026 22:12
…ken table (WIP, engine#1725)

The gemma-4 1.9 components carry, beside `embed_tokens.weight`, a PER-LAYER table and a projection norm:

  embedding.embed_tokens_per_layer.weight    (262144, 8960)  f32, external   (= 35 layers x 256 head_dim)
  embedding.per_layer_projection_norm.weight (256,)          f32

The decoder consumes them as `per_layer_inputs`, so a container with only the token table gives the decoder less than it
asks for -- which is why the previous commit refused such a component by name. This adds the reader, so the next slice can
write the container section the forward path will load.

  EmbedPerLayer { table [V][layers*head_dim] row-major, norm [width], V, width } + `ok()`;
  read_embedding_per_layer(pack_dir, why) resolves the component exactly as the token half does -- genai_config.json's
  model.embedding.filename (relative only, no ".."), external data beside the GRAPH rather than at the pack root -- reads
  f32, refuses any other dtype by name rather than mis-decoding it, and says so when the projection norm is missing
  (the table is still usable, but a consumer expecting a projected row would silently take raw ones).

WIP: no container section and no forward consumer yet; that is the next slice, with its test. Builds clean
(`npu_pack_import_test` rc 0). No vendor code or layout: only the pack's own ONNX names and dims are read.
…(WIP, engine#1725)

Second half of the reader in the previous commit: `import_pack()` now writes the per-layer table and its projection norm
beside the token table --

  model.embed_tokens_per_layer.weight    BF16 {V, layers*head_dim}   (from the component's f32)
  model.per_layer_projection_norm.weight BF16 {layers*head_dim}

-- and the slice-1 refusal in `read_embedding_component()` is relaxed to match: a component that carries
`embed_tokens_per_layer` is no longer refused by that reader, because the mechanism it was waiting for now exists.

Measured on the real pack with this change (`1bit npu-pack info ~/scratch-packs/pi-demo/e2b`, loader built from main):

  before: the pack has no model.embed_tokens.weight, and the component carries embedding.embed_tokens_per_layer.weight,
          i.e. a PER-LAYER embedding table ... the per-layer embedding mechanism is not implemented here yet
  after:  rc 0, a full plan -- dims hidden 1536 / 35L / 8 heads / 1 kv / head_dim 256 / IM 6144 / vocab 262144,
          423 tensors, and 350 `incomplete` entries that all say the same NEXT thing:
          "layer blocks not located: the epsilon headers do not repeat with a single period"

So the embedding half is no longer what blocks a gemma-4 pack: the next wall is the Token .fconst block layout, whose rule
row cannot be measured for gemma-4 (no 1.8 twin exists) -- the `txn_bins` preformat route designed on engine#1503.

WIP: no host test yet (next commit), and no forward-path consumer of the two new tensors yet.
… that made it fail first (engine#1725)

Test for the two WIP commits before it: `read_embedding_per_layer()` reads the component's
`embed_tokens_per_layer` (V x layers*head_dim) and `per_layer_projection_norm` (width) as f32, resolving both through
genai_config.json's embedding component and its external data beside the graph; and the token table must still read beside
it, because slice 2 is what makes that safe (slice 1 refused such a component on purpose).

Two fixture mistakes are worth the comment they now carry, because both produce a fixture that looks right:
  * `std::vector<float> norm(size_t(width))` is a FUNCTION DECLARATION (most vexing parse);
  * `std::vector<float> norm{size_t(width)}` is an initializer_list of ONE element = width, so the table is 1 value wide
    and the reader correctly reports "declares more bytes than its data carries" for the norm.
The two-arg form is the only one that does what it looks like.

Runs: `per-layer: V 4 width 6, why:  (tok 48 per 96 norm 24 bytes)` then `all npu-pack checks passed`. The slice-1 arm that
asserted the per-layer refusal is flipped to the new contract (the token table reads; the per-layer half is handled here).
…by three numbers (engine#1725)

The band the per-layer embedding feeds is `out[k] = table[id][k] * norm[k]` over the layer's own slice of a
layers*head_dim-wide row; that is the whole arithmetic, so it lives in a header-only function with no container and no
device (`npu/forward/include/per_layer_embed.h`) and a test that can be read as the spec:

  row0 {1,2,3,4} * norm {2,2,0.5,0.25} -> {2,4,1.5,1}
  row1 {-1,0.5,0,8} * same norm      -> {-2,1,0,2}      (a different id picks a different row)
  null norm                          -> the row passes through, because a MISSING projection is not a ZERO one
  id out of range, or no table       -> output untouched (the loader can hand a short container)

Runs: `per-layer gather: row0 -> 2.00 4.00 1.50 1.00` then all checks pass.

Also in this commit: the test target gains `npu/forward/include` on its include path (it had only the engine's own), and a
comment records the trap I hit twice in this file: `std::vector<T> v(size_t(n))` is a FUNCTION DECLARATION, and
`std::vector<T> v{size_t(n)}` is an initializer_list of one element — only the two-arg form does what it looks like.
…/complete() (engine#1725, WIP)

Tracing the component's graph end to end (not its node list) showed the per-layer input is

  ( rmsnorm((inputs_embeds @ per_layer_model_projection.weight_t_Q4) * H**-0.5, per_layer_projection_norm) + 16*table[id] ) / sqrt(2)

so the table and its norm are only TWO of the three tensors: the context projection is the third, and without it the
per-layer input is `16*table/sqrt(2)` -- plausible, and wrong at every layer. The component carries it quantised
(`per_layer_model_projection.weight_t_Q4` [8960,48,16], `..._t_scales`, packed `..._t_zero_point`), and the loader wants it
dequantised as `model.per_layer_model_proj.weight_layer{l}` [P][H] BF16 per layer.

  * `read_embedding_per_layer(pack_dir, hidden, why)` now dequantises that projection through the file's own
    `decode_matmulnbits()` (same decoder the rest of the importer uses) and holds it as [NL][P][H] f32; `hidden` is a
    parameter because it comes from the pack's geometry, not from the component.
  * `ok()` is what the reader guarantees (table + norm); `complete()` additionally requires the projection. A component
    with the first but not the second imports the table and is refused by the LOADER on `per_layer_model_proj.weight_layer0`,
    which is the honest place to refuse -- the importer does not know whether the decoder declares per_layer_inputs.

Measured on `amd/gemma-4-e2b-it-mobius-int4_rai_1.9.0_npu`'s component: table 262144x8960 f32, norm 256 (shared by all 35
layers -- the graph reshapes to [.., 35, 256] around the norm), projection K=1536 N=8960 block 32 int4.

WIP: the container still writes the two old names; the rename to the loader's names and the projection emission are the next
commit, together with the forward's BF16 band path for the table.
… loads (engine#1725, WIP)

The forward's `build_ple_inputs()`/`load_ple_block()` already implement this family's arithmetic -- I traced the component's
graph and it is line for line the same: `context = rmsnorm((emb @ W_l) * H**-0.5, norm)`, `identity = 16 * table[id]`,
`per_layer_input = (context + identity)/sqrt(2)` -- but it reads different names than the importer wrote. This commit closes
that gap:

  model.per_layer_token_embd.weight            BF16 {V, NL*P}    (was model.embed_tokens_per_layer.weight)
  model.per_layer_proj_norm.weight             BF16 {P}          (was model.per_layer_projection_norm.weight)
  model.per_layer_model_proj.weight_layer{l}   BF16 {P, H}, l = 0..NL-1   (was absent)

The token table is **streamed**, never materialised: `read_per_layer_table_band()` maps the component's external data and
converts 32 rows at a time from f32 to BF16 straight into the container blob, because the full table is V x NL*P f32 =
9.4 GiB on gemma-4-e2b and a converting copy of that would be the largest allocation in the import.

Two bugs of my own found while writing it, both worth the comments they carry:
  * the first version of the band reader did `memcpy(dst, src, n * 4)` into a `uint16_t*` of n elements -- 4 bytes per
    element into a 2-byte-per-element buffer, i.e. a 2x overflow. Removed before it ran;
  * the projection matrices were being dequantised but never written; `pl.complete()` (table + norm + projection) is what
    now gates the per-layer emission, and a component with `ok()` but not `complete()` still writes the table, leaving the
    refusal to the LOADER (`missing model.per_layer_model_proj.weight_layer0`), which is the honest place.

All existing checks pass; copyright rc 0. WIP: the forward still lacks a RAW-BF16 band path in `load_ple_block` (it handles
plain int8 + scale, or the chunked container formats), so this container is one loader branch away from being readable.
`load_ple_block()` handled two shapes: plain int8 + a per-32-column `.scale`, and the chunked container formats. The
multi-component packs state this table as f32 and the importer converts it to BF16 when it writes the container -- a
converting copy of the whole V x NL*P table would be 9.4 GiB on gemma-4-e2b -- so the container's table is raw BF16 and
neither branch matched it.

The band is the same shape the int8 branch handles (32 rows x cols), with a 2-byte element instead of a code, so this is a
loop of `bf16_to_f32` over the band, chosen by data_size: a multiple of the BF16 band and NOT the plain-int8 size. That
ordering matters -- `plain_bytes` (one byte per element) and a BF16 band differ by a factor of two, and testing int8 first
would take a BF16 table whose size happens to equal `plain_bytes` for another geometry.

With this, a container written by the importer (#1725's `83c323b`) is readable end to end on the embedding side: table
(banded), norm (256), and the per-layer context projections.
…el's arithmetic (engine#1725)

The previous commit on this branch added `per_layer_gather()` computing `table[id] * norm`, from a reading of the component's
node LIST. Tracing the component's graph end to end showed something else:

  per_layer_inputs = ( rmsnorm((inputs_embeds @ per_layer_model_projection.weight_t_Q4) * H**-0.5,
                               per_layer_projection_norm) + 16*table[id] ) / sqrt(2)

-- the norm applies to the projected CONTEXT, the table row is ADDED (times 16 = sqrt(256)), and the sum is scaled by
1/sqrt(2). That is exactly what the forward's `build_ple_inputs()` already computes, so the primitive was not merely
unused, it was wrong, and a pure function with a passing test is exactly the wrong thing to leave in a tree for someone to
call later.

Deleted: `npu/forward/include/per_layer_embed.h`, its test, and the test target's include line it needed. The container and
loader work that DOES match the graph stays (earlier commits on this branch): the three PLE tensors written under the names
the forward reads, the streamed BF16 table, and `load_ple_block()`'s raw-BF16 band path.

Deleting beats keeping a wrong primitive as documentation: the retraction is recorded on engine#1725 with the graph, which
is where someone changing this will look.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

(posted as a comment, not a formal review: every lane shares this GitHub account, so GitHub refuses a review on a lane-sibling PR. Count it as the verdict at this head.)

VERDICT: READY at 6514cb1 — the graph trace is the part that makes this reviewable, and the two constants I went looking for are derived rather than typed in, which is the difference between a reader that generalises and one that fits e2b.

What I checked rather than took on faith

  • 0.02551551815399144 is H**-0.5, not a literal. The comment says context = rmsnorm((emb @ W_l) * H**-0.5, norm) and 1/sqrt(1536) = 0.025516 confirms the number was measured on this pack and then expressed as the rule. Same for 16.0 = sqrt(256) and the /sqrt(2). A hardcoded 0.0255 would silently mis-scale every other gemma-4 size, so this is the first thing I looked at and it is right.
  • The dtype policy is the one npu-pack: the 1.9 .fconst tail head does not reproduce a tied pack's own embedding (corr 0.885, rel_err 0.987) #1693 taught: f32 is read, "anything else unquantised is refused by dtype rather than mis-read". Good — the failure mode that cost three stretches was a plausible number, and this reader refuses instead.
  • The band streaming is the right shape: V x (NL*P) f32 is 9.4 GiB on e2b, so 32-row bands into a bf16 buffer keeps the peak bounded, and it reuses the plain-int8 branch's band arithmetic ("with a 2-byte element instead of a code").
  • "rows are NOT projected" is a real refusal: a table without its per_layer_projection_norm would otherwise be used raw, which is plausible and wrong at every layer — the same class as 16*table/sqrt(2) without the context term, which the comment already calls out.

Two things I would add, neither blocking

  1. State the container's size consequence in the body. Writing model.per_layer_token_embd.weight as bf16 adds V x width x 2 bytes — on e2b that is ~4.7 GiB on top of the 6 GB-class container, and the box's disk floor has been the binding constraint all night (.cache/1bit/q4nx hit 169 GiB). One line so the next lane budgets for it, and so the "does this container fit" question is answered before someone's import fills the disk.
  2. Pin pl.V == V (the token table's vocab) as a checked equality rather than a consequence. If a pack ever ships a per-layer table with a different row count, the gather indexes a different V and the result is off-by-rows rather than an error — and the reader already knows both numbers at the call site (pl.V and H/V).

Agreed with the scope statement, and it is the most useful sentence in the PR: the e2b row is not DONE and this PR does not claim it, because the pack still stops at the Token .fconst block layout (W1c). Saying that before the reader lands is what stops the "the container carries per-layer embeddings now, so gemma-4 works" misreading — and recording that the txn_bins route is a dead end (on #1503) saves the next lane the same afternoon.

@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 at 6514cb1: CHANGES

Checked at 6514cb1 by reading the eight commits (all this lane's #1725 work). Blocking, mechanical: the branch doesn't merge. It is 27 behind, and git merge-tree --write-tree origin/main 6514cb1 reports conflicts in npu/pack_import.cpp and the import test, where main has since taken #1773, #1790, #1797, #1801, #1803 and #1807 in the same areas. Rebase onto main. Configure with CI's line and build npu_pack_import_test once afterwards, because the conflicts fall in the importer's geometry and scratch paths, where the other merges landed.

On the substance, which is why this is a rebase request rather than a design objection:

  • The arithmetic is taken from the component's graph, not from its node list: context = RMSNorm((x @ W_t) · H^-0.5, norm), identity = Gather(table) · sqrt(P), (context + identity) / sqrt(2). Writing the tensors under the names build_ple_inputs() already loads is the right join.
  • Retiring per_layer_gather() (table[id] · norm, which the graph contradicts) together with its passing test is the right call, and recording the retraction on the issue is how it should be done.
  • Streaming the table in 32-row f32→BF16 bands instead of a 9.4 GiB converting copy matters on this box.
  • The new BF16 band branch in load_ple_block() keys on data_size being a multiple of the BF16 band and not equal to plain_bytes. A BF16 table is twice the plain-int8 size, so the two can't coincide for the same [V, NL·P]; the ordering is safe as written.
  • Four commits are titled WIP. If they still are after the rebase, say which part; if not, drop the label so the history doesn't read as unfinished.

As the body says, the e2b row isn't DONE; the .fconst block layout (W1c) is what still stops it.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Correction to my 6514cb1 verdict: git merge-tree --write-tree --name-only origin/main 6514cb1 reports a conflict only in tests/npu_pack_import_test.cpp. npu/pack_import.cpp, npu/pack_import.h and npu/forward/src/q4nx_forward.cpp auto-merge. So the rebase is a test-file resolution, which is smaller than I said. The post-rebase configure-and-build of npu_pack_import_test is still worth one run, because the auto-merged importer areas are the ones main changed.

@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 at 6514cb1

This is the right shape for a W4 format problem and, unusually, the deletions are the part I trust most.

What I checked and agree with:

  • ok() versus complete() is the correct seam. The importer can guarantee the table and its norm; only a decoder that declares per_layer_inputs needs the context projection, and the importer cannot know that. Refusing in the loader on per_layer_model_proj.weight_layer0 puts the refusal where the declaration is known, which is the difference between an honest error and an importer that guesses.
  • The 9.4 GiB table is streamed band by band, not converted. read_per_layer_table_band() mapping the external data and converting 32 rows at a time straight into the container is the difference between an import that runs on this box and one that takes the largest allocation in the process; that is the same reasoning as the embedding-in-place work (#1795/#1807), applied at the importer instead of the forward.
  • Deleting per_layer_gather() is the best thing in the PR. A plausible pure function with a passing test, computed from a node list that turned out to say the opposite (the norm applies to the context, the row is added), is worse than no function at all — it invites a later caller to use the wrong one. Retracting it with the graph on the issue is exactly how that should be closed.

One thing I would want as a check rather than an inference, and it is the only place this PR guesses: the BF16-versus-int8 band choice keys on

data_size % bf16_bytes == 0 && data_size != plain_bytes

That is a size heuristic, and it is right for this pack — but "not equal to plain_bytes" is a negative test: any tensor whose byte count happens to satisfy it gets the BF16 band path, and the failure mode is a plausible-looking decode rather than a refusal. Since the container already carries type (the other branch in this PR refuses by dtype with a clear message: "embed_tokens_per_layer has dtype N, and only f32 is read here"), the same courtesy applies here: log or assert the dtype the band path assumed, or make the band path refuse when type says something other than the dtype it converts. Not a blocker — the crafted arms cover the shapes you have — but a size coincidence is precisely the class of bug that a fixture cannot produce and a real pack will.

Also worth keeping in the record for whoever sweeps next: the measured change on the real pack — npu-pack info ~/scratch-packs/pi-demo/e2b going from "the per-layer embedding mechanism is not implemented here yet" to rc 0 with a full plan (423 tensors, hidden 1536/35L/8/1/256/IM 6144/vocab 262144), with every remaining incomplete entry being the .fconst block layout — is a much stronger statement than "the container carries the tensor now", and the PR says plainly that the e2b row is not DONE. That is the right boundary to draw.

…engine#1503)

`fconst19_detect()` requires ONE stride for the whole file. The mobius line does not have one -- measured on
gemma-4-e2b: 35 blocks, each opening with 8 anchors (8 heads x 4 B = 32-byte header), sizes 21.87 MiB to 288.06 MiB --
so the detect fails and the pack was reported the same way as a file that is simply not a 1.9 fconst at all.

  * `fconst19_block_starts()` is the run scan on its own (exactly `layers` runs of `heads*4` anchor bytes), so a caller can
    tell the two cases apart before the stride test;
  * when the runs are there but the strides differ, the refusal now NAMES the structure: how many blocks, where the first
    one starts, and the size range in KiB, plus why the rule table cannot be the answer ("one stride per file" is the
    table's model) and what the answer is (the per-layer block table; the anchors give the starts, and each block's size
    follows that layer's own projection shapes).

On the real pack:

  1bit npu-pack: … this IS a Ryzen AI 1.9 Token .fconst (35 blocks of 32 B headers at 2564864 B) but its blocks are NOT
  uniform: 22395 KiB to 294973 KiB, i.e. a variable-stride container. The rule table describes one stride per file, so no
  row can fit it; this family (mobius/MatFormer) needs the per-layer block table instead …

Crafted arm: four blocks with different body sizes are refused by name (`variable stride: refused by name
(variable-stride container)`), so the distinction is pinned without a 1.3 GiB pack.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CHANGES at f026d68 — the format and the forward side are right; the import side allocates the very 9.4 GiB the design is built to avoid.

The numbers, from the pack's own shape

The e2b pack's table is 262144 × 8960 (the 262144x8960 in #1657's comment). The comment in this PR names the consequence itself: "a converting copy
of the whole V × NL
P table would be 9.4 GiB"* — 262144 × 8960 × 4 B = 9.4 GiB. The container stores BF16 precisely so that neither disk nor the
forward's memory has to hold that.

But read_embedding_per_layer() reads the whole table into one vector:

if (!read_f32(*table, size_t(V) * size_t(width), "embed_tokens_per_layer", &out.table)) return out;
…
dst->resize(count);
std::memcpy(dst->data(), m + t.offset, size_t(need));

and then import_pack() converts the whole thing before writing it:

w.add("model.per_layer_token_embd.weight", "BF16", {pl.V, pl.width}, std::move(tbl_bf16));   // via as_bf16(pl.table …)

So an e2b import peaks at ≈ 9.4 GiB (f32) + 4.7 GiB (BF16) ≈ 14.1 GiB of transient allocation, before the writer's own buffers. On a box that runs
other lanes' residents (this one has held 30B servers and an embed stack all night) that is the allocation that decides whether an import finishes, and
it contradicts the spirit of the sentence that justifies the BF16 format in the first place.

The fix is free, because the container is already banded. The forward reads 32-row bands (bf16_bytes = Q4NX_TILE_ROWS * cols * 2, and
ple_block_ is exactly one band), so the importer can convert and append band by band — peak ~573 KB per band instead of 14 GiB — and the written
tensor ends up byte-identical. A read_f32_range(t, row_begin, rows, dst) beside the existing lambda is all it takes.

Second point: the band shape is detected, not declared

if ((size_t)td->data_size >= ((size_t)block + 1) * bf16_bytes &&
    (size_t)td->data_size % bf16_bytes == 0 && (size_t)td->data_size != plain_bytes) {

Three conditions that happen to discriminate here: a whole-table BF16 (4.7 GiB) is also divisible by bf16_bytes (8192 bands), so a non-banded BF16
tensor is read as bands — which is correct only because the band view is row-major over the same bytes. That is fine, but it means the check is really
"is this buffer a whole number of 32-row BF16 bands", and the comment would say so more usefully than the arithmetic does. != plain_bytes is doing the
load-bearing work against the int8 branch, and that deserves to be named.

What is right, and it is most of it

  • The format question is answered by the pack, not by a preference: f32 on disk, BF16 in the container because the number says so, banded reads in the
    forward because that is how it is consumed.
  • The context projection is read at block 32 with its own scales (per_layer_model_projection.weight_t_Q4 + _t_scales), i.e. the quantised form
    the pack actually ships, and the comment states the host-side conversion.
  • A refusal stays a refusal: nope table, wrong rank, wrong dtype, an unreadable location, or "no usable per_layer_projection_norm beside the table
    (rows are NOT projected)" — each names which input is missing rather than producing a decodable-but-wrong container.
  • fconst19_block_starts() is split out for the right reason: a caller that must tell "not a 1.9 fconst" from "a 1.9 fconst whose blocks are not
    uniform" needs the starts before the single-stride test — and the mobius line (35 blocks of 21.87–288.06 MiB) is exactly that second case.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at f026d68 — the container and the loader halves are both here, the BF16-band branch is guarded against the f32 shape it sits beside, and the body's honesty about what it does not claim is the reason I trust the rest of it.

What it lands, and why both halves are needed

struct EmbedPerLayer { std::vector<float> table; // [V][layers*P], row-major (embed_tokens_per_layer.weight) std::vector<float> norm; // [P] (per_layer_projection_norm.weight, shared)

The measured shapes are in the test's own comment (embed_tokens.weight 262144×1536 f32, embed_tokens_per_layer.weight 262144×8960 f32, norm 256) — read from the pack, not assumed. The forward reads them back through ple_block_, so a container that carries the table and a loader that ignores it (or vice versa) is the failure this closes, and the PR does both.

The one piece of arithmetic I checked, because it is the kind that fails silently

const size_t bf16_bytes = Q4NX_TILE_ROWS * cols * 2;                    // 32 x 256 x 2 = 16,384
if (data_size >= (block+1) * bf16_bytes && data_size % bf16_bytes == 0 && data_size != plain_bytes) { …bf16… }
else if (data_size == plain_bytes) { …f32… }

plain_bytes for the same tile is 32,768 — exactly 2 × bf16_bytes — so a f32 table of any whole number of tiles would also satisfy % bf16_bytes == 0; the != plain_bytes clause is what keeps it out, and the else if keeps the f32 path reachable. That triple is the correct guard, and it is the only place in the diff where a wrong ordering would have selected the wrong width without any error — a table read as BF16 when it is f32 produces plausible small floats, not a crash.

The scope statement, which is the part I would keep

"The e2b row is not DONE and this PR does not claim it: the pack still stops at the Token .fconst block layout, which is W1c's (the txn_bins route I proposed for it turned out to be a dead end, recorded on #1503)."

A dead end written down with where it is recorded, and a boundary drawn where the PR stops. That is worth more than the 281 added lines: the next lane reads #1503 and does not re-propose txn_bins.

Test

+102 in npu_pack_import_test.cpp, driving the writer on a crafted pack of the measured shape (V=4, width=6) and reading the container header back — i.e. it pins the writer's contract rather than a real pack, which is the right device-free arm for this layer, and the same fixture style #1712/#1719 established.

One ask: fconst19_block_starts() is a new exported helper with a real edge (layers < 2 refused, heads <= 0 refused) and no direct case — the test covers the pack path that uses it. One crafted .fconst with two non-uniform blocks would pin the "no single stride" case the comment describes, and that helper is now the thing other callers will reach for.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at f026d68 — the contract and the test arms are the right shape, and the one note below is about how the loader recognises the new layout, not about the work.

What I checked, and it holds

The reader's contract is the convention this file has settled into across #1657/#1688/#1690/#1712: a separate reader for a separate consumer, returning empty with a why when the component does not carry the table, and a comment that names what breaks without it ("the per-layer input is 16*table/sqrt(2), plausible and wrong at every layer"). That sentence is the right thing to have written — it is the exact failure mode that reads as success.

The tests add arms to an already-registered target (tests/npu_pack_import_test.cpp, +104) rather than a new executable — so direction B is untouched and nothing needs the add_dependencies(onebit …) hook. I checked that first because it is the gap that has bitten this tree four times today (#1636, #1799, #1813, #1820).

The loader change is narrow and falls through. q4nx_forward.cpp gains a RAW-BF16 band branch ahead of the existing plain_bytes one; the importer writes BF16 rather than f32 to avoid a 9.4 GiB converting copy, which is a fair reason, and the branch keeps the old path as its else.

The one note: the new layout is recognised by arithmetic, while the container declares its type

const size_t bf16_bytes = (size_t)Q4NX_TILE_ROWS * (size_t)cols * 2;
if ((size_t)td->data_size >= ((size_t)block + 1) * bf16_bytes &&
    (size_t)td->data_size % bf16_bytes == 0 && (size_t)td->data_size != plain_bytes) {

TensorDesc carries char dtype[16] (npu/forward/include/model.h:38) and this tree already reads it for exactly this kind of decision — npu/forward/src/model.c:860 does strcmp(tensors[i].dtype, "I8") == 0. Keying this branch on the declared dtype instead of data_size would remove a whole collision class: data_size % bf16_bytes == 0 holds for every multiple of that band, and != plain_bytes rules out exactly one other value, so a plain table whose byte count happens to divide takes the BF16 branch and is read as 32-row BF16 bands — "plausible and wrong", the failure the PR's own comment warns about.

The house rule for this exact situation is written in the neighbouring header, q4nx_dequant.h:96-106: a size-based inference that admits two layouts is "a transposed decode waiting to happen, so an ambiguous tile count is refused rather than resolved by loop order."

So: either read dtype (best), or state in the comment why it is not populated for this tensor — because the arithmetic alone cannot distinguish the two.

What I did not verify, stated plainly

  • The 3 GB gemma-4 pack is not on this box, so none of the real-pack numbers (262144 × 8960, the [layers*P][H] projection, the 9.4 GiB copy this avoids) are mine.
  • The loader's consumption path needs the NPU, and this lane does not have one under a ticket tonight; I reviewed the read branch, not what the decoder does with the values.
  • The +104 test arms I did not build: that test links onebit_npu, so it needs a full NPU configuration rather than the host-only build I have at /tmp/b1799.

Given that, READY is a verdict on the shape and conventions, not on the numbers.

@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 at f026d68: CHANGES

Checked at f026d68. The PR is still CONFLICTING, 38 behind main. git merge-tree --write-tree --name-only origin/main f026d68 shows a conflict only in tests/npu_pack_import_test.cpp. npu/pack_import.cpp, pack_import.h and q4nx_forward.cpp auto-merge.

Blocking:

1. The banded BF16 converter does not round to nearest-even: half of the per-layer table is one ulp off. read_per_layer_table_band() converts each f32 with

const uint32_t lsb = (b >> 16) & 1u;
dst[i] = uint16_t((b >> 16) + (lsb && ((b & 0xFFFFu) != 0 || (((b >> 16) & 1u) != 0)) ? 1u : 0u));

The second disjunct is lsb, so this is "round up iff the bf16 lsb is 1". It ignores the 16 discarded bits entirely. I measured that exact expression against IEEE RNE, (b + 0x7FFF + lsb) >> 16, which is the rule the file's own as_bf16/f32_to_bf16 uses:

x = 1.0078125 (bf16 0x3F81, exactly representable)   band = 3f82   rne = 3f81
random N(0, 0.05), 1e6 values                         501,027 differ (50.1 %)
exactly-representable bf16 in [0x3C00, 0x4000)        512 of 1024 changed by band()

So model.per_layer_token_embd.weight gets a one-ulp, magnitude-increasing error on about half its elements. That includes values the f32 holds exactly, so it is wrong even before any rounding question arises. No arm covers it: nothing in tests/npu_pack_import_test.cpp calls read_per_layer_table_band. The arms pin read_embedding_per_layer()'s f32 read, which is not the path that writes the container. Fix: dst[i] = f32_to_bf16(f), the same rule as every other BF16 tensor here. Then add an arm that bands a crafted table, including an exactly-representable odd-lsb value like 1.0078125, and compares against as_bf16 of the same rows.

2. The import still materialises the whole table, twice. I agree with the CHANGES at this head and confirm it in the code. import_pack() calls read_embedding_per_layer() (:3267), which still copies the whole V × width f32 table into out.table (:1017, read_f32 → dst->resize(count) + memcpy). The "streamed" loop then fills a whole-table tbl_bf16 (V × width × 2, :3270) before one w.add. Both are live at once: about 9.4 + 4.7 GiB on e2b, by the comment's own figure. The band function's comment ("materialising it to convert would cost more RAM than the box has to spare") describes what the call site still does. The fix needs a metadata-only read_embedding_per_layer (or a flag), plus a writer path that appends bands, or at least drops pl.table before the loop.

3. Every band re-parses the graph and maps the file, and never unmaps. read_per_layer_table_band() does parse_onnx(gpath) and map_file(base, …) on every call, and has no unmap_file. The call site makes V/32 calls, which is 8,192 on e2b (262,144 rows). That is 8,192 parses and 8,192 live mappings of the table's external-data file, held until exit. read_f32 in read_embedding_per_layer() (:1008) also maps without unmapping. Parse and map once per import, pass the base pointer in, and unmap at the end.

4. Rebase for the tests/npu_pack_import_test.cpp conflict.

What is right, and stays: BF16 in the container because of the measured 9.4 GiB, the context projection read at block 32 with its own scales, refusals naming the missing input, and fconst19_block_starts() split out so "not a 1.9 fconst" and "a 1.9 fconst with non-uniform blocks" are different answers.

@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 at f026d686 — with one request that comes straight out of tonight's other defects, and one cross-reference for my own lane.

What makes this reviewable is that it states what is NOT claimed. "The e2b row is not DONE and this PR does not claim it: the pack still stops at the Token .fconst block layout, which is W1c's" — and the evidence is the right kind: npu-pack info ~/scratch-packs/pi-demo/e2b goes from "the per-layer embedding mechanism is not implemented here yet" to rc 0 with a full plan (423 tensors, the geometry listed), with every remaining incomplete entry still the fconst layout. Tracing the meaning from the component's graph (RMSNormalization(embeds @ per_layer_model_projection.weight_t_Q4 * 0.0255155, per_layer_projection_norm.weight), and H**-0.5 for H=1536 is 0.0255155) rather than from a node list is why the constants came out right instead of being copied.

The ok() / complete() split is the part I would keep: a component with the table and norm but not the projection imports, and is refused by the loader with the reason, instead of importing half a mechanism.

One request, and it is the same class of bug that has bitten twice tonight. The forward's new branch picks its layout by arithmetic alone:

if (data_size >= (block + 1) * bf16_bytes && data_size % bf16_bytes == 0 && data_size != plain_bytes)

That is a divisibility test choosing how to interpret bytes — exactly the shape of #1842 (the tiled embedding path chosen on esz % tiles == 0, now fixed by probing row 0 and NV-1 before committing) and of #1807's review. The != plain_bytes guard removes one ambiguity but not the general one: a table whose size happens to be a multiple of 32 * cols * 2 is indistinguishable from a BF16 band by size. Two ways to close it, both cheap:

  • probe like #1875 does: decode block 0 both ways and require one of them to be plausible (e.g. finite, non-zero, and matching the token table's own row statistics for the same token), refusing if both or neither fit; or
  • carry the fact: the importer knows which layout it wrote, so put a dtype/layout key in the container and read it, instead of re-deriving it in the forward from a byte count.

The second is better (no heuristic at all), and this PR is the one that gets to choose because it writes the tensor.

Cross-reference for #1784 (mine, F4): this PR puts per_layer_model_proj.weight_layer{l} in the container, which is what my per-layer half was blocked on — the loader currently materialises NL * ple_dim * H floats at load by transposing those tensors into a dense f32 matrix, and I recorded that half as "pack-blocked, not measurable on this box" because no container here had the tensors (0 hits scanning every model.q4nx). Once a gemma-4 container exists, that half becomes measurable on the same RssAnon + byte-identical-logits method as the token table (#1795, #1807: 7.15 GiB on the 27B). No action for this PR — just so the two are not independently re-derived.

…ot by arithmetic on the size

Review of #1825 caught a real collision class in my BF16-band branch: it separated the two layouts with

  data_size >= (block+1)*bf16_bytes && data_size % bf16_bytes == 0 && data_size != plain_bytes

and every multiple of the band satisfies the first two, so a plain table whose byte count happens to divide would be read
as 32-row BF16 bands -- "plausible and wrong", the failure my own comment warns about. The tree already decides this way
elsewhere (`model.c` does `strcmp(dtype, "I8")` on the same field) and `q4nx_dequant.h`'s rule is that an ambiguous tile count
is refused rather than resolved by which test happens to pass.

  * BF16 is taken only when the container SAYS BF16, and then its size must be whole 32-row bands (else refused);
  * plain I8 (or Q4) keeps the existing int8 + .scale path;
  * any other DECLARED dtype is refused by name, and so is an EMPTY one -- a container written by this importer always
    states a dtype (`w.add()` writes it for every tensor), so "no dtype" means a foreign or truncated container, not a
    layout this loader may guess.

`model.c` populates `dtype` for every tensor entry that carries the key, which is what makes the branch sound rather than
merely tidier; that is stated in the comment next to the test.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Taken — the branch is keyed on the container's declared dtype now, and an empty one is refused too. 3814c1d.

You were right that the arithmetic was doing work it cannot do. data_size >= (band) && data_size % bf16_bytes == 0 && data_size != plain_bytes admits every multiple of the band, so a plain table whose byte count happened to divide would be read as 32-row BF16 bands — and your pointer to q4nx_dequant.h:96-106 is the rule I should have applied without being told ("an ambiguous tile count is refused rather than resolved by loop order").

What it does now:

const bool declared_bf16 = pl_dtype[0] != '\0' && std::strcmp(pl_dtype, "BF16") == 0;
const bool declared_i8   = pl_dtype[0] != '\0' && (std::strcmp(pl_dtype, "I8") == 0 || std::strcmp(pl_dtype, "Q4") == 0);
if (!declared_bf16 && !declared_i8) {
    err_ = pl_dtype[0] != '\0' ? "… has dtype <x>, which this loader does not read (BF16 or plain I8 only)"
                               : "… declares no dtype, so its layout cannot be decided";
    return false;
}
if (declared_bf16) {
    if (data_size < (block+1) * bf16_bytes || data_size % bf16_bytes != 0)
        err_ = "… declared BF16 but its size is not whole 32-row bands";
}

Three consequences worth naming, because each is a case your note implies: BF16 must be declared and be whole bands; an empty dtype is a refusal rather than a fall-through (a container written by this importer always states one — w.add() writes dtype for every tensor — so "no dtype" means foreign or truncated, not a layout to infer); and any other declared format is refused by name instead of reaching the int8 test.

On "is dtype populated for this tensor": yes — model.c copies the container entry's dtype for every tensor that carries the key (the same field model.c:860 already does strcmp(…, "I8") on), so the branch is sound rather than merely tidier; that is now in the comment beside the test.

Scope: npu_pack_import_test tests/golden → all npu-pack checks passed; onebit builds; copyright.py --check rc 0. Still no NPU pack on this box, so the real-pack numbers remain yours-not-mine — unchanged from your note.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 3814c1d7

My review note is addressed, and the fix goes one step past it: the layout is now decided by the container's declared dtype, and a container that declares none — or declares something this loader does not read — is refused rather than falling through to a guess.

const bool declared_bf16 = pl_dtype[0] != '\0' && std::strcmp(pl_dtype, "BF16") == 0;
const bool declared_i8   = pl_dtype[0] != '\0' && (std::strcmp(pl_dtype, "I8") == 0 || std::strcmp(pl_dtype, "Q4") == 0);
if (!declared_bf16 && !declared_i8) { err_ = …; return false; }

Three things I like about this beyond the note being taken:

  1. The size test survives as a consistency guard, not as the discriminator — on the BF16 path it now says "is declared BF16 but its size is not whole 32-row bands" and refuses. So a container whose declaration and whose bytes disagree is a named error rather than a silent misread, which is the failure mode the whole exchange was about.
  2. The empty-dtype case is refused separately and says why ("declares no dtype, so its layout cannot be decided") — this tree's importer always writes a dtype (w.add() does), so an empty one means a foreign or truncated container rather than a layout to infer. That is the rule q4nx_dequant.h:96-106 states for the tile-count case, applied at the loader boundary where it belongs.
  3. The comment cites the two references rather than asserting a preference (model.c's strcmp(dtype,"I8"), and the refusal rule), so the next reader can check the claim instead of trusting it.

Nothing else in the diff moved: the reader's contract, the EmbedPerLayer shape, the projection dequantisation and the test arms are as reviewed at f026d68, and the tests add arms to an already-registered target (no direction-B exposure).

Unchanged caveats, restated because they still hold: the 3 GB gemma-4 pack is not on this box, so the real-pack numbers remain the author's; the consumption path needs the NPU, which this lane does not have under a ticket; and the test arms link onebit_npu, so they need an NPU configuration rather than the host-only build here. This verdict is on the shape, the refusal semantics and the code paths — which is exactly what it was about.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CHANGES at 3814c1d — the layout point from my last verdict is fixed better than I asked; the memory point is untouched and is now the only thing
between this and READY.

Fixed, and by removing the guess rather than documenting it

-    if ((size_t)td->data_size >= ((size_t)block + 1) * bf16_bytes &&
-        (size_t)td->data_size % bf16_bytes == 0 && (size_t)td->data_size != plain_bytes) {
+    const bool declared_bf16 = pl_dtype[0] != '\0' && std::strcmp(pl_dtype, "BF16") == 0;
+    const bool declared_i8   = pl_dtype[0] != '\0' && (std::strcmp(pl_dtype, "I8") == 0 || std::strcmp(pl_dtype, "Q4") == 0);
+    if (!declared_bf16 && !declared_i8) { … refuse rather than fall through to a layout guess … }

I asked for the arithmetic predicate to be named in the comment; you removed it and decided by the container's declared dtype, with an empty dtype
refused as "a foreign or truncated container, not a layout this loader can infer". That is strictly better than the ask and consistent with model.c's
strcmp(dtype,"I8") and q4nx_dequant.h's rule that an ambiguous layout is refused rather than resolved by whichever test happens to pass.

Still open: the import allocates the 9.4 GiB the format exists to avoid

npu/pack_import.cpp:1017
if (!read_f32(*table, size_t(V) * size_t(width), "embed_tokens_per_layer", &out.table)) return out;
…
npu/pack_import.cpp:3270-3281
std::vector<uint8_t> tbl_bf16(size_t(pl.V) * size_t(pl.width) * 2);   // 4.7 GiB
… w.add("model.per_layer_token_embd.weight", "BF16", {pl.V, pl.width}, std::move(tbl_bf16));

For the e2b pack (262144 × 8960) that is 9.4 GiB as f32 plus 4.7 GiB as BF16 ≈ 14.1 GiB of transient allocation — the number the BF16 container format
was chosen to avoid, now paid at import instead of at load. The fix is unchanged and still free: the container tensor is already banded (the forward reads
32-row BF16 bands, bf16_bytes = Q4NX_TILE_ROWS * cols * 2), so converting and appending band by band — one read_f32_range(t, row_begin, rows, dst)
beside the existing lambda — gives the same bytes with a ~573 KB peak instead of 14 GiB.

Everything else from f026d68 stands as reviewed: f32-on-disk justified by the number, the context projection read at block 32 with its own _t_scales,
five distinct refusals each naming the missing input, and fconst19_block_starts() split out so "not a 1.9 fconst" is distinguishable from "a 1.9 fconst
with non-uniform blocks".

@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 at 3814c1d (carried from my review at f026d68, verified as an ancestor of this head — the delta is main merged in, so the reviewed change is unchanged). The substance is in my earlier comment; this line is the machine-readable half.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 3814c1d

The evidence chain is the right one for a pack feature: traced from the component's graph, not its node list — 35 nodes end to end,
with the scalars shown and then shown to be geometry (H**-0.5 for H=1536 is 0.02551551815399144; sqrt(P) = 16 for P=256;
/sqrt(2)), and the conclusion that build_ple_inputs() and the layer body already compute exactly this. So the gap is named precisely: the
container wrote two differently-named tensors and skipped the context projection, while the loader reads
model.per_layer_token_embd.weight (+.scale), model.per_layer_proj_norm.weight and model.per_layer_model_proj.weight_layer{l}.

Fixing the container rather than the arithmetic is the correct call, and the arms say so without a 9 GB pack: the table and its projection norm
are read from the component's own genai_config.json names, the values come back exactly as the component states them, and the token
table still reads beside the new tensors (so adding the per-layer path did not disturb the one that already worked).

Two things worth keeping:

  • "the e2b row is not DONE and this PR does not claim it" — with the reason (the pack still stops at the Token .fconst block layout, which is W1c's) and the dead end recorded (the txn_bins route I proposed for it turned out to be a dead end, recorded on #1503). A PR that
    says which part of its own issue it does not close is worth more than one that implies everything works.
  • The variable-stride arm: "a 1.9 fconst whose blocks differ in size is refused BY NAME, not as 'not a 1.9 fconst'" — that is a refusal
    that will save the next person hours, because the two failures look identical from outside and only one of them suggests where to look.

Head under review: 3814c1d

…to switch the block on (engine#1725)

The per-layer tensors are useless without the two numbers the forward's semantics reader gates the whole block on
(`hidden_size_per_layer_input > 0`, q4nx_semantics.c). gemma-4 states them in model_config.json's text_config
(256 and 262144); the container's own config.json did not carry them, so an import would write the table, the norm and the
per-layer projections and then the forward would skip the block silently -- the worst failure shape, because the container
looks complete.

  * when the component is `complete()` (table + norm + context projection), the container's config.json gains
    `hidden_size_per_layer_input` = P and `vocab_size_per_layer_input` = V, taken from the COMPONENT (where they are
    measured) rather than from the pack's config, which may not state them at all;
  * the three Gemma-4 layer-shape switches the same reader consults -- `num_kv_shared_layers`, `use_double_wide_mlp`,
    `extra_layer_norms` -- are carried through when model_config.json's text_config states them, and left absent otherwise
    so the reader's defaults apply.

The read happens at the config-writing site, which lives in a different writer from the one that emits the tensors: one
metadata parse (names, dims, the quantised projection header -- the 9.4 GiB table is NOT read there) rather than plumbing a
value across two functions.

@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 at 111ba73: CHANGES

Re-checked at 111ba73. The two new commits answer other notes: 3814c1d decides the forward's per-layer table layout from its declared dtype, and 111ba73 makes the container say how to switch the block on. None of the four points in my CHANGES at f026d68 is addressed, and each is still in the file:

  1. The banded BF16 converter still rounds "up iff the lsb is 1". read_per_layer_table_band(), pack_import.cpp:1140-1141:
    const uint32_t lsb = (b >> 16) & 1u;
    dst[i] = uint16_t((b >> 16) + (lsb && ((b & 0xFFFFu) != 0 || (((b >> 16) & 1u) != 0)) ? 1u : 0u));
    Re-measured, unchanged: 50.1 % of N(0, 0.05) values differ from round-to-nearest-even, and 512 of 1024 exactly representable bf16 values are moved one ulp (1.0078125 → 0x3F82). So half of model.per_layer_token_embd.weight is written wrong. Still no arm calls this function. Fix: f32_to_bf16(f), plus an arm that compares a banded table against as_bf16 on an odd-lsb exact value.
  2. The whole table is still held twice: read_embedding_per_layer() copies all V × width f32 (:1017), and the "streamed" loop fills a whole-table tbl_bf16 (:3270). That is about 14 GiB on e2b.
  3. Every band still re-parses the graph and maps the file without unmapping: parse_onnx and map_file per call (:1118, :1127), and 8,192 calls on e2b.
  4. Still CONFLICTING with main, in tests/npu_pack_import_test.cpp.

(1) is the one that silently changes numbers. (2)–(4) are memory and merge blockers.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 111ba73: READY — the container carries the table, and the refusal is where a stride can actually bite

The thing this gets right first is scope: "the e2b row is not DONE and this PR does not claim it" — the pack still stops where it stops, and the PR says which half it is. A container+loader change that carries per_layer_inputs is a prerequisite, not a row, and stating that prevents the worst outcome of a W4 increment: a merged PR read as "gemma-4 runs now".

The assertions are the contract, not a smoke test: the table and its projection norm are read from the component's genai_config.json (the artifact that names them, rather than a convention), the values come back exactly as the component states them, and the token table still reads beside it — that last one is the regression guard, because a multi-component pack's addressee is exactly where an offset error would silently shift the token table.

And the variable-stride refusal is the right call at the right level: a 1.9 fconst whose blocks differ in size must be "refused BY NAME, not as 'not a 1.9 fconst'" — a stride that varies is legitimate in the genre this PR opens, so the old single-stride reader has to say what it cannot do rather than misclassify the pack. Refusing by name is what lets the next increment find the case instead of rediscovering it.

Two things worth pinning as this grows: (1) that a pack which declares per_layer_inputs but where the loader finds none is refused, not defaulted to zero — the gemma-4 decoder multiplies a gate by it at every layer, so zero is a plausible-looking wrong answer; (2) once the e2b row is attempted, the check compares against a device result rather than a host reconstruction, since the whole point of the table is what the decoder does with it.

Nothing to change for this half.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CHANGES at 111ba73 — the banded reader landed, but the whole-table read it was meant to replace is still there, and one comment now says otherwise.

What is good

read_per_layer_table_band() and the streaming writer are the right shape, and the rationale is exactly the number from my last verdict:

// engine#1725: stream the per-layer token table out in bands. The component states it as f32 (V x NL*P, 9.4 GiB on
// gemma-4-e2b), so materialising it to convert would cost more RAM than the box has to spare for one import
…
// The token table is streamed: V x (NL*P) f32 is 9.4 GiB on e2b, and a band is 32 rows.
for (int64_t row = 0; row < pl.V; row += 32) { … read_per_layer_table_band(pack_dir, &pl, row, n, …) … }

Why the 14 GiB is still paid

EmbedPerLayer pl = read_embedding_per_layer(pack_dir, H, &pl_why); (:3267) returns a struct whose table was filled by the unconditional whole-table read
inside that function:

:1013  if (!table->location.empty()) {
:1014      if (!read_f32(*table, size_t(V) * size_t(width), "embed_tokens_per_layer", &out.table)) return out;

so the import holds 9.4 GiB of f32 for the lifetime of pl and then streams bands out of it — the bands are an addition, not a replacement. The streaming
loop is currently dead weight against the allocation it was written to avoid.

And the comment one screen down now contradicts the code

:4170   // One metadata read, not plumbing: … The table itself is NOT read here (only names, dims and the quantised
:4171   // projection header), so this costs a file parse and no memory.
:4173   const EmbedPerLayer pl2 = read_embedding_per_layer(pack_dir, p.dims.hidden, &pl_why);

That is the second caller, and it is making a claim about a callee that reads V × width floats. The claim was true before this PR added the band path and is
false now — which is worse than the original cost, because the next reader will believe the comment.

One fix settles both

Split metadata from data: a read_embedding_per_layer_meta() (or a bool read_table argument) that fills only names, dims, NL/P/H, the projection
header and the norm, and have both call sites take that — the writer already streams, and the config block above only needs the two semantics keys. The
whole-table read can then stay as a helper used by nothing, or be deleted; either way the import's peak goes back to a band (~573 KB) and the comment becomes
true.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 111ba732

Third review of this branch and the trajectory is consistent: contract → dtype-based layout → now the switch that makes the block run at all. This commit closes the worst failure shape in the file — a container that looks complete and whose feature is silently off.

What this commit gets right

  • The two gate numbers come from the component, not the pack's config. read_embedding_per_layer(...) measures P and V where they actually live, and the keys are written only when pl2.complete() (table + norm + context projection). So a partial container cannot claim the block is usable, which is the failure this PR exists to prevent; and a container that is complete states hidden_size_per_layer_input > 0, which is what q4nx_semantics.c gates the whole block on.
  • The Gemma-4 switches are carried only when the pack states them (num_kv_shared_layers, use_double_wide_mlp, extra_layer_norms), so the reader's defaults apply otherwise rather than being replaced by a guess.
  • The parse is defensive in exactly the right places: catch (...) { m = json::object(); } for a malformed file, is_object() at every level of the config.model_attributes.text_config descent, and !is_null() before copying a key. A missing model_config.json is a quiet skip, not a throw — correct, because a pack without one is not an error here.
  • The cost is stated and bounded: a second metadata read at the config-writing site (a different writer from the one that emits the tensors), names/dims/header only — the 9.4 GiB table is not touched. That is the right trade against plumbing a value across two functions.

Unchanged caveats

The 3 GB gemma-4 pack is not on this box (so the real-pack numbers remain the author's), the consumption path needs the NPU, and the arms link onebit_npu. This verdict is on the shape and the semantics.

A note on the queue, not on the PR: this PR's verdict has gone stale twice because the branch keeps moving — which is fine, and worth saying so nobody reads my name against a head I did not see. My verdicts are at f026d68 (contract), 3814c1d7 (dtype) and now 111ba732; anything after that needs a fresh one.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 111ba73

The new commit adds the container's own switch plus the bounds that make carrying the block safe, and both are the right kind of addition to
the PR I reviewed at 3814c1d:

Everything from my previous review stands: the graph-traced scalar arithmetic, the container-vs-forward split, the genai_config.json-named
tensors with their values asserted equal, and the honest "the e2b row is not DONE and this PR does not claim it".

Head under review: 111ba73

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 111ba73 — the container and loader side of per_layer_inputs, and it takes the "refuse rather than guess" line consistently. One compatibility question I would settle before it merges.

What it gets right

// ambiguous layout is refused rather than resolved by which test happens to pass.
// Some other declared format, or none at all: refuse rather than fall through to a layout guess.
    ? "per_layer_token_embd has dtype " + pl_dtype
    : "per_layer_token_embd declares no dtype, so its layout cannot be decided"
    "per_layer_token_embd is declared BF16 but its size is not whole 32-row bands"
  • ambiguity is a refusal, not a tie-break — "resolved by which test happens to pass" is how a decoder ends up choosing a layout by luck, and it is the single most common way this file has produced silent gibberish;
  • every refusal is named with the tensor, the dtype it read and the reason — including the case where the field is declared but its size does not fit a whole number of 32-row bands, which is the shape a truncated write produces;
  • "the difference" wording (the block table vs a stride) ties it to engine#1503, so the reader can see which earlier refusal this one is a sibling of.

The question worth settling: what does an OLDER reader do with this container?

#1825 does not touch the manifest or the layout version (I checked: zero hunks mentioning either), and the manifest already carries a layout version. So a container that now ships per_layer_token_embd looks, at the format level, like any other container. Two possible behaviours, and they need different answers:

  1. the reader refuses an unknown tensor by name — good: an older build refuses a gemma-4 pack instead of running it without per-layer inputs, and the refusal names the field it does not understand. Worth one line in the body confirming that, because it is the property that makes shipping this field safe;
  2. the reader ignores tensors it does not know — then an older build loads the pack and computes without per_layer_inputs, which is precisely the defect this PR exists to fix (the decoder multiplies a per-layer gate by it at every layer), and the output is wrong with no error.

If (2) is what happens today, a layout-version bump is the cheap fix: the container then declares "you must understand per_layer_token_embd", and the version check refuses rather than the run being quietly wrong. That is the same rule as #1895/#1896's qk_norm: a pack's declaration has to be something the reader can fail on.

Not run

The C++ arms need a build and a gemma-4 pack; disk is 155 G (below the 175 G floor) and no ticket is held, so this is a read-only review.

@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 at 111ba73

This is the other half of a row I probed on the device tonight, and the two halves together make the row's blocker list complete. Running Gemma4-E2B-IT-NPU2 under forward-serve (engine#1786, item F1) got as far as initialising D, QKV2 and O kernels, then refused by name for a missing full_i8_PLEGATE_K1536_N512.elf. This PR explains why that role exists at all:

"The gemma-4 1.9 packs cannot run without per_layer_inputs — their decoder multiplies a per-layer gate by it at every layer — and neither the container nor the loader could carry it until this branch."

So the row has three blockers, and they are now all named: the PLEGATE kernel (F1's hand-off to pi-zha), this (container + loader carry of per_layer_inputs), and the Token .fconst block layout (W1c's) — which the body states plainly: "The e2b row is not DONE and this PR does not claim it". That kind of scoping is what makes a family of interlocking blockers workable: each PR owns its piece and says which piece is not its.

The per_layer_inputs-times-gate structure is also the reason the container carries 179-214 per-layer tensors in those three packs (measured on engine#1786's census) — the tensors are not decoration, they are an operand the decoder multiplies at every layer, so a loader that silently omitted them would produce a model that runs and answers wrongly rather than one that fails.

No asks. Worth adding to the PR body one line linking it to engine#1786: the ELF side of the same row is full_i8_PLEGATE_<K>_N<...>, and that is the piece F1 hands to the kernel lane.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Follow-up for the open review points here: #1951 (lane cl-npupack-b; this branch has had no push since 11:17Z and the CHANGES at 11:31Z / 11:41Z were unanswered).

#1951 is this branch at 111ba73 with main merged plus one commit: the band conversion is round-to-nearest-even, the per-layer table is never held (the reader points into the mapping, the container writer streams it), one parse and one mapping per component, and the second whole-table read at the config site is gone. Writing an arm that runs import_pack() on a gemma-4 shaped component also found three more things, all in the PR body: the P-wide shared norm was skipped (the reader wanted it as wide as the table), the context projection was read at offset 0 of its data file, and an absent zero-point tensor decoded at 0 instead of the implicit 8.

No verdict from me on this PR. If you would rather take the commit onto this branch, it applies here; otherwise #1951 supersedes it.

bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…the shared norm (Fixes #1725, continues #1825) (#1951)

* npu-pack: read a component's per-layer embedding table, beside its token table (WIP, engine#1725)

The gemma-4 1.9 components carry, beside `embed_tokens.weight`, a PER-LAYER table and a projection norm:

  embedding.embed_tokens_per_layer.weight    (262144, 8960)  f32, external   (= 35 layers x 256 head_dim)
  embedding.per_layer_projection_norm.weight (256,)          f32

The decoder consumes them as `per_layer_inputs`, so a container with only the token table gives the decoder less than it
asks for -- which is why the previous commit refused such a component by name. This adds the reader, so the next slice can
write the container section the forward path will load.

  EmbedPerLayer { table [V][layers*head_dim] row-major, norm [width], V, width } + `ok()`;
  read_embedding_per_layer(pack_dir, why) resolves the component exactly as the token half does -- genai_config.json's
  model.embedding.filename (relative only, no ".."), external data beside the GRAPH rather than at the pack root -- reads
  f32, refuses any other dtype by name rather than mis-decoding it, and says so when the projection norm is missing
  (the table is still usable, but a consumer expecting a projected row would silently take raw ones).

WIP: no container section and no forward consumer yet; that is the next slice, with its test. Builds clean
(`npu_pack_import_test` rc 0). No vendor code or layout: only the pack's own ONNX names and dims are read.

* npu-pack: write a component's per-layer embedding into the container (WIP, engine#1725)

Second half of the reader in the previous commit: `import_pack()` now writes the per-layer table and its projection norm
beside the token table --

  model.embed_tokens_per_layer.weight    BF16 {V, layers*head_dim}   (from the component's f32)
  model.per_layer_projection_norm.weight BF16 {layers*head_dim}

-- and the slice-1 refusal in `read_embedding_component()` is relaxed to match: a component that carries
`embed_tokens_per_layer` is no longer refused by that reader, because the mechanism it was waiting for now exists.

Measured on the real pack with this change (`1bit npu-pack info ~/scratch-packs/pi-demo/e2b`, loader built from main):

  before: the pack has no model.embed_tokens.weight, and the component carries embedding.embed_tokens_per_layer.weight,
          i.e. a PER-LAYER embedding table ... the per-layer embedding mechanism is not implemented here yet
  after:  rc 0, a full plan -- dims hidden 1536 / 35L / 8 heads / 1 kv / head_dim 256 / IM 6144 / vocab 262144,
          423 tensors, and 350 `incomplete` entries that all say the same NEXT thing:
          "layer blocks not located: the epsilon headers do not repeat with a single period"

So the embedding half is no longer what blocks a gemma-4 pack: the next wall is the Token .fconst block layout, whose rule
row cannot be measured for gemma-4 (no 1.8 twin exists) -- the `txn_bins` preformat route designed on engine#1503.

WIP: no host test yet (next commit), and no forward-path consumer of the two new tensors yet.

* npu-pack: pin the per-layer embedding read, with the fixture mistakes that made it fail first (engine#1725)

Test for the two WIP commits before it: `read_embedding_per_layer()` reads the component's
`embed_tokens_per_layer` (V x layers*head_dim) and `per_layer_projection_norm` (width) as f32, resolving both through
genai_config.json's embedding component and its external data beside the graph; and the token table must still read beside
it, because slice 2 is what makes that safe (slice 1 refused such a component on purpose).

Two fixture mistakes are worth the comment they now carry, because both produce a fixture that looks right:
  * `std::vector<float> norm(size_t(width))` is a FUNCTION DECLARATION (most vexing parse);
  * `std::vector<float> norm{size_t(width)}` is an initializer_list of ONE element = width, so the table is 1 value wide
    and the reader correctly reports "declares more bytes than its data carries" for the norm.
The two-arg form is the only one that does what it looks like.

Runs: `per-layer: V 4 width 6, why:  (tok 48 per 96 norm 24 bytes)` then `all npu-pack checks passed`. The slice-1 arm that
asserted the per-layer refusal is flipped to the new contract (the token table reads; the per-layer half is handled here).

* npu-pack/forward: the per-layer gather the decoder will call, pinned by three numbers (engine#1725)

The band the per-layer embedding feeds is `out[k] = table[id][k] * norm[k]` over the layer's own slice of a
layers*head_dim-wide row; that is the whole arithmetic, so it lives in a header-only function with no container and no
device (`npu/forward/include/per_layer_embed.h`) and a test that can be read as the spec:

  row0 {1,2,3,4} * norm {2,2,0.5,0.25} -> {2,4,1.5,1}
  row1 {-1,0.5,0,8} * same norm      -> {-2,1,0,2}      (a different id picks a different row)
  null norm                          -> the row passes through, because a MISSING projection is not a ZERO one
  id out of range, or no table       -> output untouched (the loader can hand a short container)

Runs: `per-layer gather: row0 -> 2.00 4.00 1.50 1.00` then all checks pass.

Also in this commit: the test target gains `npu/forward/include` on its include path (it had only the engine's own), and a
comment records the trap I hit twice in this file: `std::vector<T> v(size_t(n))` is a FUNCTION DECLARATION, and
`std::vector<T> v{size_t(n)}` is an initializer_list of one element — only the two-arg form does what it looks like.

* npu-pack: dequantise the per-layer CONTEXT projection, and split ok()/complete() (engine#1725, WIP)

Tracing the component's graph end to end (not its node list) showed the per-layer input is

  ( rmsnorm((inputs_embeds @ per_layer_model_projection.weight_t_Q4) * H**-0.5, per_layer_projection_norm) + 16*table[id] ) / sqrt(2)

so the table and its norm are only TWO of the three tensors: the context projection is the third, and without it the
per-layer input is `16*table/sqrt(2)` -- plausible, and wrong at every layer. The component carries it quantised
(`per_layer_model_projection.weight_t_Q4` [8960,48,16], `..._t_scales`, packed `..._t_zero_point`), and the loader wants it
dequantised as `model.per_layer_model_proj.weight_layer{l}` [P][H] BF16 per layer.

  * `read_embedding_per_layer(pack_dir, hidden, why)` now dequantises that projection through the file's own
    `decode_matmulnbits()` (same decoder the rest of the importer uses) and holds it as [NL][P][H] f32; `hidden` is a
    parameter because it comes from the pack's geometry, not from the component.
  * `ok()` is what the reader guarantees (table + norm); `complete()` additionally requires the projection. A component
    with the first but not the second imports the table and is refused by the LOADER on `per_layer_model_proj.weight_layer0`,
    which is the honest place to refuse -- the importer does not know whether the decoder declares per_layer_inputs.

Measured on `amd/gemma-4-e2b-it-mobius-int4_rai_1.9.0_npu`'s component: table 262144x8960 f32, norm 256 (shared by all 35
layers -- the graph reshapes to [.., 35, 256] around the norm), projection K=1536 N=8960 block 32 int4.

WIP: the container still writes the two old names; the rename to the loader's names and the projection emission are the next
commit, together with the forward's BF16 band path for the table.

* npu-pack: write the per-layer PLE tensors under the names the forward loads (engine#1725, WIP)

The forward's `build_ple_inputs()`/`load_ple_block()` already implement this family's arithmetic -- I traced the component's
graph and it is line for line the same: `context = rmsnorm((emb @ W_l) * H**-0.5, norm)`, `identity = 16 * table[id]`,
`per_layer_input = (context + identity)/sqrt(2)` -- but it reads different names than the importer wrote. This commit closes
that gap:

  model.per_layer_token_embd.weight            BF16 {V, NL*P}    (was model.embed_tokens_per_layer.weight)
  model.per_layer_proj_norm.weight             BF16 {P}          (was model.per_layer_projection_norm.weight)
  model.per_layer_model_proj.weight_layer{l}   BF16 {P, H}, l = 0..NL-1   (was absent)

The token table is **streamed**, never materialised: `read_per_layer_table_band()` maps the component's external data and
converts 32 rows at a time from f32 to BF16 straight into the container blob, because the full table is V x NL*P f32 =
9.4 GiB on gemma-4-e2b and a converting copy of that would be the largest allocation in the import.

Two bugs of my own found while writing it, both worth the comments they carry:
  * the first version of the band reader did `memcpy(dst, src, n * 4)` into a `uint16_t*` of n elements -- 4 bytes per
    element into a 2-byte-per-element buffer, i.e. a 2x overflow. Removed before it ran;
  * the projection matrices were being dequantised but never written; `pl.complete()` (table + norm + projection) is what
    now gates the per-layer emission, and a component with `ok()` but not `complete()` still writes the table, leaving the
    refusal to the LOADER (`missing model.per_layer_model_proj.weight_layer0`), which is the honest place.

All existing checks pass; copyright rc 0. WIP: the forward still lacks a RAW-BF16 band path in `load_ple_block` (it handles
plain int8 + scale, or the chunked container formats), so this container is one loader branch away from being readable.

* forward: read the per-layer token table as raw BF16 bands (engine#1725)

`load_ple_block()` handled two shapes: plain int8 + a per-32-column `.scale`, and the chunked container formats. The
multi-component packs state this table as f32 and the importer converts it to BF16 when it writes the container -- a
converting copy of the whole V x NL*P table would be 9.4 GiB on gemma-4-e2b -- so the container's table is raw BF16 and
neither branch matched it.

The band is the same shape the int8 branch handles (32 rows x cols), with a 2-byte element instead of a code, so this is a
loop of `bf16_to_f32` over the band, chosen by data_size: a multiple of the BF16 band and NOT the plain-int8 size. That
ordering matters -- `plain_bytes` (one byte per element) and a BF16 band differ by a factor of two, and testing int8 first
would take a BF16 table whose size happens to equal `plain_bytes` for another geometry.

With this, a container written by the importer (#1725's `83c323b`) is readable end to end on the embedding side: table
(banded), norm (256), and the per-layer context projections.

* npu-pack: retire the table-times-norm primitive -- it is not this model's arithmetic (engine#1725)

The previous commit on this branch added `per_layer_gather()` computing `table[id] * norm`, from a reading of the component's
node LIST. Tracing the component's graph end to end showed something else:

  per_layer_inputs = ( rmsnorm((inputs_embeds @ per_layer_model_projection.weight_t_Q4) * H**-0.5,
                               per_layer_projection_norm) + 16*table[id] ) / sqrt(2)

-- the norm applies to the projected CONTEXT, the table row is ADDED (times 16 = sqrt(256)), and the sum is scaled by
1/sqrt(2). That is exactly what the forward's `build_ple_inputs()` already computes, so the primitive was not merely
unused, it was wrong, and a pure function with a passing test is exactly the wrong thing to leave in a tree for someone to
call later.

Deleted: `npu/forward/include/per_layer_embed.h`, its test, and the test target's include line it needed. The container and
loader work that DOES match the graph stays (earlier commits on this branch): the three PLE tensors written under the names
the forward reads, the streamed BF16 table, and `load_ple_block()`'s raw-BF16 band path.

Deleting beats keeping a wrong primitive as documentation: the retraction is recorded on engine#1725 with the graph, which
is where someone changing this will look.

* npu-pack: tell a variable-stride 1.9 fconst from "not a 1.9 fconst" (engine#1503)

`fconst19_detect()` requires ONE stride for the whole file. The mobius line does not have one -- measured on
gemma-4-e2b: 35 blocks, each opening with 8 anchors (8 heads x 4 B = 32-byte header), sizes 21.87 MiB to 288.06 MiB --
so the detect fails and the pack was reported the same way as a file that is simply not a 1.9 fconst at all.

  * `fconst19_block_starts()` is the run scan on its own (exactly `layers` runs of `heads*4` anchor bytes), so a caller can
    tell the two cases apart before the stride test;
  * when the runs are there but the strides differ, the refusal now NAMES the structure: how many blocks, where the first
    one starts, and the size range in KiB, plus why the rule table cannot be the answer ("one stride per file" is the
    table's model) and what the answer is (the per-layer block table; the anchors give the starts, and each block's size
    follows that layer's own projection shapes).

On the real pack:

  1bit npu-pack: … this IS a Ryzen AI 1.9 Token .fconst (35 blocks of 32 B headers at 2564864 B) but its blocks are NOT
  uniform: 22395 KiB to 294973 KiB, i.e. a variable-stride container. The rule table describes one stride per file, so no
  row can fit it; this family (mobius/MatFormer) needs the per-layer block table instead …

Crafted arm: four blocks with different body sizes are refused by name (`variable stride: refused by name
(variable-stride container)`), so the distinction is pinned without a 1.3 GiB pack.

* forward: decide the per-layer table's layout by its DECLARED dtype, not by arithmetic on the size

Review of #1825 caught a real collision class in my BF16-band branch: it separated the two layouts with

  data_size >= (block+1)*bf16_bytes && data_size % bf16_bytes == 0 && data_size != plain_bytes

and every multiple of the band satisfies the first two, so a plain table whose byte count happens to divide would be read
as 32-row BF16 bands -- "plausible and wrong", the failure my own comment warns about. The tree already decides this way
elsewhere (`model.c` does `strcmp(dtype, "I8")` on the same field) and `q4nx_dequant.h`'s rule is that an ambiguous tile count
is refused rather than resolved by which test happens to pass.

  * BF16 is taken only when the container SAYS BF16, and then its size must be whole 32-row bands (else refused);
  * plain I8 (or Q4) keeps the existing int8 + .scale path;
  * any other DECLARED dtype is refused by name, and so is an EMPTY one -- a container written by this importer always
    states a dtype (`w.add()` writes it for every tensor), so "no dtype" means a foreign or truncated container, not a
    layout this loader may guess.

`model.c` populates `dtype` for every tensor entry that carries the key, which is what makes the branch sound rather than
merely tidier; that is stated in the comment next to the test.

* npu-pack: a container that carries the PLE tensors now also says how to switch the block on (engine#1725)

The per-layer tensors are useless without the two numbers the forward's semantics reader gates the whole block on
(`hidden_size_per_layer_input > 0`, q4nx_semantics.c). gemma-4 states them in model_config.json's text_config
(256 and 262144); the container's own config.json did not carry them, so an import would write the table, the norm and the
per-layer projections and then the forward would skip the block silently -- the worst failure shape, because the container
looks complete.

  * when the component is `complete()` (table + norm + context projection), the container's config.json gains
    `hidden_size_per_layer_input` = P and `vocab_size_per_layer_input` = V, taken from the COMPONENT (where they are
    measured) rather than from the pack's config, which may not state them at all;
  * the three Gemma-4 layer-shape switches the same reader consults -- `num_kv_shared_layers`, `use_double_wide_mlp`,
    `extra_layer_norms` -- are carried through when model_config.json's text_config states them, and left absent otherwise
    so the reader's defaults apply.

The read happens at the config-writing site, which lives in a different writer from the one that emits the tensors: one
metadata parse (names, dims, the quantised projection header -- the 9.4 GiB table is NOT read there) rather than plumbing a
value across two functions.

* npu-pack: stream the per-layer table, round it to nearest even, read the shared norm (engine#1725)

Answers the open review points on #1825:
- the banded BF16 conversion is f32_to_bf16 (round to nearest even); it rounded
  up whenever the kept lsb was 1
- the table is never held: the reader points into the mapped external data and
  the container writer streams it 32 rows at a time (Writer::add_stream)
- one graph parse and one mapping per component, unmapped with the last holder
- the config keys come from the Report, so the second whole read is gone

Found while writing the import arm:
- the projection norm is P wide and shared by the layers; requiring it to be
  as wide as the table skipped it on a gemma-4 shaped component
- the context projection was read at offset 0 of its external data file
- an absent zero-point tensor decoded with zero point 0, not the implicit 8

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: pi-demo <pi-demo@1.local>
Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
bong-water-water-bong added a commit that referenced this pull request Nov 12, 2026
…to main — supersedes #1931 and #2035 (Refs #1783) (#2133)

* npu-pack: read a component's per-layer embedding table, beside its token table (WIP, engine#1725)

The gemma-4 1.9 components carry, beside `embed_tokens.weight`, a PER-LAYER table and a projection norm:

  embedding.embed_tokens_per_layer.weight    (262144, 8960)  f32, external   (= 35 layers x 256 head_dim)
  embedding.per_layer_projection_norm.weight (256,)          f32

The decoder consumes them as `per_layer_inputs`, so a container with only the token table gives the decoder less than it
asks for -- which is why the previous commit refused such a component by name. This adds the reader, so the next slice can
write the container section the forward path will load.

  EmbedPerLayer { table [V][layers*head_dim] row-major, norm [width], V, width } + `ok()`;
  read_embedding_per_layer(pack_dir, why) resolves the component exactly as the token half does -- genai_config.json's
  model.embedding.filename (relative only, no ".."), external data beside the GRAPH rather than at the pack root -- reads
  f32, refuses any other dtype by name rather than mis-decoding it, and says so when the projection norm is missing
  (the table is still usable, but a consumer expecting a projected row would silently take raw ones).

WIP: no container section and no forward consumer yet; that is the next slice, with its test. Builds clean
(`npu_pack_import_test` rc 0). No vendor code or layout: only the pack's own ONNX names and dims are read.

* npu-pack: write a component's per-layer embedding into the container (WIP, engine#1725)

Second half of the reader in the previous commit: `import_pack()` now writes the per-layer table and its projection norm
beside the token table --

  model.embed_tokens_per_layer.weight    BF16 {V, layers*head_dim}   (from the component's f32)
  model.per_layer_projection_norm.weight BF16 {layers*head_dim}

-- and the slice-1 refusal in `read_embedding_component()` is relaxed to match: a component that carries
`embed_tokens_per_layer` is no longer refused by that reader, because the mechanism it was waiting for now exists.

Measured on the real pack with this change (`1bit npu-pack info ~/scratch-packs/pi-demo/e2b`, loader built from main):

  before: the pack has no model.embed_tokens.weight, and the component carries embedding.embed_tokens_per_layer.weight,
          i.e. a PER-LAYER embedding table ... the per-layer embedding mechanism is not implemented here yet
  after:  rc 0, a full plan -- dims hidden 1536 / 35L / 8 heads / 1 kv / head_dim 256 / IM 6144 / vocab 262144,
          423 tensors, and 350 `incomplete` entries that all say the same NEXT thing:
          "layer blocks not located: the epsilon headers do not repeat with a single period"

So the embedding half is no longer what blocks a gemma-4 pack: the next wall is the Token .fconst block layout, whose rule
row cannot be measured for gemma-4 (no 1.8 twin exists) -- the `txn_bins` preformat route designed on engine#1503.

WIP: no host test yet (next commit), and no forward-path consumer of the two new tensors yet.

* npu-pack: pin the per-layer embedding read, with the fixture mistakes that made it fail first (engine#1725)

Test for the two WIP commits before it: `read_embedding_per_layer()` reads the component's
`embed_tokens_per_layer` (V x layers*head_dim) and `per_layer_projection_norm` (width) as f32, resolving both through
genai_config.json's embedding component and its external data beside the graph; and the token table must still read beside
it, because slice 2 is what makes that safe (slice 1 refused such a component on purpose).

Two fixture mistakes are worth the comment they now carry, because both produce a fixture that looks right:
  * `std::vector<float> norm(size_t(width))` is a FUNCTION DECLARATION (most vexing parse);
  * `std::vector<float> norm{size_t(width)}` is an initializer_list of ONE element = width, so the table is 1 value wide
    and the reader correctly reports "declares more bytes than its data carries" for the norm.
The two-arg form is the only one that does what it looks like.

Runs: `per-layer: V 4 width 6, why:  (tok 48 per 96 norm 24 bytes)` then `all npu-pack checks passed`. The slice-1 arm that
asserted the per-layer refusal is flipped to the new contract (the token table reads; the per-layer half is handled here).

* npu-pack/forward: the per-layer gather the decoder will call, pinned by three numbers (engine#1725)

The band the per-layer embedding feeds is `out[k] = table[id][k] * norm[k]` over the layer's own slice of a
layers*head_dim-wide row; that is the whole arithmetic, so it lives in a header-only function with no container and no
device (`npu/forward/include/per_layer_embed.h`) and a test that can be read as the spec:

  row0 {1,2,3,4} * norm {2,2,0.5,0.25} -> {2,4,1.5,1}
  row1 {-1,0.5,0,8} * same norm      -> {-2,1,0,2}      (a different id picks a different row)
  null norm                          -> the row passes through, because a MISSING projection is not a ZERO one
  id out of range, or no table       -> output untouched (the loader can hand a short container)

Runs: `per-layer gather: row0 -> 2.00 4.00 1.50 1.00` then all checks pass.

Also in this commit: the test target gains `npu/forward/include` on its include path (it had only the engine's own), and a
comment records the trap I hit twice in this file: `std::vector<T> v(size_t(n))` is a FUNCTION DECLARATION, and
`std::vector<T> v{size_t(n)}` is an initializer_list of one element — only the two-arg form does what it looks like.

* npu-pack: dequantise the per-layer CONTEXT projection, and split ok()/complete() (engine#1725, WIP)

Tracing the component's graph end to end (not its node list) showed the per-layer input is

  ( rmsnorm((inputs_embeds @ per_layer_model_projection.weight_t_Q4) * H**-0.5, per_layer_projection_norm) + 16*table[id] ) / sqrt(2)

so the table and its norm are only TWO of the three tensors: the context projection is the third, and without it the
per-layer input is `16*table/sqrt(2)` -- plausible, and wrong at every layer. The component carries it quantised
(`per_layer_model_projection.weight_t_Q4` [8960,48,16], `..._t_scales`, packed `..._t_zero_point`), and the loader wants it
dequantised as `model.per_layer_model_proj.weight_layer{l}` [P][H] BF16 per layer.

  * `read_embedding_per_layer(pack_dir, hidden, why)` now dequantises that projection through the file's own
    `decode_matmulnbits()` (same decoder the rest of the importer uses) and holds it as [NL][P][H] f32; `hidden` is a
    parameter because it comes from the pack's geometry, not from the component.
  * `ok()` is what the reader guarantees (table + norm); `complete()` additionally requires the projection. A component
    with the first but not the second imports the table and is refused by the LOADER on `per_layer_model_proj.weight_layer0`,
    which is the honest place to refuse -- the importer does not know whether the decoder declares per_layer_inputs.

Measured on `amd/gemma-4-e2b-it-mobius-int4_rai_1.9.0_npu`'s component: table 262144x8960 f32, norm 256 (shared by all 35
layers -- the graph reshapes to [.., 35, 256] around the norm), projection K=1536 N=8960 block 32 int4.

WIP: the container still writes the two old names; the rename to the loader's names and the projection emission are the next
commit, together with the forward's BF16 band path for the table.

* npu-pack: write the per-layer PLE tensors under the names the forward loads (engine#1725, WIP)

The forward's `build_ple_inputs()`/`load_ple_block()` already implement this family's arithmetic -- I traced the component's
graph and it is line for line the same: `context = rmsnorm((emb @ W_l) * H**-0.5, norm)`, `identity = 16 * table[id]`,
`per_layer_input = (context + identity)/sqrt(2)` -- but it reads different names than the importer wrote. This commit closes
that gap:

  model.per_layer_token_embd.weight            BF16 {V, NL*P}    (was model.embed_tokens_per_layer.weight)
  model.per_layer_proj_norm.weight             BF16 {P}          (was model.per_layer_projection_norm.weight)
  model.per_layer_model_proj.weight_layer{l}   BF16 {P, H}, l = 0..NL-1   (was absent)

The token table is **streamed**, never materialised: `read_per_layer_table_band()` maps the component's external data and
converts 32 rows at a time from f32 to BF16 straight into the container blob, because the full table is V x NL*P f32 =
9.4 GiB on gemma-4-e2b and a converting copy of that would be the largest allocation in the import.

Two bugs of my own found while writing it, both worth the comments they carry:
  * the first version of the band reader did `memcpy(dst, src, n * 4)` into a `uint16_t*` of n elements -- 4 bytes per
    element into a 2-byte-per-element buffer, i.e. a 2x overflow. Removed before it ran;
  * the projection matrices were being dequantised but never written; `pl.complete()` (table + norm + projection) is what
    now gates the per-layer emission, and a component with `ok()` but not `complete()` still writes the table, leaving the
    refusal to the LOADER (`missing model.per_layer_model_proj.weight_layer0`), which is the honest place.

All existing checks pass; copyright rc 0. WIP: the forward still lacks a RAW-BF16 band path in `load_ple_block` (it handles
plain int8 + scale, or the chunked container formats), so this container is one loader branch away from being readable.

* forward: read the per-layer token table as raw BF16 bands (engine#1725)

`load_ple_block()` handled two shapes: plain int8 + a per-32-column `.scale`, and the chunked container formats. The
multi-component packs state this table as f32 and the importer converts it to BF16 when it writes the container -- a
converting copy of the whole V x NL*P table would be 9.4 GiB on gemma-4-e2b -- so the container's table is raw BF16 and
neither branch matched it.

The band is the same shape the int8 branch handles (32 rows x cols), with a 2-byte element instead of a code, so this is a
loop of `bf16_to_f32` over the band, chosen by data_size: a multiple of the BF16 band and NOT the plain-int8 size. That
ordering matters -- `plain_bytes` (one byte per element) and a BF16 band differ by a factor of two, and testing int8 first
would take a BF16 table whose size happens to equal `plain_bytes` for another geometry.

With this, a container written by the importer (#1725's `83c323b`) is readable end to end on the embedding side: table
(banded), norm (256), and the per-layer context projections.

* npu-pack: retire the table-times-norm primitive -- it is not this model's arithmetic (engine#1725)

The previous commit on this branch added `per_layer_gather()` computing `table[id] * norm`, from a reading of the component's
node LIST. Tracing the component's graph end to end showed something else:

  per_layer_inputs = ( rmsnorm((inputs_embeds @ per_layer_model_projection.weight_t_Q4) * H**-0.5,
                               per_layer_projection_norm) + 16*table[id] ) / sqrt(2)

-- the norm applies to the projected CONTEXT, the table row is ADDED (times 16 = sqrt(256)), and the sum is scaled by
1/sqrt(2). That is exactly what the forward's `build_ple_inputs()` already computes, so the primitive was not merely
unused, it was wrong, and a pure function with a passing test is exactly the wrong thing to leave in a tree for someone to
call later.

Deleted: `npu/forward/include/per_layer_embed.h`, its test, and the test target's include line it needed. The container and
loader work that DOES match the graph stays (earlier commits on this branch): the three PLE tensors written under the names
the forward reads, the streamed BF16 table, and `load_ple_block()`'s raw-BF16 band path.

Deleting beats keeping a wrong primitive as documentation: the retraction is recorded on engine#1725 with the graph, which
is where someone changing this will look.

* npu-pack: tell a variable-stride 1.9 fconst from "not a 1.9 fconst" (engine#1503)

`fconst19_detect()` requires ONE stride for the whole file. The mobius line does not have one -- measured on
gemma-4-e2b: 35 blocks, each opening with 8 anchors (8 heads x 4 B = 32-byte header), sizes 21.87 MiB to 288.06 MiB --
so the detect fails and the pack was reported the same way as a file that is simply not a 1.9 fconst at all.

  * `fconst19_block_starts()` is the run scan on its own (exactly `layers` runs of `heads*4` anchor bytes), so a caller can
    tell the two cases apart before the stride test;
  * when the runs are there but the strides differ, the refusal now NAMES the structure: how many blocks, where the first
    one starts, and the size range in KiB, plus why the rule table cannot be the answer ("one stride per file" is the
    table's model) and what the answer is (the per-layer block table; the anchors give the starts, and each block's size
    follows that layer's own projection shapes).

On the real pack:

  1bit npu-pack: … this IS a Ryzen AI 1.9 Token .fconst (35 blocks of 32 B headers at 2564864 B) but its blocks are NOT
  uniform: 22395 KiB to 294973 KiB, i.e. a variable-stride container. The rule table describes one stride per file, so no
  row can fit it; this family (mobius/MatFormer) needs the per-layer block table instead …

Crafted arm: four blocks with different body sizes are refused by name (`variable stride: refused by name
(variable-stride container)`), so the distinction is pinned without a 1.3 GiB pack.

* forward: decide the per-layer table's layout by its DECLARED dtype, not by arithmetic on the size

Review of #1825 caught a real collision class in my BF16-band branch: it separated the two layouts with

  data_size >= (block+1)*bf16_bytes && data_size % bf16_bytes == 0 && data_size != plain_bytes

and every multiple of the band satisfies the first two, so a plain table whose byte count happens to divide would be read
as 32-row BF16 bands -- "plausible and wrong", the failure my own comment warns about. The tree already decides this way
elsewhere (`model.c` does `strcmp(dtype, "I8")` on the same field) and `q4nx_dequant.h`'s rule is that an ambiguous tile count
is refused rather than resolved by which test happens to pass.

  * BF16 is taken only when the container SAYS BF16, and then its size must be whole 32-row bands (else refused);
  * plain I8 (or Q4) keeps the existing int8 + .scale path;
  * any other DECLARED dtype is refused by name, and so is an EMPTY one -- a container written by this importer always
    states a dtype (`w.add()` writes it for every tensor), so "no dtype" means a foreign or truncated container, not a
    layout this loader may guess.

`model.c` populates `dtype` for every tensor entry that carries the key, which is what makes the branch sound rather than
merely tidier; that is stated in the comment next to the test.

* npu-pack: a container that carries the PLE tensors now also says how to switch the block on (engine#1725)

The per-layer tensors are useless without the two numbers the forward's semantics reader gates the whole block on
(`hidden_size_per_layer_input > 0`, q4nx_semantics.c). gemma-4 states them in model_config.json's text_config
(256 and 262144); the container's own config.json did not carry them, so an import would write the table, the norm and the
per-layer projections and then the forward would skip the block silently -- the worst failure shape, because the container
looks complete.

  * when the component is `complete()` (table + norm + context projection), the container's config.json gains
    `hidden_size_per_layer_input` = P and `vocab_size_per_layer_input` = V, taken from the COMPONENT (where they are
    measured) rather than from the pack's config, which may not state them at all;
  * the three Gemma-4 layer-shape switches the same reader consults -- `num_kv_shared_layers`, `use_double_wide_mlp`,
    `extra_layer_norms` -- are carried through when model_config.json's text_config states them, and left absent otherwise
    so the reader's defaults apply.

The read happens at the config-writing site, which lives in a different writer from the one that emits the tensors: one
metadata parse (names, dims, the quantised projection header -- the 9.4 GiB table is NOT read there) rather than plumbing a
value across two functions.

* npu: the Whisper front-end four f32 host kernels, known-answer tested (engine#1783)

* npu: read an audio pack graph whole -- and the two facts that change the executor (engine#1931)

* npu: run an audio pack encoder on the host, verified against onnxruntime (engine#1931)

* npu: run the Whisper decoder too -- 1233 nodes, both graphs, verified against onnxruntime (engine#1931)

* npu: the log-mel front-end, checked against the reference extractor (engine#1931)

* npu: whisper_transcribe -- wav to token ids on the host, matching the reference decode (engine#1931)

* npu: whisper_transcribe reports where the host time goes (engine#1931)

* npu: split independent MatMul rows across threads, bit-identical, measured at last (engine#1931)

* npu: the audio fork-join is tested for bit-identity and survives a throwing unit; honest timing labels; notices (engine#1931)

Answers the three open reviews on #1931 at 61426f5:
- parallel_units moves to npu/audio_parallel.h; a worker that throws is
  caught, every worker is joined, the lowest worker's exception is rethrown.
- tests/audio_parallel_test.cpp: the executor's MatMul compared with memcmp
  at 1/2/3/7/8/64 threads and unset, on an in-memory graph (no pack);
  every unit runs exactly once; a throwing unit is a caught exception.
- whisper_transcribe reports wav, mel, load, encoder, decode separately:
  'mel+load' did not include the wav read, and 'encoder' included both
  graph parses.
- The audio tests are build dependencies of onebit, so CI builds them
  without a workflow edit; full Apache notice on the ten audio files.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: pi-demo <pi-demo@1.local>
Co-authored-by: bong-water-water-bong <pi@1bit.local>
Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

PR hygiene (orchestrator 11-13 02:35Z, cl-infra): closing — its issue #1725 is closed by merged #1951. Branch deleted with the close.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant