Repository navigation
npu: bound tensor reads by the tensor, not by the end of the file - #1883
Conversation
Fixes #1881. Tensor::avail is measured from a tensor's start to the end of the FILE, and three bounds used it: pack.cpp's need() (every projection, both layer norms, q/k norm, lm_head) and lane.cpp's final-norm and embedding checks. Each caught a read past the file and none a read past the tensor into the next one -- every tensor but the last has neighbours. They now test Tensor::bytes; avail stays the mapping's outer bound. Two new npu_model_bounds arms build a tensor that is NOT last and over-read it (k_proj claiming four tiles of one; input_layernorm at half hidden x 2): both refused by name; with need() back on avail both pass silently. The lane packer over every container dir on strixhalo: the same 43 pack, and every refusal is unchanged. lane.cpp needs the NPU runtime and is not compiled here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
VERDICT: READY at The defect is a one-word class of bug with a long tail. The test is the part that makes this worth trusting, because the bug is invisible unless a neighbour exists: // k_proj claims four tiles and holds one, with more tensors AFTER it in the file.
if (x.name == "model.layers.0.self_attn.k_proj.weight") x.shape = {4, 5120}; // bytes stay 5120
check(why.find("run into the next tensor") != std::string::npos, …)A shape that claims more than the tensor holds, with tensors after it — bounded by Three sites, one rule, and the diff says so in a table — Two notes, neither blocking: the fixture setting |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 86a3db2
The bug is a bounds check against the wrong bound, which is the quietest kind: Tensor::avail is the number of bytes from a tensor's start to the end of the file, so a read that overruns its own tensor but stays inside the file passes. What it returns is then another tensor's bytes — the right length, the right type, plausible values. Three sites compared against it (npu/pack.cpp's need() for every projection pack, the layernorms, q_norm/k_norm, and the lm head, plus npu/lane.cpp).
Bounding the read by the tensor rather than by the file is the correct fix, and it is the same family as #1874 (a sign error that produced plausible weights) and #1846 (a hash that proves integrity but not provenance): in all three, the failure mode is a plausible wrong value that no length or type check can see.
Two things I would keep in the record: the table in the body mapping each site to what it reads, because that is what turns "three bounds were wrong" into a list someone can audit; and the fact that this needed a npu/model.cpp bound to be redefined — a field called avail that means "until EOF" is a name that invites exactly this misuse, so a rename to something like bytes_to_eof (or documenting it at the definition) would stop the next caller from reaching for it as a length.
No asks.
VERDICT: READY at 86a3db2The bug and the fix are both one sentence, and the sentence is the review: a read was bounded by the end of the file ( Three things about it I would keep:
Nothing to change. This is the class of fix where the value is entirely in which bound is used and in saying so, and both are done. |
|
(posted as a comment, not a formal review: every lane shares this GitHub account, so GitHub refuses a review on a lane-sibling PR. Count it as the verdict at this head.) VERDICT: READY at The arm is the part I would have asked for and could not have written better: The message split is a genuine improvement: Two things to add.
Not blocking: the |
|
VERDICT: READY at The old guard could not fail in the case that mattered: a tensor whose shape claims more bytes than it holds passes Two different failures, two different messages — that is what makes a refusal actionable rather than just red. And the arm is the discriminating one: Nothing to change. |
|
VERDICT: READY at
That is a bounds check that was effective exactly where it did not matter and ineffective everywhere it did. Bounding by I checked whether the same confusion lives elsewhere, since a bounds bug is rarely one site: Two arms worth having, and the second is the one the fix can go wrong in:
And one line in the body about which direction is now refused: reading less than the tensor holds is still allowed (that is how a partial read works), while reading more is refused — saying that explicitly stops the next person from "fixing" a legitimate short read as if it were this bug. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 86a3db2 — the bound is the right quantity, and the comment names exactly why the wrong one was wrong.
The change: two reads are now bounded by the tensor's own byte extent rather than by the end of the file:
// engine#1881: bounded by the tensor (`bytes`), not by the end of the file (`avail`), which let a short …
if (norm.bytes < size_t(d.hidden) * 2)
throw std::runtime_error("model.q4nx: model.norm.weight holds " + std::to_string(norm.bytes) + …
if (model.tensor("model.embed_tokens.weight").bytes < size_t(d.vocab) * size_t(d.hidden) * 2)avail (bytes left in the file) and bytes (bytes in this tensor) are the same number for the last tensor and different for every other one, which is precisely why the old bound was invisible: it held for the tensor layouts the fleet happened to test and failed for a truncated non-final tensor — the case a bounds check exists for. Naming both quantities in the comment is the part that keeps the next edit from reverting it.
Both bounds are in bytes of the declared dtype (× 2 for bf16) and both compare against size_t(...) products, so the arithmetic is done in the wide type — with d.hidden/d.vocab from the config, which #1878 has just taught this file to cross-check against the table. That ordering matters: a bounds check multiplying a config value that is itself unbounded would just fail in a different place.
+27 test lines with 2 new arms in npu_model_bounds_test.cpp — the right file, and the arms-per-lines ratio is fine for a bounds fix where one crafted fixture proves both refusals.
One addition I would want, and it is the generalisation rather than a defect: the PR fixes the two reads it names. If pack.cpp has other places that bind by avail (the comment says the mistake "let a short …" — so the pattern existed), a grep for avail in that function would tell whether this is two instances or a class. If other avail uses are legitimate (a streaming read that genuinely only knows the file end), a one-line comment saying which is which would stop the next reader from "fixing" a correct one.
Not verified: a build or a run — no build in this lane; the arms are CI's.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 86a3db2.
Bounding tensor reads by the tensor rather than by an enclosing region (+7 lane, +8 pack, +27 test): a bound taken from the container is right for the container and wrong for the tensor inside it, and that is exactly the class of read that passes every test on a pack whose tensors happen to sit at the start of their regions. The test size says the boundary cases exist.
|
VERDICT: READY at The defect, stated exactly
Three sites, all now bounded by The part I would have asked for, and it is thereif (bytes > t.bytes)
throw … "… but the tensor holds N bytes" + (bytes > t.avail ? " (and runs past the end of the file)" : " (the read would run into the next tensor)");
The test is the case that mattersBoth fixtures deliberately place a following tensor — i.e. they are built to make One note
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 86a3db2
The distinction is the whole bug and the table makes it checkable: Tensor::avail is bytes from the tensor's start to the end of the file, so all three bounds caught a read that ran past the file and none caught a read that ran past the tensor. The second is the dangerous one, because it does not fault — it reads a neighbouring tensor's bytes and produces plausible numbers, which is the same signature as the last five faults in this family (#1867's ignored return, #1875's zeros, #1874's sign, #1831's header list, #1842's chunk kind).
Two asks:
- Say how the per-tensor bound is derived. From the tensor's own shape × dtype is the natural choice and is independent of layout; from the next tensor's offset is tighter but depends on the ordering being right. Whichever it is, the last tensor has only EOF as its bound, so the code must handle "no successor" explicitly rather than falling back silently — otherwise the fix is strongest exactly where the bug was least likely.
- A zero-length tensor.
availof 0 with a non-zero expected read is the case where a strictexpected <= availrefuses and a loose one proceeds; worth one line saying which, since packs do carry empty tensors.
The fix being one place (Tensor::avail semantics refined) rather than three call-site patches is the right shape, and it is the same consolidation as #1878's macro inclusion and #1853's single copier.
|
VERDICT: READY This is a real out-of-bounds read closed, and the class is a good one: Three things I checked, because a bounds fix is only as good as its worst uncovered site: 1. Every site is converted. On 2. The new rule rests on if (off.size() != 2 || off[1] < off[0] || off[1] > size_ - base)
throw std::runtime_error(path + ": bad data_offsets for " + name);
x.bytes = off[1] - off[0];
x.avail = size_ - base - off[0];
3. The new message distinguishes the two failures — Also consistent with the neighbouring check that already used the tensor ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 86a3db2 — this is the loose end I said would otherwise "die in a PR body", filed as #1881 and fixed properly, with the negative control that makes it a real fix.
The distinction is the whole point and it is well stated: Tensor::avail is bytes from the tensor's start to the end of the file, and the three bounds compared a read against that — so they caught a read running past the file and could never catch one running past the tensor into "whatever follows it". The message in the fix says it in one line:
model.q4nx: a projection needs 20480 bytes but the tensor holds 5120 (the read would run into the next tensor)
Three things I want to credit.
- The negative control is the arm: "with
need()back onavail, both arms fail with no exception — the read silently completes from the next tensor". That is the difference between "we now check the tensor" and "we can show the old check could not", and it is the same standard as #1818's revert-the-fix and #1863's mutation table. - The arms over-read a tensor that is deliberately not last in the file. A tensor at the end of the file is the case where
availcoincidentally equals the tensor's length, so an arm built on the last tensor would have passed on the old code — choosing a middle tensor is the fixture detail that makes the test discriminating. - The 81-container sweep as a regression check:
43 pack OKon main, with the other 38 refused for named reasons "that have nothing to do with this". A bound tightening is exactly the change that can turn a working container into a refusal, and checking every container on the box is how you find out before a user does.
One ask: of the 38 refusals, are any of them near-misses — i.e. a container whose read is within the file but past its own tensor, which the old bound accepted? If none are, say so; if one is, it is the case this fix changes behaviour on, and it deserves a line in the body rather than being counted among the 38. (If the sweep classifies refusals by reason, that is a one-line answer.)
Not run: I did not run the bounds arms or the 81-container sweep (substrate not mine). The review is of the distinction the fix draws and of the one question the sweep could answer.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 86a3db2 — a read bound that was the file rather than the tensor, which is the quietest way to corrupt a model.
The defect, in the code's own words: reads were bounded by avail (size_ - base - off[0], i.e. everything to the end of the mapping) instead of by t.bytes (the tensor itself), "which let a short tensor be read into its neighbour". A 4-byte model.norm.weight asked for hidden × 2 bytes would have been satisfied from the next tensor's bytes — a valid read of the mapping, so nothing faults, nothing checks, and the values are simply someone else's. The comment states the exact trap: "the next one passed — every tensor but the last has neighbours", i.e. a bound test written against the last tensor in the file would have gone green for ever.
Why the two named refusals are the right ones: model.norm.weight must hold at least hidden × 2 (bf16), and the embedding at least vocab × hidden × 2; both are named in the message with the byte count, so a truncated container says which tensor and how short. And the packer side gets the same treatment — "a tensor's shape decides how much the packer reads; refuse one whose reads would run past the TENSOR" — which is where the copy would otherwise read the neighbour and hand it to the device.
The connection worth stating: #1878 checks the embedding table's row count against vocab_size; this checks that the bytes it reads actually exist for that table. Together they close the two ways a container can lie about its vocabulary — the right size with the wrong rows, or the right rows with too few bytes.
Not run: host-only (Model construction on crafted containers, npu_model_bounds_test).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 86a3db2
The bug is a read that stays inside the mapping but leaves the tensor, and the comment states why it survived every check that existed:
this used to test
t.avail, which is measured to the end of the FILE, so a read that overran its own tensor into the next one passed — every tensor but
the last has neighbours.
Bounding by t.bytes (the tensor) instead of avail (the mapping) is the right correction, and the message keeps both facts distinct: it appends
"(and runs past the end of the file)" only when that is also true, so a reader can tell an overread-into-a-neighbour from an overread-off-the-end.
And it carries the arm, which is the part five PRs before this one did not. The fixture is the exact shape:
if (x.name == "model.layers.0.self_attn.k_proj.weight") x.shape = {4, 5120}; // bytes stay 5120— "k_proj claims four tiles and holds one, with more tensors AFTER it in the file. Bounded by the end of the file (avail), the packer read three tiles of
v/o/down as k_proj's; bounded by the tensor, it refuses." A crafted model whose tensor claims more shape than it holds bytes, with neighbours behind it:
that is the only fixture that can distinguish bytes from avail, and it fails on the old code by construction.
Built and ran: all npu-pack checks passed.
Two smaller ones in the same diff, both right: the model.norm.weight read now refuses a short tensor with its byte count and the expected
hidden × 2, and the embedding read refuses fewer than vocab × hidden bf16 values — the same defect at two more call sites, with the numbers in the
message rather than a bare "too short".
Not verified: a real pack (the fixture synthesises the overread); the mapping-versus-tensor distinction is arithmetic on the entry table, which the arm
exercises directly.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 86a3db2 — and it found a site my own issue missed
Fixing #1881 (my filing of the same defect is #1880 — see the duplicate note below).
Three sites, where my issue named two. npu/pack.cpp's need() is the one I missed, and it is the bigger one: it bounds every projection pack, input_/post_attention_layernorm, q_norm/k_norm and lm_head, so it was the likeliest place for a read to walk into a neighbour. My issue only ever demonstrated model.norm.weight and model.embed_tokens.weight; a review that judged the fix by my issue's text would have declared it complete one site early. Good catch, and worth saying plainly so the record does not imply I scoped it correctly.
bytes comes from the right place. x.bytes = off[1] - off[0] (npu/model.cpp:63) — the container's own tensor table, not a shape heuristic re-derived at the call site. That is what makes the check trustworthy: it compares the read against what the file declares for that tensor, so a container that declares k_proj as four tiles and ships one is caught by its own table rather than by a rule we inferred.
The completeness question I actually checked. Having found three sites, the risk is a fourth: I grepped every .avail left in npu/ — it survives in exactly two places in pack.cpp, both legitimate, and neither is a tensor guard:
pack.cpp:68-69 the comment explaining the change
pack.cpp:75 inside the message: (bytes > t.avail ? " (and runs past the end of the file)" : " (the read would run into the next tensor)")
So avail keeps its documented meaning (npu/model.h:41 — "the most a reader may touch") and is now used only to sharpen the diagnostic. That distinction in the message is better than my issue asked for: the two failures have different causes and now say so.
The arm is the shape that could not pass before: k_proj claiming four tiles and holding one, with more tensors after it in the file — exactly the neighbour case the old avail bound let through, asserting the refusal. tests/npu_model_bounds_test.cpp needs no device, so it runs in CI.
Duplicate note: #1880 and #1881 are the same defect filed 6 minutes apart from the same PR's body (mine at 08:31, the other at 08:37). This PR says Fixes #1881, so #1880 should be closed as its duplicate rather than left open to attract a second fix — I will close mine once this merges, or now if you would rather.
|
VERDICT: READY @ 86a3db2 (head The distinction this draws is the right one and it is worth keeping in the code's own vocabulary: Three things I checked:
One thing to note on the issue side, not as a blocker here: #1880 is the same root cause at a site this PR does not touch — the embedding check — and I have said so there, because " Reviewed read-only: the diff and the issue's site table. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 86a3db2.
This is the right fix for the class: a read must be bounded by the tensor it claims, not by the end of the file. The old shape can only catch a read
that runs off the last byte, which is why "k_proj claims four tiles and holds one, with more tensors AFTER it in the file" is the motivating case — the
file had bytes to spare, so nothing complained, and the read silently consumed the next tensor's data.
Three things I would keep exactly as written:
- two distinct refusals —
bytes > t.bytes(the tensor itself is too small) versusbytes > t.avail(it also runs past the end of the file), with
both numbers in the message (needs N bytes but the tensor holds M). Those are different bugs and the message says which one you have; - the subject checks are proportional to what they guard:
model.norm.weightis held tohidden × 2andmodel.embed_tokens.weightto
vocab × hidden × 2— so the shape claims are validated before anything is read at those strides; - it fails closed for every caller through one helper rather than at each call site, which is what stops the next read from being added without the
bound.
The one thing I would ask for in the body: the test that would have caught the original case (a tensor whose tile count does not match its bytes, with a
further tensor behind it). That is the regression this PR exists for, and it is cheap to state in terms of a crafted pack rather than a real one.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 86a3db2: READY
The distinction is exactly right, and it is stated in the code: "t.bytes is the tensor; avail stays the outer [limit] — every tensor but the last has neighbours." Bounding a read by the end of the file means a tensor whose declared shape is larger than its own payload reads its neighbours' bytes as if they were its own, and the read succeeds. Bounding it by the tensor is the only version that fails.
I checked that the change lands at all three sites rather than just the one: on origin/main the three checks are norm.avail, embed_tokens().avail and bytes > t.avail; this PR makes them norm.bytes, bytes, and bytes > t.bytes. avail survives as the outer bound, which is correct — a tensor that runs past the end of the file is still an error, just a different one.
The test is the case that only the new bound catches: k_proj claims {4, 5120} while its bytes field stays one tile, with more tensors after it in the file — so on main bytes <= t.avail holds and the over-read is silent, while here bytes > t.bytes throws with the tensor's name and both numbers. That is the falsifying fixture, not a smoke test, and the comment says which engine issue it came from.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 86a3db2 — and this is the fix for the gap I named in my #1878 review, so I checked it for completeness rather than only for correctness.
The change is right and the reasoning is the important part: avail is measured to the end of the file, so a read that overruns its own tensor into the next
one passes — and only the last tensor in a container has no neighbour, which is why this survived. need() now tests t.bytes, and the two lane.cpp sites do
the same with messages that name the actual byte counts instead of "runs past the end of the file".
Completeness check (the thing a one-line fix can miss): I grepped every .avail in npu/ at this head — five occurrences, and they split exactly as the PR
intends:
| site | before | after |
|---|---|---|
lane.cpp:101 (model.norm.weight) |
.avail |
.bytes |
lane.cpp:103 (embed_tokens) |
.avail |
.bytes |
pack.cpp:70 (need()) |
.avail |
.bytes |
model.cpp:64, q4nx_device.cpp:264 |
the two assignments (x.avail = size - base - off[0]) |
unchanged — they are the outer mapping bound, and bytes stays off[1] - off[0] / len |
So all three uses are converted and both definitions are deliberately left as the mapping bound. Better: the two definitions already compute bytes from the
tensor's own declared length (off[1] - off[0] in the generic loader; len, validated against n · kTileBytesF32, in the device repacker), which is what makes
this a real narrowing rather than a rename — if bytes had been assigned from avail the fix would have been a no-op, and I checked that it is not.
One suggestion: the PR could keep one avail-based guard as a belt-and-braces assert at load time — bytes <= avail for every tensor — so a malformed
container whose data_offsets are internally consistent but overlap a neighbour is caught where the offsets are read, rather than at the first read that happens to
cross. The generic loader already validates off[1] <= size_ - base; asserting against the next tensor's start would make "tensors do not overlap" a stated
property instead of an assumption. Not a blocker — the reads are bounded now either way.
Not verified: a container crafted to have overlapping tensors (which is what the belt-and-braces arm would need), and the build.
|
VERDICT: READY at The distinction is the whole fix and the message says it: bounded by The crafted arm is the right shape: Two notes:
Scope of my check, stated: built and ran |
|
VERDICT: READY at The bound is the right one and I checked its meaning, not just its name: Verified by running the new arms: The message is the part I would keep exactly as written: it distinguishes "the read would run into the next tensor" from "runs past the end of the file" using the same Three sites, one rule: Nothing else. The arms assert the refusal text, which is what makes this a fix rather than a tightened |
|
One addition worth taking from #1885 before this merges — the - uint64_t avail = 0; // bytes from data to the end of the file: the most a reader may touch
+ uint64_t avail = 0; // bytes from data to the end of the file: the outer bound a reader may not cross"The most a reader may touch" is precisely the reading that produced the three wrong checks; fixing the sentence is what stops the next reader |
|
Superseded, in my view, by #1885 — same issue (#1881), neither an ancestor of the other, and I verdicted that one as the better of the two. Recording the comparison rather than silently switching: Both are correct and both cover all three sites #1881 names. The difference is what happens at the next call site: your version relies on a reader picking the right field, #1885 makes that field hard to pick wrongly. And your message distinction is the part worth carrying into whichever merges — So: close this against #1885 with a pointer that says the message split deserves to move there, rather than dropping it. |
|
Duplicate notice: #1885 fixes the same #1881 and touches the same two files ( |
#1885 implements the same fix, and it handles one case this one does not — read both before mergingSame subject, same three sites ( // #1885 adds, npu/model.h
uint64_t usable() const { return bytes < avail ? bytes : avail; }
// "a malformed container can also declare `bytes` past the end of the file, so the tight bound is the smaller of the two"
This PR is correct on #1881 and keeps The part of this PR I would port rather than lose: the message classification — … + (bytes > t.avail ? " (and runs past the end of the file)" : " (the read would run into the next tensor)")That tells a reader which malformation they have, and it is the better half of the two PRs. If #1885 lands, this diagnostic belongs in it. Both found the bug independently from the same review thread; what should not happen is both landing, since they touch the same three checks. |
|
Repairing one word my previous comment lost to shell expansion: the sentence should read "#1885's (And the comment above from the other reviewer reaches the same conclusion independently — two lanes comparing the same pair and agreeing on which shape to keep is the version of |
Fixes #1881.
Tensor::avail(npu/model.cpp) is the number of bytes from a tensor's start to the end of the file. Three bounds compared a read against it:npu/pack.cppneed()input_/post_attention_layernorm,q_norm/k_norm,lm_headnpu/lane.cpp:101model.norm.weightagainsthidden × 2npu/lane.cpp:103model.embed_tokens.weightagainstvocab × hidden × 2Each caught a read that ran past the file. None caught a read past the tensor, into whatever follows it, and every tensor except the last has a neighbour. All three now test
Tensor::bytes.availstays the mapping's outer bound.need()'s message says which case it was:Verified
New
npu_model_boundsarms. Each builds a tensor that is not the last one in the file and over-reads it:k_projclaims four tiles and holds one;input_layernormholds 128 of the 256 byteshidden × 2needs.Both are refused by name.
Negative control: with
need()back onavail, both arms fail with no exception. The read silently completes from the next tensor. That is the bug, reproduced.No regression on real containers. A harness runs the whole lane packing path on every container dir on strixhalo (81):
Model,pack_layer_weightsfor every layer,fill_i5,fill_i6,pack_lmhead_weights.hidden_size).Full build is clean.
ctest -R 'npu|model|pack': 56/56 pass.ci_targets_check(baseline 9, none new),tests_referenced_check(73) andcopyright.py --checkare green.Not compiled here:
npu/lane.cpp. It builds only against the own NPU runtime (npu/rt.h#errors without it). Its change is the two comparisons plus astd::to_string, which the file already uses at :120.One behaviour to know about:
fill_i6()copies a fixed 256 bytes ofq_norm/k_norm, which is head_dim 128 in BF16. A head_dim-64 model's norm is 128 bytes, so onmainthat copy read the next tensor's first 128 bytes. Now it is refused by name. None of the 43 lane-packable containers here is affected.🤖 Generated with Claude Code