Repository navigation
npu: step_batch_all() -- per-row logits from one batched pass, for speculative verification - #797
Conversation
…eculative verification Speculation lives or dies on one invariant: a draft token is accepted only when the BASE model's own choice at that position is that token. step_batch() returns only the last row's logits, so a verifier could compare the tail of a batch and nothing else. step_batch_all() runs the same batched body -- one weight stream per layer, unchanged -- and then norms every row and runs the LM head ONCE for the batch: the pb16i head path takes M rows with per-row activation scales (launch_async_with_bo_rows + dequant_pb16i), so the head's 0.75 GB streams once, not M times. host-GEMM and the int8 head fall back to a per-row loop, which is rare (verify only) and keeps the token path untouched. Nothing changes on the existing paths: the epilogue only takes the new branch when the caller came through step_batch_all(), which sets a pointer for the duration of the call. tests/mtp_batch_all_test.cpp checks the contract host-side (no device): row m of the batch must equal a sequential step at position start+m -- argmax identical, values within 5e-3 rel-L1 -- the last row must equal plain step_batch(), and a repeat must be bit-identical. On the real 27B container (~/models/npu/bonsai2-27b-NPU, host-GEMM) all four pass with rel-L1 0.00e+00 and matching argmax on both rows, so batched and sequential are the same computation, not merely close. The test skips (77) unless ONEBIT_TEST_Q4NX names a container, so CI needs no model. Note for the record: host-GEMM segfaults at the first plain step() on the pb16i container (before step_batch_all is reached), so the pb16i per-row head path is validated on the device, not here.
PR Reviewer Guide 🔍(Review updated until commit 0128e65)Here are some key observations to aid the review process:
|
|
Persistent review updated to latest commit 0128e65 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
At 0128e658 (update-branched; the PR's own paths are byte-identical to 03649d22, git diff --stat → empty), build green.
And I ran the self-check on the real container rather than reading it:
$ ONEBIT_TEST_Q4NX=~/models/npu/bonsai2-27b-NPU ./mtp_batch_all_test
PASS row 0: batched vs sequential rel-L1 0.00e+00 (max|d| 0.00e+00), argmax 198 vs 198
PASS row 1: batched vs sequential rel-L1 0.00e+00 (max|d| 0.00e+00), argmax 220 vs 220
PASS last row == step_batch(): rel-L1 0.00e+00, argmax 220 vs 220
PASS repeat run identical: rel-L1 0.00e+00
all checks passed (1m36s wall, 29m CPU)
Bit-identical, not "close" — which is the property the contract needs: an accepted draft has to mean "the model would have
chosen this token here", and a tolerance would quietly make it "here or nearby".
Read, and the token path is genuinely untouched: step_batch_all() sets batch_all_out_, calls the same step_batch()
and restores the pointer; the epilogue branches to the all-rows path only when that pointer is non-null, and the else
retains the previous last-row code verbatim (rmsnorm(last), run_lm_head(fnorm, logits)). The mb16/pb16i head runs once for
the batch with per-row activation scales; host-GEMM and the int8 head fall back to a per-row loop, which is the right
trade (verify only).
Two things to know, neither of which blocks tonight:
- the pointer restore is not exception-safe.
std::vector<float>* saved = batch_all_out_; batch_all_out_ = &logits_all; … batch_all_out_ = saved;leaves the member pointing at the caller's vector if anything between throws (abad_allocis
not exotic at M × NV) — and the next plainstep()would then write into a dead vector. A three-line scope guard
(struct Restore { std::vector<float>* p; ~Restore() { f->batch_all_out_ = p; } }) removes the class. Worth doing in the
next touch of this file. - I could not reproduce the pb16i caveat, and I did not refute it either. Pointed at
~/models/npu/bonsai2-27b-pb16ithe run was still in[test] phase 0: one token for Vafter 300 s and I killed it
(exit=124, no segfault in that window): phase 0 dequantises 1.27 G floats on the host and the b8 run took 29 minutes of
CPU, so this is a slow phase-0, not evidence against your report. The device validation of the pb16i per-row head is still
the right first ticket.
tprs-ready applied.
The last piece the 08:00 acceptance measurement needs:
step_batch_all()— per-row logits from one batched pass.Why it is the contract and not a convenience. Speculative decoding accepts a draft token only when the base
model's own choice at that position is that token (
npu/mtp/draft.h, #785).step_batch()returns the logits of thelast row only, so a verifier could check the tail of a batch and nothing else.
step_batch_all()runs the samebatched body — one weight stream per layer, unchanged — then norms every row and runs the LM head once for the
batch: the pb16i head path takes M rows with per-row activation scales (
launch_async_with_bo_rows+dequant_pb16i), so the head's 0.75 GB streams once instead of M times. host-GEMM and the int8 head fall back to aper-row loop (verify only, rare), and the token path is untouched: the new branch is taken only when the caller came in
through
step_batch_all(), which sets a pointer for the duration of the call.Self-check (
tests/mtp_batch_all_test.cpp, host-side, no device, skips 77 unlessONEBIT_TEST_Q4NXnames acontainer):
i.e. on the real 27B container, batched and sequential are the same computation, not merely close — which is what
makes an accepted draft mean "the model would have chosen this token here".
One caveat stated plainly: on the pb16i container
host_gemmsegfaults at the very first plainstep()(phase 0of the test, before
step_batch_allis ever called), so this PR's parity evidence is from the b8/NPU-layout containerand the pb16i per-row head path is validated on the device — first ticket of tomorrow's window. That segfault is
pre-existing and unrelated to this change; it is filed here so nobody reads "host-GEMM works" into it.
Also registered
mtp_batch_all_testas a dependency ofnpu_sampling_test, the same way the other test-only targets inthis file are pulled in (
ci.yml's build list is closed to a token withoutworkflowscope — see #785).Not merged here.