Skip to content

[CUDA] Cut the MoE routing prologue/epilogue cost for small-batch decode - #31156

Open
tianleiwu wants to merge 2 commits into
mainfrom
tlwu/20260730/moe_routing_decode_cost
Open

[CUDA] Cut the MoE routing prologue/epilogue cost for small-batch decode#31156
tianleiwu wants to merge 2 commits into
mainfrom
tlwu/20260730/moe_routing_decode_cost

Conversation

@tianleiwu

@tianleiwu tianleiwu commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

Cuts the cost of the MoE routing prologue and epilogue at small batch, where those kernels stop
being negligible relative to the expert GEMMs. Touches
moe_gemm/moe_kernels.cu, moe/qmoe_kernels.cu and core/providers/cuda/cu_inc/topk_warp_sort.cuh.

Motivation and Context

At decode batch sizes the routing kernels run on a handful of blocks (grid 1 and grid 4 in the
table below), so they are latency-bound, not throughput-bound, and their fixed cost is a
measurable share of the step. Once the expert GEMVs themselves were optimized, the routing
family became the next item on the profile.

BlockRadixRank keeps a private set of packed digit counters per thread, which is what makes
the sort-based path expensive at these sizes; the replacement is a warp-level top-k in
core/providers/cuda/cu_inc/topk_warp_sort.cuh. Tie-breaking is unchanged — the same packed
stable key is used, so routing decisions are identical.

Measured impact

1x H200 SXM (SM90, 132 SM, ~4.8 TB/s HBM), CUDA 13.0, Qwen3.6-35B-A3B-NVFP4 + MTP N=3
(verify batch M=4).

Per kernel (graph OFF):

kernel before (µs) after (µs) before (ms/step) after (ms/step)
finalizeMoeRoutingKernel (grid 4) 4.64 4.18 0.185 0.169
SoftmaxTopKMergeKernel -> WarpTopKKernel 4.52 3.68 0.181 0.150
fusedBuildExpertMapsSortFirstTokenKernel (grid 1) 4.16 2.22 0.167 0.089
expandInputRowsKernel (grid 1056) 2.49 0.100 0.100 (unchanged)
routing family total 0.656 0.529

All kernels (both families): 6.033 -> 5.893 ms/step (−0.140 ms/step).

End-to-end (graph ON, 5 interleaved reps per arm):

  • before: 8.511 / 8.535 / 8.501 / 8.541 / 8.566
  • after: 8.467 / 8.382 / 8.370 / 8.374 / 8.445

8.531 -> 8.408 ms/step (−1.4%).

Left on the table (~0.05 ms/step, judged low ROI): 32-bit packed keys in WarpBitonicTopN, and
splitting finalizeMoeRouting's columns across more blocks to use more than 4 SMs' L1 bandwidth.

Tests

Existing MoE/QMoE unit tests; routing output is unchanged by construction (same stable key), so
these are parity tests rather than new coverage.

Methodology note

End-to-end deltas are quoted as ms/step from a fixed-step measurement, never tok/s: any
numerics change alters the generated sequence and therefore the MTP acceptance rate, which swamps
the speed delta. Per-kernel durations are taken with CUDA graphs off
nsys --cuda-graph-trace=node inflates durations ~35% globally and up to 3.8x for large-grid
kernels.

Note

Rebased onto main after #29919 ("Fuse MoE softmax/top-k into the expert-map prologue at
decode") merged. The two are complementary, not competing — no textual or semantic
conflict:

  • [CUDA] Fuse MoE softmax/top-k into the expert-map prologue at decode #29919's fusedRoutingBuildExpertMapsSingleTokenKernel is gated by
    isFusedMoeRoutingSupported(), which requires num_tokens == 1, ep_size == 1 and
    num_experts <= kWarpBitonicMaxSize (32). Qwen3.6-35B-A3B has 256 experts, and an MTP
    verify step has 4 tokens, so that path never engages here.
  • fusedBuildExpertMapsSortFirstTokenKernel (the general prologue) still uses
    cub::BlockRadixRank on main — this PR's BlockRadixRankMatch swap applies to it.
  • The num_experts <= 256 dispatch still lands on SoftmaxTopKMergeKernel<T, 128, 2> — this
    PR's SoftmaxTopKWarpTopKKernel replaces it for num_experts <= 256 && k <= 8.
  • finalizeMoeRoutingKernel was untouched by [CUDA] Fuse MoE softmax/top-k into the expert-map prologue at decode #29919.

#29919 also moved WarpReduceMax / WarpReduceSum / SafeInvSum / TopKNormalizeDenom into
topk_warp_sort.cuh and re-exported them into qmoe_kernels.cu with using declarations, so
the unqualified calls added here still resolve.

