perf(sparse_moe): widen the Q5 routed-down Rows2 window to its measured crossover - #200
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 26a5f10c64
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| : tokens <= 5 ? SparseMoeSmallTD4Schedule::Rows2 | ||
| : SparseMoeSmallTD4Schedule::Rows4; | ||
| plan.d4_schedule = tokens <= 2 ? SparseMoeSmallTD4Schedule::Rows1 | ||
| : tokens <= 11 ? SparseMoeSmallTD4Schedule::Rows2 |
There was a problem hiding this comment.
Extend Q5 Rows2 through the measured winning interval
For Q5 routed-down at T=12..16, which is reachable through DFlash/DFlash2, this condition still selects Rows4 even though the change's forced-schedule sweep reports Rows2 is 1.47–1.82% faster there. Stopping at 11 solely because verification was performed at that boundary knowingly leaves an inferior production schedule; extend and requalify the boundary through the measured winning interval.
AGENTS.md reference: AGENTS.md:L7-L11
Useful? React with 👍 / 👎.
26a5f10 to
4f01601
Compare
|
The Codex finding is correct, and the answer changed the change: Stopping at eleven was defended here as a verification-risk trade-off. The crossover had moved. On the old base ( So the diff is no longer "raise Q5 to eleven". The Two claims of mine were wrong and are corrected in the body rather than quietly dropped. The first was about which widths the product can reach. I had counted only the decode backends and concluded that 17, 19, 23 and other primes above 16 were unreachable, which made "18 versus 19 is indistinguishable" part of the argument. That is false: the small-T route is selected by token count alone, and the tail chunk of a prompt enters it as well ( The second was the null control. The body said 40 of 40 cells read exactly What is bought, and what it costs if the contested reading is wrong. Q5 T=12..19 gains 1.23% to 1.82% of the operator, on 37 of the 40 MoE layers. End to end at the constant: decode +1.38% at T=6, +0.89/+0.67% at T=12, +0.44/+0.43% at T=16; prefill zero; the no-speculation row zero. The Q6 contest at T=18,19 was resolved in this change's favour by taking three-of-five non-zero windows as lattice noise. If that is wrong, Q6's interval shrinks to Verification was re-taken at 19, which is what the previous version could not claim: bitwise gate 24 of 24 IDENTICAL with a strength control at 5 of 6 DIFFER, covering T=12..16 through Not done. The full 🤖 Generated with Claude Code |
|
The branch was replaced after the finding above; the review on this PR still points at the previous head. Current head is @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4f016015bd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // One constant therefore serves both without costing either. Why the crossover sits where it | ||
| // does was not measured - no counter capture was taken - so no mechanism is claimed here. | ||
| plan.d4_schedule = tokens <= 2 ? SparseMoeSmallTD4Schedule::Rows1 | ||
| : tokens <= 19 ? SparseMoeSmallTD4Schedule::Rows2 |
There was a problem hiding this comment.
Preserve the measured Q6 crossover
For the Q6 profile at T=18 or T=19, this selects Rows2 even though the replacement commit's own forced-schedule data puts the Q6 crossover at 17 and reports roughly 1.2% wins for Rows4 in three of five windows at those widths, with full sample separation in three of the six readings. These widths are reachable by batched/speculative decode or small-T prefill, and the parent selected Rows4, so collapsing the existing codec branch introduces a measured Q6 regression; retain the independently measured Q6 threshold while extending Q5 to 19.
AGENTS.md reference: AGENTS.md:L7-L11
Useful? React with 👍 / 👎.
4f01601 to
01401e6
Compare
|
Correct, and taken: The objection lands on the one joint in the previous revision that rested on a judgement rather than on a number. Collapsing the branch needed the intervals What makes it clear-cut is that the bet had nothing on the other side. 17 is not worse than 19 for Q6 under either reading: if the contested cells are noise, T=18 and T=19 run identically either way; if they are signal, 17 is right and 19 is a regression. There is no reading in which the shared constant wins on Q6, so it bought only tidiness, and One thing from the previous revision is kept, because the measurement supports it: the branch as it stood on master was not supported either. It separated the codecs by six tokens (5 against 11) where this base's sweep separates them by two (19 against 17). Both arms move up; what changes now is that each moves to its own measured value instead of to a shared one. What was re-run and what was not. The diff is one file, +10/−9,
🤖 Generated with Claude Code |
|
Head is now @codex review |
|
Codex Review: Didn't find any major issues. Delightful! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Local review of On RTX 5090, CUDA 13.1, Release sm_120a (g++ 13.3, Ubuntu 24.04/WSL2),
This is a 1.6125% Op-time regression, with zero difference between the identical baseline controls. The controlled experiment captured all three graphs in one process over shared inputs/buffers and interleaved replay through all six arm orders: 300 samples per arm after 20 warmups, with a 256 MiB cold-cache flush before each call. Three separate-binary comparisons also showed +1.613%, +1.613%, and +1.587%. At trace-like T=17, the same-process comparison showed no gain either (153.376 vs 153.600 us). The rest of the change has useful results: Q5 trace-like T=6..19 reduced Op time by approximately 1.3–5.9%, and Q6 T=12..16 improved approximately 1.2–1.8%. The original 16 numerical cases and 58 supplemental FP64 cases passed on both arms; the complete BF16 outputs of the supplemental cases were byte-identical. No correctness or implementation-quality blocker was found. Please requalify the Q6 upper boundary, including a cutoff of 16, or use a justified selection that avoids the T=17 regression. This does not establish that 16 is globally optimal; the Q5 improvement can be retained independently. A focused comparison at the boundary with both concentrated and trace-like routing should be sufficient to revisit this finding. This is an Op-level performance finding, not a mathematical bug or a claim of 1.6% whole-model slowdown. End-to-end measurements were not pursued after establishing this changed-route regression. These measurements belong to the baseline above, not today's master. |
…ed crossover Rows is only a grid split: launch_d4_small_t_rows uses (kHidden / Rows, tokens). Rows2 runs twice the blocks of Rows4, so it has twice the parallelism to cover DRAM latency while the grid is small, and it re-reads the same per-expert activations in every block. Below the crossover the parallelism wins; above it only the cost is left. The cost is SM-side, not DRAM-side. Counters on the D4 kernel, Q6 at T=17, four launches per arm: blocks x2.000 l1tex__t_bytes.sum x1.634 lts__t_bytes.sum x1.004 sm__throughput x0.841 kernel duration x1.142 L2 and DRAM traffic do not move; the extra activation reads are absorbed by L1. dram__* is not exposed on this part and returned n/a, so the DRAM statement rests on lts__t_bytes being flat. Q5G64_F16S carries 37 of the 40 routed-down layers of Qwen3.6-35B-A3B; Q6G64_F16S carries three (34, 38, 39). Its window was 5, well below where Rows2 stops paying. This raises it to 17 and leaves Q6 at 11. Measured on RTX 5090, CUDA 13.1, Release sm_120a, base b88c0f6, ninfer_sparse_moe_bench with --execution graph --cache cold --warmup 20 --repeat 300 --flush-mib 256. Three arms built from one tree: Rows2 forced, Rows4 forced, and a second build of the Rows4 source that came out byte identical to the first and serves as the null control. Arms are compared paired inside a pass; a pass whose two identical binaries do not read equal in a cell is discarded for that cell. Two machines, deliberately at different clocks: local WSL2, SM clock 1975 MHz under load (48 C, 265 W of 575) - the binding reading stand B rented Linux, SM clock 2810 MHz under load (50 C, 476 W of 525) Largest T at which Rows2 is not slower, by routing distribution, with T <= 2 excluded (Rows1): codec distribution local stand B Q5 trace-like 22 22 Q5 independent 18 20 Q5 same 17 19 Q6 trace-like 15 17 Q6 independent 16 22 Q6 same 11 16 The binding case is `same` - every token sharing one set of eight experts - which is where Rows2 is weakest. 17 holds on both machines for Q5. The crossover moves right with SM clock because the cost is SM-side, so the lower-clocked machine is the one that sets the value. Effect at Q5, concentrated routing, local machine, median of the kept per-pass ratios: T=6 -2.63 % T=10 -1.82 % T=14 -0.02 % T=17 +0.00 % T=8 -2.43 % T=12 +0.00 % T=16 +0.00 % T=18 +1.09 % <- first loss, outside the window On trace-like routing the same range reads -1.3 % to -8.8 %. The crossover is not a property of the current base. The same comparison built on ad0f3d3 - before the two commits that added L2 prefetch to this kernel - gives the same ratios: +1.51 % at T=12 and +2.23 % at T=17 against +1.56 % and +2.35 % here. Those commits lowered the absolute time by about 4 % without moving the balance between the two schedules. Bit-exact: Rows only changes how output rows are distributed over blocks. Each row's dot product accumulates over the intermediate dimension in the same order, and the nine paths are summed in rank order in the epilogue, independent of Rows. Not run: no end-to-end measurement is offered. At batch 1 the decode round runs T=4, inside the old window as well as the new one; the range this change touches, T=6..17, is reached at batch 2 and above, and the local card cannot resolve a per-layer operator effect of this size end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
01401e6 to
b907232
Compare
|
You were right. Reproduced first, then requalified; the change is now smaller than the one you suggested. (Reposted: the first version of this comment lost its non-ASCII characters in transit.) Your cell, reproduced
Your question about a cutoff of 16Tested. 16 is right on one of my two machines and not on the other, so I took the justified
On the rented box 16 is exactly the boundary, matching your finding. On the local card the losses The interleaved ties at T=13 on the local card are not noise: nine passes, agreeing to two What the PR is nowOne number: the Q5G64_F16S window goes 5 -> 17. Q6G64_F16S stays at 11. The 19 in v1 was also This matters per codec because RequalificationThree arms from one tree: Rows2 forced, Rows4 forced, and a second build of the Rows4 source that Largest T at which Rows2 is not slower (threshold 0.50 %, T <= 2 excluded, that is Rows1):
Why the two machines differ, and which one sets the valueThe local card sits at 1975 MHz under this load at 48 C and 265 W of a 575 W cap - not Mechanism
Rows2 buys parallelism to cover DRAM latency while the grid is small, and pays by re-reading the Two things v1 got wrong beyond the number
I also checked whether the base had moved the crossover - two commits touch this kernel between Verification and what is not offeredBit-exact by construction: No end-to-end number. At batch 1 the decode round runs T=4, inside both the old and the new |
Level of the claim: schedule (kernel selection), confirmed end to end. On this artifact the change is
inert at
T >= 20and live atT <= 19, through two entries: a decode round of6..19columns onthe Q5 arm and
12..19on the Q6 arm, and a prefill chunk of2..19tokens on either. The secondentry is why
T <= 5is not inert either.Environment
RTX 5090, sm_120a. Release,
-DCMAKE_BUILD_TYPE=Release -DCMAKE_CUDA_ARCHITECTURES=120a -DNINFER_BUILD_BENCHMARKS=ON, CUDA 13.1.115 with host compiler GNU 13.3.0 (logs/cmake.log:1-3).Artifact Qwen3.6-35B-A3B; the engine's own summary reports the resident weight set as 19.6 GiB
with no speculative head, 20.0 GiB under
--spec dflashand 20.4 GiB under--spec mtp(
weights_capacity_bytes= 21 038 469 632 / 21 448 523 264 / 21 936 403 968 in the ladder CSVs).Base
origin/mastera16b6442.Every measurement runs under a GPU-exclusive lock that refuses to start when a foreign compute
process holds the card. In this session the card witness (
nvidia-smisampling of SM clock, memoryclock, temperature and power) is started after the lock is taken and killed before it is
released, so the witness window can no longer include time the card was not ours. Over the sweep the
SM clock reads median 2872 MHz, mode 2902 MHz, maximum 2910 MHz, at up to 66 C and up to
538.03 W - 4031 samples across the three witness logs, 23 of them above 535 W.
The operator instrument is
ninfer_sparse_moe_bench(bench/ops/sparse_moe_bench.cu) at--cache cold --execution graph --distribution trace-likewith the default seed. Every pass is itsown process under its own lock acquisition; pass 0 of each arm is discarded and the median of the
rest is taken. 520 raw files (
raw/in the attached archive: 24 each for blocks F, G, H, K, M, Nand Z, 272 single points, 80 single-point null-control runs).
Observation
resolve_sparse_moe_small_t_plancarries two arms of the same main-profile ladder that differ in onenumber: the Q5 arm gives up the
Rows2D4 schedule above five tokens, the Q6 arm holds it to eleven(
src/ops/sparse_moe/small_t/sparse_moe_small_t_plan.cpp:46-54on this base, with the sharedplan.d3_scheduleassignment on:45). TheRowsparameterdecides only how many destination rows one routed-weight stream serves; the rank-order FP32 epilogue
is identical either way.
A codec-dependent threshold is defensible in principle - Q6 reads more weight bytes per row, so it
tolerates the extra activation re-reads of the narrower schedule for longer - and it is not a general
rule in this op either way: the sibling prefill plan gives both codecs the same entry point
(
kSparseMoePrefillQ4Q5Min == kSparseMoePrefillQ4Q6Min == 47,src/ops/sparse_moe/prefill/sparse_moe_prefill.h:17-18).What I have is a measurement of the crossover itself, on both codecs, with the schedule forced.
On
a16b6442it lands at T=19 on Q5 and T=17 on Q6: two tokens apart, where the ladder separatesthe arms by six. A codec-dependent threshold is real here, but it is a third of the one that is
written down, and both crossovers sit far above both constants in the tree.
37 of the 40 MoE layers on this artifact take the Q5 arm. That is the loader's own rule rather than
a property of one file:
routed_down_formatreturnsQ6G64_F16Sfor layers 34, 38 and 39 andQ5G64_F16Sfor every other layer(
src/targets/qwen3_6_35b_a3b/impl/load/bindings.cpp:20-23), against 40 layers in total - 10 fullattention plus 30 GDN (
src/targets/qwen3_6_35b_a3b/impl/config.h:65-66). So the arm this changemoves furthest is the one that carries 37 of 40 layers.
Change
One file,
src/ops/sparse_moe/small_t/sparse_moe_small_t_plan.cpp, 8 insertions and 9 deletions,of which 5 insertions are the comment recording where the constant comes from. The
routed_downbranch is deleted and one ladder replaces both arms:
Effect per arm: Q5 goes from 5 to 19, Q6 from 11 to 17.
T <= 2keepsRows1on both, asbefore. Each arm now carries the value its own forced-schedule sweep produced, and the branch stays
because the two values differ.
Affected behavior and ownership boundary
No public contract changes. No CLI, workspace, graph-profile or artifact change. The op's numerical
contract is untouched, and that is demonstrated bitwise below rather than asserted.
sparse_moe_small_t_workspace_bytesdepends ontokens, not on the schedule, so the reportedworkspace is identical at every width on both arms.
The plan has exactly two consumers, both in
src/ops/wrapper/sparse_moe.cpp(:221workspace,:246launch);resolve_sparse_moe_small_t_planis called nowhere else insrc/,tests/orbench/. The adaptive (persistent-grid) route does not go through the plan at all:src/ops/sparse_moe/prefill/sparse_moe_prefill_kernels.cu:1218callssparse_moe_decode_launch_d4_small_twith a hard-codedSparseMoeSmallTD4Schedule::Rows4(:1220).That matters, because the adaptive grid is
kAdaptiveD4Blocks = 5 * (kHidden / 4)(
src/ops/sparse_moe/decode/sparse_moe_decode_kernels.cu:31), a constant derived fromRows4; ifthe adaptive path read the plan, this change would move a persistent grid. It does not.
The change is live in two places, not one: in a speculative or batched decode round of 6..19
columns, and in a prefill chunk of 2..19 tokens - which, at the default
prefill_chunkof 1024,means any prompt of nineteen tokens or fewer, and the tail chunk of any longer prompt whose remainder
falls there. The next section reads that out of the source. Neither end-to-end ladder in this report
exercises the second one, and that is stated again under Limitations rather than left implicit.
Which widths the product can actually ask for
This decides what the constant is worth, so it is read out of the source rather than out of memory.
The operator has two entry points, and an earlier draft of this section counted only the first.
Entry 1, a decode round. The width the small-T operator is called with is the number of
columns in the batch, not the draft window:
and
columnstokens then travelrun_layers->Variant::post_mixer(
src/targets/qwen3_6_35b_a3b/impl/variant.cpp:218) ->run_sparse_moe(:66-73) ->ops::sparse_moe,where
tokens = columnsreachesresolve_sparse_moe_small_t_plan(
src/ops/wrapper/sparse_moe.cpp:246).Entry 2, a prefill chunk. The route is chosen from the token count alone, with no notion of
phase.
src/ops/wrapper/sparse_moe.cpp:208-210computesuse_small_t = sparse_moe_uses_small_t(tokens)anduse_prefill = sparse_moe_uses_prefill(tokens, ...); theprefill route opens only at
tokens >= minimumand that minimum is 47 for both codecs(
src/ops/sparse_moe/prefill/sparse_moe_prefill_plan.cpp:24-28, withkSparseMoePrefillQ4Q5Min == kSparseMoePrefillQ4Q6Min == 47), while the small-T domain is[2,46](
sparse_moe_small_t_plan.cpp:9-11). So every call intoops::sparse_moewithtokensin[2,46]lands in the ladder this PR edits, whichever phase issued it - and prefill issues suchcalls. A prefill chunk is
(further clipped by the split frontier on
:1108), and that chunk then runsrun_layers(x, Phase::Prefill, tap)(:1184) ->mlp_tail(:1018,:1039) ->Variant::post_mixer(:993) ->src/targets/qwen3_6_35b_a3b/impl/variant.cpp:218->run_sparse_moewithhidden.ne[1] = len(variant.cpp:66-73). The defaultprefill_chunkis1024 (
include/ninfer/types.h:160,apps/cli/options.h:24, and the value in theprefill_chunkcolumn of all eight ladder CSVs), so a prompt shorter than 1024 tokens is one chunk of its own
length, and the tail chunk of any prompt is the remainder - any number from 1 to
chunk.kMaximumConcurrency, the cap onbatchinclude/ninfer/types.h:20kMtpDecodeMaximumDraftswidth <= 6src/targets/qwen3_6/export/ninfer/targets/qwen3_6/round_state.h:15-16kDFlashDecodeMaximumDraftswidth <= 16:17-18--spec mtprequires--draft-tokensin[1,5]src/product/speculative_options.h:41--spec dflashrequires[1,15]:46--spec dflash2requires[1,15]:51widthis bound to 1,batchto[1,8]T = batchsrc/targets/qwen3_6/impl/runtime/text_context_impl.h:652-654,679[2, 46]src/ops/sparse_moe/small_t/sparse_moe_small_t.h:14,16T >= 47, both codecssrc/ops/sparse_moe/prefill/sparse_moe_prefill.h:17-18Inside entry 1 the reachable set is
{ width x batch }:Tin[2,46]width = 1)--spec mtp(width = 2..6)--spec dflash/dflash2(width = 2..16)Eleven values in
[2,46]are not a product ofwidth <= 16andbatch <= 8- 17, 19, 23, 29,31, 34, 37, 38, 41, 43, 46: the eight primes above 16, plus
34 = 2 x 17,38 = 2 x 19and46 = 2 x 23, whose only factorisations need a width above 16 - so no decode round can ask forthem. Entry 2 reaches all of them. A prompt of nineteen tokens, or any prompt whose tail chunk is
nineteen tokens, enters the ladder at
T = 19. The reachable set of this operator is therefore thewhole of
[2,46], 18 and 19 are distinguishable in the product, and the constant proposed hereis a width the product can actually ask for.
Crossover sweep, on
a16b6442Two extra builds of the operator benchmark rewrite both arms of the ladder at once
(
resolve_sparse_moe_small_t_planpatched by script): one forcesRows2at every width in[3,46],the other forces
Rows4.Rows1atT <= 2is kept on both, soT=2is a built-in null cell andreads
+0.00%in every pass on both codecs.Rowsis only a grid split(
grid = dim3(kHidden / Rows, tokens),sparse_moe_decode_kernels.cu:619), both instantiationsalready exist for both codecs on master, and the workspace does not depend on the schedule, so
forcing either one is legal across the whole range.
delta = (Rows2 - Rows4) / Rows4; negative meansRows2is faster.*marks full sample separation(the worst pass of the winner beats the best pass of the loser). Five sweep windows plus single-point
--tokens Truns, ABAB alternation, 5 scoring passes per arm per window (3 for the single points):Q5G64_F16SQ6G64_F16SWhy five windows, and how the contested cells were settled
Readings lie on a 2.048 us lattice; at T=20 one step is 1.18% of the operator. Raising
--repeatdoes not help - within an arm at
--repeat 400the spread is 0.00% and the cell can still sit on thewrong step. Contested cells were settled by changing the sweep window and by single-point runs.
Both contests are recorded rather than rounded away, and they did not resolve the same way.
read exactly
+0.00%. On the lattice the cause is visible: theRows2arm reads 172.032 us inevery configuration, while the
Rows4arm reads 174.080 us when the sweep starts at 10/12/16 and172.032 us when it starts at 2 or is measured as a single point. The losing arm is what moves,
and sweep order moves it, not the schedule. Taken as zero, so the Q5 optimum is 19. Believing
the four windows would have made it 20, which is the constant this change would then have
proposed instead of 19 - not a difference that stops at this cell.
the table above prints it. At each of the two widths three of the five windows read non-zero
for
Rows4, and they are not the same three: at T=18 it is F (+1.27%), H (+1.27%) and K(+1.25%), at T=19 it is F (+1.20%), K (+1.19%) and G (+1.19%). Only three of those six
readings carry full sample separation - F and H at T=18 (
raw/200_analiz_blokov.txt:141,:223)and F at T=19 (
:142); in K and G at both widths the samples overlap(
raw/200_analiz_tochek.txt:82-83for K,raw/200_analiz_blokov.txt:279-280for G).Which arm moves is not the same in all four of those cells, and the exception is the one that
argues against this change, so it is printed rather than summarised. In three of them the
Rows2arm moves by one lattice step while
Rows4stands still: K at T=18 (Rows2163.808 at 1.27%spread against
Rows4161.792 at 0.00%), G at T=18 (161.824 at 1.27% against 161.792 at 0.00%)and G at T=19 (172.032 at 1.19% against 170.016 at 0.02%). In the fourth, K at T=19, it is the
other way round:
Rows2reads 172.032 in every pass andRows4is the arm that moves (170.016,1.19% spread), with four of its five passes a whole lattice step below the stationary arm.
That is the strongest single reading in this report for
Rows4winning at T=19, and it sitsinside the contest this section resolves the other way. Window M
(
raw/200_analiz_blokov.txt:95-96) and the single point (raw/200_analiz_tochek.txt:30-31) read+0.00%at both widths, and one window per width reads a bare timer tick - G at T=18 (+0.02%) andH at T=19 (+0.02%). Taken as zero, so the top of the Q6 indifference band is 20 and the first
firm
Rows4win is 21, where all five windows and the single point agree with full separation.What that decision is worth if it is wrong: about 1.2% of the operator at T=18 and T=19 on the
Q6 arm, i.e. on 3 of the 40 MoE layers, at widths the product can reach through either entry point.
Reading it the other way would put the Q6 optimum at 17 and shrink the Q6 admissible interval to
[17,17], which does not intersect Q5's[19,25]at all: there would then be no free sharedconstant, and 19 would become an explicit trade - about 1.2% given up at T=18 and T=19 on the three
Q6 layers against 1.23% to 1.30% gained at the same widths on the thirty-seven Q5 layers. The Q5
answer does not move either way. That dependency is real and is stated here rather than buried.
separation. That is 0.032 us, one timer tick, not a lattice step. Zero.
Null control and noise floor
Null control, blocks Z and Z2: 37 of 40 cells exactly
+0.00%, three at+0.02%. Theforced-
Rows4arm is run against a byte-identical copy of itself (same md5) through the sameABAB pipeline. Block Z, over the 10:24 window, is 30 cells; 28 read exactly
+0.00%, and thetwo that do not are
q4-q5at T=19 andq4-q6at T=22, both+0.02%(
raw/200_analiz_blokov.txt:162and:185). Block Z2, the same on single points atT = 17,18,19,20,23, is 10 cells; 9 read exactly
+0.00%, and the one that does not isq4-q5at T=19,
+0.02%(raw/200_analiz_tochek.txt:47). All three are+0.02%= 0.032 us, onetimer tick, which is a sixty-fourth of the 2.048 us lattice step, and all three have
overlapping samples, which is the property the decision rule keys on. On the 2026-09-03 session
this control had 28 of 30 clean with two one-lattice-step false cells at T >= 23; this session
has no false cell of that size, which is the comparison that matters - but it is not a clean
40 of 40 and the report should not say so. Operationally: a reading with full sample separation and
a whole lattice step behind it is signal; a reading of
+-0.02%is a timer tick and is zero.Where this bites, stated at its worst rather than its best. Two of the three tick cells
sit at
q4-q5T=19 - the width this PR proposes as the constant - block Z atraw/200_analiz_blokov.txt:162and the single point atraw/200_analiz_tochek.txt:47; the thirdis
q4-q6T=22 (:185). Both T=19 cells are printed in the package's own summary,raw/200_svodka.txt, in its rightmost null-control column.And
+-0.02%is the floor of the median delta, not of the cell. The block Z cell at T=19reads
167.936against167.904with spreads of 1.23% and 1.22%, so read conservatively -worst pass of one arm against best of the other - the same null cell is +1.22%, a whole
lattice step, at the constant this change proposes. The single point at that width is tight
(0.02% on both arms), and the two disagree.
It does not move the answer: the signal at that cell is -1.23% with full sample separation in
five windows and at the single point, and a null control that can read one step under the
conservative rule is exactly why the rule used here is separation plus a step, which this cell
fails on separation. But the honest summary is that the noise floor of this instrument is one
lattice step under conservative reading and a timer tick under median reading, and that both
numbers land on T=19.
Noise floor, block N, five passes of one arm back to back: median
(max-min)/minis 0.00% onall four series; the per-cell maxima are 1.19..2.86%, and every one of those is exactly one
2.048 us lattice step.
Did the 16/17 crossover hold
Q6: exactly. 17 then, 17 now.
Q5: no. The optimum moved from 16 to 19. The difference is not subtle. On
a140e7aethe cellsQ5 T=17..19 read -0.02..-0.01%, i.e. zero. On
a16b6442the same three cells read-1.37 / -1.30 / -1.23% with full separation in five windows and at the single point, against
a null control that is exactly on zero in 37 of 40 cells and one 0.032 us timer tick off it in the
other three. (T=20, the fourth width of the old interval, is the contested cell discussed above and
is taken as zero on this base; an earlier draft of this sentence wrote "T=17..20" over three
numbers.) Between the two bases master rewrote
src/ops/sparse_moe/small_t/sparse_moe_small_t_kernels.cu(102 lines) andsrc/ops/sparse_moe/sparse_moe_route.cuh(70 lines) - a plausible cause, but not a checked one; Itook no per-commit bisect of the shift.
The three numbers that follow
Q5G64_F16S)Q6G64_F16S)a16b6442a140e7aea16b6442Rows4winThe intervals intersect at [19, 20], so a single shared constant is arithmetically available.
This PR does not take it, and the reason is below.
Why 19 for Q5, 17 for Q6, and why the branch stays
Each arm takes the value its own sweep produced. Q5's optimum is 19, Q6's is 17, both measured
on this base with a forced schedule rather than inherited from the neighbouring codec. Neither
number rests on a judgement call.
The shared constant would have rested on one. 19 sits inside Q6's admissible interval only
because T=18, 19 and 20 are taken as zero there: three of five windows at each of T=18 and
T=19 read about +1.2% for
Rows4, three of those six readings with full sample separation, andthe null control is exactly on zero in 37 of 40 cells rather than in all 40. Under the other
reading Q6's interval is
[17,17], the intersection is empty, and 19 costs about 1.2% on thethree Q6 layers.
17 is not worse than 19 for Q6 under either reading, and better under one. If the contested
cells are lattice noise, T=18 and T=19 run the same either way and the choice is free; if they
are signal, 17 is right and 19 is a regression. There is no reading in which 19 wins on Q6, so
there is nothing to buy by collapsing the branch - only a bet to place.
The branch is no longer degenerate. Under the previous revision of this PR both
ifarmswere byte-identical, so the condition selected nothing. With 19 and 17 it selects again, and the
two tokens between them are what the measurement actually found.
19 rather than 18 or 20. 19 is the measured Q5 optimum and a width the product can ask
for: a nineteen-token prompt, or any prompt whose tail chunk is nineteen tokens, enters the
ladder at
T=19(see Which widths the product can actually ask for). 18 would give T=19 away onQ5 - -1.23% of the operator, full separation in five windows and at the single point. 20 is
admissible too, but it is one width past the last unambiguous Q5 win and would plant the constant
on the cell this report resolved against itself. Of the admissible numbers, the one standing
exactly on the measurement is taken.
Not 17 for Q5, the answer of the old sweep. 17 leaves T=18 on the table for Q5: -1.30% of the
operator, full separation in five windows and at the single point, and T=18 is reachable -
width=9, batch=2,width=6, batch=3,width=3, batch=6, all insidewidth <= 16, batch <= 8,and also as an eighteen-token prefill chunk.
What stopping at eleven would have cost
Single points, pass 0 discarded, median of three, on
a16b6442:T=12..16"; on the current head the band is wider and runs to T=19, not to T=16.
Roofline
MMA: zero, and that is a property of the kernel, not an omission. The D4 small-T kernel carries no
MMA tier at all -
dot_fp32_rows(
src/ops/sparse_moe/decode/sparse_moe_decode_kernels.cu:325) is a SIMT FP32 FMA loop over therow-split codec followed by a warp reduction, and the translation unit contains no
mma/wmmainstruction (re-checked on
a16b6442: zero matches). So the share of the MMA peak is 0% on both armsat every width, and the only ceiling that applies is memory.
GDDR7: 44-48% of the vendor peak on both arms, everywhere
Rows2wins. The numerator is thebenchmark's own
unique_weightcolumn, median of block K over its five scoring passes per arm. Thedenominator is
theoretical_memory = 1792.1 GB/s, the figure the benchmark prints in the header ofevery raw file, and the percentage in each cell below is the one the benchmark itself prints
beside that reading - not one computed here:
Rows2Rows4Rows2Rows4Both arms sit between 44% and 48% of the vendor peak across the whole interval where
Rows2winsand slide down towards T=21. That is the profile of a latency-limited kernel, not a
bandwidth-limited one: the narrower schedule buys latency hiding, not bandwidth. Which also means
the roofline does not explain where the crossover sits - see Limitations.
Verification, taken at the constant 19 on
a16b6442Candidate arm:
a16b6442plus the single constant 19 (cli_cand, md5c7538e06...). Master arm:bare
a16b6442(cli_master, md58d74557f...).Bitwise gate: 24 of 24 IDENTICAL
Three prompts (short / code / math) x four modes x two repetitions. On rep1 the arm order is
master, cand, master2; on rep2 it is reversed. Each cell is its own single-shot process under thelock. A cell is declared INVALID, not equal, if either arm produced under 40 bytes - the CLI default
context is 2048 and a long prompt can drive both arms to empty output, where a naive MD5 comparison
of two empty files always passes;
--max-context 8192is set for that reason.Tper roundcandvsmastermaster2control--spec mtp --draft-tokens 5--spec dflash --draft-tokens 11--spec dflash --draft-tokens 15Both ends of the T=12..16 band now have end-to-end coverage - T=12 and T=16, through
--spec dflashrather than throughmtp- which is exactly what the previous revision of this PRdid not have and named as a gap. T=13, 14 and 15 were not run: the gate steps by the draft
window, and no intermediate
--draft-tokensvalue was measured. An earlier draft of this sentencesaid the band was "covered end to end", which reads as coverage of all five widths.
What the gate does and does not exercise on the prefill side. All three prompts are short enough
to enter the small-T route in prefill as well: the engine's own summary reads
prompt tokens 27forthe short and math prompts and
25for the code prompt, identically in all 78 recorded runs(
gate/err_*.txt), and both counts are inside the operator's[2,46]domain, so every cell pushedone prefill chunk through this ladder. Both counts are above 19, so both arms select
Rows4there and the change is inert at that width: the gate demonstrates that the prefill entry is real and
exercised, not that the constant is bitwise safe at a prefill width where it acts. No run in this
package uses a prompt of nineteen tokens or fewer.
Gate strength control, and it is not clean. A sabotage arm was built: the D4 small-T epilogue
multiplied by
1.001(sparse_moe_decode_kernels.cu, the line*output = __float2bfloat16_rn(value)), md5466ba764.... Six cells (mtp5 and dflash15 x threeprompts): 5 of 6 DIFFER, one - dflash15/math - IDENTICAL. The gate can tell arms apart, but it
let a 0.1% numeric corruption through in one cell of six. That is the known blindness of a bitwise
gate, and it is stated here rather than left out.
The
master2control is a byte-identical rebuild ofcli_master(same md5, the build isreproducible), not an independent one. As a null control for the pipeline it is sound; as evidence of
independence it is not.
End-to-end ladder
ninfer_bench,-pg 128,128 -pg 4096,128 -r 3 --warmup 1 --max-ctx 8192, ABAB alternation, 4 passesper arm, pass 0 discarded, median of three.
mtp --draft-tokens 5mtp --draft-tokens 5dflash --draft-tokens 11dflash --draft-tokens 11dflash --draft-tokens 15dflash --draft-tokens 15Two checks inside the same table:
+0.13% and -0.07%.
(
accept m=0.190 c=0.190,0.881/0.881,0.028/0.028,0.734/0.734,0.025/0.025,0.814/0.814) - independent evidence of bitwise equality: the stream of acceptance decisions didnot change.
Round step
step = decode_seconds_mean * 1000 / spec_rounds, derived from the same ladder CSVs. This is aderived quantity, not a separate instrument - no dedicated graph-step capture was taken this
session.
mtp 5mtp 5dflash 11dflash 11dflash 15dflash 15Because the round count is identical across the arms (
spec_roundsand acceptance match exactly),the whole end-to-end figure is round speed, not a substitution of round count. See the last section.
ctest: a targeted subset, 6 of 6 on both arms - and the full suite was not runThe full
ctestwas not taken, and the reason is disk, not a decision. Building the 110 testtargets did not fit: the build filled the root filesystem and was stopped. The figure the session
recorded for the object tree is
14 GB, written into the header of the script that replaced the fullrun (
ctest2.sh:2); thedflines that survive in the logs are from after the recovery and read13Gand14Gfree of 152G at 92% and 91% (logs/ctest2_out.txt,logs/buildv_out.txt). My ownbuild/testsdirectory was deleted and 13-14 GB were recovered. Nothing belonging to anyone else wastouched.
Instead, the tests this change can plausibly reach were built and run on both arms out of one build
directory, sequentially, because two trees plus the artifact do not fit on this disk:
ninfer_public_api_testninfer_decode_graph_testninfer_qwen3_6_35b_a3b_real_testninfer_qwen3_6_35b_a3b_dflash_real_testninfer_qwen3_6_35b_a3b_dflash_load_plan_testninfer_sparse_moe_testThe first run gave 3 Passed and 3 Skipped (
logs/ctest_cand.log):ninfer_qwen3_6_35b_a3b_real_test,ninfer_qwen3_6_35b_a3b_dflash_real_testandninfer_qwen3_6_35b_a3b_dflash_load_plan_testallcarry
SKIP_RETURN_CODE 77and skip withoutNINFER_QWEN3_6_35B_A3B_WEIGHTS(
tests/CMakeLists.txt:165,:169and:178-179). Re-run with that variable pointing at the realartifact, no skips remain. An earlier draft cited only
:165,169and described the three as "the*_real_*tests"; the third one is..._dflash_load_plan_test, which does not match that patternand carries its skip code on
:178-179.What this does not replace. The other 108 registered tests were run on neither arm at this
constant. The diff touches one
.cppand does not touchtests/CMakeLists.txt, so the registered setdoes not change - but that is a prediction, not a measurement.
clang-formatRe-run on
a16b6442against this diff, since removing the branch re-indents the hunk:clang-format --dry-run -Werror src/ops/sparse_moe/small_t/sparse_moe_small_t_plan.cppexits 0 withno diagnostics, on
clang-format21.1.8. The repository pinsSortIncludes: Never(
.clang-format:39), and this diff adds no include.What the earlier session measured, and on which diff
Kept for the record, not relabelled. Everything in this section was taken on
a140e7ae/ad0f3d38, against a diff that set the Q5 threshold to eleven and left Q6 alone. It is notevidence about the diff proposed here.
Operator, on
a140e7ae, two independent passes per arm with the arm order reversed on thesecond; each delta taken from the faster master pass against the slower change pass:
Codec
q4-q6, which that diff could not reach at all, was the second control: +0.00% at nine of theeleven widths, +0.07% at T=3 (a single 0.032 us tick), and +5.18% at T=2. That last cell bounds
the instrument rather than the change: at T=2 the
q4-q6master arm disagreed with itself by 1.664 usbetween its two passes, and the conservative delta rule turned that into a five-percent "regression"
on a codec the diff cannot touch.
Graph step and end to end, on
e3aeaf8canda140e7ae, at the threshold eleven: the rounddropped 2.05-2.24% at
--spec mtp --draft-tokens 5across contexts 128 / 4095 / 12000, and decoderose +1.48% to +1.58% at that mode across contexts 128 / 1000 / 4095 / 12000, with drafts 0, 1 and 3
measuring -0.03% to +0.03%. Prefill was unaffected, -0.09% to +0.14%. The 2026-09-07 ladder above,
taken at 19 on
a16b6442, reads +1.37% and +1.38% at the same mode - the same effect, on a differentbase, at a different constant, measured with a different ladder shape. Neither number is a re-label of
the other.
Resource census, on
ad0f3d38: both schedules the ladder can select already exist insparse_moe_decode_kernels.cu.oon master -sparse_moe_d4_token_kernel<Q5Codec, 2, false>at 43registers / 72 B shared / 0 stack / 0 local, and
<Q5Codec, 4, false>at 40 / 144 / 0 / 0. Acuobjdump --dump-resource-usagecensus of that object, matched by demangled name with templatearguments, reported 0 changed bodies of 33. That census was not repeated on
a16b6442. Thecompanion denominator for
sparse_moe_small_t_kernels.cu.owas 95 onad0f3d38and is 50 onthis base, because PR #191 replaced the
sparse_moe_small_t_s2_kernel<Tokens>family with onenon-template kernel - read out of the source, not out of a re-run.
Corrections published on the previous revision, kept here
One audit table and five correction blocks below stood in the published body of this PR. The
text each of them corrected has since been rewritten or re-measured on
a16b6442, which is why theyno longer sit next to the sentences they belong to - but nothing this package has retracted is
allowed to disappear with the paragraph that carried it. They are reproduced verbatim, internal
words like "below" included, with the base each belongs to; the notes in italics between them are
new and say where each one now stands.
Line references re-checked, first against
487f8977and again againsta16b6442. The auditbelow was published on the previous revision.
a16b6442is487f8977plus one documentationcommit, and I re-opened all five citations on
a16b6442: every row still reads exactly as printed.src/targets/qwen3_6/impl/state/round_state.cpp, the DFlash allocation gate:176:174:176is inside the block, not its condition:DFlashDecodeStateLayout& decode = layout.dflash_decode.emplace();include/ninfer/types.h, the backend default:78:79:78is the opening linestruct SpeculativeOptions {include/ninfer/types.h,ProposalHead::Full:80:82:80is the comment// Startup-fixed K: MTP 1..5; DFlash and DFlash2 1..15 (query width K+1).src/product/speculative_options.h, the MTP draft-window cap:38:41:38is a closing}; the check `if (options.draft_tokens == 0src/serve/serve_options.h,SpeculativeOptions speculative;:46:46(The 2026-09-07 commands in this revision all use the current spelling; the block above is why.)
(Those percentages were taken on
a140e7aeagainst the 1689.4 GB/s denominator this revision nolonger uses; see the roofline correction above. The 2026-09-07 roofline table replaces them and is
quoted against 1792.1 GB/s.)
What the wider window costs
Rows2halves the destination rows one CTA serves, so the D4 grid doubles fromkHidden/4tokHidden/2CTAs per token (const dim3 grid = Adaptive ? dim3(kAdaptiveD4Blocks) : dim3(kHidden / Rows, tokens);,sparse_moe_decode_kernels.cu:619,kHidden = 2048at:24). The FP32 activationslab each CTA reads -
(kTopK+1) x kIntermediatefloats, 9 x 512 x 4 B = 18 KiB, fromkTopK = 8at:27andkIntermediate = 512at:28- is read outside the row loop, so halvingRowsdoubles howmany times that slab is fetched. It is L2-resident per token, so it does not appear on the DRAM
roofline, but it is the term that eventually outweighs whatever the narrower schedule wins, and it is
why a threshold exists at all. The measurement says that term does not win until T=20 on Q5 and T=21
on Q6.
Limitations and checks not run
The mechanism is measured; the explanation is not. The stack this change came out of asserted
that
Rows4costs 115 registers against 77 forRows2and therefore halves resident CTAs per SM.That does not reproduce on this tree. The census reads 40 registers for
<Q5Codec, 4, false>and 43 for
<Q5Codec, 2, false>, both with zero stack and zero local, and 144 B against 72 B ofshared memory at 288 threads per CTA - i.e. the wider schedule uses fewer registers, and neither
instantiation is near a register-driven occupancy cliff. The occupancy story is withdrawn. No
ncuoccupancy or stall-reason capture was taken in either session, so the proximate cause of the gain
and the proximate cause of the crossover both remain unestablished.
Why the Q5 crossover moved from 16 to 19 is not attributed. Between
a140e7aeanda16b6442master rewrote
sparse_moe_small_t_kernels.cu(102 lines) andsparse_moe_route.cuh(70 lines). Idid not bisect. The shift is measured; its cause is not.
The full
ctestwas not run. 6 targets on both arms, out of 114 registered. That count wastaken on this base, not carried:
ctest.sh:6runscmake --build . --target help | grep _test$andctest -N | grep -c 'Test *#'in this tree'sbuild directory, and
logs/ctest_out.txt:1records the result - 110 build targets, 114ctesttests. (
tests/CMakeLists.txtis byte-identical between487f8977anda16b6442as well, so thetwo agree.) An earlier draft of this bullet claimed the count "was not re-counted by a
ctest -Non this base", which the attached log contradicts. The build of the test set filled the machine's
disk; see above. The remaining 108 were run on neither
arm at this constant, and the claim that the registered set is unchanged is read out of the diff,
not measured. No round "114 of 114" is claimed here for any base, for two reasons that predate this
change:
ninfer_attn_input_proj_testis red on the bare base (upstream defect, issueninfer_attn_input_proj_testis flaky on master: the harness launches before the phase-1 activation upload has landed #196), andctestand a direct run of the same test binary have been observed to disagree on this host.The prefill entry into this ladder has no end-to-end coverage at a width where the change acts.
Prefill reaches this ladder with
tokens = lenfor any chunk in[2,46](see Which widths theproduct can actually ask for), so the constant is live for prompts of 19 tokens or fewer and for
tail chunks of that size. The bitwise gate does exercise that entry - at 25 and 27 prompt tokens,
which are above the constant, where both arms pick
Rows4. Neither end-to-end ladder touchesit at all:
-pg 128,128gives one chunk of 128 and-pg 4096,128gives four chunks of 1024, andevery one of those is
>= 47, i.e. the prefill route rather than small-T. So the row"Prefill is -0.17 to +0.19%" in the ladder is measured at widths where this change cannot act;
it is an internal control for the prefill route, not evidence about a short prompt. The live prefill
widths are covered by the operator bench, which sets
tokensdirectly, and by nothing else here.The roofline denominator is the vendor peak, not a measured ceiling.
theoretical_memory = 1792.1 GB/sis what the benchmark prints and what the percentages above are taken against. Ameasured read ceiling for this card exists in our earlier notes as 1689.4 GB/s, but the probe behind
it was not preserved and was not re-taken in this campaign, so no percentage here is quoted against
it. Reading the same GB/s against a measured ceiling would raise every cell by about three points;
the shape - both arms together, both sliding towards T=21 - does not depend on the choice.
Batch > 1 has no end-to-end coverage.
T = width x batchis established from the source, butneither the gate nor the ladder ran at
batch > 1: the CLI andninfer_benchboth drive a singlesequence. So the widths T=18..45 that only a batch can open are covered by the operator bench and by
reading the code, not by an end-to-end run.
--spec dflash2was never exercised on this artifact. Itsdflashsection exists in themanifest (51 objects), but the dflash2 route (
coherent_selector) was not run once.The gate does not see numeric corruption. The strength control proves it directly: 1 cell of 6
passed a 0.1% corruption as IDENTICAL. No numeric oracle and no delta-NLL was taken here.
The
master2control is not independent - it is a byte-identical rebuild, sound as a pipelinenull control and nothing more.
The top of the Q5 tail (T=26..30) is contradictory - +0.92% at the single point with overlapping
samples, +-0.8..0.9% across windows without separation, which is the signature of a false signal.
The first firm
Rows4win on Q5 is taken as T=30, not 26 and not 28. T >= 31 has no independentrepeat: that stretch was swept by block G only. None of this touches the threshold, which sits at
19.
One artifact, one fixture.
--distribution trace-like, default seed, Qwen3.6-35B-A3B. The D4grid does not depend on how routing concentrates, but D4's share of the operator does. Qwen3.6-27B
has no routed MoE and never reaches this ladder, so it is untested rather than unaffected.
The graph-step ladder and the
cuobjdumpresource census were not repeated ona16b6442.Both are carried from the old base and labelled where they appear.
No test covers the ladder.
SparseMoeSmallTD4Scheduleappears in five sources(
sparse_moe_decode.h,sparse_moe_decode_kernels.cu,sparse_moe_prefill_kernels.cu,sparse_moe_small_t.h,sparse_moe_small_t_plan.cpp) and two evaluation corpora, and in no fileunder
tests/- re-checked ona16b6442. A table-driven unit test overresolve_sparse_moe_small_t_planacross widths is about twenty lines and would pin the ladder to acontract. I have left it out to keep the diff to one hunk, and will add it in this PR if you want
it; with the branch gone it is a shorter test than it was.
The candidate was never built as a commit on the bench machine. The constant existed there only
as a working-tree modification; the branch published here is the same one-hunk change applied to
a16b6442.Speculation counters: how much of this is round count and how much is round speed
ninfer_benchreports decode as generated tokens over elapsed time. A change that alters the numbersthe model produces can alter draft acceptance, and with it the number of speculative rounds spent
on a fixed output length - which raises tokens per second with no kernel running faster. The two
effects separate exactly, because the bench prints the round count:
where
t = decode_seconds_mean / spec_rounds.spec_roundsis an exact integer printed by theengine and
decode_seconds_meanis the measured time, so the split is an identity, not a model.Here the round count is identical on both arms, so the whole figure is round speed. In the
2026-09-07 ladder,
spec_acceptance_rateagrees between master and candidate to the third decimal inall six speculative cells, and this holds at
--spec dflash --draft-tokens 15, i.e. at T=16, thewidest width the change touches end to end - precisely where a round-count substitution would hide.
The earlier session's ladder made the same check across 16 groups of (draft window x point) at drafts
0, 1, 3 and 5, with
spec_rounds,spec_fallback_stepsandspec_acceptance_ratematching to thelast digit.
Which proposal head this was taken at. Every cell above ran at the product default
ProposalHead::Full(include/ninfer/types.h:82).--lm-head-draft, the configurationdocs/performance.mdpublishes in, is not covered by any cell in this package: an earlier revisionsaid it "was checked separately on the same artifact and gives the same split", and no file in
raw/supports that. Treat every number here asProposalHead::Fullonly.🤖 Generated with Claude Code