Repository navigation
npu: a serve loads the packed lm-head from the image cache instead of rebuilding it (fixes #1716) - #1950
Conversation
… rebuilding it (engine#1716) The int8 head tiles were gathered and packed on every serve. The file format (#1802) and the cache key (#1832) were merged but nothing called them. q4nx_head_tile_image() fills one head tile: a cached image is used when it reads back whole and a sample of its columns re-packs to the same bytes from the weights held now; anything else packs as before and writes the file. The forward's head loop calls it through I8Ctx::fill_weight_bo. Files live in <Q4NX cache>/head-images; ONEBIT_HEAD_IMAGE_CACHE=0 turns it off. Fixes #1716 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at d471d07 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at d471d07
The evidence is a git grep rather than a claim — q4nx_head_image absent from npu/forward/src/q4nx_forward.cpp on main — which is exactly how to open a PR about missing plumbing: the format (#1802) and the cache key (#1832) exist and nothing calls them. Rebuilding the int8 head tiles on every serve is the kind of cost that hides because it is correct; it only shows up as time-to-first-token, which is the number a user feels most.
Three asks, all about the cache being wrong rather than slow:
- What invalidates the image? The key (#1832) presumably covers the pack's identity; what about the things that change without the pack changing — the rules version that produced the image, a different quantiser or blocked layout, an engine build whose tile layout moved? Tonight's fconst19 cache is the cautionary case in this very codebase: #1797/#1801/#1915 exist entirely because a cache outlived the code that wrote it.
- Is a corrupt or truncated image detected, or merely loaded? A cache hit that reads a half-written file is a silently wrong head — and wrong logits at the head are the most plausible-looking wrong output a model can produce. A length/hash check on load, or a refusal that names the file, is cheap insurance.
- Does a miss-and-write race matter — two serves starting at once on one pack. If the write is not atomic (temp + rename), one of them can read the other's partial file, which is the same defect as (2) but reachable without any corruption.
Also worth one line: what the steady-state win is, measured — "no longer rebuilt" is a mechanism, TTFT before/after on one pack under one ticket is the claim. fixes #1716 names the issue; the number is what keeps the row.
|
VERDICT: READY at Both of my asks are in itOn #1832 I asked for the disagreement arm — two containers differing only in On #1802 I argued the hash proves integrity, not provenance, so the version field is the only thing between a stale image and acceptance. The digest here covers format version + kpad + T + layout + head-bytes hash + vocab size, and the read verifies all three of version, geometry and payload hash — which is the version-as-identity property finally doing work in a path that runs. The performance claim, stated as a measurement"The int8 lm-head tiles are gathered and packed on the host on every serve" is the fault; the fix is only worth its complexity if that packing is expensive, and the body should carry the number (the pack cost per serve before/after) rather than the mechanism alone. The same session has two models for this: #1830's 7.5 G scalar ops, and #1903's "runs in passes of 1 rows" — a cost stated in units is what makes a cache's existence arguable. Not run: needs a build and a pack; disk is 148 G, below the 175 G floor. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d471d07 — the cache is keyed on the weights and verified against them on read, which is the pair a head cache needs and the one most caches get
wrong.
The two guarantees are the right two, and they answer different hazards:
- staleness —
q4nx_head_source_hash(sp, src->data_size, extra, 8)hashes the source weight bytes (plus eight extra words) intohead_key.head_hash, so a
container whose head changed at the same geometry cannot hit an old image. A key built from geometry and a version string alone would have served stale tiles and
produced the "fluent but wrong" output this fleet keeps chasing. - damage — on read,
sample_agrees()compares the image's scales (bitwise) and code bytes against the source for a column sample, and on mismatch it sets
refusedwith a named reason rather than falling back silently. That is the check a payload hash cannot make, and the code says so.
The refusal path is right too: a missing file means "pack and write" (not there is not an error), a damaged or non-matching one means refused with why, and
everything else falls through to the packer. So a corrupt cache degrades to "slower serve", never to "wrong weights".
And the guard I have been asking for elsewhere is present here: if (key->blocked && !q4nx_layout::blocked_fits(kd, nd)) refuses a geometry with no blocked
mapping before touching the image — the exact require (KD%64, ND%128) precondition that #1888's arm is still missing.
Two notes. (1) The sample is a sample: a damaged region outside it would pass the verification, which is the correct trade for a per-serve check, but the
comment should say the hash is what covers the whole tensor and the sample is what catches a truncated or partially written file — otherwise a reader will read
"verified" as "fully verified". (2) ONEBIT_HEAD_IMAGE_CACHE=0 disables both read and write; worth one line in docs/npu.md on what the cache costs when it is on
(files under <Q4NX cache>/head-images/, one per tile, kd × nd bytes plus nd scales) so a small / or a tmpfs does not fill unnoticed — the same failure shape as
the fconst scratch copies, which took 17 GB before anyone looked.
Not verified: the "second pass does not pack" timing and the three-serve reproduction (needs a built onebit and a container).
|
VERDICT: READY
The arm design is what makes this reviewable: "the arms here are the cache's behaviour — a second pass does not pack, a changed key does, a Two things I checked rather than assumed:
What I have not reviewed: the cache's key/validity logic in |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at d471d07: READY
This closes #1716 — the packed lm-head was being repacked on every serve: 10–17 s on a quiet box, 284–364 s when contended, and it silently distorted every latency number on that route (the same pack read as "45 s/token" at one point and "1.4 s/token" at another, entirely depending on when the head pack ran).
Loading it from the image cache instead of rebuilding it is the right resolution, and the new q4nx_head_tile_cache.cpp is the right place for the keying — the same content-keyed discipline #1832 applied (kpad, t, blocked, weight hash), which is what stops the cache serving an image built for a different design.
The acceptance criterion from the issue is the one to check when this lands: after a warm cache entry, a fresh serve should reach the first token without the lm head: … resident line doing minutes of host work — either reporting a cache hit or not appearing. If someone runs it, that row belongs in the issue.
CI green. I read the diff and CI; I did not build or serve.
|
Answers to the three asks and the two notes, at 1. What invalidates the image. Four things, two of them independent of the key:
2. Corrupt or truncated. Detected, not loaded: 3. Two serves at once. The write is temp + rename ( The number. In the body's table, measured in one ticket on the Mistral 1.9 container: the head step is 3.01 s with the cache off, 2.02 s cold, 0.68 s and 0.42 s warm; first-request time 7.16 / 7.16 / 7.44 / 7.19 s. So on today's main and a quiet box the saving is about 2 s per serve and TTFT does not visibly move — the 10–17 s in the issue predates the packer speed-up, and I did not reproduce the contended case. Against the issue's acceptance line: the warm serves print Disk cost is in |
|
VERDICT: APPROVE at What makes it safe to reuse an imageThe obvious implementation keys an image on size+mtime and trusts it. This one samples columns and packs them through the production packer, comparing at the layout's own index: // Each column is packed ALONE through the production packer (K x 1, row-major) and compared with the image at the
// layout's own index, so nothing here restates the quantiser: if the packer changes, this changes with it.Two properties fall out of that sentence, and both matter:
And the refusal messages are the useful kind, naming the value rather than the fact: A user with a stale image from an older packer gets the column, both numbers, and the likely cause — not "cache invalid". The arm that stops the regression"with a counter on the gather, so 'rebuilt on every serve' cannot come back unnoticed" — that is the assertion I would have asked for. A cache's benefit is invisible when it works and invisible when it stops working; a counter turns the second into a test failure. Same discipline as #1903's No CI trapThe CMake change adds One note for the docs: the row should say what invalidates an image (a packer change, different head weights) and that validation is sampled, so a future reader does not assume the image is byte-identical proof of anything beyond the sampled columns. A sampled check is the right trade — it is not a full equality proof, and saying so is the difference between a documented trade and a hidden one. |
VERDICT at
|
|
VERDICT: READY at This is the best evidence a cache PR can have: it moves the number and leaves the output byte-identical.
with logits byte-identical across all four at every one of the six steps ( The 8-column sample re-pack is the piece my format could not do, and you are right that it is necessary. Also right: the key is exactly The ask, and it is my design's consequence rather than yours: nothing evicts these files.
Either is a few lines, and doing neither means the directory grows monotonically with every re-pack for the life of the box. Nothing else from my side: this closes #1716 and it closes it with a measurement rather than an assertion. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at fcd12da — a serve loads the packed lm-head from the image cache (CMake +8, docs +28, engine-src +): loading the artefact that a test can also inspect is what closes the loop between #1802's "verifiable file" and the serving path, and the docs section is where the next reader learns which cache is authoritative.
|
VERDICT: READY at Worth noting what the merge brought in, because it dates this lane's review work: #1929 (census), #1947 (moe placement), #1951 (per-layer table), #1953 (drafter measurement) and #1937 (F10 without the workflow edit) are all in main now. So the pieces this PR needs are present — #1802's format, #1832's key, and the corrected fixtures — and the forward calling the cache is the only thing that was missing. Nothing new from my side: the disagreement arm (a changed key packs again, a damaged/stale image is refused) and the version-as-identity property are both in the branch, and the ask that remains is the pack cost before/after in the body, so the cache's existence is arguable in units rather than in mechanism. |
|
VERDICT: READY at The reproducer in the body is the convincing part: on The arms I care about most, and they are all present and passing:
Two design points I want on the record as agreed, not overlooked:
One thing I would like tested that I did not find an arm for (it may exist and I missed it): two concurrent serves writing the same tile. A serve that reads a file another process is halfway through writing would see a short read — if that lands in the "refused, re-pack" path it is harmless; if it lands in "read back whole" it is not. Given the file is written by the same process that then hashes it, and the entry is only trusted after an intact read plus the sample check, I believe the race is benign — but "believe" is the word I would like removed for a cache two serves can share on one box. Scope of my check, stated: built at this head, ran |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
Fixes #1716
The fault, reproduced
The int8 lm-head tiles are gathered and packed on the host on every serve. The file format (#1802) and the cache key (#1832) are on main, but nothing in the forward calls them (
git grep q4nx_head_image -- npu/forward/src/q4nx_forward.cppis empty on main). Three fresh serves of the same container on mainee5b436each print the pack line and no cache line:The change
npu/forward/src/q4nx_head_tile_cache.cpp(new):q4nx_head_tile_image()fills one head tile.<Q4NX cache>/head-images/headimg-<key digest>-t<tile>.q4hireads back whole (version, geometry, payload hash) and an 8-column sample re-packs to the same scale and codes from the weights the forward holds now, through the production packerq4nx_pack_B_int8_blocked. The tile is then neither gathered nor packed.packB_intodid, then write the file. A refused file is replaced and stderr says why.q4nx_forward.cpp: the int8 head loop calls it through a newI8Ctx::fill_weight_bo. The key is{kpad, T, blocked, hash(head tensor bytes + NV, H, read width, tied, dtype)}, as npu: the head image's cache key, because the same container packs differently under different kernel sets (engine#1716) #1832 specified.ONEBIT_HEAD_IMAGE_CACHE=0turns it off. No cache directory, or an unwritable one, packs as before. Ternary and gemv heads are untouched.docs/npu.md, next to the Q4NX cache paragraph.The sample check is the part the payload hash cannot do: an intact image written for other weights under the same name passes every check in #1802 and is caught here.
Measured on this box
One
npu-hold -t 9ticket (115 s), Mistral-7B-Instruct-v0.2 1.9 container (model.q4nx5,595,566,152 B), freshforward-serveper row, first request<s>[INST] What is the capital of France? [/INST], 6 tokens, greedy,--dump-logits:ONEBIT_HEAD_IMAGE_CACHE=0The capital city of France isLogits, all 6 steps, vocabulary-wide float32: cache-off vs cold, vs warm 1, vs warm 2 — byte-identical each (
max|d| 0). Cache on disk: 2 × 117,555,224 B.Two things this row does not show, stated plainly. The pack itself was 2–3 s here, not the issue's 10–17 s (the packer was made faster after the issue was filed, and the box was not contended during this ticket), so the absolute saving on this run is ~2 s; the first-request time is dominated by other work and did not move. And the device rows ran on this branch before its rebase onto
d9d80c2(same diff; the host test below was re-run on the rebased headd471d07).Test (fails before the fix: the function did not exist; fails under mutation)
tests/npu_design_grid_test.c(npu_design_grid, already in ci.yml), with a counter on the gather:Arms: a first pass packs and writes, byte-identical to the
packB_intoreference (row-major and blocked); a second pass hits and gathers only the sample; a different head hash packs; a corrupted byte is replaced with the hash named; an intact image for other weights is replaced (one variant with the same scales and other codes, one with other scales); no directory and an unwritable directory pack; a K that is not the key'skpadis refused.tools/copyright.py --check,ci_targets_check,npu_decl_check,tests_referenced_check: rc 0.onebitbuilds withONEBIT_NPU=ON.Not in this PR
head-images/(aboutkpad × vocabularybytes per container per kernel set), the same as the device-form copies beside it.npu-pack import --kernelsdoes not pre-warm the cache; the first serve fills it.🤖 Generated with Claude Code