@tianleiwu
tianleiwu force-pushed the tlwu/20260730/moe_routing_decode_cost branch from cdd88fa to 72cff94 Compare July 30, 2026 09:53
The MoE routing kernels run once per layer with a grid of one to four blocks,
so they are bound by their own dependency chains rather than by throughput.
On the Qwen3.6-35B-A3B-NVFP4 MTP decode shape (4 rows, 256 experts, top-8,
hidden 2048) the four of them cost 0.656 ms/step of the 6.033 ms/step of
kernel time, with Nsight Compute reporting 0.07-0.4% of both compute and
memory throughput: the SMs were essentially idle waiting on dependencies.

Three changes, each aimed at the chain that a profile identified:

fusedBuildExpertMapsSortFirstTokenKernel used cub::BlockRadixRank, which
keeps a private set of packed digit counters per thread. Its shared memory
and its per-thread scan both scale with 2^LOG2_NUM_EXPERTS, and the block
size is picked from the token count, so 256 experts on a 4-token decode meant
512 bins spread over a single warp: 16KB of shared memory, 255 registers per
thread, local memory spilling, and 1784 instructions to rank 32 assignments.
Switching to cub::BlockRadixRankMatch, which ranks with warp ballots instead
of per-thread counters, drops that to 2.2KB, 64 registers, no spilling and
787 instructions. 4.16 -> 2.22 us.

SoftmaxTopK for 64 < num_experts <= 256 fully sorted every logit through
cub::BlockMergeSort to read the first k, and the per-round MergePath searches
and barriers dominated the chain. SoftmaxTopKWarpTopKKernel instead keeps one
row per warp entirely in registers: each lane bitonic-sorts its own keys and
the warp runs a butterfly of bitonic top-N merges. Because merging two
descending sequences as C[i] = max(A[i], B[N-1-i]) is symmetric in A and B, a
single __shfl_xor_sync per key leaves both partners with the same result, so
there is no shared memory and no barrier anywhere in the kernel. The new
BitonicSortRegistersDescending / BitonicCleanDescending / WarpBitonicTopN
primitives live next to the existing warp sort helpers. Tie-breaking is
unchanged (same packed stable sort key). 4.52 -> 3.68 us.

finalizeMoeRoutingKernel re-read the routing metadata from global memory for
every column tile through a chain of two dependent loads, which left it
long-scoreboard bound at 7.5 stall cycles per warp. It now stages the
metadata in shared memory once per block, as the num_rows == 1 specialisation
already did, so the k expanded-row loads are independent and can all be in
flight. The staging is a template parameter so the k > 256 fallback keeps the
original path without a branch in the hot loop. 4.64 -> 4.18 us.

Routing family 0.656 -> 0.529 ms/step, all kernels 6.033 -> 5.893 ms/step.
Interleaved end-to-end A/B over five reps: 8.511/8.535/8.501/8.541/8.566 ->
8.467/8.382/8.370/8.374/8.445, i.e. 8.531 -> 8.408 ms/step (-1.4%).

Tests: onnxruntime_provider_test '*QMoE*:*MoE*:*TopK*' 72 passed;
test_qmoe_nvfp4_cuda.py 22 passed; test_moe_cuda.py 68 passed with the two
TestSparseMixer failures reproducing identically on the parent commit.
@tianleiwu
tianleiwu force-pushed the tlwu/20260730/moe_routing_decode_cost branch from 72cff94 to 0b22d02 Compare July 30, 2026 21:06
@tianleiwu
tianleiwu requested a review from Copilot July 30, 2026 21:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes CUDA Mixture-of-Experts (MoE) routing overhead in small-batch decode scenarios by replacing heavier sort/rank primitives with warp-register top-k and lighter ranking, and by staging routing metadata to reduce dependent global-memory reads in the routing epilogue.

Changes:

  • Added a register-only warp bitonic Top-N primitive (WarpBitonicTopN) to topk_warp_sort.cuh.
  • Introduced a new warp-per-row softmax + top-k kernel for num_experts <= 256 && k <= 8 in qmoe_kernels.cu, avoiding shared memory and block merge-sort overhead.
  • Swapped MoE prologue ranking to a match-based radix ranker and added shared-memory staging of routing metadata in finalizeMoeRoutingKernel to reduce latency at low occupancy.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
onnxruntime/core/providers/cuda/cu_inc/topk_warp_sort.cuh Adds register-level bitonic sort/clean and a warp Top-N merge primitive used for fast top-k selection.
onnxruntime/contrib_ops/cuda/moe/qmoe_kernels.cu Adds a warp-per-row softmax + top-k path for (64, 256] experts and small k, dispatching to the new warp Top-N routine.
onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_kernels.cu Replaces block radix rank with a match-based variant and stages routing metadata in shared memory for faster finalize routing at decode.

Comment thread onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_kernels.cu Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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