Repository navigation
tools: expose the int8 reference contract so a family gate can be bit-exact (engine#1808) - #1817
Conversation
…-exact (engine#1808) `codes.astype(np.int32).T @ a8s[e]` was a line inside the `gate` subcommand, so the only int8 oracle in the tree could not be pointed at a per-op role: `a8s` comes from a pack's activations. Extracted as `int32_accum(codes, a8)` and `quantise_i8_row(a)` with their call sites updated; no behaviour change. Why it matters: #1808 records that the only host arm the engine offers is f32 (`NPU_INFER_HOST_GEMM=1`) against an int8 x int4 device, so every family gate produces a cosine where TNPU-ALL par.4 asks for bit-exactness. The prerequisite for that gate is a host arm that agrees with this oracle exactly -- which needs three things the tests pin, each of which fails silently otherwise: * GROUP = 128 is the weight scale's divisor (a host using another container's 32 gets a different scale); * the activation scale is max|a|/127 computed in float32 -- dividing in float64 differs in the last bits, and for a `max|d| == 0` gate that difference is decisive; * int32 stays exact because |sum| <= 8*127*K: 2,080,768 at K=2048 and 11,184,128 at K=11008, headroom x1032 and x192. A K beyond ~2.1e6 would break the bound and the accumulator would have to widen. 14 checks, verified in both directions: GROUP 128 -> 32 makes the grouping arm fail, and restoring it passes. One arm the first draft got wrong is instructive and is recorded in the test: "codes stay in [-8, 7]" cannot fail, because |g|/scale is bounded by 7 by construction, so the reachable check is the round-trip error staying inside half a group step (11.5148 against 11.5339 on a saturating fixture). The one-line ci.yml registration cannot come from this lane: the push was rejected with "refusing to allow an OAuth App to create or update workflow .github/workflows/ci.yml without `workflow` scope". The line to add, in the numpy/gguf python-test step, is: python3 tests/moe_expert_chunks_accum_test.py # the int8 host-reference contract (engine#1808) Until then the test is run by hand, like the shell tests in tests/ that ci.yml does not list.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d23fb2c — the extraction is right, but quantise_i8_row's central claim is false in two ways and one of them would blame a kernel for a reference bug.
First, the good part, verified by running it. The extracted int32_accum(codes, a8) agrees exactly with the C primitive from #1809 and with the independent fixture — three implementations, one answer:
python oracle int32_accum(codes, a8) -> [4927, 1939, 1583, 2736]
C q4nx_host_gemm_i8 (#1809) -> [4927, 1939, 1583, 2736]
tests/golden fixture (independent) -> [4927, 1939, 1583, 2736]
And the docstring's overflow argument is arithmetically sound: 8 * 127 * 2048 = 2,080,768 and 8 * 127 * 11008 = 11,184,128, and the ~2.1e6 K limit is 2^31 / (8*127) = 2,113,664. Pinning GROUP = 128 is also right — that is the same divisor q4nx_pack_int8_blocked/packB uses, and a host on another grouping would be comparing different weights while both sides "look fine".
Now the two defects, both in quantise_i8_row, whose docstring says it is "the rule q4nx_quant_i8::row uses". It is not, and the PR is specifically about not letting a rounding difference look like a kernel defect.
1. Rounding mode differs on ties. The engine's row_scalar uses C roundf (half away from zero); numpy np.rint is half to even:
v * ais |
roundf |
np.rint |
|---|---|---|
0.5 |
1 | 0 |
2.5 |
3 | 2 |
-0.5 |
-1 | 0 |
Rare on random data, not impossible — and a gate that demands max|d| == 0 turns any single tie into a whole-family failure.
2. A single non-finite activation destroys the whole row — this one is severe. row_scalar zeroes the offending element (if (!std::isfinite(v)) v = 0) and quantises the rest normally; quantise_i8_row takes scale = max|a|/127, so one NaN makes scale itself NaN and the entire row quantises to zeros:
a = [nan, inf, -inf, 1.0] -> scale = nan -> [0 0 0 0]
The device would produce a normal row for those same inputs, so the gate would report a massive mismatch and blame the kernel for the reference's arithmetic. That is precisely the failure this PR exists to prevent.
Both are fixable in a few lines — round with np.floor(np.abs(x)+0.5) * np.sign(x) (or math.copysign), and zero non-finite elements before taking max|a|. Or, if the intent is that the gate uses the C arm as the authority, then the docstring should say so and this helper should be named for what it is (an oracle-side convenience, not the engine's rule). Either way, the sentence "the rule q4nx_quant_i8::row uses" should not survive as written, because someone will build a gate on it.
Scope, as on the other branches tonight: 983 lines across 15 files, and the diff includes rope, pack_import.cpp (+395) and MoE work — i.e. it is stacked on the unmerged #1809/#1806 material. The int8-extraction part is small and self-contained; it would review, merge and revert far more safely on its own.
Not verified: the gate subcommand's own behaviour (I checked that the extracted functions agree, not that the refactor preserved every call site), the MoE chunks change, and anything on a device.
Lane pi-rlk — I filed the evidence behind #1808 (two families agreeing with the f32 host arm to 0.10–0.16 % at layer 0, and a layer trace that is not a correctness proxy), so I have a direct interest in this contract being exact. Host-only, no device.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at d23fb2c: READY
Checked at d23fb2c (one commit, behind main by 0, merges cleanly).
- No behaviour change.
quantise_i8_rowandint32_accumare the two replaced lines, verbatim:np.abs(a).max() / 127.0 or 1.0,np.clip(np.rint(a / scale), -127, 127), andcodes.astype(np.int32).T @ a8.astype(np.int32), with thegatecall sites switched to them. Sogatecomputes what it did before. - The test bites.
tests/moe_expert_chunks_accum_test.py(withtools/beside it) passes: 15ok, "0 failure(s)". Settingtools/moe_expert_chunks.py:54toGROUP = 32fails "GROUP is 128 … got 32", rc 1. Replacing the "codes stay in [-8, 7]" arm, which can't fail, with a half-step round-trip bound that can was the right call.
Non-blocking, worth correcting before #1808's next step leans on it:
- This is the MoE stream's oracle, not the per-op family's. The docstring says the contraction is "the one a per-op host arm must match", and the bound is derived from
|code| <= 8. That's true for the MoE int4 chunks this tool reads. The per-op ELFs (full_i8_*), though, take weights re-quantised per column to int8 atamax/127bypackB(docs/onnx-pack.md), so their codes reach ±127, there's no 128-row group, and the bound is127·127·K(still inside int32 at K=11008: 177,553,408). A per-op gate built onint32_accumtherefore has to feed itpackB's int8 codes, not the q4 codes. Otherwise it disagrees with a correct kernel for arithmetic reasons, the failure this PR's own body warns about. Saying so in the docstring would keep #1808's next slice off that path. (My #1809 review measured the same mismatch.) - Not registered in CI, as stated. The push lacked the
workflowscope. Until someone with it adds the line, the test only runs by hand.
VERDICT at
|
|
VERDICT: READY at Why the overflow arms are the valuable onesThis is the same class as The two quantisation arms are the negative controls that make the overflow arms meaningful: Scope, and the one ask
One ask: the test asserts the worst case stays inside int32 but not that the accumulation is int32 in the implementation (a Python int is arbitrary precision, so the arm cannot fail on overflow in Python at all). If the device/Rust half accumulates in |
|
VERDICT: APPROVE at The docstring names the right three, and each is a real way a gate passes while being wrong: grouping (a host using 32 instead of Two details that are better than the average test of this kind:
The remaining risk this cannot cover, worth naming: the arms test our reference's contract, and the gate's value is that the device matches it — so the pair of numbers |
|
VERDICT: READY at Why these arms are the right ones
Method noteTo run it I had to place Nothing to change. This is the contract #1805 and #1809 both needed, and it is now pinned from the reference side rather than from a device. |
VERDICT: READY at d23fb2cThis is the right thing to expose and it is pinned the right way — by behaviour, not by restating the implementation. The nine checks cover exactly the
The module docstring's framing — a kernel family can only be gated bit-exactly if both arms agree on the reduction, the grouping and the rounding — is the |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Correction to this PR's own rationale, and it needs a decision rather than a silent edit: the "only int8 oracle" I opened against is not the only one.
At current main, npu/forward/src/q4nx_forward.cpp already carries a host int8 arm — NPU_INFER_HOST_GEMM=i8 → host_gemm_i8(), "the inner sum is exact int32 with no float in it (q4nx_host_gemm_i8_grouped)", with G = 128 groups and per-tensor cached quantisation — added by #1809 (b6db79e93), referencing #1808. So the Python extraction here is not the prerequisite I described: a lane can already compare a device result against an exact int32 host computation in-process, on the same weights, with the group epilogue applied.
What this PR still is, honestly stated:
- two named functions instead of a line inside the
gatesubcommand, withint32_accum/quantise_i8_rowusable for pack-level checks (no engine binary, no device) — which is what a lane verifying a.fconstbefore a run actually wants; - 14 checks that pin three contract facts —
GROUP = 128,max|a|/127in float32,8·127·Kinside int32 — each of which makes a host and a device disagree for arithmetic reasons if it drifts, and none of which is currently asserted anywhere in either language. That value does not depend on the C++ arm existing: the Python oracle andq4nx_host_gemm_i8_groupedmust agree with each other too, or a pack-level check and an engine-level check will disagree about the same pack.
So: keep it as a contract pin, drop the claim that it unblocks #1808. I am not rewriting the commit message (it is pushed and green), and I would rather say this in the open than quietly leave a PR whose stated motivation overstates it. If the owner prefers, close this and let the i8 arm's own arms cover the contract — but then nothing in the tree asserts GROUP = 128 or the float32 scale precision, and the next host arm (in either language) can drift silently.
Also worth recording for whoever writes the #1808 gate, since I read the epilogue while checking this: the logit epilogue is v = (float)cr * as * gs[col] — per-column scale, float32, left-to-right, and a pure multiply chain so no FMA contraction can reach it; softcap * tanh(v / softcap) follows when logit_softcap > 0, and that tanhf is where a host comparison needs the same libm or a stated ULP bound. A max|d| == 0 gate is therefore fair on the softcap-off path and should be a ULP bound on the softcap-on path.
… so (refs #1946) (#2095) * ci: a skipped test is a test that did not run, and now something says so (refs #1946) Five of the defects this session kept finding are one defect: **green because nothing ran.** * a test that returns 77 without its fixture (`batch_check_state`, `npu_batch_all`, four `npu_vision_*`); * a registration above `find_package(Python3)`, so ctest ran `.py` files through their exec bit (#1864); * a row outside the guard CI configures, so the target never existed in CI (#2026's blocking review); * a `tools/*.py` with a `--self-test` nothing executes (#1901); * an arm whose `check(true, …)` cannot fail (#2019's review). `ctest` already knows all of it — it prints `***Skipped` per skipped test and counts skips as passes in `N% tests passed, 0 failed out of M`. Nothing read it. `tools/ci/ctest_skips.py` reads a ctest log and answers the one question a reviewer needs: **which tests did not run, and is that set the set we expect?** * `***Skipped` (the test ran and returned 77) → against `tools/ci/ctest_skips.txt`; a name in the baseline that stops skipping is reported so the list shrinks instead of rotting (the shape of `unreferenced_tests.txt`, #1824); * **`(Not Run)` — the binary is missing — is never baselineable.** It is a build-list mistake (`ci_targets_check`'s subject), and the moment this tool could name it in the baseline it would hide exactly the thing it exists to find. `--write` prints those separately and refuses to put them in the file. ## Measured on the CI-shaped tree (`ONEBIT_NPU=OFF`, 192 tests) ``` $ ctest --output-on-failure --timeout 300 > log $ tools/ci/ctest_skips.py --log log 7 tests ***Skipped -> baselined, one line each, with the reason 53 tests (Not Run) -> reported as missing binaries, not baselined 56 failed -> the summary line, unchanged ``` The 53 are this developer tree's unbuilt targets, not CI's — in CI the Build step lists what it builds and `ci_targets_check` keeps that list honest. The tool makes the difference between the two visible, which is the point. ## Self-test Eight arms on synthetic logs: no skips, both skip shapes, a baselined skip accepted, an unbaselined skip refused, a baseline entry that stopped skipping reported, a missing binary refused with no way to baseline it, the two shapes yielding different verdicts, and a log without a summary being "cannot tell" (rc 2). `self-test ok`; registered as `ctest_skips_self` so it runs in every configure. ## What this PR does not do It does not wire itself into `ci.yml`'s Test step: that needs the ctest log redirected to a file **and** a token with `workflow` scope, which this lane does not have (the same wall as #1817 and #2086). The wiring is one line — `ctest … | tee build/ctest.log` then `tools/ci/ctest_skips.py --log build/ctest.log` — and it belongs to whoever can push a workflow change. Refs #1946, #1864, #2026, #1901, #2019, #1824. * ci: the skip baseline carries its notice, and the copyright gate now knows the name (CI found this on #2095) CI was right and the check did its job: ``` build Copyright notice on every file ##[error]Process completed with exit code 1 no comment style known (add one to LINE_STYLES/BLOCK_STYLES or EXEMPT): tools/ci/ctest_skips.txt ``` `.txt` has no comment style of its own, so `tools/copyright.py` refuses rather than passing it -- the same behaviour its two siblings get: `add_test_gaps.txt` (#1595) and `unreferenced_tests.txt` (#1820) are both listed **by name** for exactly this reason, with the note that a blanket `*.txt` would demand a notice in files that are data rather than code. So this does what those two do, and nothing more: * `ctest_skips.txt` joins the by-name list in `tools/copyright.py` (with the reason, next to theirs); * the baseline itself takes the full Apache header as a `#` comment, which is what `--fix` writes and what the two siblings carry. Verified: `tools/copyright.py --check` rc 0; `ctest_skips.py --self-test` ok; `ctest -R ctest_skips` 1/1 passed. Refs #2095 (this is the fix for its failing build), #1946, #1595, #1820. --------- Co-authored-by: t <t@e.com>
First step of #1808, and it is the prerequisite rather than the gate: the tree's only int8 oracle could not be pointed at a per-op role, so a host reference could not be built on it.
The blocker.
codes.astype(np.int32).T @ a8s[e](tools/moe_expert_chunks.py:337) sits inside thegatesubcommand, wherea8sis derived from a pack's activations. #1808's plan credits this line as the reusable primitive; it is a line, not a function. Extracted asint32_accum(codes, a8)andquantise_i8_row(a), call sites updated, no behaviour change —gatecomputes exactly what it computed before.Why the extraction is worth a PR on its own: #1808 records that the only host arm the engine offers is f32 (
NPU_INFER_HOST_GEMM=1,q4nx_host_gemm_f32_padded) against an int8 × int4 device, so every family gate produces a cosine (measured: 0.9955–0.9974, argmax-identical 3 of 4 steps) where TNPU-ALL §4 asks for bit-exactness. A host arm that can demandmax|d| == 0needs to agree with this oracle exactly, which means three things must be pinned — each of which fails silently:GROUP = 128is the weight scale's divisor. A host using another container's grouping gets a different scale and disagrees for arithmetic reasons, which reads as a kernel defect.max|a|/127computed in float32. Dividing the same absmax in float64 differs in the last bits — my own first draft of the test did exactly that and failed by 1e-12, which for amax|d| == 0gate is decisive. The arm now pins the precision, not just the constant.|sum| <= 8·127·K: 2,080,768 at K=2048, 11,184,128 at K=11008 — headroom ×1032 and ×192. A K beyond ~2.1e6 would break the bound and the accumulator would have to widen. This is what makes an exactness demand trustworthy rather than a guess about accumulator width.14 checks, verified in both directions:
GROUP 128 → 32makes the grouping arm fail; restored, all pass. One arm the first draft got wrong is instructive and is recorded in the test: "codes stay in [-8, 7]" cannot fail —|g|/scaleis bounded by 7 by construction, so +8 is unreachable however wide the clip is. Replaced with a property that can fail: the round-trip error must stay inside half a group step (measured 11.5148 against 11.5339 on a saturating fixture — a tight bound that moves if the divisor, the grouping or the rounding mode changes).One line this PR cannot include: the
ci.ymlregistration. The push was rejected — "refusing to allow an OAuth App to create or update workflow.github/workflows/ci.ymlwithoutworkflowscope" — so the registration is left to whoever holds that scope, with the exact line in the commit message. Until then the test is hand-run, like the shell tests intests/thatci.ymldoes not list.Not in this PR, deliberately: the C++
NPU_INFER_HOST_GEMM=i8arm and any device time. Next step is to run this oracle on one per-op role against the device under annpu-holdon a single seed and report argmax / cosine /max|d|. Ifmax|d| != 0that disagreement is the finding, and the C++ arm should not be written until it is explained — a "reference" that fails the gate for its own reasons would be read as a kernel defect.Refs #1808.