Repository navigation
[Perf][MoE] Select full-warp top-k rows with redux.sync and keep selections in registers (SM80+) - #62912
[Perf][MoE] Select full-warp top-k rows with redux.sync and keep selections in registers (SM80+)#62912kimseunghyun-kr wants to merge 2 commits into
Conversation
|
Hi @kimseunghyun-kr, thanks for this PR. I was working on the same The difference is the output path:
On B300 (E=512, top-10, fp32 softmax), all three bitwise equal to main in 11,520 cases:
An ablation (my selection with your per-round store) runs at your PR's speed, so the gap comes from the write path. Happy to go whichever way you prefer:
If you can try it on A100, it'd be great to confirm the gain holds there too. |
|
Thanks @Gin-Sin, and thanks for the clear ablation. It shows that the write path, not the selection, is where #62912 loses time, so your version is the better kernel. Let's converge. I prefer option 1: I fold your output path (selection kept in registers, one row write at the end, rescan only in the winning lane) into #62912 and add you as co-author. Then you can close #64731. If you would rather keep your own authorship on that part, option 2 is also fine with me. Tell me which you prefer. I will run main, #62912 and #64731 on A100-SXM4-40GB (E=512, top-10, the same token counts as your table) and post the numbers here. Our deployment is Qwen3.8-Flash-Next-FP8 on 8 × A100, where decode steps carry 4–32 tokens, so the 1–128 token range matters most for us. (Drafted with AI assistance; I reviewed it.) |
|
Thanks @kimseunghyun-kr, option 1 sounds great. Please fold the output path into #62912; co-author credit as The change is in #64731 (single commit
Feel free to take or drop either. I'll follow up here with end-to-end numbers on B300 for Qwen3.8-Flash-Next (DEP4 + MTP3 on the DeepGEMM MegaMoE path from #64315, which calls |
|
@Gin-Sin, here are the A100 numbers. A100-SXM4-40GB, E=512, top-10, renormalize. Each revision of
all rows (4–256 tokens)
#64731 runs at main's speed here because its full-warp path is compiled only for (Drafted with AI assistance; I reviewed it.) |
|
Thanks @Gin-Sin. I am folding (Drafted with AI assistance; I reviewed it.) |
|
Thanks for the A100 numbers, @kimseunghyun-kr. |
|
End-to-end data on B300 for the folded-in kernel (the #64731 output path), as promised. Setup.
Kernel in-engine (TP1 torch profile, per
Serving (Δ is kernel vs main, mean of 2 runs; acceptance length is the MTP mean per arm):
Verdict:
So the kernel-level gain is real, while e2e on this setup it is at most about 1% and below run-to-run noise at every concurrency tested. Accuracy (GSM8K,
Greedy identity (200 GSM8K chat prompts, 192 tokens): main vs kernel 200/200, and main vs main 200/200. |
|
@Gin-Sin, the fold is in (c752616, with your One correction first: my earlier A100 table tested #64731 at e1a0d3c, which still had the SM100-only gate. Your current head 7a51d76 already gates on SM80+, so my "runs at main's speed" note does not apply to it. Sorry for the confusion. A100-SXM4-40GB, E 512, top-10, softmax, per call under CUDA graph replay, all four copies built side by side (sm_80):
All three changed copies are bitwise equal to main in every timed case and in 36 extra tie-heavy cases (few distinct values, all-equal rows, wide randn; fp32 / bf16 / fp16). The full table is in the PR description. The combined kernel uses your key/redux structure and your output path; the main differences are a 4-argument selection helper that returns (Drafted with AI assistance; I reviewed it.) |
|
Thanks @kimseunghyun-kr, and thanks for the correction. Here are the B300 numbers for the combined kernel. B300,
All 28 cases
Times in µs. So on B300 the combined kernel is another 4-9% faster than #64731 at every full-warp shape (1.7-2.7x vs I didn't rerun end to end: at about 0.2 µs per call this is far below the serving noise in my earlier comment, and the outputs are bitwise identical to the kernel measured there. LGTM from my side; I'll close #64731 once this lands. |
|
Thanks @Gin-Sin, for the B300 run on this head and for the review. The B300 results match the A100 results: the combined kernel is 4-9 % faster than #64731 at every full-warp shape, 1.7-2.7x faster than I agree that a rerun end to end is not necessary: the change is about 0.2 µs per call against #64731, and the outputs are bitwise identical. (Drafted with AI assistance; I reviewed it.) |
Gin-Sin
left a comment
There was a problem hiding this comment.
Approving c752616. I read the kernel and test changes. On B300 this head is bitwise equal to main in 336/336 case-runs and 1.7-2.7x faster at the full-warp shapes, with sub-warp rows unchanged (numbers in my comment above). The SM80+ gate and the separate FULL_WARP_SELECT instantiation, which keeps the existing loop's code generation untouched, look right to me.
Thanks for folding it in, @kimseunghyun-kr. I'll close #64731 once this lands.
|
This pull request has merge conflicts that must be resolved before it can be |
c752616 to
0d29a48
Compare
|
@bnellnm, could you take a look when you have time? This PR is rebased on current main (0d29a48). The only conflict was #65059 (PDL launch attribute only on SM90+); both changes are kept, and our two commits are unchanged ( (Drafted with AI assistance; I reviewed it.) |
topkGating gives each row with 512 experts (and 256 for 16-bit logits) to one full warp. For each of the k selections it runs a 5-stage shuffle butterfly with 3 shuffles per stage, so top-10 needs 150 dependent shuffles per row. Without a correction bias the choice score is the output score, which is >= +0 after the NaN/Inf clamp. For these rows, select each expert with two redux.sync max reductions over (score bits, ~expert) instead. This keeps the maximum score and the lowest-index tie rule, so the experts, their order and the weights are bit for bit the same. Selected slots are tracked in a per-lane bit mask, so the choice array is no longer written with a run-time index. Biased routing, sub-warp rows, ROCm and pre-SM80 keep the current loop. The selection write and the final scaling move into shared lambdas used by both loops. The new test checks bias None against a zero bias (which keeps the old loop) bit for bit for E 128/256/512, top-k 8/10, M 1/7/128/2048 with tie-heavy logits, and the tie order against a stable sort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: kimseunghyun <incongnito12@gmail.com>
Fold the output path of vllm-project#64731 into the SM80+ redux.sync selection. Each lane keeps its key array in registers. A round is two redux.sync ops (max key, then min expert among the lanes that hold it), and only the lane that owned the winner clears that slot and rescans. Lane i keeps selection i, so lanes 0..k-1 write the row once at the end. The previous revision stored every selection to global memory and then rescaled it in a second pass. The launcher selects a separate topkGating instantiation (FULL_WARP_SELECT) for full-warp rows without a bias and with k <= 32. Its device code uses the new path only on SM80+ CUDA; on older GPUs it is the existing loop. The existing loop is again byte-for-byte the code on main, so biased routing, k > 32, sub-warp rows, ROCm and pre-SM80 compile and run as before. Experts, their order, the weights and token_expert_indices stay bit for bit the same as main, including the lowest-index tie rule, NaN/Inf rows, padding rows, renormalize and routed_scaling_factor. The test now compares bias None with a zero bias (which keeps the old loop) for E 128/352/384/512/576, top-k 1/10/32, bf16/fp16/fp32, softmax/sigmoid, renormalize on/off, padding rows, tie-heavy and NaN/Inf rows, and checks the tie order against a stable sort. The fused top-k benchmark gets a --dtype option. Co-authored-by: Gin-Sin <ginxian@foxmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: kimseunghyun <incongnito12@gmail.com>
0d29a48 to
16849fb
Compare
Overview
topkGating(csrc/moe/topk_softmax_kernels.cu) gives each row to one full warp when the row is wide enough (512 experts, 256 experts with 16-bit logits, 128-512 experts with fp32 logits). For each of the k selections it runs a 5-stage shuffle butterfly with 3 dependent shuffles per stage, then lane 0 stores the selection and later re-reads the weights to scale them.For those rows, without a correction bias, this PR selects each expert with two
redux.syncreductions (__reduce_max_syncon the score key, then__reduce_min_syncon the expert among the lanes that hold the max), keeps selection i in the registers of lane i, rescans only in the winning lane, and writes the row once at the end. The output is bitwise equal tomain: the same experts, the same order, the same lowest-index tie rule and the same weight bits.The register-resident output path is from @Gin-Sin (#64731, co-author); the SM80+
redux.syncselection scope is from this PR. #64731 will be closed in favour of this PR.Claims
main(ids, weights as bits, token-expert indices) in every case run on A100, including tie-heavy, all-equal and NaN/Inf rows.main's time at 1-256 tokens, 0.71-0.76x at 1,024-4,096 tokens. On B300 (measured by @Gin-Sin): 1.7-2.7x faster thanmainat every full-warp shape.main, the kernel saves 2.7-4.7 us per router call on A100 (1-256 tokens) and 2.5-6.9 us on B300 (E 512, 1-464 tokens).main. No new CPU/GPU sync, no new launch, no change to the op schema.Validation
Kernel A/B, one A100-SXM4-40GB (sm_80), 31 Oct 2026. Four copies of
topk_softmax_kernels.cuare built side by side as separatetorch.utils.cpp_extensionmodules (CUDA 13.0, torch 2.13,-gencode=arch=compute_80,code=sm_80):main1755058, the first revision of this PR c7f8f96, #64731 7a51d76, and this PR c752616. Each time is per call under CUDA graph replay (100 calls x 20 replays, minimum of 3 interleaved rounds). E 512, top-10, softmax, renormalize.Bitwise checks against
mainon the same A100: every timed case above, plus 36 tie-heavy cases (fp32 / bf16 / fp16 x tokens 1 / 7 / 64 / 333 x few distinct values / all-equal rows / wide randn): 0 mismatches for all three changed copies.B300 (sm_100), measured by @Gin-Sin on this head c752616 (comment): CUDA 13.1, FlashInfer CUPTI timing with CUDA graph and cold L2, median of 3 rotated rounds; bitwise equal to
mainin 336/336 case-runs.End-to-end serving and GSM8K on B300 with the #64731 output path are in this comment: no difference beyond run-to-run noise at concurrency 16/64, about +1 % at 464 (at the edge of noise); GSM8K 0.967/0.969 (main) vs 0.971/0.969; greedy outputs identical on 200/200 prompts.
Details
Why it is exact. Without a bias the choice score is the output score. After softmax or sigmoid and the existing NaN/Inf clamp every score is >= +0, so the unsigned order of the float bits is the float order. Each lane offers
key = score bits + 1for its best unselected slot (a selected slot has key 0). The firstredux.syncfinds the maximum key; the second finds the minimum expert among lanes that hold it, which is the lowest expert index, the same tie rule as the butterfly. Only the owner lane clears its slot and rescans. Lane i keeps selection i; lanes 0..k-1 write once at the end, with the same fp32 renormalization sum order and the same scale asmain.Scope.
topkGatingrows withTHREADS_PER_ROW == WARP_SIZE, no bias, k <= 32,__CUDA_ARCH__ >= 800, not ROCm; the launcher picks a separateFULL_WARP_SELECTinstantiation, so the existing loop's code generation does not change. Rows beyondnum_rowsreturn per warp before the selection, so all 32 lanes reach eachredux.sync. TensorRT-LLM usesredux.syncfor this selection on SM100+.Tests.
test_topk_without_bias_matches_zero_bias(from #64731, extended) compares a launch without a bias against a launch with an all-zero bias, which keeps the old loop, bit for bit: E 128/352/384/512/576, k 1/10/32, bf16/fp16/fp32, softmax/sigmoid, renormalize on/off,is_paddingrows,routed_scaling_factor, tie-heavy rows with NaN/+Inf, and the tie order against a stable sort (720 cases). It has not run in a full vLLM build on our side; CI will run it. The benchmark gains a--dtypeoption for fp32 router logits.CI. There is no sm_80 runner. The A100 data come from our cluster.
Related. #64731 (Gin-Sin, same selection; its output path is folded in here). #59737 (PDL for this kernel, SM90+ only) does not overlap. #57823 (padded row strides) touches the same file.
Pull Request Checklist
I used vLLM's
/pr-checklistskill. (Mandatory for agents, optional for humans).AI assistance was used during the creation of this PR.
Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.
Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.
Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.
Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.
AI assistance: Claude Code (Claude Opus 5.5) wrote the kernel change, the tests and the benchmark; I reviewed them and ran the A/B on our A100 cluster.
🤖 Generated with Claude Code