Conversation
…fallback When SGLANG_OPT_DEEPGEMM_HC_PRENORM is off, mhc_fused_post_pre's fallback comment claims it mirrors mhc_pre, but mhc_pre runs the split-K pre-norm GEMM up to 2048 tokens while the fallback calls the non-split kernel. That kernel's grid is ceil(num_tokens / 32) blocks, so a decode batch of 40 streams the whole 1.5 MiB fp32 fn matrix through two SMs: 84.1 us instead of 12.4 us on sm_120 (103.5 vs 12.5 on sm_89), 3.1 ms per decode step over DeepSeek-V4-Flash's 43 layers. Batches at or below the 32-token FMA threshold take a different path and never see it. Extract the split-K launch into _mhc_pre_gemm_sqrsum_splitk so both call sites share it, and take it in the fallback under the same condition mhc_pre uses, tracking the resulting last GEMM dim in gemm_last_dim. hc_hidden_size values the split-K kernel is not specialized for keep the plain kernel rather than starting to raise, as does any batch above MHC_PRE_SPLITK_MAX_TOKENS. Against a float64 reference the split-K path is slightly more accurate than the kernel it replaces (post 5.53e-05 vs 5.86e-05, comb 7.91e-05 vs 8.40e-05 at 40 tokens) and identical on layer_input; residual is bit-identical. The new test asserts the branch rather than only the outputs, since the plain kernel is correct and merely slow.
zzjc1234
requested review from
BBuf,
DarkSharpness,
HaiShaw,
HydraQYH,
celve and
yuan-luo
as code owners
September 8, 2026 09:33
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
mhc_fused_post_preis the mHC layer boundary DeepSeek-V4 runs between every pair of layers. Abovefma_token_threshold = 32tokens it splits intomhc_post+ a pre-norm GEMM, and whenSGLANG_OPT_DEEPGEMM_HC_PRENORMis off it takes a fallback whose comment reads:It does not mirror
mhc_pre. In the same file and the same configuration,mhc_prerunsmhc_pre_gemm_sqrsum_splitk_kernelfor any batch up to 2048 tokens, while this fallback calls_mhc_pre_gemm_sqrsum_dispatch()— the non-split kernel, whose grid isT.Kernel(T.ceildiv(num_tokens, token_block))withtoken_block=32. At a decode batch of 40 that is 2 thread blocks, so 2 SMs stream the whole 1.5 MiB fp32fnmatrix while the rest of the device idles.Measured standalone at DeepSeek-V4-Flash shapes (
hidden_size=4096,hc_mult=4, sohc_hidden_size=16384), 40 tokens:mhc_fused_post_pre, plain fallbackmhc_fused_post_pre,mhc_pre's split-KThe op itself only has ~12 us of work in it: at 40 tokens the input is 40 x 4 x 4096 bf16 (1.3 MB) and the weights are 1.5 MiB, so the whole boundary is a few microseconds of traffic plus launch overhead. The remaining 72 us is one kernel occupying two SMs. Over the 43 layers of DeepSeek-V4-Flash that is 3.1 ms per decode step on sm_120 that no configuration needs to pay.
Who reaches this. A DeepSeek-V4 decode batch above 32 with the DeepGEMM pre-norm path off, i.e. any of:
SGLANG_OPT_DEEPGEMM_HC_PRENORM=0set directly;SGLANG_ENABLE_JIT_DEEPGEMM=0, which is exactly the configuration in [Bug] NameError: name 'deep_gemm' is not defined in tf32_hc_prenorm_gemm when SGLANG_ENABLE_JIT_DEEPGEMM=0 #29738 (NameError: name 'deep_gemm' is not definedintf32_hc_prenorm_gemm, being fixed in Fix DeepGEMM import for independently enabled MHC prenorm #38442) — turning the pre-norm flag off is the workaround people land on;Batch <= 32 takes the FMA path and never sees it, which is why a batch-size-dependent 6x on a layer-boundary op has gone unreported.
The two have differed since
mhc_fused_post_prewas introduced in #25976 —mhc_prehad already moved to the split-K kernel in #23882 three weeks earlier. It cost nothing at the time, because the fused path was opt-in; #34019 and #35214 made it the default in August, and today'smain(ccfa120dae) still takes the plain kernel here.Root cause
mhc_preandmhc_fused_post_preeach open-code the same "no DeepGEMM" decision, and onlymhc_prewas updated when the split-K kernel landed.mhc_pre:mhc_fused_post_pre:The two kernels compute the same thing; they differ only in how the
hc_hidden_size = 16384reduction is parallelized. The split-K kernel launches(ceil(num_tokens / 32), split_k)blocks withsplit_k = 32, so each block reads 1/32 offnand the reduction over splits is folded intomhc_pre_big_fuse, which already takes ann_splitsargument. The plain kernel gives one block per 32 tokens and no split at all, so its occupancy is set entirely by the batch size.CUDA profile of the boundary at 40 tokens on sm_120 (us per call):
Nothing else moves: the split-K partials cost
big_fuse0.3 us more, and the 76.6 us kernel becomes a 4.1 us one.Modifications
Extract the split-K launch into
_mhc_pre_gemm_sqrsum_splitk, shared by both call sites, so the branch cannot drift again — the duplication is what caused this. The kernel selection constants come with it:mhc_prekeeps its exact behaviour; its inlineif hc_hidden_size == 16384 / elif 28672 / else raisemoves into the helper unchanged.mhc_fused_post_pretakes the helper on the same condition, tracks the resulting last GEMM dim ingemm_last_dim(32 for split-K partials,hc_mult3otherwise) the waymhc_prealready does, and gains the samen_splits_pre: int = 32parameter.One deliberate asymmetry: for an
hc_hidden_sizethe split-K kernel is not specialized for,mhc_preraisesNotImplementedErrorbutmhc_fused_post_prehas always accepted any size through the plain kernel. The new branch is therefore anelifguarded onhc_hidden_size in MHC_PRE_SPLITK_HIDDEN_BLOCK, and unqualified shapes keep the plain kernel rather than starting to raise. Batches aboveMHC_PRE_SPLITK_MAX_TOKENSalso keep it, matchingmhc_preand the measurements below.No new JIT compilation:
mhc_pre_gemm_sqrsum_splitk_kernelisfunctools.cached on(hc_mult3, hc_hidden_size, split_k, token_block, hidden_block)and the fallback passes the argumentsmhc_prealready passes, soprewarm_mhc_precovers it.test_mhc_fused_post_pre_no_deepgemm_matches_mhc_preasserts the branch, not just the numbers. WithSGLANG_OPT_DEEPGEMM_HC_PRENORMoverridden off it counts_mhc_pre_gemm_sqrsum_dispatchcalls across anmhc_post+mhc_prereference and anmhc_fused_post_precall at 40 and 64 tokens, and requires zero from both. This is the assertion that fails on the pre-fix code — a numeric-only test would not, because the plain kernel is correct, just slow:It then checks the outputs against the unfused sequence at the tolerances the neighbouring test uses. The TP-group bypass the existing test needed is factored into
_bypass_tp_groupand shared.Accuracy Tests
Switching a reduction from one accumulator to 32 partials changes fp32 summation order, so the honest question is not whether the two kernels agree bit-for-bit but which is closer to the truth. Against a float64 reference of the same
mhc_post->mhc_presequence (both kernels consume a bit-identical bf16residual_cur, so this isolates the GEMM), mean|diff|/ mean|ref|on sm_120:postplainpostsplit-Kcombplaincombsplit-Klayer_inputplainlayer_inputsplit-KThe split-K path is consistently more accurate on
postandcomb— pairwise summation over 32 partials loses less than one long accumulator — and identical onlayer_input, whose error is bf16 output rounding, not the GEMM.residualis bit-identical (it comes frommhc_post, untouched). Direct plain-vs-split-K deltas are correspondingly small:post<= 1.7e-05 andcomb<= 7.8e-06 absolute at 40 tokens, and 327 of 163840layer_inputelements differ by more than one bf16 ulp.Unit tests, RTX PRO 6000 Blackwell (sm_120),
test/registered/kernels/ops/layernorm/test_mhc_kernels.py:28 = the 24 existing cases plus the 4 new ones. Both configurations were run because the changed branch is only reachable in the second.
No model eval was run. The DeepGEMM path — the default, and what CI exercises — is untouched, and on the fallback the change is a reduction-order difference that measures closer to a float64 reference than what it replaces.
Speed Tests and Profiling
mhc_fused_post_prealone, CUDA-graph replay, 200 replays,hidden_size=4096/hc_mult=4/norm_weightset,SGLANG_OPT_DEEPGEMM_HC_PRENORM=0. "before" is the plain fallback, "after" is split-K; A/B in one process so the two share compilation and allocator state.RTX PRO 6000 Blackwell (sm_120):
RTX 4090 (sm_89):
The shape of the curve is the diagnosis. Below 32 tokens nothing changes, because that is the FMA path. From 33 to 128 the plain kernel is flat at ~84 us regardless of batch — it is not doing more work, it is doing the same work on 2 to 4 SMs — while split-K is flat at 12.4 us. By 512 tokens the plain grid is wide enough to matter and the gap narrows; by 2048,
MHC_PRE_SPLITK_MAX_TOKENS, they converge, which is why bothmhc_preand this fallback stop using split-K there.For DeepSeek-V4-Flash (43 layers) at a decode batch of 40 on sm_120, 43 x 71.7 us = 3.1 ms per decode step returned to the model. Standalone the kernel has the device to itself; in a real forward pass it is competing for SMs with the rest of the layer, so this is a floor rather than an estimate. No full-model end-to-end benchmark is claimed here — the numbers above are
mainplus this patch and are exactly what the attached script reproduces.Repro (both tables and the accuracy table come from this script):
# /tmp/bench_mhc_fused_post_pre.py, attached below SGLANG_OPT_DEEPGEMM_HC_PRENORM=0 python3 /tmp/bench_mhc_fused_post_pre.py BENCH_ACCURACY=1 SGLANG_OPT_DEEPGEMM_HC_PRENORM=0 python3 /tmp/bench_mhc_fused_post_pre.py 40 64 512 2048 BENCH_KERNELS=1 SGLANG_OPT_DEEPGEMM_HC_PRENORM=0 python3 /tmp/bench_mhc_fused_post_pre.py 40bench_mhc_fused_post_pre.py
Checklist
Happy to open a tracking issue if maintainers prefer one. If the benchmark script is worth keeping in-tree I can add it under a benchmark directory instead of inlining it here.
CI States
Latest PR Test (Base): ❌ Run #34210698260
Latest PR Test (Extra): ❌ Run #34210697766
Latest PR Test (AMD ROCm 7.2): ❌ Run #34210698066