Eight weight codes per thread in the MoE prefill staging: routed MoE 1.10x, prefill up to +4.6% - #106
Merged
Neroued merged 1 commit intoAug 28, 2026
Conversation
Neroued
approved these changes
Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
sparse_moe_prefill_q4_gate_up_kernelandsparse_moe_prefill_qx_down_kernelstaged their weighttiles one bf16 pair per lane: each lane decoded one packed byte and issued a 4-byte
store_vec, soa row of the tile cost 32 shared-memory stores. Each thread now decodes the eight codes of one chunk
and stores them with a single 16-byte
store_vec. Per weight the arithmetic is unchanged.When you closed #96 you named "separate MoE and projection kernel optimizations" as work that
belongs in its own submission. This is the MoE half. The projection half - the same widening in
w8_rowsplit_gemm_mma, which six unrelated Op families instantiate - is a different contract and adifferent claim, so it is not in this branch; I will send it separately. The two branches are
independent by file and were each built and tested on their own; I did not build their union.
Targeted at
master.devis two commits ahead (3c937178,921698b7), both in sampling and thefrontend; neither touches anything here.
What changes
Q4MmaDecodeAtom::decode_eightbuilds four bf16 pairs from one packed word withbyte_perm.Q5MmaDecodeAtom::decode_eightandQ6MmaDecodeAtom::decode_eightsit besidedecode_pair,evaluating the same expression for the four bytes a thread now owns.
decode_pairstays: it isstill the entry point for
src/ops/common/rowsplit_grouped_mma.cuhand the Q5/Q6 row-split GEMMs.Q5RowSplitStorageandQ6RowSplitStoragepublishkHighBytesPerChunk, derived from the groupconstants they already own, so the kernel no longer infers the high-bit stride by division.
Q5DownMma::decodeandQ6DownMma::decodelose their last caller and go with it.The staging loop is indexed by thread rather than by warp, and its row width comes from
kCodeBytesPerGroupwith astatic_assertthat a row of codes decodes to exactly the tile's kwidth.
Operator benchmark
ninfer_sparse_moe_bench, product codecs, cold L2, median of 50,--execution eagerbecauseprefill is not graph-captured - the only capture site is
src/core/decode_graph.cppand the prefillchunk loop is outside it.
full_sparse_moe_device_body, speedup againstmaster:The
w8-w8routed codec is a separate kernel pair that does not take these decoders; it is aharness-stability control, not a control for the change.
All three token counts are above
kSparseMoePrefillWideMin(768), so only the wide<8,64>instantiation is timed. The narrow
<4,32>form appears in the SASS census below and in the Optests, but has no measurement here.
The five W8 benchmarks and the pair benchmark are unchanged by this branch, which is the expected
result and is included as evidence that the change is confined to the two MoE kernels:
w8_linear_add, the 18 row-split MMA shapesw8_linear_add, all 50 shapesmedium_splitkrow at 1.030w8_linear_swiglulinear --suite allgdn_input_proj, w8attn_input_proj, w8-qkvattn_input_proj, w8-qgkvlinear_pair, T 64-1024Those excursions bound the benchmarks' own noise: on a branch that touches none of those kernels,
single rows still move by 16% at T=1 and by 3.9% at T=768. Read the MoE speedups above against that
floor rather than against 1.000.
Resources
cuobjdump --dump-resource-usage, this branch againstmaster:sparse_moe_prefill*w8_rowsplit_gemm_mmaThe first row pools every
sparse_moe_prefillkernel, including the scan, gather, reduce and routerkernels this change does not touch. Only two instantiations move at all:
sparse_moe_prefill_q4_gate_up_kernel<8,64>71 -> 75 registers, against the 85 available under__launch_bounds__(256, 3), so it keeps three blocks per SM; andsparse_moe_prefill_qx_down_kernel<Q5DownMma,8,64>78 -> 77. The W8 row is there because thatkernel is the sibling PR's subject: this branch leaves it untouched, register for register.
No allocation, workspace boundary, graph node or public contract changes; the diff is four files,
all under
src/.Instruction stream,
cuobjdump -sasson the twoninferbinaries, compared body by body. Of 2927device functions, 6 changed and 2850 are identical; 71 could not be compared, because they are
internal-linkage symbols carrying a per-compilation module id that differs between builds. All six
changed bodies are instantiations of the two kernels above:
qx_down_kernel<Q5DownMma,8,64>q4_gate_up_kernel<4,32>Across all six the reduction is 6.4% to 16.9%, median 16.3%.
HMMAis identical in every one -the MMA stream is exactly what it was, and what shrinks is the work around it.
Numerics
Per weight each decoder computes the value the path it replaces computed:
ndecodes to(n ^ 8) - 8, an integer in [-8, 7] that bf16 represents exactly, soforming
128 + (n ^ 8)and subtracting 136 lands on the same bits thenibble -> int -> float -> bf16path produced.decode_pairevaluates. Withlane = 4 * chunk + ithe old high-bit indexlane >> 2ischunk(Q5) andlane >> 1is2 * chunk + (i >> 1)(Q6), which is what the newchunk pointer and
kHighBytesPerChunkreproduce.it replaces:
gemm_swz64(row, 8c)is a multiple of 8 elements, so the byte offset is 16-bytealigned, and
c ^ (row & 7)is a bijection on the row's chunks, so the runs tile each row exactlyonce.
The shipped Op tests compare against tolerances rather than bits, so they qualify the routes but do
not by themselves establish bit-identity. What does: the
HMMAcensus above, and greedy outputbyte-identical to
masterat every measured point.tests/ops/test_sparse_moe.cppreaches bothinstantiations of both kernels - its token list straddles 768 - for both down codecs.
92 pass, 1 skipped, 1 fails - on this branch and on
masterbuilt in the same directory in the samecampaign. The skip is
27b_load_plan, which requires both real 27B artifacts and only one is on thisbox. The failure is
ninfer_qwen3_6_27b_prefix_real_test, with the same message on both arms:Host checkpoint restore changed greedy output: restored=64,1248, baseline=64,56127 ... transfers=1/1/3/3. It is reported separately as #105.End to end
Confirmation only. Arms alternate inside each round, every point its own process, greedy, four
rounds. The spread column is the largest round-to-round spread among the arms in that row.
All 16 generations are byte-identical to
master. The dense Qwen3.6-27B "has a dense SwiGLU MLP inevery layer and does not contain MoE experts" (
docs/maintainer/qwen3.6-27b-model.md:41), so thatrow is a null control for this branch rather than a result.
Checks not run
ncucounters:RmProfilingAdminOnlyis set on this host. Kernel-level claims come from thebenchmark and from
cuobjdump, not from hardware counters.<4,32>instantiation is qualified by the Op tests but not benchmarked.docs/performance.mdpublishes prefill throughput forqwen3_6_35b_a3bmeasured at a 1,024-tokenprefill chunk, which is a path this change is on, so those figures would move. I have not re-run
that harness - it uses INT8 group-64 KV, prefix reuse, CUDA Graphs and five seeds through the serve
corpus - and I am not proposing edits to the document.
RTX 5090, sm_120a, driver 580.105.08, CUDA 13.1.115, Release,
-DCMAKE_CUDA_ARCHITECTURES=120a.Base is
6e8b2e2a.