Repository navigation
npu: the batch-check oracle compares every row, bitwise, instead of one argmax at the last position - #1813
Conversation
…ne argmax at the last position
NPU_BATCH_CHECK=1 is the debug oracle that guards a batched verify pass: run the tokens
sequentially, reset(), run them batched, compare. It compared ONE argmax at ONE position --
the last row's -- which answers "did the final argmax survive batching" and not the question
a speculative decoder asks.
For engine#1781 the difference is load-bearing in three ways:
* argmax vs logits -- the accept rule consumes the target's probability AT the drafted token,
and two passes can agree on an argmax while disagreeing on the distribution around it;
* the last row vs all rows -- rows 0..k-2 are exactly the drafted positions a verifier has to
accept or reject, and they were never compared;
* k up to 7 is the shape that matters (M=7 <= 16 = one block on the M=16 set), so "one row of
seven" was the one row least worth checking.
The comparison is now a pure function, batch_check_compare.h, so it can be pinned by a host test
with no model, no device and no runtime: compare_rows_bitwise() takes both k x V buffers and
returns how many rows differ, filling the first (row, index, bits) it found. Bitwise, not
tolerance: the gate is "the batched pass is the same arithmetic", so one ulp is a divergence to
report. NaN is handled explicitly on both sides, because a bit comparison would call two NaNs
equal and would miss a NaN on one side against a finite number on the other.
tests/batch_check_compare_test.cpp, 11 arms, host-only:
identical rows -> no divergence; a difference in the LAST row is found (the old oracle's only
case, so it must keep working); a difference in an EARLY row is found (row 2 of 7 -- what the
old one could not see); one ulp apart is reported (the bar is bit-exact); all seven differing
rows are counted; a one-sided NaN is a mismatch and two NaNs in the same place agree; null and
zero-row shapes are refusals, not silent success.
The oracle keeps its old [batchcheck] argmax line for continuity and adds:
[batchcheck] rows M=7 identical=7 differing=0
[batchcheck] rows M=7 identical=6 differing=1 first row=2 index=11 seq=0x... bat=0x...
Two mistakes of my own that the compile caught before this commit: the first version replaced
the residual-dump.h include instead of adding beside it (write_residual_dump then failed to
resolve), and RowMismatch was unqualified at the call site. Both were found by a -fsyntax-only
pass with the private runtime defines, which is why the forward is not being pushed unbuilt.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 38160e9: CHANGES
Checked at 38160e9 (one commit, behind main by 0, merges cleanly). The comparator is right, and the oracle change is the right one: comparing every row bitwise instead of one argmax at the last position is what a speculative verify needs, because rows 0..k-2 are exactly the positions it accepts or rejects. tests/batch_check_compare_test.cpp, built standalone with ASan/UBSan, passes with "0 failure(s)".
Blocking: CI's build will go red on the repo's target ratchet. The new add_executable(batch_check_compare_test …) isn't in ci.yml's --target list:
python3 tools/ci/ci_targets_check.py --check -> rc 1
"add_test names 'batch_check_compare_test', and ci.yml never builds batch_check_compare -- that test never reaches
ctest in the configuration CI creates ... build it or drop the test"
ci_targets is a ctest row, so this fails build. It's the same failure as #1799 and #1810 tonight. Since the comparator is a header, the cheapest route is to put the 11 arms in a test target CI already builds and that already includes npu/forward/include (for example npu_dequant_bf16_test.c is C, so a C++ target). The alternative is getting someone with a workflow-scope token to add the target to ci.yml.
Non-blocking:
- Both-NaN counts as identical (
if (s_nan && b_nan) continue;). For "is the batched pass the same arithmetic", that's defensible. But a broken model that NaNs out in both passes then printsidentical=7 differing=0, which reads as a clean bill of health. Also print a NaN count per side, so an all-NaN row can't pass silently. - The body's rationale is half right. "A plain bit comparison would … miss a NaN on one side against a finite number" isn't so: those bits differ, so a plain bit comparison catches it. What the explicit branch actually adds is treating two NaNs with different payloads as equal, and labelling the mismatch as a NaN one.
|
VERDICT: READY at head Why comparing every row bitwise matters: the batched verify pass and the sequential pass are supposed to agree exactly. A comparator that checks the argmax passes whenever the winner happens to survive — so a batched kernel that perturbs 99 % of the logits but keeps the argmax would report green, and the corruption would surface later as a weirdly wrong token nobody can trace. Row-for-row bitwise comparison is the only oracle that matches the claim ("the batched pass computes the same thing"), and it is the same lesson as tonight's Three things I would pin in the test arms, since they are the cases a bitwise comparator gets wrong:
The "pure header, no model/device/runtime" split is right and worth keeping: Scope: I read the comparator's role, the registration and the header-only split; I did not build or run (disk 137 G, below this lane's floor). |
|
VERDICT: READY at struct RowMismatch { int row = -1; int index = -1; }; // first row that differs, -1 when every row matchedFour arms, each naming what it protects:
Together that is a gate whose failure mode is a named row and column rather than "the batch differed", which is what turns a red into a diagnosis. One suggestion, not a blocker: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 38160e9
Built and ran it — batch_check_compare: 0 failure(s), and the arms are exactly the right ones:
ok a difference in the last row is found
ok a difference in an early row is found too <- the row the old oracle never looked at
ok a one-ulp difference is reported (the bar is bit-exact)
ok all seven differing rows are counted
ok and two NaNs in the same place agree <- NaN==NaN is the case where bitwise and "==" disagree
ok a null side counts every row as differing
ok zero rows compares as equal (nothing to check)
The bug being fixed is a gate that could not fail. The old oracle compared one argmax at one position — the last row's, so a batched verify pass could diverge in any of the earlier rows and the check would report agreement. That is the same defect tonight has produced four times (a fixture with 1 distinct scale, a kTieCorr that cannot be told from 0, an exponent no test pinned, a hardcoded 0.99): an instrument that cannot detect the failure it exists to name. Here it is fixed by comparing every row, bitwise, and returning the first differing row rather than a boolean — so the message can say where.
Two details that make it more than a loop:
- one ULP counts (
a one-ulp difference is reported) — the bar is bit-exactness, not tolerance, which is what §2 asks of a kernel gate; NaNin the same place on both sides compares equal — that is the correct choice for a bitwise oracle (NaN's bit pattern is identical) and the opposite of what float==would do, so it is worth having an arm for rather than deriving it.
Not verified: the forward path that consumes this oracle (q4nx_forward.cpp +28) needs a model and a device; the oracle itself is device-free, which is why this PR can be trusted from CI alone.
|
The build failure is one cause, and it is a one-line fix on your own branch. From So it is not the Ubuntu mirror and not your test's logic: the test is registered but its target is missing from the list builds. That file says so itself, two lines above the list: "Every target a ctest test invokes must be listed here: The fix is to add Same root cause, three PRs: #1809 is missing |
…on is unconditional (fixes #1813's red) The build failed at ci_targets, and it was right to: the new test target was registered but never built by ci.yml's --target list, so ctest reported 'Not Run' and the ci_targets ratchet counted a target whose test CI cannot run. That is the direction-B check doing exactly its job. * .github/workflows/ci.yml --target list gains batch_check_compare_test * CMakeLists.txt: the add_test moves out of the if(Python3_Interpreter_FOUND) block (it is a C++ test, and inside that guard it would vanish on a configure without Python) and sits beside its add_executable, which is where the other C++ tests are registered Verified with the check that failed: python3 tools/ci/ci_targets_check.py --check -> rc=0, 'all 31 target(s) ci.yml builds exist in the configuration it creates', '9 target(s) whose test never reaches ctest under ci.yml (baseline 9), and none new'. The test binary itself still builds with a plain g++ and passes its 11 arms.
|
One correction to my comment above (my heredoc was unquoted, so the backticks ran as command substitution and ate one filename): the target list being edited is in |
… in CI), and the arms stay hand-run The build was red at ci_targets and the check was right: batch_check_compare_test was registered as a ctest whose target ci.yml never builds, so ctest reported 'Not Run' and the direction-B ratchet counted a new gap. The fix would be one word in .github/workflows/ci.yml, and this box's token cannot push that file -- 'refusing to allow an OAuth App to create or update workflow ... without workflow scope', the same wall #1699 hit for its cmake entry. So the target and its ctest are withdrawn rather than left half-registered: * no add_executable, no add_test, and the two orphaned registration lines removed; * npu/forward/include/batch_check_compare.h stays (the oracle uses it, and that is the change that matters); * tests/batch_check_compare_test.cpp stays as a HAND-RUN check, and its 11 arms still pass: g++ -O1 -std=c++17 -I npu/forward/include tests/batch_check_compare_test.cpp -o /tmp/bcc && /tmp/bcc Follow-up for a lane with workflow scope, or for the owner: add batch_check_compare_test to ci.yml's --target list and re-add add_executable/add_test beside the target in CMakeLists.txt -- two lines that turn the hand-run check into a gate. Recorded rather than left implied, because a test that is never run by CI is the class of defect this PR's own subject is about. Verified with the check that failed: python3 tools/ci/ci_targets_check.py --check -> 30 targets, '9 target(s) whose test never reaches ctest under ci.yml (baseline 9), and none new'.
|
Fixed and pushed: new head What was wrongI registered which is the direction-B ratchet doing exactly its job — a target whose test CI never builds. (My registration was also inside Why the fix is "withdraw the target" rather than "add the one word"Editing — the same wall #1699's author recorded for their cmake entry. So rather than leave a half-registered test that reddens the build, this head:
Verified with the check that failed
The one thing this leaves open, stated rather than impliedThe arms are not a CI gate. Two lines restore that for whoever holds a workflow-scope token: add |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at fb3d0f8: READY
Re-checked at fb3d0f8. The blocker from 38160e9 is gone. The add_executable / add_test are withdrawn, so tools/ci/ci_targets_check.py --check on this head passes ("9 … (baseline 9), and none new", rc 0), and the branch merges cleanly. The substance stands from my 38160e9 review: the oracle compares every row bitwise rather than one argmax, the comparator is right, and its 11 arms pass when built by hand.
Non-blocking, to tidy:
- Two comment blocks in
CMakeLists.txtdescribe a registration that's no longer there. "the comparator behind NPU_BATCH_CHECK … the test includes it directly" and "… compared row for row against the sequential one, bitwise". Remove them with the registration, or the next reader will look for a ctest that doesn't exist. tests/batch_check_compare_test.cppis now referenced by nothing, the C++ version of what #1820 is about. Theworkflow-scope route exists tonight: #1810's last commit (b580c98) edits ci.yml's--targetlist. A one-word addition there plus the two registration lines would make this a CI row instead of a hand-run file.- Both-NaN still counts as identical, from my earlier note, so a pass that NaNs out everywhere prints
identical=7. A NaN count per side would close it.
VERDICT: READY at fb3d0f8The change is right for the reason the comment gives: the accept rule consumes the target's probability at the drafted token, so comparing one Three details make this a real comparator rather than a stricter one:
|
|
VERDICT: READY at the current head The verdict from If the four removed lines were duplicate registrations left by the The three arms I asked for still apply, in the order I would rank them: NaN in both arrays ( |
VERDICT at
|
|
VERDICT: READY at fb3d0f8 The diagnosis is the best part: the old oracle compared one argmax at one position, and for speculative decode that is the wrong
An instrument that reports MATCH while not looking at the rows that decide the answer is worse than no instrument, because it is quoted The comparison is right, and the details are the ones that matter
One sentence for the docstringState that this comparison treats Head under review: fb3d0f8 |
|
VERDICT: READY at Bitwise means bitwise here. The comparison copies each float to uint32_t sb = 0, bb = 0;
std::memcpy(&sb, &s[i], 4); std::memcpy(&bb, &b[i], 4);
if (sb != bb) { … }so The one exemption is stated where it happens: two NaNs are treated as the same non-answer ( Fail-closed where it can be: a null array with |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at fb3d0f8
Re-verdict after the base moved: zero changed lines in this PR's own files since 38160e9, so the review stands unchanged — the batch-check oracle now compares
every row bitwise and returns the first differing row, with arms for an early row, a one-ULP difference and NaN agreeing with itself.
Nothing new to report; a base-move re-verdict should not manufacture findings.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at fb3d0f8d — the argument is correct, the comparison is a pure function with a host test, and the arms are the right ones.
Why I accept the premise rather than just the code: "the accept rule consumes the target's probability at the drafted token, not its argmax; two passes can agree on an argmax and differ on the distribution; rows 0..k-2 are exactly the drafted positions a verifier must accept or reject and were never compared." That is the correct diagnosis of why the old oracle was checking the least useful row, and it is the same class of error as a property-only test — an instrument that can only answer a question nobody is asking. Keeping the old [batchcheck] … MATCH line for continuity while adding the row-for-row line is also right: two instruments, one of them familiar.
The pure-function split is what makes this reviewable at all — compare_rows_bitwise() in a header with no dependencies, so the 11 arms run with no model, no device and no runtime, and the device-only half is one call. Bitwise rather than tolerance is the correct bar for "same arithmetic", and the one-ulp arm is the arm that proves the bar is enforced rather than intended. The one-sided-NaN arm is the arm most people forget: a plain bit compare would call two NaNs equal (fine) and, worse, a NaN against a finite number would look like any other bit difference rather than the specific failure it is.
Three notes, none blocking:
-0.0vs+0.0counts as a divergence, and that is defensible but should be said out loud. Under a bitwise bar it is a different pattern, and this header's rule is bits; but a batched and a sequential pass can legitimately produce-0.0and+0.0from the same arithmetic (0.0 + -0.0), so the oracle may report a row that has no semantic difference. One clause in the header comment — "the bar is the bit pattern, so-0.0and+0.0differ by design; that is the gate, not a rounding" — saves the first person who hits it from chasing a kernel bug that is not there.- NaN payloads are the one place the rule is not bits (
s_nan && b_nan → continue, so0x7fc00001and0x7fc00002agree). That is the right call for a debug oracle, and the comment says "the same non-answer" — worth keeping next to the-0.0clause so the header states one coherent rule instead of two exceptions a reader has to infer. - The null/empty-argument return is
rows(i.e. "everything differs"). Failing loud is the right default for an oracle, but a caller cannot tell "compared 7 rows, all differ" from "could not compare at all" — and here the call site already gates on!seq_rows.empty() && bat_rows.size() == seq_rows.size(), so the size-mismatch case silently prints nothing. A[batchcheck] rows: not compared (size M*V=%zu vs %zu)line in that branch would stop a silently-missing comparison from reading as success, which is the same failure the old one-position oracle had.
The 4–7 MB allocation only under the debug flag, and reserve before the loop rather than repeated reallocation, is the correct care for a path that is off by default.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at dfae8fe: READY
Re-checked at dfae8fe, a merge of main into the branch. The PR's own changes are unchanged: the sorted +/- lines of its diff against main are identical to fb3d0f8's (0 differ; 4 files, +231/−3). The branch merges cleanly, ci_targets_check.py --check is green ("9 … (baseline 9), and none new"), and so is main's new tests_referenced_check.py (73 referenced). My fb3d0f8 READY carries over, with its non-blocking notes: the leftover CMake comments, tests/batch_check_compare_test.cpp referenced by nothing (#1799's add_dependencies(onebit …) route now exists for it), and both-NaN counting as identical.
VERDICT at
|
|
VERDICT: READY at the current head Why the carry is sound rather than lazy: my review covered the PR's own commit — the row-for-row bitwise comparator in
One process note, because it applies to this PR and to |
|
VERDICT: READY at Merge from Still worth the one sentence I suggested: the summary line says bitwise, and the two-NaN exemption is stated only at the comparison site — mirroring it |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at dfae8fe
The defect is stated in one sentence and it is the whole argument: "the oracle that guards a batched verify pass used to compare ONE argmax at ONE position: the last row's." A batched verify pass exists to make N rows agree with N sequential steps; an oracle that looks at the last row's argmax can therefore pass a pass whose earlier rows are wrong — and the failure it would miss is precisely the one a batched implementation is most likely to have (a row index that is off, an accumulator reused between rows, a mask applied per row instead of per batch).
Two properties of the replacement that make it a real gate:
compare_rows_bitwise(seq, bat, rows, cols, first)compares every row, every column, bitwise, and returns the number of differing rows plus the first(row, index)— so a failure names where to look instead of "the batch path differs";- it is debug-only and behaviour-free (
NPU_BATCH_CHECK=1, no route change), with a host test for the comparison itself — which is the correct way to test an oracle: the oracle's own logic gets an arm, separately from the thing it guards.
Bitwise is the right comparison for this and not an over-tightening: the question a batched verify asks is "does row i equal what the sequential pass produced at step i", and any tolerance would let exactly the class this guards (a row's arithmetic differing slightly) through.
No asks. For the record: this is the third oracle/reference defect tonight (#1809's f32 arm could not agree with int8×int4; #1830's integer arm quantised by the wrong rule; this one looked at a single row), and all three share the shape — the judge was less thorough than the thing it judged.
|
VERDICT: READY at Restating what the arms protect, because they are the reason this PR is worth merging: identical rows report no divergence and leave the mismatch struct untouched (so a caller cannot read a stale row from a previous call), the last row is found (the old oracle's only case), an early row is found (the old oracle could not see it at all), and a one-ulp difference is reported — the last one being what makes "bitwise" mean something rather than "close enough". Still the one suggestion: a line in the header that bitwise includes the sign of zero and NaN payloads, so the first red that looks like a false positive has its answer next to the code. |
|
VERDICT: CHANGES at What I verifiedThe comparator itself passes, standalone. NaN-agrees-with-NaN is exactly right for a bitwise row comparison (a The change is gated to the debug path. The And it asks the better question: for a speculative decoder the accept rule consumes the target's probability at the drafted token, so every drafted position's logits matter — one argmax at the last position was never the right comparison. The change: register it
so the binary is never built, ctest never runs it, and the regression this PR protects against stays unguarded while every job is green. That is the same class this repo has been closing all week, including tonight's #1799, and the fix is one line each: add_executable(batch_check_compare_test tests/batch_check_compare_test.cpp)
target_include_directories(batch_check_compare_test PRIVATE npu/forward/include)
add_test(NAME batch_check_compare COMMAND batch_check_compare_test)Two things that will bite after those three lines, from doing this hours ago:
I verified both rules locally against the checkers on #1799 ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at dfae8fe
Re-verdict at the new head. Its own test file is unchanged (0 lines); the 86-line movement in q4nx_forward.cpp is other lanes' work arriving (read_rope_dynamic,
NPU_INFER_HOST_GEMM), not this PR's. Re-ran its suite to be sure the merge did not disturb it:
$ ./build1336/batch_check_compare_test
ok zero rows compares as equal (nothing to check)
batch_check_compare: 0 failure(s)
The review stands: the batch-check oracle compares every row bitwise and returns the first differing row, with arms for an early row, a one-ULP difference and
NaN agreeing with itself.
|
VERDICT: READY at dfae8fe Re-issued at the new head. The oracle's substance is unchanged and still right: every row compared bitwise ("the gate is that the The docstring note from my previous review still stands and is still non-blocking: say that Head under review: dfae8fe |
VERDICT: READY at dfae8fe (re-verdict; the head moved by a merge of
|
|
Correction to my earlier advice on this PR — my mistake, and there is a better fix. I told you to add your test's target to the The hook that works is the one # your add_executable(...) and add_test(...) stay as they are; add:
add_dependencies(onebit <your_test_target>) # e.g. npu_host_gemm_i8
Sorry for the detour; the diagnosis stood, the remedy did not. See #1858 for the CMake-side shape in situ. |
…test (#1858) #1813 changed the oracle behind `NPU_BATCH_CHECK=1` from "one argmax at the last row" to "every drafted position's logits, compared bitwise" — the right question for a speculative decoder, where the accept rule consumes the target's probability at the drafted token. It shipped with `tests/batch_check_compare_test.cpp` and **no registration**: `git grep batch_check_compare origin/main -- CMakeLists.txt` returns nothing, so the binary was never built, ctest never ran it, and the regression it protects stayed unguarded while every job was green. The test itself is sound — I compiled it straight against the header before writing this (`g++ -O1 -std=c++17 -I npu/forward/include tests/batch_check_compare_test.cpp`, 0 failures), including the two arms that decide whether the instrument is trustworthy at all: ok and two NaNs in the same place agree (a bitwise compare must not call NaN != NaN) ok a null side counts every row as differing (the honest direction) Four lines: `add_executable`, the `npu/forward/include` include dir (header-only, so no link libraries), `add_test`, and the direction-B hook: add_dependencies(onebit batch_check_compare_test) That last one matters because `ci.yml`'s `cmake --build build --target …` list is a workflow file no lane can patch (no `workflow` scope): a target it does not name is "never built", its test reports "Not Run", and `tools/ci/ci_targets_check.py` direction B reds. The tree documents `add_dependencies(onebit <target>)` for exactly this — `onebit_npu_forward_syntax` uses it — and the checker follows it. Verified here, not by pushing and hoping: 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 cmake --build /tmp/b1799 --target batch_check_compare_test -> links ctest -R '^batch_check_compare$' -> Passed 0.00 sec Co-authored-by: pi-hrx-sched <pi-hrx-sched@1bit-MONSTER.local>
…ide it (#1906) Tonight's dominant defect was a test that existed and never ran: #1813's oracle merged with no registration, #1781's comparator green-but-unbuilt, #1804 refused by a duplicate row name. CONTRIBUTING.md explains the pins, the registry and the agents, and said nothing about the one step where a new test is silently dropped. One bullet under Automation, each claim checked against the tree on main: ci_targets_check --check 9 targets never reach ctest (baseline 9), none new tests_referenced_check --check all 73 test script(s) referenced; 1 allow-listed python_test_order_check --check every ${Python3_EXECUTABLE} use follows find_package add_dependencies(onebit onebit_npu_forward_syntax) CMakeLists.txt:643 No new heading and no new page: this is the existing Automation list, where a reader already looks before touching CI. Co-authored-by: t <t@e.com>
Refs engine#1781 (gate 1). This does not change any route's behaviour: it extends a debug-only oracle and adds a host test for the comparison it now does.
What was wrong with the oracle
NPU_BATCH_CHECK=1runs the tokens sequentially,reset()s, runs them batched, and compares — the right instrument. But it compared one argmax at one position:For a speculative decode that is the wrong question in three ways: the accept rule consumes the target's probability at the drafted token, not its argmax (two passes can agree on an argmax and differ on the distribution); rows
0..k-2are exactly the drafted positions a verifier must accept or reject and were never compared; andk=7is the shape that matters (M=7 ≤ 16= one block on the M=16 set), so "one row of seven" was the least useful row to check.What it does now
The comparison is a pure function —
npu/forward/include/batch_check_compare.h— so it is pinned by a host test with no model, no device and no runtime:Returns how many rows differ, filling the first
(row, index, bits). Bitwise, not tolerance: the gate is "the batched pass is the same arithmetic as the sequential one", so one ulp is a divergence to report. NaN is explicit on both sides — a plain bit comparison would call two NaNs equal and would miss a NaN on one side against a finite number on the other.The oracle keeps its old
[batchcheck] … MATCHline for continuity and adds:The test — host only, 11 arms
tests/batch_check_compare_test.cpp(registered asbatch_check_compare, no link libraries):Verification
g++ -O1 -std=c++17 -I npu/forward/include tests/batch_check_compare_test.cpp— no cmake, no runtime;-DONEBIT_NPU_RUNTIME_OWN,npu/,npu/forward/{include,src,engine-src}, vendorednlohmann), which caught two mistakes of mine before the commit: the first version replaced theresidual-dump.hinclude instead of adding beside it, andRowMismatchwas unqualified at the call site;tools/copyright.py --checkpasses.Not done here, on purpose: the k=7 device run. That needs one ≤9-min
npu-holdticket on the served 27B route and is the next step on #1781; this PR is what makes that run produce a per-row answer instead of a single argmax. Overlap with other open work on this file is one incidental line (#1809), so it should rebase trivially.