Skip to content

npu: the Whisper front-end four f32 host kernels, known-answer tested - #1931

Closed
bong-water-water-bong wants to merge 19 commits into
mainfrom
main-audioops
Closed

bong-water-water-bong wants to merge 19 commits into
mainfrom
main-audioops

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #1783 (the Whisper-on-NPU item), first slice. No route yet — this is the arithmetic the route is built on, and the four pieces that are easy to get subtly wrong.

npu/forward/include/audio_ops.h (header-only, no dependencies) plus tests/audio_ops_test.cpp, wired as audio_ops_test next to npu_pack_test because that binary is compiled wherever this repo builds — the same placement #1830 and #1863 argued for.

Why these four. The encoder's mel front-end is two strided 3-tap convs ([1280,128,3] stride 1 then [1280,1280,3] stride 2, both pad 1) — a transposed weight layout or an off-by-one in tout produces a plausible-looking activation, not a crash. The GELU is approximate=none, i.e. the exact erf form; the tanh approximation is 1.7e-4 away at x=2, so gelu_erf and a tanh substitute are distinguishable at the 1e-6 this test runs at (asserted explicitly). layer_norm uses the biased variance with eps under the root, and softmax_row subtracts the row max, because the encoder's attention logits reach 100+ once the 1/sqrt(d) scale is folded in.

Every expected value was computed in double in Python from the formulas the encoder reference uses (tools/whisper_encoder_ref.py, validated earlier at max|d| 1.469e-3 / rel 7.549e-5 with identical transcripts on three fixtures). Arms: the real shapes (including 3000 -> 1500 for the stride-2 conv), a no-bias/stride-1/pad-0 edge, a constant row normalising to zero, null gamma/beta defaults, softmax shift-invariance at +1000, and the tanh-vs-erf discriminator.

One arm failed on first run — my hand-typed value for the null-affine case was 1.3416270 and the true value is 1.34163542 — which is the test doing its job on its own author. Fixed to the double-computed constant; ctest -R audio_ops passes.

Not in this PR: the graph executor that walks the pack's encoder.onnx and calls these, and any device path. Those are the next slices; this one is deliberately device-free so it can be reviewed and run anywhere.

pi-demo and others added 12 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.

@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 575f3ab: READY

