Repository navigation
npu: the decode lm head on the host, bit-identical logits — head time 51–78 ms → 8–18 ms per token on Qwen3.5-2B (opt-in, Refs #1785) - #2191
Conversation
… (NPU_HEAD_HOST=1) (Refs #1785) A decode step is one row and a launch costs the same weight pass for one row as for a batch, so the int8 head's launches were 51-78 ms of each token on a 248,320 x 2,048 head. q4nx_host_head::tile computes the same int32 sums on the host from the image the device reads; the activation quantiser and epilogue are the device path's. Opt-in, off by default. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at 7ec0aaa |
PR Reviewer Guide 🔍(Review updated until commit 7996c29)Here are some key observations to aid the review process:
✅ Resolved findingsnpu/forward/include/q4nx_host_head.h:44-94Blocked Layout Handling The q4nx_host_head::tile function handles both blocked and row-major layouts, but the implementation assumes that the blocked layout is properly aligned and that the image dimensions are valid. There's no explicit validation that the blocked layout is actually used correctly in all cases, which could lead to memory access violations or incorrect results. npu/forward/src/q4nx_forward.cpp:5801-5871Host Head Implementation The implementation of NPU_HEAD_HOST=1 introduces a new code path for computing the lm head on the host instead of the device. While the performance benefits are demonstrated, the implementation may introduce thread safety concerns with shared state and potential memory access issues when mapping device buffers to host memory, especially in multi-threaded scenarios. |
|
VERDICT: READY at Why bit-identical is the only acceptable claim hereThe lm head is where a decode step's logits are produced, and a host implementation that is merely close to the device's would change sampled tokens — so "bit-identical logits" is the property that makes the swap a relocation rather than a second model. It is the same standard #1888 reached for the head end-to-end (device logits bit-exact over four steps) and the reason this lane keeps asking for Three things that make it usable
Two asks
Nothing else: single-row head, host, off by default, bit-identical — the exact shape a relocation should take. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 7ec0aaa: READY
Bit-identical is claimed by construction here, not by measurement, and that is the strongest form. With NPU_HEAD_HOST=1 the single-row head is computed on the host from the same image the device reads — the resident weight buffer's host mapping, no second copy — using the device path's own activation quantiser.
Every one of those clauses closes a way the two could differ:
- the same image — no re-pack, so no chance of a differently-tiled or differently-scaled copy (the failure #1888 spent the day on, where an arm quantised itself and verified a different model);
- the host mapping, no second copy — the host reads the bytes the device reads, so there is no import or transfer that could reorder or round;
- the device path's own quantiser — the activation scale is not re-derived, so an int4/int8 mismatch (#1839's whole subject) cannot creep in on one side only.
And the motivation is the right one to act on: a launch costs the same weight pass for one row as for a full batch, so single-row head launches are the most expensive use of the device in a token. Moving that work to the host is only safe because of the three clauses above — otherwise it would be the classic "optimisation that changes the answer".
The flag being opt-in means the device path remains the default until this is proven in the field.
…logs both, so the rate is measured inside one serve (Refs #1785) Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at 7996c29 (adds |
|
Persistent review updated to latest commit 7996c29 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 7996c29: READY
Opt-in host lm head (NPU_HEAD_HOST=1), off by default. It merges cleanly onto main, and CI build at 7996c29 is a success.
Code: the host path is the device path's own arithmetic, in the same order. The activation is hs with as = amax/127, quantised by q4nx_quant_i8::row. The weight codes come from the resident BO's host mapping, so no second copy of the head is held. The sums are int32, and the epilogue is acc * as * gs[n] then softcap, which is term for term the packed device loop's Cm * as * gs then softcap. It is gated to head_packed && !head_pb16i && !lctx.gemv, so the pb16i, gemv and batched heads are untouched. When a tile fails, the device loop rewrites every logit, so a partial host pass cannot leak.
Tests, from an ONEBIT_NPU=ON ONEBIT_NPU_HOST=ON build at the head:
ctest -R '^npu_blocked_layout$': passed.- A mutant different from the body's: I transposed the microtile read in
q4nx_host_head::tile(w[c]tomp[c*8 + r]). It fails 3 cases, for exampleFAIL host head tile K=2048 N=7168 KD=2048 ND=7168 blocked: ok=1, 7168 of 7168 sums differ. After restoring the file the tree is clean.
Measurements, recomputed from the run directory (~/.cache/scratch-cl-npurt/hh):
-
Dumps, by
cmpagainstdev1:- host-head serves (
host1,host2,h1-h3): 336 of 336 byte-identical; - device-head serves (
dev2,d1-d3): 240 of 240; alt1: 96 of 96.
- host-head serves (
-
The
[head-ab]lines ofalt1.serve.log:- head: host n=51 median 8.7 ms, device n=50 median 76.4 ms;
- the layers of the following token: 325.0 ms after a host head, 309.9 ms after a device head.
All of these match the body.
-
The
lm head: on the host ... 21 tile(s), 4 thread(s)line is present in the host serves.
Non-blocking:
- The fallback line (
NPU_HEAD_HOST is set but the image cannot be read on the host) is not guarded bysaid, so a box where mapping fails would print it on every decode token. NPU_HEAD_HOSTset to anything other than1oraltis silently off.- The +15 ms on the following token's layers is unexplained, as the body says. It is worth an issue before the default flips.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 7996c29 — and the arm that matters is built the way this fleet has learned it must be: the reference shares nothing with the kernel but the index contract, and the sentinel past the last column proves the write's bounds.
The reference is independent of the kernel, which is the whole question
for (int j = 0; j < N; j++) {
int64_t acc = 0;
for (int k = 0; k < K; k++)
acc += (int64_t)a[k] * (int64_t)img[q4nx_layout::image_index(k, j, KD, ND, blocked)];
want[j] = (int32_t)acc;
}
const bool ok = q4nx_host_head::tile(img.data(), KD, ND, blocked, a.data(), K, N, got.data());An element-at-a-time sum through image_index() — the index function this file already pins — compared against the blocked-tile kernel with got[j] != want[j] counted per column. That is the only kind of reference that can disagree for the right reason: it does not re-derive the kernel's loop, it derives the contract (image_index) and sums naively. This is the #1830/#1888 rule applied correctly rather than nominally.
Three details make it a real test rather than a smoke:
a[0] = 127; a[K-1] = -127;— the int8 edges, where aint32_taccumulator is the thing that saves the sum (127 * 127 * 2048overflowsint16, notint32— which is why the body says "int32 sums" and the test puts the edges in rather than trusting the middle).a[1] = 0whenK > 2— the kernel skips zero activations (if (a == 0) continue;), so a reference that does not is testing the same arithmetic by a different route; that arm is what keeps the skip honest.got(N)initialised to0x5a5a5a5aand asserted unchanged — a sentinel one past the last column, so a write that overruns is caught even when every real column agrees. That is the assertion that turns "the sums are right" into "the sums are right and nothing else was touched".- Geometries: whole blocked tile at production shape (
2048 × 7168), plus ragged/padded cases and the three refusals (K > KD,ncols > ND, non-whole tiles) — so the host path refuses what the device path refuses rather than computing something plausible.
Why a host head at all, in the PR's own terms
"A decode step sends one row through every head tile, and a launch costs the same weight pass for one row as for a batch." That is the #1959 shape (16 rows of launch cost to carry one row) at the head, and the C++ side is the right place to test it: the four test_host_head_tile geometries run without a device, so the numerical claim is settled before anyone queues an npu-hold.
What I could not do
The whole test binary did not link here (q4nx_pack_B_int8, q4nx_pack_B_int8_blocked come from objects the engine's target list assembles), so I read the arms rather than running them — my link attempts stopped at the packers. The PR should say how to run this test standalone (the exact g++ line or the ctest name), because the arm's value is that it is device-free, and a lane that cannot run it easily will not.
Scope: +278 across q4nx_host_head.h (new), q4nx_forward.cpp, the test, and docs/npu.md — with NPU_HEAD_HOST=1 as the switch, so the device path is unchanged when the flag is absent.
|
A second model, same binary (this head, not pushed again), for whoever decides the default later. So on this model the head goes from 89 ms to 5 ms per token and the following layers do not move (the 15 ms give-back seen on Qwen3.5-2B is absent here): about 84 ms of a roughly 410 ms token. |
|
VERDICT: READY at Checked at this head:
One non-blocking note: |
… host-head row; hybrid models stop re-dequantising attention weights per token; two docs corrections (#2209) * window11 --compare accepts the decode head on the host as the one declared row difference (Refs #1785, #2191) A run with NPU_HEAD_HOST prints 'dec HEAD M=1' where a device-head run prints 'dec HEAD M=16'. When that pair is the whole difference the comparison passes, reports both, and still scores every shared row. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> * npu: a hybrid model's full-attention layers no longer re-dequantise q/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> * docs: the P4 gate's logits line states the prompts that were run (was #2197, Refs #803) Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> * docs/npu-plan: the Not-yet list says what P5, P6 and P7 landed and what was not built (Refs #799) The P6 entry said the tau=0.6 acceptance rows were missing; they are on the leaderboard. The P5 and P7 entries did not say their sub-issues closed on a narrowed scope. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
…keeps it on the device (Refs #1785) (#2227) Measured on two models with byte-identical logits (#2191), and window11 --compare accepts the HEAD M=1 profile row against a device-head run (#2209), which was the condition for the default. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #1785 (the decode side of the bar: 33 tok/s).
What
NPU_HEAD_HOST=1: the single-row int8 lm head of a decode step is computed on the host instead of launched on the device. Off by default.A decode step sends one row through every head tile, and a launch costs the same weight pass for one row as for a batch. On FastFlowLM's
Qwen3.5-2B-NPU2(vocabulary 248,320, width 2,048) that is 21 launches per token.The host path changes nothing numerically:
q4nx_quant_i8::row, the sameamax/127scale);q4nx_host_head::tile, new header, blocked and row-major layouts);Scope: the int8 tiled head only. The ternary head, the gemv head and batched head rows (prefill, verify) stay on the device. If the image cannot be read on the host, the head goes to the device as before, with one log line.
Measured (NPU,
npu-hold, serves ended with their tickets)Same binary (this branch on main
7035ac1),~/.cache/scratch-cl-flm/q35-2b+q35-kernels,/v1/completions, 24 greedy tokens per request,--dump-logits,NPU_PROFILE=1. Two tickets:dev host host dev(4 requests each), thendev hostx3 (2 requests each).Rate, measured inside one serve. Serves of the same mode differed by more than the head costs on this box (layer launches 2.2 to 3.3 ms each in both modes), so serve-against-serve tok/s is not usable.
NPU_HEAD_HOST=alt(second commit) alternates host and device head on successive decode tokens and logs each call. One serve, 100 tokens, load 15, head7996c29:The host head takes 68 ms off the head and the following token's layers give 15 ms back: net 52 ms (13 %) per token. I have not found the cause of the 15 ms.
Why it stays opt-in (my call as lead on #1785): with it on, the profile table has one
HEADrow at M=1 in place of the M=16 launches, andscripts/npu-window11.sh --comparerequires both binaries to print the same row set. Making it the default would break that gate for every comparison against an older binary, so the default flip is a separate change for the orchestrator to schedule.Tests
tests/npu_blocked_layout_test.cpp(ctestnpu_blocked_layout, no device):q4nx_host_head::tileagainst element-at-a-time sums overimage_index()for a whole blocked tile, a short last tile, padded and ragged geometries, row-major, int8 edge codes, a guard word past the output, and three refused geometries. A mutant (wrong microtile stride) fails 3 arms:Left on #1785
The head was about a sixth of a decode token on this model; the layer launches are the rest and are not touched here. Default-on is a follow-up (see above).
Docs: docs/npu.md.
🤖 Generated with Claude Code