Skip to content

perf(ops): warm L2 for the next projection from the MoE down tail, issuing the hint before the block barrier - #206

Merged
Neroued merged 1 commit into
Neroued:masterfrom
MichaelDementii:perf/moe-weight-prefetch-plan
Sep 8, 2026
Merged

perf(ops): warm L2 for the next projection from the MoE down tail, issuing the hint before the block barrier#206
Neroued merged 1 commit into
Neroued:masterfrom
MichaelDementii:perf/moe-weight-prefetch-plan

Conversation

@MichaelDementii

Copy link
Copy Markdown
Contributor

Rebased onto a16b6442. Where the numbers below come from

Base. This package was written against e3aeaf8c, first re-based onto ad0f3d38, and now
targets upstream a16b6442 (docs: organize performance reports and update 35b results).
Its parent is 487f8977 (perf(sparse_moe): one CTA per token in small-T S2, and a warp merge in place of eight dependent rounds) - that is the commit the submitted head sits on, and
the commit every re-measurement below was taken on. Master moved one commit further while this
was being prepared. That one commit touches thirteen files, all of them documentation -
README.md, docs/, eval/README.md, model-cards/, tools/bench/README.md - and no source,
header, kernel, test or bench file. None of the eleven files this branch touches changed in
it
: git diff --stat 487f8977 a16b6442 -- <the eleven files listed under "Change"> is empty.
Every code citation and every number in this report therefore reads the same on 487f8977 and on
a16b6442. e3aeaf8c and ad0f3d38 are still named at the point of use for the figures that
were taken on them; they are left standing rather than rewritten, so each number stays attached to
the tree it came from.

What has been re-measured on 487f8977, and what has not. Unlike the earlier editions of this
package, the headline is not carried over from an older base. Re-measured on 487f8977,
against the exact head submitted here: end-to-end decode at mtp0 and at
--spec mtp --draft-tokens 3, prefill, the bitwise output gate, ctest, clang-format, the
workspace capacity, and an nsys capture proving the edited route executes. Not re-measured,
and left attached to the base each was taken on: the register and shared-memory census
(e3aeaf8c), the HBM bandwidth ceilings and the roofline figures derived from them (ad0f3d38),
the operator sweep control, the CUDA Graph round-step control, and the
--spec mtp --draft-tokens 1 / 5 rows (all ad0f3d38). Two changes to the instrument itself bear
on the carried-over figures, and are stated once here instead of at every table:

  • The engine context cache changed sides. On 487f8977 ninfer_bench switches it off itself
    • engine_options.context_cache.enabled = false,
      bench/targets/qwen3_6_27b/ninfer_bench.cpp:157, introduced upstream by 385b30ce. On
      ad0f3d38 that line does not exist at all, so the bench inherited the engine default
      ContextCacheOptions::enabled = true and every end-to-end run behind the carried-over rows
      was taken with the cache constructed. Two bounds on how far that reaches, both stated because
      the difference has not been measured. From above: a live context cache can serve a prefix and
      shorten a prefill, which for prefill and TTFT would be a first-order difference rather than a
      rounding one. From below: the bench also sets options.execution.allow_prefix_reuse = false on
      every request (bench/targets/qwen3_6_27b/ninfer_bench.cpp:65) - byte-identical on both
      bases
      - so no request in those runs was eligible for a prefix hit at all, and what the old
      configuration carried was a constructed-but-unused cache rather than served prefixes. The
      re-measured rows are free of this: they were taken on 487f8977, with the cache off.
  • The bench flag was renamed. --mtp-draft-tokens no longer exists anywhere in the tree. The
    current spelling is --spec mtp --draft-tokens N
    (bench/targets/qwen3_6_27b/ninfer_bench_support.cpp:356-359), the same pair the product CLI
    takes (apps/cli/options.cpp:141-145). One trap in that rename is worth naming, because the
    headline of this report is measured in the arm it concerns: --spec mtp --draft-tokens 0 is
    rejected - validate_speculative_cli_options requires [1,5] for MTP
    (src/product/speculative_options.h:41-43) - so the zero-draft arm, written
    --mtp-draft-tokens 0 throughout the old text and called mtp0 below, is spelled on the current
    bench by passing neither flag. That is not a workaround but the identical engine state:
    SpeculativeOptions defaults to backend = SpeculativeBackend::None and draft_tokens = 0
    (include/ninfer/types.h:79,81), and validate_speculative_cli_options accepts None only when
    draft_tokens == 0, so "neither flag" and the rejected "--draft-tokens 0" name the same
    configuration. Every reproduction line below is written in the current spelling; the re-measured
    runs used it, the carried-over runs used the old one.