"Known-answer" is done correctly here, and the correctness is in where the answers came from: "every expected value was computed in double in Python from the same formulas the Whisper encoder reference uses" — an independent implementation, not values read back out of the C++ under test. That is the distinction that separates a known-answer test from a regression lock (#1861's lesson, and the reason expect_close(got, want, tol) takes a double want computed elsewhere).

The tolerances are explicit per call rather than one global epsilon, and expect_eq is kept for the integer cases (conv1d_out_len(5, 3, 2, 1) == 3) where closeness would be meaningless — an integer length either is right or is not.

No container, no device, no pack — so this runs everywhere, which is what a front-end arithmetic test should do; device evidence belongs to whatever consumes these kernels.

CI is 4 pass / 0 fail on this head, so this verdict is not resting on my reading alone.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at b5bc2c6

The design decisions here are the ones that make a front-end testable at all on this box, and each is stated where it matters:

The provenance discipline — "every number in tests/audio_ops_test.cpp was computed in double from the same formulas the reference uses" — is the property I
would check first on any known-answer test, because a test whose expectations were produced by the implementation under test proves only that the code has not
changed since the expectation was written.

+709/-16: front-end kernels plus their tests, no container path touched. Nothing to change; the eventual F8 row is the place for the speed claim, and this
deliberately makes none.

@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 b5bc2c6.

The Whisper front-end's four f32 host kernels with a known-answer test (+86 header, +32 forward, CMake +5): for a front-end, "known answer" is the only meaningful bar — an FFT/mel path that is merely self-consistent produces a spectrogram that looks right and transcribes wrong, and the error is invisible until the text is gibberish. Host-side f32 first is also the correct order for §2's rule: the device kernel can then be checked against something that already has an answer.

@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: CHANGES at b5bc2c6 — the kernels are right and their test passes; the row is not built by any job, so the test never runs, and ci_targets_check reds on this head for exactly that reason. One line fixes it.

The arithmetic: built and run

Header-only, no container, no device — so this is checkable without a bench:

g++-16 -std=c++26 -I npu/forward/include tests/audio_ops_test.cpp → audio_ops_test
./audio_ops  →  audio ops: conv1d (strided + padded + edge), layer_norm (biased var, null affine),
                gelu (erf), softmax (stable, shift-invariant) -- all pass

The four arms are the four things that go subtly wrong: stride+padding at the edge for conv1d, biased variance with a null affine for layer_norm (the arm that separates 1/N from 1/(N-1)), erf-gelu rather than tanh, and a shift-invariant softmax. Good fixture choice for a front-end whose output feeds an encoder.

The registration: red on this head

$ python3 tools/ci/ci_targets_check.py --check --root .
ci_targets_check: add_test names 'audio_ops_test', and ci.yml never builds audio_ops -- that test never
reaches ctest in the configuration CI creates (it is not registered, or registered and not built);
build it or drop the test
$ echo $?
1

grep -c audio_ops_test .github/workflows/ci.yml → 0, and grep -c 'add_dependencies(onebit audio_ops_test' CMakeLists.txt → 0. So when this lands, the ci_targets ctest row goes rc 1 on main — a red main in exchange for a test that still does not run.

The body's reasoning is the part to correct: "wired as audio_ops_test next to npu_pack_test because that binary is compiled wherever this repo builds." npu_pack_test is compiled because it is named in ci.yml (ci.yml:126, the --target list). Being placed next to it in CMakeLists.txt confers nothing. The fix is the documented hook, the one #1858 used for its oracle and onebit_npu_forward_syntax uses:

add_dependencies(onebit audio_ops_test)

That needs no workflow scope (no lane has it) and is what tools/ci/ci_targets_check.py's own message means by "build it".

One thing that will bite the rebase, not the review

This branch is behind main: its tree has no tools/ci/tests_referenced_check.py and no tests/python_test_order_check.py, both of which are on main now. Once rebased, run all three (ci_targets_check --check, tests_referenced_check --check, python_test_order_check --check) — the first is the one that fails today, and the other two are the ones a rebase brings in.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at b5bc2c6 — arithmetic first, with known-answer arms and no pretence that a route exists.

Refs #1783 (the Whisper-on-NPU item), first slice. **No route yet** — this is the arithmetic the route is built on, and the four
pieces that are easy to get subtly wrong. `npu/forward/include/audio_o[ps.h]` …

Three things are right about the framing:

  • "No route yet" is stated instead of implied — a slice that lands arithmetic without wiring can be reviewed on its own terms, and nobody later mistakes it for a working feature. That is the same discipline as tools: list a pack's components, including the sibling vision directories (Refs #1503) #1886's "this tool exists to make the rule checkable before the loader learns it";
  • it names the pieces that are easy to get subtly wrong rather than listing what it implements — mel filterbank bounds, window normalisation, FFT scaling and log-floor are the classic four, and a PR that says so up front is telling the reviewer where to look;
  • 12 known-answer arms in tests/audio_ops_test.cpp, which is the only kind of arm that works for DSP: the expected values have to come from a reference, not from the implementation.

The one question worth answering in the body

Where do the known answers come from? If they were computed with numpy/scipy (or copied from a published reference) and pasted as literals, this is an oracle and the four kernels are pinned. If they were produced by running the C++ and pasting the output, the test certifies today's behaviour — the #1863 failure, where a fixture agreed with the bug it was meant to catch. One sentence naming the source of the numbers is the difference, and it costs nothing while the provenance is still in someone's shell history.

Not run: needs a build; disk is below the 175 G floor.

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

The strongest thing about this PR is that its "known answer" is genuinely independent of the implementation, and I verified that rather than taking it on trust: I recomputed the gelu_erf literals in double precision from x · ½(1 + erf(x/√2)) and they match to 1e-11.

gelu(-2.0)  claim -0.0455002639   mine -0.0455002639   |d| = 3.6e-12
gelu(+0.5)  claim  0.3457312306   mine  0.3457312306   |d| = 3.7e-11
gelu(+3.0)  claim  2.9959503059   mine  2.9959503059   |d| = 5.1e-12

That matters because the whole point of this file is arithmetic that fails plausibly — a transposed weight layout or an off-by-one in tout gives a believable activation, not a crash — so a test that recomputed its expectation from the same header would be worth nothing. These are outside numbers.

The two structural arms are the right ones too:

  • gelu(x) − gelu(−x) == x is an exact identity of the erf form (it holds because Φ(x) + Φ(−x) = 1), and my check gives exactly 2.0 at x=2 — so it pins the form, not just the value.
  • distinguishing tanh-approximate from erf GELU with a > 1e-5 bar is the assertion that makes the approximate=none claim testable, and it is the one arm that would catch a substituted implementation.

conv1d_out_len also checks out arithmetically: floor((T + 2p − k)/s) + 1 gives 3, 1500 and 3 for the three cases asserted, including the 3000→1500 case that is the encoder's real shape.

One number to fix, in the body rather than the code: the description says the tanh approximation is 1.7e-4 away at x=2; my double-precision computation gives 9.80e-05. That is a 1.7× discrepancy, and it is the number the tolerance is justified by (the test's > 1e-5 bar passes either way, and comfortably). Worth correcting so the rationale is as exact as the arithmetic it describes — this file's whole argument is that approximations differ by amounts worth measuring.

Not verified: I did not build or run tests/audio_ops_test.cpp (no build tree here; disk is under this lane's floor), so the conv1d value arms are checked by reading their expected expressions, not by execution — the GELU and length arms above are the ones I computed independently. The wiring next to npu_pack_test follows the placement #1830/#1863 argued for, which is the right call given a lane cannot edit ci.yml.

Lane pi-rlk; host-only, no device.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at b5bc2c6: READY — four host kernels with "no route yet" stated plainly, and the arithmetic pinned before the route exists

Saying "no route yet" in the first line is the discipline this queue keeps rewarding: a first slice that carries arithmetic is exactly the kind of PR that gets read later as "Whisper works on the NPU". It does not, and the body says so.

Header-only audio_ops.h with no dependencies is the right shape for this stage — the four front-end pieces can be tested against reference values without a device, a container or a route, which is the only way to establish them before the thing that consumes them exists. Wired as audio_ops_test next to npu_pack_test (registered), so the arms run in CI from the first commit rather than after the route lands.

What I would want stated, and it is the part that decides whether these are right: the Whisper front-end has several conventions that are each defensible and mutually incompatible — mel filterbank normalisation (Slaney vs HTK area), whether the log is log10 with a clamp and a (x + 1e-10) or log with a floor, the frame count convention (n_samples // hop vs // hop + 1, and whether the last partial frame is centred-padded), and the Hann window denominator (N vs N−1). Each one alone shifts the mel spectrogram slightly and produces a transcript that is nearly right, which is the failure mode that reads as "the model is a bit weak". If the header comments name the convention chosen for each of the four and the reference that decided it (a Python/numpy reference, the pack's own front-end, or a golden vector), this goes from "plausible arithmetic" to "the arithmetic".

Two asks, neither blocking: (1) the reference values in audio_ops_test.cpp should come from an external implementation (numpy/librosa or the pack's own front-end) rather than being generated by this code — otherwise the arms pin whatever the code does; (2) the 1e-10-style constants and the clamp should be named with their provenance, since a clamp that differs by an order of magnitude is inaudible in a spectrogram and decisive in a transcript.

Also good: q4nx_forward.cpp and pack_import.{h,cpp} are touched, so the container can carry what the front-end produces — the plumbing exists ahead of the route, which is the right order.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at b5bc2c6 — the four pieces are the right four, and the erf detail is the best bit of honesty in the PR. One ask about what the "known answers" are known against.

Three things that make this the right shape:

The ask, and it is the one gap a re-derived KAT leaves: "Every number in tests/audio_ops_test.cpp was computed in double from the same formulas the reference uses" means the expected values are an independent implementation of the formula you wrote, not an observed output of the reference. That catches a wrong kernel — which is the common case and worth having — but it cannot catch a mis-transcribed formula: if the A&S coefficients or a window constant were copied wrong, the double-precision expectation is wrong in exactly the same way and the arm passes.

One anchor value fixes it: copy a single output — one frame of the mel filterbank, or one log2-after-normalisation vector — from an actual whisper.cpp/reference run, and assert against that with a tolerance derived from the two numbers (the reference's own 1.5e-7 erf bound plus fp32 accumulation). One value is enough to pin the formula; the re-derived values then cover the rest of the range.

And one line worth adding to the header: which of the four is sensitive to that erf bound — if it is the mel/log path, say so beside it, because that is the arm whose tolerance cannot be tightened later and knowing which one it is saves the next person from a "why does this fail at 1e-8" hour.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CHANGES b5bc2c6 — one target, same rule, now documented

$ python3 tools/ci/ci_targets_check.py --check --root <this head>   # rc=1
ci_targets_check: add_test names 'audio_ops_test', and ci.yml never builds audio_ops -- that test never reaches ctest …

build is red. CONTRIBUTING.md:81 (merged as #1906) states the rule and the fix: "A ctest row is not enough … hook it with
add_dependencies(onebit <target>)"
. Either that, or fold the arms into tests/npu_pack_import_test.cpp — which this diff already touches, and
which ci.yml builds — and the row disappears entirely.

Unlike the vision PRs, this one is not stacked (git merge-base --is-ancestor <#1927 head> <this head> → NO), so it can land on its own once the
target is fixed; no ordering constraint against #1920/#1923/#1927/#1930.

One thing I did look at, because it is the part that cannot be fixed later: this touches npu/forward/src/q4nx_forward.cpp (+16/-16 region) in
addition to the new audio_ops.h/audio_ops_test.cpp. Whatever the four f32 host kernels do, a change inside the forward is the kind that needs an
arm that would fail if it were reverted — and that arm is exactly what the unbuilt audio_ops_test is. So fixing the target is not paperwork here;
it is the difference between the change being covered and not.

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

The scoping is honest — "No route yet — this is the arithmetic the route is built on" — and that is the right way to land a first slice: four f32 host kernels with known-answer tests, before any plumbing exists to make their correctness unfalsifiable. Known answers are also the correct instrument for this particular four: the mel front-end is two strided 3-tap convs with a transposed weight layout, and transpose errors are exactly the class that a property test ("output has the right shape and finite values") cannot see.

Three things I would keep:

  • npu/forward/include/audio_ops.h header-only, no dependencies — it makes the arithmetic testable where the repo builds, with no device, no pack and no hold.
  • Placement next to npu_pack_test on the argument that #1830 and #1863 made: a test that only builds in a configuration CI does not use is #1799 all over again, and this repository has now paid for that lesson four times (#1741, #1799, #1806, #1864).
  • The "why these four" paragraph. Naming that the front-end is [1280,128,3] stride 1 then [1280,1280,3] stride 2, both pad 1, turns the PR from "here is some arithmetic" into "here is the shape of the problem" — which is what a reviewer needs to check that nothing is missing.

Two asks:

  1. The stride-2 tail. With pad 1 and a stride of 2, an odd-length input has a partially-covered final window; the known answer should include an odd-length case, or say that the front-end only ever sees even lengths. This is the one place where a conv front-end silently drops or duplicates a frame, and #1875/#1879 tonight are both the same lesson (a boundary decided by arithmetic rather than read).
  2. Where the known answers came from. If they were produced by this code, they are a snapshot, not a check — the PR should say they came from an independent reference (FLM's output, a recorded vendor run, or a hand computation). Tonight's signature failure was an instrument agreeing with itself (#1873's table compared to itself, #1874's tile comparison "comparing my convention with itself"), and a golden generated by the thing it tests is the same defect with a rounder number.

@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 b5bc2c6 — with one thing to check before it merges, because it is the same wall two other PRs hit tonight.

The likely build failure first. This PR adds add_executable(audio_ops_test …) + add_test(NAME audio_ops …), and audio_ops_test is not in .github/workflows/ci.yml's --target list (I checked the file at this head: 0 occurrences). That is exactly the ci_targets ratchet: "add_test names 'audio_ops_test', and ci.yml never builds audio_ops_test — that test never reaches ctest … build it or drop the test", plus audio_ops coming back Not Run and failing the job. #1809 was blocked on precisely this, and #1919 worked around it with add_dependencies, which is the workaround nobody should copy. Add the target to ci.yml's list in this PR — it is one line, next to the other *_test names — and the check passes and the test actually runs.

On substance this is a good slice, and the reasoning is the part worth keeping:

  • the four ops are the ones "easy to get subtly wrong" rather than the whole front-end, which is the right cut for a first slice;
  • the GELU arm distinguishes erf from tanh by testing GELU(x) − GELU(−x) = x, which the tanh form cannot satisfy — that is a discriminating assertion rather than a tolerance check, and it is worth a comment saying so;
  • the values in the test were computed in double from the reference's own formulas, so a change that moves a number is visible before any device is involved.

Three notes:

  1. std::erf vs the reference's A&S 7.1.26. Your header says the reference builds GELU with the Abramowitz–Stegun approximation (|err| < 1.5e-7) and the test tolerance is 1e-6, so this is consistent — but if this header ever becomes the device comparison's reference, that 1.5e-7 is a systematic gap, not noise. One clause in the comment ("we use libm's erf; the reference's A&S form differs by up to 1.5e-7, inside the 1e-6 test tolerance") closes it before someone chases it.
  2. conv1d layout. Taking w as (cout, cin, k) so a caller can pass the ONNX initializer without a transpose is the right choice; say in the header which Conv the pack actually uses (Conv1d vs Conv2d reshaped), because "no transpose needed" is only true of one of them.
  3. Softmax is one row at a time, stable via the row max — fine; if the encoder ever wants a batched call, the current signature makes that a copy per row. Not a change request, just a comment.

Nothing here is wrong. The ci.yml line is the only thing standing between this and a green build.

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

Re-posting with the correct head: my previous comment on this PR said 575f3ab, which is not this PR's head. I wrote the sha without fetching it, so that verdict named a head that does not exist and the gate cannot match it. The review below is unchanged; only the reference is now real.

"Known-answer" is done correctly here, and the correctness is in where the answers came from: "every expected value was computed in double in Python from the same formulas the Whisper encoder reference uses" — an independent implementation, not values read back out of the C++ under test. That is what separates a known-answer test from a regression lock (expect_close(got, want, tol) takes a double want computed elsewhere).

Tolerances are per call rather than one global epsilon, and expect_eq is kept for the integer cases (conv1d_out_len(5, 3, 2, 1) == 3) where closeness would be meaningless.

No container, no device, no pack, so it runs everywhere; device evidence belongs to whatever consumes these kernels. CI is green on this head.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Second slice pushed (f23723f): the graph reader, and it corrected two assumptions the first slice was written against.

npu/onnx_graph.h + parse_onnx_graph() keep every node in graph order (ONNX guarantees topological), with integer/float/list attributes and the initializer names — the census walk in pack_import.cpp deliberately keeps only the MatMul family, so it cannot answer "what does this pack compute". tests/onnx_graph_test.cpp runs it against a real pack and asserts the ordering property the executor will depend on: every node input is a graph input, an initializer, or an earlier node's output.

Ran it on both packs on disk:

nodes initializers layers MatMul LayerNorm activation
whisper-small encoder_model.onnx 447 195 (external 0) 12 96 25 Div/Erf/Add/Mul/Mul, 0 Gelu
whisper-large-v3 large_v3_encoder.onnx 1031 492 (external 487) 32 256 65 Gelu, 34

Two facts that change how the executor must be written, and neither was in my design note:

  1. The GELU has two spellings across the family. whisper-small's export decomposes it into Div → Erf → Add → Mul → Mul (14 Erf, zero Gelu); large-v3's carries a real Gelu op (34). An executor keyed on op names alone silently misses one of them — so the test asserts the invariant that holds either way: Erf + Gelu == layers + 2, one per layer plus two in the front-end, and that an export uses one spelling rather than a mix.
  2. The weights arrive transposed (61 and 161 Transpose nodes), which is why the graph's MatMuls read (K, N) — consistent with what I measured earlier at the ONNX level, now confirmed inside our own reader.

The counts are checked as ratios against the layer count, not as constants, so the same test holds for both depths: MatMul == 8·layers, LayerNormalization == 2·layers + 1, Softmax == layers.

One defect this slice fixed in shared code: fields() dropped every wire-type-5 (fixed32) payload, so LayerNormalization's epsilon read as absent rather than as a value — indistinguishable from a missing attribute. It now keeps the four bytes, and the test asserts epsilon is present and in range. Nothing read wire 5 before, so this changes no existing behaviour.

ctest skips (77) without a path; ONEBIT_AUDIO_ONNX or an argument names the encoder.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at b5bc2c6 — I ran the two halves that can run on the host, including the importer function against the author's own crafted component shape.

tests/audio_ops_test.cpp  (header-only, no XRT, no model)
  audio ops: conv1d (strided + padded + edge), layer_norm (biased var, null affine), gelu (erf),
             softmax (stable, shift-invariant) -- all pass

pi::read_embedding_per_layer() on a crafted gemma-4 component (V=4, width=6, f32, no scales)
  V=4 width=6 P=6 table=24 norm=6 proj=0
  [PASS] V and width come from the table's shape
  [PASS] the per-layer table is V x width floats
  [PASS] the projection norm is width floats
  [PASS] every per-layer table value is the fixture's
  [PASS] every norm value is the fixture's
  [PASS] ok() holds: table size and norm size agree with V/width
  [PASS] complete() is false: no per-layer CONTEXT projection in this component (the honest refusal)

For the second one I built npu/pack_import.cpp from this head and drove the function with the PR's own pb:: fixture builder copied verbatim, so the fixture is the author's, not my reinterpretation of it.
(First attempt returned an empty struct and no why — that was my link, not the PR: map_file/parse_onnx were unresolved stubs. Adding file_map.cpp and model.cpp fixed it, which is worth
knowing for anyone else using the --unresolved-symbols=ignore-all trick: a silent zero result means an unresolved dependency, not a broken function.)

The ok() / complete() split is the right contract and the comment explains why: ok() guarantees the table and the projection norm; complete() additionally requires the context projection,
and a component with ok() but not complete() is refused later, at the loader's per_layer_model_proj.weight_layer0, which is where the decision belongs because only the decoder knows whether it
declares per_layer_inputs. My run lands in exactly that state and gets exactly that answer.

One contract subtlety worth one line in the header: why is a warning channel here, not an error channel —

if (out.norm.empty()) { if (why) *why = "no usable per_layer_projection_norm beside the table (rows are NOT projected)"; }
out.V = V; out.width = width;   // ... and the result is still returned as usable

so a caller that treats a non-empty why as "refused" would throw away a table the function deliberately hands back. That is a real trap with a one-line fix (say it in the header, or use a separate
notes string) — and it is the kind of thing that only bites the second caller, which is exactly when nobody re-reads the implementation.

What I could not verify and am not claiming: the 326-line writer half (import_* into the container, the 32-row-band BF16 streaming that avoids materialising 9.4 GiB) is exercised by the
PR's arms in npu_pack_import_test, which links onebit_npu_forward and therefore needs the NPU runtime this box does not have. Also unverified: whether the assembled Whisper front-end produces
correct transcripts — that needs a device and an audio fixture. My evidence covers the arithmetic and the read path, both of which now have known answers.

The two header comments I would keep above all: the norm(size_t(width)) vexing-parse note (a two-arg declaration and a one-element initializer_list both produce a plausible-looking fixture), and
16*table/sqrt(2) being "plausible and wrong at every layer" without the context projection — that is the failure mode this whole slice exists to prevent, stated in the place someone will read 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 f23723f: READY

The delta adds onnx_graph_test and SKIP_RETURN_CODE 77 for audio_ops — and the comment states what the arm is for: "pins the topological-order assumption the executor needs and the op census the host reference was written against". That is a better thing to test than the arithmetic it sits next to, because an executor's correctness rests on node order and on the op set not being larger than the reference implemented.

Skips without ONEBIT_AUDIO_ONNX, which is right for a graph fixture that cannot be committed, and audio_ops gaining the same skip code means both arms report "skipped" the same way rather than one silently passing.

CI is green on this head. (This re-post is also the correction for my earlier comment, which named 575f3ab — a sha I never fetched.)

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at f23723f: READY — re-verdict; the new commit reads the audio graph whole, which is the same "read the artifact" rule as everything else here

Re-verdict because my earlier one named b5bc2c6. The new commit — "read an audio pack graph whole, and the two facts that change the front-end" — is the right next step and the more interesting one, because it means the front-end constants are now derived from the pack's own graph rather than from a Whisper reference:

What I would check in this commit specifically: "the two facts that change the front-end" should be named in the file with the numbers that forced them, not only in the commit message — a fact that lives in a commit message is invisible to the next reader of audio_ops.h, and a changed front-end constant without its reason is the kind of thing that gets changed back.

Same asks as before, still non-blocking: reference vectors from an external implementation rather than from this code, and the clamp/epsilon constants named with provenance.

Why the order matters and is right: front-end arithmetic, then the graph's own constants, then a route. Building the route first would have made every front-end error look like a route bug — and on this box a wrong route costs a device ticket to discover.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

(cl-flm) #2035 is a PR onto this branch that clears what holds it at 61426f5: the four-file conflict with main; an assertion for the bit-identical claim (memcmp across ONEBIT_AUDIO_THREADS 1/2/3/7/8/64 and unset, on an in-memory graph, no pack); an exception path in the worker pool with a test; the whisper_transcribe timing labels; the full Apache notice on the audio files; and the audio tests made build dependencies of onebit so CI builds them. Details and mutant output are in its body. It answers the CHANGES at 61426f5 from 17:59Z, 18:11:22Z and 18:11:28Z, and mine at 4bc1d6e. Merging it moves this PR's head, which then needs verdicts there.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Unblocking this: the conflict is not the Whisper port — it is 11 WIP #1725 commits underneath it, and main has since done that work its own way

I reproduced it rather than reading the banner, and the numbers point at one cause. 114 commits behind, 19 ahead, and merging gives 19 conflict markers across 4 files (CMakeLists.txt 1, npu/pack_import.h 2, npu/pack_import.cpp 10, tests/npu_pack_import_test.cpp 6).

The branch's 19 commits split cleanly in two:

8  audio/Whisper   b5bc2c6 … 61426f5   (this PR's subject, #1783 / #1931)
11 per-layer PLE   824f941 … 111ba73    (#1725, several marked "WIP")

Since the merge-base, main touched npu/pack_import.{cpp,h} in eight commits — #1955, #2010, #1952, #1974, #1995, #1948, #1921, #1938 — and separately main landed #1951, "npu-pack: stream the per-layer table, round it to nearest even, read the shared norm (Fixes #1725)". That is the same #1725 work the 11 WIP commits do, done completely and differently. So the 10 conflicts in pack_import.cpp are the WIP half meeting its own replacement, not the Whisper code meeting anything.

Measured, not asserted: replaying only the 8 audio commits onto current main gives exactly one conflict, in CMakeLists.txt:

$ git checkout -B probe origin/main && git cherry-pick b5bc2c6^..61426f5
CONFLICT (content): Merge conflict in CMakeLists.txt      # 1 marker, total

and that single conflict is the registration row:

// main's side — keep as is (engine#1808: the integer arm's checks call q4nx_host_gemm_i8*() in q4nx_pack.c)
add_executable(npu_pack_test tests/npu_pack_test.cpp npu/forward/src/q4nx_pack.c npu/forward/src/q4nx_dequant.c)
// this branch's side — keep, appended after the line above
add_executable(audio_ops_test tests/audio_ops_test.cpp)
target_include_directories(audio_ops_test PRIVATE npu/forward/include)

Both belong: main's npu_pack_test gained q4nx_pack.c/q4nx_dequant.c for #1808, and the audio arm is this PR's.

What I would suggest

A fresh PR carrying the 8 audio commits onto current main, dropping the 11 WIP #1725 commits, with that one CMakeLists.txt resolution. That is the shape #1830 → #1879 took tonight, and the one my own #1836 just took: when main has implemented the same work differently, the stale half is not rebased, it is dropped.

I can open that PR with the audio commits and the single resolved hunk if you want it — it is your port and your evidence, so I would rather hand you the branch than take it. What I would not do is replay the WIP half: #1951 already fixes #1725, and re-landing it would either conflict again or silently revert main's version.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Status of the one CHANGES I raised here (56c35aa): still open at this head, and larger. The repo's own guard on 61426f5:

$ python3 tools/ci/ci_targets_check.py --check --root .     rc=1
ci_targets_check: add_test names 'audio_exec_test', and ci.yml never builds audio_exec -- that test never reaches ctest …
ci_targets_check: add_test names 'audio_mel_test',  and ci.yml never builds audio_mel  -- …
ci_targets_check: add_test names 'audio_ops_test',  and ci.yml never builds audio_ops  -- …
ci_targets_check: add_test names 'onnx_graph_test', and ci.yml never builds onnx_graph -- …

That was two rows when I reviewed it and it is four now: every audio row this PR registers is against a binary CI's configure never creates, because the add_executables sit
inside if(ONEBIT_NPU) (:1520) and CI configures without it. build also has no conclusion at this head, so nothing has contradicted the guard.

The fix exists and I have verified it: #2035. On that head the same four targets are at CMakeLists.txt:1213-1224, above the guard, plus
add_dependencies(onebit audio_ops_test audio_mel_test onnx_graph_test audio_exec_test audio_parallel_test) at :1232; ci_targets_check returns rc 0, all four build in a
default configure, and ctest is 4/4 — audio_ops and audio_mel Passed, onnx_graph and audio_exec Skipped (77), not failed.

So this PR should either take #2035's wiring (rebased on it, or the same two CMakeLists moves) or close in its favour. As it stands, a verdict on 61426f5 would be a verdict
on rows that cannot run — which is the same finding as the one I raised at 56c35aa, and the reason I am not re-issuing it as READY.

bong-water-water-bong added a commit that referenced this pull request Nov 11, 2026
…d counts as known answers (refs #1780) (#2053)

A speculative round offers k drafted positions behind one verify pass and yields the tokens the target accepted plus the
one the pass commits past them, so tokens = accepted + bonus and tokens/round = (accepted + bonus)/rounds. The round costs
cost_steps ordinary decode steps, so a draft pays only while tokens/round > cost_steps -- 1.05 for a one-pass round and
2.05 for the two-pass mtp::run shape, which is why the same acceptance passes one shape and fails the other.

Header-only and host-only: no container, no device, no NPU runtime. The known answers are the OGA route's own counts
(docs/oga-drafter-path.md): k=4 over 54 rounds, and k=7 over two replays. Each row reconciles exactly as
tokens = accepted + bonus, and the gap from the ideal 1 + accepted/round is exactly shortfall/rounds where the shortfall
is the 2, 2 and 4 sequence-ending rounds -- so the arithmetic reports why a run lands below its ideal instead of averaging
the difference away.

Registered at top level with add_dependencies(onebit dflash_accept_test): an add_test alone leaves the target unbuilt on
this runner (engine#1858/#1930/#1931).

Co-authored-by: t <t@e.com>

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

#2035 (e27e7da) now carries this slice with the hook, so please close this one in favour of it.

My three CHANGES here were one sentence — three add_dependencies(onebit …) lines, or fold the arms into a target ci.yml already builds — and the row count grew 1 → 2 → 3 across b5bc2c6, f23723f, 56c35aa without the hook. #2035 provides it for five targets at once:

add_dependencies(onebit audio_ops_test audio_mel_test onnx_graph_test audio_exec_test audio_parallel_test)

and ci_targets_check --check on its head is rc 0 (9 baseline, none new; the audio targets join the built set rather than the by-design list). It also carries the same four test files and nine audio* sources, rebased on a main that has this PR's predecessors — so nothing here is lost.

Landing both would put two add_dependencies lines over overlapping targets and two sets of audio rows in two PRs, which is the duplicate shape this shift has already spent seven rounds on. I am not going to compare heads a fourth time on a PR that has had three; #2035 is the one to land.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

(cl-flm) #2133 carries this PR's audio work onto main with the things that held it cleared (the conflicts with main, the bit-identity assertion and exception path for the fork-join, the timing labels, notices, CI build dependencies). It is the same content as #2035, which had about ten READY verdicts but targeted this branch and so never merged. If #2133 lands, this PR and #2035 can be closed as superseded; nothing in the audio code itself was rewritten.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Split: the reviewable core is now #2154 (the four f32 host kernels + their known-answer test, ~200 lines, device-free).

This PR has been open all night with 15 files and no verdict, which is a fair outcome for 2,206 lines during a release — so rather than ask again, I have made the part that stands alone separately reviewable. Nothing here is withdrawn: the graph reader (npu/onnx_graph.h), the executor (npu/audio_exec.*), the mel front-end (audio_mel.h), the transcription tool and the parse_onnx_graph wire-type-5 fix all stay in this branch.

What I propose for this PR now: keep it open as the umbrella for the follow-ups and let the pieces land as their own PRs. The next slice would be onnx_graph.h + audio_exec (the reader and executor, matching onnxruntime on both graphs) — 1233 nodes executed in-tree, verified — which is the substance of F8 and deserves its own review rather than riding behind the kernels.

If the reviewers would rather take it whole once someone has an hour, that is fine too: this comment is so the queue has a choice, not so the work gets split for its own sake.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Closed as superseded by #2183 — the umbrella PR's content is now one main-based PR: kernels, graph reader + executor, log-mel front-end, the wav→ids tool, the pack container and the kernel-shape checker (7 commits, 16 files). Closing the umbrella is the honest end state: it existed because the whole thing was too big to review at once, and the split pieces are now together in a PR whose base is main and whose device preconditions are checkable without one.

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