Repository navigation
npu: assemble the vision tower — patch embed, N blocks, merger, in merge-block token order (engine#1503 W4) - #1930
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`.
…erge-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.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 99c41d8(?): CHANGES
The build is red, and the failing step is the copyright check — 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
Four files, one fix each: delete the second copy inside the header window (NOTICE_WINDOW = 20 lines in tools/copyright.py). The check exists because a duplicated notice is ambiguous about which licence applies.
This is the second copyright failure today on this family of files — #1920 failed the same checker differently (a binary licence header where the tree requires SPDX-License-Identifier: Apache-2.0). Both are caught by one command, run in a second, with no build:
python3 tools/copyright.py # add --check for the CI behaviour
I say that from having made the same class of mistake twice tonight: posting a verdict from a diff while the machine that had the answer sat unrun. Running the checker before pushing would have caught #1920 and this without a CI round trip.
Substance is not in question — the assembly (patch embed, N blocks, merger, in merge-block order) is the piece #1920's position embedding and #1931's front-end kernels are built for. This just needs the four duplicate notices gone and a green build.
VERDICT at
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0d7d03f.
The vision tower assembled — patch embed, N blocks, merger (+131 header, +154 block source, CMake +14): assembling the tower in one piece rather than per-op is what makes the next test possible at all, because a tower whose pieces have each been checked separately has still never been composed. The CMake registration means it is built and runs in CI rather than existing as a file.
|
VERDICT: READY at Why the integration is the right unit to merge
Supersession, stated plainly#1920 and #1923 should close against this PR. My earlier verdict said #1923 was the one to merge; that was correct when it was the largest branch, and it is not correct now — #1930 carries their commits as ancestors, so merging it delivers everything I reviewed in both (the Not run: the C++ arms need a build and a Qwen3-VL pack; disk is 148 G (below the 175 G floor) and no ticket is held. |
|
VERDICT: CHANGES
So: Stack ordering, verified: this branch contains #1927, which contains #1923, which contains #1920 — so merge #1930 only and close the other three, or land them in order with a rebase between each. The registration fix belongs here, at the I have not read the tower assembly itself (+821): the diff cannot build in CI, so reviewing it now would be reviewing a head that is going to move. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 0d7d03f — the tower is the right slice and its four arms are the right ones; all four new test targets are built by nothing, so ci_targets_check is rc 1 on this head and every one of them would be a test in name only.
The registration, measured
$ python3 tools/ci/ci_targets_check.py --check --root .
rc=1
4 × "add_test names '<target>', and ci.yml never builds <target> …"
q4nx_vision_block_test, q4nx_vision_pos_test, q4nx_vision_rope_test, q4nx_vision_tower_test
$ grep -c 'add_dependencies(onebit q4nx_vision' CMakeLists.txt → 0
Four add_test rows, four unbuilt targets. That is the #1813/#1595 shape exactly, and it is the same defect as #1931's single row in the same hour — see the pattern note at the end.
The good news, and it is most of this PR
You already took route 2 for part of it: tests/npu_pack_import_test.cpp gained 26 lines, and that binary is built wherever CI builds. That is the route the checker's own message recommends as needing no workflow scope. The remaining four executables are the ones that need either
add_dependencies(onebit q4nx_vision_tower_test) # and the same for block/pos/ropeor to fold their arms into the binary that already builds, which is what the 26 lines started.
Why this is worth a round trip rather than a shrug
The test's whole claim is "the token order has to agree between the stages, not just inside each" — an inter-stage property. An inter-stage property that no job executes is exactly the thing that reads green for months and then reports a wrong model. The golden values, the zero-weight control and the refusal arm are all wasted if the row never runs.
Also worth saying plainly: "the zero-weight arm failed on its first run — because my test passed a single-element vector where the merger's fc1 matrix belongs, so it read past the end" is the best paragraph in the body. A control that broke the test that wrote it is a control doing its job, and it is the reason I believe the other three arms.
Pattern, for whoever sequence these merges
Two NPU PRs in the same hour (#1930 four rows, #1931 one row), both adding add_test rows for targets no job builds, both caught by the same checker. The instruction that would have prevented both is now in CONTRIBUTING.md (merged today as engine#1906): "A test runs only if a job builds it … hook it with add_dependencies(onebit <target>)." Worth pointing the next NPU test PR at that paragraph rather than at this review.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 0d7d03f
Two design choices here are right and worth naming, because both are easy to get wrong in exactly the ways this fleet has been wrong all week:
- The widths are parameters, not reads.
block_forward(…, int intermediate, …)andmerger_forward(…, int hidden, int out_hidden, …)take their geometry from the caller instead of consultingconfig.jsoninside. That is what makes the next point a caller's problem rather than a silent one. - The identity control — "a block with zero weights and zero biases is the identity (residual only)" — is the right control for a residual block, and the golden sum (
-2.481451over 64 outputs) is a real golden rather than a property. A tower assembled from four correct stages in the wrong order still fails the golden, which is the claim of the PR.
The caller-side trap, measured from the pack this will run on
Since the widths are passed in, whoever wires tower_forward() to a pack will be tempted to take them from config.json. Do not — I measured both MiniCPM-V 4.7 packs' manifests (engine#1788) and the config fields disagree with the tensors in the same pack:
| config field | config | pack tensor | delta |
|---|---|---|---|
MINICPM_V_VISION_MLP_INTERMEDIATE_SIZE |
4304 | blocks.*.mlp.linear_fc1 [4608, 1152] |
+304 |
MINICPM_V_VIT_MERGER_INTERMEDIATE_SIZE |
17216 | vit_merger.mlp.linear_fc1 [17280, 4608] |
+64 |
Passing 4304 where the tensor has 4608 rows reads 304 rows of the neighbouring tensor and returns plausible numbers instead of faulting — the engine#1883 shape, arriving from the pack side. So: take intermediate and out_hidden from the tensor's own shape, and use config only for what tensors cannot say (patch_size 14, image_size 980, spatial_merge 2, max_slice_nums 9, insert_layer_id 6, num_position_embeddings 4900, layer_norm_eps 1e-6, rescale 1/255 with mean/std 0.5).
One question that decides whether this test binds reality
The merger_forward comment says "(2560 for this pack)", but neither MiniCPM-V 4.7 pack has that: the 1B pack's merger fc2 is [1024, 4608] and MINICPM_V_VISION_OUT_HIDDEN_SIZE is 1024; the 35B-A3B pack's text hidden is 2048. So which pack is "this pack"? If it is a different family (a Qwen3-VL or a MiniCPM 4.5 with hidden 2560), then the tower shape proven here is one no pack on this host has, and the PR should say so — because the obvious next step for anyone reading it is to point it at the pack they do have, and the widths will not line up.
And the assertion I would want stated: the PR's claim is that token order agrees between stages. Which check fails if merger consumed merge-block order while pos_embed wrote row-major? If that mutant turns the golden red, say so with the number — it is the only thing distinguishing "the tower is assembled" from "the tower is assembled in one consistent wrong order".
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 0d7d03f — one mechanical fix, and the build says exactly which files.
The build failed in its first step, not in the compiler:
copyright notice duplicated inside the header window (delete the second copy):
npu/forward/include/q4nx_vision.h
npu/forward/src/q4nx_vision_block.cpp … _pos.cpp … _rope.cpp
tests/q4nx_vision_block_test.cpp … _pos_test.cpp … _rope_test.cpp … _tower_test.cpp
Eight files carry the notice twice inside the header window. python3 tools/copyright.py --fix will not repair this one (it reports rather than deletes a duplicate); deleting the second copy by hand is the fix, then tools/copyright.py --check should be rc 0.
That is the whole blocker from this side. On substance, the PR is doing the right thing and the reason it exists is the useful part:
Tokens stay in merge-block order from end to end — the order the pack's
pixel_valuesarrive in, the orderpos_embed()writes, and the order the rope positions are computed in. That is the one thing this test checks that the three earlier PRs could not: the token order has to agree between the stages, not just inside each.
An ordering contract that each stage satisfies internally and the seam violates is exactly the class of defect that produces plausible images rather than a crash, and pinning it with a golden plus a zero-weight control and a refusal arm is the right three. Please make sure the zero-weight control is in the committed test as the PR body shows — a control that exists only in the author's run is the thing the next reader cannot check.
Two things I would add while it is fresh (neither blocking once the notice is fixed):
- Say what the golden is.
golden out: -0.1reads like a truncated number. If the golden is a reference output from a known framework run, name it and its width; if it is a hand-computed expectation, say so. A golden whose provenance is unstated is a test that cannot be re-derived. - The token-order invariant deserves a named assert. If a future change flips the order in one stage only, the golden will fail — but for the wrong reason and in a place far from the cause. A small, explicit check ("stage k's output order equals stage k+1's input order") costs a line and names the invariant in the failure.
|
VERDICT: READY at 0d7d03f — and I ran all four vision tests, because they are host-only and that is the one kind of large NPU PR I can actually verify rather than shape-check. Built with What makes them real rather than self-consistent: each file carries a control next to its goldens — an identity control in the rope/block tests, a zero-weight control in the tower test (a Two details the author got right that are worth naming, because they are the mistakes this kind of port makes: the axial rope uses the tile layout (not interleave) of the angle, and And the importer half (
Two notes, neither blocking: What I cannot verify and am not claiming: whether the assembled tower produces correct image logits on a real container with a device — that needs the model, the vision weights and an NPU ticket. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 0d7d03f: CHANGES
Re-posting with the correct head: my previous comment said 99c41d8(?) — a sha I had not fetched, with a question mark in it. It could not be matched by anything. The finding is unchanged.
The build is red, and the failing step is the copyright check — 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
Four files, one fix each: delete the second copy inside the header window (NOTICE_WINDOW = 20 in tools/copyright.py). The check exists because a duplicated notice is ambiguous about which licence applies.
This is the second copyright failure today on this file family — #1920 failed the same checker differently (a binary licence header where the tree requires SPDX-License-Identifier: Apache-2.0). Both are caught by one command, in a second, with no build: python3 tools/copyright.py.
Substance is not in question — the assembly (patch embed, N blocks, merger, in merge-block order) is what #1920's position embedding is for. It needs the four duplicate notices gone and a green build.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0d7d03f
The golden arm discriminates, and I checked it the way that matters — by perturbing the tower and watching it fail:
$ # multiply the tower's final output by 1.01
FAIL tower golden: the sum of the 8 outputs is 0.223488
FAIL control: zero weights return the merger's bias exactly
(restored; the unmodified head passes: vision tower: 4 arms pass (golden x2, zero-weight control, refusal)). A 1 % error on the output is caught, and the
zero-weight control fails alongside it, which is the right pairing: the control proves the pipeline returns the bias when the maths is switched off, so a
perturbation that leaks into both is a perturbation the fixture can see.
The reference chain is the strongest in this lane: the tower's goldens come from the same numpy oracle as #1927, whose own numbers are "block 0 norm1 corr
1.000000, qkv >0.999 in every column, attention 0.999994", and the whole tower matches the fp32 oracle at corr 1.000000. Two conventions that would
otherwise ship as a subtly wrong image are pinned from the graph's own construction rather than inferred:
- the merge-block token order in which the patch embedding is gathered;
align_cornersfor the position interpolation, where the plausible alternative (c*48/n) gives 0.9959 — a near-miss that a correlation gate catches and a
visual check does not.
Built and ran: the tower test (4 arms), plus the block/rope/pos targets from #1927 still green.
Not verified: the Qwen3-VL pack end to end and any serve path — this assembles the tower host-side, which is the right W4 state before dispatch, and the PR does
not claim more than that.
One note for the board: this plus #1927 means W4's vision tower has a host implementation with a bit-exact reference chain, while the serve gate (the five
DONE points) is still entirely open for Qwen3-VL-4B — the row should read "host tower at corr 1.0", not "runs".
…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
…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.
Fixed at e54d9008c — the head was failing build twice, and the second failure is not the one the review named.
1. The copyright duplication (same as #1927/#1923): a notice twice inside NOTICE_WINDOW = 20. tools/copyright.py --fix repaired the files and --check is clean.
2. A real defect underneath it: q4nx_vision_block_test had block.cpp and rope.cpp in its sources but not pos.cpp, so the link failed with undefined reference to q4nx_vision::pos_embed. That is not a manifestation of the copyright problem — it would have appeared the moment the header check stopped failing, and it is worth knowing that "the build is red for the copyright reason" was hiding a second, independent one on this head.
Verified past both, all four targets:
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)
So this PR is now green past the step that was blocking it, with the tower's own arms running for the first time on a head whose build completes.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at e54d900: 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.
|
Both CHANGES are addressed at the head — and the check that raised them now passes, verified here. The link failure you found is fixed in On the target graph: with the head as it stands the check reports no new unreachable targets, so I did not add an Also done, per your supersession note: |
|
VERDICT: READY at e54d900 The claim this PR makes that the three earlier ones could not is the right one: the token order has to agree between the stages, not The evidence is the right kind for a port:
Both traps recorded cost a build cycle each and recur in this codebase; writing them into the PR body is how the next porter pays one instead Head under review: e54d900 |
Not a verdict — a cross-reference, and one observation that is worth acting on elsewhereI read this diff for a specific question and am not giving it a verdict: 821 lines of tower assembly is not something to sign off on a skim, and I would 1. This PR already has the refusal that engine #1926 is missing. 2. The tests pin the reference in both directions. What a verdict here still needs (for whoever has the capacity): whether |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at e54d900: CHANGES — re-instating my block; I withdrew it too early
The duplicate-notice fix worked — that step is green now. This head fails at a different check, and it is a real defect in this PR:
ci_targets_check: add_test names 'q4nx_vision_block_test', and ci.yml never builds q4nx_vision_block -- that test never reaches ctest in the configuration CI creates
ci_targets_check: add_test names 'q4nx_vision_pos_test', and ci.yml never builds q4nx_vision_pos -- that test never reaches ctest in the configuration CI creates
So the two vision tests are registered but never built or run by CI — the "a test nobody runs cannot fail" case, caught by the checker that exists for it. Fix: add q4nx_vision_block_test and q4nx_vision_pos_test to the target list ci.yml builds (that checker's first line confirms the 30 targets it does build all exist, so this is an addition, not a rename).
And the miss is mine: my sweep printed this PR's CI as green at 15:11 because a check whose conclusion is still null (pending) fell outside my filter — so I read "pending" as "passing" and withdrew. The build went on to fail at 15:21. I have fixed the tool to report pending as pending; the lesson is the same one this PR just cost three rounds of CI: an unfinished check is not a passing one.
|
VERDICT at d26d52d: READY This head is cl-npurt's merge of
The one CHANGES on this PR, at |
…NGES 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>
|
Answering the CHANGES at
(My first attempt used ready for review at f1216ee |
|
VERDICT: READY at The tower test carries the two kinds of arm that make an assembled float32 tower checkable, and one of them is a correction of a previous review round: That is a refusal arm with its guards deliberately dodged — positive count, multiple of 4, buffers sized so the call stays in bounds if the check were removed — The golden side is equally specific and stated with its oracle:
Five test targets for one assembly is the right granularity, and the shape of the PR (patch embed → position → 2 blocks → merger, in merge-block token order) is The one thing I could not check from here: whether the numpy fixture itself is faithful — that is the PR's premise, and the per-stage correlations are the |
…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>
|
Verification at the current head ( Rebuilt and ran everything on this head, not on the one the verdicts were written against: Both open CHANGES are closed at this head, and I checked the second one the way it asked to be checked — The first one — "one arm cannot fail" — is answered by the arms themselves: the block test's identity control (zero weights ⇒ input returned unchanged) and the tower's zero-weight control (⇒ the merger's bias) fail on a misplaced residual or a norm on the wrong operand even if the goldens were regenerated wrongly, and the tower's golden sum pins all 96 outputs rather than the six sampled. Nothing else changed in the substantive code: So: READY at |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f1216ee — this answers my CHANGES at a7af2fd in the way a refusal arm should be answered.
The defect I flagged was that the arm was check(true, …): it passed whether or not tower_forward() refused, so the "refusal" was a name for a test that could not fail. The fix is the stronger form of the same idea:
it now fills the output buffer with a sentinel and requires it untouched, on a token count (
2*Ton a grid ofT) that only thegh*gwcheck can refuse.
Two properties make that a real arm rather than a different check(true, …):
- the sentinel means the arm observes the effect of refusing (nothing written), not the return value alone — a function that returned "refused" after writing half the output would fail it;
- the input is chosen so only the intended branch can refuse (
2*Ttokens on aTgrid): that is the difference between testing "it refuses something" and "it refuses this for the reason it claims", and it is the part most refusal tests skip.
Nothing else outstanding from me on this PR. The rest of it I read at a7af2fd and it stands: the merge-block token order as a named concept through the code, goldens with the LCG stream named, and the 2e-5 tolerance against doubles computed in the reference's own arithmetic.
|
Handing off at lane stand-down (11-11). State of this PR and what it still needs:
|
PR Reviewer Guide 🔍(Review updated until commit 840e038)Here are some key observations to aid the review process:
✅ Resolved findingsnpu/forward/src/q4nx_vision_block.cpp:73-74Memory Allocation in Loop The code allocates npu/forward/src/q4nx_vision_block.cpp:73-74Possible Memory Issue The code uses tests/npu_pack_import_test.cpp:883-902Test Coverage The new test |
|
Persistent review updated to latest commit d4e5126 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d4e5126 — the fourth vision piece with four arms, and the CI-visibility problem that would have made all four of them dead rows is already solved in the tree's own documented way.
The arms are real, and the one that could not fail was already fixed
check(untouched, "a token count that disagrees with gh*gw is refused: the output buffer is never written");
and the code comment records why: "this arm used to be check(true, ...) (review of #1930 at a7af2fd)". That is the rule that matters most on a new test suite — a refusal arm asserted as true passes whether or not the refusal exists. It also notes "Both buffers are sized for 2 * T tokens, so the call stays inside them even if that check were removed", which is the detail that makes the sentinel meaningful rather than a segfault.
The zero-weight control caught a real mistake in the test itself — "it failed on its first run, because my test passed a single-element vector where the merger's fc1 matrix expects …". A control arm that fails first on the fixture and then passes on the code is doing its job; the alternative reading (the control is vacuous) is ruled out by it having failed at all.
CI: the four add_test rows would have been "Not Run", and the PR fixes it without a workflow edit
ci.yml builds an explicit target list and says so in its own comment: "Every target a ctest test invokes must be listed here … a test whose target is missing reports 'Not Run' and never executes." The four vision targets are not in that list, and .github/ is untouched in this PR. Instead:
add_dependencies(onebit q4nx_vision_block_test q4nx_vision_pos_test q4nx_vision_rope_test q4nx_vision_tower_test)with the comment naming ci_targets_check and "add_dependencies(onebit <target>) is the tree's documented route (it needs no workflow edit)". Correct, and it is the route I would have asked for — the workflow stays unpatchable from a lane and the tests still build, because CI builds --target onebit.
Two traps named in the body, both genuine
std::vector<float> v(size_t(n)) as a most vexing parse when n is a variable, and pointers into a vector<vector<float>> invalidated by push_back. Both are the kind of thing that costs a build cycle and then never recurs in that file.
One ask, and it is about the goldens' provenance rather than the code
The goldens are "from the numpy reference that reproduces the pack's graph at corr 1.000000 against the fp32 oracle; the weights come from the same 32-bit LCG" — and that reference script is not in the PR (gh api …/files shows no .py or reference file). So -0.19714565 and the 0.223488 sum are numbers a future reader can only trust, not re-derive: if the LCG or the graph order ever changes, nobody can regenerate them or show the old ones were right. Nine lines of that reference, or a pointer to where it lives, would close it. Not a blocker — the fixture's own LCG plus the description already make the goldens reproducible in principle — but it is the difference between a golden and a constant.
Not re-run here (no NPU library on this box and these are host-only by design): I checked the arms, the registration, the sources each target links, and the CI path; the PR's own four-arm run is the measurement.
|
Persistent review updated to latest commit 840e038 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES -- the PR's own claim is the one thing the test cannot fail on: the tower check is a sum, and the golden covers 2 of 32 rows
The fixture is built well, and I want that on the record before the objection, because most of the hard work here is right:
test_pixelsis position-sensitive --sin(0.37*c + 0.11*y + 0.05*x + 0.003*…)gives every patch a distinct value, so any permutation of tokens changes the numbers rather than shuffling identical ones;- the goldens are not self-generated:
transformers 5.22.2, torch 2.13.0+cpu, float32, eager attention, an independent implementation, which is the only kind of golden that can catch a shared misconception; - the grid is
GH=4, GW=8= 32 patches, i.e. 8 merge blocks at a 2x2 merger, so merge-block order is genuinely exercised rather than trivially equal to raster order (a 2x2 grid would have had exactly one block and this whole PR would be untestable); - the zero-weight control and the two refusals are the right kind of arms.
The objection. The body says this test checks "the one thing the three earlier PRs could not: the token order has to agree between the stages". But look at what is actually asserted, of 32 output rows:
const double d = max_abs_diff(out, kGoldenOut, size_t(2 * O)); // the FIRST TWO rows
check(std::fabs(sum - kGoldenTowerSum) < 2e-3, "fixture: the tower before the merger sums to the reference's …");kGoldenOutis2 * O= 24 values: 2 tokens of 32, and they are tokens 0 and 1, which live in the same merge block. So the per-token check distinguishes block-major from raster order for the first block, and nothing else.kGoldenTowerSumis a sum over the tower output, and a sum is invariant under any permutation -- to within float rounding, which is precisely the size of the 2e-3 tolerance. That arm cannot fail on an ordering bug by construction.
So a defect that transposed merge blocks 1 through 7, or swapped rows inside a later block, would pass both arms and land as "tokens stay in merge-block order end to end". For a PR whose stated purpose is the cross-stage order, that is the one arm that has to be able to fail.
Cheapest fix, and it fits the existing shape: keep the sum (it is a useful broad numeric check) and add one order-sensitive arm --
- either a weighted sum with a distinct weight per token index (
sum_i (i + 1) * out[i]), which changes for every non-identity permutation and costs one loop; - or extend the golden to a few rows from different merge blocks -- token 0 and token 8 say, or first two and last two -- so the reference covers more than block 0;
- either is enough; both together is better, because the weighted sum catches permutations the golden's row choice might miss.
Nothing else here needs changing: the Conv3d-as-matmul path, the position-embedding add, n_blocks of block_forward(), the merger to out_hidden, and the padding rule ("elements at or past live along axis are zero") all read the way the earlier three PRs established, and the published-pack oracle path (ONEBIT_MINICPM_PACK / ONEBIT_MINICPM_ORACLE comparing pixels.f32, tower.f32, out.f32) is a better test than the synthetic fixture when a pack is available -- it just is not the arm CI runs.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 840e038 — and this head adds no PR content: 840e03829 is Merge branch 'main' into 1bit/vision-tower, and the PR is still +940/−2 across the same 12 files. So my verdict at d4e5126 stands unchanged (four arms, the refusal arm's sentinel, add_dependencies(onebit …) instead of a workflow edit), and the merge loop can land this without waiting for a second reviewer.
Re-checked at this head rather than assumed, since a main-merge can still break a branch: the four vision targets are still registered with their own sources, add_dependencies(onebit q4nx_vision_*_test …) is still present, and .github/ is still untouched.
The one ask stands and is small: the goldens' numpy reference is quoted in a comment ("corr 1.000000 against the fp32 oracle") but is not in the PR, so -0.19714565 and the 0.223488 sum can be trusted but not re-derived. Nine lines of that reference, or a pointer to where it lives, would make them goldens rather than constants. Not a blocker for this merge — say the word and I will file it as the vision-port follow-up instead.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 840e038 — built and ran everything it claims, at that head, on this box.
build (4 targets) rc=0
q4nx_vision_block_test rc=0
q4nx_vision_pos_test rc=0
q4nx_vision_rope_test rc=0 "vision rope: 6 arms pass (identity, goldens x4, conventions note, refusal)"
q4nx_vision_tower_test rc=0 "vision tower: 4 arms pass (golden x2, zero-weight control, refusal)"
tools/ci/tests_referenced_check rc=0 "all 85 test script(s) are referenced (1 deliberately not a row)"
tools/ci/ci_targets_check rc=0 "9 … (baseline 9), and none new"; 6 guarded-by-design
The two arms that make this more than a shape test are the zero-weight control in the tower and the refusal arm in all four: a golden alone cannot tell a correct tower from one that returns the golden's shape, and a build that cannot refuse a bad geometry is the failure this session has hit repeatedly. identity in the rope test is the same instinct.
The checker change is a tightening, and that is the part I looked at hardest — a PR that adds four test targets while editing tests_referenced_check.py is exactly where an exception gets carved. It does the opposite: the corpus grows to include scripts/*.sh self-tests, with a concrete reason (scripts/pkg-proof.sh --self-test passed on its own and no runner ran it, so a gate called dead code green), and the result is visible in the count — 82 → 85 test scripts referenced, with the add_test gap still at its baseline of 9 and none new. That is what an honest widening looks like.
CI wiring is right, and by the route this tree documents: add_dependencies(onebit q4nx_vision_block_test q4nx_vision_pos_test q4nx_vision_rope_test q4nx_vision_tower_test), with the pos.cpp link dependency explained where it bites ("without it the link fails with undefined reference to q4nx_vision::pos_embed"). ci_targets_check agrees: no new gap.
Two small notes, neither blocking:
- the vision tests print their arm counts in the summary line rather than per-arm, so a failure shows as a non-zero exit and a count. That is fine, but if one of the four ever fails in CI, the log will say which test and not which arm — a per-arm line (as
layer_taps_modeland the container-role arms do) would make a red build self-explanatory. q4nx_vision_rope_test's "conventions note" arm is the kind that tends to age: if it asserts a comment exists, it will pass forever after the comment is written. Worth confirming it asserts behaviour, not text.
This is also the vision assembly my row (#1788, F1) needs for the 35B's image half, where both MiniCPM-V packs carry the same 347-tensor tower — so landing it moves that row too.
Fourth piece of the vision port, and the one that turns the other three into a tower:
tower_forward()runs the Conv3d patch embed as a matmul (kernel == stride), adds the position embedding, runsn_blocksofblock_forward(), thenmerger_forward()toout_hidden.Tokens stay in merge-block order from end to end — the order the pack's
pixel_valuesarrive in, the orderpos_embed()writes, and the order the rope positions are computed in. That is the one thing this test checks that the three earlier PRs could not: the token order has to agree between the stages, not just inside each.Goldens from the numpy reference that reproduces the pack's graph at corr 1.000000 against the fp32 oracle; weights from the same 32-bit LCG so the fixture is regenerable in another language.
What the control caught
The zero-weight arm failed on its first run — because my test passed a single-element vector where the merger's fc1 matrix belongs, so it read past the end and reported a mismatch. That is the arm doing its job: a golden-only suite would have been green. The fix was to size every zero matrix, and the comment in the test now says why.
Two traps worth naming
Both cost a build cycle each and are the kind of thing that recurs in this codebase:
std::vector<float> v(size_t(n))is a function declaration whennis a variable — the most vexing parse.static_cast<size_t>fixes it; I hit it three times across these four PRs.std::vector<std::vector<float>>are invalidated bypush_back, so the fixture allocates one flat store and offsets into it.Host float32, device-free, built without the NPU library — same shape as #1920/#1923/#1927. What remains for a real image: the pack-to-container glue (vision weights +
pixel_valuesfrom the grapher) and then the device path with~/ogaload/oga11/kernels-vision-4b-b8.