Repository navigation
npu: hybrid models stop re-dequantising attention q/k/v/o every decode token — load_layer 12–13 ms → 0.04 ms per token on Qwen3.5-2B, logits byte-identical (Refs #1785) - #2201
Conversation
…/k/v/o on every decode token (Refs #1785) load_layer() filled wq_/wk_/wv_/wo_ each token although their only reader, q4nx_assemble_B, is skipped once the layer's packed QKV and O images are resident. Skipped when both are; NPU_ATTN_RELOAD=1 keeps the old behaviour. Qwen3.5-2B: load_layer 12-13 ms -> 0.04 ms per token, logits byte-identical. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at 46bd1c2 |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
|
CI note for reviewers: |
|
VERDICT: READY The change is small and the argument is the right kind — "these buffers have one reader, and that reader is already skipped" — so I checked the reader claim instead of the diff, because there are two assembly sites per path and a missed one would read a buffer nobody loaded. Reader sites, by enclosing function (
The guard the PR needs is exactly the one it wrote, and it closes in both directions: Two things the change gets right that are easy to get wrong: it excludes The measurement is the honest kind: No objection. The one thing worth a line in the docs if you touch it again: the docstring says the four buffers have one reader, |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 46bd1c2: READY
It merges cleanly onto main.
Code: the skip is safe because of three facts, each checked at this head.
- The four buffers have exactly four readers.
wq_/wk_/wv_/wo_are read only atq4nx_forward.cpp:3895and:4095(step_batch, both behind!cached, which is QKV key 0) and at:5154and:5233(step, behind!weight_cached(0)and!weight_cached(1)). All four also sit behind!xd_->pb16i(). Requiring both keys covers step_batch's O guard, which uses key 0. - A resident image cannot disappear.
XdnaTarget::weight_cached()iswcache_[layer].find(key) != end(), and nothing innpu/erases fromwcache_. Soload_layer()'s check cannot go stale before the layer's own O assemble. - Host-GEMM runs are excluded (
!host_gemm_), so the device-free paths that do read the f32 copies still load them.
Measurements, recomputed from the run directory (~/.cache/scratch-cl-npurt/hh):
- Dumps, by
cmp: ra1 vs rb1 48 of 48, ra1 vs rb2 48 of 48, ra1 vs ra2 48 of 48, and rb1 vs dev1 48 of 48. load_layerfrom the[lh]lines, median over the 92 nonzero decode samples per arm: ra1 12.96, rb1 0.04, rb2 0.03, ra2 11.63 ms. This matches the table. The body also claims only the host share, not a tok/s figure, which is the right reading of B2 sitting within 4 ms of the A arms.
Build and tests at the head (ONEBIT_NPU=ON ONEBIT_NPU_HOST=ON): onebit_npu_forward builds, and the object carries NPU_ATTN_RELOAD. ctest -R "npu_blocked_layout|npu_rmsnorm" passes 2 of 2. One unrelated target, npu_rt_shim_test, does not link in my review build (an undefined npu_rt_api::ext::bo constructor against the runtime directory I configured with). This PR does not touch it.
CI: build at 46bd1c2 failed in Tools for checkout and the build at 14:51Z. That is the runner DNS outage, not this tree.
Non-blocking: there is no unit arm, as the body says, because the condition only exists with the device. NPU_ATTN_RELOAD is a measuring switch. Once the default has had a few windows, it could go, or move to the documented env list in docs/npu.md, which this PR already edits.
Refs #1785 (decode speed; host side of a decode token).
The waste
On a hybrid (GDN + full-attention) model,
step()callsload_layer()for every layer on every decode token, because its skip (dense_weights_cached) only applies to dense models. A GDN layer skips its own big dequants when its packed images are resident (proj_cached), and the MLP tail does too (ffn_tail_cached). The full-attention layers did not:q,k,vandowere dequantised intowq_/wk_/wv_/wo_on every token.Those four buffers have one reader,
q4nx_assemble_B, and both paths already skip it once the layer's packed image is on the device (step():!weight_cached(0)/!weight_cached(1);step_batch():!cached). So the reload wrote buffers nothing read.The change
load_layer()skips the four dequants when the layer's packed QKV (key 0) and O (key 1) images are both resident and the run is notNPU_INFER_HOST_GEMM. Both keys, becausestep_batch()guards its O assemble with the QKV key: a half-cached layer still loads everything.NPU_ATTN_RELOAD=1keeps the old per-token reload (the A arm below).Measured (NPU, one
npu-holdticket, serves ended with it)FastFlowLM's
Qwen3.5-2B-NPU2(24 layers, 6 full-attention), this branch at46bd1c2,NPU_PHASE_PROF=1,--dump-logits, 2 requests x 24 greedy tokens per arm, order A B B A (A =NPU_ATTN_RELOAD=1), box load 20 to 27. Medians over 46 decode tokens per arm:load_layerms/tokenWhat this shows: the host share of the layer loop halves (about 12 ms per token) and no logit changes. What it does not show cleanly: the token wall. 347/345 against 332/341 ms points the right way, but B2 is within 4 ms of the A arms, so on this box I claim the host share, not a tok/s figure. The device wait (about 241 ms, 85 % of the token) is untouched.
Tests
No unit arm: the condition is "the device holds both packed images", which only exists with the device. The gate is the byte comparison above (old path against new, same binary, same ticket).
ctest -R "npu_blocked_layout|npu_rmsnorm"still passes in the NPU build.Models this reaches: any hybrid container with full-attention layers on the NPU route (Qwen3.5 family, MiniCPM-V 4.7's text side). Dense models never took this path on a cached layer. Only Qwen3.5-2B was measured.
Docs: docs/npu.md, next to the dense-tail cache paragraph.
🤖 Generated with Claude Code