Skip to content

tools: dflash_draft_info, the gate before a DFlash draft loader: the package is in forms we already read (engine#1780) - #1957

Merged
bong-water-water-bong merged 2 commits into
mainfrom
cl-npuperf/1780-dflash-draft-info
Nov 10, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
cl-npuperf/1780-dflash-draft-info

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #1780. This is the scoped first PR for that issue (its "first step, device-free"), not the whole of it; what remains is listed at the end and on the issue.

What was open

#1780 asks to run the DFlash draft packages as model sources on our runtime. Its thread stopped on two questions about the Bolt draft's container (Nemotron-3.5-Bolt-E4B-DFlash-NPU2, 270,406,816 B, sha256 matching the repo's MANIFEST.sha256):

  1. q_proj is 6,553,600 bytes, which fits both a q4_1 tile grid of 4096 x 2560 and a half-float matrix of 1280 x 2560. The last comment stopped short of a decode rule because of that.
  2. An earlier comment concluded that the 23 tensors labelled BF16 are really F16, because norm gains read "tight" as half-float.

Both are settled here from the bytes, and both go against a new format.

Findings (device-free, this box)

$ tools/dflash_draft_info.py ~/scratch-packs/f2/bolt-dflash
PASS  the weight file parses as a Q4NX table: 64 tensors, 6776-byte header, 270406816 bytes
PASS  all 5 x 7 projections are in the container (absent: none)
PASS  each projection is a whole q4_1 tile grid of the config's N x K: bytes == (N/32)(K/256) x 5120 (35 of 35)
PASS  every tile header is sane: scale finite and positive, minimum finite, over 10240000 32-weight groups (first bad: none)
PASS  minimum/scale sits at the q4_1 signature of a symmetric 16-level group: median per tensor -7.592 .. -7.329 (expected near -7.5)
PASS  dflash.block_size in the container equals config.json's block_size ([8] vs [8])
PASS  dflash.target_layer_ids in the container equals config.json's target_layer_ids ([5, 11, 17, 23, 35] vs [5, 11, 17, 23, 35])
PASS  model.fc.weight is hidden x (taps x hidden) at 2 bytes: 65536000 B == 2560 x 12800 x 2 (declared BF16 [80, 12800, 32])
PASS  22 norm weights are 2 bytes x hidden or x head_dim (off-size: none)
PASS  no tensor is left unexplained (unexplained: none)
19 of 19 checks passed            (3.4 s)
  • The projections are the ordinary Q4NX q4_1 tile grid. I8 [1280, 5120] is 1280 tiles of 5120 bytes, and 1280 = (4096/32) x (2560/256). The same arithmetic gives 320 for k/v (1024 x 2560) and 1600 for gate/up (5120 x 2560) and down (2560 x 5120). That is the form every main-model container already uses.
  • head_dim is 128, as the config and the README say. The half-float reading that suggested 40 is wrong: read that way, 25.7 % of q_proj's values exceed 100 in magnitude and 1.8 % are non-finite. As a tile grid, 1280 x 2560 would also be 400 tiles, not 1280.
  • The BF16 labels are right. The "tight as half-float" pattern is not evidence: it is present in Qwen3-0.6B's main-model container too (input norm 1.49-1.88 as half-float, 0.12-1.05 as bf16), and our forward serves that model correctly with the bf16 reading. Half-float folds bf16's exponent range into one octave, so anything near 1 looks tight.
  • Control for the tile reading: the same header decode on that main-model container gives minimum/scale -7.41; the draft gives -7.59 .. -7.33.

So importing a DFlash draft is a config-schema translation. Nothing in the container needs a new reader.

The change

  • tools/dflash_draft_info.py (new, stdlib only): checks a draft package against its own config and prints a census (--json). It reads tile headers only, so the 0.27 GB package takes seconds. It fails closed.
  • --selftest: a synthetic package plus eight negative controls, each failing by name: a projection one tile short, a head_dim that disagrees with the stored tiles, one NaN scale, a plain half-float matrix of the same byte count, a block size that differs from the config, an unexplained tensor, a non-dflash config.
  • CMakeLists.txt: ctest row tool_selftest_dflash_draft_info.
  • docs/amd-drafters.md: the answers above, and what is missing.
$ python3 tools/dflash_draft_info.py --selftest      # 13 PASS, rc 0
$ python3 tools/ci/tests_referenced_check.py --check --root .
tests_referenced: all 76 test script(s) are referenced ...; 22 tool self-test(s) are registered
$ python3 tools/copyright.py --check                 # clean

What remains on #1780

  1. The draft's forward. Fuse the target's hidden states at target_layer_ids through fc, run five non-causal layers over a block of block_size mask positions, score with the target's embedding table and head. forward-serve stops at the config schema today (key 'model' not found).
  2. The target-side taps: the main model's hidden states at five layers, which needs the main package (Nemotron-3.5-Bolt-E4B-NPU2) on the box.
  3. The second package (Qwen3.8-27B-DFlash2-NPU2, 1.4 GB) is a different architecture family with selector and convolution tensors. This tool reports those as unexplained rather than guessing; it was not downloaded (the disk is under its floor).
  4. The verify hookup and the measured pair row, which depend on npu: speculative decode on the NPU -- a draft proposes up to 7 tokens and the NPU verifies them in one pass (FLM 1.0.12 DFlash parity, F2+F3) #1781.

🤖 Generated with Claude Code

…package is in forms we already read (engine#1780)

A DFlash draft package (FastFlowLM 1.0.12's two-package tags) is config.json (model_type dflash) plus one Q4NX
container. The thread on #1780 stopped at two byte-identical readings of the projections (q4_1 4096x2560 against a
half-float 1280x2560) and at a claim that the BF16 labels are really F16. Both are settled from the bytes:

- the I8 [tiles, 5120] projections are the ordinary q4_1 tile grid: all 35 have bytes == (N/32)(K/256) x 5120 for the
  config's N x K, 10,240,000 groups have a finite positive scale, and minimum/scale is -7.59 .. -7.33 per tensor (a
  main-model container our forward serves reads -7.41 the same way). The half-float reading has 25.7 % of values above
  100 in magnitude. So head_dim is 128, as the config says.
- the BF16 labels are bf16: the 'tight as half-float' pattern is also present in a main model that runs correctly with
  the bf16 reading, because half-float folds bf16's exponent range into one octave.

tools/dflash_draft_info.py checks a package against its own config (tile-grid sizes by role, tile-header sanity, the
container's scalars, fc and norm sizes, no unexplained tensor) and fails closed; 19 of 19 on the Bolt draft in 3.4 s.
--selftest builds a synthetic package with eight negative controls (ctest tool_selftest_dflash_draft_info). Stdlib only.

docs/amd-drafters.md records the answers and what is actually missing: the draft's forward, not a format.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at 0f0033a

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: APPROVE at 0f0033a

tools/dflash_draft_info as "the gate before a DFlash draft loader" is the right thing to build first: a draft package is only useful if its shape matches what the loader will ask for, and a gate that answers that question before a load is cheaper than a load that fails or, worse, succeeds and drafts nonsense.

Two asks:

  1. Both directions, as arms. A gate that refuses everything passes any refusal test; the pair that matters is a package that should pass (a well-formed draft) and one that should be refused, with the refusal naming the field it objected to. Tonight's #1872/#1877 pattern: the message is the product, and the negative arm is the test.
  2. What the gate does not know. If a property of the draft can only be checked at load time (tensor shapes against the target, vocab/tokenizer agreement), say so in the body — a gate whose silence is read as "the package is fine" is precisely the "answered zeros instead of a refusal" shape that #1875 and #1918 spent the night on.

Same praise as #1958: the "first step, device-free, and here is what remains" framing is what makes a 390-line PR reviewable on its rules rather than on an unavailable device.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 0f0033a — ran it, and the negatives are the part that makes it a gate rather than a report.

python3 tools/dflash_draft_info.py --selftest          -> 10 arms PASS, rc 0
[PASS] a well-formed synthetic package passes every check (19 checks, failing: [])
[PASS] its census lists the 7 projections with minimum/scale -7.5
[PASS] a projection one tile short of its config shape FAILS by name
[PASS] a config whose head_dim disagrees with the stored tiles FAILS (the geometry is checked, not assumed)
[PASS] one NaN scale in one tile header FAILS
[PASS] a plain half-float matrix of the same byte count does not pass as a tile grid
[PASS] a block size that differs between container and config FAILS
[PASS] a tensor this tool does not know (a second draft family) FAILS as unexplained
[PASS] a config that is not a dflash draft is refused at the first check

The fourth-from-last arm is the one I look for in a tool like this: "a plain half-float matrix of the same byte count does not pass as a tile grid". A gate that only checks shapes will happily accept a reinterpretation of the bytes as
something else — that arm is the false-positive control, and it is what separates "the container labels them I8 [tiles, 5120], so I read them that way" from "I checked they are a tile grid". The NaN scale in one tile header and
head_dim disagrees with the stored tiles arms cover the same class from the other side.

It answers the three questions the docstring poses from the bytes (every projection in a form the reader decodes; the draft's geometry agreeing with its own config; and the census of what is actually in there), and it refuses at the first
check
when the config is not a dflash draft at all — which is the behaviour that stops a half-written reader from being pointed at the wrong package.

Registered, which is the trap this class of PR keeps falling into: add_python_test(tool_selftest_dflash_draft_info … --selftest), so the row runs under find_package(Python3)'s interpreter rule from #1864 and appears in the ctest table.

And it is the right order of work: "before a forward for it is written" is exactly when a container's format questions should be settled, because the alternative is writing a kernel against a shape nobody verified and finding out from
a served answer. The docs/amd-drafters.md section says what the tool does not settle (it is not a perf row and not an acceptance row — #1953's tool is), which keeps the two from being conflated.

Nothing here needs a model, a device or a ticket: the package is synthetic, and the negative controls are generated rather than fetched.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 0f0033a — tools/dflash_draft_info (+3 CMake-free, docs +28): a gate before a DFlash draft is loaded is the right order, because a draft that does not match the main model's vocabulary produces plausible tokens and a slow, confusing failure later. The docs naming the check is what makes it a gate rather than a script.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 0f0033a

The ambiguity is real, and I confirmed the arithmetic that creates it: at N=4096, K=2560 the q4_1 grid is 128 × 10 = 1280 tiles of 5120 B = 6,553,600, and the f16 matrix reading 1280 × 2560 × 2 = 6,553,600. Exactly equal, so no size argument can decide it — which is why the two checks that do decide are the substance of this tool:

  • the grid matches the config's own N and K for 35 of 35 projections (bytes == (N/32)(K/256) × 5120). That is falsifiable in a way the size comparison is not: a wrong reading would have to coincide with the config's dimensions and land on a whole tile count for every tensor.
  • the minimum/scale signature: median −7.592 … −7.329 across the tensors, against ~−7.5 expected for a symmetric 16-level group. That is the check that separates "these are real q4_1 codes" from "these are f16 bytes read as codes", because a reinterpretation would put the median wherever the mantissa bits fall.

Both are stated as measured distributions over ten million groups rather than as a single spot value, and the tile-header sanity pass (scale finite and positive, minimum finite, first bad: none) covers the third way a container can lie — a plausible grid of garbage.

Your #1901 convention is already in place: add_python_test(tool_selftest_dflash_draft_info …) registers the --selftest, which is exactly what the sweep added this morning now requires of any tool that advertises one. Good to see it adopted without a reminder.

Two notes for the follow-up, not requests. First, the −7.5 signature is a property of the family's quantiser, so it belongs in the doc text next to the rule, not only in this run's output — the next pack from a different exporter should be checked against it deliberately rather than rediscovered. Second, the tool's PASS lines are excellent evidence and are cheap: if it grows a --json summary the same way check_q4nx_dir.py has one, the follow-up PR (the loader) can assert these facts in CI instead of quoting a terminal.

Scoping is right too: "the scoped first PR for that issue, not the whole of it", with the remainder listed — that is how a gate stays a gate.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT at 0f0033a: READY

The gate is the right shape and it registers its own self-test — add_python_test(tool_selftest_dflash_draft_info … --selftest) in CMakeLists.txt, which is exactly the rule engine#1901 added after tools shipped --self-test flags nothing ran. That is the tidy half of this PR.

One thing I want to flag rather than block, because my own earlier position is now out of date: the docs sentence names a vendor build — "FastFlowLM 1.0.12 ships speculative decode as two-package tags…" — in docs/amd-drafters.md, i.e. public text in this repo. I posted CHANGES on #1799 for exactly that. But #1799 now carries a READY from the owner at dc9d7a7 and still cites "FLM 1.0.12" four times, so the current position on that rule is not the one I applied this afternoon, and I am not going to enforce a superseded reading against a current verdict.

If the rule is still meant to hold, this sentence and #1799's four are the same edit and should be fixed together; if it has been relaxed deliberately, then this PR is fine as it stands. Either way it is a documentation question, not a correctness one, and the tool plus its gate are good.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 0f0033a — ran it on the real package on this box (258 MB, ~/scratch-packs/f2/bolt-dflash): 19 of 19 checks pass, and the two open questions on #1780 are answered by discriminating checks, not by size coincidences.

Question 1 (q_proj 6,553,600 B fits both a q4_1 tile grid and an f16 matrix) — answered twice over:

  • each projection is a whole q4_1 tile grid: bytes == (N/32)(K/256) x 5120 (35 of 35) — a byte identity against the config's own geometry;
  • minimum/scale sits at the q4_1 signature of a symmetric 16-level group: median per tensor -7.592 .. -7.329 — the content agrees with the interpretation, which the byte count alone cannot show.

And the tool carries the counter-arm that makes those two meaningful: a plain half-float matrix of the same byte count does not pass as a tile grid (an f16 fill substituted into one projection, rejected by the signature check). Without it, "fits a tile grid" would be the same statement as "fits f16" and the question would still be open.

The negative controls are the review's main question, and they are all there: a head_dim that disagrees with the stored tiles fails (geometry checked, not assumed), one NaN scale in one tile header fails, a block size that differs between container and config fails, and a stray tensor (model.selector.weight) fails rather than being tolerated. A tool that only prints PASS lines cannot be trusted to gate a loader; this one is self-testing on that basis (22 tool self-test(s) are registered, per its own CI line).

One gap I want named rather than implied: F16 vs BF16 is still not discriminated for the 2-byte tensors. The checks for model.fc.weight and the 22 norms are sizes (hidden x taps*hidden x 2, 2 x hidden or 2 x head_dim) and the declared dtype is printed alongside — but both interpretations are 2 bytes, so nothing here separates them. The thread's earlier conclusion that the BF16 labels are really F16 is therefore not confirmed by this tool; it is simply not contradicted. That matters because the first consumer will unpack those bytes, and picking the wrong one produces a plausible-but-wrong projection rather than an error. If the answer is known from elsewhere, cite it in the doc; if it is not, one exponent-range check on a norm weight (bf16 exponent bits land in a different band than f16's for the same value) would settle it cheaply here rather than at the forward.

Two smaller notes:

  1. dflash.embedding_scale compares 50.59644317626953 against 50.59644 at a 1e-6 tolerance — that is a float32 round-trip, and the comment should say so, because a future reader will wonder why a value is compared to a shorter literal at a tight tolerance.
  2. What remains on npu: run the DFlash draft packages as model sources on our runtime (W8-F2, drafts half) #1780 is honestly listed (the fused forward, the target-side taps, the second package, the verify hookup) and the second package is called out as a different family rather than assumed to work. That is the difference between a gate and a wish.

Scope of my check, stated: ran the tool at this head against the one package on this box; read the checks and the counter-arms. Did not load the container in the engine, which is what the next step (the forward) will do.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 0f0033a: READY — a gate before a drafter is adopted, which is the right place to put the question

The framing is what makes this valuable: it is a gate in front of a decision, not a component of it. "The gate before a DFlash draft package is used as a model source" means the tool answers is this package the thing it claims to be before any runtime path is written against it — so an unsuitable package is refused while the cost is a tool run rather than a traced model that produces subtly wrong drafts.

Scoping to #1780's "first step, device-free" and listing the residue is the honest cut, and it matches the pattern that has worked all night (#1920 → #1923 → #1927 → #1930's vision chain, #1931's front-end before the route): establish what the artifact is from the artifact, then build the thing that consumes it. Doing it in the other order makes every artifact problem look like a runtime bug, and on this box that costs a device ticket to discover.

What I would check in dflash_draft_info, in order:

  1. It must report the things that decide fitness, not just identity — vocab size and tokenizer agreement with the target are the two that quietly ruin a drafter (a drafter whose vocab differs produces drafts the target cannot score, and acceptance collapses to zero, which looks like "the drafter is bad"). If the tool prints only name/size/architecture, the gate cannot answer its own question.
  2. It must be able to say "cannot decide" — a package missing a config field, or with an unreadable head, is a third outcome; conflating it with "unsuitable" makes the tool lie in the safe direction and hides the real problem (the same issue I raised on tools: fconst19_prescreen.py -- decide whether a twin-diff rule row is possible without downloading either pack #1828's prescreen, where undecidable paths fell through to return 0).
  3. Determinism and no network: the gate must read the local package only, so it can run in CI and inside a ticket. A tool that needs HF metadata is not a gate in front of a device run.

And the one thing I would want in the body before this lands: what the gate rejects, with one concrete example. A gate whose rejection criteria are described as "checks the metadata" cannot be reviewed; naming one package it refuses and why makes both the criterion and its first user visible.

docs/amd-drafters.md being updated is right — a gate's criteria belong in the doc the next drafter author will read, not only in the tool.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 07caded — re-verdicted at the new head, which is a merge of main, not a change to the tool.

git diff origin/main...07caded8   -> 3 files, +390     (docs/amd-drafters.md, tools/dflash_draft_info.py, CMakeLists.txt)
git diff 0f0033a8...07caded8      -> 32 files, +3114   (that is main arriving: #1958, #1960, …)
python3 tools/dflash_draft_info.py --selftest -> PASS on every arm, including the last three

So the substance is what I reviewed at 0f0033a8: ten selftest arms, of which the one I would keep is "a plain half-float matrix of the same byte count does not pass as a tile grid" — the false-positive control that separates "the container labels them I8 [tiles, 5120], so I read them that way" from "I checked". The others pin the refusals that matter: a projection one tile short of its config shape fails by name, a head_dim that disagrees with the stored tiles fails, a single NaN scale fails, a differing block size fails, an unknown tensor fails as unexplained, and a non-dflash config is refused at the first check.

Re-ran the selftest at this head rather than assuming the merge was inert — the two refusals I quote (a block size that differs between container and config FAILS, a config that is not a dflash draft is refused at the first check) are from that run.

One note that applies to merges of main generally: the head is now a merge commit, so a squash-merge message will not describe the 32 files git diff shows between the two heads — which is exactly the confusion I had to resolve by hand on #1878 earlier today. A rebase onto main gives the same diff without it.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 07caded

A device-free gate before a draft loader is the right first step, and the scoping says so: "This is the scoped first PR for that issue (its 'first step, device-free'), not the whole of it; what remains is listed at the end and on the issue."

What a dflash_draft_info buys before any loader exists: a DFlash draft package's usability is decided by facts that are all readable from the files — the draft's shape against the target's, its vocabulary and rope against the target's, and the block/window parameters it declares. Establishing those as a report means the loader is written against a known-good package, rather than discovering a mismatch through a wrong answer after a load.

And it is the same instrument this family has converged on all week: a header-only or file-only reader that answers "what does this pack claim about itself", used before anything touches a device — npu-pack info's section offsets (#1975), the container census (#1786), and the npu_model_bounds arms. A draft loader that skipped this step would be the one component in the chain whose inputs were never checked.

One thing worth pinning in the report while it is cheap: the draft's vocabulary against the target's, explicitly, because a draft with a different vocab can pass every shape check and still sample from a different distribution — which is the acceptance-rule failure mode #1958 just made testable on the sampler side.

No asks.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 07caded — the two questions #1780 stopped on are settled by evidence that discriminates, and I checked the arithmetic that makes the second one decisive rather than merely plausible.

The q_proj question, which is the one worth reading twice

"both readings are 6,553,600 bytes, but the half-float one has 25.7 % of its values above 100 in magnitude and 1.8 % non-finite, and 1280 x 2560 as a tile grid would be 400 tiles, not 1280" — all three clauses check out:

4096x2560 tile grid → (4096/32)*(2560/256) = 1280 tiles × 5120 B = 6,553,600 B   ✓ matches the container
1280x2560 tile grid →   40*10            =  400 tiles × 5120 B = 2,048,000 B    ✗ would not match
1280x2560 as half   → 1280*2560*2                             = 6,553,600 B      ✓ also matches

So the byte count genuinely cannot decide it — two hypotheses give the identical number — and the doc says so instead of pretending the size settled it. What does decide it is the tile count in the container's own label plus the value distribution, and those are two independent arguments pointing the same way. That is the shape of evidence I want in a doc that closes a thread.

The bf16 question, and the control that makes it an answer

"half-float folds bf16's exponent range into one octave, so 'tight' proves nothing" — and the control is a main-model container the forward already serves correctly, showing the same tight-as-f16 / wide-as-bf16 pattern (1.49-1.88 against 0.12-1.05). A mislabel diagnosis that contradicts an earlier comment needs exactly this: not a better argument, but a case that already works.

Ran the tool

python3 tools/dflash_draft_info.py --selftest   → rc 0, 13 PASS lines
  PASS  a block size that differs between container and config FAILS
  PASS  a tensor this tool does not know (a second draft family) FAILS as unexplained
  PASS  a config that is not a dflash draft is refused at the first check
python3 tools/dflash_draft_info.py /nonexistent → rc 2 (usage)
python3 tools/dflash_draft_info.py              → rc 2 (usage)

Both refusal paths return non-zero, which matters: a gate that returns 0 on a bad path is a gate a script believes. (My first reading of that was rc 0 — I had piped through tail and was reading the pipe's status, not the tool's. F31 again; re-ran without the pipe.)

Registration and gates

add_python_test(tool_selftest_dflash_draft_info ${CMAKE_SOURCE_DIR}/tools/dflash_draft_info.py --selftest) — the helper, so it runs through the interpreter and is not exec-bit dependent. On a real tree:

ci_targets_check --check         rc 0   9 targets never reach ctest (baseline 9), none new
tests_referenced_check --check   rc 0   all 77 test script(s) are referenced (76 → 77)
python_test_order_check --check  rc 0

The doc's own conclusion is the right size

"So importing a DFlash draft is a config-schema translation, not a format project. What our runtime does not have is the draft's forward", with the exact stop (forward-serve fails on key 'model' not found, i.e. the schema, before any of it) and the remaining work itemised — fuse at target_layer_ids, five non-causal layers over block_size masked positions, score with the target's own embedding table. That is a scoped PR that says what it did not do, which is why the Refs #1780 is honest rather than coy.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 07caded — and I ran the self-test rather than reading it: 13 PASS, rc 0, no device and no package needed.

$ python3 tools/dflash_draft_info.py --selftest
PASS  tile arithmetic: 4096x2560 is 1280 tiles, 1024x2560 is 320, 5120x2560 and 2560x5120 are both 1600
PASS  a 1280x2560 reading of the same 1280-tile tensor would be 400 tiles, not 1280
PASS  a shape that does not fill whole tiles has no tile count
PASS  a well-formed synthetic package passes every check (19 checks, failing: [])
PASS  a projection one tile short of its config shape FAILS by name
PASS  a config whose head_dim disagrees with the stored tiles FAILS (the geometry is checked, not assumed)
PASS  one NaN scale in one tile header FAILS
PASS  a plain half-float matrix of the same byte count does not pass as a tile grid
PASS  a block size that differs between container and config FAILS
PASS  a tensor this tool does not know (a second draft family) FAILS as unexplained
PASS  a config that is not a dflash draft is refused at the first check

The two arms that decide whether this is a gate or a rubber stamp are the ones the doc's table calls out, and they are both in the self-test: a
half-float matrix of the same byte count must not pass as a tile grid
(so "the BF16 labels are really F16" is settled by the tool, not by hope) and a
1280×2560 reading of a 1280-tile tensor would be 400 tiles
(so q_proj is 4096×2560 and head_dim is 128 as the config says). Each failure is by name,
including "a tensor this tool does not know … FAILS as unexplained", which means a second draft family is refused rather than half-imported.

Registered as a ctest row (add_python_test(tool_selftest_dflash_draft_info … --selftest)) and stdlib-only, so it runs in CI with no pack — which is the
opposite of the environment-gated tests I flagged on #1931 an hour ago.

What makes the PR worth merging beyond the tool is the shape of it: a gate written before the loader, whose doc answers six specific questions with the
evidence the tool prints (projection shape, bf16-vs-f16, container-vs-config agreement, and "is anything unexplained" → 64 tensors accounted for, 35
projections + 22 norms + model.fc.weight 2560×12800 as the fusion of five). That is what stops the loader from being written against a guess.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 07caded

A scoped first PR that says what it is and what it is not, which is the discipline this issue needs: "its 'first step, device-free', not the whole of it; what remains is listed at the end and on the issue." #1780 asks to run the DFlash draft packages as model sources, and its thread had stopped on two open questions about the Bolt draft's contents — a gate that answers "can this draft even be described" before anyone spends a device ticket is exactly the right first step, and +390/−0 means nothing existing changed behaviour.

What I would want confirmed before merge, since a gate is only useful if its negative case fires:

  1. a package that fails each check gives a distinct, named refusal — "not a draft", "draft with an unsupported container", "draft whose head geometry disagrees with its config". Four outcomes behind one exit code is how a gate becomes a rubber stamp, and I have asked the same of two other tools tonight (#1828's prescreen, #1967's census classifier);
  2. the two open questions from the issue are answered in the tool's output, not just in the report — the point of a gate is that the next lane does not re-derive them. If the answers are prose in the PR body only, the tool will be run again by someone who was not here.

Nothing else. The +390/−0 shape is right for a first step, the scope is declared, and listing what remains on the issue rather than in the PR is where it should live — a PR body is not a tracking board.

One thing worth keeping in the record: this is the fourth cheap-gate-in-front-of-an-expensive-step tonight (fconst19_prescreen, the family cost model, check-geometry.sh, and now dflash_draft_info). The pattern holds — the expensive step costs time and a ticket, not correctness, so a host-side answer to "is this even shaped like the thing we can run" pays for itself the first time it says no.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 07caded — two open questions settled from the bytes, and one of the checks is a statistical fingerprint rather than a spot check.

What makes it convincing. The thread stopped on two ambiguities that a "reasonable assumption" would have answered wrongly:

  1. q_proj at 6,553,600 bytes fits both a q4_1 tile grid (4096×2560) and an f16 matrix (1280×2560) — settled not by convention but by requiring that every projection be a whole q4_1 tile grid of the config's N×K ("35 of 35") and that every tile header be sane (scale finite and positive, minimum finite) over 10,240,000 32-weight groups;
  2. the 23 tensors labelled BF16 — settled by size (2 bytes × hidden) and by the quantiser's own signature: minimum/scale median −7.592 … −7.329 per tensor, against the −7.5 a symmetric 16-level group implies.

That second one is the strong kind of evidence: a distribution over ten million groups landing where the format says it should is very hard to fake by accident, and it is the same method as #1878's per-layout table and #1874's signed-nibble hunt — let the artefact say what it is.

And both findings go against introducing a new format, which is the outcome a reader should be able to trust because the checks were built to fail (the header and signature checks would reject a non-q4_1 container regardless of the labels).

Two asks.

  1. Is the −7.5 signature discriminating? What tolerance does minimum/scale use, and would a different symmetric 4-bit family also land near −7.5? If the check would pass for any symmetric 16-level quantiser, that is fine — but then it proves "symmetric 4-bit", not "q4_1", and the tile-grid check is what proves the latter. Naming which check proves what makes the 9 PASS lines readable as a chain rather than a list.
  2. The scope's next step: "this is the scoped first PR for that issue, not the whole of it; what remains is listed at the end and on the issue." Since both questions resolved against a new format, the remaining work is the draft's forward path on a standard container — worth saying whether that is unblocked by this PR or still needs something else, because that is the question the next taker will ask first.

Not run: I did not run dflash_draft_info.py against the pack (the file lives on the box but the check is I/O-only; I read the output as given). The review is of what each PASS proves and of the chain between them.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 07caded (carried from 0f0033a). Checked rather than assumed: git log --no-merges 0f0033a..d1957 --not origin/main returns 0 commits, so the +3114 delta is entirely main merged in and the patch I reviewed is unchanged.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: APPROVE at 07caded

Re-verdict at the merge-of-main head; the delta is the merge and the substance from 0f0033a is unchanged: tools/dflash_draft_info as the gate in front of a DFlash draft loader, device-free, with what remains stated rather than implied.

The two asks from that review are what make the gate worth having, and a merge is the right moment to have them in the body rather than a thread:

  1. Both directions as arms — a well-formed draft that passes, and one that is refused with the offending field named. A gate that refuses everything satisfies a refusal test; the message is the product (#1872, #1877).
  2. What the gate cannot know — any property only decidable at load (tensor shapes against the target, vocab/tokenizer agreement). A gate whose silence is read as approval is the "answered zeros instead of a refusal" shape this night has produced in #1875, #1918 and #1966.

One addition specific to a tool that will be run by hand: what does it print when the package is fine? "No findings" and "I could not check" must be different lines, for the same reason.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 07caded8

I ran it rather than reading it — the pack is on this box — and then I broke things to see whether the checks are live. They are, on both axes, and it doubles as a gate.

On the real container (~/scratch-packs/f2/bolt-dflash, 270 406 816 B):

19 of 19 checks passed          exit 0

including the two checks that settle #1780's questions: each projection is a whole q4_1 tile grid of the config's N x K: bytes == (N/32)(K/256) x 5120 (35 of 35) — so the 6 553 600-byte q_proj is a 4096×2560 grid, not a 1280×2560 half-float matrix — and minimum/scale sits at the q4_1 signature of a symmetric 16-level group: median per tensor -7.592 .. -7.329, which is what makes the 23 BF16-labelled tensors' question answerable from bytes instead of from how "tight" the gains look.

Mutation 1 — the config comparison is live. sliding_window 1024 → 512 in a copy's config.json:

FAIL  dflash.sliding_window in the container equals config.json's sliding_window ([1024] vs [512])
18 of 19 checks passed          exit 1

Mutation 2 — the tile-header check is live, and it names the tensor. 4096 zero bytes written at offset 135 203 408 of the weight file:

FAIL  every tile header is sane: scale finite and positive, minimum finite, over 10240000 32-weight groups
      (first bad: model.layers.1.self_attn.o_proj.weight)

That second one is the reason this PR's conclusion is trustworthy rather than plausible: a 4-bit misreading of a tensor does not produce a sane scale/minimum distribution, and the tool notices when one group does not.

It is a gate, not just a report: exit 1 on any failure, 0 on success, checked both ways above.

And the ambiguity is now a self-test arm, which is the part I would have asked for if it were missing — --selftest (13 arms, exit 0) includes:

PASS  a plain half-float matrix of the same byte count does not pass as a tile grid
PASS  a 1280x2560 reading of the same 1280-tile tensor would be 400 tiles, not 1280
PASS  a block size that differs between container and config FAILS
PASS  a tensor this tool does not know (a second draft family) FAILS as unexplained

So the exact question that stopped the #1780 thread — could this be f16? — is now a failing test rather than a paragraph of reasoning.

What it does not claim, and correctly does not: every comparison is container ↔ the config.json beside it, so this catches a mismatched pair or an unsupported family, not "these weights are the right weights". The MANIFEST.sha256 check in the README covers provenance, and the sha256 of the file is stated in the PR body as matching — that is a separate trust step and is not what these 19 checks are for.

Residual, for the follow-up rather than this PR: the run is 3.4 s over 10.24 M groups, so it is cheap enough to run before every load; wiring it in as a pre-load refusal (rather than a tool a human remembers) is the natural next step, and #1780 already lists the rest.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 07caded — checked the three things this PR is most likely to get wrong, and all three hold.

1. The new tool's self-test is registered, through the guarded helper, after find_package. add_python_test(tool_selftest_dflash_draft_info … tools/dflash_draft_info.py --selftest) at CMakeLists.txt:325, below the find_package(Python3) at :291, so it runs through the interpreter rather than through the
file's exec bit. The repository's own ordering guard agrees on this head:

$ python3 tests/python_test_order_check.py --check --root .
ok: every ${Python3_EXECUTABLE} use follows find_package(Python3)          rc=0

2. My own sweep for unregistered tool self-tests — merged into main this morning — passes on this tree, which is the first time it has been exercised by a new
tool rather than the five retrofits it was written for (tools/ci/tests_referenced_check.py):

$ python3 tools/ci/tests_referenced_check.py --check --root .      rc=0

3. The self-test runs and its negative controls are real. --selftest → rc 0, and the last lines name what fails rather than what passes:

PASS  a block size that differs between container and config FAILS
PASS  a tensor this tool does not know (a second draft family) FAILS as unexplained
PASS  a config that is not a dflash draft is refused at the first check

The one thing my own automated scan flagged as a suspect turned out to be correct, and it is worth saying so because it looks like the shape I dinged #1930 for:

    try:
        hdr, base, size = read_header(wpath)
    except (ValueError, json.JSONDecodeError) as e:
        check(False, f"the weight file parses as a Q4NX table: {e}")
        return checks, census
    …
    check(True, f"the weight file parses as a Q4NX table: {len(names)} tensors, …")

That check(True, …) is the success side of a try/except whose failure side records check(False, …) and returns, so it can fail; the #1930 one sat after a call that
was never observed at all. A scan hit is a prompt to read, not a finding.

Scope note, since the body is honest about it: this is the gate and the report, not the loader — "what remains" is listed on the issue, and nothing here claims
otherwise. Device-free by construction (--selftest builds a synthetic package), so a verdict needs no npu-hold.

bong-water-water-bong added a commit that referenced this pull request Nov 11, 2026
…n the middle pinned (refs #1781; supersedes #2059) (#2196)

* npu: the speculative round's seam and bookkeeping, host-tested (engine#1781 item 1, part 1)

#1781's remaining list has three items; this is the first half of item 1 (the wiring), scoped on the issue:

  app/spec_decode.h        DraftStep + DraftProvider: what a drafter hands the verifier (up to k ids and its own
                           q at each, k x V row-major), and decide(): p_rows + q_rows + drafts -> the tokens a round
                           emits, the count accepted, and where the chain stopped.
  tests/spec_decode_test.cpp  seven arms, all hand-computed: partial acceptance, stop-at-first-rejection, full
                           acceptance, q(x)=0 rejecting instead of dividing, the no-drafter path, and uniforms outside
                           [0,1) refused rather than clamped.

It builds on what is already merged rather than repeating it: the rule is npu/mtp/spec_accept.h (#1958) and p is already
per-row because app/forward_serve.cpp's begin_verify()/step_batch_all() returns M x NV logits -- the source calls that
'the speculative verifier's call'. The existing per-row argmax there IS the rule's temperature-0 case, so the loop
substitution is one line, and it stays untouched until a DraftProvider exists (#1780/#1957): arm (v) pins that decide()
refuses with no drafts, which is the condition the loop keys on, so the no-drafter path cannot change.

decide() takes its two uniforms from the caller on purpose. Drawing inside it -- per position, or from a fresh
generator -- would follow p in distribution but not in stream, and the acceptance rate this prints beside tok/s would
then not be reproducible.

Found while writing the arms: my own first fixture proposed token 1 on the row where p/q = 7, so it accepted and arm
(ii) failed. The arm was right and the fixture was wrong; the comment records it.

* npu: give spec_decode_test the repo root on its include path, so CI can find spec_accept.h

CI run 42935896650 failed with exactly the risk the PR body named:

  app/spec_decode.h:36:10: fatal error: npu/mtp/spec_accept.h: No such file or directory

app/spec_decode.h includes that header by its repo-relative path, which is how every other consumer of it spells it, and
the target's include path had only app/. Adding ${CMAKE_SOURCE_DIR} fixes it without changing the header's spelling.

'fatal error' rather than a warning is the useful failure here: the arms and the header were right, the build path was not,
and a standalone g++ call had passed only because I happened to pass -I. by hand.

* spec_decode_test: a rejection in the middle of three drafts ends the round (refs #1781)

Review of 030070e: arm (ii) rejects at its last draft, so a decide() whose
break was a continue passed all seven arms. Arm (ii-b) puts the rejection
second of three; that mutant now fails. Also states in spec_decode.h that the
caller owns the sample after a fully-accepted round.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: pi-rlk <pi-rlk@1.local>
Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
bong-water-water-bong added a commit that referenced this pull request Nov 11, 2026
… residues recognised (engine#1786, F1 slice 3a) (#2097)

* npu-pack: a container's roles from names alone, with the two measured residues recognised

engine#1786 (item F1), slice 3a -- independent of slice 2 (#2070), since it needs only the
container reader that slice 1 (#2054, merged) put in main.

`container_roles(tensors)` maps each tensor name to a role (embed, lm_head, q/k/v/qkv/o_proj,
gate/up/down/gate_up_proj, input/post/final norm, q/k norm, vision, audio) and returns the names
nothing matched in `unassigned`.

Measured before writing it (engine#1786, headers only, 30 packs): **21 of 30 classify fully by name
alone**, and the entire residue is two families -- `model.layers.N.linear_attn.ssm_*` and
`model.mtp.*`. Both HF and the alternate spellings (`attn_q`, `wq`, `ffn_gate`) resolve, and so do
the biases. So this is a rule, not a per-pack table.

The two residues are RECOGNISED (`ssm`, `mtp`) rather than reported as unknown, because
`unassigned` is only useful if it means "nothing knows this": the SSM block is a forward question
(its projections have no transformer analogue, so assigning roles buys nothing until the forward
uses them) and `model.mtp.*` is a draft head, not a model -- the same packages that carry no
tokenizer.json, which #1957's gate exists to state up front.

What a role does NOT establish, stated in the header because a reader will assume otherwise: the
byte layout of the tensor, or whether it is stored transposed. A name says which projection;
`graph_weights` (present on 13 of the packs) and the pack's own `dims` are what check the rest.

Arms: a transformer pack classifies completely and the top-level norm is not confused with a layer
norm; the alternate spellings land on the same roles; a `q_proj.bias` keeps its projection's role
and `qkv_proj` is not classified as `q_proj`; the SSM and MTP families are recognised and counted
under their own names; an unknown tensor is listed by name and no role is invented for it.

* npu-pack: the two dead needle rules and the shortconv misclassification (engine#2097 review)

Answers the CHANGES at 9c2d5b6, all three asks:

1. `attn_q` is a prefix of `attn_q_norm`, so with the projection rules first the q_norm rule's own
   `attn_q_norm` needle could never fire -- every per-head q norm was filed as `q_proj` (same for k). The norm
   rules now come first.
2. `in_proj` alone is not attention: LFM2's `shortconv.in_proj.weight` was counted as `qkv_proj` on a real pack
   (10 layers), which is a wrong role the residue count cannot see. shortconv gets its own role from model.h's
   `shortconv_in_proj_weight`, and the attention needle now requires `attn.in_proj`.
3. Three arms pin both cases, plus the un-suffixed spellings still being projections.

Mutation-checked: restoring the old order turns "roles: attn_q_norm is a norm, not q_proj" red. Full suite green.

---------

Co-authored-by: t <t@e.com>
Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
bong-water-water-bong added a commit that referenced this pull request Nov 12, 2026
…gnised (engine#1786, F1 slice 3b) (#2099)

* npu-pack: a container's roles from names alone, with the two measured residues recognised

engine#1786 (item F1), slice 3a -- independent of slice 2 (#2070), since it needs only the
container reader that slice 1 (#2054, merged) put in main.

`container_roles(tensors)` maps each tensor name to a role (embed, lm_head, q/k/v/qkv/o_proj,
gate/up/down/gate_up_proj, input/post/final norm, q/k norm, vision, audio) and returns the names
nothing matched in `unassigned`.

Measured before writing it (engine#1786, headers only, 30 packs): **21 of 30 classify fully by name
alone**, and the entire residue is two families -- `model.layers.N.linear_attn.ssm_*` and
`model.mtp.*`. Both HF and the alternate spellings (`attn_q`, `wq`, `ffn_gate`) resolve, and so do
the biases. So this is a rule, not a per-pack table.

The two residues are RECOGNISED (`ssm`, `mtp`) rather than reported as unknown, because
`unassigned` is only useful if it means "nothing knows this": the SSM block is a forward question
(its projections have no transformer analogue, so assigning roles buys nothing until the forward
uses them) and `model.mtp.*` is a draft head, not a model -- the same packages that carry no
tokenizer.json, which #1957's gate exists to state up front.

What a role does NOT establish, stated in the header because a reader will assume otherwise: the
byte layout of the tensor, or whether it is stored transposed. A name says which projection;
`graph_weights` (present on 13 of the packs) and the pack's own `dims` are what check the rest.

Arms: a transformer pack classifies completely and the top-level norm is not confused with a layer
norm; the alternate spellings land on the same roles; a `q_proj.bias` keeps its projection's role
and `qkv_proj` is not classified as `q_proj`; the SSM and MTP families are recognised and counted
under their own names; an unknown tensor is listed by name and no role is invented for it.

* npu-pack: plan() reports the pack's roles, and names what nothing recognised

engine#1786 (item F1), slice 3b -- stacked on slice 3a (#2097), which added container_roles().

Plan gains container_role_counts and container_roles_unassigned, filled in the container arm, and
container_note now states the role coverage: "every tensor has a known role", or
"N of M tensors have no known role (first: <name>)".

The point is the shape of the statement. An empty `unassigned` says the rule covers the pack -- a
fact about the pack, produced by a rule that was measured on 30 of them before it was written. A
non-empty one names the tensor rather than counting it, because the next question after "some
tensor is unknown" is always "which one".

Arms: the roles reach the plan and an empty `unassigned` is reported as coverage; a pack whose
tensors all classify says so in the note.

Still not importable: with geometry (slice 2) and roles (this) both on the plan, what remains is
mapping roles to the forward's weight slots -- which is the SLICE that needs the per-role kernel
arithmetic, not more container work.

---------

Co-authored-by: t <t@e.com>
Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant