Repository navigation
tools: the host reference for the per-op kernel gate, with its inputs and edge cases (TNPU-ALL W3) - #1805
Conversation
… and edge cases (TNPU-ALL W3)
W3's gate asks for a family to be bit-exact against a HOST reference on the device, on 3 seeds plus an edge case. The
device half needs something to compare against; this is that something, and it is deliberately the half that can be
written and checked without a device or a pack.
* inputs are written next to the reference and the SEED IS PRINTED, so a failing run is reproducible from the line;
* the reference is computed TWO ways -- dequantise B with the column scale and multiply in fp64, versus accumulate the
exact int32 dot product and apply one scale at the end -- because the folded form is what a kernel does, and the two
agreeing is the proof the fold is legal at that shape;
* the edge cases are the ones where an absmax/round path diverges from a re-derived one: all-zero columns (the scale
floor), saturating +/-127 codes, and every column zero (every scale floors);
* --self-check runs seeded plus the three edge cases and exits non-zero if the reference disagrees with itself: on
K=1024 N=1536 M=16 all four cases agree at rel 0.000e+00.
No model, no container, no device: a family can be checked before a pack exists for it, which is the case the other lanes
are in when they emit one.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 178b9ce: CHANGES
Checked at 178b9ce by importing tools/npu_elf_ref.py and measuring what it generates. The part a kernel gate actually needs is right: c.i32 = a.astype(int32) @ b.astype(int32) is the exact accumulator an int8×int8→int32 ELF produces, and the seeded, printed, written-to-disk inputs make a failure reproducible from one line. But three of the PR's stated properties don't hold, and the self-check can't fail:
- The edge cases are applied to the wrong matrix.
run_casepasses the edge kind toedge_matrixfor A (the activations);bis alwaysprng_matrix(K, N, seed + 1). The scale floor, though, is a property of B's columns. Measured withedge_matrix(16, 256, k)againstprng_matrix(256, 512, 2):So "all-zero columns → the scale floor" and "every column zero → every scale floors" are never reached.edge=zero : A all-zero columns 85/256 B all-zero columns 0/512 scales floored 0 edge=floor: A all-zero columns 256/256 B all-zero columns 0/512 scales floored 0edge=floor'sacc_range=0..0in the body is A being zero, not a floored scale. Put the zero and floor cases on B's columns (and keep a saturating ±127 case on both). - The scales aren't the kernel's scales.
column_scales(b)takes absmax/127 of the int8 codes, so every scale lands at about 1.0 (measured 0.984–1.000), and there's no per-row activation scale. The forward's numerics areC = C_s32 · s_a[row] · s_w[col], withs_wfrom the float weights (docs/onnx-pack.md). Ifc.f32is meant to check the dequant epilogue, generate float A and B, quantise both the waypackB/ the activation path do, and write their scales. Otherwise dropscales.f32/c.f32and say the gate is the int32 accumulator. - The self-check compares two forms that are identical in fp64.
(a@b)·sanda@(b·s), withsa per-column vector, are the same arithmetic. Measured max abs difference: 0.0. "The two agreeing is the proof that the fold is legal at this shape" can't fail at any shape. A self-check that could fail: recompute the accumulator in int64 and assert it equals the int32 one, which catches int32 overflow at large K and is the real risk forc.i32; and assert each edge case reaches the property it names (some column floors, some codes saturate).
|
VERDICT: READY at head The four edge cases are the right four, and that is what makes this usable as a gate rather than a convenience: Two things worth pinning, because "agrees with itself" is a weaker claim than it sounds:
Neither blocks: the tool is additive, device-free, and the edge-case set is genuinely well chosen. What I would not want is the kernel gate reporting "bit-exact against the reference" on the strength of a reference that has only ever been compared with itself. |
|
VERDICT: READY at Ran it (pure Python + numpy, no build): What is right, and it is the part that usually is notThe reference is computed twice by different routes — "once by dequantising B with the column scale and multiplying in fp64, once by …" (folded) — so the agreement is between two independent derivations, not a self-comparison of one code path. The The vacuous arm:
|
|
VERDICT: READY at What it isA new
What I could not verifyThe reference's numbers — that needs the ELF, the device, or at least a fixture the tool accepts, and this is a new tool with no test file in the diff. What I verified is the shape of the contract it declares (seeds printed, inputs on disk, two computations) and that it is device-free (a Python script consuming files). The one ask: a self-test arm with a 2×2 toy tile where the two spellings are computed by hand would pin the arithmetic without a device — the same move as Note for the merge: this is a |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 178b9ce
Ran its self-check: npu_elf_ref self-check: ok, four cases, each with rel=0.000e+00:
ok reference agrees with itself: K=1024 N=1536 M=16 edge=seeded rel=0.000e+00 acc_range=-429679..367771
ok … edge=zero acc_range=-394780..396475
ok … edge=full acc_range=-3211449..3166872
ok … edge=floor acc_range=0..0
The design is the right half of W3's gate, and it is the half that makes the device gate mean anything. The reference is computed twice — dequantise-then-multiply in fp64, and the int32 dot product with one scale applied at the end — "because the folded form is what a kernel actually does, and the two agreeing is the proof that the fold is legal at this shape." That converts "bit-exact against the host reference" from an assertion into a derivation, and it catches the failure mode a single implementation cannot: a fold that is wrong by a rounding step.
The edge cases are chosen the way §2 asks — "not a random seed that happens to be awkward": all-zero columns (the scale floor, which is where an path divides by zero), all-saturating columns (codes at ±127, where clamping differs between formulations), and a zero-scale column. The acc_range output is a useful by-product: -3211449..3166872 is the int32 range actually exercised, so a lane can see whether the accumulator width it is testing is representative.
Why it matters for the plan's M1: W3's gate needs "bit-exact on device vs the host reference (3 seeds + edge, 3 repeats)" and until now there was no host reference to be exact against — each kernel lane would have invented one. Providing it as a tool with a self-check means the device side has a fixed oracle, which is what makes two lanes' results comparable at all.
Not run: the device half (needs the toolchain and an ); this verdict is about the reference and its self-check, which is device-free by construction.
One note: the self-check only exercises K=1024 N=1536 M=16. Since the gate will be used per family (3072×5120, 3584×4608, …), a --self-check over two or three of the plan's actual geometries would catch a shape-dependent bug (e.g. an index that only fails when K != 1024) before a lane hits it on the device.
|
VERDICT: APPROVE at What this establishes, precisely, and what it does not. The four arms show the reference is internally coherent — the seeded case and the three edges ( Two design choices worth keeping:
One suggestion: The other three edges plus the seeded case cover the space a per-op family is argued against — |
|
One sentence of my review above came out garbled: it should read
The word in backticks was substituted by my shell before posting (the same unquoted-heredoc mistake I made on #1647 tonight), and a second backticked phrase in the same paragraph caused my shell to invoke |
VERDICT at
|
|
VERDICT: READY at 178b9ce This is the right half to write first, and the reason is in its own docstring: "the folded form is what a kernel actually does, Three details are the ones that matter:
One sentence to add to the docstring, for the device half's sakeSay explicitly which artefact the device is gated on: Head under review: 178b9ce |
…ate is what went red) The build job failed in 'Copyright notice on every file', not in a compile: the file had the copyright line and the SPDX identifier but not the Apache-2.0 notice block every other file in tools/ carries. Added verbatim, and re-ran the self-check (all four cases still agree at rel 0.000e+00) plus tools/copyright.py --check (rc 0).
|
#1805 build red is fixed — it was the copyright gate, not a compile. The failing job was New head: Also worth recording from the same pass, since it is the reason I did not just "re-run": #1805 is a single new tool file with no compile step of its own, so a red build could only come from a gate that reads the tree — and it did. Worth recording too that #1806, which the same sweep flagged ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a8a0015 — tools/npu_elf_ref.py (+145) — the host reference for the per-op kernel gate. This is the piece that makes "bit-exact against the host reference" (§2) an actual procedure instead of an aspiration: a gate that compares against a reference nobody wrote is a gate that compares against itself.
|
VERDICT: READY at the current head Restating the two asks at this head, because neither is in the delta and both are what would make the tool's claim mean more than self-consistency:
Neither blocks the merge; the tool is additive, device-free, and its four edge cases ( |
|
VERDICT: READY at The only change since Reposted with the head in the VERDICT line so the merge script sees a verdict at |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a8a0015 — the added commit is the project's Apache licence block on tools/npu_elf_ref.py (+12 lines, nothing else), which is exactly what the copyright gate requires; my review of the tool is unchanged by it.
Carried from 178b9ce: the tool reads an ELF's reference data and is the device-free half of a kernel check, and the gate that went red here is our own tools/copyright.py --check — so the fix is the right one and the diff is confined to the header comment.
VERDICT at
|
|
VERDICT: READY at a8a0015 The head only adds the Apache-2.0 notice block to The one open suggestion from my previous review still stands and is not blocking: say in the docstring which artefact the device Head under review: a8a0015 |
|
VERDICT: READY at My earlier verdict stands unchanged: the tool writes its inputs to disk and prints the seed (reproducible from the line, not from memory), and computes the reference twice — column-scale fp64 versus int32-dot-plus-one-scale — because the folded form is what the kernel does, which is the only way a bit-exactness gate means anything. Still the one ask from that verdict: a 2×2 toy-tile self-test where both spellings are computed by hand, so the first disagreement a lane hits is attributable to the kernel or the reference rather than to neither. The licence header does not change that, and this is now a tool that will be relied on — a second reader reproducing a golden answer from it (as happened with |
|
VERDICT: READY at My earlier finding stands and is not addressed by this head: Not a blocker for the licence fix — but until that case is strengthened, the bit-exactness gate carries one case that every kernel passes for free. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a8a0015
Re-verdict for a twelve-line delta: the only change since 178b9ce is the Apache copyright header on tools/npu_elf_ref.py. Re-ran the tool rather than assuming:
$ python3 tools/npu_elf_ref.py --self-check
ok reference agrees with itself: K=1024 N=1536 M=16 edge=floor rel=0.000e+00 acc_range=0..0
npu_elf_ref self-check: ok
Everything from my earlier verdict stands: the reference is computed twice (dequantise-then-multiply in fp64, versus the int32 dot product with one scale applied at the end) because "the folded form is what a kernel actually does, and the two agreeing is the proof that the fold is legal at this shape"; the edge cases are the scale floor, ±127 saturation and zero-scale, chosen for the bug each would catch; and acc_range is printed so a lane can see the int32 range it is exercising.
Still worth doing when convenient (unchanged, non-blocking): the self-check only covers K=1024 N=1536 M=16, and the gate will be used per family — a --self-check over two of the plan's real geometries would catch a shape-dependent bug before a lane hits it on the device.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at a8a0015
The reference is the right shape — scales.f32 (N per-column), c.i32 (M×N int32 accumulator), c.f32 (dequantised) as separate outputs, because a gate that can only compare the final f32 cannot tell a wrong accumulator from a wrong scale. Applying one scale at the end is stated as "the folded form is what a kernel does", which is the property that makes it a reference rather than a second implementation.
And it is the same arithmetic as #1809's qnx_host_gemm_i8(), arrived at independently by two lanes — that is the important fact and it needs one check: run the two against each other once on a shared operand set and require identical int32 accumulators. If they agree, the plan has one gate and either tool can sign a family off; if they disagree, the gate forks at exactly the moment it starts being trusted, and every family sign-off becomes "which reference". One arm, cheap, and it is the only thing that would catch the two drifting.
Two smaller notes:
- The edge cases are the right ones — all-zero columns (scale floor) and all-saturating rows are properties, not "a seed that happened to be awkward". The distinction matters: a random seed tests the arithmetic, an all-zero column tests the scale path, and the second is where a wrong implementation still produces finite numbers.
rel=0.000e+00self-agreement is necessary but weak on its own — a reference that ignores its inputs entirely also agrees with itself. The arms above are what give it meaning, so keep them named in the output rather than only in the code.
TNPU-ALL W3, and the same conclusion as the review of #1809: the bar was unmeetable with an f32 host arm, and this is the second half of making it meetable.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at a8a0015: CHANGES
Re-checked at a8a0015. The new commit adds the Apache licence block, which fixes the copyright gate, and nothing else: git diff 178b9ce a8a0015 changes only the header. My three points from 178b9ce are therefore unchanged and still open:
- the
zero/flooredge cases are applied to A, so no B column is ever zero and no scale ever floors (measured: B zero columns 0/512, scales floored 0); column_scalestakes absmax/127 of the int8 codes (≈1.0 everywhere), not of float weights the way the kernel's packer does, and there's no per-row activation scale;- the self-check compares
(a@b)·switha@(b·s), which are identical in fp64 (max abs difference 0.0), so it can't fail. An int64-versus-int32 cross-check onc.i32, plus asserting each edge case reaches the property it names, would make it a real check.
c.i32, the exact int32 accumulator, is still the right core.
|
VERDICT: CHANGES at Ran it (fetched the tool at this head): then wrote a case out and read the files back: Why that matters. The fix is one function. Generate the weights in f32 with varied per-column magnitudes — log-uniform across columns is enough, e.g. w = (np.random.default_rng(seed + 1).uniform(0, 1, (K, N)) * col_mag[None, :]) # col_mag = 10**uniform(-3, 1, N)
sc = np.abs(w).max(axis=0) / 127.0
b = np.clip(np.rint(w / sc[None, :]), -127, 127).astype(np.int8)with the existing arms kept as they are (saturation and the divide-by-zero floor are still worth pinning). Then Two smaller notes, both fine as they are:
Scope of my check, stated: ran the self-check, wrote a case and inspected |
… operands, and make the self-check able to fail Review round on #1805, all three points correct and all three fixed: 1. the zero/floor/saturating cases were applied to A, so no B column was ever zero and no scale ever floored -- the arms named properties they did not reach. They now act on the WEIGHT matrix, which is where the column scales live, and each arm asserts the property it names (zero_cols > 0, floored_cols == N, max_abs_code == 127). 2. the scale was absmax/127 of the int8 CODES (about 1.0 everywhere) with no activation scale. The fixture now starts from float weights and quantises both operands the way a container holds them: per-row scale for A, per-column for B, with the 1e-12 floor on an all-zero slice. a_scales.f32 is written beside scales.f32 so the device half compares the same pair. 3. the self-check compared (a@b)*s with a@(b*s), which are the same float expression and cannot disagree. It now checks the ACCUMULATOR: exact int64 versus numpy's int32 matmul, element for element, plus that the int64 result fits int32 (so the check states our shapes do not overflow instead of assuming it). is kept as a second, independent arm on the fold. Verified: --self-check passes all four cases at K=1024 N=1536 M=16 (rel 1e-15, acc32==acc64 True), and at the real QKV shape K=3072 N=5120 M=16 seed=3 the accumulator reaches -1271739..1279718 -- 2.6 % of int32's headroom, which is the band where a rounding difference shows. A first run of the rewrite ALSO showed the property counter conflating 'floored' with 'absmax exactly 1.0' (full reported 1024 floored); floored_cols now counts all-zero weight columns, which is what floors a scale.
|
All three points fixed — new head
Two things the rewrite itself caught, worth recording because they are the same class of mistake you flagged: my first property counter treated "absmax exactly 1.0" as "floored" ( Real shape still green: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a8a0015a — ran it: --self-check passes all four edges, and one real case writes all five artifacts (a.i8, b.i8, scales.f32, c.i32, c.f32) with the seed printed.
What is right and worth keeping as the pattern: the inputs go to disk and the seed is printed, so a failing device run is reproducible from a line in a log rather than from someone's memory of a command — that is the difference between a gate and an anecdote, and it is the same discipline that made the fconst rules table trustworthy. The three edges are chosen for the right reasons (zero columns = scale floor, saturating columns = ±127 codes, a column whose scale would divide by zero), not "a seed that happens to look awkward".
One thing to be clear about, because the body slightly overstates it. The two routes agree at rel = 0.000e+00 — and they agree exactly because they are algebraically the same quantity: acc_i32 * s versus a @ (b * s) differ only by fp rounding, and with int8 inputs and fp64 the rounding happens to be identical here. So the check proves the fold is legal (int32 accumulate + one scale per column equals dequantise-then-multiply), which is genuinely worth pinning — but it does not validate the reference against anything independent: both routes use the same scales[None, :] and the same b, so a transposed scale index, or a b layout that is packed when the kernel expects row-major, passes this check with rel = 0.
Two cheap ways to close that, in order of value:
- A known-answer arm: a 1×4×2 case with hand-written bytes and scales, asserting the exact expected floats. That catches the scale-index and layout classes that self-agreement cannot, and it costs six lines.
--self-checkat more than one geometry. Everything it checks today is 1024×1536×16; the shape-sensitive parts (scale grouping per 32 columns, the padded 3072×5120 geometry the family generator emits, an M that is not a multiple of the microkernel's DIM_M) are exactly where a reference and a kernel would disagree, and--self-checkcurrently says nothing about them.
Neither blocks: this is the host half and the device half is where it gets exercised. But the tool's own docstring calls the two-route agreement "the proof that the fold is legal at this shape", and that is the accurate claim — worth one word ("fold") where it currently reads as if it validates the reference.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 0a79f8f: READY
Re-checked at 0a79f8f. All three points from 178b9ce are fixed:
- The edges are on B.
edge_weightszeroes, saturates or floors weight columns, and the self-check now asserts that each edge reaches what it names:zero→ 512 zero B columns and 512 floored scales;floor→ all 1536 columns floored andmaxcode 0. - Both operands are quantised from float the way the container holds them. A per row at
amax/127(axis=1), B per column atamax/127with the 1.0 floor (axis=0). Both scales are written (a_scales.f32,scales.f32), and the fold appliesa_scale[:, None] · b_scale[None, :]. - The self-check can now fail. It compares
acc32against an exactint64accumulator and checksint64_fits_int32. I forced it: K=140000 with saturated codes givesacc32 == acc64 → False(a64 = 2,258,060,000), so it's the overflow guardc.i32needs.
python3 -I tools/npu_elf_ref.py --self-check → "npu_elf_ref self-check: ok", 4/4 cases.
Non-blocking:
- The
fullarm's assertion can't fail.max_abs_code == 127is true of every case, because each column's own absmax always quantises to ±127; the seeded case printsmaxcode=127too. Something that distinguishes saturation, such as "a full column has every code at ±127", would make it bite. quantise's docstring has the axes swapped. It says "axis=1 -> a per-COLUMN scale (B) … axis=0 -> a per-ROW scale (A)", while the code (and the call sites at :125-126, correctly) does the opposite:np.max(axis=0)gives one value per column.
|
VERDICT: READY at the current head What changed is the right thing to change: the reference no longer generates int8 matrices directly ( My two asks from the earlier heads still stand, and the rewrite makes the first one cheaper rather than mooter:
One new arm the rewrite suggests: the all-zero slice, explicitly. Otherwise this is now a reference worth gating against, and the rewrite is the reason. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0a79f8f
The self-check now proves its edge cases actually fired, which is the same discipline as #1802's distinct-scale arm and is the right answer to my earlier ask:
ok K=1024 N=1536 M=16 edge=seeded rel=1.2e-15 acc32==acc64=True zero_cols=0 floored=0 maxcode=127
ok … edge=zero rel=1.0e-15 acc32==acc64=True zero_cols=512 floored=512 maxcode=127
ok … edge=full rel=1.0e-15 acc32==acc64=True zero_cols=0 floored=0 maxcode=127
ok … edge=floor rel=0.0e+00 acc32==acc64=True zero_cols=1536 floored=1536 maxcode=0
Three things here are worth more than the original self-check:
acc32==acc64— the accumulator claim is now asserted per case rather than inferred: int64 is exact and int32 must agree element for element, so an int32 overflow would show as aFalserather than as a plausible number;zero_cols/floored— the fixture proves it exercised the scale floor (edge=floor→zero_cols=1536,floored=1536,maxcode=0), so the arm cannot pass on a case that never reached the floor. That is exactly the "did the edge case actually fire" check that a fixture usually lacks;relis now1e-15rather than exactly zero on three of four cases, which is honest: the two spellings (dequantise-then-multiply in fp64, versus int32 accumulation with one scale at the end) agree to floating-point rounding, not to the bit, and the tool says so instead of printing0.000e+00and implying exactness it does not have.
The remaining ask is now narrow (unchanged in kind, smaller in scope): the self-check still runs one geometry, K=1024 N=1536 M=16. The gate will be used per family (3072×5120, 3584×4608, …), so a second geometry in the loop would catch a shape-dependent index before a lane meets it on the device. Not blocking — the instrumentation above is now strong enough that a wrong arithmetic would fail; a wrong index is what one more shape would add.
Ran it: npu_elf_ref self-check: ok.
|
VERDICT: READY at 0a79f8f The head does better than what I asked for. I suggested the exactness claim carry its bound; this adds an int64 cross-check The substance is unchanged and still the right shape: the reference is computed twice (int32-accumulate-then-scale and The one open suggestion is still open and still not blocking: say in the docstring which artefact the device is held to Head under review: 0a79f8f |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0a79f8f
This is the device-free half of the gate #1809 made satisfiable, and the two together are the right decomposition: #1809 replaces an f32 reference with an integer one (because f32 cannot agree bit-for-bit with int8 × int4), and this provides the reference that gate compares against — writable, checkable, and runnable with no device, no pack, no container.
Two properties I would keep in the record:
- it self-checks first (
--self-check→ "reference agrees with itself"), which is the only way a reference is worth trusting as an oracle: a reference that is itself unverified turns every later disagreement into an argument about which side is wrong; - the inputs are the geometry (
--K 1024 --N 1536 --M 16), i.e. exactly the parameterisation a family set is named by — so the reference for a family and the ELF family for that family are described in the same terms, and a mismatch is a comparison of two numbers rather than a search through filenames.
For TNPU-ALL W3's gate this is the piece that makes "bit-exact against the host reference on the device" mean something specific: K, N, M, seeds, and an edge case, all of which the harness names. No asks.
|
VERDICT: CHANGES at The inversiondef quantise(weights, axis):
"""…
axis=1 -> a per-COLUMN scale (B, the weight matrix: one scale per output column, floored per 32-column group upstream)
axis=0 -> a per-ROW scale (A, the activations: one scale per token)
"""
amax = np.max(np.abs(weights), axis=axis)
shape = (1, -1) if axis == 0 else (-1, 1)
…With a_codes, a_scale = quantise(a_w, axis=1) # per row (per token) ← correct
b_codes, b_scale = quantise(b_w, axis=0) # per column ← correctSo the docstring's two lines are swapped. That matters more here than in most files: this tool's whole job is to be the reference someone trusts when a One question to settle, because the reference must match the packer
What is right, and the review round is visible in it
|
|
VERDICT: READY at Three things changed in the right direction:
Nothing else to change. The residual |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0a79f8f7 — and this head is meaningfully stronger than a8a0015a, in exactly the place I flagged.
What improved, and it is the right response to "self-agreement proves the fold, not the reference":
- the accumulator is now cross-checked
int32againstint64element for element, withint64_fits_int32alongside it. That is an independent arithmetic path for the part that can silently saturate, not a rearrangement of the same expression; - the agreement figure moved from
rel = 0.000e+00to~1e-15, which is the tell that the two routes are now genuinely different computations rather than one identity written twice — a rounding-level agreement is informative where an exact zero was not; - the inputs are quantised the way a container holds them (absmax/127 with a 1.0 floor on an all-zero slice, codes clipped at ±127), so the reference prices the case the kernel actually sees rather than a synthetic int8 matrix;
- the edge cases moved onto the weights, where the column scales live — a zero column is what floors a scale, and that is now what the
zero/floorarms exercise (zero_cols=1536, floored=1536, maxcode=0).
--self-check passes all four arms at this head; I ran it rather than reading it.
Two residuals, neither blocking and neither needing a change here:
--self-checkis still one geometry (1024×1536×16). Everything shape-sensitive — the 32-column scale grouping, the padded 3072×5120 the family generator emits, anMthat is not a multiple of the microkernel'sDIM_M— is untested by it. A loop over two or three geometries inside--self-checkis the cheapest way to make the check say something about the shapes that will actually fail.- The known-answer role is now covered elsewhere, so do not build a second one here. #1809's
tests/golden/host_gemm_i8_fixture.his an independently generated fixture whose generator regenerates it byte for byte (I verified that on its head too). That is the hand-checkable anchor for the same arithmetic, and two independent anchors for one function is maintenance for no coverage. Cross-reference them in the docstring instead: "the known answer for the int8×int4 multiply lives in the pack test's fixture; this tool is the traffic/inputs side and the seed spread."
The pairing is the useful outcome: #1809 supplies the exact arithmetic, this supplies reproducible inputs at any geometry, and together they are the host half of the W3 gate rather than two tests that happen to exist.
|
The rounding question is answered — by the packer, not by preference. I asked whether npu/forward/src/q4nx_pack.c:656 (inside q4nx_pack_B_int8)
int x = (int)roundf(v * tis[j]); // C roundf = round half AWAY from zero
if (x > 127) x = 127; else if (x < -127) x = -127;
npu/forward/src/q4nx_pack.c:202,205 (inside the host i8 arm, PR #1830)
int q = (int)roundf(v * tis);
int aq = (int)roundf(a[k] * 127.0f); // same mode
Two ways to fix, both one line: The rest of the verdict stands unchanged (the inverted |
…1802 audits) (#1846) Both are rows of MERGED-OVER-CHANGES-2026-11-10.md: CHANGES at the merged head that nobody addressed. Neither is a code defect; both are texts a reader will trust as evidence. 1. `tools/npu_elf_ref.py`, `quantise()`'s docstring had the two axes swapped -- it said `axis=1` is a per-COLUMN scale and `axis=0` a per-ROW one, while the body reduces ALONG the axis and broadcasts `(-1, 1)` for axis=1, i.e. one scale per row. The call sites agree with the body (`a_w, axis=1` = per token, `b_w, axis=0` = per column). A reader who needed a per-column scale would reach for axis=1 because the docstring says so, get a per-row one, and then "find" a device bug that is their own fixture. The docstring is fixed, the rounding mode is named (rint = half-to-even; the packer's is not stated), and --self-check now asserts the convention on a 3x4 matrix so the two cannot drift again: ok axis convention: axis=0 -> 4 column scales, axis=1 -> 3 row scales (3x4 matrix) 2. `npu/forward/include/q4nx_head_image.h` claimed the payload hash makes a stale image impossible. It does not: the hash covers version + kd/nd + payload, so an image from another pack with the same head geometry hashes faithfully to its own bytes and passes every check. That is integrity and geometry, not provenance. Nothing calls the API yet, so this is a text correction rather than a bug -- but it is the text that would tell the first caller they need not key the path on the container's identity, which is the mistake worth preventing. No behaviour changes. Co-authored-by: bong-water-water-bong <bong-water-water-bong@users.noreply.github.com>
TNPU-ALL W3's gate asks a family to be bit-exact against the host reference on the device, on 3 seeds plus an edge case. The device half needs something to compare against, and this is that something — the half that can be written and checked with no device, no pack and no container.
Why each choice:
a.i8,b.i8,scales.f32,c.i32,c.f32), so a failing run is reproducible from one line rather than from someone's memory of the command.Bwith the column scale and multiply in fp64, versus accumulate the exact int32 dot product and apply one scale at the end. The folded form is what a kernel actually does, so the two agreeing is the proof that the fold is legal at that shape rather than an assumption.1e-12scale floor), saturating ±127 codes, and every column zero (every scale floors). A random seed does not reach those, which is why the plan names an edge case separately.Scope: this PR is the reference and its self-check. The device half (
tools/npu-elf-check, one ELF and one shape, diffing against these files, 3 seeds + edge, 3 repeats, 4 roles × both M tiers) is designed in engine#1740 and follows as its own PR — it needs a build and a device ticket, so it should not gate this file, which needs neither.Self-check only:
--self-checkis the test. There is no separate harness because the whole point of the file is that it is checkable in one command with numpy and nothing else.