[MoE] Gather the cutlass MoE activation and its scales in one launch - #34915
Merged
Merged
Conversation
yuan-luo
requested review from
BBuf,
DarkSharpness,
Edwardf0t1,
Fridge003,
HaiShaw,
HydraQYH,
Ying1123,
celve,
ch-wan,
ispobock and
merrymercy
as code owners
August 15, 2026 06:13
yuan-luo
force-pushed
the
fuse_moe_a_gather
branch
from
August 16, 2026 09:24
b85662c to
859d538
Compare
The fp8 blockwise cutlass MoE quantizes its activation once and then replicates rows per routed expert, which took two shuffle_rows launches walking the same dst2src map: one for the fp8 values, one for the fp32 group scales. The scale gather moves 1/32 of the bytes the value gather does (k // 128 fp32 against k fp8), so as its own launch it was almost pure latency -- and at low concurrency the whole gather is a few tens of KB, where latency is all there is. Add sglang.kernels.ops.moe.shuffle_rows_with_scales, a triton kernel that walks the map once and writes both, registered as a TRITON KernelSpec per RFC sgl-project#29630. The grid is (dst_row, k_tile); the tile that owns column 0 also carries the scale row, which is 1/32 the width and does not deserve a launch of its own. The result is bit-identical to the pair it replaces: rows are copied, never recomputed, and the values move as bytes rather than as fp8, so a random byte pattern that happens to be a NaN survives unchanged. Row bases are computed in int64, matching the CUDA shuffle_rows for the same reason. BLOCK_K is a bytes-per-thread knob rather than a parallelism one, which only shows up at prefill sizes: at 512 the kernel puts 4 bytes in each thread, a quarter of the 128 bits per thread the CUDA kernel vectorizes to, and runs at half its speed on the last row of that table. 4096 puts 32 bytes per thread and lands on the plateau. Low-concurrency shapes measure the same at every setting, since all that is being timed there is the launch. The kernel masks its tails, where the CUDA shuffle_rows takes its element count as num_cols / elems_per_thread with no remainder handling and so only moves a whole fp32 scale row when (k // 128) % 4 == 0. Every k that is a multiple of 512 is unaffected either way, which covers the shapes this path serves today.
yuan-luo
force-pushed
the
fuse_moe_a_gather
branch
from
August 22, 2026 16:43
859d538 to
83a7b61
Compare
longxin9715
added a commit
to longxin9715/sglang
that referenced
this pull request
Aug 24, 2026
…n-transport1 * 'main' of https://github.com/sgl-project/sglang: (326 commits) [diffusion] feat: cache LoRA-merged weights in files the page cache can hold (sgl-project#36062) [diffusion] Speed up LingBot high-quality VAE decode (sgl-project#36024) [diffusion] Honor XDG cache for model overlays (sgl-project#36019) Support streaming session on NPU (sgl-project#32597) fix(xpu): read enable_deterministic_inference from the config bag (sgl-project#36149) xeon ci fail fast strategy change (sgl-project#36146) [diffusion] Fix Hunyuan QKV pack indexing at production video shapes (sgl-project#36009) [diffusion] Refresh quality and BCG benchmark skills (sgl-project#36016) [MoE] Gather the cutlass MoE activation and its scales in one launch (sgl-project#34915) [diffusion] feat: add plain component weight overrides (sgl-project#36086) [diffusion] feat: support loading mixed w4a8 text encoders (sgl-project#36037) [diffusion] Default Hunyuan VAE to tiled decode (sgl-project#36012) fix(xpu): enable compressed-tensors FP8 W8A8 on XPU (RedHatAI FP8-dynamic models) (sgl-project#33057) chore: move cuda_vmm_utils.py under srt/utils/ (sgl-project#36053) [Intel XPU] Add xpu pass for biased_topk and hash_topk (sgl-project#33323) [CPU] Fix NUMA/core binding for DP ranks (sgl-project#32856) [Fix] Harden FlashAttention CUDA graph metadata bounds (sgl-project#35454) [XPU] Use a fused GDN kernel from sgl-kernel for Qwen3.5 (sgl-project#33354) [diffusion] Fuse LongCat-Image QKNorm and interleaved RoPE (sgl-project#35995) [diffusion] Keep LongLive2 components resident on large GPUs (sgl-project#35993) ... # Conflicts: # python/sglang/srt/multimodal/processors/base_processor.py # python/sglang/srt/server_args.py
saturn-acc
pushed a commit
to saturn-acc/sglang
that referenced
this pull request
Aug 31, 2026
…gl-project#34915) Co-authored-by: luoyuan.luo <luoyuan.luo@antgroup.com>
jakki-amd
pushed a commit
to jakki-amd/sglang
that referenced
this pull request
Sep 9, 2026
…gl-project#34915) Co-authored-by: luoyuan.luo <luoyuan.luo@antgroup.com>
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.
Motivation
The fp8 blockwise CUTLASS MoE quantizes its activation once and then replicates rows per routed expert, so the gather it performs walks a
dst2srcmap ofm * topkentries. Until now it walked that map twice: oneshuffle_rowslaunch for the fp8 values and a second one for the fp32 group scales.The second walk moves a thirty-second of the bytes the first one does —
k // 128fp32 againstkfp8 — so as a launch of its own it is almost entirely latency. That is exactly the cost that matters where this path spends its time: at low concurrency the whole gather is a few tens of KB, and the launch is the work.Where the gather sits, between quantizing the activation once and the first grouped GEMM:
Modifications
A triton kernel that walks the map once and writes both tensors, registered as a TRITON
KernelSpec(op="moe.shuffle_rows_with_scales") per RFC #29630. The grid is(dst_row, k_tile); the tile that owns column 0 also carries that row's scales, which are 1/32 the width and do not deserve a launch of their own.The non-mxfp8 path calls it instead of the two
shuffle_rows. The SM100 mxfp8 ES branch gathers a bf16 activation through a different kernel and is untouched.The two blue arrows are the point:
token 0is read twice because it routes to two experts, which is why the gather movestopktimes the rows the quantizer ever saw. The narrow cell trailing each row is that row's scales, and it travels under the samea_map— that shared map is the whole reason one kernel can produce both.Rows are copied as bytes rather than as fp8 — the values are never decoded — so the kernel works for any 1-byte dtype and cannot perturb them. Row bases are computed in int64, matching the CUDA
shuffle_rowsit replaces, sincerows * koverflows int32 well inside the shapes this path serves.The kernel also masks its tails. The CUDA
shuffle_rowstakes its element count asnum_cols / elems_per_threadwith no remainder handling, so it only moves a whole fp32 scale row when(k // 128) % 4 == 0; everykthat is a multiple of 512 is unaffected either way, which covers the shapes this path serves today.mxfp8 MoE is not affected by this PR. It will be addressed in the next PR.
Accuracy Tests
The result is bit-identical to the pair of calls it replaces, by construction rather than by tolerance.
test/registered/kernels/ops/moe/test_shuffle_rows_with_scales.py(new) checks that three ways, on B200: against plain torch advanced indexing as the oracle, against the twoshuffle_rowscalls for drop-in equivalence, and on the zero-row short circuit. Comparisons are made on integer views of both tensors — a random fp8 byte pattern is a NaN often enough thattorch.equalon the float view would report a difference where the bytes agree, and bytes are what the kernel promises.The shape list includes
k = 896(7 scale groups), which the CUDA reference cannot express because of the remainder above, so that case is checked against the torch oracle only. Maps are drawn withrandintand therefore contain duplicate source rows, which is the normal case here: a token is replicated once per expert it routes to.Speed Tests and Profiling
test/registered/kernels/benchmark/moe/bench_shuffle_rows_with_scales.py(new) benchmarks the fused kernel against the two launches it replaces. On B200 (sm100, torch 2.11.0+cu130, triton 3.6.0), topk 8:Checklist
CI States
Latest PR Test (Base): ❌ Run #32585586997
Latest PR Test (Extra): ✅ Run #32585586727
Latest PR Test (AMD ROCm 7.2): ❌ Run #32585586801