Repository navigation
npu: one vision transformer block and the merger, host float32 (engine#1503 W4) - #1927
bong-water-water-bong wants to merge 5 commits into
Conversation
…, not a literal (engine#1841)
The rule `2^(bits-1)` existed twice with two different derivations of the same parameter: the projection path passed a
hardcoded `4`, the head path passed the node's own `bits`. Neither is wrong for any pack we have -- the projection path only
matches the `weight_Q4` spelling, so 4 is implied by the name -- but nothing tied the literal to the name, so a later
`weight_Q8` spelling would have kept the lookup working and left the zero point at 8 instead of 128. That is a silent wrong
weight, not a crash.
`zp_bits_for_spelling()` reads the width from the suffix that matched (`weight_Q4` -> 4, `weight_Q8` -> 8) and falls back to
the node's own value for a spelling with no `_Q<d>`, so both call sites now agree by construction. Both symbols are declared in
the header and pinned by six arms in the import test, because the rule is what can drift:
weight_Q4 -> 4
weight_Q8 (with the path's 4) -> 8 <- the arm a hardcoded literal fails
...MatMulNBits.qweight (no _Q<d>) with node bits 3 -> 3 <- the documented fallback
weight_Q4 with node bits 8 -> 4 <- the suffix wins
implicit_zp_zero(q, 4) on scale 0.5 -> zero -4.0
implicit_zp_zero(q, 8) on scale 0.5 -> zero -64.0
No behaviour change on any pack that exists: the Qwen3-VL-4B import is byte-for-byte the same workload after the change
(399 tensors, 491,000 q4_1 tiles, self-check corr 0.993981), and the whole npu-pack suite passes (0 FAIL).
Closes the second point of engine#1841. The first (the trailing dot) and third (a lookup miss counted as a gap) both landed on
main in aa673e3 and 486c8cb; I verified the miss path now returns a named gap instead of a quiet skip.
…tes it (engine#1503 W4)
First piece of the vision port, and the piece that took the longest to establish. `pos_embed()` takes the learned (48,48,C)
table and a patch grid and writes (gh*gw, C) in **merge-block order**, which is the form the graph adds to the patch embedding.
Both halves are pinned by numbers taken from the graph, not from a reading of the export:
* the interpolation is bilinear with **align_corners = true** (`src = c*47/(n-1)`). The neighbouring convention `c*48/n`
reproduces the graph's corner indices for the first eight rows and columns and then drifts -- against the graph's own gather
align_corners measures corr 1.00000000 / mean|d| 5e-8, and `c*48/n` measures 0.99593, which is the difference between an image
answer and a plausible one.
* the reorder is one permutation, `perm = [0 1 gw gw+1 | 2 3 gw+2 gw+3 | ...]`: group g is the 2x2 block at
(2*(g/gw2), 2*(g%gw2)). Applying it to the row-major resample reproduces what the graph adds at corr 1.000000 / mean|d| 5e-8.
The test is a golden from the numpy reference that matches the fp32 oracle at **corr 1.000000, mean|d| 4.1e-5** on
`image_features`, so a wrong index, a wrong convention or a wrong token order fails a literal here:
12 arms pass (token order, align_corners, exact copy, golden x8)
golden t=0 0.000000000 0.001000000 0.002000000 t=2 0.966857143 0.967857143 0.968857143
golden t=47 3.041571429 ... sum of all 96 tokens = 995.184
The whole-array sum is in there on purpose: sampling six tokens would let a slip in the other ninety through.
Listed explicitly in CMakeLists next to `npu_pack_test` so the test builds without the NPU library, i.e. it runs on any lane.
Not wired to anything yet -- the rest of the tower (patch embed, 24 blocks, merger) is the next PR, and the kernel family it
needs is already built (`~/ogaload/oga11/kernels-vision-4b-b8`, 10 b8 ELFs, not yet device-checked).
…y goldens (engine#1503 W4)
Second piece of the vision port, and the other detail that took several wrong attempts. The graph's rope is
`x*cos + rotate_half(x)*sin` with `cos = Cos(Concat(a, a))`, i.e. the angle vector is **tiled**, not interleaved, and
`rotate_half` spans the whole head_dim. Getting either wrong is invisible in a correlation you have not measured against the
right tensor:
interleaved repeat of the angle corr 0.44
tile, but rotating each half separately corr 0.99, never 1
tile + rotate_half over head_dim corr 0.999996 vs the graph's own post-rope q (mean|d| 3.3e-3, |ref| 1.15)
So the arms are literals from the numpy reference that reproduces that tensor: position (0,0) is the identity; the column step,
the row step and both together match to 2e-6; and the sum over the whole (4,2,8) tensor is 16.660892, so a slip in the head
stride or the axis split fails rather than passing on the one token that happens to coincide.
vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal)
`inv_freq` is passed in (the pack's own `visual.rotary_pos_emb.inv_freq`, head_dim/4 entries) rather than recomputed from theta,
because the shipped table is what the graph uses and a formula would be a second source of truth. Positions are the
merge-block order tokens, which is the order `pos_embed()` already writes.
Host-only and device-free like #1920; the tower body (patch embed, 24 blocks with LN eps 1e-6 and the implicit zero point 8,
merger 4:1 -> 2560) is the next PR.
…e#1503 W4)
Third piece of the vision port: `block_forward()` (norm -> qkv -> rope -> attention -> proj -> residual -> norm -> fc1 ->
gelu-tanh -> fc2 -> residual) and `merger_forward()` (per-token norm, view 4 tokens as 4*hidden, fc1 -> gelu -> fc2 to
`out_hidden`). Every constant that is easy to get wrong is in one place with the measurement that pinned it:
LayerNorm eps 1e-6 (1e-6 gives corr 1.000000 against the graph's norm, 1e-5 is 0.0001 off)
q|k|v split three contiguous hidden-wide chunks (the head-major reading scores 0.41 where this scores 0.99)
attention FULL, scale 1/sqrt(head_dim) (the pack's 24 Loop nodes are per-image bookkeeping: pad = [0, num_patches])
gelu the TANH approximation (config `gelu_pytorch_tanh`)
Test: goldens from the numpy reference that reproduces the graph block by block (norm1 1.000000, qkv >0.999 in every column,
attention 0.999994) and the whole tower at corr 1.000000 against the fp32 oracle. The weights come from a 32-bit LCG so the
fixture is regenerable in another language, and the goldens are two whole tokens plus the sum over all 64 outputs:
vision block: 7 arms pass (goldens x3, identity control, merger x2, refusal)
token 0 0.08357919 0.07403285 0.03188136 -0.16534122 …
token 3 -0.02847019 0.34347527 0.18980209 -0.23568934 …
sum over the block's 64 outputs = -2.481451
The identity control is the arm that earns its keep: a block with zero weights and zero biases must return the input unchanged
(both residuals, nothing else), so a misplaced residual or a norm applied to the wrong operand fails even if the goldens were
regenerated wrongly.
Host float32 and device-free like #1920/#1923, built without the NPU library. Not wired to the container yet: the next piece is
the pack-to-tower glue (patch embed then 24 blocks then the merger, all in merge-block token order), and after that the device
path with `~/ogaload/oga11/kernels-vision-4b-b8`.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 72d0023: CHANGES
Checked at 72d0023. The block's constants are each pinned by a measurement against the graph (LayerNorm eps 1e-6 at corr 1.000000; the contiguous q|k|v split, 0.99 against 0.41 head-major; full attention at 1/sqrt(head_dim); tanh gelu from the config). I built this head: ctest -R '^q4nx_vision_(block|pos|rope)$' passes 3/3. The same two blockers as #1923 apply, because this branch carries #1923:
1. ci_targets goes red, now for three targets. tools/ci/ci_targets_check.py --check at this head returns rc 1, naming q4nx_vision_block_test, q4nx_vision_pos_test and q4nx_vision_rope_test: none of them is built by ci.yml or by anything it builds. "Listed explicitly so it builds without the NPU library" makes them buildable, not built. The no-workflow-scope fix is add_dependencies(onebit q4nx_vision_block_test q4nx_vision_pos_test q4nx_vision_rope_test). Fixed once, at the bottom of the stack (#1920), it covers all three PRs.
2. Stacked on three open PRs, with base main. git log origin/main..72d0023 gives four commits: this PR's block/merger (72d0023), #1923's rope (89ffdee), #1920's position embedding (45ce9ad) and #1893's implicit-zero-point bit width (bea86be, which is the npu/pack_import.cpp +16 −2 and its test arm). The description covers only the first. Merged as it is, this would land all four under one title. Retarget it onto #1923's branch, or say in the body that it carries #1920, #1923 and #1893 and merges after them.
VERDICT at
|
|
VERDICT: CHANGES at The blocker: three new targets, none of them built by CICI builds from an explicit Fixes, as on #1923: add all three names to On the content, which is goodThe identity control is the arm I would have asked for and rarely get: a transformer block with zero weights and biases must reduce to its residual, which pins the plumbing (residual add, norm placement) independently of any learned value. Paired with a sum golden it covers both "the arithmetic runs" and "the shape of the computation is right". Two notes:
Land it once the targets can be built; the arms are worth having and the identity control especially so. |
|
VERDICT: CHANGES
The ordering, which decides what the merging lane does. Merge #1927 only and close the other two, or land them in order with a rebase between; three merges would apply the same commits three times I have not reviewed the vision-tower maths (+661: block, merger, axial rope, position embedding). That is a real read and I will do it at the head |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 72d0023
This is the strongest reference work I have reviewed in this lane: every literal comes from a numpy oracle, and the oracle itself is established block by block against the pack's own graph, with numbers.
Established against the graph block by block: block 0 norm1 corr 1.000000, qkv all 3072 columns > 0.999, attention corr 0.999994, and the whole
tower matches the fp32 oracle at corr 1.000000 (mean|d| 4.1e-5).
kLnEps = 1e-6f— the tower's LayerNorm epsilon, measured (corr 1.000000 at 1e-6, 0.0001 off at 1e-5)
That last one is the kind of number that only comes from testing the alternative: the epsilon is not "probably 1e-6", it is the value at which the correlation is
1.0 and the neighbouring value is measurably worse.
The two shape facts are pinned against the graph's own construction, not against intuition, and both would be easy to get wrong:
- the rotary table is "cos = TILE(cos(ang), 2), sin = TILE(sin(ang), 2) (Lg: cat([a,a]) — a tile, NOT an interleave)", with
rotate_half(u) = cat(-u[head_dim/2:], u[:head_dim/2]); - the position embedding uses
align_corners("gives corr 1.00000000 / mean|d| 5e-8, andc*48/ngives 0.9959") — a 0.9959 alternative is exactly the
kind of near-miss that would ship as a slightly-soft image.
Built and ran all three test targets: q4nx_vision_block (7 arms: goldens plus bad-geometry refusals), q4nx_vision_rope (6 arms: identity, four goldens, convention),
q4nx_vision_pos (12 arms: token order, align_corners, exactness). No build errors; the tests print their passes.
Not verified: the Qwen3-VL pack itself (not on this box) and the tower end to end — this lands one block plus the merger in host float32, which is the right
first slice for W4: it gets the arithmetic and the two layout conventions right before anything is dispatched to a device.
One ask for the plan's DONE points: this cannot yet serve, so the W4 row should record "host block + merger at corr 1.0 against the pack's graph" as the state,
with the serve gate still open — the PR does not overstate it, and the row should not either.
|
Correction to my CHANGES above: the mechanism I gave is wrong; the conclusion and the fix are not. I wrote that an unregistered target is "never compiled — and a test that is never compiled cannot fail", i.e. that the arms would look like coverage while passing. That is wrong for this repo's CI:
So the failure mode is the loud one: the target is not built, ctest cannot find its executable, and the step fails. And it is failing: I could not retrieve the failing log to confirm that this is the cause ( What stands unchanged:
The reason this correction matters beyond bookkeeping: "a check that cannot fail" and "a check that fails for a packaging reason" need different responses, and I gave you the first when the evidence says the second. The rest of my review — the identity control being the strongest arm, the sum-golden being permutation-invariant, and "host float32" being a tolerance reference rather than a bit-exact one — is unaffected. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 72d0023: CHANGES
This head carries a READY and a red build. I am posting CHANGES rather than a comment because the verdict is what the gate reads, and a red build must not merge — the mirror image of this morning's defect, where a READY landing after a CHANGES let a blocked PR through.
The failure is mechanical, and it is the same failure on three PRs — this one, #1923 and #1930 all die in the build job at the copyright step, with no compile error involved:
copyright notice duplicated inside the header window (delete the second copy): tests/q4nx_vision_block_test.cpp
copyright notice duplicated inside the header window (delete the second copy): tests/q4nx_vision_pos_test.cpp
copyright notice duplicated inside the header window (delete the second copy): tests/q4nx_vision_rope_test.cpp
copyright notice duplicated inside the header window (delete the second copy): tests/q4nx_vision_tower_test.cpp
One fix clears all three: delete the second copy inside the header window (NOTICE_WINDOW = 20 in tools/copyright.py), one edit per file. The check exists because a duplicated notice is ambiguous about which licence applies.
It is worth noting where this could have been caught without a CI round trip — the same place I should have caught my own #1920:
python3 tools/copyright.py
This is the second distinct copyright failure today in this file family (#1920 was a binary-licence header where the tree requires SPDX-License-Identifier: Apache-2.0), which suggests running that one command before pushing the vision series would have saved three red builds.
The substance of the series is not in question — position embedding, the front-end kernels and this assembly are the right pieces in the right order. This needs the duplicate notices gone.
…the header check Review of #1927: the `build` job dies at `tools/copyright.py --check`, before compiling anything -- copyright notice duplicated inside the header window (delete the second copy): npu/forward/include/q4nx_vision.h ... q4nx_vision_{block,pos,rope}.cpp, and the three tests The header carried a notice twice inside `NOTICE_WINDOW` (20 lines), which is ambiguous about which licence applies, so the check refuses it. `python3 tools/copyright.py --fix` repaired all seven files; the tree is clean under `--check` now. Verified past the step that was failing: the three test targets build and pass -- vision block: 7 arms pass (goldens x3, identity control, merger x2, refusal) vision pos embed: 12 arms pass (token order, align_corners, exact copy, golden x8) vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal) The same repair is applied to #1923 and #1930, which fail identically at this step. Refs #1927
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Fixed at 12d5d1fe4 — the mechanical failure, past the step that was failing.
tools/copyright.py --fix repaired all seven vision files (a notice twice inside NOTICE_WINDOW = 20 is ambiguous about which licence applies, which is why the check refuses rather than picks). --check is clean, and the three test targets build and run:
vision block: 7 arms pass (goldens x3, identity control, merger x2, refusal)
vision pos embed: 12 arms pass (token order, align_corners, exact copy, golden x8)
vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal)
The lesson you named is the one worth carrying: python3 tools/copyright.py is a local command that would have caught this before the CI round trip, the same way it would have caught #1920. I have been running --check on other PRs and skipping it on ones where the edit "was only code" — which is exactly the edit that adds a header.
Also worth noting your framing of the verdict itself: posting CHANGES because a READY-then-red sequence must not merge is the mirror of this morning's defect, and it is the right call — the gate reads the verdict, so the verdict has to reflect the head's actual state rather than the last thing anyone said about it.
…this head too) Review of #1923: the `build` job dies at `tools/copyright.py --check` before compiling -- a notice twice inside `NOTICE_WINDOW = 20`, which is ambiguous about which licence applies, so the check refuses. `--fix` repaired the five files this branch carries; `--check` is clean. Verified past the failing step: `q4nx_vision_pos_test` and `q4nx_vision_rope_test` build and pass -- vision pos embed: 12 arms pass (token order, align_corners, exact copy, golden x8) vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal) Same repair as #1927 (`12d5d1fe`) and the same one #1930 needs. Refs #1923
…bedding it calls Review of #1930: the head failed `build` twice over, and the second one is not mechanical. 1. **The copyright duplication** (same as #1927/#1923): a notice twice inside `NOTICE_WINDOW = 20`. `--fix` repaired the files; `--check` is clean. 2. **`q4nx_vision_block_test` had no `pos.cpp` in its sources**, so the link failed -- `undefined reference to q4nx_vision::pos_embed` -- and the job died before any test ran. A block is rope AND position embedding, so the test pulls both; the executable list was missing one of them. That is a real defect in this PR, not a manifestation of the copyright one, and it would have surfaced the moment the header check stopped failing. Verified past both: all four vision targets build and pass -- vision block: 7 arms pass (goldens x3, identity control, merger x2, refusal) vision pos embed: 12 arms pass (token order, align_corners, exact copy, golden x8) vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal) vision tower: 4 arms pass (golden x2, zero-weight control, refusal) Refs #1930
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 12d5d1f: READY — withdraws my CHANGES at the previous head
The duplicate notice is gone: tests/q4nx_vision_pos_test.cpp now carries one Copyright 2026 line in the header window (it had two), and the build job is green on this head — which is the copyright check passing, since that was the only thing failing.
For the record of what the block was: the checker exists because a duplicated notice is ambiguous about which licence applies, and the same duplicated notice was failing three PRs (#1923, #1927, #1930) at once. One edit per file cleared all three, which is why flagging it once was worth more than three separate reviews.
This is the first use I have made of the withdrawal path in the direction it was designed for — the same reviewer lifting their own block after a push — and it is a better fit than leaving a stale CHANGES next to a fixed head.
|
VERDICT: READY at A 752-line "implement the block" PR, so I checked the three things that decide whether it is a reference implementation or a plausible one: 1. The numbers come from outside. "The weights come from a 32-bit LCG so the fixture can be regenerated in another language, and the goldens come from 2. The easy-to-get-wrong constants are declared once, with the measurement that chose them. That last arm is the interesting one: a hint that disagrees with the tensor's own spelling must lose, and the test says so. 3. Each new source has a test that stands on its own: The one thing I could not check: whether the numpy reference itself is faithful to the shipped graph order — that is the PR's premise, and the per-stage |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 12d5d1f
The structure named in the body is the reason I would trust this port: "Every constant that is easy to get wrong lives [in one place]" — a vision block is norm → qkv → rope → attention → proj → residual → norm → fc1 → gelu-tanh → fc2 → residual, and the ordering plus the two norms plus the activation's exact spelling are the four things a port silently gets wrong while producing plausible numbers.
Host float32 is the right first step, and it is the same decomposition that worked on the text side: get the arithmetic right where it can be compared against a reference, then move the hot matmuls to the device — rather than porting the block and the kernels at the same time, where a wrong number has two candidate causes. It also makes the piece testable without a device, which is what the rest of this family has been insisting on all week.
Two things worth pinning while it is host-only (both cheap now, expensive later): the gelu-tanh variant specifically — 0.5x(1+tanh(√(2/π)(x+0.044715x³))), which differs from erf-gelu by enough to change a top-1 token — and the merger's four-token view as 4*hidden, since a view with the wrong stride produces a tensor of the right shape and the wrong content.
No asks on the structure.
|
VERDICT: CLOSE at #2055 carries this PR's vision-tower work and fixes the blocker I raised against it: add_dependencies(onebit q4nx_vision_pos_test q4nx_vision_rope_test q4nx_vision_block_test q4nx_vision_tower_test)That is the missing half of what I flagged above — Nothing is wrong with this branch beyond being the earlier copy: it is the same vision code without the prefill-launch work, and without the registration fix. Merging both would conflict on |
|
VERDICT: READY at Reposted with the head in the line, because the newest comment on this PR names |
VERDICT at
|
…rge-block token order (engine#1503 W4) (#1930) * npu-pack: the implicit zero point's bit width comes from the spelling, not a literal (engine#1841) The rule `2^(bits-1)` existed twice with two different derivations of the same parameter: the projection path passed a hardcoded `4`, the head path passed the node's own `bits`. Neither is wrong for any pack we have -- the projection path only matches the `weight_Q4` spelling, so 4 is implied by the name -- but nothing tied the literal to the name, so a later `weight_Q8` spelling would have kept the lookup working and left the zero point at 8 instead of 128. That is a silent wrong weight, not a crash. `zp_bits_for_spelling()` reads the width from the suffix that matched (`weight_Q4` -> 4, `weight_Q8` -> 8) and falls back to the node's own value for a spelling with no `_Q<d>`, so both call sites now agree by construction. Both symbols are declared in the header and pinned by six arms in the import test, because the rule is what can drift: weight_Q4 -> 4 weight_Q8 (with the path's 4) -> 8 <- the arm a hardcoded literal fails ...MatMulNBits.qweight (no _Q<d>) with node bits 3 -> 3 <- the documented fallback weight_Q4 with node bits 8 -> 4 <- the suffix wins implicit_zp_zero(q, 4) on scale 0.5 -> zero -4.0 implicit_zp_zero(q, 8) on scale 0.5 -> zero -64.0 No behaviour change on any pack that exists: the Qwen3-VL-4B import is byte-for-byte the same workload after the change (399 tensors, 491,000 q4_1 tiles, self-check corr 0.993981), and the whole npu-pack suite passes (0 FAIL). Closes the second point of engine#1841. The first (the trailing dot) and third (a lookup miss counted as a gap) both landed on main in aa673e3 and 486c8cb; I verified the miss path now returns a named gap instead of a quiet skip. * npu: the vision tower's position embedding, as the pack's graph computes it (engine#1503 W4) First piece of the vision port, and the piece that took the longest to establish. `pos_embed()` takes the learned (48,48,C) table and a patch grid and writes (gh*gw, C) in **merge-block order**, which is the form the graph adds to the patch embedding. Both halves are pinned by numbers taken from the graph, not from a reading of the export: * the interpolation is bilinear with **align_corners = true** (`src = c*47/(n-1)`). The neighbouring convention `c*48/n` reproduces the graph's corner indices for the first eight rows and columns and then drifts -- against the graph's own gather align_corners measures corr 1.00000000 / mean|d| 5e-8, and `c*48/n` measures 0.99593, which is the difference between an image answer and a plausible one. * the reorder is one permutation, `perm = [0 1 gw gw+1 | 2 3 gw+2 gw+3 | ...]`: group g is the 2x2 block at (2*(g/gw2), 2*(g%gw2)). Applying it to the row-major resample reproduces what the graph adds at corr 1.000000 / mean|d| 5e-8. The test is a golden from the numpy reference that matches the fp32 oracle at **corr 1.000000, mean|d| 4.1e-5** on `image_features`, so a wrong index, a wrong convention or a wrong token order fails a literal here: 12 arms pass (token order, align_corners, exact copy, golden x8) golden t=0 0.000000000 0.001000000 0.002000000 t=2 0.966857143 0.967857143 0.968857143 golden t=47 3.041571429 ... sum of all 96 tokens = 995.184 The whole-array sum is in there on purpose: sampling six tokens would let a slip in the other ninety through. Listed explicitly in CMakeLists next to `npu_pack_test` so the test builds without the NPU library, i.e. it runs on any lane. Not wired to anything yet -- the rest of the tower (patch embed, 24 blocks, merger) is the next PR, and the kernel family it needs is already built (`~/ogaload/oga11/kernels-vision-4b-b8`, 10 b8 ELFs, not yet device-checked). * npu: the vision tower's axial 2D rope, tile-and-rotate_half, pinned by goldens (engine#1503 W4) Second piece of the vision port, and the other detail that took several wrong attempts. The graph's rope is `x*cos + rotate_half(x)*sin` with `cos = Cos(Concat(a, a))`, i.e. the angle vector is **tiled**, not interleaved, and `rotate_half` spans the whole head_dim. Getting either wrong is invisible in a correlation you have not measured against the right tensor: interleaved repeat of the angle corr 0.44 tile, but rotating each half separately corr 0.99, never 1 tile + rotate_half over head_dim corr 0.999996 vs the graph's own post-rope q (mean|d| 3.3e-3, |ref| 1.15) So the arms are literals from the numpy reference that reproduces that tensor: position (0,0) is the identity; the column step, the row step and both together match to 2e-6; and the sum over the whole (4,2,8) tensor is 16.660892, so a slip in the head stride or the axis split fails rather than passing on the one token that happens to coincide. vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal) `inv_freq` is passed in (the pack's own `visual.rotary_pos_emb.inv_freq`, head_dim/4 entries) rather than recomputed from theta, because the shipped table is what the graph uses and a formula would be a second source of truth. Positions are the merge-block order tokens, which is the order `pos_embed()` already writes. Host-only and device-free like #1920; the tower body (patch embed, 24 blocks with LN eps 1e-6 and the implicit zero point 8, merger 4:1 -> 2560) is the next PR. * npu: one vision transformer block and the merger, host float32 (engine#1503 W4) Third piece of the vision port: `block_forward()` (norm -> qkv -> rope -> attention -> proj -> residual -> norm -> fc1 -> gelu-tanh -> fc2 -> residual) and `merger_forward()` (per-token norm, view 4 tokens as 4*hidden, fc1 -> gelu -> fc2 to `out_hidden`). Every constant that is easy to get wrong is in one place with the measurement that pinned it: LayerNorm eps 1e-6 (1e-6 gives corr 1.000000 against the graph's norm, 1e-5 is 0.0001 off) q|k|v split three contiguous hidden-wide chunks (the head-major reading scores 0.41 where this scores 0.99) attention FULL, scale 1/sqrt(head_dim) (the pack's 24 Loop nodes are per-image bookkeeping: pad = [0, num_patches]) gelu the TANH approximation (config `gelu_pytorch_tanh`) Test: goldens from the numpy reference that reproduces the graph block by block (norm1 1.000000, qkv >0.999 in every column, attention 0.999994) and the whole tower at corr 1.000000 against the fp32 oracle. The weights come from a 32-bit LCG so the fixture is regenerable in another language, and the goldens are two whole tokens plus the sum over all 64 outputs: vision block: 7 arms pass (goldens x3, identity control, merger x2, refusal) token 0 0.08357919 0.07403285 0.03188136 -0.16534122 … token 3 -0.02847019 0.34347527 0.18980209 -0.23568934 … sum over the block's 64 outputs = -2.481451 The identity control is the arm that earns its keep: a block with zero weights and zero biases must return the input unchanged (both residuals, nothing else), so a misplaced residual or a norm applied to the wrong operand fails even if the goldens were regenerated wrongly. Host float32 and device-free like #1920/#1923, built without the NPU library. Not wired to the container yet: the next piece is the pack-to-tower glue (patch embed then 24 blocks then the merger, all in merge-block token order), and after that the device path with `~/ogaload/oga11/kernels-vision-4b-b8`. * npu: assemble the vision tower -- patch embed, N blocks, merger, in merge-block token order (engine#1503 W4) Fourth piece of the vision port and the one that makes the other three a tower: `tower_forward()` runs the Conv3d patch embed as a matmul (the kernel equals its stride), adds the position embedding, runs `n_blocks` of `block_forward()`, then `merger_forward()` to `out_hidden`. Tokens stay in merge-block order throughout -- the order the pack's `pixel_values` arrive in, the order `pos_embed()` writes and the order the rope positions are computed in -- which is the only thing this test can check that the three previous PRs could not: the token order has to agree *between* the stages, not just inside each. Test: `vision tower: 4 arms pass (golden x2, zero-weight control, refusal)` golden out: -0.19714565 -0.03816292 -0.08239722 0.27405666 0.34750172 -0.23880654 0.19937676 -0.04093523 golden sum: 0.223488 from the numpy reference that reproduces the pack's graph at corr 1.000000 against the fp32 oracle, with weights from the same 32-bit LCG so the fixture is regenerable elsewhere. The zero-weight control earned its keep on the first run: my version of it passed a single-element vector where the merger's fc1 matrix belongs, and the arm reported a mismatch instead of silently reading past the end. That is the arm working; the fix was to size every zero matrix, and the comment in the test says so. Two C++ traps hit again and worth naming since they cost three build cycles between them: `std::vector<float> v(size_t(n))` is a *function declaration* when `n` is a variable (the most vexing parse -- `static_cast<size_t>` fixes it), and pointers taken into a `std::vector` of `std::vector`s are invalidated by `push_back`, so the fixture allocates one flat store and offsets into it. Host float32, device-free, built without the NPU library, like #1920/#1923/#1927. * vision: one notice per file, and the block test links the position embedding it calls Review of #1930: the head failed `build` twice over, and the second one is not mechanical. 1. **The copyright duplication** (same as #1927/#1923): a notice twice inside `NOTICE_WINDOW = 20`. `--fix` repaired the files; `--check` is clean. 2. **`q4nx_vision_block_test` had no `pos.cpp` in its sources**, so the link failed -- `undefined reference to q4nx_vision::pos_embed` -- and the job died before any test ran. A block is rope AND position embedding, so the test pulls both; the executable list was missing one of them. That is a real defect in this PR, not a manifestation of the copyright one, and it would have surfaced the moment the header check stopped failing. Verified past both: all four vision targets build and pass -- vision block: 7 arms pass (goldens x3, identity control, merger x2, refusal) vision pos embed: 12 arms pass (token order, align_corners, exact copy, golden x8) vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal) vision tower: 4 arms pass (golden x2, zero-weight control, refusal) Refs #1930 * vision: build the four vision tests from `onebit`, so their ctest rows exist in CI Review of #1930: the duplicate-notice fix took, and the head then failed `ci_targets_check` — all four vision test targets are `add_test`ed but `.github/workflows/ci.yml` never builds them, so the rows would never run in CI. Same wall as #1822/#1799/#1809. `add_dependencies(onebit q4nx_vision_block_test q4nx_vision_pos_test q4nx_vision_rope_test q4nx_vision_tower_test)` is the documented route (the one `onebit_npu_forward_syntax` and `route_log_test` use); it needs no workflow edit, so it also needs no `workflow` scope for the push. Verified: * `ci_targets_check --check --root .` -> "9 target(s) whose test never reaches ctest under ci.yml (baseline 9), and none new"; * all four targets build and their arms pass -- vision block: 7 arms (goldens x3, identity control, merger x2, refusal) vision pos embed: 12 arms (token order, align_corners, exact copy, golden x8) vision rope: 6 arms (identity, goldens x4, conventions note, refusal) vision tower: 4 arms (golden x2, zero-weight control, refusal) Refs #1930 * vision: the tower's refusal arm observes the refusal (answers the CHANGES at a7af2fd) The arm was check(true, ...): it passed whether or not tower_forward() refused. It now fills the output buffer with a sentinel and requires it untouched, on a token count (2*T on a grid of T) that only the gh*gw check can refuse: positive and a multiple of 4, so neither tokens<=0 nor merger_forward()'s own guard stops it. With that check removed the arm fails; restored, 4 of 4 vision tests pass. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --------- Co-authored-by: pi-npufeat <npufeat@strixhalo> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
Third piece of the vision port:
block_forward()— norm → qkv → rope → attention → proj → residual → norm → fc1 → gelu-tanh → fc2 → residual — andmerger_forward()(per-token norm, four tokens viewed as4*hidden, fc1 → gelu → fc2 toout_hidden).Every constant that is easy to get wrong lives in one place with the measurement that pinned it:
hidden-wide chunks1/sqrt(head_dim)Loopnodes are per-image bookkeeping —padmeasured[0, num_patches]gelu_pytorch_tanhTest
Goldens come from the numpy reference that reproduces the graph block by block (norm1 1.000000, qkv >0.999 in every column, attention 0.999994) and the whole tower at corr 1.000000 against the fp32 oracle. The weights come from a 32-bit LCG so the fixture is regenerable in another language — no opaque blobs.
The arm that earns its keep is the identity control: a block with zero weights and zero biases must return its input unchanged (both residuals, nothing else). That fails on a misplaced residual or a norm applied to the wrong operand even if someone regenerates the goldens wrongly — which is exactly the failure mode a golden-only suite cannot see.
Not in this PR
The pack-to-tower glue (patch embed, then 24 blocks, then the merger, all in merge-block token order) and the device path. Host float32, device-free, built without the NPU library like #1920 and #1923.