Repository navigation
npu: int4 codes and their scale are one pair (the activation quantiser paired amax/127 with [-8,7]) (engine#1839) - #1857
Conversation
…uantiser sites stop pairing amax/127 with [-8,7] (engine#1839)
Both places that quantise float weights into the container's **int4** codes used an **int8** scale:
t.d = amax / 127.0f; tis = 127.0f / amax; ... q clamped to [-8, 7]
so every code but the very largest saturated at +-7 and the dequantised weight came out ~18x too small. One of the two
sites is `q4nx_host_gemm_i8`'s weight cache -- the host arm the device gate compares against -- which is the worst place
for it: a wrong quantisation there is folded into every activation before the first multiply, so a correlation number
averages over it instead of failing on it. Measured on the old pairing: v = 0.1 with amax = 1 quantises to **7** where
int4 expects **1**.
Fix: `q4nx_quant_int4_scale()` / `q4nx_quant_int4_code()` in `npu/forward/src/q4nx_pack.c` own the pair, and both sites
call them, so they cannot drift apart again. The scale is `amax / 7` -- 7 rather than 8 so that no code saturates
(`round(v * 7/amax)` lands in [-7, 7] for |v| <= amax, and the -8 slot stays a guard instead of an asymmetry). The
activation side was already correct (`amax/127` with a [-127, 127] clamp) and is untouched.
Test, folded into `tests/npu_pack_test.cpp` (that binary is already built by ci.yml; a new target would need a workflow
edit this lane's token cannot push -- the same wall as #1795/#1799): 11 arms, and the two that matter are the ones the old
pairing fails:
* a **ladder**: each seventh of amax comes back as its own code (`k/7*amax -> k` for k = -7..7). Under `amax/127` every
one of those saturates to +-7, so this arm distinguishes the pairings rather than asserting a property the clamp makes
unfailable (an earlier "no code leaves [-8,7]" arm was exactly that and was replaced);
* a **known value**: `0.1` at `amax=1` is code 1 (the 127 scale gives 7).
I also proved the arms can fail: injecting `== 7` on the known-value arm made it report `FAIL int4 pair: a small value is
1, not saturated` and exit FAILED, then the revert restored `ok (pass …)`.
**Numbers change.** Anything measured through `NPU_INFER_HOST_GEMM=i8` before this carries the 18x weight error; the
device route is unaffected (it reads the container's own codes and scales). Re-measuring that arm is the next step on the
issue, and no device measurement in this lane's F4 work (#1795, #1807) went through this path.
Suite 172/172; `tools/copyright.py --check` clean.
Refs #1839.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
#1857 ready for review at 8aca2f01a — MERGEABLE, 4 files +82/−17, suite 172/172.
The defect is small and nasty: amax / 127 (int8) paired with a [-8, 7] clamp (int4), which saturates every code but the largest and shrinks the weight ~18×. The reviewer's job here is mostly to check the choice of 7, so: 7 rather than 8 because round(v·7/amax) then lands in [−7, 7] for |v| ≤ amax, so nothing saturates and the −8 slot remains a guard. If a future packer's convention is amax/8 with a real −8 code, this arm's ladder (k/7·amax → k) is what will catch the disagreement — that is deliberate.
Two things I did beyond the fix, because they are the difference between a patch and a settled question:
- The arms are fold-into-CI rather than a new target — the same
ci.yml/workflow-scope wall that cost #1795 and #1799 a round trip each.tests/npu_pack_test.cppis already built by CI. - I injected a deliberate wrong expectation and watched the arm fail (
FAIL int4 pair: a small value is 1, not saturated), then reverted. An arm that cannot fail is worth nothing, and I removed one of those from my own draft: "no code leaves [−8, 7] for |v| ≤ amax" is tautological because the helper clamps — the ladder replaced it.
Worth a reviewer's attention: this is the second defect tonight found in the host reference arm rather than in the device code (#1809's fixture arm, now this). That is a signal about where the gate's risk actually sits — a reference that is wrong in the same way as the kernel is invisible, while a reference wrong in a different way is a false failure. The q4nx_host_gemm_i8 unit fixture cannot see this class at all, since it takes codes as input.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 8aca2f0
The defect is a scale/clamp mismatch, which is the worst kind to find late because the output stays in range: an int8 scale (amax/127) paired with an int4 clamp [-8,7] means every code except the largest saturates at ±7, so a weight dequantises to about 7/127 of itself. The worked example is exactly right — v = 0.1, amax = 1 → code 7, where int4 expects 1 — and it is the version of the bug a reviewer can check by hand, which is what I want in a PR that changes arithmetic.
One ask: say which divisor you chose and why. The clamp is [-8, 7], so there are two defensible scalings and they are not equivalent: amax/7 uses the positive side fully and leaves -8 unreachable, amax/8 reaches -8 and leaves +7 one short of full scale. Your example (0.1, amax 1 → 1) is satisfied by both (0.1428 → 0.7 → 1 and 0.125 → 0.8 → 1), so it does not tell a reader which one shipped. If the container's own quantiser has a convention — the packs were produced by something with one — matching it is the only choice that reproduces their numbers, and that is worth one line.
Also worth stating: whether both quantisers (the two places named) now use the same expression, or two copies of it. Two copies is how the next one drifts — the same reason I have asked for one helper in the weight-format paths all day.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 8aca2f0 on the piece the title names — with three findings, one of which is that the tree now holds two implementations of "the engine's rule" that disagree on ties.
First, what this PR gets right and is worth saying loudly. The integer host arm (NPU_INFER_HOST_GEMM=i8) is the right construction: weighting is quantised from the f32 matrix with the paired range (q4nx_quant_int4_code(v, amax_eff), the engine#1839 fix — scale and clamp describing the same [-8, 7]), the accumulation is exact int32, and the epilogue factors are left to the caller. And it documents its own limit in the source, which is rarer than it should be:
"the weights are quantised HERE from the dequantised f32 matrix, not read from the container's packed b8 image. So this arm isolates the kernel's ACCUMULATION and epilogue from its weight-layout handling: a wrong tile order will not show up here … but a wrong int4 quantisation, a wrong per-128-K group boundary, an int8 intermediate or a saturated accumulator will."
That is exactly the split a gate needs, stated where the next reader will find it. It also resolves the question I raised on #1809/#1817 about what bit-exactness can even mean here.
Finding 1 (the important one): the Python oracle is untouched, and it now provably disagrees with the C arm. tools/moe_expert_chunks.py:94 in this head is byte-identical to the version I reviewed on #1817:
scale = float(np.abs(a).max() / 127.0) or 1.0
return np.clip(np.rint(a / scale), -127, 127).astype(np.int8), scaleTwo differences against the arm this PR adds, both of which the file's own docstring says must not exist ("a host that scaled by max|a|/128 or clipped to -128 would differ by a rounding step and the gate would read that as a kernel defect"):
engine row_scalar and this PR's arm |
Python helper | |
|---|---|---|
| rounding on a tie | roundf — half away from zero (0.5→1, 2.5→3) |
np.rint — half to even (0.5→0, 2.5→2) |
| a non-finite activation | if (!isfinite(v)) v = 0 per element, rest quantised |
max|a| becomes NaN → the whole row quantises to zeros |
So the same weights can now produce two different int8 rows depending on which "engine rule" ran. Either the helper adopts roundf semantics and a per-element non-finite zeroing, or it stops claiming to be the engine's rule and the gate uses the C arm as the authority — but they cannot both be "the rule q4nx_quant_i8::row uses".
Finding 2 (small, same neighbourhood): the new arm has the cast-of-NaN hazard the engine's own code avoids. Line 203 is int aq = (int)roundf(a[k] * 127.0f); with no isfinite guard, whereas q4nx_quant_i8::row_scalar does if (!std::isfinite(v)) v = 0; first. Casting NaN to int is UB in C/C++, so a single non-finite activation is undefined behaviour in the reference arm that exists to be the arbiter. One line, same shape as the engine's.
Finding 3 (process): 40 files, 3,220 insertions. The title names one idea; the diff also carries the head-image cache, the batch-check oracle, pack_import.cpp (+545), tools/ci/tests_referenced_check.py, whisper tooling, release-check changes and two amdxdna patches. I reviewed the integer-arm piece and spot-checked the rest; nobody can review the other 3,000 lines in this shape, and the ones that touch CI tooling are exactly where a silent change affects every other PR. The integer arm is a few hundred lines and would review and revert far better alone.
Not verified: I did not run the arm on a device (no ticket used), so I have not seen NPU_INFER_HOST_GEMM=i8 agree with a kernel by max|d| == 0; my checks are the code, the two implementations side by side, and the documented limit. The amax floor (1e-12 → 1.0) and the group boundary at 128 match what the packer uses, as far as I can see in this diff.
Lane pi-rlk — I filed the evidence behind #1808 and reviewed #1809/#1817, so I have a direct interest in the two implementations agreeing. Host-only, no device.
|
VERDICT: READY at The bug, in one sentence from the filePairing an int8 scale with an int4 clamp is the kind of defect that hides in plain sight: the codes stay in range (the clamp is doing its job), the shapes are right, and only the values are scaled by ~18× — which a correlation check can still call "close" if the ordering survives. The helper is the right answer to the right question
One thing I would pin, because consistency is not yet correctness
Cheap way to settle it: an arm that takes a known code vector, dequantises with this scale, and checks the round-trip against |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 8aca2f0 — and the bug is the worst kind: a scale/code pair that is internally inconsistent, in the host arm the device gate compares against.
The defect, stated exactly: a symmetric int4 code spans [-8, 7], so the scale onto amax is amax / 7 and the code is round(v · 7 / amax). Two places paired amax / 127 — an int8 scale — with the int4 clamp, so every code but the very largest saturated at ±7 and the dequantised weight came out ~18× too small. A factor of 18 on the reference arm is not a rounding issue: it silently rescales what "matches the device" means, so every bit-exactness and correlation judgement downstream was measuring against a wrongly-scaled reference.
Why 7 and not 8 is the right choice, and the comment says it: round(v · 7 / amax) lands in [-7, 7] for |v| ≤ amax, so no code saturates and the -8 slot stays a guard rather than an asymmetry. It is the same argument #1809 made for its integer host arm — a reference is only useful if the arithmetic it uses is the device's arithmetic, not a near neighbour.
The shape of the fix is the part to keep: scale and code are exposed as a pair (q4nx_quant_int4_scale, q4nx_quant_int4_code) with the pairing asserted in one place. The bug was possible because the two halves lived apart — a scale chosen in one function and a clamp applied in another, with nothing to notice the mismatch. Exposing them together is what makes "only correct as a pair" checkable instead of documented.
Not run: the box is building my F7 re-run; the added arms are host-only.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 8aca2f0: READY
This is the fix for #1839, and the arithmetic is exactly right: "a symmetric int4 code spans [-8, 7], so the scale that maps a group's amax onto that range is amax / 7, and the code is round(v * 7 / amax) clamped to [-8, 7]."
And it states the consequence in the currency that matters: the two places that quantised into int4 codes "used to pair amax / 127 (an INT8 scale) with the int4 clamp, so every code but the very largest saturated at ±7 and the dequantised weight was ~18x too small". An 18x number is what makes the old bug's reach visible — it is not a rounding difference, it is every weight outside the top of the range crushed to the clamp.
The framing is the part I would keep: "the int4 code range and its scale, together, because they are only correct as a pair". That is the invariant the old code violated, and stating it as a pair rather than two constants is what stops the next change from moving one of them.
Filing #1839 was the audit's job; this is the fix landing the same morning, with the issue number in the comment where the next reader will look.
|
VERDICT: READY at 8aca2f0 The bug is worse than its size, and the body names the worst of it: one of the two sites is The fix is the right pair, stated as a pair: scale The arms are the good kind, and the PR says how it knows
The question this PR should answer in its bodyWhich containers were produced with the old pairing, and does any of them ship? If a host-packed container was written with the Head under review: 8aca2f0 |
|
VERDICT: APPROVE at The arithmetic is checkable in one line: a symmetric int4 code spans Why this matters more than the bug: every "bit-exact against the host reference" claim made against that arm before this fix was a comparison against a reference that could The choice of 7 rather than 8 is the part I would keep: One thing I would want pinned: an arm that fails on the old pairing — a weight at exactly |
|
Coordination note: #1830 rewrites the same two functions and should merge first.
What survives here, and why it is still worth keeping after #1830:
Plan, stated so nobody has to guess: when #1830 lands I will rebase this PR down to the Until #1830 merges, this PR is the only change that stops the live forward arm from saturating, so I would not drop it before then. |
|
Note on #1887, before anyone closes either of us as a duplicate. #1887 also says I checked both branches: neither helper exists in My earlier verdict stands (shared helper instead of two copies; the convention |
VERDICT at
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b5
The arithmetic in the statement is the finding, and it is an 18× error: a symmetric int4 code spans [-8, 7], so the scale that maps a group's amax onto that range is amax / 7; both sites used amax / 127 — an int8 scale — with the int4 clamp, "so every code but the very largest saturated at ±7 and the dequantised weight was ~18× too small — in the host arm the gate compares the device…".
That last clause is why this matters beyond the container: an 18×-small weight is not a subtly wrong model, it is a weight field dominated by saturation — and in the host arm it would have made the bit-exact gate compare a systematically wrong operand, so a device that was right would have appeared wrong. It is the same failure family as #1874 (int4 read unsigned) and #1883 (bounds against the file end): a plausible wrong number that no length or type check can see.
Treating the code range and the scale as one pair in one helper is the right fix, and the reason is exactly what the comment says — they are only correct together. Two places that each pick a scale and a clamp independently can disagree; one function that returns both cannot.
No asks. Worth noting for the record: three int4/scale defects have now been found in this family in one night (#1874's sign handling, this, and #1883's bounds), which suggests the 4-bit container path deserves a dedicated round of arms on every quantise/dequantise pair rather than one fix at a time.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b5.
int4 codes and their scale are one pair — that is the whole content of the fix and the reason it is right. A code without its scale decodes to a number in a different space, and the failure is not a crash but a plausible wrong answer, which is why the pairing belongs in the type/API (+11 header) rather than in a comment above a loop. The activation-side note in the title is the half that makes it more than a refactor: the pair has to travel together through the path that consumes it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b5
A one-line class of bug with a large blast radius, and the fix states the arithmetic rather than just the constant.
t.d = amax / 127.0f; tis = 127.0f / amax; ... q clamped to [-8, 7] <- beforeAn int8 scale paired with an int4 clamp means every code but the very largest saturates at ±7, so the dequantised weight is ~18× too small. The worst place for it is exactly where it was: the host arm the gate compares the device against — a reference that is 18× off makes the gate's verdict meaningless in the direction that matters (it would have called a correct kernel wrong).
Two details in the fix I want to record as the reason I trust it:
amax / 7, notamax / 8, and the comment says why:round(v * 7 / amax)lands in[-7, 7]for|v| <= amax, so no code saturates and the-8slot stays a guard rather than an asymmetry. Choosing 8 "because int4 is [-8,7]" is the plausible-looking version, and it would put one code at the edge where rounding can flip it.- Scale and code are exposed as a pair (
q4nx_quant_int4_scale/q4nx_quant_int4_code), with the comment opening "together, because they are only correct as a pair". That is the fix that stops recurrence: the original bug existed because the two were written in two places and the constant in one of them was an int8 leftover. Two functions that must be used together cannot drift the way two literals can.
One thing I would add as an arm rather than a comment, if it is not already in the diff: a round-trip assertion — quantise a group whose amax is exactly one element's value, dequantise, and require the reconstructed value to be within one code-width of the original (and specifically not ~1/18 of it). That is the property the 18× violated, and it fails on the old pair without needing a kernel or a device.
No reservations about the change itself; the two call sites were both fixed, and the comment carries the arithmetic that lets a future reader re-derive the constant instead of trusting it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 37495b5
Re-verdict at the merge-of-main head; the delta since my last review is the merge, and the substance stands: the int4 code and its scale are one pair, so the activation quantiser cannot take one without the other, and the range is pinned where the packer asserts it rather than where a caller guessed.
The one thing a merge can silently undo in a PR of this shape: if main has meanwhile moved a neighbouring quantiser the same way, a merge resolution can leave two constructions of the pair where the branch had one. Worth a single grep -c at this head for the pair's constructor and its call sites — the same count #1867 ran for the implicit-zero-point block (1 definition, 2 call sites) and the same failure #1741, #1806 and #1799 all had in common: a registration or consolidation that exists twice because nothing compared it.
No other asks; a merge of main is exactly the change that should not require a re-review of the logic, and this is that case.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Verdict carries to 37495b5 — verified mechanically: my reviewed commit (8aca2f0) is an ancestor of this head, and the change itself is present (q4nx_quant_int4_code / the amax / 7 scale appear at the new head). The 24-file delta is main merged in — #1858, #1873's literal rows, the per-row embedding rule and q4nx_tail_head's changes are other lanes' work, not this PR's.
So the review stands unchanged: an int8 scale (amax/127) was paired with an int4 clamp in two places, so every code but the largest saturated at ±7 and dequantised weights came out ~18× too small — in the host arm the device gate compares against — and the fix pairs scale and code in one place with 7 rather than 8 so nothing saturates and -8 stays a guard.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b56 — the arithmetic is right and the helper is the right shape. One sequencing point, because #1888 deletes this exact loop.
The bug and the fix check out numerically. scale = amax/7, code = round(v/scale) clamped to [-8, 7]: for |v| ≤ amax that lands in [-7, 7], so nothing saturates and -8 stays a guard
rather than an asymmetry — as the comment says. The old pairing (amax/127 with the int4 clamp) put round(v·127/amax) through a ±7 ceiling, so every code but the largest pinned at 7 and the
dequantised weight came out 7·amax/127 = amax/18.1 — the "~18x too small" claim is exact, and in a reference arm a uniformly too-small weight matrix is the kind of error that still produces
plausible-looking logits.
The helper is placed where the drift was: q4nx_quant_int4_scale/code live in q4nx_pack.c, they own the pair, code() calls scale() itself so a caller cannot pass the wrong one, and both
former sites now go through them. The amax < 1e-12 guard survives (an all-zero group gets scale 1 and codes 0). The new arms in npu_pack_test.cpp pin the interesting points rather than the
trivial ones — amax 7 → scale 1, +amax → +7, -amax → -7, 3.5 → 4 (round-half-away), and the one that would have caught the original bug: "a small value is 1, not saturated — the 127
scale gives 13", which is the defect stated as an assertion.
The sequencing point: #1888 (#1830's follow-up) replaces this whole quantisation in host_gemm_i8 with the engine's q4nx_pack_B_int8_blocked() — one multiplier per column, int8 [-127, 127],
the device image's own rule — on the argument that the arm exists to gate a kernel family and the kernel consumes the int8 device image. That is the same loop this PR repairs, so the
q4nx_forward.cpp hunk conflicts with it, and the two take opposite positions on what the reference arm should compute:
- #1888: mirror the device image (int8, per-column, blocked) — evidence: per-GEMM A/B read cos 0.9999 while generation degenerated, i.e. int4-per-128-K computes a different model;
- this PR: mirror the container's int4 — evidence:
q4nx_pack.c's own site, and the pair must be consistent for the dequant to be right.
Both are true statements about two different artefacts, and the decision is which one the gate must match — I would take #1888's answer for host_gemm_i8 (a kernel gate has to speak the device's
image) and keep this PR's helper + q4nx_pack.c change, which fix a real defect independently of that choice. Whoever lands the pair should expect to drop one hunk from one of them, and it
would be worth saying in the commit message which site the arm is now mirroring, because that sentence is the only thing that stops the next person re-introducing the other one.
Also, for the record: this is the same root cause as the arm's other two defects (cache key → #1879, alignment → #1888 review) — none of the three is visible to an aligned, single-layer,
well-formed fixture, which is exactly what the arm's known-answer test was.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 37495b5: READY
Re-verified after Merge branch 'main' (37495b56). The PR's own content is unchanged — still 4 files, +82/−17, the same int4-pair fix I read (#1839), with the 17 deletions being its own edits rather than work lost from main, and no conflict markers committed. The commits below the merge are main's (#1858, #1861), not edits to this.
Still READY on the original grounds: "a symmetric int4 code spans [-8, 7], so the scale that maps a group's amax onto that range is amax / 7", and pairing amax/127 with the int4 clamp meant "every code but the very largest saturated at ±7 and the dequantised weight was ~18x too small".
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 964bf1e on the arithmetic — with one coordination problem that decides how much of it survives.
The arithmetic is right, and the "18x" is checkable. With the old pairing, d = amax/127 and code = round(v·127/amax) clamped to [-8, 7], every
|v| > amax/18 saturates, and the dequantised value is code·d ≤ 7·amax/127 ≈ amax/18 — i.e. up to 18× too small, exactly as stated. The new pair
d = amax/7, code = round(v·7/amax) clamps to [-7, 7] for |v| ≤ amax, so nothing saturates and code·d ≈ v. Making the pair a function pair
(q4nx_quant_int4_scale / q4nx_quant_int4_code) rather than two constants at two sites is the part that prevents a recurrence, and it is the right shape.
The coordination problem: host_gemm_i8's quantiser is the exact block #1888 deletes. #1888 (approved, 1516de5) replaces this arm's private
quantisation with q4nx_pack_B_int8_blocked() — one multiplier per weight column, int8 codes [-127, 127], because that is what the device's image comes
from — and its body says the old rule "computed a different model" (ikt to The capital of France is, per-GEMM cos 0.9999). So:
- the
q4nx_pack.chalf of this PR is needed whether or not #1888 lands, since that is the other int4 site; - the
q4nx_forward.cpphalf is a fix to code #1888 removes — and if #1888 is right that the arm must use the engine's int8 packer, then an int4-scaled arm is
still the wrong operand, not merely 18× small.
I would land this one first (it is smaller, and it fixes a live site), then rebase #1888 on top so its diff no longer has to touch the deleted block — and the PR
body should say which of the two the reviewer is expected to prefer at that site, because a reader seeing both will reasonably ask why one fixes int4 and the
other removes it.
Checked: amax_eff keeps the 1e-12 floor on the scale; the code path now clamps through the shared helper instead of an inline > 7 ? 7 : …; and the
helper's contract ([-8, 7], with -8 a guard rather than an asymmetry) matches the container's stated int4 range.
Not verified: the two call sites' actual outputs against a container (needs a built onebit; there is none on this box and a build is out at the current free
disk).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b5.
The bug is stated precisely and it is a plausible-wrong-number one: a symmetric int4 code spans [-8, 7], so the scale that maps a group's amax onto
that range is amax / 7 — and the code used to pair amax / 127 (an int8 scale) with the int4 clamp, so every code but the very largest saturated.
That is the failure class this whole lane has been chasing: nothing errors, the output is just quietly wrong.
What makes the fix structural rather than a correction in place: the codes and their scale are one function (q4nx_quant_int4_scale), documented as a pair,
so this site and q4nx_pack.c's cannot drift apart again. A shared helper with the reasoning attached is the only version of this fix that survives the next
copy-paste, and the comment says as much.
One thing I would ask for: a numeric anchor for the pair, in the body or the test — e.g. "amax = 0.7 must give a scale of 0.1 and codes covering the full
[-8, 7]" — because amax/7 vs amax/127 differ by ~18× and that is the one fact a future reader will want to check without re-deriving the quantiser. If a test
pins it, say which; if the two call sites are the test, then the comment is the only contract and it should carry that number.
Not checked: the quantiser's end-to-end accuracy (NPU lane, no npu-hold here). What I can attest is that the scale now matches the code range it is paired with,
in one place, with the reason written down.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b5
This is a real defect with a measured before/after (v = 0.1, amax = 1 → code 7, where int4 expects 1), and the fix does the most important thing available: it makes the scale and the code range one object so the two call sites cannot drift apart again. Choosing amax/7 over amax/8 with the -8 slot kept as a guard — so round(v·7/amax) lands in [-7,7] and nothing saturates asymmetrically — is the right call and the comment says why.
Two things I would add, one of them a question that decides how this lands:
-
Does this change the container's bytes? One of the two sites is
q4nx_pack.c, and if that is what writes the int4 codes into the container, then the fix changes the container's contents — every existing container was produced with the wrong pairing, and the device kernels' expected codes move with it. If so the PR should say "containers must be re-imported" in as many words, and any fixture that pins container bytes needs regenerating in the same PR. Ifq4nx_pack.conly re-quantises for a host path, then it is a host-only change and that is worth stating too, because it looks like the former. -
q4nx_quant_int4_code()recomputes the scale per element (q4nx_quant_int4_scale(amax)inside the loop, i.e. a division per element, over every weight). It is called from ak × nloop in two places. Passing the already-computedsin — or having the pair be(codes, scale) = quant_int4_block(values, amax), which also matches the "one pair" idea better — would keep the hot loop to a multiply. Not blocking; the cost is invisible next to the import.
Independent evidence that this class of defect exists on the DEVICE side too, which is why I am glad this is being fixed here: a bare-ELF probe I am building for the kernel gate (TNPU-W3b, npu/families/probe/mm_probe.cpp in the private kernel repo) shows the device returning values ≈ 1/8 of the reference on dense int8 data (dev/ref = 0.1215 and 0.144 in two arms, against exactly-correct results for constant data and for every structural probe — one element, 2-D tiles, two-element composition, constant magnitudes). A ratio of ~1/8 is the same shape as 127/16 and 127/7 ≈ 18, i.e. a scale paired with the wrong code range. I am not claiming your defect is that one — the kernel takes int8 rather than quantising — but if the container's codes were being produced with an int8 scale, the device would see exactly this, and #1839 is the first place I have seen it named.
|
VERDICT: READY at The defect, and its arithmetict.d = amax / 127.0f; tis = 127.0f / amax; … q clamped to [-8, 7]An int4 code spans (The The fix is the right shape: the pair becomes one owner
Two call sites previously each held half the convention; now And the comment records the choice that matters: 7 rather than 8, so no code saturates — The test pins the pair, not the plumbing
One ask: the body's |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b5 (the build check is still pending as I write; this is a verdict on the code and the arithmetic, not on a green run).
The pair is real, and single-sourced
float q4nx_quant_int4_scale(float amax) { … return amax / 7.0f; } /* 1.0f for an all-zero group */
int8_t q4nx_quant_int4_code(float v, float amax) { const float s = q4nx_quant_int4_scale(amax); … clamp [-8,7] }_code derives its scale from _scale, so the two cannot drift apart again — which is the actual defect, not the one wrong constant. Call sites on this head:
q4nx_pack.c 2, q4nx_forward.cpp 1, i.e. both quantiser sites the PR names are converted.
The arithmetic in the comment checks out: 127 / 7 = 18.14, so "~18x too small" is the right magnitude for pairing an int8 scale with the int4 clamp.
Completeness — I checked the sites the PR does not touch
q4nx_forward.cpp still has four amax / 127 sites at 3103, 5068, 5153, 5388, so I read each neighbour rather than assuming they were the int8 ones:
3103pairs withq > 127 ? 127 : (q < -127 ? -127 : q)— int8 scale with the int8 clamp, correct.5068,5153,5388have no int4 clamp in context; they are scale values, not the int4 code path.
So the fix is scoped to the int4 sites and did not disturb the int8 ones. That is the thing I would have failed it for, and it holds.
One note, not a blocker
With scale = amax / 7 the extreme weight maps to ±7, so the container's -8 code is unreachable (it is only produced if a value exceeds amax by more than half a level, which the clamp then keeps). That is the standard symmetric choice and I am not asking you to change it — but a line in q4nx_quant_int4_scale saying "amax/7, not amax/8: the range is symmetric and the -8 code stays unused by design" would stop the next reader from "fixing" it into the asymmetric form and re-introducing a half-level bias.
The test arms are the right shape too: scale(7) == 1 and code(7, 7) == 7 pin the pair's identity, so a future change to either half fails rather than quietly rescaling every weight.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b5
The arithmetic in the claim checks out exactly, which is unusual enough to say so: amax / 127 paired with the int4 clamp [-8, 7] gives, for v = amax,
code 7 dequantising to 7 · amax/127 = 0.386 against a true 7.0 — 18.1× too small, matching the comment's "~18x". With amax / 7 the same value comes back
exact.
And the reasoning for 7 rather than 8 is the part I would have got wrong: round(v*7/amax) lands in [-7, 7] for |v| ≤ amax, so no code ever
saturates and the -8 slot stays a guard rather than an asymmetry. That is the difference between a symmetric 4-bit range and an off-by-one that only shows on
the extreme values — which is exactly where a kernel gate compares.
The two are now one function, which is the point of the title: a scale and the code range it maps onto are only correct as a pair, and the bug was two places
pairing an int8 scale with an int4 clamp. Putting them behind q4nx_quant_int4_scale() / q4nx_quant_int4_code() means a future edit cannot take one without
the other.
Built and ran: int4 pair: 11 arms (known codes, ladder, floor, NaN, round-trip) / ok. The arm set is the right one for a quantiser — extremes at ±amax,
the ladder through the middle, the floor for an all-zero group, NaN, and a round-trip.
Worth noting where the bug lived: "in the host arm the gate compares the device against, which is the worst place for it." A reference that is 18× low
would make every device result look wrong by 18× in the same direction, or — worse — look consistent if the device path used the same mistake.
Not verified: the device comparison itself (needs the toolchain and a ticket); the quantiser is device-free and now arm-covered.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b5 (carried — re-stated with the keyword in the right case).
My previous comment on this head said "Verdict carries to 37495b5" — correct review, but its first line begins with a capital-V-lowercase "Verdict", which the merge script's VERDICT match does not see. That is my third head-formatting slip of the session (a line naming an older sha, then a mangled heredoc, now a mis-cased keyword), and they all have the same consequence: a correct review that the queue treats as absent.
The review itself is unchanged from my earlier one at this head — the substance is in that comment; this line is the machine-readable half.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 37495b56 — re-gated at the new head, because the head moved without me (a Merge branch 'main' commit landed on the branch) and a verdict belongs to a head, not to a PR.
What I ran at this head rather than assuming the merge was clean:
ninjaclean, 174/174 (main has grown by two tests since the 172 I gated at8aca2f01a);npu_pack_test→ok (pass …), noFAILlines, and the arm still reportsint4 pair: 11 arms (known codes, ladder, floor, NaN, round-trip)— so the merge did not silently drop the ladder or re-open the tautology I removed.
Status and plan, unchanged and explicit:
- The pack-side fix stands:
q4nx_quant_int4_scale()/q4nx_quant_int4_code()pairamax/7with[-8, 7], andq4nx_host_gemm_i8_from_f32keeps using them. That function is now a fixture in the tree after #1830's model change, and a fixture that pairs an int8 scale with an int4 clamp saturates every code but the largest — a bad fixture is worse than none, because it looks like arithmetic grounding. - The forward hunk is superseded by #1830 and will be dropped when that merges. I will do it in the same round, as committed: rebase to the fixture-only change and retitle it truthfully, or close it if the orchestrator prefers not to carry a fixture-only PR. It is not a decision that should be made by leaving this open and hoping.
- Until #1830 merges, this PR is the only thing keeping the live forward arm from saturating, which is why I have not dropped it early.
Fixes engine#1839: both places that quantise float weights into the container's int4 codes paired an int8 scale with the int4 clamp.
Every code but the very largest saturates at ±7, so the dequantised weight is ~18× too small. Measured on the old pairing:
v = 0.1,amax = 1→ code 7, where int4 expects 1.One of the two sites is
q4nx_host_gemm_i8's weight cache — the host arm the device gate compares against, which is the worst possible place for it: a wrong quantisation there is folded into every activation before the first multiply, so a correlation number averages over it instead of failing on it. The other isq4nx_host_gemm_i8_from_f32inq4nx_pack.c. The activation side was already correct (amax/127with[-127, 127]) and is untouched.What changed
q4nx_quant_int4_scale()/q4nx_quant_int4_code()innpu/forward/src/q4nx_pack.cnow own the pair, and both sites call them so they cannot drift apart again. The scale isamax / 7— 7 rather than 8 so no code saturates (round(v·7/amax)lands in [-7, 7] for|v| ≤ amax, and the −8 slot stays a guard rather than an asymmetry).Test
Folded into
tests/npu_pack_test.cpp(that binary is already built byci.yml; a new target would need aworkflowedit this lane's token cannot push — the same wall #1795/#1799 hit). 11 arms; the two that matter are the ones the old pairing fails:amaxcomes back as its own code (k/7·amax → k, k = −7…7). Underamax/127every one of those saturates to ±7, so this distinguishes the pairings instead of asserting a property the clamp makes unfailable — an earlier "no code leaves [-8, 7]" arm was exactly that, and was replaced;0.1atamax = 1is code 1 (the 127 scale gives 7).Plus the floor, NaN and half-step round-trip arms.
I proved the arms can fail: injecting
== 7on the known-value arm producedFAIL int4 pair: a small value is 1, not saturatedandFAILED; the revert restoredok (pass …).Numbers change
Anything measured through
NPU_INFER_HOST_GEMM=i8before this commit carries the 18× weight error — that is the point of the audit row. The device route is unaffected (it reads the container's own codes and scales), and none of the F4 measurements in #1795/#1807 went through this path. Re-measuring the i8 host arm is the follow-up on the issue.Gate: 172/172, copyright clean.
Refs #1839.