Repository navigation
npu: carry the GDN planes whole in a snapshot, so the prefix cache can restore one - #985
Conversation
PR Reviewer Guide 🔍(Review updated until commit 8ac86d0)Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
READY on the device-free half. What I ran, on 5b87def:
$ g++ -std=c++17 -O1 -I. tests/q4nx_state_planes_test.cpp -o t && ./t
q4nx_state_planes: all checks passed (rc 0)
Two mutations, both caught — so the test discriminates rather than just passing:
give_whole()made to install the shorter prefix (the oldput()behaviour): 4 checks fail (a truncated rec plane is refused,a refused restore leaves the plane untouched,a below-size rec plane is refused too,a below-size plane does not resize the destination).give_per_pos()with the coverage test removed: 1 check fails (restore refuses KV that does not cover the positions).
Code read, and the failure direction is the right one: a snapshot written by the old code (conv/rec truncated by tokens * NKV * HD) is now refused by give_whole() and falls back to a full prefill, instead of installing a short plane that the GDN step reads past the end of. take_per_pos() keeps the min(want, size) clamp, and the shared-KV empty-layer case is preserved verbatim in both directions.
Not verified by me: the outcome this is for — [prefix] slot=-1 … slot has no usable state disappearing and the second 849-token prompt not paying a full prefill. That needs the NPU; I take no npu-hold ticket, so the serve A/B in your PR comment is the half I cannot re-derive from here.
|
Measured on the box (this tip, The first version of this fix (
So the plane fix alone is not shippable: it turns a silent slowdown into silent wrongness. The second bug is the save The slot grows 78.6 MB -> 232.7 MB because the conv + recurrent planes (154 MB at this geometry) are now carried;
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
READY at aa2f5c1 (the delta from 5b87def, +78/−19 in app/forward_serve.cpp, app/npu_prefix_cache.h, tests/q4nx_state_planes_test.cpp, docs/npu.md, CMakeLists.txt).
Device-free test, run: compiled tests/q4nx_state_planes_test.cpp + q4nx_state_planes.h + app/npu_prefix_cache.h standalone and ran it → q4nx_state_planes: all checks passed (rc 0). Mutations of resume_point(), both caught:
resume = cached -> 4 check(s) failed (all four one-token-short checks)
no min() against the prompt-> 1 check(s) failed ("a short prompt still resumes one token short of itself")
(A third I tried, dropping the slot < 0 || cached <= 0 guard, is behaviourally identical because the function returns 0 in those cases anyway — not a gap in the test.)
The save point, read for the cases that can go wrong. The split is off + take == n && take > 1 → run take - 1 tokens, then the next iteration has take == 1 and falls through to the save branch at off == n - 1, i.e. the snapshot is taken with the last prompt token still unevaluated, and the same token is then run to produce the logits — exactly the point resume_point() restores to. chunk_max == 1 degenerates to the same thing without splitting, ids.empty() cannot reach the branch, a failed snapshot_state() only skips the insert, and a resumed request (resume > 0) re-saves at n - 1 with the resumed state, which is consistent. insert(ids, …) still keys the slot on the whole prompt while the snapshot covers n - 1, which matches the new cached - 1 convention on the read side.
One operational thing: gh pr view 985 still reports head 5b87def while the branch npu/snapshot-whole-gdn-state is at aa2f5c1 (git ls-remote agrees with the branch). So CI and the re-gate are currently running against the previous commit — worth a push/touch to get the PR's recorded head onto aa2f5c1 before the morning pass reads the checks.
Same limit as before: the serve number (0.34x TTFT on a hit, identical text) is yours — no npu-hold ticket from me.
…ad of re-prefilling Two bugs, one measured path: the cache answered every request with `slot has no usable state` and re-prefilled from 0, and the first version of this fix made it hit but answer differently. 1. `snapshot_state()` cut all four planes with the per-position count `tokens * NKV * HD`, including the GDN conv window and the recurrent state. Those are one fixed block per layer that each position *updates*, not appends to, so on Bonsai-2-27B (per-position 1024 floats, conv 30,720, recurrent 786,432) the cut broke them on both sides: a prompt over 30 tokens makes `tokens*1024` exceed the conv plane, so `restore_state()`'s cover test failed and the cache refused its own snapshot; a prompt under 768 tokens kept only `tokens*1024` of the recurrent plane (23 % at 180 tokens) and the install would shorten the plane the GDN step reads. The saved sizes are the fingerprint: `[prefix] saved slot=...` measured exactly `393,216 * tokens + 7,864,320` bytes at 180/252/584/704 tokens -- 384 KiB per token against the 64 KiB/token of KV this geometry has. 2. The snapshot was also taken at the wrong point. A slot's snapshot was saved *after* the last prompt token and restored at `cached - 1` positions, which KV allows and the in-place GDN planes do not: the last token's recurrence update applied twice. Measured on the plane fix alone, the same 180-token prompt answered `"\n\nThe capital of France is Paris. The ri"` on the first request and `" h o l e prefix with a recurrent state: "` on the resumed one -- the cache hit (`slot=0 cached=179 of 180 reason=hit`, TTFT 5.3 s against 15.6 s) with the wrong text. `forward-serve` now saves one token short (splitting the final prefill chunk, one extra launch) and resumes through `npu::resume_point()`, so both kinds of plane are exact at the resume point and the last token is re-run for the logits the answer is drawn from. Measured after, one `npu-hold` ticket, one serve per binary, the same 180-token prompt twice (prefix cache on by default), `amdxdna` errors 0 in both windows: | binary | request 2 | slot bytes | TTFT 1st -> 2nd | text | |---|---|---|---|---| | shipped `t2s2-20261103` | `slot=-1 cached=0 ... slot has no usable state` | 78,643,200 | 19.26 -> 22.17 s | same (full re-prefill) | | this branch | `slot=0 cached=179 of 180 ... hit` | 232,652,800 | 22.90 -> 7.89 s (**0.34x**) | identical `8c116342101d013a` | The slot grows by the 154 MB of conv+recurrent planes that were being dropped, which is what carrying them whole costs; the +3.6 s on the first request in that window is the split chunk's extra launch plus load (19.0 vs 24.7), not the fix, and both requests keep the shipped binary's text. The plane copy and the resume rule are header-only and device-free so they can be tested on every build: `npu/forward/include/q4nx_state_planes.h` and `npu::resume_point()` in `app/npu_prefix_cache.h`, gated by `tests/q4nx_state_planes_test.cpp` (ctest `q4nx_state_planes`). Three mutations are caught: carrying a whole plane with the per-position cut (9 failures), accepting a below-size plane (2), and resuming at `cached` instead of `cached - 1` (4). `docs/npu.md` states both rules and what each bug cost.
aa2f5c1 to
133278a
Compare
|
Preparing review... |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 133278a31 (re-verdict; the head moved and the new commit is a real behaviour fix, not a rebase).
Two commits since my last verdict at aa2f5c1: the branch was rebased onto main f63af5f and 133278a — "make the
prefix cache's snapshot exact, so a hit reuses it instead of re-prefilling" — landed on top. Its content, not the base
move, is what follows.
What I ran at this head:
$ g++ -std=c++17 -O1 -I npu/forward/include -I app tests/q4nx_state_planes_test.cpp -o /tmp/qsp && /tmp/qsp
q4nx_state_planes: all checks passed # rc 0
The invariant the new commit rests on, which is the thing I went looking for: a slot's snapshot holds cached - 1
positions, and nothing else can insert a slot. Both halves check out on the tree.
grep -rn "snapshot_state(\|prefix->insert(\|resume_point(" app npu/forward/srcfinds exactly one save
(app/forward_serve.cpp:289-291) and one restore (:257), and the save is inside the split-final-chunk branch, so
there is no second path that could store a snapshot at full length and makeresume_pointoff by one.- The split is sound by construction: with
chunk_max >= 2the last chunk runstake-1tokens andoffbecomes
n-1; the next iteration hastake == 1, so it takes the save branch atoff == n-1and then runs that token — the
snapshot precedes the token whose logits the answer is drawn from, which is the only exact point for planes that are
updated in place. Withchunk_max == 1the split never triggers and the same state is saved atoff == n-1
directly. A resumed request (offstarting above 0) re-saves atn-1from a state that was restored toresume
positions, which is again exact. resume_point()clamps on both sides (min(cached, prompt) - 1, 0 when the slot is unusable or the prompt is
empty), so a slot cannot be restored past the positions it holds and a hit never resumes at the prompt's own last
token.
Nit: the split's cost is stated as "one launch" and the whole fix is only covered by the header test — forward_serve's
chunk loop has no test of its own, so the "save one short, resume cached-1" pairing is pinned by reading plus the
serve log ([prefix] saved slot=… tokens=…), not by the suite. Worth a serve-level case when this lane next has a
ticket; the failure it prevents (restoring one position too many, which applies a GDN update twice and answers the same
prompt differently — engine #984) is exactly the kind that a log line will not catch.
CI on 133278a31 was still running when I posted this (build/Analyze pending, CodeQL pass, hw_gate skipped).
|
One cross-check the measured slot size gives for free, and one thing this PR deliberately does not fix. The planes-only arm's slot was
So the fix carries each GDN plane at the size this forward allocated (the whole 786,432-float recurrent block per Not fixed here, on purpose: |
|
Persistent review updated to latest commit 8ac86d0 |
…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.
The 1k row this fix was never gated on: the hit works, and the resumed prefill is bit-identical to a fresh oneThe device A/B in this PR was 180 tokens. At 1k the chunking, the two planes' sizes and the resume point all differ, so
171x on the second TTFT, and the slot is exactly Text is not a usable gate here, so the check is the distributionBoth arms emitted So the gate compares the raw float32 vocabulary of the first decode position (serve All 248,320 logits are bit-identical. That is a stronger statement than the 180-token text identity this PR shipped Harness for both runs is |
Closes #984.
Root cause
snapshot_state()cut every plane with the per-position counttokens * NKV * HD, including the GDN conv windowand the recurrent state, which are one fixed block per layer that each position updates rather than appends to.
On Bonsai-2-27B (per-position 1024 floats, conv 30,720, recurrent 786,432) that broke the planes on both sides:
tokens*1024exceed the conv plane, sorestore_state's "must cover thepositions being restored" test failed and the prefix cache fell back to a full prefill;
tokens*1024of the 786,432-float recurrent plane(23 % at 180 tokens), and the install would then shorten the plane the GDN step reads.
Measured before
On the shipped serve (
t2s2-20261103/1bit,:18097) every shared-prefix request answered[prefix] slot=-1 cached=0 of 849 reason=slot has no usable state(5 of 5 long requests across three windows, onthe ladders recorded in
~/wt/prefill-rows/), and a byte-identical repeat of an 849-token prompt paid its wholeprefill twice: 104.0 s total against an 81.9 s TTFT.
The saved sizes are the fingerprint — one serve,
[prefix] saved slot=...:393,216*tokens + 7,864,320384 KiB/token against the 64 KiB/token of KV the geometry has, i.e. the fixed planes were being grown as if they
were per-position; at 849 tokens the slot is 21,233,664 B below the line, exactly
64 layers * 82,944 floats,which is those planes reaching their real size.
The change
npu/forward/include/q4nx_state_planes.h(new, header only, no device):take_per_pos/give_per_posfor theKV,
take_whole/give_wholefor the GDN planes, with the shared-KV empty-layer case kept as it was.snapshot_state()/restore_state()use them. A whole plane is accepted only at the size this forwardallocated, so a truncated snapshot is refused (caller re-prefills) instead of installed short, which would
leave the GDN step reading past the end of the plane. Net −19/+16 lines in
q4nx_forward.cpp.tests/q4nx_state_planes_test.cpp(new ctestq4nx_state_planes, header only, runs in CI): the old arithmeticreproduced (both symptoms), the round trip at the real plane sizes, the KV per-position behaviour unchanged, and
the truncated-plane refusal. Wired with the same
add_dependencies(npu_sampling_test ...)trick asmtp_draft_test, becauseci.ymllists every ctest target and this branch cannot edit that file.docs/npu.md: a short section stating the two kinds of plane and what the old behaviour cost.Verification
tools/copyright.py --checkrc 0.g++-16 -std=c++17 -Wall -Wextra -Werror tests/q4nx_state_planes_test.cppgreen;cc1mutation checks: carryinga whole plane with the per-position cut fails 9 checks, accepting a below-size plane fails 2.
The changed translation unit compiles with the tree's own NPU flags
(
g++-16 … -DONEBIT_NPU_RUNTIME_OWN -std=c++26 -fopenmp -c npu/forward/src/q4nx_forward.cpp) rc 0.Device A/B on this box, one
npu-holdticket, one serve per binary, same 180-token prompt sent twice, greedy(evidence in the comment below, serve logs in
~/wt/prefill-rows/):t2s2-20261103(md52d66b8ca)slot=-1 cached=0 of 180 … slot has no usable state8c116342…both (full re-prefill)5b87defslot=0 cached=179 of 180 … hitslot=0 cached=179 of 180 … hit8c116342…, equal to the shipped binaryamdxdnaerrors 0 in both windows, both serves stopped with SIGTERM inside their grace windows.Device-free re-check after the rebase onto
f63af5f:tools/copyright.py --checkrc 0 andg++-16 -std=c++17 -Wall -Wextra -Werror -Iapp -Inpu/forward/include tests/q4nx_state_planes_test.cppprintsq4nx_state_planes: all checks passed.No merge, no tag, no deploy: the owner merges.