Repository navigation
npu: the Whisper front-end four f32 host kernels, known-answer tested - #1931
bong-water-water-bong wants to merge 19 commits into
Conversation
…ken table (WIP, engine#1725)
The gemma-4 1.9 components carry, beside `embed_tokens.weight`, a PER-LAYER table and a projection norm:
embedding.embed_tokens_per_layer.weight (262144, 8960) f32, external (= 35 layers x 256 head_dim)
embedding.per_layer_projection_norm.weight (256,) f32
The decoder consumes them as `per_layer_inputs`, so a container with only the token table gives the decoder less than it
asks for -- which is why the previous commit refused such a component by name. This adds the reader, so the next slice can
write the container section the forward path will load.
EmbedPerLayer { table [V][layers*head_dim] row-major, norm [width], V, width } + `ok()`;
read_embedding_per_layer(pack_dir, why) resolves the component exactly as the token half does -- genai_config.json's
model.embedding.filename (relative only, no ".."), external data beside the GRAPH rather than at the pack root -- reads
f32, refuses any other dtype by name rather than mis-decoding it, and says so when the projection norm is missing
(the table is still usable, but a consumer expecting a projected row would silently take raw ones).
WIP: no container section and no forward consumer yet; that is the next slice, with its test. Builds clean
(`npu_pack_import_test` rc 0). No vendor code or layout: only the pack's own ONNX names and dims are read.
…(WIP, engine#1725)
Second half of the reader in the previous commit: `import_pack()` now writes the per-layer table and its projection norm
beside the token table --
model.embed_tokens_per_layer.weight BF16 {V, layers*head_dim} (from the component's f32)
model.per_layer_projection_norm.weight BF16 {layers*head_dim}
-- and the slice-1 refusal in `read_embedding_component()` is relaxed to match: a component that carries
`embed_tokens_per_layer` is no longer refused by that reader, because the mechanism it was waiting for now exists.
Measured on the real pack with this change (`1bit npu-pack info ~/scratch-packs/pi-demo/e2b`, loader built from main):
before: the pack has no model.embed_tokens.weight, and the component carries embedding.embed_tokens_per_layer.weight,
i.e. a PER-LAYER embedding table ... the per-layer embedding mechanism is not implemented here yet
after: rc 0, a full plan -- dims hidden 1536 / 35L / 8 heads / 1 kv / head_dim 256 / IM 6144 / vocab 262144,
423 tensors, and 350 `incomplete` entries that all say the same NEXT thing:
"layer blocks not located: the epsilon headers do not repeat with a single period"
So the embedding half is no longer what blocks a gemma-4 pack: the next wall is the Token .fconst block layout, whose rule
row cannot be measured for gemma-4 (no 1.8 twin exists) -- the `txn_bins` preformat route designed on engine#1503.
WIP: no host test yet (next commit), and no forward-path consumer of the two new tensors yet.
… that made it fail first (engine#1725)
Test for the two WIP commits before it: `read_embedding_per_layer()` reads the component's
`embed_tokens_per_layer` (V x layers*head_dim) and `per_layer_projection_norm` (width) as f32, resolving both through
genai_config.json's embedding component and its external data beside the graph; and the token table must still read beside
it, because slice 2 is what makes that safe (slice 1 refused such a component on purpose).
Two fixture mistakes are worth the comment they now carry, because both produce a fixture that looks right:
* `std::vector<float> norm(size_t(width))` is a FUNCTION DECLARATION (most vexing parse);
* `std::vector<float> norm{size_t(width)}` is an initializer_list of ONE element = width, so the table is 1 value wide
and the reader correctly reports "declares more bytes than its data carries" for the norm.
The two-arg form is the only one that does what it looks like.
Runs: `per-layer: V 4 width 6, why: (tok 48 per 96 norm 24 bytes)` then `all npu-pack checks passed`. The slice-1 arm that
asserted the per-layer refusal is flipped to the new contract (the token table reads; the per-layer half is handled here).
…by three numbers (engine#1725)
The band the per-layer embedding feeds is `out[k] = table[id][k] * norm[k]` over the layer's own slice of a
layers*head_dim-wide row; that is the whole arithmetic, so it lives in a header-only function with no container and no
device (`npu/forward/include/per_layer_embed.h`) and a test that can be read as the spec:
row0 {1,2,3,4} * norm {2,2,0.5,0.25} -> {2,4,1.5,1}
row1 {-1,0.5,0,8} * same norm -> {-2,1,0,2} (a different id picks a different row)
null norm -> the row passes through, because a MISSING projection is not a ZERO one
id out of range, or no table -> output untouched (the loader can hand a short container)
Runs: `per-layer gather: row0 -> 2.00 4.00 1.50 1.00` then all checks pass.
Also in this commit: the test target gains `npu/forward/include` on its include path (it had only the engine's own), and a
comment records the trap I hit twice in this file: `std::vector<T> v(size_t(n))` is a FUNCTION DECLARATION, and
`std::vector<T> v{size_t(n)}` is an initializer_list of one element — only the two-arg form does what it looks like.
…/complete() (engine#1725, WIP)
Tracing the component's graph end to end (not its node list) showed the per-layer input is
( rmsnorm((inputs_embeds @ per_layer_model_projection.weight_t_Q4) * H**-0.5, per_layer_projection_norm) + 16*table[id] ) / sqrt(2)
so the table and its norm are only TWO of the three tensors: the context projection is the third, and without it the
per-layer input is `16*table/sqrt(2)` -- plausible, and wrong at every layer. The component carries it quantised
(`per_layer_model_projection.weight_t_Q4` [8960,48,16], `..._t_scales`, packed `..._t_zero_point`), and the loader wants it
dequantised as `model.per_layer_model_proj.weight_layer{l}` [P][H] BF16 per layer.
* `read_embedding_per_layer(pack_dir, hidden, why)` now dequantises that projection through the file's own
`decode_matmulnbits()` (same decoder the rest of the importer uses) and holds it as [NL][P][H] f32; `hidden` is a
parameter because it comes from the pack's geometry, not from the component.
* `ok()` is what the reader guarantees (table + norm); `complete()` additionally requires the projection. A component
with the first but not the second imports the table and is refused by the LOADER on `per_layer_model_proj.weight_layer0`,
which is the honest place to refuse -- the importer does not know whether the decoder declares per_layer_inputs.
Measured on `amd/gemma-4-e2b-it-mobius-int4_rai_1.9.0_npu`'s component: table 262144x8960 f32, norm 256 (shared by all 35
layers -- the graph reshapes to [.., 35, 256] around the norm), projection K=1536 N=8960 block 32 int4.
WIP: the container still writes the two old names; the rename to the loader's names and the projection emission are the next
commit, together with the forward's BF16 band path for the table.
… loads (engine#1725, WIP)
The forward's `build_ple_inputs()`/`load_ple_block()` already implement this family's arithmetic -- I traced the component's
graph and it is line for line the same: `context = rmsnorm((emb @ W_l) * H**-0.5, norm)`, `identity = 16 * table[id]`,
`per_layer_input = (context + identity)/sqrt(2)` -- but it reads different names than the importer wrote. This commit closes
that gap:
model.per_layer_token_embd.weight BF16 {V, NL*P} (was model.embed_tokens_per_layer.weight)
model.per_layer_proj_norm.weight BF16 {P} (was model.per_layer_projection_norm.weight)
model.per_layer_model_proj.weight_layer{l} BF16 {P, H}, l = 0..NL-1 (was absent)
The token table is **streamed**, never materialised: `read_per_layer_table_band()` maps the component's external data and
converts 32 rows at a time from f32 to BF16 straight into the container blob, because the full table is V x NL*P f32 =
9.4 GiB on gemma-4-e2b and a converting copy of that would be the largest allocation in the import.
Two bugs of my own found while writing it, both worth the comments they carry:
* the first version of the band reader did `memcpy(dst, src, n * 4)` into a `uint16_t*` of n elements -- 4 bytes per
element into a 2-byte-per-element buffer, i.e. a 2x overflow. Removed before it ran;
* the projection matrices were being dequantised but never written; `pl.complete()` (table + norm + projection) is what
now gates the per-layer emission, and a component with `ok()` but not `complete()` still writes the table, leaving the
refusal to the LOADER (`missing model.per_layer_model_proj.weight_layer0`), which is the honest place.
All existing checks pass; copyright rc 0. WIP: the forward still lacks a RAW-BF16 band path in `load_ple_block` (it handles
plain int8 + scale, or the chunked container formats), so this container is one loader branch away from being readable.
`load_ple_block()` handled two shapes: plain int8 + a per-32-column `.scale`, and the chunked container formats. The multi-component packs state this table as f32 and the importer converts it to BF16 when it writes the container -- a converting copy of the whole V x NL*P table would be 9.4 GiB on gemma-4-e2b -- so the container's table is raw BF16 and neither branch matched it. The band is the same shape the int8 branch handles (32 rows x cols), with a 2-byte element instead of a code, so this is a loop of `bf16_to_f32` over the band, chosen by data_size: a multiple of the BF16 band and NOT the plain-int8 size. That ordering matters -- `plain_bytes` (one byte per element) and a BF16 band differ by a factor of two, and testing int8 first would take a BF16 table whose size happens to equal `plain_bytes` for another geometry. With this, a container written by the importer (#1725's `83c323b`) is readable end to end on the embedding side: table (banded), norm (256), and the per-layer context projections.
…el's arithmetic (engine#1725)
The previous commit on this branch added `per_layer_gather()` computing `table[id] * norm`, from a reading of the component's
node LIST. Tracing the component's graph end to end showed something else:
per_layer_inputs = ( rmsnorm((inputs_embeds @ per_layer_model_projection.weight_t_Q4) * H**-0.5,
per_layer_projection_norm) + 16*table[id] ) / sqrt(2)
-- the norm applies to the projected CONTEXT, the table row is ADDED (times 16 = sqrt(256)), and the sum is scaled by
1/sqrt(2). That is exactly what the forward's `build_ple_inputs()` already computes, so the primitive was not merely
unused, it was wrong, and a pure function with a passing test is exactly the wrong thing to leave in a tree for someone to
call later.
Deleted: `npu/forward/include/per_layer_embed.h`, its test, and the test target's include line it needed. The container and
loader work that DOES match the graph stays (earlier commits on this branch): the three PLE tensors written under the names
the forward reads, the streamed BF16 table, and `load_ple_block()`'s raw-BF16 band path.
Deleting beats keeping a wrong primitive as documentation: the retraction is recorded on engine#1725 with the graph, which
is where someone changing this will look.
…engine#1503)
`fconst19_detect()` requires ONE stride for the whole file. The mobius line does not have one -- measured on
gemma-4-e2b: 35 blocks, each opening with 8 anchors (8 heads x 4 B = 32-byte header), sizes 21.87 MiB to 288.06 MiB --
so the detect fails and the pack was reported the same way as a file that is simply not a 1.9 fconst at all.
* `fconst19_block_starts()` is the run scan on its own (exactly `layers` runs of `heads*4` anchor bytes), so a caller can
tell the two cases apart before the stride test;
* when the runs are there but the strides differ, the refusal now NAMES the structure: how many blocks, where the first
one starts, and the size range in KiB, plus why the rule table cannot be the answer ("one stride per file" is the
table's model) and what the answer is (the per-layer block table; the anchors give the starts, and each block's size
follows that layer's own projection shapes).
On the real pack:
1bit npu-pack: … this IS a Ryzen AI 1.9 Token .fconst (35 blocks of 32 B headers at 2564864 B) but its blocks are NOT
uniform: 22395 KiB to 294973 KiB, i.e. a variable-stride container. The rule table describes one stride per file, so no
row can fit it; this family (mobius/MatFormer) needs the per-layer block table instead …
Crafted arm: four blocks with different body sizes are refused by name (`variable stride: refused by name
(variable-stride container)`), so the distinction is pinned without a 1.3 GiB pack.
…ot by arithmetic on the size Review of #1825 caught a real collision class in my BF16-band branch: it separated the two layouts with data_size >= (block+1)*bf16_bytes && data_size % bf16_bytes == 0 && data_size != plain_bytes and every multiple of the band satisfies the first two, so a plain table whose byte count happens to divide would be read as 32-row BF16 bands -- "plausible and wrong", the failure my own comment warns about. The tree already decides this way elsewhere (`model.c` does `strcmp(dtype, "I8")` on the same field) and `q4nx_dequant.h`'s rule is that an ambiguous tile count is refused rather than resolved by which test happens to pass. * BF16 is taken only when the container SAYS BF16, and then its size must be whole 32-row bands (else refused); * plain I8 (or Q4) keeps the existing int8 + .scale path; * any other DECLARED dtype is refused by name, and so is an EMPTY one -- a container written by this importer always states a dtype (`w.add()` writes it for every tensor), so "no dtype" means a foreign or truncated container, not a layout this loader may guess. `model.c` populates `dtype` for every tensor entry that carries the key, which is what makes the branch sound rather than merely tidier; that is stated in the comment next to the test.
…to switch the block on (engine#1725)
The per-layer tensors are useless without the two numbers the forward's semantics reader gates the whole block on
(`hidden_size_per_layer_input > 0`, q4nx_semantics.c). gemma-4 states them in model_config.json's text_config
(256 and 262144); the container's own config.json did not carry them, so an import would write the table, the norm and the
per-layer projections and then the forward would skip the block silently -- the worst failure shape, because the container
looks complete.
* when the component is `complete()` (table + norm + context projection), the container's config.json gains
`hidden_size_per_layer_input` = P and `vocab_size_per_layer_input` = V, taken from the COMPONENT (where they are
measured) rather than from the pack's config, which may not state them at all;
* the three Gemma-4 layer-shape switches the same reader consults -- `num_kv_shared_layers`, `use_double_wide_mlp`,
`extra_layer_norms` -- are carried through when model_config.json's text_config states them, and left absent otherwise
so the reader's defaults apply.
The read happens at the config-writing site, which lives in a different writer from the one that emits the tensors: one
metadata parse (names, dims, the quantised projection header -- the 9.4 GiB table is NOT read there) rather than plumbing a
value across two functions.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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.
VERDICT: READY at b5bc2c6The 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
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
VERDICT: READY at Three things are right about the framing:
The one question worth answering in the bodyWhere 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
left a comment
There was a problem hiding this comment.
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) == xis an exact identity of the erf form (it holds becauseΦ(x) + Φ(−x) = 1), and my check gives exactly2.0at x=2 — so it pins the form, not just the value.- distinguishing tanh-approximate from erf GELU with a
> 1e-5bar is the assertion that makes theapproximate=noneclaim 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.
VERDICT at
|
|
VERDICT: READY at Three things that make this the right shape:
The ask, and it is the one gap a re-derived KAT leaves: "Every number in One anchor value fixes it: copy a single output — one frame of the mel filterbank, or one And one line worth adding to the header: which of the four is sensitive to that erf bound — if it is the mel/ |
|
VERDICT: CHANGES
Unlike the vision PRs, this one is not stacked ( One thing I did look at, because it is the part that cannot be fixed later: this touches |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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.hheader-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_teston 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:
- 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).
- 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
left a comment
There was a problem hiding this comment.
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:
std::erfvs 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.conv1dlayout. Takingwas(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.- 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
left a comment
There was a problem hiding this comment.
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.
…the executor (engine#1931)
|
Second slice pushed (
Ran it on both packs on disk:
Two facts that change how the executor must be written, and neither was in my design note:
The counts are checked as ratios against the layer count, not as constants, so the same test holds for both depths: One defect this slice fixed in shared code:
|
|
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. For the second one I built The One contract subtlety worth one line in the header: 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 usableso a caller that treats a non-empty What I could not verify and am not claiming: the 326-line writer half ( The two header comments I would keep above all: the |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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.)
VERDICT at
|
|
(cl-flm) #2035 is a PR onto this branch that clears what holds it at |
Unblocking this: the conflict is not the Whisper port — it is 11 WIP
|
|
Status of the one CHANGES I raised here ( 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 The fix exists and I have verified it: #2035. On that head the same four targets are at 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 |
…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
left a comment
There was a problem hiding this comment.
#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.
|
(cl-flm) #2133 carries this PR's audio work onto |
|
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 ( 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 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. |
|
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 |
…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>
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) plustests/audio_ops_test.cpp, wired asaudio_ops_testnext tonpu_pack_testbecause 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 intoutproduces a plausible-looking activation, not a crash. The GELU isapproximate=none, i.e. the exact erf form; the tanh approximation is1.7e-4away at x=2, sogelu_erfand a tanh substitute are distinguishable at the 1e-6 this test runs at (asserted explicitly).layer_normuses the biased variance with eps under the root, andsoftmax_rowsubtracts 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 (including3000 -> 1500for 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.3416270and the true value is1.34163542— which is the test doing its job on its own author. Fixed to the double-computed constant;ctest -R audio_opspasses.Not in this PR: the graph executor that walks the pack's
encoder.onnxand 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.