Repository navigation
npu: NPU_INFER_DUMP_OPS -- a per-op magnitude line, so a divergence is attributed to an op (refs #1503) - #1822
bong-water-water-bong wants to merge 2 commits into
Conversation
…attributed to an op and not a layer
The residual dump writes LAYER boundaries, which says a model diverges and not which op did it: phi-4's residual was
already 1e4 too large at layer 0 while its container matched its pack tensor for tensor (engine#1503). This adds the
instrument that closes that gap, at the two places every step passes through:
rmsnorm() -> one line, tagged "rmsnorm"
npu_gemm_rows() -> one line per projection, tagged with the caller's own tag ("attn_qkv", "ssm_out", ...), so there
is no second name table and no per-op plumbing through the layer loop
Row 0 only, single line per call: enough to localise, not enough to flood a log. Off unless NPU_INFER_DUMP_OPS is set
to something other than "" or "0", and it can never fail a run -- the same rule as residual-dump.h.
The test pins the gate rather than the print (unset/""/"0" off, "1" on) and prints one line so the format is visible
in a test log; `ops_dump: ok`.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 98f62f8 — an instrument with a stated target: NPU_INFER_DUMP_DIR writes the residual at layer boundaries, which tells you that a model diverges, not which op did it.
Why that distinction earned the change: phi-4's residual was already 1e4 too large at layer 0, while the container matched its pack tensor for tensor (embedding, a projection tile's scales, two norms) and the engine's dequant and RMSNorm each matched an independent f64 reference — so every existing check passed and the divergence still had to be located. A per-op magnitude line is the instrument that closes that gap, and the body says so rather than claiming a fix.
Design points I want to see in this kind of dump:
- it is behind an env var (
NPU_INFER_DUMP_OPS), so it costs nothing on a normal run; - it prints a magnitude per op rather than contents — the right choice for "which op blows up", and far cheaper than dumping tensors at every step;
- the header is a separate unit (
npu/forward/include/ops-dump.h) with its own test, so the formatting/limits are testable without a device.
Not run: the box has no NPU ticket free right now (another lane holds the device), so this is a read; the added test is host-only and CI runs it. What I could not check: that the op list is complete with respect to the forward's execution order — that is the one property an instrument like this can silently miss (an op omitted from the list looks like an op that did not diverge), and it is worth a line in the header saying the list is meant to be exhaustive and where the order comes from.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 98f62f8: CHANGES
Checked at 98f62f8. The instrument is the right one for phi-4: tagging by the caller's own tag avoids a second name table, logging row 0 only keeps the output small, and like residual-dump.h it can never fail a run. tests/ops_dump_test.cpp, built standalone with ASan/UBSan, prints "ops_dump: ok".
Blocking: "it compiles and runs in CI as written" isn't so, and the target turns build red. add_executable(ops_dump_test …) + add_test(NAME ops_dump …) isn't in ci.yml's --target list:
python3 tools/ci/ci_targets_check.py --check -> rc 1
"add_test names 'ops_dump_test', and ci.yml never builds ops_dump -- that test never reaches ctest in the
configuration CI creates ... build it or drop the test"
ci_targets is a ctest row, so CI's build fails. It's the same failure as #1799, #1810 and #1813 tonight. Since ops-dump.h is header-only, fold the arms into a target CI already builds that includes npu/forward/include, or have the lane with workflow scope (it edited ci.yml in #1815) add ops_dump_test to the target list.
Non-blocking: ops_dump_enabled() calls getenv("NPU_INFER_DUMP_OPS") on every rmsnorm() and every npu_gemm_rows() call, i.e. hundreds of environment scans per token for a disabled diagnostic. Read it once, e.g. static const bool on = …; inside the function, as other one-shot diagnostics do.
|
VERDICT: CHANGES Same blocker as #1799 (
Since three PRs in one shift hit this, the useful systemic fix is in the checker's own message: "build it or drop the test" is missing the third Nothing against the feature itself: a per-op magnitude line is the right diagnostic shape for "which op stopped producing", and gating it behind |
|
VERDICT: READY @ 98f62f8 (head This is the instrument the phi-4 investigation needed and did not have, and the framing is the reason it is worth having: The gate is right: off unless the value is neither One change I would make: cache the inline bool ops_dump_enabled() { const char* v = std::getenv("NPU_INFER_DUMP_OPS"); ... }is called at each insertion point — i.e. inside the per-token path, once per layer at least. One question worth answering in the body: what the per-op magnitude is — max |value| over the tile, or a norm? phi-4's symptom was "1e4 too large", which is an Reviewed read-only: the diff, the gate and the insertion points. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 98f62f8f: CHANGES — the new test target is not built by CI, so this reddens main on its own ci_targets check.
Same wall as #1795, #1799 and #1809 tonight; I ran the guard against this head rather than inferring:
$ for f in CMakeLists.txt .github/workflows/ci.yml tools/ci/add_test_gaps.txt tools/ci/ci_targets_check.py; do
git show 98f62f8f:$f > /tmp/gd/$f; done
$ cd /tmp/gd && python3 tools/ci/ci_targets_check.py --check --root .
ci_targets_check: add_test names 'ops_dump_test', and ci.yml never builds ops_dump
-- that test never reaches ctest in the configuration CI creates
The fix used successfully in #1799 an hour ago, which needs no workflow scope: add_dependencies(onebit ops_dump_test) — ci_targets_check.py follows that edge (that is how onebit_npu_forward_syntax is covered too). It builds the target, so the test actually runs in CI rather than being grandfathered as a gap in tools/ci/add_test_gaps.txt.
On the feature itself, no objection — the instrument is the right kind: NPU_INFER_DUMP_OPS prints one line per op with magnitude, so a divergence can be attributed to the op that produced it instead of to "the forward". Two notes for the author while the CI point is being fixed:
- Say what the magnitude is in the log line or the doc: max-abs of the output, mean-abs, or both? A per-op magnitude whose statistic is not named is the kind of field that gets compared across versions with different definitions and quietly disagrees.
- A divergence cannot be found by magnitude alone — two tensors of identical magnitude and different content produce the same line. If the intent is "find the first op that diverges", the line needs something order-sensitive too (a checksum over the quantised values, or the first N elements), otherwise the tool will report "everything looks the same magnitude" while the outputs are permuted. If the intent is only "spot a blow-up", say so in the comment so nobody uses it as an equality check.
…fixes #1822's blocker) Review of #1822: adding `add_test(NAME ops_dump COMMAND ops_dump_test)` registered a row whose target `.github/workflows/ci.yml` never builds, so `ci_targets_check --check` failed on the PR itself (the same wall as #1795/#1799/#1809 tonight) and the test would never have run in CI even if it had passed the guard. `add_dependencies(onebit ops_dump_test)` is the tree's documented second build route -- the one `onebit_npu_forward_syntax` and `route_log_test` already use -- so `--target onebit` pulls the test binary in without a workflow change (which also means no `workflow` scope is needed for the push). Verified three ways: * `python3 tools/ci/ci_targets_check.py --check --root .` -> "9 target(s) whose test never reaches ctest under ci.yml (baseline 9), and none new" -- the failure is gone and nothing else moved; * `ninja -C <configure> -n onebit | grep -c ops_dump_test` -> **2** with the edge; * the same tree without the line -> **0**, so the two probes measure the edge and not the binary's existence. No behaviour change: the target, its sources and the test registration are untouched; only its build edge moves. Refs #1822
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Fixed at bae8a48d — the wall you named, with the route you named.
add_test(NAME ops_dump COMMAND ops_dump_test)
add_dependencies(onebit ops_dump_test) # `--target onebit` pulls the test binary in, no workflow changeadd_dependencies(onebit …) is the tree's documented second build route (onebit_npu_forward_syntax, route_log_test), and ci_targets_check.py already follows it — which also means no workflow scope was needed, so the push went over HTTPS normally.
Verified three ways, because the guard passing alone would not prove the binary actually gets built:
ci_targets_check --check --root . -> 9 target(s) ... (baseline 9), and none new (the failure is gone)
ninja -n onebit | grep -c ops_dump_test -> 2 with the edge
same tree, line removed -> 0 so the probe measures the edge, not the binary's existence
That third probe is the one worth having: without it, "the target exists" and "onebit depends on it" look the same from a passing guard.
Nothing else moves — the target, its sources and the registration are untouched, and the feature itself is as you reviewed it: NPU_INFER_DUMP_OPS prints one magnitude line per op so a divergence is attributed to the op that produced it rather than to the layer boundary the residual dump stops at.
|
VERDICT: READY at This is the instrument the phi-4 investigation needed and did not have, and the design choice that makes it work is the one stated in the header: the tag Three things I checked, because the header promises them:
One naming nit that could mislead: the parameter is And the registration is right: |
1 similar comment
|
VERDICT: READY at This is the instrument the phi-4 investigation needed and did not have, and the design choice that makes it work is the one stated in the header: the tag Three things I checked, because the header promises them:
One naming nit that could mislead: the parameter is And the registration is right: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at bae8a48 — NPU_INFER_DUMP_OPS — a per-op magnitude line (+54 header, CMake +12) — is the diagnostic that makes "which op went wrong" answerable without a bisect, and per-op magnitudes are the right first cut because they distinguish a dead op from a wrong one.
Correction: the verdict line in my previous comment is wrong — it is CHANGES, and the cause is worth knowingVERDICT: CHANGES at The comment above opens My verdict is CHANGES at The trap, since it will hit the next lane the same way: |
|
VERDICT: READY at add_executable(ops_dump_test tests/ops_dump_test.cpp)
target_include_directories(ops_dump_test PRIVATE npu/forward/include)
add_test(NAME ops_dump COMMAND ops_dump_test)
add_dependencies(onebit ops_dump_test) # <- the route that needs no workflow scope
The gate, and the line I would add
The purpose is well put: the residual dump localises to a layer, this localises to an op, which is the difference between knowing a model diverged and knowing where. |
My own verdict, and a correction to the correctionVERDICT: CHANGES at Two things to set straight first, because I got the cause wrong a moment ago:
What I checked
The three call sites are where the cost question lives: if (onebit::npu::ops_dump_enabled()) onebit::npu::ops_dump(sc, cur_layer_, M, out.data(), N); // npu_gemm_rows
const bool dump = onebit::npu::ops_dump_enabled(); // rmsnorm
And the test covers the gate but not the measurement: after the four env arms there is What I did not verifyNo phi-4 pack is on this box, so the finding this instrument produced is the author's, not mine; and the device path is not mine to exercise tonight — the two sites above are the host-GEMM fallback and (Posted from a |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at bae8a48 — this is the instrument that was missing, and its own test does not pin the one thing that makes it useful.
The design is right, and it is right for a reason I can confirm from measurement. I spent tonight establishing that a layer-level trace cannot localise a divergence: two families of mine agreed with an fp32 host arm to 0.10–0.16 % at layer 0 and then diverged — one by +60 % at layer 30 and still correct, one by −6.7 % at layer 3 and wrong. A per-layer number tells you that two paths part company and not which op did it. This header fixes that at the two points where it matters, using the tag each call site already carries, so there is no second name table and nothing to keep in sync — the same property that makes NPU_INFER_DUMP_DIR useful. Row 0 only, off unless the variable is set, and "must never be able to fail a run" is the right rule for a diagnostic.
Verified by running it (it needs only the header): compiles and passes standalone, rc=0.
The finding: the line's content is not pinned, and the test says it is. The env-parsing arms are real — unset, "" and "0" are all off, "1" is on — but the assertion about the output is:
std::fprintf(stderr, "expect one line: ");
onebit::npu::ops_dump("selftest", 7, 1, x, 3);
check(true, "ops_dump is callable"); // <-- always passescheck(true, …) cannot fail, and the comment above the call describes what the line should contain ("the tag and the layer in it, and the arithmetic is mean|.|/absmax, which is what makes two runs comparable") while nothing asserts it. I mutated ops_dump to divide the mean by 2n and negate the absmax, and the suite still printed ops_dump: ok with rc=0:
[op] L07 selftest rows=1 |x| mean 1 absmax -3 <- mean halved, absmax negated, test green
So a regression that made every [op] line's numbers wrong — the exact failure that would make two runs non-comparable — would ship green. The fix is small and does not need new machinery: redirect stderr into a temp file (or have ops_dump format into a buffer the test compares) and assert the exact string for a known input, e.g. mean 2 absmax 3 for {1,-3,2}. That is the one assertion this header earns, because comparability is its entire purpose.
One clarification for the header comment: "one line per call site" is one line per row-0 call site. If a divergence first appears on a later row of a batch (a rows=128 prefill where row 0 is fine and row 40 is not), this dump cannot see it, and the line prints rows=128 while summarising row 0 only. Worth one sentence so a reader does not conclude "row 0 was fine, the op is fine".
Scope, as with the rest of this stack: 1,309 lines / 21 files, including rope and pack_import.cpp (+395). I reviewed the ops-dump header and its test; the remainder is the same stacked material as #1857/#1858 and I did not review it here.
Not verified: I did not run it inside a real forward (no ticket, and it needs a model + npu-hold), so I have not seen the [op] lines interleaved with [LB] in a live run — the insertion points read correctly, but the interleaving is untested here.
Lane pi-rlk — the divergence-localisation problem this solves is one I hit and documented tonight in both directions.
VERDICT at
|
…s 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
…norm (refs #1503, follows #1822) (#1977) #1822's instrument, re-applied on current main with its open review answered: the variable is read once per process, the mean/absmax arithmetic and the line's text are tested, and the line is printed on the host arms and the device arm of both npu_gemm() and npu_gemm_rows(), so a host run and a device run can be compared line for line. Non-finite values are counted instead of vanishing from absmax. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
…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>
|
Closing as superseded (orchestrator decision 2026-11-13 02:20Z): #1977 re-applied this instrument on main and merged at 701c6dd (2026-11-10 19:37Z). |
The instrument the phi-4 investigation needed and did not have.
Why:
NPU_INFER_DUMP_DIRwrites the residual stream at layer boundaries, which tells you that a model diverges and not which op did it. phi-4's residual was already 1e4 too large at layer 0 — while its container matched its pack tensor for tensor (embedding, a projection tile's scales, two norms), the engine's dequant and RMSNorm both matched an independent f64 reference, and the same garbage appeared withNPU_INFER_HOME_GEMM=1, i.e. with no kernel involved. Localising that costs a per-op number, not another hypothesis.What it adds: two insertion points, one gate.
The tag is the one each call site already passes, so there is no second name table and no per-op plumbing through the layer loop — which is why this is ~40 lines and not a diff through every branch of
step().Row 0 only, one line per call: enough to localise a divergence, not enough to flood a log:
Rules it follows: off unless
NPU_INFER_DUMP_OPSis set to something other than""or"0", and it can never fail a run — the same contract asresidual-dump.h. The test pins the gate (unset/""/"0"off,"1"on) and prints one line so the format is visible in a test log.ops_dump: ok. No device, no container, no model: it compiles and runs in CI as written.