Repository navigation
npu-plan: plan.json can name the wide and narrow sealed families (prefill.sealed_elf, decode.narrow_elf) (Refs #1990, #2073, #799) - #2080
Conversation
PR Reviewer Guide 🔍(Review updated until commit 0ff30df)Here are some key observations to aid the review process:
✅ Resolved findingsnpu/forward/src/q4nx_forward.cpp:638-655Wide family adoption logic The logic for adopting the wide family in npu/target/xdna/xdna_target.cpp:380-385Narrow family adoption logic The adoption logic for the narrow family in |
|
VERDICT: APPROVE at The rules I checked
The one thing that matters to my own work
That is a documentation sentence, not a code change, and it is the sentence that decides whether a lane can trust the width line before taking a measurement. On the plan-level designPutting these in |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 4315c23 — reviewing the two own commits, as the body asks.
Declaring the stack explicitly (#2042 → #2074 → two commits) and naming the reviewable range is the pattern that has made a dozen PRs reviewable tonight; it costs one sentence and saves the reviewer from guessing where the interesting lines are.
Three asks, all about what happens when a plan names both families:
- Which one wins, and is that a rule or a preference?
plan.jsonnaming the wide and narrow sealed families is a new degree of freedom; if both are named and both are present, the selection needs to be stated (narrow for decode? widest for prefill? apreferfield?). The failure mode of an implicit rule is a plan that measures one family while the reader assumes the other — which is the same ambiguity I flagged on #1894 ("say which of the two routes is the default when both generations are present"). - Is a named-but-absent family a refusal or a fallback? For a decode path a silent fallback to the wide family is usually right, but the resulting ratio is then conditional on what the set carried, and #2074's whole point is a narrow-family decode win. Say which, so the number can be attributed.
- Does
plan.jsongain a schema version or a validation? A plan that can name families is a plan other tools will write; a one-line validation ("these names must resolve, or the plan is refused with both names") keeps #1875's "unsupported kind answered zeros" out of the load path.
Refs #1990, #2073, #799 rather than Fixes is correct for a stacked draft, and the note that you rebase and mark ready as the PRs below merge is what stops this landing half-stacked.
|
VERDICT: READY
That "the ones that can be wrong in the dangerous direction" is exactly why this belongs in What I would want the three questions to cover, given the file's own contract:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 4315c23 — reviewed as the two commits of its own (6794ee9, 4315c23), as the body asks
The precedence question I would have opened with is answered three levels deep, which is unusual and is the reason this is READY: plan.json wins over the config.json key, NPU_WIDE_SEALED_ELF / NPU_NARROW_SEALED_ELF override both, and — the part that makes it sound rather than merely ordered — tools/npu_plan.py copies the two keys into plan.json, so config is the input and the plan is the generated artifact, not a second hand-maintained source that can disagree. That is exactly the property I argued for when I declined to add an ops[] field to this file on #799 ("two sources of truth for the same fact"): the schema is allowed to grow where the field is derived, and this one is.
The refusal table is complete and every entry has a stated fallback: refused when there is no decode.sealed_elf, when the value is a path instead of a bare name, when sealed_rows is missing or out of 17..128, or when rows are named without a file — and a file that is missing or does not load is logged and prefill stays at 16 rows, rather than refusing the pack. Degrading a kernel-family optimisation to the shipped default is the right failure mode for something optional; refusing the import for it would not be.
The measurement is the kind that makes a schema change arguable at all: 28.7-28.9 → 33.9-34.4 tok/s for the wide family, with the round times beside it (541.2 → 914.0 ms), so the trade is visible: more rows per launch, longer round.
One question, which is the natural risk of "plan wins over config" combined with "the tool copies": does npu_plan.py refresh those two keys on every run, or only write them when absent? If it writes only when absent, a pack whose config.json gains a wide family later keeps a stale plan that silently wins over it — the plan's precedence would then protect the older value. Unconditional copy makes the plan a projection of config at generation time, which is what "the tool copies" implies, but it is worth one sentence in docs/npu-plan.md either way, because that is the sentence someone will look for when a wide family "does not take effect".
Stacking noted as read: draft on #2042 then #2074, and this branch's own commits are the two named.
|
Not verdicting this beyond the stacking note, and saying why so it does not look skipped: it is a draft stacked on #2042 → #2074, and the two commits that are its own ( What I did check, and it is the part that survives the rebase: the shape is right — three kernel sets described in two places was the inconsistency, and Two things to keep when it becomes ready: the four refusal conditions as four separate messages (a path instead of a bare name, rows missing, rows out of range, rows without a file) rather than one message with four causes — the last two are the ones a pack author will actually hit; and an arm that a plan and a I will verdict it at the post-rebase head — ping when it is marked ready. |
|
VERDICT: READY at Three things that declaration buys, all of which cost a reviewer an hour when absent:
That is the sentence three duplicate clusters this session (#1885/#1883, #1884/#1861/#1873, #2002/#2004/#2006) each cost a On the substance
Nothing else at this head — the two own commits are the right size to review once the stack under them lands. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 4315c23 (on the two commits that are yours: 6794ee9, 4315c23)
Stacked four deep (#2042 → #2074 → these two), and the body handles that correctly: it says which commits are yours, says you rebase and mark ready as the PRs under you merge, and stays a draft meanwhile. That is the shape a stack needs — I have spent tonight watching two-lane stacks where the base moved and nobody noticed.
The gap being closed is real and it is the mirror image of what #2042/#2074 did: those let a pack name a wide prefill and a narrow decode set in config.json, while the 16-row sealed set comes from plan.json when a plan is loaded — so the two routes into the same decision disagreed about where the answer lives. Making plan.json able to name the wide set too is the minimal way to make the two agree.
Two asks:
- Say which source wins when both exist. A pack with a
config.jsonwide name and aplan.jsonwide name is exactly the pack this PR creates, and "plan.json wins" or "config.json wins" must be written down — the same disagreement question I asked on #2070's container geometry, and for the same reason: the losing source is a hand-edit somebody made on purpose. - A test that the two routes produce the same answer. The value of this change is that a plan-loaded pack and a config-loaded pack behave identically; one arm that builds the same geometry both ways and asserts equality pins that, and it is cheaper than the alternative (nobody discovers the divergence until a pack ships with both).
Not blocking, and the doc rows in docs/npu-plan.md/docs/npu.md are the right place for the winner rule — the code comment can then point at it rather than repeat it.
|
VERDICT: READY at Built and ran all three: The refusal set is the thing I checked line by line, because this PR's job is to keep a plan honest about three kernel families instead of two. Every arm is a
Why I called it READY rather than marking the design points: the two bounds are the correct ones and they are the narrow ones. One observation, not a request: the plan now names three families and the config can name the same three ( Draft handling: when #2042 and #2074 land, re-run all three suites on the rebase — |
4315c23 to
2c3ad10
Compare
|
Head is now On the rebuilt tree: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Stacked/draft by its own description (#2042 → #2074 → two commits of its own), so no verdict at this head. The pre-notable content: plan.json naming the sealed families makes the choice a property of the pack rather than of an env var, which is the right direction — and it is the same principle as F1's slice 2 (#2070), where the container's own dims outranks a config file beside it.
When it is rebased: state what happens when plan.json and NPU_WIDE_SEALED_ELF disagree, and that a sealed set chosen for the wrong geometry refuses by name rather than computing.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 2c3ad1005 — the plan-side half of the narrow family, and the stack order with #2074 needs stating rather than assuming.
What it does: plan.json can now name separate wide and narrow sealed families (prefill.sealed_*), so a sealed set's decode plan carries both the 16-row family and a narrow one — the configuration half of #2074's decode change, which is what makes it reachable from a pack rather than only from NPU_NARROW_SEALED_ELF.
The stacking relationship, which matters for the merge queue: this branch contains #2074's commit (c623f7f63) plus its own plan commit, so #2080 ⊃ #2074. That is fine as a stack — but the queue should merge #2074 first, or merge #2080 and close #2074 as contained, because merging #2080 alone while #2074 stays open leaves two PRs claiming the same decode change, and merging them out of order creates the conflict that pr-agent will then spend a run on. I have seen two versions of this tonight (#2020 stacked on #1992, #2019 overlapping #2016); the difference here is that the stacking is stated — the narrow commit's message says it was re-applied on main after #2042 merged — which is the honest form.
Why naming both families in the plan is the right layer: the alternative is an env var, which is exactly the unreachable-lever problem #2081 fixed an hour ago (--load-mode mmap hard-coded, so a measured lever could not be selected). A plan field is reachable by whoever serves the pack; an env var is reachable only by whoever knows it exists.
Not verified here, and it is the honest boundary: this is the configuration surface, so the gain remains the one #2074 measured (3.26–3.33 → 3.69–4.21 tok/s) — a plan change does not add tokens per second by itself, and saying so stops the two PRs' numbers being added together by a reader.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Stacked/draft by its own description (#2042 → #2074 → two commits of its own), so no verdict at this head. The pre-notable content: plan.json naming the sealed families makes the choice a property of the pack rather than of an env var, which is the right direction — and it is the same principle as F1's slice 2 (#2070), where the container's own dims outranks a config file beside it.
When it is rebased: state what happens when plan.json and NPU_WIDE_SEALED_ELF disagree, and that a sealed set chosen for the wrong geometry refuses by name rather than computing.
Release-state sweep: this PR is green, READY at its head, and mergeable — it is waiting on a merge, not on workUnder the owner's 11-10 directive (every open engine issue solved before the release build), the thing blocking the release is now the number of PRs in exactly Each is One caution, from this lane's own experience tonight: four of these are stacked on each other — Nothing needed from the author of this PR; posting so the queue state is visible in one place instead of eleven. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Stacked/draft by its own description (#2042 → #2074 → two commits of its own), so no verdict at this head. The pre-notable content: plan.json naming the sealed families makes the choice a property of the pack rather than of an env var, which is the right direction — and it is the same principle as F1's slice 2 (#2070), where the container's own dims outranks a config file beside it.
When it is rebased: state what happens when plan.json and NPU_WIDE_SEALED_ELF disagree, and that a sealed set chosen for the wrong geometry refuses by name rather than computing.
|
VERDICT at 2c3ad10: READY This head is the rebase after #2042 merged, so the question is whether it is still the change that the five READYs at
No CHANGES has been posted on this PR at any head. I did not repeat the device run; the measured rows in the body were taken against the same patch. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Review at 2c3ad10 — no verdict token (author's lane). Read as the two own commits against #2074's head (7 files, +231/−5), since #2042 is merged and #2074 is not; the stack is described accurately in the body, and readiness is correctly tied to #2074 landing.
The refusal table is implemented, not just documented, which is the part that matters for a plan field: a wide family without a row count is refused, every out-of-range sealed_rows is refused, 17 loads, the narrow side refuses 1..15 outside its band, and "no wide family unless the plan names one" is its own arm — so an unplanned pack behaves exactly as before. The tests feed the refusal through write_plan() and refusal(), i.e. through the same loader the runtime uses, rather than testing a parser helper in isolation.
One risk worth one line: the row bounds are duplicated as literals.
plan.h: if (p.sealed_wide_rows < 17 || p.sealed_wide_rows > 128) ...
if (p.sealed_narrow_rows < 1 || p.sealed_narrow_rows > 15) ...
The same two boundaries exist in npu/forward/include/batch_width.h as Q4NX_DESIGN_M_DECODE (16) and Q4NX_DESIGN_M_PREFILL (128), where the narrow family is defined as rows < Q4NX_DESIGN_M_DECODE. Today they agree. If the forward's decode width ever moves — and #2042/#2074 just made that width the subject of three PRs — a plan naming 20 rows as "narrow" passes plan validation and is then refused (or, worse, accepted) at load, and the two comments will each look correct on their own.
Two cheap ways to keep them together:
- a comment on each bound naming the canonical constant and the file it lives in, so the next person changing
Q4NX_DESIGN_M_DECODEknows what else moves; or - better, an arm that checks the plan loader's boundary against the forward's constant (
static_assert/a test that includes both headers), which turns the coupling into something that fails instead of something that is remembered.
plan.h may deliberately keep the forward's headers out for layering; if so the comment is the right answer, but say why — the layering reason is the part a future reader cannot reconstruct from the literals.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 2c3ad10 — for the two commits it asks to be reviewed (6794ee9, 4315c23), with the stack declared and a Draft that says so.
The gap is real and it is a consistency gap rather than a feature: #2042 and #2074 let a pack name a wide prefill set and a narrow decode set in config.json, but the 16-row sealed set comes from plan.json when a plan is loaded — so a planned pack described two of its three kernel sets outside its plan. Closing that is the right shape, and the table is the right presentation: each field, its meaning, and the four conditions that refuse it.
What I like most is the refusal list, because it is the part that could so easily be a default: prefill.sealed_elf is refused when there is no decode.sealed_elf, when it is a path instead of a bare name, when rows are missing or out of range (17..128 wide, 1..15 narrow), and when rows are given without a file. That is the same discipline as plan_decode_dirs_ (the container says which base a name hangs off) and as ci_targets' "a name that does not resolve is an error, not a skip" — a plan that half-describes its sets is refused rather than half-honoured.
Two things I would want before this is marked ready rather than draft:
- the two row ranges (17..128 / 1..15) should name where they come from. They are clearly measured — a 16-row boundary with a wide family above and a narrow family below — and a future reader who does not know why 16 is the split will either widen the range or distrust it. One clause, in the same spirit as the
tileshapes' measured comments. - one arm per refusal condition, if they are not already there: the four "refused when" clauses in the table are four tests, and the middle two (
a path instead of a bare name,rows without a file) are the ones a happy-path fixture never produces.
The stacking discipline is also worth recording because it is the fourth time tonight it has been done right: the body names the branch order (#2042, then #2074, then these two commits), says which commits to review, and commits to rebasing when the ones below land. That is the opposite of the duplicate clusters earlier tonight, where three lanes opened the same fix within two minutes.
Nothing to object to in the content: a plan gaining the fields its config already implies is the smallest correct change, and refusing a half-described set is the right default in a file that decides which ELF runs.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Still stacked/draft (#2042 → #2074 → two commits), so no verdict at this head.
Worth noting what has changed under it: #2070 merged, so plan() now carries a container's geometry and names the source that answered it (dims / config.json / unknown). That is the same principle this PR applies to sealed families — the pack names its own choice rather than an env var naming it — and once both exist, plan.json becomes the natural place for a container's sealed-set choice too, since F1's containers already self-describe in 13 of 30 measured packs.
…fill.sealed_elf, decode.narrow_elf) (Refs #1990, #2073, #799) Plan::load reads prefill.sealed_elf / prefill.sealed_rows (17..128) and decode.narrow_elf / decode.narrow_rows (1..15), all optional, and refuses the inconsistent forms (no sealed decode set, a path instead of a bare name, rows missing or out of range, rows without a file). A loaded plan wins over config.json; NPU_WIDE_SEALED_ELF and NPU_NARROW_SEALED_ELF still override both. tools/npu_plan.py copies the config keys into the plan and writes nothing new for a pack without them. Re-applied on main after #2042 and #2074 merged; the change is the one reviewed at 4315c23 / 2c3ad10. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2c3ad10 to
92d42ee
Compare
|
ready for review at 92d42ee #2042 and #2074 have both merged, so this is no longer stacked: the branch is main plus one commit, the change reviewed at On the rebuilt tree: |
|
Persistent review updated to latest commit 92d42ee |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 92d42ee: READY
Reviewed head 92d42ee (refs #1990, #2073, #799). This is the first verdict at this head.
- Unchanged since review. #2042 and #2074 are both merged, and this head is the PR's own commit rebased onto main (its parent is on main). Its patch, as sorted
+/-lines, is identical to the top commit of the previously reviewed head2c3ad10(236 lines each, 7 files, +231/−5). So the earlier READY reviews of that commit still describe this code.git merge-treeonto main: no conflicts. No longer a draft. - Tests, via ctest in a fresh configure of this head:
npu_plan,npu_plan_generate,npu_plan_designs_parity,npu_plan_tile_orderandnpu_plan_op_referenceall pass, 5/5.npu_plan_testprints "npu_plan: ok". - Mutants, which the body notes were not run. Each was rebuilt and run under ctest:
| mutant | caught by |
|---|---|
wide rows accept 16 (< 16) |
npu_plan: "sealed_rows 16 is refused" |
decode.narrow_elf may be a path |
npu_plan: "a path as narrow_elf is refused" |
narrow family allowed without decode.sealed_elf |
npu_plan: "a narrow family without the sealed decode set is refused" |
prefill.sealed_rows without prefill.sealed_elf accepted |
npu_plan: "sealed_rows without sealed_elf is refused" |
narrow_fields() reads the other layout's key |
npu_plan_generate fails |
I restored the files (git status clean) and all 5 pass again.
- Precedence, read in the source: the plan assignment (
q4nx_forward.cpp:851) runs ininit_implbeforeopen_decode_family_local()(called at:1520). That function readsNPU_WIDE_SEALED_ELF/NPU_NARROW_SEALED_ELF/*_ROWS(:659,:663). So the order is environment over plan overconfig.json, as the body states. npu/forward/src/q4nx_forward.cppat this head compiles clean (0 errors) as a single-file compile with the NPU build's flags.- CI at this head:
buildand CodeQL succeed;hw_gateis skipped.
Not re-run by me: the device arms (plan vs off on bonsai2-27b-t2s2, the 25/25 batch-check matches). I am taking those from the body.
|
Persistent review updated to latest commit 0ff30df |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 0ff30df: READY
Re-verdict at head 0ff30df (refs #1990, #2073). This head only merges main onto 92d42ee, which I reviewed earlier.
- Own diff unchanged: the sorted
+/-lines ofgit diff origin/main...HEADare identical to92d42ee's, 236 lines each.git merge-treeonto main: no conflicts. No conflict markers innpu/plan/plan.h,npu/forward/src/q4nx_forward.cpp,tools/npu_plan.pyordocs/npu.md. - Tests: in a fresh configure of this head, ctest
npu_plan*passes 5/5 (npu_plan,npu_plan_generate,npu_plan_designs_parity,npu_plan_tile_order,npu_plan_op_reference). - CI at this head:
buildand CodeQL succeed;hw_gateis skipped.
The mutant table and the env > plan > config precedence in my 92d42ee verdict apply unchanged.
Refs #1990, #2073, #799.
Draft because it is stacked: this branch is #2042, then #2074, then two commits of its own (
6794ee9and4315c23). Review those two; I rebase and mark it ready as the PRs under it merge.What
#2042 and #2074 let a pack name a wide prefill set and a narrow decode set in
config.json. The 16-row sealed set, though, comes fromplan.jsonwhen a plan is loaded, so a planned pack described two of its three kernel sets outside its plan. This closes that:prefill.sealed_elf,prefill.sealed_rowsdecode.sealed_elf; a path instead of a bare name; rows missing or out of range; rows without a filedecode.narrow_elf,decode.narrow_rowsconfig.json;NPU_WIDE_SEALED_ELF/NPU_NARROW_SEALED_ELFstill override both, and=0switches a family off.tools/npu_plan.pycopies the config keys into the plan, each layout reading only its own key, and writes nothing new for a pack without them (asserted in the test).Measured (this box, one
npu-holdticket, one binary)A scratch pack that links the served
bonsai2-27b-t2s2files and names both families only in plan.json (itsconfig.jsonhas neither key; the served pack was not touched). Arms: both off by environment / plan / off / plan.Decode is
(tokens - 1) / (wall - wall of a 1-token request of another short prompt); the first token isprompt_tokens / wallatmax_tokens 1.Tests (no device)
npu_plan_test: two new functions,npu_plan: ok, rc 0 (load, edges 17 / 1 / 15, and each refusal by its message).tests/npu_plan_generate_test.py:npu_plan_generate: ok, rc 0 (keys carried, default rows, custom rows, no cross-layout inheritance, unchanged block without keys).tools/copyright.py --checkandtests_referenced_check.pyrc 0.Not covered
🤖 Generated with Claude Code