Repository navigation
npu-pack: stream the per-layer table, round it to nearest even, read the shared norm (Fixes #1725, continues #1825) - #1951
Conversation
…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.
…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.
…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.
…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.
Conflict in tests/npu_pack_import_test.cpp: both sides added test functions at the same place; both are kept. The file also carries the new per-layer arms of the next commit, which could not be separated from the resolution. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…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>
|
ready for review at eb93d5f |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at eb93d5f
Continuing #1825 with main merged and answering review points that have been open since 11:31Z is the right disposition, and the three things in the title are each decisions rather than chores:
- "stream the per-layer table" — a per-layer table read whole is exactly the shape that produces a memory ceiling nobody measured; streaming it makes the peak independent of layer count.
- "read the shared norm" — a norm tensor shared across layers is a fact about the pack, and reading it per layer either re-reads the same bytes or, worse, re-derives a value that should be one value.
- "round it to nearest even" — this is the one I want justified in the body, because it is the only item here that changes numbers. Ties-to-even is the right default for float→int (it matches the IEEE rounding mode and it is what a reference converter almost always does), but why this pack needs it is the question a reviewer cannot answer from the diff: if the vendor's converter rounds ties away from zero, matching it bit-for-bit requires the same rule, and choosing ties-to-even "because it is standard" would be right in principle and wrong for this pack.
Two asks:
- State where the tie rule comes from — the pack's own converter, a reference decode, or IEEE default — and if it is IEEE default, say which packs it can disagree with.
- An arm at a tie. Rounding is only testable where the two rules disagree, i.e. exactly at
.5. A test whose inputs never land on a tie passes for both implementations, which is the #1874 lesson in its purest form: values that decode identically under either convention prove nothing about the convention.
Fixes #1725 with the #1825 commits carried and main merged is also the honest way to present a PR that both continues and answers — a reader can see which parts are new and which are the earlier branch's.
|
VERDICT: READY at The rounding defect is the part worth the review: "the band converter had no arm, and it rounded 'up iff the kept lsb is 1' — half of all values off by[ one ulp]". That is not round-to-nearest-even; it is a rule that happens to agree with RNE on half the inputs and disagrees on the rest, which is the worst kind — every value it gets wrong is one ulp away, so a KAT with a loose tolerance passes and a bit-exactness gate fails for a reason nobody can find. Routing through the shared Three arms this needs, since rounding is a convention and conventions drift:
And one sentence I would add to the body: whether the streamed band and the previously-materialised table are byte-identical on a real pack — that is the check that proves "streaming" changed the mechanism and not the values, and it is the one a reader will ask about after seeing |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at eb93d5f: READY
The numbers are in the PR, which is what makes the shape defensible: embed_tokens_per_layer.weight is vocab x layers*P f32 — 262,144 × 8,960 on e2b, 9.4 GiB — plus a P-wide shared per_layer_projection_norm.weight and a context projection quantised at block 32.
A 9.4 GiB f32 table is exactly why this cannot be materialised whole, so streaming the per-layer table and rounding to nearest even during the pass is the right structure rather than a memory optimisation bolted on afterwards. Round-to-nearest-even is also the correct default for the conversion; truncation would bias every value down by half a step and that bias is invisible in a correlation.
CI green. I read the diff and CI; I did not run it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at eb93d5f: READY
Checked at eb93d5f (Fixes #1725; it carries #1825's six commits with main merged, and is clean on current main). It answers all four points of my CHANGES on #1825, each checked in the code and then run:
- Rounding. The band converter is now
f32_to_bf16(f)(pack_import.cpp:1189), the same RNE every other tensor gets. Mutant: putting #1825's old expression back ("round up iff the lsb is 1") fails 4 arms, among themper-layer band: an exactly representable value with an odd lsb is not moved(the1.0078125 → 0x3F82case I measured),every banded value is round-to-nearest-even bf16 (192 of 350 differ), andper-layer import: every value of the STREAMED table is round-to-nearest-even bf16 (270 of 512 differ). #1825 had no arm on this path at all. - Memory. Both whole-table allocations are gone: the
read_f32(*table, V*width, …)copy, and the whole-tabletbl_bf16buffer. The table is read in place from the mapping and written throughWriter::add_stream(…)(:3921), band by band. - Parse and map. The graph is parsed and the external data mapped once per component, not once per band.
- Conflict. Merges clean.
npu_pack_import_test tests/golden gives rc 0, 0 FAIL. onnx forward syntax builds. ci_targets_check is at baseline 9, none new.
For the merge order: #1825 should close in favour of this, and #1931 (which still carries #1825's unfixed commits) should rebase onto main once this lands.
|
VERDICT: APPROVE at The rounding bug, quantified
The arms are the canonical triple, and having all three is what makes the claim checkable:
The other two changes
Per-layer tables are now refused by name ( One structural noteThis is three subjects in one PR — a rounding correctness fix, a performance change, and the block-start/per-layer-table work. Each is internally justified, but the RNE fix is the kind that should be findable by subject when someone later asks "when did the per-layer band start rounding correctly", and a revert of the streaming change would take the rounding fix with it. If it is not too late in the queue, splitting the RNE fix out (with these three arms, which are already written) would cost little and make the history honest. |
Fixes #1725. Continues #1825 (its six commits are in this branch, with
mainmerged), and answers the review points that have been open on it since 11:31Z and 11:41Z with no push since 11:17Z. If the #1825 author would rather take these commits onto that branch, they apply there unchanged apart from the merge.The four open points on #1825, and what this does about each
111ba73)1.0078125→0x3F82)f32_to_bf16, round to nearest even, the conversion every other tensor getsread_embedding_per_layer, then a 4.7 GiB BF16 buffer)Writer::add_streamconverts 32 rows at a time while the container file is written (573 KB band on e2b)EmbedPerLayerand unmapped with its last copyCONFLICTINGwithmaintests/npu_pack_import_test.cppThe comment that said "the table itself is NOT read here" above a second call to the whole-table reader is gone with the call: the two config keys now come from the
Reportthe tensor writer filled.Three more defects, found by running the writer end to end
#1825's arms called the reader helpers; nothing ran
import_pack()on a per-layer component. Writing that arm with gemma-4's stated shapes (a tablelayers*Pwide, a normPwide) turned up:per_layer_projection_normto be as wide as the table. npu-pack: the gemma-4 embedding component is f32 and unquantised, so the 1.9 packs stop at the reader's shape (4-bit table + scales) #1725 states the norm as(256,)beside a table 8,960 wide, so on that shape the norm was not read,Pfell back to a hard-coded 256, and the container had nomodel.per_layer_proj_norm.weight— which the forward refuses on (q4nx_forward.cpp: "missing model.per_layer_proj_norm.weight"). Any length that divides the table width is accepted now.implicit_zp_zero, ONNX Runtime's behaviour) is 8.I could not check 1 to 3 against the real pack:
amd/gemma-4-e2b-it-mobius-int4_rai_1.9.0_npuis not on this box (14 GiB, and the disk is under the download floor). They follow from the shapes #1725 records and from ONNX external-data semantics; the arm below pins them on a crafted component.Tests
New arms, no pack and no device:
per-layer band(reader + band converter): 70 rows read as 32 + 32 + 6; every value equals round-to-nearest-even bf16, including an exact value with an odd lsb, a tie on an even lsb, a tie on an odd lsb and a value just above a tie; out-of-range bands refused; a table longer than its data refused when it is located.per-layer import(test_per_layer_import, throughimport_pack()): the container carries the table as BF16[V, layers*P]with exactly that many bytes; every streamed value is round-to-nearest-even; theP-wide norm that follows the streamed tensor reads back intact (so the offsets after the stream are right); one projection per layer, read at its non-zero external-data offset,(11 - 8) x 0.5 = 1.5everywhere;config.jsoncarrieshidden_size_per_layer_inputandvocab_size_per_layer_input.Mutants, each rebuilt and run with the same command:
Not covered
32 x 8,960 x 2)..fconsthas a variable stride, which is npu-pack: the 1.9 packs split on the MatMulNBits K block size (32, and mixed 32/128), before the hidden-896 limit is reached #1503's.docs/npu.mdgains a paragraph on the per-layer block and how the table is streamed.🤖 Generated with Claude Code