Eight weight codes per thread in the W8 row-split staging: the changed kernel 1.12x, production GEMM shapes 1.09x - #112
Merged
Neroued merged 1 commit intoAug 29, 2026
Conversation
dequant_w gave each lane one 16-bit code pair per quantisation group and broadcast the group scales across the warp with shfl_sync, because a lane's pair could belong to a group whose scale another lane had loaded. A row of a weight tile therefore cost 32 four-byte shared stores on the BK == 64 schedules and 64 on the BK == 128 one, and the broadcast cost one shuffle per row pair on the GROUPS == 2 branch and two on the GROUPS == 4 branch. The loop is now indexed by thread over eight-code chunks. A thread owns a whole chunk inside one quantisation group, so it reads its own scale halfword directly, decodes eight codes out of one uint2 and stores them with a single 16-byte store_vec through W8Bf16x8Bits, the union this file already keeps for that shape. Three quarters of the shared stores, the loop trips around them and all three shuffle sites go with it. The scale loads change shape rather than growing: every thread now loads instead of two or four lanes per warp, but the number of load instructions per tile halves on the BK == 64 schedules and is unchanged on the BK == 128 one, and each of them touches four distinct 32-bit words in four distinct banks, so it stays conflict-free. Per weight the arithmetic is unchanged: the same sign extension, the same multiply by the same group scale, the same __floats2bfloat162_rn. Only the route by which the scale reaches the thread changes. For GROUPS == 2 the old code read a uint32 at the scale tile offset and extracted >> ((gg & 1) * 16); for GROUPS == 4 it shuffled two uint32 from +0 and +4. Both resolve to the halfword at scale_tile_offset + gg * 2, which is what the thread now reads directly, and the highest byte index touched is 15, the last of the 16 in SCALE_CACHE_BYTES, as before. The 16-byte store lands on the same eight shared slots in the same order as the four 4-byte stores it replaces: w8g32_swz64(row, 8c) is a multiple of eight elements, so the byte offset is 16-byte aligned, and c ^ (row & 7) is a bijection on a row's chunks. Removing the shuffles is safe because they sat in a warp-uniform loop - the old trip count depended only on warp, so all 32 lanes were converged. Eight codes is the widest chunk that stays contiguous in As for every row, since w8g32_swz64 permutes whole eight-element runs and a wider run is contiguous only when row & 7 is even. The static_assert states what the body depends on beyond that: BK % 32 keeps gg inside GROUPS, and 8 % GROUPS keeps the scale cache a whole number of tiles. The GROUPS == 4 branch that the old code asserted on is gone with the branch, so those relations are stated where the loop now lives.
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. |
Owner
|
Great work. One suggestion for future performance reports: besides the speedup over the previous implementation, a roofline analysis, e.g. achieved memory bandwidth and tensor core peak relative to the hardware limits would be very useful. It helps clarify how much optimization headroom remains. |
Neroued
approved these changes
Aug 29, 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.
w8_rowsplit_gemm_mma'sdequant_wgave each lane one 16-bit code pair per quantisation group andbroadcast the group scales across the warp with
shfl_sync, because a lane's pair could belong to agroup whose scale another lane had loaded. A row of a weight tile therefore cost 32 four-byte shared
stores on the
BK == 64schedules and 64 on theBK == 128one. The loop is now indexed by threadover eight-code chunks: a thread owns a whole chunk inside one quantisation group, reads its own
scale halfword directly, decodes eight codes out of one
uint2and stores them with a single16-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 projection half; the MoE half was #106. 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, one commit once09aee5. Every number here was measured at1fc1cb76, itsparent; the single commit between them cancels abandoned streaming requests in
src/serveand ison no path measured here.
devreviewed: it is two commits ahead (11e76d8d,0b5b7c9a), addinga perplexity corpus and a
target_logprobsop, and touches neither this header nor its consumers.This header is instantiated by six public Op families -
linear,linear_add,linear_pair,linear_swiglu,attn_input_projandgdn_input_proj- so the change reaches all six. Note thekernel name: the
GROUPS == 4scheduleMmaR64x16C48K128A1is selected attin [41,48] forN=34816 and [34,48] for N=248320 (
src/ops/linear/w8/w8_dispatch.cpp:30-37), so this is not aprefill-only change; those extents are swept below.
What changes
One function,
src/ops/linear/w8/w8_rowsplit_gemm_mma.cuh. The staging loop is indexed by threadrather than by row pair, all three
shfl_synccall sites go - one on theGROUPS == 2branch andtwo on
GROUPS == 4- and the store goes throughW8Bf16x8Bits, the 16-byte union this filealready keeps for that shape (
w8_rowsplit_gemm_mma.cuh:22).Eight codes is the widest chunk that stays contiguous in
Asfor every row:w8g32_swz64permuteswhole eight-element runs, and a wider run is contiguous only when
row & 7is even. What the bodyneeds beyond that is now stated where the loop lives, because the
GROUPS == 4branch that used toassert it is gone with the branch:
The scale load changes shape rather than growing. Every thread now reads its own halfword instead of
two or four lanes per warp reading a word and broadcasting it, and a warp's 32 threads land on four
distinct 32-bit words in four distinct banks -
SCALE_CACHE_BYTESis 16, so consecutive rows sitfour banks apart - with same-address broadcast inside each.
LDSover the changed bodies falls48.4% in the census below.
Operator benchmarks
Existing benchmarks, product shapes,
--repeat 50, cold L2 throughbench::measure_cold_launch.w8_linear_add --production-onlycovers four different kernels and only one of them is this one, sothe rows are grouped by the kernel each shape reaches:
w8_rowsplit_gemm_mmaw8_small_t_mma(splitk8)medium_splitkThe three kernels this change does not touch give this arm's noise floor, and it is wide at the
narrow end: the single T=1 decode row reads 0.857 in one pass and 1.143 in the other, and
w8_small_t_mmaspans 0.929 to 1.066 while its median stays exactly 1.000. The changed kernel's18 rows, by contrast, land on the same median to three decimals in both passes and its worst row,
1.018, repeats. Read the worst case against that floor rather than against 1.000.
The
GROUPS == 4band is the one the kernel name points at and the one no benchmark reaches bydefault, so it is swept token by token,
tfrom 30 to 56, at both registered N:At N=34816 the band is bounded on both sides by rows that do not move, because
t <= 40goes tow8_small_tand this kernel does not appear again untilt > 48. At N=248320 the rows above theband also move:
t > 48selectsMmaR32C64and thenMmaR64C128, which are the same header.The four production W8 GEMM shapes, every one of them the changed kernel:
Sixteen cells, median 1.089, spread 1.080 to 1.118 - narrower than the
w8_linear_addrows above,which sweep T through shapes where the staging loop is a smaller share of the body.
linear --suite allcarries Q4, Q5, Q6 and W8 rows, so the formats this change cannot touch sit inthe same table as the ones it does. Two independent passes, so a row that moves can be told from a
row that is noisy:
18 of the 33 W8 rows move by more than 1% in both passes. No W8 row is slower in both passes -
the 0.859 in pass 2 is a single row that reads 1.03 in pass 1, which is why I am reporting two
passes rather than one. The 35 untouched rows span 0.996 to 1.006 across both passes, and that is
the floor the W8 numbers should be read against.
The other four Op families
Every benchmark below was run twice, so a row that moves can be told from a row that is noisy.
gdn_input_projandattn_input_projeach measure one operator in several weight formats, so thechange and the formats it cannot touch sit in the same table (pass 1 / pass 2):
gdn_input_projattn_input_projattn_input_projThe untouched formats span 0.987 to 1.032 over both passes and 40 cells; every W8 row above T=64
sits outside that band in both passes.
linear_pairputs the route boundary on display, becausekK2048Routesselects the firstConcatMmaschedule that instantiates this header at T=193:w8_linear_swiglu --production-only, 39 rows: 17 faster in both passes, median 1.107, range1.038 to 1.143; none slower in both passes; 19 flat in both. Three rows disagree between passes -
all three are
linear_swiglu.w8.splitk.mma.pair.exact_tat T=3, 11 and 17, reading 0.912/1.100,0.917/1.091 and 1.000/1.074, which is what this benchmark's scatter looks like at those token
counts rather than a route effect.
Finally, the MoE prefill benchmark is the confinement control - a different Op, which the sibling
PR #106 changed and this one does not touch:
Resources
cuobjdump --dump-resource-usage, this branch againstmaster:w8_rowsplit_gemm_mmaRegisters fall in 103 of the 120 and rise in 9; the 22 distinct shared-memory sizes are the same
list before and after; local, spill and stack are zero on both sides. No allocation, workspace
boundary, graph node or public contract changes; the diff is one file.
Instruction stream,
cuobjdump -sasson the twoninferbinaries, compared body by body. Of 2937device functions 2899 are comparable and 120 of those changed; every one of the 120 is an
instantiation of this kernel. The 38 that cannot be compared are the internal-linkage instantiations
from
src/ops/linear_pair/w8/w8_pair_gemm_concat.cu, whose symbols carry a per-compilation moduleid that differs between builds. Over the 120 changed bodies the instruction count falls
179,948 -> 161,238 (-10.4%), per body -33.3% to -1.6%, median -4.9%.
What moves, summed over those bodies:
SHFLWARPSYNCSTSLDSI2FI2FP0 -> 3720, the packed formHMMANot everything falls:
FMUL+35.2%,F2FP+17.5% andSHF+37.5% are the index arithmetic and thepacked conversion the new loop does per thread instead of per lane pair. The two extremes:
The census does not reach the 38 concat instantiations, so for those the evidence is the greedy
output gate below rather than an instruction census.
Numerics
Per weight the arithmetic is the same int8 sign extension, the same multiply by the same group
scale, and the same
__floats2bfloat162_rn. Only the route by which the scale reaches the threadchanges. For
GROUPS == 2the old code loaded auint32at the scale tile offset and extracted>> ((gg & 1) * 16); forGROUPS == 4it shuffled twouint32from+0and+4. Both resolve tothe halfword at
scale_tile_offset + gg * 2, which is what the thread now reads directly, and thehighest byte index touched is 15, the last of the 16 in
SCALE_CACHE_BYTES, as before.The 16-byte store lands on the same eight shared slots in the same order as the four 4-byte stores
it replaces:
w8g32_swz64(row, 8c)is a multiple of eight elements, so the byte offset is 16-bytealigned, and
c ^ (row & 7)is a bijection on a row's chunks. Removing the shuffles is safe becausethey sat in a warp-uniform loop - the old trip count depended only on
warp, so all 32 lanes wereconverged.
The shipped Op tests compare against tolerances rather than bits. What establishes bit-identity is
the
HMMAcensus above and greedy output byte-identical tomaster. TheGROUPS == 4schedule isreached by
tests/ops/linear/test_w8_a16.cppthroughsrc/ops/linear/w8/w8_dispatch.cpp:30-37.100% tests passed, 0 tests failed out of 94, 1 skipped, on this branch and onmasterbuilt inthe same directory in the same run. The skip is
27b_load_plan, which needs both real 27B artifactsand only one is on this box.
ninfer_qwen3_6_27b_prefix_real_test, which I reported as #105 andwhich failed on a clean tree at the previous base, passes here -
fd48e2fafixed it.End to end
Confirmation only; the operator benchmarks above are the claim. Arms alternate inside each round,
every point its own process, greedy, four rounds.
I quote the paired per-round ratios rather than a mean of means because the absolute rate drifts
between rounds on both arms; the ratios do not. The dense 27B row is the null control and behaves
like one: its four ratios straddle 1.000 and average to -0.01%. Its W8 row-split tensors are the MTP
and vision entries, which a text-only greedy run with speculation off does not exercise.
All 20 generations are byte-identical to
master.Checks not run
ncucounters:RmProfilingAdminOnlyis set on this host. Kernel-level claims come from thebenchmarks and from
cuobjdump, not from hardware counters.docs/performance.mdpublishes prefill throughput forqwen3_6_35b_a3bat a 1,024-token prefillchunk, 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.
GROUPS == 2schedules are exercised throughout, but I did not separate their rows from theGROUPS == 4ones outside the two swept bands.RTX 5090, sm_120a, driver 580.105.08, CUDA 13.1.115, Release,
-DCMAKE_CUDA_ARCHITECTURES=120a.