Registered tests. tests/CMakeLists.txt moved with the base. The suite that registered 104
targets on ad0f3d38 registers 114 on 487f8977 (+10), and one more of them is
artifact-gated: 20 named targets carry an explicit SKIP_RETURN_CODE 77 against 19 on
ad0f3d38, on top of the blanket rule inside ninfer_add_op_test. tests/CMakeLists.txt is
byte-identical on 487f8977 and a16b6442, so 114 is also the count on the current base.

No round of "114 of 114" is claimed, and the ctest figures below are stated as executed
tests, not as a ratio over registered ones.
ninfer_attn_input_proj_test is red on the bare
base
- an upstream defect, filed as issue #196, not something this branch introduces. And
ctest and a direct run of the same test binary have been observed to disagree on this host, so a
green ctest is not by itself evidence of anything.

Line references re-checked against a16b6442. Every file:line citation in this report was
opened on the current base. The ones that moved since the previous edition are corrected in place;
the number each one used to carry is kept here so nothing is replaced silently.

citation printed here before on a16b6442 what stands at the old number now
src/targets/qwen3_6/impl/state/round_state.cpp, the masked-draft allocation gate :176 :174 :176 is inside the block, not its condition
include/ninfer/types.h, the backend default :78 :79 :78 is the opening line struct SpeculativeOptions {
include/ninfer/types.h, ProposalHead::Full :80 :82 :80 is a comment line
the published 35B configuration docs/performance.md docs/performance/qwen3.6-35b-a3b.md:29 docs/performance.md still exists but is now an index; a16b6442 split the per-model reports out
src/serve/serve_options.h, SpeculativeOptions speculative; :46 :46 unchanged - re-checked, still correct

Ownership: the objection from #96, answered before anything else

You will ask this first, so it goes first. The form you rejected when you closed #96 is gone from
this branch.
The earlier edition of this change carried the weight range to the op through a
thread_local WeightPrefetchSpan in src/ops/wrapper/sparse_moe.cpp plus a public
set_next_weight_prefetch() setter called from TextContext. That is, word for word, the pattern
you named as unacceptable - "Hidden mutable channels such as thread-local setters or module-global
device state are also not acceptable ownership."
That edition was never opened as a PR.

What is submitted here passes the parameter explicitly, down the call chain, and stores nothing:

  • a new struct ops::SparseMoeHints { const void* next_weight_prefetch; std::size_t next_weight_prefetch_bytes; } in the public op header;
  • a second overload of ops::sparse_moe() taking const SparseMoeHints&, with the existing
    six-argument signature kept and forwarding a default-constructed SparseMoeHints{} to it. This is
    the idiom already in the tree at include/ninfer/ops/linear.h:107 / :121 - a policy-bearing
    overload beside a plain one - not a new convention;
  • the value then travels in the plan, as prefill and small-T already do:
    SparseMoeDecodePlan gains two fields, resolve_sparse_moe_decode_plan() gains a third parameter
    defaulted to {} (so the capacity-computation call site is untouched), and
    sparse_moe_decode_launch() takes const SparseMoeDecodePlan& instead of two loose arguments;
  • the 8 MiB clamp moved out of state-reading and into resolve_sparse_moe_decode_plan(), where it
    is a pure function of its arguments.

Consequences worth stating rather than leaving to be inferred:

  • thread_local is gone, and the grep is in the package. git grep -n thread_local over
    src include at the head returns four hits, all pre-existing random-number generators in
    src/serve/*.cpp, none of them in a file this branch touches. WeightPrefetchSpan,
    set_next_weight_prefetch, set_next_projection_prefetch and projection_prefetch_span return
    zero hits anywhere, including tests/ and bench/. There are no mutable __device__
    globals.
  • CUDA Graph capture is now correct by construction. The pointer is an ordinary kernel argument
    computed on the same path as the launch. Under the old form the value was read from thread-local
    storage, which made behaviour depend on which OS thread performed the capture and left the setter
    dead on replay. That was a live defect, not only a style objection.
  • The op is re-entrant and keeps no state between calls. The hint is read once, inside the call.
  • tests/ and bench/ are not touched by a single character:
    git diff --name-only 487f8977 HEAD | grep -E "^(tests|bench)/" is empty.
  • The MTP post-mixer passes ops::SparseMoeHints{} explicitly, so "zero under speculation" is a
    property of the structure rather than a coincidence; and the 27B target returns {} from both
    overloads of Variant::projection_prefetch_hints and never learns about prefetch at all.

The D4 kernel body, its arguments and its values are unchanged relative to the branch that was
measured.
Only the host-side plumbing was rewritten. The measurements in this report were taken on
the submitted head, in this form.

Claim level

Schedule (cross-kernel producer-consumer). No arithmetic added, no numerics changed. The two
added kernel parameters are consumed only by
asm volatile("prefetch.global.L2 [%0];"), an instruction with no destination register and no
memory side effect, so bit-identity here is a property of the instruction rather than a result the
gate had to establish.

Linked issue and scope

Issue: none. Optimisation changes are opened directly in this repository, as #191, #193, #194 and
#195 were.

One commit. 48978ea0, parent 487f8977, tree adbf1c77,
git patch-id --stable 00a1bbb5, 11 files changed, +140 -31, 30 hunks:

include/ninfer/ops/sparse_moe.h                        +22  -0
src/ops/sparse_moe/decode/sparse_moe_decode.h           +6  -1
src/ops/sparse_moe/decode/sparse_moe_decode_kernels.cu +30 -11
src/ops/sparse_moe/decode/sparse_moe_decode_plan.cpp   +14  -1
src/ops/wrapper/sparse_moe.cpp                          +9  -2
src/targets/qwen3_6/impl/runtime/text_context.h         +4  -1
src/targets/qwen3_6/impl/runtime/text_context_impl.h   +19  -4
src/targets/qwen3_6_27b/impl/variant.h                 +13  -2
src/targets/qwen3_6_27b/impl/variant.cpp                +2  -1
src/targets/qwen3_6_35b_a3b/impl/variant.h             +13  -2
src/targets/qwen3_6_35b_a3b/impl/variant.cpp            +8  -6

Of those, the device-side change is one block of ten lines in
sparse_moe_d4_nine_warp_kernel; everything else is signature and plumbing.

An earlier edition of this branch carried two commits - the warm-up, then a commit moving it
ahead of the barrier - and its evidence was a two-step ladder. That branch is not what is submitted.
The head here is a single commit that contains both, and the ladder is reported below as history,
explicitly, rather than as evidence for this head.

Environment

RTX 5090, sm_120a, driver 580.95.05, CUDA 13.1.115. Release,
-DCMAKE_CUDA_ARCHITECTURES=120a -DNINFER_BUILD_BENCHMARKS=ON -DBUILD_TESTING=ON.
Artifact Qwen3.6-35B-A3B (21.2 GiB, revision 3d960f7b). Every measurement under a GPU-exclusive
lock that refuses a card shared with a foreign process, with the card-witness window opened inside
the lock. Batch 1.

Protocol for the re-measured rows. The mtp0 session: five arms built from one tree, all arms in one
pass over one set of points, seven passes with pass 0 discarded and the median taken over the
remaining six
. The mtp3 session below is smaller and is stated separately rather than folded in:
three arms, six passes, pass 0 discarded. Arm
order reversed on alternate passes, one process per cell. Two zero controls, both printed below: M2
is a bit-identical copy of the master binary (measures run-to-run noise) and MR is master
rebuilt from the same tree (measures rebuild noise).

Observation

In the T=1 decode path sparse_moe_d4_nine_warp_kernel is the last producer before the next layer's
attention or GDN projection reads its weights from HBM. That address range is known while D4 is still
running, and L2 holds nothing of it: no earlier kernel in the round has touched it. The consumer
therefore starts every layer cold for a range that was predictable one kernel earlier. The warmed
block is capped at 8 MiB against a 96 MiB L2, so it fits with room to spare and cannot evict what it
is meant to help.

Change

  1. ops::SparseMoeHints names the next consumer's weight span; it reaches the decode path through
    the second sparse_moe() overload and two fields of SparseMoeDecodePlan (see Ownership).
  2. sparse_moe_d4_nine_warp_kernel issues prefetch.global.L2 over that span, one 128-byte line per
    thread, clamped to 8 MiB in resolve_sparse_moe_decode_plan().
  3. The hint is issued before __syncthreads() and the FP32 rank-order epilogue, so it hides
    behind the slowest warp instead of extending the grid tail.

Verification commands and results

# end to end, both arms in one pass, 7 passes, pass 0 discarded, arm order reversed on odd passes
./build/bench/ninfer_bench --weights qwen3_6_35b_a3b.ninfer \
    -pg "128,128;1000,128;4095,128;12000,128" -r 10 --warmup 2 --max-ctx 16384
#   mtp0  = pass neither --spec nor --draft-tokens
#   mtp3  = add --spec mtp --draft-tokens 3

# bitwise gate, redirection performed INSIDE the exclusive-lock wrapper
./build/apps/ninfer qwen3_6_35b_a3b.ninfer --prompt "<prompt>" \
    --greedy --seed 1 --max-new 160 --max-context 16384 --no-thinking --raw-output

ctest --test-dir build --output-on-failure

End to end, decode output t/s against master, re-measured on 487f8977 on the submitted head

mode 128 1000 4095 12000
mtp0, no speculative flag +1.944% +1.873% +1.808% +1.720%
pass spread of that arm +1.763..+2.694 +1.183..+2.190 +1.758..+1.969 +1.644..+1.795
zero control M2 (bit-identical master) +0.016% -0.006% +0.080% +0.016%
zero control MR (master rebuilt) +0.035% +0.101% +0.077% -0.010%
mtp3, --spec mtp --draft-tokens 3 -0.035% -0.049% +0.034% +0.012%
zero control M2 at mtp3 -0.029% -0.139% +0.039% +0.013%

Prefill was zero in both sessions: |delta| <= 0.06% on every median, against a zero control of
the same order. Per single run the spread is wider - up to +0.663% in one cell - and since this
report defines a cell as one process run, the 0.06% figure is a statement about medians and is
labelled as such rather than left to be read as a per-run bound.

The three-digit medians are printed because they are what the raw says, but the claim this report
makes is the interval: +1.7 to +1.9%.
The zero-control band on the short context reaches +/-0.57%
at the extremes, which is wider than the last digit of the median; the headline is stated as a range
for that reason, and the title says the range and not the medians.

The previous edition, on ad0f3d38, read lower, and both sets are left visible:

edition 128 1000 4095 12000
this head, 487f8977 +1.944% +1.873% +1.808% +1.720%
previous edition, ad0f3d38 (two commits) +1.74% +1.70% +1.53% +1.54%

The mechanism therefore reads 0.18 to 0.28 percentage points higher than previously claimed, on
a newer base and after the ownership rewrite. Nothing was tuned to obtain that; the D4 kernel is
byte-identical to the one that produced the lower figures.

The effect is flat in context length because a weight block is of fixed size; request data scales
with context, weights do not.

Why the two-step ladder is not shown as evidence. The previous edition split its evidence into
"warm only" (+1.00 / +0.98 / +0.93 / +0.91%) and "warm + reorder" (+1.74 / +1.70 / +1.53 / +1.54%),
with the reorder worth +0.60 to +0.74% of its own. Those rows cannot be reproduced from what is
submitted here
: the intermediate commit does not exist on this branch, so there is no arm to build
and no command that yields the split. They were measured on e3aeaf8c with -r 3 --warmup 1 and
four passes, on a two-commit branch, and they are printed here as history only. If the split matters
for review, say so and I will re-cut the branch into two commits and re-measure it on a16b6442
rather than carry the old figures forward.

Controls carried over from ad0f3d38, not re-run here: the CUDA Graph decode step at draft 3
read -0.03 to +0.04% over contexts 128/4095/12000; the operator sweep
ninfer_sparse_moe_bench --sweep 2:12 read +0.00% in all 22 cells of both codecs; end to end at
--spec mtp --draft-tokens 1 and 5 read +0.01% and -0.09 to -0.10%. Both zeros are structural
and the structure is unchanged on this head: the prefetch parameters exist only on Rows=1
instantiations, T >= 2 goes to sparse_moe_small_t_launch, which has no prefetch
(git grep prefetch -- src/ops/sparse_moe/small_t/ is empty), and the MTP post-mixer passes
SparseMoeHints{} explicitly. The re-measured mtp3 row above confirms the same zero on the new base.

The edited route really executes (nsys profile -t cuda --cuda-graph-trace=node,
-pg "128,16" -r 1 --warmup 0 --max-ctx 2048, on 487f8977):

bare master this head
hot-kernel signature sparse_moe_d4_nine_warp_kernel<Q5Codec,1>(..., __nv_bfloat16*) ...(..., __nv_bfloat16*, const char*, unsigned long long)
launches, Q5 / Q6 777 / 63 777 / 63
mean Q5 duration 18513.7 ns 19694.4 ns
gap before d1 0.096 us 0.096 us

Launch counts are equal, so the route is the same one; the signature carries the two new parameters,
so it is the edited kernel that runs; and D4 itself got 6.4% more expensive, which is how one
knows the if (prefetch_data != nullptr) branch is actually taken - with a null pointer it would be
skipped and the duration would match the base. At a standard deviation of 1688 ns over 777 launches,
a shift of 1181 ns is about twenty standard errors. The round as a whole got 1.8% cheaper while its
last kernel got 6.4% dearer; that is the trade the change makes, stated plainly.

Correctness: the bitwise gate, and why it is weaker than it looks

apps/ninfer, --greedy --seed 1 --max-new 160 --max-context 16384 --no-thinking, three prompts
(instruction, code, arithmetic chain), two repeats, two modes (mtp0 and
--spec mtp --draft-tokens 3), arms alternated, output redirection performed inside the
exclusive-lock wrapper, compared by MD5 and byte length:

result
this head matched master by length and MD5 12 of 12
replies below the 40-byte floor 0 (316 to 613 bytes)
zero control, master against a bit-identical copy of master 12 of 12
base repeatability, repeat 1 against repeat 2 (mtp0 only) 3 of 3

Now the part that matters more than the score. Of those twelve cells, exactly one discriminates.
The two repeats are the same deterministic run and are always byte-for-byte equal, so they carry no
information. Prompts p1 and p3 produce the same output at mtp0 and at mtp3 - they do not
distinguish even two different execution routes. Only prompt p2 responds: it differs between mtp0
and mtp3, and it is also the only prompt that differs in a deliberately-broken control
(--kv-dtype int8 against bf16 on the bare base: 617 bytes 32612429 against 545 bytes
678138cd, while p1 and p3 match). The instrument can tell outputs apart, but a battery of
three short prompts leaves it with a single sensitive cell. The previous edition of this package ran
four prompts and 64 comparisons; this one runs twelve. That is a regression in the gate, and it is
named here rather than left for review to find.

And the gate nearly lied, once. Its first run returned 36 replies of one byte each. All MD5s
matched, and "12 of 12 IDENTICAL" would have printed as a success. The cause: the model is a
thinking model, all 160 permitted tokens went into reasoning, reasoning goes to stderr, and stdout
held a newline. What caught it was the 40-byte floor on reply size, plus the strength control
(mtp0 and mtp3 returning identical empty replies is not a plausible pass). The fix was
--no-thinking, and the floor and the strength control are now standing parts of the procedure
rather than a one-off repair. This is the same class of mistake this package already documents -
measuring the wrapper's banner instead of the model's output - in a new disguise.

What the gate is and is not evidence for. The risk it guards is low by construction, not by
score: prefetch.global.L2 has no destination register, so the added values cannot enter arithmetic
at all. The gate here checks wiring, not numerics. It should not be read as the numerical validation
a change with arithmetic in it would owe you.

ctest, re-run on 487f8977 on both arms from the same build tree:

this head bare 487f8977
executed 107 of 114 registered 107 of 114 registered
passed 106 106
failed ninfer_attn_input_proj_test ninfer_attn_input_proj_test
skipped, artifact-gated 7 the same 7
wall time 901.06 s 891.34 s

The formulation this package stands behind is the exact one: the arm's result matched the base,
and the single failure reproduces on bare 487f8977 built from the same tree
(upstream defect,
issue #196). Two caveats belong with that count. NINFER_QWEN3_6_35B_A3B_WEIGHTS was not set, so
the seven artifact-gated tests were skipped - including the five that would exercise the changed
decode route, which means today's green ctest verifies nothing on the edited path. And with real
weights attached, this project has separately observed a second red test on a bare upstream base;
that observation is not from this package's raw and is mentioned only so the count above is not read
as more than it is.

clang-format (version 23.1.0, run in the repository tree with the repository's
.clang-format): delta to the base is 0. Thirteen warnings exist in
src/targets/qwen3_6/impl/runtime/text_context_impl.h on the bare base and the same thirteen exist
on the head; they are master's own, at line numbers shifted by exactly +1 by this branch's single
added #include. An earlier state of this rewrite carried +18 of its own; those are fixed.

Resources, workspace, roofline

Workspace capacity is unchanged: workspace_capacity_bytes reads 109453312 on every arm,
re-measured on 487f8977. No CLI, graph-profile or artifact change. The span is a pointer and a
length.

Register and shared-memory census - measured on e3aeaf8c, not re-run on the current base;
carried over because the D4 kernel body is byte-identical to the one censused.
cuobjdump --dump-resource-usage over sparse_moe_decode_kernels.cu.o, matched by demangled name
with template arguments: three Rows=1 bodies gain two parameters at registers 40 -> 40, shared
36 -> 36, stack 0, spills 0
; every other body unchanged. Placing the hint before rather than after
the barrier changed 0 of 33 bodies.

Roofline - measured on ad0f3d38, not re-run. The operator table puts baseline MoE decode at
715 to 867 GB/s of unique weight, i.e. 42.3 to 51.3% of the 1690.7 GB/s read ceiling measured on
this card
. The 1792.1 GB/s constant printed by the benchmark header was not reproduced and is not
used. I did not decompose the T=1 step into per-kernel byte counts and therefore quote no
roofline figure for the step this change acts on - a check not run, not a number withheld.

Provenance of the ceilings: measured on this card with the in-tree probe
tools/hbm_bandwidth_probe.cu, two independent runs under the same exclusive lock. Sustained read
1690.7 and 1690.6 GB/s (medians 1690.4 / 1690.3), write 1667.8, kernel copy 1496.8 GB/s of bus
traffic, cudaMemcpyAsync D2D 1530.8; the two runs agree to 0.01%. The probe also confirms
L2 = 96.0 MiB, 170 SMs, a 512-bit bus and an advertised peak of 1792.0 GB/s, so the sustained read
reaches 94.3% of the advertised figure. Percentages here are taken against the measured read rate,
not the advertised one.

Limitations and checks not run

  • Only non-speculative decode benefits. With any draft window the mechanism is inert and measures
    zero. Read against the published 35B report, that means it acts on one of the five runs the report tabulates (P0, M3, DS, DG, S3): P0
    ("Long-context phases", backend None) is non-speculative and is the arm this change moves; M3
    (MTP K=3), DS and DG (DFlash K=7) are speculative and get nothing
    (docs/performance/qwen3.6-35b-a3b.md, run table). That limit is in the title.
  • No direct operator benchmark exists for the T=1 route with a span set, and the rewrite makes
    this a sharper omission rather than a softer one: the public contract now does accept the span,
    but ninfer_sparse_moe_bench does not set it and its sweep starts at T=2, which never reaches
    the T=1 kernel. So there is still no production-shape operator benchmark for this mechanism. The
    evidence offered instead is the end-to-end delta, the structural controls and the nsys route
    proof. If a benchmark is a condition of merge, say so and I will write one.
  • The gate battery is weak (above): twelve cells, one of them discriminating, no long prompt. In
    particular the --max-context trap that this package itself documents - a prompt near 7500 tokens
    silently producing empty output - is not exercised by this battery at all.
  • ctest did not run on real weights, so the five tests covering the changed decode route did
    not execute.
  • No profiler capture of L2 itself: no hit-rate delta, no byte decomposition. The causal story is
    consistent with the timings - flat in context, D4 up 6.4%, round down 1.8% - but is not directly
    demonstrated. Performance counters are closed by the driver default on the host used.
  • 8 MiB of L2 is spoken for while the hint is in flight, out of 96 MiB. Nothing else measured a
    regression, which is weak evidence rather than proof.
  • Batch 1 only. The multi-request case was not measured. Note that the old reason for that gap -
    a thread-local channel of unknown behaviour across requests - is gone; the plan-carried form is
    re-entrant. What remains is simply that batched decode was not benchmarked.
  • One artifact. Qwen3.6-27B has no routed MoE and never reaches this kernel; its
    projection_prefetch_hints returns {}.
  • The 35B span lengths are literals. qwen3_6_35b_a3b/impl/variant.h writes 9216 * 2048 and
    12288 * 2048. Both are correct today - the matrices are W8G32_F16S, one byte per element - but
    they duplicate the weight's shape by hand, with no static_assert and no query of the Weight
    object. If the artifact ever changes shape, the literal will not complain; only the clamp keeps it
    safe. I would take a suggestion on deriving the length from the weight instead.
  • The hints argument reaches the prefill and small-T branches of the wrapper and is ignored there.
    Numerically irrelevant, but it is a loose end a reviewer will notice, and I would rather name it
    than have it found.
  • The in-tree dflash_round_bench does not run on current master (it bypasses EngineOptions
    normalization); root-only context_cache fields were set locally to obtain the carried-over graph
    round-step control on ad0f3d38.
  • Two measurement sessions. The re-measured decode rows and the mtp3 rows come from two sessions
    about an hour apart, with the same master binary in both. The noise band in the second is wider on
    the short context (up to +/-0.57%) than in the first (+/-0.25%). Every number above is a paired
    delta taken within a pass, so the sessions are not subtracted from one another.

Honest note on the origin of these numbers

Inside the 38-commit stack this mechanism was credited with +3.4% at mtp0, as two commits worth
+2.5% and +0.9%. Measured on its own against master it came out at +1.53 to +1.74% on
ad0f3d38, and at +1.72 to +1.94% on 487f8977 after the ownership rewrite. So the stack
figure was about twofold high, and it also failed to mention that the mechanism is inert under
speculation. The corrected figure is what the title claims.

Speculation counters: how much of this number is round count, and how much is round speed

ninfer_bench reports decode as generated tokens over elapsed time. A change that alters the numbers
the model produces can alter draft acceptance, and with it the number of speculative rounds spent
on the same fixed output length; such a change raises tokens per second without any kernel running
faster. The two effects separate exactly, because the bench prints the round count itself:

decode_time(base) / decode_time(arm)  =  [ rounds(base) / rounds(arm) ]  *  [ t(base) / t(arm) ]

where t = decode_seconds_mean / spec_rounds. spec_rounds is an exact integer printed by the
engine and decode_seconds_mean is the measured time, so the split is an identity rather than a
model.

At the operating point of the headline the question does not arise at all: at mtp0 there are no
speculative rounds, so the whole figure is round speed by construction.

For the speculative windows the check was run on ad0f3d38 and is carried over. Raw:
data/all_raw.tgz, directory e2e/, one CSV per (arm, draft window, pass), carrying spec_rounds,
spec_fallback_steps and spec_acceptance_rate. Grouped by (draft window x point), 6 arms agree
in all 16 groups, to the last digit:

draft pp128+tg128 pp1000+tg128 pp4095+tg128 pp12000+tg128
0 0 rounds, acceptance not printed 0 0 0
1 267/3 @ 0.4269662921 234/3 @ 0.6282051282 192/0 @ 1 195/0 @ 0.9692307692
3 204/0 @ 0.2955665025 186/0 @ 0.3567567568 96/0 @ 1 99/0 @ 0.9595959596
5 198/0 @ 0.1896024465 195/3 @ 0.1968253968 72/0 @ 0.8813559322 69/0 @ 0.9459459459

Which proposal head this was taken at. Every cell cited above ran at the product default
ProposalHead::Full (include/ninfer/types.h:82). The same question was put separately to
--lm-head-draft, the configuration the published 35B report uses
(docs/performance/qwen3.6-35b-a3b.md:29), at -pg 2048,384 --prefill-chunk 8192 --spec mtp --draft-tokens 3: a bit-exact arm and the base agree there as well - 690 rounds and acceptance
0.7819767442 in both
- so the split is the same under either head. A non-bit-exact arm measured
beside them in the same ladder does move the count (714 rounds, acceptance 0.7422969188), which is
what shows the instrument would have caught a substitution had there been one.

🤖 Generated with Claude Code

…suing the hint before the block barrier

In the T=1 decode path sparse_moe_d4_nine_warp_kernel is the last producer before the next
layer's attention or GDN projection reads its weights from HBM. Those weights are a fixed,
known address range while D4 is still running, and L2 holds nothing of them: no earlier
kernel in the round has touched them. The kernel now receives that range and issues
prefetch.global.L2 over it, one 128-byte line per thread, clamped to 8 MiB against a 96 MiB
L2. The hint is issued before the block barrier rather than after the FP32 rank-order
epilogue, so it hides behind the slowest warp instead of extending the grid tail.

Ownership. The range is passed explicitly and nothing is stored. A new public struct
ops::SparseMoeHints names the span; a second overload of ops::sparse_moe() takes it, with
the existing six-argument signature kept and forwarding a default-constructed
SparseMoeHints{} to it - the idiom already at include/ninfer/ops/linear.h:107 and :121.
From there the value rides in the plan, as prefill and small-T already do: two fields on
SparseMoeDecodePlan, a third defaulted parameter on resolve_sparse_moe_decode_plan() so the
capacity call site is untouched, and sparse_moe_decode_launch() taking a
const SparseMoeDecodePlan&. The 8 MiB clamp became a pure function inside
resolve_sparse_moe_decode_plan(). There is no thread-local channel, no setter and no
module-global device state; the Op keeps no state between calls and is re-entrant, and CUDA
Graph capture is correct by construction because the pointer is an ordinary kernel argument
computed on the same path as the launch. No test and no bench file is touched.

This is a cache hint: it reads no value, writes nothing, and cannot change an addition
order. cuobjdump --dump-resource-usage over sparse_moe_decode_kernels.cu.o, matched by
demangled name with template arguments, shows the three Rows=1 bodies gaining two
parameters with registers 40 -> 40, shared 36 -> 36, stack 0 and spills 0, and every other
body unchanged; issuing the hint before rather than after the barrier changes 0 of 33
bodies. That census was taken on e3aeaf8 and has not been repeated on the current base;
the D4 kernel body it describes is byte-identical to the one here.

Claim level: schedule, cross-kernel producer-consumer.

Measured on one RTX 5090 (sm_120a, CUDA 13.1.115, driver 580.95.05, Release,
-DCMAKE_CUDA_ARCHITECTURES=120a), artifact Qwen3.6-35B-A3B, batch 1, every measurement
under a GPU-exclusive lock with the card-witness window opened inside the lock. All arms
built from one tree and run in one pass over one set of points; seven passes with pass 0
discarded and the median over the remaining six; arm order reversed on alternate passes;
one process per cell; two zero controls, a bit-identical copy of the master binary and a
rebuild of master.

ninfer_bench -pg 128,128;1000,128;4095,128;12000,128 -r 10 --warmup 2 --max-ctx 16384,
decode output t/s against master at mtp0, i.e. with neither speculative flag:
+1.944 / +1.873 / +1.808 / +1.720 %, pass spread +1.183 to +2.694 %, zero controls
-0.010 to +0.101 %. The claim is the interval, +1.7 to +1.9 %; the medians are printed
because that is what the raw says, but the zero-control band reaches +/-0.57 % at the
extremes on the short context. An earlier edition of this change, on ad0f3d3 and split
across two commits, read +1.74 / +1.70 / +1.53 / +1.54 %; both sets are left visible. The
effect is flat in context length because what is warmed is a weight block of fixed size,
not request data.

At --spec mtp --draft-tokens 3 the change measures -0.049 to +0.034 % against a zero
control of -0.139 to +0.039 %, and prefill medians are zero to within 0.06 % (a single run reaches +0.663 %). That zero is
structural, not weak: the prefetch parameters exist only on Rows=1 instantiations, T >= 2
goes to sparse_moe_small_t_launch which has no prefetch at all, and the MTP post-mixer
passes SparseMoeHints{} explicitly. Carried over from ad0f3d3 and not re-run: draft 1 and
draft 5 read +0.01 and -0.09 to -0.10 %, the CUDA Graph decode step at draft 3 read -0.03
to +0.04 %, and ninfer_sparse_moe_bench --sweep 2:12 read +0.00 % in all 22 cells - the
last a control rather than evidence, since the sweep starts at T=2 and never reaches the
T=1 kernel. There is no production-shape operator benchmark for this mechanism.

Output is bit-identical to master by MD5 and length over greedy generations with neither
speculative flag and at --spec mtp --draft-tokens 3, three prompts, two repeats, arms
alternated: 12 of 12, with a master-against-master control also at 12 of 12 and no reply
below the 40-byte floor. The gate is weaker than that score suggests and the weakness is
stated rather than left to be found: the repeats are the same deterministic run, and two of
the three prompts return identical output under both modes, so exactly one of the twelve
cells discriminates. The gate was also repaired mid-work - its first run returned 36
one-byte replies because a thinking model sent every permitted token to reasoning and
reasoning goes to stderr; the 40-byte floor caught it and --no-thinking fixed it.

ctest on the base this branch sits on, both arms from the same build tree: 107 of 114
registered targets executed, 106 passed, ninfer_attn_input_proj_test failed, 7 skipped as
artifact-gated - identical on the arm and on the bare base, so the single failure is an
upstream defect (issue Neroued#196) and not something this branch introduces. No round of
"114 of 114" is claimed. Real weights were not attached, so the five tests that exercise
the changed decode route did not run. clang-format 23.1.0 delta to the base is 0.

Base and provenance. This change targets upstream a16b644; it sits on a16b644's parent
487f897, which is the commit every measurement above was taken on, and the single commit
between the two touches documentation only and none of the eleven files changed here.
Earlier heads are named at the point of use for the figures taken on them. The bench flag
--mtp-draft-tokens no longer exists: the spelling is --spec mtp --draft-tokens N, and
--draft-tokens 0 is rejected for MTP (src/product/speculative_options.h:41-43), so the
zero-draft arm is spelled by passing neither flag - the identical engine state, since
SpeculativeOptions defaults to backend None with draft_tokens 0 and the validator accepts
None only when draft_tokens is 0.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-07T09:13:59.722768Z f860368 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants