Skip to content

[MoE] Gather the cutlass MoE activation and its scales in one launch - #34915

Merged
BBuf merged 1 commit into
sgl-project:mainfrom
yuan-luo:fuse_moe_a_gather
Aug 24, 2026
Merged

BBuf merged 1 commit into
sgl-project:mainfrom
yuan-luo:fuse_moe_a_gather

Conversation

@yuan-luo

@yuan-luo yuan-luo commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Motivation

The fp8 blockwise CUTLASS MoE quantizes its activation once and then replicates rows per routed expert, so the gather it performs walks a dst2src map of m * topk entries. Until now it walked that map twice: one shuffle_rows launch 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 // 128 fp32 against k fp8 — 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:

image

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.

image

The two blue arrows are the point: token 0 is read twice because it routes to two experts, which is why the gather moves topk times the rows the quantizer ever saw. The narrow cell trailing each row is that row's scales, and it travels under the same a_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_rows it replaces, since rows * k overflows int32 well inside the shapes this path serves.

The kernel also masks its tails. The CUDA shuffle_rows takes its element count as num_cols / elems_per_thread with no remainder handling, so it 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.

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 two shuffle_rows calls 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 that torch.equal on 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 with randint and therefore contain duplicate source rows, which is the normal case here: a token is replicated once per expert it routes to.

22 passed, 6 warnings in 16.02s

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:

k tokens rows two launches fused speedup
7168 1 8 3.06 us 1.31 us 2.33x
2048 1 8 2.47 us 1.42 us 1.74x
7168 8 64 3.25 us 1.60 us 2.03x
7168 64 512 4.30 us 2.10 us 2.05x
7168 1024 8192 23.78 us 11.14 us 2.14x

Checklist


CI States

Latest PR Test (Base): ❌ Run #32585586997
Latest PR Test (Extra): ✅ Run #32585586727
Latest PR Test (AMD ROCm 7.2): ❌ Run #32585586801

@BBuf BBuf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

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.
@BBuf
BBuf merged commit 77940de into sgl-project:main Aug 24, 2026
304 of 341 checks passed
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
@yuan-luo
yuan-luo deleted the fuse_moe_a_gather branch August 28, 2026 05:55
saturn-acc pushed a commit to saturn-acc/sglang that referenced this pull request Aug 31, 2026
jakki-amd pushed a commit to jakki-amd/sglang that referenced this pull request Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants