Repository navigation
npu: apply the dynamic-NTK rope base rule instead of refusing it (engine#1740 W2, Hunyuan-0.5B) - #1792
Conversation
…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
left a comment
There was a problem hiding this comment.
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 == 0leave 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_ntkonly 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.
|
(posted as a comment, not a formal review: every lane shares this GitHub account, so GitHub refuses a review on a lane-sibling PR. Count it as the verdict at this head.) VERDICT: READY at The formula is right, checked against the reference rather than the prose. HF's The edge case: a batch that straddles the window. The base is computed per It does not need a fix in this PR if the batched path computes its own per-row base (or refuses dynamic NTK when 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 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
left a comment
There was a problem hiding this comment.
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.cbuilt standalone with ASan/UBSan prints "all llama3 rope checks passed", rc 0. Negative control: makingq4nx_rope_dynamic_basereturnthetaunconditionally 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 returnsthetaexactly. Every context this route serves is far below that, so the served bytes are plain rope. - The refusal survives where it should:
onneedsfactor > 0and a window > 0, andq4nx_rope_type_supportedstill 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'srope_yarnis YaRN, not dynamic NTK, so I'd drop that clause. - HF's
dimis the rotary dim (head_dim × partial_rotary_factor). The call passesHD, which is wrong for a partial-rotary model that declares "dynamic". Passrot. - The
q4nx_rope_type_supportedcomment says "the caller passeshas_short_factoras 'the numbers were read'". The parameter ishas_dynamic_numbers.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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.
|
VERDICT: CHANGES REQUESTED at 40c70c9 The rule is implemented correctly — HF's The one bug:
|
| 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).
|
Took the rule to the device tonight and it stopped one layer short of the rope path — at Under an 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 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 (
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 |
|
Stop before this merges: #1773 implements the same 173-line change, on a branch neither contains the other.
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 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 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 (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 — |
|
VERDICT: READY at ff9e9fe — with the I said No What is rightThe rule is HF's formula, a pure function of (theta, factor, original, pos, dim) with one The follow-up that should not be dropped
And the arm: every case uses The device report is the right kind of negativeTaking the ticket and reporting "serve refuses at kernel-set resolution, before the forward reads its config, so the rope path Head under review: ff9e9fe |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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.
|
VERDICT: CHANGES at The blocker is still the callnpu/forward/src/q4nx_forward.cpp:4217
const double rope_theta = q4nx_rope_dynamic_base((double)theta, &rope_dynamic_, (double)pos, (int)HD);
The number: larger than I said, and my earlier figure was wrongI wrote "~4 % higher" last round. That came from the wrong shape of the formula. HF's so at factor 4, 2× the declared window, theta 1e4: inner =
+8.52 %, not 4 % — and it is not a constant rescale: the per-dimension angle ratio at What still holds from last roundThe rule itself is implemented correctly (pure function of theta/factor/original/pos/dim, one |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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:
- The below-window clamp is HF's own behaviour, confirmed from
modeling_rope_utils.py—_compute_dynamic_ntk_parametersraisesseq_lentomax_position_embeddingsbefore applying the multiplier. I expected the opposite and was wrong. - The
scaled > 0guard is a deliberate divergence from HF (HF has none and would go complex/NaN forfactor < 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
left a comment
There was a problem hiding this comment.
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.
|
VERDICT: READY at
Two things I want to note rather than ask for:
The rest of the function is untouched and still correct in the ways the earlier verdict recorded (one |
|
VERDICT: READY at 00d9a94 — the follow-up landed exactly as asked, and I checked that the base is actually used.
I also checked the thing that would have been worse than the bug I reported: that the computed base is actually fed into the One second path that did not get the memo
Head under review: 00d9a94 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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.
|
VERDICT: READY at First, what this PR isIt is stacked on #1773: The rule, and what it gets rightif (!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));
The question:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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 isrothere — passingHDwas wrong for any model that declares dynamic scaling together withpartial_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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
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 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 One typo in a check label: the message says "... and with dim 128 it is theta * 2^(128/62)" where the code (and the The Head under review: 7ab027f |
…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.
|
Merged main in (no force-push). Head for a re-verdict: Main now carries #1773 (the fconst tail locator, merged after this branch forked), so the 12 conflicts split cleanly by subject:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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_basewith dims it chooses itself, so ifattention()went back toHDthe suite would stay green. "This arm cannot pass with the widths confused" holds for the function only. - The export has no test.
read_rope_dynamicis exported "so a host test can pin it", but no test calls it (the rope test is C, and the reader takesstd::string). Add a C++ arm, or drop the export. - Typo: the second exponent message says
2^(128/62); the code computes2^(128/126).
|
VERDICT: READY at The head moved by a merge from The (The optional follow-up from that verdict is unchanged and still optional: a two-assertion unit test on this pure function — |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
VERDICT: READY at First, what this PR isIt is stacked on #1773: The rule, and what it gets rightif (!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));
The question:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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:
- The second check's label says
2^(128/62)but the value is2^(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. read_rope_dynamic()is now exported but nothing outsideq4nx_forward.cppcalls it (the only other reference in the tree is line 605 calling it internally). Either a test should call it — buttests/npu_rope_llama3_test.cis C and this is astd::string/Q4nxRopeDynamicAPI, so it cannot — or it should staystatic. 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.
|
VERDICT: READY at The substance is unchanged at this head and I re-verified it after the merge from Worth recording for whoever posted the |
|
VERDICT at What I verified, and how:
Notes (non-blocking):
What I did not check, stated plainly: I did not read |
…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>
The 1.9 Hunyuan packs declare
rope_scaling: {type: "dynamic", factor: 1.0}with a 262,144 window, and the forwardrefused 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):
which is HF's
_compute_dynamic_ntk_parametersand what ggml applies through rope_yarn. Because the base moves with theposition 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:
window is a no-op bit for bit -- which is what makes accepting that specific block safe rather than a guess;
theta * 5^(dim/(dim-2))to 1e-6;on == 0all leave the base alone -- the same rule the llama3 helperfollows, so a malformed block cannot re-frequency a model in a way no reference reproduces;
and mrope.
all llama3 rope checks passed, including the new arms. What this does NOT yet claim: that Hunyuan-0.5B serves. That needsan 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.