Skip to content

npu-pack: stream the per-layer table, round it to nearest even, read the shared norm (Fixes #1725, continues #1825) - #1951

Merged
bong-water-water-bong merged 13 commits into
mainfrom
1bit/npupack-b-1725-per-layer-stream
Nov 10, 2026
Merged

bong-water-water-bong merged 13 commits into
mainfrom
1bit/npupack-b-1725-per-layer-stream

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Fixes #1725. Continues #1825 (its six commits are in this branch, with main merged), 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

point (reviews at 111ba73) before now
the banded BF16 conversion rounds up whenever the kept lsb is 1 half of all values one ulp off, including values bf16 holds exactly (1.0078125 → 0x3F82) f32_to_bf16, round to nearest even, the conversion every other tensor gets
the table is held twice (9.4 GiB f32 in read_embedding_per_layer, then a 4.7 GiB BF16 buffer) about 14 GiB transient on gemma-4-e2b never held: the reader points into the mapped external data, and Writer::add_stream converts 32 rows at a time while the container file is written (573 KB band on e2b)
every band re-parses the graph and re-maps the data, never unmapping (8,192 calls on e2b) one parse + one mmap per band one parse and one mapping per component, held by the EmbedPerLayer and unmapped with its last copy
CONFLICTING with main conflict in tests/npu_pack_import_test.cpp merged (both sides had added test functions at the same place; both kept)

The 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 Report the 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 table layers*P wide, a norm P wide) turned up:

  1. The shared norm was skipped. The reader required per_layer_projection_norm to 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, P fell back to a hard-coded 256, and the container had no model.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.
  2. The context projection was read at offset 0 of its external-data file, not at the tensor's own offset.
  3. A projection with no zero-point tensor decoded at zero point 0. The file's own rule (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_npu is 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

$ npu_pack_import_test tests/golden
all npu-pack checks passed -- pack leg SKIPPED, no pack directory given

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, through import_pack()): the container carries the table as BF16 [V, layers*P] with exactly that many bytes; every streamed value is round-to-nearest-even; the P-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.5 everywhere; config.json carries hidden_size_per_layer_input and vocab_size_per_layer_input.

Mutants, each rebuilt and run with the same command:

mutant failing arms
the old rounding rule in the band converter 4 (192 of 350 banded values differ; 270 of 512 streamed values differ)
projection read at offset 0 1
norm required to be as wide as the table 3 (no norm, no per-layer projections, config keys absent)
implicit zero point dropped 1
the stream producer writes one band short 7; the import throws "the streamed tensor did not write the 1024 B the container's header states"

Not covered

🤖 Generated with Claude Code

pi-demo and others added 13 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.
…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>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at eb93d5f

@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: 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:

  1. 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.
  2. 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at eb93d5f — two fixes in one, and the rounding one is a real numerics defect that the comment already diagnoses exactly.

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 f32_to_bf16 (RNE) is the right fix, and "the conversion every other tensor in the container gets" is the right framing: one converter, no per-site variant.

Three arms this needs, since rounding is a convention and conventions drift:

  1. exact ties — values exactly halfway between two bf16 values (0x3F808000-class), where RNE picks the even mantissa rather than the larger magnitude. Those are precisely the inputs where "round up iff lsb is 1" and RNE differ, so they are the regression arms;
  2. a value where the old rule differs in the other direction — up-iff-lsb rounds toward zero when the lsb is 0, so at least one arm must fail if the old rule returns;
  3. the perf claim's guard — "it used to re-parse the graph and re-map the data on every call, 8,192 times on e2b" is a large speedup, and a test that asserts the conversion result does not show it. If there is a call counter or a cheap timing arm, say which; if not, say in the body that the 8,192-call change is measured by inspection rather than by test.

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 8192 → 1 calls.

@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 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 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 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:

  1. 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 them per-layer band: an exactly representable value with an odd lsb is not moved (the 1.0078125 → 0x3F82 case I measured), every banded value is round-to-nearest-even bf16 (192 of 350 differ), and per-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.
  2. Memory. Both whole-table allocations are gone: the read_f32(*table, V*width, …) copy, and the whole-table tbl_bf16 buffer. The table is read in place from the mapping and written through Writer::add_stream(…) (:3921), band by band.
  3. Parse and map. The graph is parsed and the external data mapped once per component, not once per band.
  4. 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: APPROVE at eb93d5f — the rounding fix is a data-correctness bug with the right arms, and the block-start scan agrees with the .fconst facts on this box.

The rounding bug, quantified

// #1825 review: the band converter had no arm, and it rounded "up iff the kept lsb is 1" -- half of all values off by
// 0x3F81), a tie on an even lsb (stays), a tie on an odd lsb (goes up to even), and one just above a tie.

up iff the kept lsb is 1 is round-half-up on ties, not round-half-to-even, so half of all tied values landed one bf16 ULP high (0x3F80 → 0x3F81). Now it goes through f32_to_bf16, the same conversion every other tensor in the container gets — which is the part that matters beyond the arithmetic: the per-layer band was the only tensor converted by a second implementation, and two implementations of a rounding rule is how one of them ends up wrong with no test to notice.

The arms are the canonical triple, and having all three is what makes the claim checkable:

  • a tie on an even lsb → stays;
  • a tie on an odd lsb → goes up to even;
  • one just above a tie → must not be treated as a tie.

The other two changes

  • Streaming — "It used to re-parse the graph and re-map the data on every call, 8,192 times on e2b". A measured count beats a suspicion, and 8,192 re-parses of the same graph is not a micro-optimisation.
  • fconst19_block_starts() — scans for the heads-parameterised header (fconst19_header(heads), matching on 00 00 7f ff at i+2/i+3), with a layers < 2 guard. That agrees with what the tail facts recorded here for the 1.9 packs: the marker is present once per block for the families that carry it, and its width is a function of the head count — which is exactly why the pattern has to be built from heads rather than hardcoded.

Per-layer tables are now refused by name (embedding.embed_tokens_per_layer.weight is a different table, not a missing one), which keeps the "absent vs unsupported" distinction that #1703 established for the pb16i reader.

One structural note

This 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.

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.

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)

1 participant