Register the text profile of ops::rmsnorm_rope and route the two full-attention call sites to it - #273
Register the text profile of ops::rmsnorm_rope and route the two full-attention call sites to it#273MichaelDementii wants to merge 1 commit into
Conversation
Both full-attention call sites normalized the query, normalized the key and rotated both - three Op calls for one semantic step. ops::rmsnorm_rope already fuses exactly that and was already registered for two profiles, the DFlash2 pair at [128,32,W,B] and the context-K single at [128,8,T]; neither covers the text heads, which are 256 wide with a 64-channel rotation and want the result out of place. So the text stack kept issuing the three. On qwen3.6-35b-a3b that is ten of the forty text layers, plus the MTP tail once per draft step. A third overload takes q_in [256,Q,T] and k_in [256,K,T] and writes q_out and k_out, for (Q,K) of (16,2) or (24,4). Only the first 64 channels rotate; the remaining 192 carry the normalized value through. The norm weight enters as a delta around one - the Offset epilogue the text stack normalizes with - unlike the two in-place forms, which multiply by the stored weight directly. The rotation consumes the BF16 represented normalized value, so the result is bit-identical to rmsnorm(q_in) -> rmsnorm(k_in) -> rope(q_out, k_out). text_qk_norm_rope sits beside text_rope in execution/attention.cpp, where the MRoPE axis check already lives, so both call sites go through one place. It takes the fused Op where the profile matches and the three calls everywhere else: the MRoPE path, any other geometry, and above a width bound. The width bound is the part that had to be measured. The fused form wins by 41.94 to 52.07 % at decode widths and loses above about six hundred tokens: T (16,2) (24,4) 256 -30.01% -21.83% 320 -26.40% -0.90% 512 -10.99% -1.91% 576 -10.35% +2.91% 1024 +5.23% +21.87% 2048 +17.20% +32.05% The two geometries cross at different widths - (24,4) has already fallen to -0.90 % at T=320 and is positive from 576, while (16,2) stays ahead through 896 and turns at 960 - so the dispatch stops at 256, where the fused route is 30.01 % ahead on (16,2) and 21.83 % on (24,4). One width further out (24,4) is at -0.90 %, inside this stand's own null, so the margin is gone before the sign is. I have no established mechanism for the crossover; the obvious one does not predict the non-monotone approach to it, and nothing here measures occupancy. The predicate covers every model with a card in the tree: three base models, all head_dim 256 and rotary 64, and exactly two head geometries. Qwen3.6-27B and Qwen3.8-27B are (24,4) with 16 full-attention layers of 64; Qwen3.6-35B-A3B is (16,2) with 10 of 40. The call sits on the full-attention path, so it fires 16 or 10 times per pass, not once per layer. The Op itself is valid at any width - the test drives it directly out to 16384 - and this is a dispatch choice. Decode issues T = W x B, at most 128, so the bound never excludes it; a prefill chunk is a multiple of 128 and the default is 1024, so prefill keeps the three calls. The benchmark gains the text profile and a --route split|fused switch, so both routes live in one binary and the comparison is one process per pass: split issues the three calls, fused issues the Op, and split again is the identical-baseline null. Six passes with the arm order rotated through six permutations, graph execution, 20 warmups and 200 samples over 32 inner repetitions. A pass is dropped when the two identical runs disagree by more than 1 %: 29 of 240 on the wide sweep and 19 of 252 on the dense sweep of the crossover region. The rule bounds what survives, so the width of the instrument is the worst kept pass: 0.97 % and 0.96 %, with medians of 0.15 % and 0.18 %. band cells median best worst faster taken by the dispatch, T <= 256 38 -43.27% -52.07% -21.83% 38/38 declined, keeps the three calls 38 -3.58% -26.85% +32.05% 25/38 T (16,2) split -> fused (24,4) split -> fused 1 3.72 -> 2.17 us 3.81 -> 2.20 us 8 4.25 -> 2.19 us 4.50 -> 2.20 us 16 4.36 -> 2.18 us 4.61 -> 2.23 us 64 4.67 -> 2.52 us 5.02 -> 2.78 us 128 4.88 -> 2.90 us 5.34 -> 3.38 us 256 5.40 -> 3.78 us 6.16 -> 4.81 us The fused form is flat at about 2.2 us from T=1 to T=16 on both geometries while the traffic it moves grows about sixteen-fold, so its time is independent of the work; (24,4) leaves the plateau first, at 2.36 us by T=24. The split form climbs over the same range, 3.72 to 4.36 us on (16,2) and 3.81 to 4.61 on (24,4). No width the dispatch takes is slower on either geometry. Output is unchanged: NINFER_OP_REPORT_STATS=1 at %.17g over the whole test suite gives 11912 cases shared between the arms, all byte-identical, and the 56 cases the candidate adds are the text-profile checks the test gains. The same comparison on the previous base dc58675 read 11900 shared, 0 differing, 56 added - master gained 12 cases from its own LinearSwiGLu retune, and the part being claimed did not move. The test judges the new form against the independent FP64 oracle and, separately, against the three calls it replaces, bit for bit. Strength control: bit-exactness is the claim, so the test has to see a perturbation far below any oracle criterion. Scaling the rotation by one part per million - about 3900x below one BF16 ulp - fails 33 of the 60 exact-equality comparisons the suite makes against the split route, over 20 of its 30 text cases; restoring it passes. It does not fail everywhere, and should not: that far below the output quantum the nudge only moves a value already sitting on a rounding boundary. ctest 120/120 on both arms, on both bases. No end-to-end number: an identical-baseline decode arm on this machine reads +0.55 % median over six passes spanning -0.44 % to +0.89 %, which is wider than a change that saves tens of microseconds per pass. That arm was collected for another submission on the same stand and is quoted as a property of the stand, not as a measurement of this change. An earlier collection of the same arm at two repetitions read +1.81 % median with a +5.41 % worst pass; ten repetitions and three warmups is what tightened it.
…ope, and route to it # Conflicts: # include/ninfer/ops/rmsnorm_rope.h # src/models/qwen3_5/execution/text.cpp # src/ops/rmsnorm_rope/launch.cu # src/ops/rmsnorm_rope/launch.h # src/ops/rmsnorm_rope/rmsnorm_rope.cpp # tests/ops/test_rmsnorm_rope.cpp
|
@codex review |
No std:: math call in the file; the include arrived with the PR Neroued#273 merge.
…ope, and route to it
|
The fusion direction makes sense, but this PR needs the following changes before merging:
|
Problem and scope
Related Issue: #272
Replaces #222, which sat from 2026-09-09 without review. Rewritten on today's master rather than
rebased: the sweep behind it is new, three of its claims are withdrawn, and one decision is
reversed. What changed is listed at the end, under What this replaces.
Both full-attention call sites normalize the query, normalize the key, and rotate both — three Op
calls for one semantic step.
ops::rmsnorm_ropealready fuses exactly that, and is registered fortwo profiles: the DFlash2 pair at
[128,32,W,B]and the context-K single at[128,8,T]. Neithercovers the text heads, which are 256 wide with a 64-channel rotation and want the result out of
place — so the text stack keeps issuing the three.
On
qwen3.6-35b-a3bthat is ten of the forty text layers, plus the MTP tail once per draft step.Scope is a third profile of that Op and the dispatch that chooses it. No existing overload changes,
and no caller of them is touched.
Implementation
The Op. A third overload takes
q_in [256,Q,T],k_in [256,K,T]and writesq_out,k_out,for
(Q,K)of(16,2)or(24,4). Only the first 64 channels rotate; the remaining 192 carry thenormalized value through. The norm weight enters as a delta around one — the Offset epilogue the text
stack normalizes with — unlike the two in-place forms, which multiply by the stored weight directly.
The rotation consumes the BF16 represented normalized value, so the result is bit-identical to
rmsnorm(q_in) → rmsnorm(k_in) → rope(q_out, k_out). Its contract says so, and the test checks it.The dispatch.
text_qk_norm_ropesits besidetext_ropeinexecution/attention.cpp, where theMRoPE axis check already lives, so both call sites go through one place. It takes the fused Op where
the profile matches, and the three calls everywhere else — the MRoPE path, any other geometry, and
above a width bound.
The width bound is the part I would not have guessed
The fused form wins by 41.94 to 52.07 % at decode widths and loses above about six hundred
tokens:
The two geometries cross at different widths.
(24,4)is positive from 576 onward;(16,2)staysahead through 896 and turns at 960. So the bound has to suit the earlier of the two.
It is set at 256, where the fused route is −30.01 % on
(16,2)and −21.83 % on(24,4).Those figures pool the two sweeps, which read that width independently at −30.03 / −21.97 and
−29.99 / −21.70.
I did not set it at the crossover, because the margin disappears well before the sign does. On
(24,4)the approach is −11.43 at 288, −0.90 at 320, −3.12, −6.30, −8.58, −5.98 out to 448 — and−0.90 % is inside the null's own worst kept pass, so at 320 this stand can no longer tell the two
routes apart. 256 is the last width where the gain is larger than the instrument on both geometries
by more than an order of magnitude.
I have no established mechanism for the crossover. The obvious one — that one warp owns one head, so
the kernel stops gaining once the width alone fills the machine — does not predict that non-monotone
approach, and nothing here measures occupancy. What is measured is where the sign changes, on both
geometries, at every width between.
The Op itself is valid at any width; this is a dispatch choice, and both branches are the same
arithmetic bit for bit.
Decode issues
T = W × B, at most 128, so the bound never excludes it. A prefill chunk is amultiple of 128; the default is 1024, so in practice prefill keeps the three calls.
The predicate covers every model this engine ships a card for. There are five cards over three base
models, and their published
text_configgives:head_dim256 and rotary 64 on all three, and the two head geometries are exactly the two thepredicate lists and the two the sweep measures.
(24,4)is the pair that crosses earliest, so it isthe one that sets the bound.
The call site is on the full-attention path only —
TextContext::run_layerspicks the mixer perlayer on
config_.layer_types[layer] == MixerKind::FullAttention, and the call lives insideattn_mix. So it fires 16 times per forward pass on the 27B models and 10 onthe 35B-A3B, not once per layer. At T=1 that is 16 × 1.611 µs = 25.8 µs saved per pass on
(24,4)and 10 × 1.553 = 15.5 µs on
(16,2). I am not turning that into a share of a decode round, becausethis package does not measure a round.
Verification
RTX 5090 at a 575 W cap, driver 616.64, CUDA 13.1.115,
CMAKE_CUDA_ARCHITECTURES=120a, Release,base
f76e19c0. Both arms' CMake caches agree on every value once the tree paths are normalised;they differ in one line, and only in its cache type annotation —
CMAKE_CUDA_COMPILER:UNINITIALIZEDagainst
:STRING, same/usr/local/cuda/bin/nvccon both. That is an artifact of one tree beingconfigured with the variable on the command line and the other inheriting it, not a build
difference.
The operator sweeps were taken on
dc58675f; master has moved six commits since and this branch isrebased onto today's head. None of the six touches any of the eleven files here. The gates were
re-run on the submission base; the sweeps were not, so they are quoted as what they are —
measured on
dc58675f.What makes them still comparable is that the instrument did not move either.
bench/ops/ rmsnorm_rope_bench.cuis unchanged between the two bases, andbench/ops/ninfer_bench_common.hgained 67 lines and lost zero — a new
measure_cold_launch_preparedfor in-place Ops, whichthis bench does not call. It calls
bench::measure_graph, untouched. So the code that produced thesweep numbers is byte-identical on the base this ships against.
What the dispatch takes, and what it declines
The benchmark gains the text profile and a
--route split|fusedswitch, so both routes live in onebinary and the comparison is one process per pass:
--route splitissues the three calls the modelissues today,
--route fusedissues the Op, and--route splitagain is the identical-baseline null.Six passes, the arm order rotated through six permutations, graph execution, 20 warmups and 200
samples over 32 inner repetitions.
A pass is dropped for a cell when the two identical runs disagree by more than 1 %. Over the wide
sweep, 29 of 240; over the dense sweep of the crossover region, 19 of 252. The rule bounds what
survives, so the honest statement of the instrument's width is the worst kept pass: 0.97 % and
0.96 %, with medians of 0.15 % and 0.18 % and 95th percentiles of 0.87 % and 0.69 %.
T ≤ 256Both rows pool the two sweeps: 40 cells wide plus 42 dense, minus the two the sweeps share at
T=256, so 80 distinct cells, 38 at or below the bound and 42 above it. Quoting the population
matters more than it looks — the wide sweep alone reads −43.02 % over its own 40 cells, and the
dense sweep alone −6.58 % over its 42, because they cover different width ranges. Six of the wide
sweep's cells sit above the bound; four of those six are where the fused route is actually slower
(T=1024 and 2048 on both geometries), and at T=512 it is still faster by 10.93 and 2.30 % but the
dispatch declines it anyway.
The fused form is flat at about 2.2 µs from T=1 to T=16 — 2.171 to 2.204 on
(16,2), 2.191 to2.232 on
(24,4)— while the logical traffic it moves over that range grows about sixteen-fold(8.5 → 135.3 GB/s and 13.1 → 205.6 GB/s). Its time is independent of the work.
(24,4)leaves theplateau first, at 2.361 µs by T=24. The split form climbs over the same range, 3.72 → 4.36 µs on
(16,2)and 3.81 → 4.61 on(24,4), with a step between T=6 and T=7 on both — +0.38 µs and+0.52 µs — that a constant three-launch overhead does not explain.
No width the dispatch takes is slower on either geometry.
These are warm-cache numbers, and that is the conservative direction here. The graph holds 32
launches and the harness does not flush L2 between samples, so every launch but the first reads a
warm cache. On an Op that reads a weight matrix, that would be fatal — it hides the dominant
traffic. This Op has no weight matrix: it reads
q,kand two 256-element norm vectors. The wholesweep is L2-resident on both arms, worst case 58.7 MB against 96 MB at T=2048, 0.03 % of L2 at
T=1.
The asymmetry that remains points the safe way. The split route materialises normalized
qandkand reads them back; the fused route never spills them. A warm cache makes exactly those re-reads
cheap, so it flatters the route being replaced — a cold measurement would show a larger gain, not a
smaller one.
And the instrument is not drowning: the identical-baseline null arm's worst kept pass is 0.97 %
against a median effect of −43.27 % over the 38 cells of the taken band. If the mode were costing resolution, it would
show there first.
Output
NINFER_OP_REPORT_STATS=1at%.17gover the whole test suite, run on both bases:dc58675f5b4303c0f76e19c0(this one)Master gains cases between the bases as it gains tests of its own, and both arms see them. What does not move is the part being claimed: no shared case differs in any field,
and the candidate adds exactly the 56 text-profile checks the test gains.
ctest120/120 onboth arms on both bases.
The test judges the new form against the independent FP64 oracle and, separately, against the three
calls it replaces, bit for bit.
Strength control. Bit-exactness is the claim, so the test has to be able to see a perturbation far
below any oracle criterion. Scaling the rotation by a factor of 1.000001 — one part per
million, about 3900× below one BF16 ulp — fails 33 of the 60 exact-equality comparisons the
suite makes against the split route, across 20 of its 30 text cases, 20 on the query and 13 on the
key (
equals split route: exact mismatch at index …; the run's own summary line readsfailures=33). Restoring it passes. It does not fail everywhere, and it should not: that far belowthe output quantum, the nudge only moves a value that was already sitting on a rounding boundary,
which is also why the check has to be exact equality — a tolerance would have reported nothing.
clang-formatreports no replacements on any of the eleven files, and none gains a line over the100-column limit that master did not already have.
No end-to-end number
This stand cannot resolve a change of this size in a decode run. An identical-baseline arm — the
same build measured twice — reads +0.55 % median over six passes, spanning −0.44 % to +0.89 %.
That arm was collected on this machine for a different submission, same build configuration and
artifact; I am quoting it as a property of the stand, not as a measurement of this change.
An earlier collection of the same arm at two repetitions read +1.81 % median with a +5.41 % worst
pass; ten repetitions and three warmups is what tightened it, and both are in the record. Either
way the arm is wider than a change that saves tens of microseconds per pass, so the claim here is
at the operator level.
Limits
on
(16,2)— by 26.85 % at T=288, falling to 1.45 % at 896 — but is already marginal on(24,4).A per-geometry bound would recover it; both geometries ship in this engine's model cards, so one
constant that is safe for both is what this ships.
causal_softmax_attention_cached's own sites are not involved; only the two full-attention ones.the position axis, which is the existing
require_rope_axestest.What this replaces
#222 argued, in its own thread, that a width bound should not exist — "one warp owns one head, so
the only real ceiling is the launch grid" — and removed one. This version puts a bound back, at
256 tokens. The two are not the same kind of bound. That one was a correctness ceiling I had
invented, and the review that removed it was right. This one is a dispatch choice on measured
speed: above it the fused Op is slower than the three calls, by up to +32.05 % at T=2048 on
(24,4). The Op stays valid at any width, and both branches are the same arithmetic bit for bit.Three claims from #222 are withdrawn rather than restated:
This one counts Op calls, which is what it can show.
per pass of 64 layers on the 27B models, 10 of 40 on
qwen3.6-35b-a3b.median over six passes spanning −0.44 to +0.89 %, so the stand cannot resolve a change this size
end to end. The claim is at the operator level and the title says so.