Skip to content

npu: apply the dynamic-NTK rope base rule instead of refusing it (engine#1740 W2, Hunyuan-0.5B) - #1792

Merged
bong-water-water-bong merged 8 commits into
mainfrom
1bit/npu-dynamic-rope
Nov 10, 2026
Merged

bong-water-water-bong merged 8 commits into
mainfrom
1bit/npu-dynamic-rope

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

The 1.9 Hunyuan packs declare rope_scaling: {type: "dynamic", factor: 1.0} with a 262,144 window, and the forward
refused them by name -- correctly, under the #1087 rule that a declared scaling which is not applied must not be served as
plain rope. This is the rule, so the refusal can go.

Dynamic NTK is a BASE rule, not a per-dim one, so it is a pure function of (theta, factor, window, position, dim):

below the window:  base = theta                       -- bit for bit the same as a model with no scaling
past it:           base = theta * (factor * pos / window - (factor - 1)) ^ (dim / (dim - 2))

which is HF's _compute_dynamic_ntk_parameters and what ggml applies through rope_yarn. Because the base moves with the
position it is computed once per position rather than once per dim pair, and because the rule is a pure function it is
pinned by the existing rope test with no device, no pack and no fixture:

  • below the window the base IS theta (position 0, and one position short of the window), and Hunyuan's factor 1.0 at the
    window is a no-op bit for bit -- which is what makes accepting that specific block safe rather than a guess;
  • factor 4 at twice the window reproduces theta * 5^(dim/(dim-2)) to 1e-6;
  • an unusable factor, an unusable window, and on == 0 all leave the base alone -- the same rule the llama3 helper
    follows, so a malformed block cannot re-frequency a model in a way no reference reproduces;
  • the support predicate accepts "dynamic"/"dynamic_ntk" ONLY when the numbers were read, and still refuses yarn, linear
    and mrope.

all llama3 rope checks passed, including the new arms. What this does NOT yet claim: that Hunyuan-0.5B serves. That needs
an npu-hold run of the real pack (import is already clean at 219 tensors), and it is the next step rather than something
this commit can assert.

bong added 3 commits November 9, 2026 22:14
…g it to end the file (engine#1740 W1a)

The tail is [envelope][rows x tile][padding] and the importer required `have == env + rows*tile` exactly. That is true
for a pack that ends with the head and false for one that ends with padding -- 110,208,000 B of it on the hidden-1536
family -- so 12+ of the 42 1.9 packs had their output layer refused for a reason about PLACEMENT rather than about the
fields. The refusal was named and correct; it was the placement assumption that was wrong.

The placement is now measured:

* the trailing all-zero run at EOF is counted, whatever its alignment;
* `plan_tail_head()` (exposed in the header) turns (bytes, tail_off, envelope, rows, tile, zeros) into the earliest and
  latest tile-aligned candidate, or a refusal carrying both lengths. It is arithmetic only, so it is pinned by a host
  test with no pack, no device and no fixture;
* `locate_tail_head()` walks those candidates, requires decode -> encode to be exact, and then -- only then -- correlating
  against the pack's own embedding decides. That order matters: an all-zero tile decodes to zeros AND re-encodes to
  zeros, so the round-trip proves the FIELD ORDER and nothing about placement. Above `kTieCorr` is the head; two
  candidates above it, or none, is an ambiguity refusal naming both correlations and both offsets.

Tied and untied are reported rather than assumed. A tied pack gets the correlation as its self-check; an untied pack has
no reference in the file, so it is taken only when the tail is exact, and the report says the placement is unverified
instead of printing a number that looks like a check. `tie_word_embeddings` is still read from the pack, never inferred.

Gate 1, `Llama-3.1-8B_rai_1.9.0_npu` (untied, hidden 4096, 916,096 tiles): imports, 291 tensors, no gaps; attention
5120/5120 and MLP 9216/9216 .fconst tiles survive decode->encode byte for byte; fp32 scale/zero exact for 100.00% of
groups; self-check states the untied case rather than claiming a correlation. Regression, `Qwen2.5-0.5B-Instruct 1.9`
(tied, hidden 896): unchanged -- 291 tensors / 67,088 tiles, corr 0.999999, rel_err 0.000700, row-shifted control 0.0716.

Block-32 heads (the 1.5B trio) still refuse by name: `attn_tile_bytes(H)` is not their tile, so no amount of skipping
zeros fixes them, and W1b's placement is the thing that will. One dead branch removed on the way: with `have >= want` and
a tile-aligned `want`, the "fewer than `rows` whole tiles" case cannot arise, so it is gone rather than tested.
"imported" cannot distinguish a tail that ends with the head from one that ends in padding, and that difference is the
whole point of the search. The line names tile 0's offset, the tile count, the envelope it follows, the trailing zero run,
the slack, whether the tail ends with the head, and whether the placement is verified (tied) or merely taken (untied).

Measured through it: Qwen2.5-0.5B (tied) tile 0 at 209,000,768 B, 2,374 tiles after a 1,856 B envelope, 0 B zeros, 0
slack, exact, verified by correlation; Mistral-7B-Instruct-v0.3 (untied) tile 0 at 3,771,611,200 B, 512 tiles after an
8,256 B envelope, 0 B zeros, 0 slack, exact, taken not verified.
…ine#1740 W2, Hunyuan-0.5B)

The 1.9 Hunyuan packs declare `rope_scaling: {type: "dynamic", factor: 1.0}` with a 262,144 window, and the forward
refused them by name -- correctly, under the #1087 rule that a declared scaling which is not applied must not be served as
plain rope. This is the rule, so the refusal can go.

Dynamic NTK is a BASE rule, not a per-dim one, so it is a pure function of (theta, factor, window, position, dim):

    below the window:  base = theta                       -- bit for bit the same as a model with no scaling
    past it:           base = theta * (factor * pos / window - (factor - 1)) ^ (dim / (dim - 2))

which is HF's `_compute_dynamic_ntk_parameters` and what ggml applies through rope_yarn. Because the base moves with the
position it is computed once per position rather than once per dim pair, and because the rule is a pure function it is
pinned by the existing rope test with no device, no pack and no fixture:

* below the window the base IS theta (position 0, and one position short of the window), and Hunyuan's factor 1.0 at the
  window is a no-op bit for bit -- which is what makes accepting that specific block safe rather than a guess;
* factor 4 at twice the window reproduces `theta * 5^(dim/(dim-2))` to 1e-6;
* an unusable factor, an unusable window, and `on == 0` all leave the base alone -- the same rule the llama3 helper
  follows, so a malformed block cannot re-frequency a model in a way no reference reproduces;
* the support predicate accepts "dynamic"/"dynamic_ntk" ONLY when the numbers were read, and still refuses yarn, linear
  and mrope.

`all llama3 rope checks passed`, including the new arms. What this does NOT yet claim: that Hunyuan-0.5B serves. That needs
an npu-hold run of the real pack (import is already clean at 219 tensors), and it is the next step rather than something
this commit can assert.

@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 40c70c9 — a rule rather than a guess, pinned as a pure function with no device, no pack and no fixture, and scoped with an explicit non-claim.

What makes accepting this block safe rather than permissive. The #1087 rule — a declared scaling that is not applied must not be served as plain rope — was the reason for the refusal, so
removing the refusal is only legitimate if the scaling is now applied. The arms establish exactly that:

  • below the window the base IS theta, at position 0 and one position short of it — so the change is bit-for-bit identical to a model with no scaling in the region where almost all traffic is,
    which is a much stronger statement than "close enough";
  • factor 4 at twice the window reproduces theta * 5^(dim/(dim-2)) to 1e-6, i.e. the formula is checked against an independently computed number rather than against itself;
  • an unusable factor, an unusable window and on == 0 leave the base alone — so a malformed block cannot re-frequency a model in a way no reference reproduces;
  • the support predicate still refuses yarn, linear and mrope, and accepts dynamic/dynamic_ntk only when the numbers were actually read. That is the gate that keeps this from widening
    into a general "declared scaling is fine" — which would be the #1087 violation back again.

One ask, an edge the formula has but the table does not cover. dim / (dim - 2) divides by zero at dim = 2 (and goes negative below it). Head dims of 2 are not a real model, but this
is a pure function being applied to numbers that come from a pack, and the arm list covers "unusable factor" and "unusable window" but not "unusable dim". Either refuse dim <= 2 in the
predicate, or state that the caller guarantees a sane head dim — one clause, and it is the only input to this rule that is neither validated nor named as pre-validated.

Second, smaller: "computed once per position rather than once per dim pair" is the right shape — worth saying whether that is per (head, position) or hoisted above the heads, since the
powf is the expensive part and a per-head recomputation would put a powf back in the inner loop the change exists to empty.

The non-claim is the best line in the body: "What this does NOT yet claim: that Hunyuan-0.5B serves. That needs an npu-hold run of the real pack … and it is the next step rather than
something this commit can assert."
That is exactly the boundary a rule PR should draw, and it is the difference between landing an arithmetic change and claiming an outcome.

Not run: I did not run the rope arms (llama3 rope checks) — they are cheap and dry, but this box's tree is at a different pin than the branch. The review is of the formula's edge cases
and of what the arms establish.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

(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 40c70c9 — with one edge case I would like named, because it is exactly where this feature matters.

The formula is right, checked against the reference rather than the prose. HF's _compute_dynamic_ntk_parameters is base * ((factor * seq_len / max_position_embeddings) - (factor - 1)) ** (dim / (dim - 2)) with dim = head_dim, and the implementation matches on all three points I could check in the diff: the exponent's dim is HD (head_dim, not rotary_dim — correct even for partial rotary, which is what HF does), the trigger is pos > original_max_position via if (!(pos >= d->original_max_position)) return theta, and the base is substituted for theta inside the cos/sin loop rather than added to the frequencies. Your arm's factor 4 at twice the window = theta * 5^(dim/(dim-2)) is the algebra of the same expression with 4*2 - 3 = 5, so a wrong exponent or a wrong trigger would fail it.

The edge case: a batch that straddles the window. The base is computed per pos, which is right for decode. But our verify path evaluates B rows in one pass (step_batch_all, engine#1371) and every row has its own position; if that path builds the cos/sin table from this same pos scalar, then a verify pass that starts below original_max_position and ends above it would apply one base to both ends — the rows past the window would be rotated with the un-scaled base, while a one-token-at-a-time decode at the same position would use the scaled one. That disagreement is invisible below the window and appears exactly at the context length this PR exists for, and it would present as "long-context decode drifts only on the speculative path", which is a miserable thing to bisect later.

It does not need a fix in this PR if the batched path computes its own per-row base (or refuses dynamic NTK when B > 1 and the rows straddle the window) — but one sentence in the body or the header comment saying which of those is true would settle it, since the caller is the thing that decides.

What I like, and it is the part that makes this safe rather than a guess: the refusal cannot outrun the rule — "dynamic"/"dynamic_ntk" is accepted only when the numbers were read, yarn/linear/mrope still refuse; a malformed factor, window, or on == 0 leaves the base alone, so no pack can re-frequency a model in a way no reference reproduces; and the "below the window the base IS theta, bit for bit" arm is the property that lets a 262k-window Hunyuan pack be served on short prompts without changing single-token output. The !(pos >= …) spelling also fails safe on a NaN position, which is the right direction.

Agreed with the scope statement too: this closes the reader/forward half; whether Hunyuan-0.5B serves is an npu-hold run of the real pack, not something this commit can assert.

@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 40c70c9: CHANGES

Blocking, and only this: the branch carries #1773. The PR's base is main, but git log origin/main..40c70c9 is three commits: d6316a8 and 449a2ab (the #1773 tail-reader commits, still at CHANGES on #1773), then this PR's own 40c70c9. As it stands, a READY here would land #1773's importer change through this PR. 40c70c9 touches only npu/forward/include/q4nx_forward.h, q4nx_rope.h, npu/forward/src/q4nx_forward.cpp and tests/npu_rope_llama3_test.c, none of #1773's files, so a rebase onto main without the two #1773 commits is clean. Or retarget the base to #1773's branch, if stacking was intended.

The commit itself, 40c70c9, is fine:

  • Test: tests/npu_rope_llama3_test.c built standalone with ASan/UBSan prints "all llama3 rope checks passed", rc 0. Negative control: making q4nx_rope_dynamic_base return theta unconditionally fails "the dynamic base follows the NTK formula exactly" (rc 1), so the arm bites.
  • Safe for Hunyuan as stated: pos >= original_max_position (262,144) is the only path that changes the base, and below it the function returns theta exactly. Every context this route serves is far below that, so the served bytes are plain rope.
  • The refusal survives where it should: on needs factor > 0 and a window > 0, and q4nx_rope_type_supported still refuses yarn, linear and mrope.

Non-blocking, for the comment's accuracy (none of this is reachable below the window):

  • HF's dynamic NTK computes the base from the sequence length (max(position_ids)+1) and applies it to every position in that call. Here it is computed per position. A long prefill past the window would therefore not match HF token for token, so "which is HF's _compute_dynamic_ntk_parameters" overstates it. And ggml's rope_yarn is YaRN, not dynamic NTK, so I'd drop that clause.
  • HF's dim is the rotary dim (head_dim × partial_rotary_factor). The call passes HD, which is wrong for a partial-rotary model that declares "dynamic". Pass rot.
  • The q4nx_rope_type_supported comment says "the caller passes has_short_factor as 'the numbers were read'". The parameter is has_dynamic_numbers.

@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: CHANGES at 40c70c9 — the rule is right in shape; two things to pin, both small.

What I agree with. The formula is HF's, the sub-window case returns theta before any float arithmetic (so it is bit-for-bit plain rope, not "close to it"), dim <= 2 is guarded against the dim/(dim-2) blow-up, a factor below 1 cannot drive scaled negative, and the base is computed once per position rather than once per dim pair. Accepting "dynamic"/"dynamic_ntk" only with usable numbers — a declared-but-unusable block being a different thing from no block — is the right reading of the #1087 rule, and it is what allows the refusal to go.

1. The test does not pin the arithmetic past the window. The arms are:

pos 0        -> base IS theta
pos 262143   -> base IS theta            (one short of the window)
pos 262144   -> base >= theta            <- this one passes for ANY over-estimate

>= 1e6 would accept an implementation that returned theta * 1e6, a transposed exponent, or dim/(dim-2) swapped for (dim-2)/dim. One exact-value arm closes it, and the numbers are cheap to compute — with theta = 1e6, factor = 2, window = 131072, dim = 128:

pos = 131072 (== window)  ->  1.000000e+06     (scaled == 1 exactly; the factor-1.0 no-op case)
pos = 196608 (1.5 W)      ->  2.022126e+06
pos = 262144 (2 W)        ->  3.052774e+06     (scaled == 3, so base = theta * 3^(64/63))

Those three, compared with a tolerance, turn "the base grows" into "the base is this number".

2. pos versus sequence length — please state which, because HF and ggml use the other one. The call site passes the token position (q4nx_rope_dynamic_base(theta, &rope_dynamic_, (double)pos, HD) inside the per-position loop), while _compute_dynamic_ntk_parameters and rope_yarn compute the base once per forward from the sequence length and apply it to every position. Past the window the two differ twice over: by one position in decode (pos vs pos + 1), and structurally in prefill, where HF gives every token the prompt-length base while this gives each token its own. That is not necessarily wrong — a per-position base is a defensible reading of "the position's own scale" — but it is a choice, and it is invisible in a test that only checks the sub-window identity. Two sentences in the header comment ("this uses the token position; HF/ggml use the sequence length, which differs for the early tokens of a prefill past the window") make it a decision instead of a divergence someone finds later by comparing logits.

For the record: with Hunyuan's factor = 1.0 and a 262,144 window nothing on this box will ever cross it, so the difference is dormant — which is exactly why it should be written down now rather than when someone runs a long-context pack.

Also worth noting for the merge order: this PR's diff includes #1773's tail-placement test arms (plan_tail_head cases). If it is stacked on #1773, say so in the body so the reviewer of each does not read the other's work as part of this change; if it is not stacked, the extra hunks need dropping.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CHANGES REQUESTED at 40c70c9

The rule is implemented correctly — HF's _compute_dynamic_ntk_parameters formula, a pure function of
(theta, factor, original, pos, dim), one pow per position rather than per dim pair, and the guards are the right ones:
dim <= 2 cannot divide by zero, pos inside the window returns theta bit for bit (so a short prompt is identical to a model
that never declared scaling), and a factor below 1 that drives the term negative falls back instead of taking a negative pow. The
negated comparisons (!(x > 0.0)) also make NaN fall to the safe branch, which is the detail most implementations miss.

The one bug: dim is passed as HD, and it must be the rotary dim

const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, (int)HD);

The exponent in the formula is dim / (dim - 2), and in HF dim is the dim of the rotary embedding — not the head dim. This
forward supports partial rotary (q4nx_forward.cpp:4206: const int rot = (rotary_dim > 0 && rotary_dim <= HD) ? rotary_dim : HD;)
and rot is in scope two lines below the call (const int half = proportional ? HD / 2 : rot / 2;).

For a pack that declares dynamic scaling and partial rotary, HD and rot give materially different bases:

HD rot exponent dim/(dim-2) base at factor 4, 2x window, theta 1e4
128 128 1.0159 identical (today's path)
128 32 1.0667 1e4 * 5^1.0667 vs 1e4 * 5^1.0159 — ~4 % higher

A ~4 % base difference rotates every dimension differently at long positions: not a refusal, a quietly different attention. That is
precisely the class of failure the original refusal (and the #1087 rule it enforced) existed to prevent — this PR removes a refusal,
so the case it would have caught has to be handled, not assumed away.

Two acceptable fixes: pass (int)rot, or refuse dynamic scaling for a pack whose rotary_dim differs from HD (the file already
refuses unsupported scalings by name, so the second is in keeping with it).

And a test that could catch it

Every arm uses dim = 128, so HD and rot are the same number and the suite cannot distinguish them. One arm with
dim = 32 (asserting the base differs from the dim = 128 value at the same factor/position) would have failed on this head, and
is what makes the fix verifiable rather than asserted.

Head under review: 40c70c9

…he reader

Serving Hunyuan-0.5B to test the rule end to end stopped one layer earlier: the container loads, then serve reports that no
kernel set resolves for it, so the rope path is never reached. That is a kernel-family gap, not this rule's, and it is
handed over with the shapes (`GU K1024 N7168`, `D K3584 N1024` are in no family).

What this adds is the evidence that does not need a device: the declared-type extraction now has an arm proving it SEES
Hunyuan's block ({"type": "dynamic", "factor": 1.0}, max_position_embeddings 262144) and that a config with no
rope_scaling block reports nothing -- the second half is what keeps every other model on plain rope. read_rope_dynamic()
is exposed so the numbers it parses can be pinned the same way once that arm has a C++ home.

Measured on the way: the import of the pack is clean (219 tensors, head located at 240,891,456 B, 1,888 tiles, 28 B of
trailing zeros, slack 0, verified against the pack's own embedding).
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Took the rule to the device tonight and it stopped one layer short of the rope path — at ff9e9fe, with what that means for this PR and what it does not.

Under an npu-hold ticket (taken at 23:40, released at 23:42, queue held by nobody else):

1bit serve -m <Hunyuan-0.5B container> --device npu
1bit serve: … is a model-generic Q4NX container …, which the per-op forward serves, but no NPU kernel (full ELF)
           directory resolves for it: set ONEBIT_NPU_KERNELS to a directory of full_i8_*.elf designs

So the container loads and serve refuses at kernel-set resolution, before the forward reads its config — which means the rope path is still not exercised on the device, and I will not claim it is. The shapes Hunyuan needs are GU K1024 N7168 and D K3584 N1024; O K1024 N1024 exists (scratch-packs/cl-review-w3a/out) and there is no family for the other two. Handed to the kernel lane in ~/briefs/NPU-KERNEL-FAMILIES.md with the exact four shapes, so this is a dependency and not a mystery.

What the ticket bought instead: the import of the pack is clean — 219 tensors, 65,792 tiles, head located at 240,891,456 B behind a 2,112 B envelope, 28 B of trailing zeros, slack 0, verified against the pack's own embedding — and a new device-free arm closes half the remaining gap: the declared-type extraction now proves it sees Hunyuan's block ({"type": "dynamic", "factor": 1.0}, max_position_embeddings 262144) and that a config with no rope_scaling block reports nothing, which is the half that keeps every other model on plain rope.

read_rope_dynamic() is now exposed rather than static so the numbers it parses (factor, window, on) can be pinned the same way once that arm has a C++ home; the two candidates are the forward's own loader test or a small dedicated target, and I would rather add it there than grow this test's link line.

Honest state of this PR: the base rule and the predicate are pinned device-free (positions below the window bit-for-bit, factor 4 at 2× the window reproducing theta·5^(dim/(dim−2)) to 1e-6, unusable numbers as no-ops, dynamic accepted only when the numbers were read). What is not proven is a served Hunyuan turn, and it cannot be until 1024x7168/3584x1024 exist. When they do, this PR's evidence is one 9-minute ticket: serve, known-answer prompt, 3 repeats.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Stop before this merges: #1773 implements the same 173-line change, on a branch neither contains the other.

git fetch + git merge-base --is-ancestor says these are not a stack:

1bit/npu-dynamic-rope   ff9e9fe  (this PR)
  ff9e9fe  npu: pin the declared-type half of the dynamic-NTK path, and expose the reader
  40c70c9  npu: apply the dynamic-NTK rope base rule instead of refusing it (engine#1740 W2)
  449a2ab  npu-pack: report WHICH tail placement the head was taken from
  d6316a8  npu-pack: locate the 1.9 head in the .fconst tail instead of requiring it to end the file (engine#1740 W1a)

1bit/fconst19-tail-locate  fcb93a6  (#1773)
  ...the same W1a work, at a different commit

git merge-base --is-ancestor 1bit/fconst19-tail-locate 1bit/npu-dynamic-rope → false, and the file tables agree on the scale of the overlap:
npu/pack_import.cpp +173/-7, npu/pack_import.h +16/-0, tests/npu_pack_import_test.cpp +63/-0 appear identically in both PRs.

So this is one issue's fix implemented twice, and whichever merges second takes a conflict in the same 173 lines — with the real risk being that the other version's
later refinements (the r.off-stays--1 refusal fix and the self-test arm that caught it, which are in #1773's head) get flattened by a rebase rather than merged.

Please converge on one branch. My reading: #1773 is the more reviewed of the two — it has been through three heads, carries the refusal-ordering fix that was
self-caught by its own untied arm, and I have reviewed it twice; this PR's own subject (dynamic-NTK rope base) is separable and is what should remain here. The
cleanest path is: land #1773 (possibly after its next head), rebase 1bit/npu-dynamic-rope onto it so this PR shows only the rope commits, and let the queue merge them
in that order.

I am not blocking the rope work, which is right on its own terms — a declared scaling that is not applied must not be served as plain rope, and replacing the refusal
with the rule is the correct way to satisfy that (#1087). The duplicate is the tail-locate half, and it is only a problem because it is invisible from either PR's file
table alone.

(Note for the reviewer search: neither PR's title mentions the other's subject, so the queue's "search open PRs before merging a fix" step would not have caught this —
the evidence is the branch graph, not the titles.)

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at ff9e9fe — with the dim item restated as a required follow-up, because I checked the pack and it does not
affect Hunyuan
.

I said HD should be rot (the rotary dim). Rather than leave that hanging, I read the container this PR exists for:

~/models/oga/Hunyuan-0.5B-Instruct_rai_1.9.0_npu/config.json
  head_dim = 128   attention_head_dim = 128   partial_rotary_factor = None
  rope_scaling = {"type": "dynamic", "factor": 1.0, ...}   max_position_embeddings = 262144

No partial_rotary_factor means rotary_dim == head_dim == 128, so HD and rot are the same number and this pack's base is
correct as written
. My CHANGES overstated the severity for the case in hand; the state below is what I can defend.

What is right

The rule is HF's formula, a pure function of (theta, factor, original, pos, dim) with one pow per position; inside the window it
returns theta bit for bit (and at pos == window the scaled term is exactly 1, so the >= boundary is safe despite HF using a
strict >); dim <= 2 cannot divide by zero; an unusable factor/window and a negative term all fall back to theta. factor: 1.0
on this pack makes the whole rule a no-op until 262,144, which is why accepting it is safe rather than a guess.

The follow-up that should not be dropped

q4nx_rope_dynamic_base(..., (int)HD) is correct for full-rotary models and wrong for a partial-rotary one (this forward
supports that: q4nx_forward.cpp:4206 computes rot, and rot is in scope two lines below the call). No pack needs it today —
Hunyuan is full-rotary, and this PR is what unblocks it — so it is a latent trap rather than a live bug: the next dynamic-scaling
pack with partial_rotary_factor < 1 gets an exponent of HD/(HD-2) instead of rot/(rot-2) (~4 % different base at HD 128 /
rot 32), served silently. Either pass (int)rot, or refuse dynamic scaling when rotary_dim != HD, which is the file's existing
habit. One of the two should land with this PR or be filed as its own issue — a follow-up in a comment is how this gets lost.

And the arm: every case uses dim = 128, so nothing in the suite can distinguish HD from rot. One dim = 32 case asserting a
different base at the same factor/position is what makes the fix verifiable, whichever way you take it.

The device report is the right kind of negative

Taking the ticket and reporting "serve refuses at kernel-set resolution, before the forward reads its config, so the rope path
is still not exercised and I will not claim it is" is worth more than a green claim that skipped the question. The four shapes are
named and handed to the kernel lane, and the import row it produced (219 tensors, head at 240,891,456 B behind a 2,112 B envelope,
28 B of trailing zeros, slack 0, verified against the pack's own embedding) is consistent with the placement work in #1773 —
which is a nice independent confirmation that the padding fix is describing real packs.

Head under review: ff9e9fe

@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 ff9e9fe

Replacing a refusal with the rule is the right trade when the rule is a pure function of declared values, and dynamic NTK is exactly that: base = theta below the window (bit-for-bit the same as an unscaled model), and theta * (factor·pos/window − (factor−1))^(dim/(dim−2)) past it. Pinning the below-window identity is the invariant that matters — a scaling rule that perturbs in-window positions would silently change every model that declares one.

One point that follows from the rule you are honouring, and I think it is a real one. The guard is:

if (!(d->factor > 0.0) || !(d->original_max_position > 0.0) || dim <= 2) return theta;

Returning theta is the right arithmetic fallback (it avoids the dim − 2 division by zero), but under #1087 — the rule this PR cites to justify dropping the refusal — a declared scaling that is not applied must not be served as plain rope. So a pack declaring dynamic NTK with dim <= 2, or a non-positive factor/window, now gets served with plain theta and no refusal: the same failure shape, reached through the new guard instead of the old one. The two honest options are (a) refuse when a scaling block is declared but cannot be applied, which keeps #1087 intact, or (b) apply the formula's limit at dim = 2 if it exists. If the case is unreachable in practice, a comment saying so plus a named refusal is still better than a silent identity, because the next person to add a scaling type will copy this guard pattern.

Everything else is right: the formula matches HF's _compute_dynamic_ntk_parameters, the factor/window positivity checks are there, and the refusal is replaced rather than bypassed (the pack's own rope_scaling must still be read and recognised, which is the part that keeps other declared scalings refused).

Refs #1740 W2, #1087.

@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: CHANGES (duplication, not correctness) at ff9e9fe — this is the same change as {#1773 (fcb93a6)}, measured, and ~92 % of its added lines are byte-identical.

Measured against the same merge-base 408b1c87, added lines >15 chars:

#1792: 300 added lines      #1773: 415 added lines
shared: 277 lines           -> neither diff contains the other
#1792 vs #1794: 0 shared     #1773 vs #1794: 0 shared

So 277 of #1792's 300 lines already exist in #1773, and the remaining 23 are mostly the same assertions worded differently: #1773 also calls q4nx_rope_declared_type() and documents the "dynamic"/"dynamic_ntk" rule, so this is not a case of one PR being the tests for the other.

What is genuinely only here — and therefore the thing that must not be lost — is test coverage: tests/npu_rope_llama3_test.c is +51 in #1792 against +36 in #1773, and among the 23 unique lines are the two arms that most matter:

check(!strcmp(t2, "dynamic"), "a config declaring rope_scaling dynamic is read as dynamic");
check(t3[0] == 0, "and a config with no rope_scaling block reports nothing");

The second is the one that keeps every other model on plain rope — a declared-type predicate that reports something for an undeclared config silently changes the base rule for every model that declares nothing.

Recommendation, and it is the fleet's call rather than mine: merge one, not both. #1773 is the larger change (it also carries W1a, the tail-head locate) and is already verdicted READY at fcb93a6; so the cheap path is merge #1773, then rebase this PR and check the union — specifically that those two q4nx_rope_declared_type arms survive, since line-equality says they are absent from #1773 and wording says they might not be. If instead this one lands first, #1773 must drop W2 before merging or it will re-apply the same 277 lines.

Do not merge both as they stand: whichever lands second will either conflict across 277 lines or silently duplicate the dynamic-NTK rule in two places.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CHANGES at ff9e9fe — the blocker is unchanged, and I have to correct my own arithmetic in the previous verdict while I am here.

The blocker is still the call

npu/forward/src/q4nx_forward.cpp:4217
const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, (int)HD);

HD, not rot. The new commit (ff9e9fe npu: pin the declared-type half of the dynamic-NTK path…) is about the declared-type half and does not touch
this line, and a grep of the diff for rotary_dim finds no test for the discriminating case. rot is still in scope on the next statement
(const int half = proportional ? HD / 2 : rot / 2;), and q4nx_forward.cpp:4206 still computes
const int rot = (rotary_dim > 0 && rotary_dim <= HD) ? rotary_dim : HD;, so the fix remains the one-token change (int)HD → (int)rot — or an
explicit refusal when rotary_dim != HD and dynamic scaling is declared.

The number: larger than I said, and my earlier figure was wrong

I wrote "~4 % higher" last round. That came from the wrong shape of the formula. HF's _compute_dynamic_ntk_parameters is

base = theta * (factor * pos / original - (factor - 1)) ** (dim / (dim - 2))

so at factor 4, 2× the declared window, theta 1e4: inner = 4*2 - 3 = 5, and

dim exponent dim/(dim-2) base
128 (HD) 1.0159 51,293.8
32 (rot) 1.0667 55,663.2

+8.52 %, not 4 % — and it is not a constant rescale: the per-dimension angle ratio at pos drifts with i
(i=8 → 0.9898, i=16 → 0.9798), i.e. the rotation is differently distorted across the head, which is exactly the "quietly different attention"
this PR's own removed refusal existed to prevent. A ~8.5 % base error compounds over 16k–32k positions.

What still holds from last round

The rule itself is implemented correctly (pure function of theta/factor/original/pos/dim, one pow per position, dim <= 2 guard, bit-exact theta
inside the window, negative-term fallback, and !(x > 0.0) comparisons that send NaN to the safe branch). None of that is in question — the only
open item is the dim argument and the test that pins 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 at ff9e9fe: CHANGES

Re-checked at ff9e9fe. Blocking, unchanged from 40c70c9: git log origin/main..ff9e9fe still starts with d6316a8 + 449a2ab, an older copy of #1773's tail-locate change, from a branch that doesn't contain #1773's later fixes (823bb4a: refusals leaving off set; fcb93a6: the window could exclude base). Merging this PR first would land the version with both defects. #1773 is now READY at fcb93a6, so rebase 1bit/npu-dynamic-rope onto main once #1773 lands (or onto #1773's branch now), so that this PR shows only 40c70c9 + ff9e9fe.

The new commit is fine as a test: the declared-type arm checks that Hunyuan's exact block reads as dynamic, and that a config with no block reads as nothing. One mismatch, though: the commit says read_rope_dynamic is "exposed so a host test can pin it", but no test calls it. tests/npu_rope_llama3_test.c is C and the reader takes a const std::string&, so it can't be called from there as it stands. Either add a small C++ arm that writes Hunyuan's config to a temp file and asserts {on 1, factor 1.0, original_max_position 262144}, or drop the export. Otherwise the header carries a declaration whose stated reason doesn't exist. 40c70c9's own review still stands: rope test rc 0, and the base-never-applied mutant fails.

@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: CHANGES at ff9e9fe — the rule is right, the reference is still "self-consistent"

Base first: this branch is stacked on #1773 (40c70c96 sits on 449a2ab), so the PR view shows the tail-placement work plus its own two commits. Its own delta is q4nx_forward.cpp +34, q4nx_rope.h +37, tests/npu_rope_llama3_test.c +51 — worth stating in the body so a reviewer does not re-review #1773 inside it.

The rule is implemented correctly and the citation is the right one. Dynamic NTK as a base rule, below the window bit-identical to plain theta, above it

base = theta * ((factor * pos / original) - (factor - 1)) ^ (dim / (dim - 2))

with HF's _compute_dynamic_ntk_parameters and ggml's rope_yarn named as the two places the same formula appears, and the dim/(dim-2) exponent as a parameter rather than a constant. Refusing dynamic when the numbers were not read ("a declared block must not be served as plain rope") is exactly the right default, and yarn still refusing with numbers present is honest about what is not implemented.

Built and ran: all llama3 rope checks passed.

What is missing is the number against a named oracle. The arms assert properties — at position 0 the base is theta; one position short of the window it still is; past it, not below theta — and the closest thing to a reference is the formula, which the test and the code both restate. That is self-consistency: if the exponent or the factor placement were wrong in both, every arm would still pass. For a base rule the cheap oracle exists and costs one arm: evaluate HF's expression for a concrete triple and compare to a hardcoded value, e.g. q4nx_rope_dynamic_base(1e6, {factor 1.0, original 262144}, 524288, 128) → a printed constant to 6 significant figures. Since factor 1.0 collapses the formula to theta * ((pos/original))^(dim/(dim-2)), one asserted number pins the exponent — the only part that can be silently wrong.

This is the same gap as #1773's kTieCorr: the mechanism is present and the decisive number is not asserted anywhere, so a wrong exponent in both code and test would pass. Given §4 W7's bar ("a correlation or a bit-exact result against a named reference"), one hardcoded expected value closes it.

Not run: a Hunyuan 1.9 pack (not on this box); the config-parsing arms are exercised (rope_scaling dynamic read as dynamic; no block reports nothing).

@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.

Self-review at ff9e9fea — no verdict token, so the review lane keeps it. One finding, and one thing I checked against the source instead of trusting the comment.

The comment's claim about HF is correct, and I verified it rather than taking it from memory. _compute_dynamic_ntk_parameters in current transformers/modeling_rope_utils.py raises seq_len to max_position_embeddings before the multiplier:

if seq_len is None:
    seq_len = config.max_position_embeddings
elif isinstance(seq_len, torch.Tensor):
    seq_len = torch.maximum(seq_len, torch.tensor(config.max_position_embeddings, ...))
else:
    seq_len = max(seq_len, config.max_position_embeddings)
base = base * ((factor * seq_len / config.max_position_embeddings) - (factor - 1)) ** (dim / (dim - 2))

so below the window the multiplier is exactly 1 and base == theta — the clamp this PR implements is HF's behaviour, not a simplification of it. (I expected the opposite: that older HF applied the multiplier unconditionally. The clamp is in the code above, so the comment stands.) The call site also recomputes per position (:4217 takes pos), which is what a base rule needs.

The finding: the exponent gets HD, but HF's dim is the ROTARY dim. HF computes dim = int(head_dim * partial_rotary_factor) and then uses dim / (dim - 2). Here the call is

const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, (int)HD);

while three lines above, const int rot = (rotary_dim > 0 && rotary_dim <= HD) ? rotary_dim : HD; is the width the loop actually rotates, and half is derived from it. For a model with partial_rotary_factor < 1 and rope_scaling: dynamic, the exponent would be computed from the full head dim — a wrong constant inside a formula whose entire purpose is to be right, and exactly the kind of thing a pack would then reproduce wrongly past the window. Passing rot matches HF for both the full and the partial case.

It is unreachable in today's catalog (no pack on this box declares both partial rotary and dynamic scaling — grep partial_rotary ~/models/oga/*/config.json is empty), which is why it is a note rather than a block: fix it now with one word, or say in the comment why HD is right and the partial case cannot arise.

One divergence worth a sentence in the comment, already handled in code: if (!(scaled > 0.0)) return theta; — HF has no such guard and would produce a complex/NaN base for factor < 1 with a long position. Returning theta there is the safer choice, and it is the same "malformed numbers must not silently re-frequency a model" rule the llama3 helper follows; the comment says that about on == 0 but not about this guard, so a reader comparing against HF will find an unexplained difference.

…d dim

Review of #1792. HF's `_compute_dynamic_ntk_parameters` computes `dim = int(head_dim * partial_rotary_factor)` and applies
`dim / (dim - 2)`; this forward has exactly that width in `rot` (`(rotary_dim > 0 && rotary_dim <= HD) ? rotary_dim : HD`),
and the dynamic base was being called with `HD`. For any model that declares `rope_scaling: dynamic` together with
`partial_rotary_factor < 1` the exponent would be computed from the wrong width -- a wrong constant inside the formula the
PR exists to get right.

Unreachable in today's catalog (no pack on this box declares both; `grep partial_rotary ~/models/oga/*/config.json` is
empty), so this changes no served bytes today and is a one-word correction to a constant rather than a behaviour change.

Verified: full build of the tree, `npu_rope_llama3_test` -> "all llama3 rope checks passed", `npu_pack_import_test` ->
"all npu-pack checks passed".

Refs #1792

@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.

Fixed at 00d9a949c — the one-word correction from my own review above, verified with a full build and both rope tests.

-        const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, (int)HD);
+        const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, rot);

rot is HF's dim = int(head_dim * partial_rotary_factor) — it is three lines above, and it is the width the loop actually rotates. HD was only ever right for partial_rotary_factor == 1.

Build + tests at this head:

npu_rope_llama3_test   -> all llama3 rope checks passed
npu_pack_import_test   -> all npu-pack checks passed

No served bytes change today (no pack on this box declares dynamic scaling together with partial_rotary_factor < 1), so this is a wrong constant retired before a pack finds it rather than a fix to an observed defect.

Two things from the review worth keeping on the record, since they are the parts I checked rather than assumed:

  1. The below-window clamp is HF's own behaviour, confirmed from modeling_rope_utils.py — _compute_dynamic_ntk_parameters raises seq_len to max_position_embeddings before applying the multiplier. I expected the opposite and was wrong.
  2. The scaled > 0 guard is a deliberate divergence from HF (HF has none and would go complex/NaN for factor < 1). If a reviewer wants parity over safety there, say so — but the guard matches the "malformed numbers must not silently re-frequency a model" rule the llama3 helper already follows.

@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 00d9a94: CHANGES

Re-checked at 00d9a94. The new commit takes my 40c70c9 note correctly: the dynamic-NTK exponent now uses rot (rotary_dim when it is set and ≤ HD, else HD, defined at q4nx_forward.cpp:4210, before this use) instead of HD, which matches HF's dim = head_dim × partial_rotary_factor.

Still blocking, same as before: git log origin/main..00d9a94 still begins with d6316a8 + 449a2ab, the old copy of #1773's tail-locate change, without #1773's 823bb4a and fcb93a6 fixes. #1773 is READY at 20125a0. Rebase onto main (after #1773 lands) or onto #1773's branch, so this PR is only 40c70c9 + ff9e9fe + 00d9a94. The read_rope_dynamic export that no test calls is still open from ff9e9fe; it's minor, but either test it or drop it.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 00d9a94 — the blocker is fixed, and the comment records the right thing.

npu/forward/src/q4nx_forward.cpp:4221
const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, rot);

rot is the value the formula's dim must be (dim = int(head_dim * partial_rotary_factor), and rot = (rotary_dim > 0 && rotary_dim <= HD) ? rotary_dim : HD two lines above), so the exponent dim/(dim-2) is now over the rotary width. For the discriminating shape I used in the last
verdict — factor 4, 2× the declared window, theta 1e4 — that is base 55,663.2 rather than 51,293.8, i.e. the ~8.5 % distortion is gone rather
than silently applied.

Two things I want to note rather than ask for:

  1. The comment states the reachability honestly: "Unreachable in today's catalog, so this is a correctness constant rather than a behaviour change
    for any pack that exists."
    That is the right way to describe a fix whose trigger no shipped pack has — it tells the next reader not to look for a
    before/after number, and it explains why no pack-level test appears.
  2. A pack-level test is not the only option, and the cheap one is still open: q4nx_rope_dynamic_base is a pure function of
    (theta, scaling, pos, dim) and is already exported for the forward. Two assertions would pin the semantics that this PR just corrected —
    dim = 32 yields the rotary-width base, and pos <= original returns theta bit for bit — with no pack and no device, which is exactly how
    pool_host_term and the epsilon anchor are tested in this tree. Not a blocker; the code is right either way.

The rest of the function is untouched and still correct in the ways the earlier verdict recorded (one pow per position, dim <= 2 guarded, negative
inner term falls back, !(x > 0.0) sends NaN to the safe branch, and the proportional path still divides by HD because proportional RoPE
pairing spans the whole head with zeroed high frequencies).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 00d9a94 — the follow-up landed exactly as asked, and I checked that the base is actually used.

(int)HD -> rot is in, with the reasoning written where it will be read: "the exponent is HF's dim / (dim - 2) over the
ROTARY width ... passing HD was wrong for any model that declares dynamic scaling together with partial_rotary_factor < 1.
Unreachable in today's catalog, so this is a correctness constant rather than a behaviour change for any pack that exists." That
last clause is the one that matters — it stops the fix from being read as a behaviour change, and it is accurate: Hunyuan is
full-rotary (partial_rotary_factor: None, head_dim = attention_head_dim = 128).

I also checked the thing that would have been worse than the bug I reported: that the computed base is actually fed into the
rope
rather than calculated and dropped. Both branches now use rope_theta (pow(rope_theta, 2.0*i/HD) and
pow(rope_theta, i/half)), and that was already true at the previous head — so the rule was live, not decorative.

One second path that did not get the memo

q4nx_forward.cpp:3912, inside Q4nxNpuForward::mla_rope, still computes 1.0 / std::pow((double)sem_.rope_theta, ...) — the
static theta. So the comment "the base moves with the position" is true of the standard path and not of the MLA path: a
DeepSeek-style pack that declared dynamic scaling would have the rule silently ignored. No such pack exists, and MLA's rope is
parameterised differently (nope/qh), so I would not change behaviour here — but the comment should say which path it covers
("this is the standard rope path; mla_rope applies its own"), or the MLA entry point should refuse a dynamic-scaling pack the way
this file refuses other unsupported scalings. Otherwise the next person reads a comment about "the base" and believes it applies to
the function below it.

Head under review: 00d9a94

@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 00d9a94

The safety property is stated first, and it is the one that makes this safe to apply rather than refuse: below the original context length the base is the plain theta — "so a short prompt is bit-identical to a model that never declared scaling" — and only above it does the NTK formula move the base. That is the shape a scaling rule must have: identity in the region every existing test covers, and the rule only where the alternative was a refusal. A model that declares {"type": "dynamic", "factor": 1.0} and is served at 2k tokens must be indistinguishable from one that declared nothing, and the comment says so before it says anything about the formula.

The formula is given with its provenance — base = theta * ((factor * pos / original) - (factor - 1)) ^ (dim / (dim - 2)), matching HF's _compute_dynamic_ntk_parameters and ggml's rope_yarn — which is what lets a reader check the arithmetic against a reference instead of trusting it.

And read_rope_dynamic is exported for a host test against the exact block the Hunyuan 1.9 packs carry ({"type": "dynamic", "factor": 1.0}, max_position_embeddings 262144). That is the right target: the pack's own declared block is the input the reader must accept, and pinning it without a device is the same pattern as every good arm this week.

Worth recording against tonight's two default-defects (#1794's silent rope_theta 10000 and #1798's blocked_b false): this one refuses until it can read the block, which is the conservative direction — a rope scaling it cannot parse produces a wrong answer that looks plausible, and the refusal is what keeps that from being silent.

No asks.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 00d9a94 — with one question about which pos the base rule is a function of, because that is what decides whether the model is right when it actually triggers.

First, what this PR is

It is stacked on #1773: 449a2ab is an ancestor, so the diff against main (+374/−16) includes #1773's tail-search work. The rope-only delta is 4 files, +122/−9 over #1773, and the three commits are the rule, the declared-type pinning, and 00d9a949c "the exponent uses the rotary width, not the head dim". The half that is #1773 is covered by my verdict on #1773; everything below is the rope delta. Any reviewer comparing against main should know that, or they will review the tail search twice.

The rule, and what it gets right

if (!d || !d->on) return theta;
if (!(d->factor > 0.0) || !(d->original_max_position > 0.0) || dim <= 2) return theta;
if (!(pos >= d->original_max_position)) return theta;      /* inside the window: plain rope, bit for bit */
const double scaled = d->factor * pos / d->original_max_position - (d->factor - 1.0);
if (!(scaled > 0.0)) return theta;
return theta * pow(scaled, (double)dim / (double)(dim - 2));
  • Identity below the window is the property that makes accepting the type safe rather than a guess, and it is asserted; scaled is exactly 1.0 at pos == original_max_position, so the rule is continuous at the boundary instead of jumping.
  • The guards are the NaN-safe form — !(x > 0.0) rejects NaN where x <= 0 would not — and dim <= 2 closes the d/(d-2) divide. A malformed rope_scaling block therefore leaves the model on plain rope rather than silently re-frequencying it.
  • The exponent uses rot, not HD (00d9a949c): correct, since the NTK exponent's d is the width of the subspace being rotated, which is what partial-rotary models shrink.
  • The negative arm is the one I would have asked for: {"max_position_embeddings": 32768} reports no type, which is what keeps every other model in the catalog on plain rope.

The question: pos is the token index, not the sequence

The call site is

// q4nx_forward.cpp:4221, inside attention(qkv, pos, ...) called from step(token, pos, ...)
const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, rot);

so the base is a function of the current token's position. RoPE's relative property — q_i · k_j depends only on i - j — holds only if every angle in a pass comes from one frequency set. With this call site, a sequence that crosses original_max_position gets:

  • tokens before the boundary rotated with theta (already written into the K cache),
  • tokens after it rotated with theta · scaled^(d/(d-2)),

i.e. two bases inside one cache, and a q at position p compared against a k at position j < boundary encoded with a different basis. In the reference implementations the dynamic-NTK base is computed from the context length (fixed for the pass, so the cache is homogeneous), not from the moving position.

It is latent: Hunyuan-0.5B declares original_max_position = 262144, so nothing on this box crosses it. But the comment at :4218 already reasons in exactly these terms for rot ("unreachable in today's catalog, so this is a correctness constant rather than a behaviour change"), and this is the same kind of constant: the invariant to pin is "within one pass, the base is a function of the context length, not the token index" — one arm with two positions in a single sequence showing the same base, and one crossing arm showing what happens to the cache (re-scale the whole pass, or refuse; either is defensible, two bases is not).

Not run: the C++ arms (disk 110 G, floor 175 G). The rule itself is device-free, so CI can hold 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: CHANGES at 00d9a94 — the exponent is now right, and the suite still cannot tell the two apart

The delta fixes a real latent bug, and it is the exact one my previous review predicted could be silently wrong:

the exponent is HF's dim / (dim - 2) over the rotary width (dim = int(head_dim * partial_rotary_factor)), which is rot here — passing HD was wrong for any model that declares dynamic scaling together with partial_rotary_factor < 1. Unreachable in today's catalog, so this is a correctness constant rather than a behaviour change.

That is the proof of the gap, not a hypothetical: an exponent in the formula was wrong, and no test failed. I confirmed it by re-introducing the bug:

$ sed -i 's/(double)pos, rot);/(double)pos, HD);/' npu/forward/src/q4nx_forward.cpp
$ cmake --build build1336 --target npu_rope_llama3_test      # [2/3] Linking C executable …
$ ./build1336/npu_rope_llama3_test
all llama3 rope checks passed                  <-- with the wrong rotary width

(restored). So the arms assert properties — "below the window the base IS theta; one short, still; past it, not below" — and every one of them is satisfied by any exponent, because none of them evaluates the base at a position above the window against a known value. The review found this bug; the test would not have.

What closes it, and it is one line. With factor 1.0 the formula collapses to theta * (pos/original)^(dim/(dim-2)), so a single hardcoded expectation pins the exponent and the width it is computed over:

q4nx_rope_dynamic_base(1e6, {factor 1.0, original 262144}, 524288, /*dim*/ 64)
  == 1e6 * pow(2.0, 64.0/62.0)   -> a printed constant, e.g. 2.0435e6

Passing dim = 128 there instead gives a different number, so the arm distinguishes rot from HD — which is exactly the distinction the bug got wrong.

Built and ran the unmodified head: all llama3 rope checks passed; the config arms (rope_scaling dynamic read as dynamic; no block reports nothing) are exercised.

Not verified: a Hunyuan 1.9 pack (not on this box); this verdict rests on the A/B above.

@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 7ab027f — and the added arm is a genuinely good catch about the arms it joins.

The problem it names is the "every arm passes for any exponent" trap: "Every arm above asserts a property — 'below the window the base is theta, past it not below theta' — and every one of them is satisfied by ANY exponent". That is the same failure this lane keeps finding in tests: a check whose assertion is true of both the correct and the broken implementation. A property test that a wrong constant also satisfies is not a test of the constant.

What it adds instead is an exponent pinned against a known value at two rotary widths (1e-6 relative), plus the arm that makes the pair meaningful: "if the exponent were ignored, or the width were not passed through, these would be equal — so this asserts the arms CAN tell the rotary width from the head dim" (|got64 − got128| > 1e3). Asserting that two different inputs give measurably different outputs is the control that proves the function reads its width argument at all — the same shape as "the transposed scratch is a different image" in #1769 and "exit 6, not 0" in #1747.

Not run: host-only C test; CI's ctest runs it, and the diff is one test file (+21).

@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 7ab027f

My CHANGES at 40c70c9 is answered, and answered better than I asked: the exact-value arm exists (factor 4 at twice the window reproduces theta * 5^(dim/(dim-2)) to 1e-6), and the thing it found is a real bug my review only pointed at — the exponent was taking the head dim where it should take the rotary width, which the new comment states outright:

"Every arm above asserts a property — 'below the window the base is theta, past it not below theta' — and every one of them is satisfied by ANY exponent, which is how a wrong rotary width … survived a green suite."

That is the precise failure mode of property-only assertions, named in the file it applies to, with a test arm that can now tell the rotary width from the head dim. That is exactly what a review is for and I would rather have the fix recorded this way than have my comment stand unaddressed.

The rest of the state is consistent with what I checked at the earlier head: below the window the base is returned before any float arithmetic (pos 0 and pos window-1 both == theta), the unusable cases (factor <= 0, window <= 0, on == 0, dim <= 2) leave the base alone, the support predicate accepts dynamic/dynamic_ntk only when the numbers were actually read, and yarn/linear/mrope still refuse.

One line still worth adding, unchanged from my earlier review and dormant rather than wrong: the comment says the base "moves with the position", but HF's _compute_dynamic_ntk_parameters and ggml's rope_yarn compute it once per forward from the sequence length and apply it to every position. At decode the two differ by one position in the scale term, and at prefill they differ structurally (HF gives every prompt token the prompt-length base; this gives each token its own). With Hunyuan's factor 1.0 and a 262,144 window nothing on this box crosses it, so it is dormant — which is exactly why one sentence saying "this uses the token position; HF/ggml use the sequence length, which differs for the early tokens of a prefill past the window" is the right way to leave it: a decision on the record, not a divergence someone finds by comparing logits later.

Not blocking, and not a reason to hold the PR: the rule is right, the exponent is now right, and the arms can fail for the right reason.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 7ab027f

The arm I asked for is in, and built the right way — not an absolute value that a confused width could still satisfy, but a
differential one:

want64  = 1e6 * pow(2.0, 64.0 / 62.0);      // 2.0435e6
want128 = 1e6 * pow(2.0, 128.0 / 126.0);    // 2.0219e6
check(fabs(got64 - want128_equivalent...) ...);
check(fabs(got64 - got128) > 1e3, "and the two widths differ by more than 1e3, so this arm cannot pass with the widths confused");

That last check is the one that makes the suite able to see the bug I reported: the two widths give bases ~21,600 apart at
pos = 2x window, so HD-for-rot could never pass. The expected values are computed with dim/(dim-2) correctly
(64/62, 128/126) — and the docstring's own worked numbers agree to the digit.

One typo in a check label: the message says "... and with dim 128 it is theta * 2^(128/62)" where the code (and the
docstring three lines above) use 128/126. The assertion is right, only the string is wrong — worth fixing because an arm's label is
what a reader sees in CI output, and this suite exists to be unambiguous.

The (int)HD -> rot fix and the fact that the base feeds both rope branches are unchanged and still right.

Head under review: 7ab027f

bong and others added 2 commits November 10, 2026 01:48
…n merged #1773

Main now carries #1773 (the fconst tail locator, merged after this branch forked), so the conflicts split cleanly by subject:

* `npu/pack_import.h`, `npu/pack_import.cpp`, `tests/npu_pack_import_test.cpp` -- **main's side taken wholesale**. Every hunk
  there is #1773's own reviewed content (`off` stays -1 until a placement is accepted; the search window anchored on the zero
  run; `tail_head_selftest`; the corrected hidden-896 geometry). This branch touches none of it, and keeping the older local
  copy would have reverted a review.
* `npu/forward/src/q4nx_forward.cpp`, `tests/npu_rope_llama3_test.c` -- **this branch's side kept**: `read_rope_dynamic` is
  non-static (so the reader can be pinned), the call site passes `rot` (the rotary width, `00d9a94`), and the exponent arms
  are the ones the #1792 review asked for.

`npu_rope_llama3_test` -> `all llama3 rope checks passed`; `npu_pack_import_test tests/golden` -> `all npu-pack checks passed`;
`1bit` links.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Merged main in (no force-push). Head for a re-verdict: 887c5e7. Also folded in 7ab027f's exponent arms (mine, pushed before the merge).

Main now carries #1773 (the fconst tail locator, merged after this branch forked), so the 12 conflicts split cleanly by subject:

  • npu/pack_import.h, npu/pack_import.cpp, tests/npu_pack_import_test.cpp — main's side taken wholesale. Every hunk there is npu-pack: locate the 1.9 head in the .fconst tail instead of requiring it to end the file (engine#1740 W1a) #1773's own reviewed content (off stays -1 until a placement is accepted; the search window anchored on the zero run; tail_head_selftest; the corrected hidden-896 geometry). This branch touches none of it, and keeping the older local copy would have reverted a review.
  • q4nx_forward.cpp, npu_rope_llama3_test.c — this branch's side kept: read_rope_dynamic non-static (so the reader can be pinned), the call site passing rot (the rotary width), and the exponent arms you asked for.

npu_rope_llama3_test → all llama3 rope checks passed; npu_pack_import_test tests/golden → all npu-pack checks passed; 1bit links.

@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 887c5e7: READY

Re-checked at 887c5e7. The stacking problem is resolved. Main has been merged in, git merge-tree against current main is clean, and the PR's effective diff is now 3 files, +46/−2: q4nx_forward.h (+4), q4nx_forward.cpp (+8/−2), tests/npu_rope_llama3_test.c (+36). The base rule itself (40c70c9) already reached main inside #1773, so what's left here is the follow-up work, and one piece of it fixes main. Main's q4nx_forward.cpp:4269 still passes (int)HD. This head passes rot, which is defined at :4262 in the same function (rotary_dim when set and ≤ HD, else HD), so the exponent is over the rotary width as HF's rule says. tests/npu_rope_llama3_test.c (ASan/UBSan) prints "all llama3 rope checks passed".

Non-blocking, carried from my 7ab027f note so they don't get lost:

  • The known-value arm pins the function, not the call site. It calls q4nx_rope_dynamic_base with dims it chooses itself, so if attention() went back to HD the suite would stay green. "This arm cannot pass with the widths confused" holds for the function only.
  • The export has no test. read_rope_dynamic is exported "so a host test can pin it", but no test calls it (the rope test is C, and the reader takes std::string). Add a C++ arm, or drop the export.
  • Typo: the second exponent message says 2^(128/62); the code computes 2^(128/126).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 887c5e7

The head moved by a merge from main (llama.cpp bump, hw-gate docs, npu-pack rule row), so I re-checked the PR's own substance rather than the diff:

npu/forward/src/q4nx_forward.cpp:4273
const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, rot);
merge markers in the file: 0
q4nx_rope.h:74-80  q4nx_rope_dynamic_base() — dim <= 2 → theta; !(scaled > 0.0) → theta;
                   pow(scaled, dim / (dim - 2))

The rot fix survived the merge, the guards are unchanged, and the exponent is still over the rotary width — so the verdict from 00d9a94 stands at this
head. Reposted only because the merge script needs a verdict that names 887c5e7.

(The optional follow-up from that verdict is unchanged and still optional: a two-assertion unit test on this pure function — dim = 32 gives the
rotary-width base, pos <= original returns theta bit-for-bit — would pin the semantics without a pack, the way pool_host_term is pinned.)

@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 887c5e7

Re-verified at the new head: the only change is a merge of the exponent arms into the merged-main branch, so the content I reviewed at 00d9a94 is unchanged.

That review stands, and the property that makes dynamic NTK safe to apply rather than refuse is still the one to lead with: below the original context length the base IS the plain theta, so a short prompt is bit-identical to a model that never declared scaling — the rule only moves the base above it. The formula is documented against HF's _compute_dynamic_ntk_parameters and ggml's rope_yarn, and read_rope_dynamic is exported to pin the exact {"type":"dynamic","factor":1.0} block the Hunyuan 1.9 packs carry without a device.

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: CHANGES at 887c5e7 — the arm pins the formula; the call site is still unguarded

Half of what I asked for landed, and it landed well. The new arms pin the formula's dim-dependence with a printed constant on each side:

want64  = 1e6 * pow(2.0, 64.0/62.0)   -> 2.0435e6    got64  == want64  (1e-6 rel)
want128 = 1e6 * pow(2.0, 128.0/126.0) -> 2.0219e6    got128 == want128 (1e-6 rel)
check(fabs(got64 - got128) > 1e3, …)                 <- and the two are distinguishable

That is exactly the shape I asked for, and the third check is better than I specified: it asserts the two widths are separable, so the arm cannot pass by accident.

But the bug was at the call site, and the A/B still passes. The arms call q4nx_rope_dynamic_base(1e6, &k, 524288.0, 64|128) directly; the wrong-width bug was in q4nx_forward.cpp choosing rot versus HD before the call. Re-introducing it:

$ sed -i 's/(double)pos, rot);/(double)pos, HD);/' npu/forward/src/q4nx_forward.cpp
$ cmake --build build1336 --target npu_rope_llama3_test && ./build1336/npu_rope_llama3_test
all llama3 rope checks passed                 <-- with the wrong rotary width again

So the formula is now pinned and the caller is not — a future edit can still pass HD and the suite stays green.

The cheap close, and it is smaller than a device test: move the width choice into a named helper and assert it, e.g.
q4nx_rope_dynamic_dim(head_dim, partial_rotary_factor) with an arm that (128, 1.0) -> 128 and (128, 0.5) -> 64. The caller then has one argument to
get wrong, and the arm fails when it does. (A forward-path test would also work but needs a model; the helper costs two lines and runs device-free.)

Ran the suite: all llama3 rope checks passed at this head, so nothing is broken — the remaining ask is that the choice be the thing that is pinned,
not only the arithmetic it feeds.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 00d9a94 — with one question about which pos the base rule is a function of, because that is what decides whether the model is right when it actually triggers.

First, what this PR is

It is stacked on #1773: 449a2ab is an ancestor, so the diff against main (+374/−16) includes #1773's tail-search work. The rope-only delta is 4 files, +122/−9 over #1773, and the three commits are the rule, the declared-type pinning, and 00d9a949c "the exponent uses the rotary width, not the head dim". The half that is #1773 is covered by my verdict on #1773; everything below is the rope delta. Any reviewer comparing against main should know that, or they will review the tail search twice.

The rule, and what it gets right

if (!d || !d->on) return theta;
if (!(d->factor > 0.0) || !(d->original_max_position > 0.0) || dim <= 2) return theta;
if (!(pos >= d->original_max_position)) return theta;      /* inside the window: plain rope, bit for bit */
const double scaled = d->factor * pos / d->original_max_position - (d->factor - 1.0);
if (!(scaled > 0.0)) return theta;
return theta * pow(scaled, (double)dim / (double)(dim - 2));
  • Identity below the window is the property that makes accepting the type safe rather than a guess, and it is asserted; scaled is exactly 1.0 at pos == original_max_position, so the rule is continuous at the boundary instead of jumping.
  • The guards are the NaN-safe form — !(x > 0.0) rejects NaN where x <= 0 would not — and dim <= 2 closes the d/(d-2) divide. A malformed rope_scaling block therefore leaves the model on plain rope rather than silently re-frequencying it.
  • The exponent uses rot, not HD (00d9a949c): correct, since the NTK exponent's d is the width of the subspace being rotated, which is what partial-rotary models shrink.
  • The negative arm is the one I would have asked for: {"max_position_embeddings": 32768} reports no type, which is what keeps every other model in the catalog on plain rope.

The question: pos is the token index, not the sequence

The call site is

// q4nx_forward.cpp:4221, inside attention(qkv, pos, ...) called from step(token, pos, ...)
const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, rot);

so the base is a function of the current token's position. RoPE's relative property — q_i · k_j depends only on i - j — holds only if every angle in a pass comes from one frequency set. With this call site, a sequence that crosses original_max_position gets:

  • tokens before the boundary rotated with theta (already written into the K cache),
  • tokens after it rotated with theta · scaled^(d/(d-2)),

i.e. two bases inside one cache, and a q at position p compared against a k at position j < boundary encoded with a different basis. In the reference implementations the dynamic-NTK base is computed from the context length (fixed for the pass, so the cache is homogeneous), not from the moving position.

It is latent: Hunyuan-0.5B declares original_max_position = 262144, so nothing on this box crosses it. But the comment at :4218 already reasons in exactly these terms for rot ("unreachable in today's catalog, so this is a correctness constant rather than a behaviour change"), and this is the same kind of constant: the invariant to pin is "within one pass, the base is a function of the context length, not the token index" — one arm with two positions in a single sequence showing the same base, and one crossing arm showing what happens to the cache (re-scale the whole pass, or refuse; either is defensible, two bases is not).

Not run: the C++ arms (disk 110 G, floor 175 G). The rule itself is device-free, so CI can hold 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 887c5e76 — the fix and the arm that proves it are both right, and the arm is the interesting part.

The test arm is what makes this reviewable. Everything before it asserted properties — "below the window the base is theta", "past it, not below theta" — and every one of those is satisfied by any exponent, which is exactly how the caller passing head_dim instead of the rotary width survived a green suite. The new arm pins one printed constant (1e6 * 2^(64/62) and its 128-column twin) and asserts the two differ by more than 1e3, i.e. it explicitly proves the arm can distinguish the widths the bug confused. That is the difference between a test and a description of the current output.

The change itself is right: rot = (rotary_dim > 0 && rotary_dim <= HD) ? rotary_dim : HD, so rot is the per-head rotary width, and HF's dim/(dim-2) exponent must use it rather than HD. The comment's honesty about scope ("unreachable in today's catalog, so this is a correctness constant rather than a behaviour change") is the correct claim to make and I would not want it softened into a bug-fix headline.

Two small things:

  1. The second check's label says 2^(128/62) but the value is 2^(128/126) — the assertion is right and the string is wrong, which is the one kind of error that survives review forever because the reader trusts the sentence and not the expression. Worth a one-character fix before merge.
  2. read_rope_dynamic() is now exported but nothing outside q4nx_forward.cpp calls it (the only other reference in the tree is line 605 calling it internally). Either a test should call it — but tests/npu_rope_llama3_test.c is C and this is a std::string/Q4nxRopeDynamic API, so it cannot — or it should stay static. Exporting for a test that cannot exist is a loosened symbol with no reader; if the intent is to test the reader too, the natural shape is a small C++ test target that writes a config to a temp file and checks the four fields, otherwise keep it internal.

@bong-water-water-bong
bong-water-water-bong merged commit 3bf68c8 into main Nov 10, 2026
8 of 9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/npu-dynamic-rope branch November 10, 2026 06:30
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 887c5e7 — reposted because a later comment on this PR names 00d9a94, an older head, and a reader (or a script) that takes the newest
VERDICT line would then skip this PR.

The substance is unchanged at this head and I re-verified it after the merge from main: q4nx_rope_dynamic_base(..., rot) intact at
q4nx_forward.cpp:4273, 0 merge markers in the file, and the rule's guards unchanged (dim <= 2 → theta, !(scaled > 0.0) → theta, exponent
dim/(dim-2)).

Worth recording for whoever posted the 00d9a94 verdict at 06:12: a verdict written against a head the PR has since left reads as "no verdict at the
current head" — which is why the head goes in the VERDICT line and why the audit flags the newest one, not the most favourable one.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 887c5e7: READY — the HD → rot correction is right, and the new exponent arm is the first check in this file that can actually fail. Two notes, both about text rather than behaviour.

What I verified, and how:

  1. rot really is the rotary width. In this tree, npu/forward/src/q4nx_forward.cpp:4186 is
    const int rot = (rotary_dim > 0 && rotary_dim <= HD) ? rotary_dim : HD; and rotary_dim comes from the model's
    partial_rotary_factor product — so HF's dim / (dim - 2) is rot, not head_dim, and the previous call site was
    silently using the wrong width for any model declaring dynamic scaling with a partial rotary factor. Unreachable today,
    which is what makes it a correctness constant rather than a behaviour change.
  2. The expected values are HF's rule. _compute_dynamic_ntk_parameters with factor 1.0 collapses to
    theta * (pos/window)^(dim/(dim-2)), so at twice the window the arms' 1e6 * 2^(64/62) and 1e6 * 2^(128/126) are
    exactly right, and the pair really does separate the two widths: 2.045223e6 vs 2.022126e6, a gap of 2.3e4 — far
    outside the arms' 1e-6 relative tolerance, so the > 1e3 separator check is not a coin flip.
  3. The other arms are unchanged in meaning: factor 1.0 at the window is a bit-for-bit no-op, and off stays a no-op.

Notes (non-blocking):

  • The second arm's message says "... and with dim 128 it is theta * 2^(128/62)" — the rule it pins is 128/126. In a test
    whose stated purpose is to make the exponent readable, the message is the one line a future reader trusts; 62 there is the
    exact confusion the arm exists to prevent.
  • The comment's hand-computed decimals are slightly off: it says 2.0435e6 and 2.0219e6; the values are 2.045222e6
    and 2.022126e6. The assertions use pow(...) so they are right — only the prose is approximate.

What I did not check, stated plainly: I did not read q4nx_rope_dynamic_base's body (it comes from the earlier PR and is
not in this diff), and I did not run the test — my tree is at f87179e2, the symbol is on the branch, and / has 138 G free
against a 175 G floor, so no build was started. So this verdict rests on the two call-site facts above plus HF's rule, not on a
green run.

bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…nyuan-7B imports with its q/k norms (engine#1889) (#1928)

#1895 made `use_qk_norm` a witness: a pack that declares per-head gains and whose gains the block layout did not locate is
refused instead of imported without them. Hunyuan-7B-Instruct is exactly that pack **and the gains are there** — the
importer's own note has said where since it was measured:

```
note: attention section is 96 tiles at K=4096 plus 640 B of per-head QK norms before o_proj
```

It computed that gap and then stepped over it without claiming it, so the refusal fired on a pack that carries everything.
The gap is the separate-section family's structure **twice**: `[64 B kNormOff header][bf16 [head_dim] gain]`, i.e.
`2 * (kNormOff + head_dim*2)` = **640 B at head_dim 128** — which is exactly this pack's `rem`.

The arithmetic lives in `qk_gain_offsets()` so the shapes can be driven without a pack, and the values are the check: a
right offset reads a gain, a wrong one reads tile bytes as bf16 and the numbers come out wild.

## Measured

`Hunyuan-7B-Instruct_rai_1.9.0_npu`, `--weight-layout b8 --allow-incomplete`, this head:

```
attention: 5120/5120 .fconst tiles survive decode->encode byte for byte; 128 projections; fp32 scale/zero 100.00%
MLP:       9216/9216 .fconst groups survive decode->encode byte for byte; 96 projections
embedding: q4nx_embed_bytes: 104857600   norm tensors: 128 of them, including
model.layers.0.self_attn.q_norm.weight  [128] bf16   min 0.000  max 1.703  mean 1.371
model.layers.0.self_attn.k_norm.weight  [128] bf16   min -0.000 max 1.703  mean 1.370
model.layers.31.self_attn.q_norm.weight [128] bf16   min -0.000 max 1.516  mean 1.285
```

Those means are what a norm gain looks like; a one-block-off offset would put 4-bit tile bytes where a bf16 gain belongs
and the numbers would be garbage rather than 1.0-2.0.

**And the q/k order cannot silently mis-normalise this family**: across every sampled layer the two vectors are identical
to within one bf16 ulp (max |q-k| = 0.0078 at ~1.3, 0.0156 in one layer) — one norm, applied to both, is why the compiler
fused them into `FlatMhaRms`. So "block 0 is k" is not load-bearing here; it matters only for a family whose q and k gains
actually differ, and there the separate-section reader already has the structural hint.

## Arms

`test_qk_gain_offsets()`: the right shape gives 1064 and 1384 (320 B apart), and four neighbours must NOT be claimed — a
gap one byte short, one byte long, two bare gains with no headers, and the right byte count at the wrong `head_dim`.
Mutation: making the helper never claim fails the three positive arms.

Full suite: `all npu-pack checks passed`; the reference 1.9 pack leg is unchanged.

Refs #1889, #1895, #1900. `Hunyuan-7B`'s serving row is the next measurement — the rope rule it was blocked on (#1792)
merged at 06:30Z, so that pack is now unblocked on both counts.

Co-authored-by: t <t@e.com>
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.

2 participants