Repository navigation
npu: the C syncs cover the rows the host reads, not the whole BO (16x less flush traffic per decode launch, outputs bit-identical) - #988
Conversation
PR Reviewer Guide 🔍(Review updated until commit 5eaea0b)Here are some key observations to aid the review process:
|
|
Second gate arm — the int64 two-plane path (
|
|
Pushed
Re-gate after it ( |
|
READY. Read with the one question that decides this: is
The rest checks out too: The residual risk is the one your own comment names: a batched launch with I did not re-run the device rows (16x flush, |
…get 2, audit row 4)
bo::sync(direction, size, offset) is a per-64-B clflush loop over exactly that range (the runtime's own
npu_rt::bo::sync, bounds-checked against the BO: verified in libonebit_npu_rt.a), and the device writes C
through the IOMMU into memory -- so only lines the host touched can be stale, and the host touches rows
[0, am). readback() invalidated the whole MD-row BO anyway, and the canary's writeback (poison_canary)
had the same whole-BO range: 31 032 C syncs per run, 16x the lines needed on every decode launch.
last_am_ is set by the three quantisers (the rows they just wrote) and both C syncs now cover
c_host_bytes() = [0, last_am_) rows x ND x 4 B (x2 in the int64 plane mode), clamped to the BO.
NPU_NO_C_NARROW=1 restores the whole-BO range for an A/B on one binary; NPU_DBG forces it because
bochk/RBCHK checksum the full extent. NPU_C_SYNC_PROF=1 prints the two syncs' calls/bytes/ms per token.
Measured on the shipped route (1bit forward-serve, bonsai2-27b-t2s2-serve, M=16 decode), 4 arms
alternating narrow/full x 2 rounds, one binary, 3 prompts x 24 tokens:
identity PASS -- emitted tokens, every top_logprobs float and the final hidden-state sha256 identical
in all four arms (a too-narrow invalidate shows as garbage, as NPU_NO_C_INVAL=1 did)
C syncs 31 032 calls in every arm; flush traffic per token 1 065.1 MB -> 66.6 MB (16x, the M=16 C BO)
and 38 343 MB -> 6 764 MB per run (5.7x, incl. the 128-row prefill BOs where am == MD)
C sync time 11.0-11.3 ms/token -> 2.0-4.0 ms/token (median, same instrument), 406.6 -> 168.6/189.8 ms
per 30-token run: ~7-9 ms/token of clflush removed
Not quoted: the token-wall delta. The arms' spread on this box (150 ms median npu_gemm between the two
narrow runs) is larger than the effect, and both windows whose ordering was confounded moved the wall the
other way; the sync's own paired number above is what the change is.
…NNX EP client) `readback()`'s invalidate is now sized from the rows the *quantiser* wrote, but onnx/ort/onebit_npu_gemm.cpp (the plugin EP's MatMul leg) memcpys the graph's int8 rows straight into `ctx.Am` and never touches the quantisers -- so it would have inherited a one-row range while reading back `min(MD, m - row0)` rows of a batched block. That is a silent wrong answer for every block after the first, and CI cannot see it: that file is built only with ONEBIT_EP_NPU=ON, which the `onebit` CI job does not configure. `I8Ctx::set_activation_rows(rows)` is the one line that client needs; it is called with the block's own row count right after the rows are written. Compile-checked with -DONEBIT_NPU=ON -DONEBIT_ORT=ON -DONEBIT_EP_NPU=ON (object built, 0 errors). Re-gated after this commit on the shipped route (bonsai2-27b-t2s2-serve, narrow vs full, 1 round, ticket 07:08-07:10, Tctl 65 C, 0 amdxdna errors): identity PASS (tokens, all top_logprobs floats, head sha256), C syncs 31 032 in both arms, flush traffic 1 065.1 -> 66.6 MB/token, sync time 10.52 -> 1.79 ms/token.
e8e0283 to
5eaea0b
Compare
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 5eaea0bda (re-verdict: the branch was rebased onto main f63af5f and gained one commit).
Reviewed the new commit on its own: 5eaea0b "a client that writes A itself must say how many rows it wrote (ONNX EP
client)", +7 lines over npu/forward/engine-src/npu_engine_i8ctx_inc.h and npu/ort/onebit_npu_gemm.cpp.
The defect it closes is real and is invisible to CI, which is the part worth recording:
- My earlier review verified
last_am_is set by all three quantisers and that the only direct writer ofAm
(onebit_npu_gemm.cpp:330-334) callsset_activation_rows(rows)right after its memcpy. That call is this commit —
before it, that client wroterowsrows of A and leftlast_am_at its one-row default, so the narrowed invalidate
covered one row while the readback readsmin(MD, m - row0)of them. A batched block after the first would have read
back rows it never invalidated, on a path (ONEBIT_EP_NPU=ON) that theonebitCI job does not configure. - The fix is the smallest one available:
void set_activation_rows(int rows) { last_am_ = rows > 0 ? rows : 1; }on the
context, called with the block's row count, and the clamp keeps the ">= 1 row" invariant the C-sync sizing depends on
(a zero would size both syncs to nothing, which is how a canary stops watching). No call site other than the one
writer needs it, and the three quantisers already do this themselves.
Not reproduced by me: the commit's re-gate numbers (C syncs 31,032 both arms, flush 1,065.1 -> 66.6 MB/token, sync
10.52 -> 1.79 ms/token, identity PASS) are an NPU-ticket run by the zero-copy lane, and the compile side
(-DONEBIT_NPU=ON -DONEBIT_ORT=ON -DONEBIT_EP_NPU=ON) is a configuration this box builds only with the private runtime.
I took no NPU ticket for this review.
The rest of the head is the same content I verdicted at e8e0283d (the C syncs sized from last_am_ and c_host_bytes()
clamped to the C BO), and the rebase did not touch it: git diff origin/main...5eaea0bda is these three files, +69/-2.
CI on 5eaea0bda: build and Analyze x5 were still running when I posted this; pr_agent behaves as on every engine
PR while coder-llm is off, hw_gate skips.
|
Persistent review updated to latest commit 5eaea0b |
|
Rebased onto #873 landed A narrow-vs-whole A/B inside one binary cannot see a mistake in that interaction, so the re-gate added a third arm:
Identity: PASS — 3 prompts x 24 decode tokens, all three arms identical at every level the harness records: Cost, measured by
16x per decode launch (C BO is 16 rows on the M=16 decode context, the host reads 1), 5.7x per run (prefill launches Not claimed: the wall-clock delta. The three arms' median First attempt at this re-gate failed my own gate for an instrument reason worth stating so the table above is not |
|
Note for the merge order: #1004 ( |
…988's C-sync rows land outside the split main gained four commits; two of them edit the file this PR split: #988 3ea5d09 the C invalidate covers rows [0, last_am_) instead of the whole C BO, plus I8Ctx::c_sync_print(); 4 lines in q4nx_forward.cpp #985 f9b19bb the prefix-cache snapshot carries the GDN conv/recurrent planes whole and resumes at cached-1; snapshot_state()/restore_state() in q4nx_forward.cpp, new npu/forward/include/q4nx_state_planes.h Both applied without a conflict, which is exactly the case this branch's history says to distrust, so the merged result was read rather than trusted (that is how the family_tag, xor-rotation, head_pb16i_ and the 16 a_k/wkey/sc call sites were found — all in conflict-free or trivially-conflicting rebases): * #985 is model state, not launch state: q4nx_state::take_per_pos/take_whole in snapshot_state() and restore_state(), the include at the top, and the resume rule in app/npu_prefix_cache.h. Nothing it touches was moved into XdnaTarget, and the split leaves those two functions in the model. * #988 is inside I8Ctx (last_am_, c_host_bytes(), NPU_NO_C_NARROW) with one caller-side line, the NPU_C_SYNC_PROF print. The target neither writes kctx.Am (its only Am uses are the debug dumps at xdna_target.cpp:541,569,806) nor reads C outside [0, last_am_): row 0 for the single-row path, [0,M) after readback() for the rows path, which is the range the narrowed invalidate covers. So the split cannot inherit a stale row from that change, and it does not need to set activation rows itself. Green on this boot: ninja (all targets) rc 0, ctest 79/79 (1 device skip: mtp_batch_all), copyright rc 0, and CI's own object target onebit_npu_forward_syntax rebuilt from these sources (the #912 lesson). Gate rows re-run against A = origin/main@9020f8c6, because a landing in npu/forward voids them.
Part of #698 (target 2, "NPU runtime: activations live in BOs"), audit row 4.
Root cause
bo::sync(direction, size, offset)is a per-64-Bclflushloop over exactly the range it is given — read offthe runtime's own
npu_rt::bo::syncinlibonebit_npu_rt.a(bounds-check againstm->bytes, thenclflushfrom
base+offsettobase+offset+size-1, XRT's(size, offset)order) — so the cost is the range.The device writes C through the IOMMU into memory, so the only lines that can be stale are the lines the host
touched, and the host touches rows
[0, am).readback()invalidated the wholeMD-row BO on every launchanyway, and
poison_canary()'s writeback (the sentinels it stores in row 0) had the same whole-BO range.Measured on the shipped route: 31 032 C syncs per run, and the flush traffic was 16x the lines the host reads.
Change (2 files)
npu/forward/engine-src/npu_engine_i8ctx_inc.hlast_am_is set by the three quantisers (the rows they just wrote); both C syncs now coverc_host_bytes()=[0, last_am_)rows xNDx 4 B (x2 in the int64 plane mode), clamped to the BO.NPU_NO_C_NARROW=1restores the whole-BO range for an A/B on the same binary;NPU_DBGforces it becausebochk/RBCHKchecksum the full extentnpu/forward/src/q4nx_forward.cppNPU_C_SYNC_PROF=1prints the two syncs' cumulative calls/bytes/ms per token (the instrument the numbers below come from)Measured before/after
1bit forward-serve, shipped packbonsai2-27b-t2s2-serve(M=16 decode, M=128 prefill), 3 prompts x 24 tokens,4 arms alternating
narrow/fullx 2 rounds, one binary, onenpu-holdticket:full(NPU_NO_C_NARROW=1, today)narrow(default)MD/am = 16); 5.7x perrun including prefill, where
am == MDand nothing is saved.clflushremoved, paired on the same instrument, with the call count identical — i.e. thesame work, a smaller range. This is the row the 11-02
NPU_NO_C_INVAL=1windows measured as required(5.4-9 ms/token): it still happens, it just stops covering 16x the lines.
top_logprobsfloat and the final hidden-state sha256 are identicalin all four arms. A narrowed invalidate that is insufficient shows up as garbage (that is what
NPU_NO_C_INVAL=1did), so this gate is the one that decides the change.Not quoted: the token-wall delta. Between the two
narrowruns the mediannpu_gemmmoved 150 ms on this box,which is larger than the effect; two windows whose arm order was confounded moved the wall the other way. The
sync's own paired numbers above are what the change is.
Skipped, and why
amrows are only safe to flush aloneonce the zeroed pad rows are known to be in memory — npu: zero the activation BO's pad rows only when a previous launch wrote them (target 2) #873 makes that memset conditional, so narrowing the A
flush needs its row tracker and a second gate. Left as an audit-row follow-up rather than folded in here.
pb16i/gemvroutes were not re-gated on the device; the pb16i path belongs to the Bonsai lane and iswrong on full-attention layers for its own reasons. The code path is shared, and the int64-plane mode is
covered by
c_host_bytes()'s 2-plane row, but the numbers quoted are the shipped one.quantize_async_rowsaround the memset.last_am_sits just before thememset; if npu: zero the activation BO's pad rows only when a previous launch wrote them (target 2) #873 lands first, I will rebase and wait for CI on it.