Blackwell 上的 DeepGEMM FusedMoE(sglang)
Blackwell 上的 DeepGEMM FusedMoE(sglang)
- 基准版本:SGLang 最新 release
v0.5.21。 - DeepGEMM 版本
sgl-deep-gemm==0.2.0
TL;DR
上篇博客我们介绍了 vLLM 上 DeepSeek V4 的 MoE 层 DeepGemm 相关实现,这篇博客我想走读一下 sglang 上 deepgemm MoE 层的 DeepGemm 使用。在 sglang 中,当 runner backend 选中 deep_gemm 后,整个前向是一套固定的流程,本文按 六大阶段 组织:
- 权重预处理阶段(加载期一次性):权重与权重 scale 整理成 DeepGEMM 最终消费的布局;
- GEMM1 前:路由(TopK)→ dispatch → pre_permute,把 token 打成 grouped 布局并量化激活;
- GEMM1 计算:w13 的 grouped GEMM(gate/up 投影);
- GEMM1 与 GEMM2 间:SiLU/SiTU 激活 + 下游激活量化(融合 kernel);
- GEMM2 计算:w2 的 grouped GEMM(down 投影);
- GEMM2 后处理:post_permute → combine → 模型尾部融合与归约。
四个可以直接对号入座的量化数据类型(统一由 DeepGemmMoeQuantInfo 表达,scale_recipes 决定 GEMM 行为):
| 数据类型 | w13 / w2 dtype | 权重布局 (E,N,K) | block_shape | scale recipes (A/B) | scale 类型 |
|---|---|---|---|---|---|
| BF16 | bf16 | (E,2I,K) / (E,K,I) | — | — | — |
| FP8 block(含 per-tensor 展开) | float8_e4m3fn | (E,2I,K) / (E,K,I) | [128,128] | (None,None) | UE8M0 int32(if DEEPGEMM_SCALE_UE8M0)或 fp32 |
| MXFP8 | float8_e4m3fn | 同上 | [1,32] | (1,32)/(1,32) | 必须 UE8M0 |
| MXFP4 experts(W4A8,Blackwell 默认) | int8(packed 2×fp4) | (E,2I,K//2) / (E,K,I//2) | [128,128] | (1,128)/(1,32) | 必须 UE8M0 |
如何走到 deep_gemm:
方法一:--moe-runner-backend deep_gemm 直接显示指示走 deep gemm
方法二:--moe-a2a-backend ∈ {deepep, mooncake, nixl} + ENABLE_JIT_DEEPGEMM
fp8.py:1334-1356
方法三:--moe-a2a-backend deepep_v2 会强制使用 deep_gemm,runner.py:85-94
Blackwell 相关环境变量:
| 常量 | 成立条件 | 影响 |
|---|---|---|
ENABLE_JIT_DEEPGEMM |
sm≥90 ∧ SGLANG_ENABLE_JIT_DEEPGEMM(默认 True)∧ 包可导入;SM120/121 额外要求探测到 fp8_einsum 等 API(configurer.py:17-29,44-45) |
全链路总开关 |
DEEPGEMM_BLACKWELL |
ENABLE_JIT_DEEPGEMM ∧ is_sm100(capability[0]==10,含 100/103) |
Blackwell 专属路径 |
DEEPGEMM_SCALE_UE8M0 |
ENABLE ∧ (sm100 ∨ sm120/121) | scale 以 UE8M0 打包为 int32 存储 |
DEEPGEMM_NEED_TMA_ALIGNED_SCALES |
not (UE8M0 ∨ MUSA) | 非 UE8M0 时 scale 需 TMA 对齐 |
注:SGLang 中
is_sm100即is_sm100_supported(capability[0]==10 且 CUDA ≥ 12.8,utils/common.py:298-302),把 SM100 与 SM103 都算进来;is_sm120()/is_sm121()分别对应 (12,0)/(12,1)(utils/common.py:326-334)。本文行文中的“SM100 / Blackwell”均指前者。
1. 全景:从 Decoder 到 DeepGEMM kernel
MoE 层的全局计算:每个 token 同时经过 routed 与 shared 两条分支,最后相加:
图:DeepSeek V4 MoE 层全局计算流(V4 默认路径)。每个 token 先经 gate 打分、Top-K(V4 默认 6/256)选出专家;routed 分支的 token 经 dispatch 按目标专家分组(EP 部署下即 all2all),在被选中的专家上依次执行 gemm1(w13,gate/up 投影)→ SiLU/SiTU 激活 → gemm2(w2,down 投影),最后 combine 按 topk 权重加权合并(含 routed_scaling_factor);shared experts 分支对全量 token 始终计算一遍 MLP,两条分支的结果相加即为 MoE 层输出。V4 默认专家权重 MXFP4、激活 FP8(W4A8)。下面 §2 的六大阶段与 §3 的 DeepGEMM 接口,都对应这张图里的具体环节。
1.1 六阶段速览
| 阶段 | 位置(v0.5.21) | 关键函数 / 类 | 输入 → 输出 |
|---|---|---|---|
| ① 权重预处理(加载期) | layers/quantization/fp8.py:2004, 2743-2801;mxfp4.py:1590-1606 |
process_weights_after_loading_block_quant / _process_mxfp8_moe_weights / create_moe_runner;运行期 apply 组装 DeepGemmMoeQuantInfo |
checkpoint 权重 → deep_gemm 消费布局 + runner 选定 |
| ② GEMM1 前 | models/deepseek_v2.py:916;fused_moe_triton/layer.py:1562;moe_runner/runner.py:195;moe_runner/deep_gemm.py:970/1194/1312/1354/1698 |
TopK → dispatcher.dispatch → pre_permute_* |
hidden [M,H] bf16 → DeepGemmRunnerInput(masked 或 contig 激活 + scale) |
| ③ GEMM1 计算 | moe_runner/deep_gemm.py:455-462 / 771-779 |
grouped_gemm_nt_f8f8bf16_{contig,masked} |
激活 + w13 → gateup (·,2I) bf16 |
| ④ GEMM1 与 GEMM2 间 | moe_runner/deep_gemm.py:470-588 / 790-824 |
SiLU/SiTU + sglang_per_token_group_quant_fp8 / *_post_quant 融合 |
gateup bf16 → down 输入 fp8 + scale |
| ⑤ GEMM2 计算 | moe_runner/deep_gemm.py:597-621 / 830-894 |
第二次 grouped_gemm_nt_f8f8bf16_* |
down 输入 + w2 → (·,K) bf16 |
| ⑥ GEMM2 后处理 | moe_runner/deep_gemm.py:1236/1288/1338/1457/1847;fused_moe_triton/layer.py:1589;deepseek_v2.py:1368-1393 |
post_permute_* → dispatcher.combine → 尾部融合 + post_experts_all_reduce |
→ hidden [M,H] bf16 |
1.2 调用图
1 | |
2. 六大阶段拆解
2.1 ① 权重预处理阶段
这一块只在权重加载期执行一次(外加运行时对 quant_info 组装),目的是让 deep_gemm 在运行期只做纯 GEMM,不再碰权重布局。
2.1.1 谁触发了 deep_gemm runner
两个入口:一个在权重加载期,一个在runner 创建期。
- 加载期:
Fp8MoEMethod.process_weights_after_loading_block_quant的 CUDA 部分实现里,会先判断 deepgemm 是否会被使用will_use_deepgemm = is_deepgemm_moe_runner_backend_enabled()。这个布尔值决定权重与 scale 是否按 DeepGEMM 所需布局预处理,(具体细节详见下文 2.1.2)。 - 运行期(runner 创建):
Fp8MoEMethod.create_moe_runner):auto:若is_deepgemm_moe_runner_backend_enabled()为真 →MoeRunnerBackend.DEEP_GEMM,否则TRITON(:2764-2774);- 显式
deep_gemm等后端直接触发
is_deepgemm_moe_runner_backend_enabled(fp8.py:1334-1356)判定:
moe_runner_backend == DEEP_GEMM→ True(显式指定);AUTO∧moe_a2a_backend ∈ {DEEPEP, MOONCAKE, NIXL}∧ENABLE_JIT_DEEPGEMM→ True;- 否则 False。
另外两个来源的强制 / 例外:
- deepep_v2 强制:
runner.py:85-94校验 deepep_v2 必须搭配 deep_gemm runner; - V4 例外(
arg_groups/model_overrides/deepseek_v4.py:66-88):nvfp4_moe_meta时改为flashinfer_trtllm_routed;fp4 experts ∧ a2a=none ∧ SM90/100/120时改为flashinfer_mxfp4; SGLANG_DSV4_FP4_DEQUANT=1(fp8.py:414-418):只允许auto起步(fp4 反量化成 fp8 运行的对照路径)。
2.1.2 四条权重路径
现在我们仔细看一下 Fp8MoEMethod.process_weights_after_loading_block_quant 的 CUDA 实现细节:
1 | |
第一行 will_use_deepgemm = is_deepgemm_moe_runner_backend_enabled() 做判断,然后按权重类型分四条路。
路径 A:MXFP4 experts(Blackwell 默认路径,is_fp4_expert=True)
- 先把权重 view 成 int8(marlin / NPU / megamoe 各有早退分支)。
if deep_gemm_wrapper.DEEPGEMM_SCALE_UE8M0 and will_use_deepgemm:,对 w13/w2 分别调用:
1 | |
即把 (E, N, K/32) 的 fp32 scale 转成 DeepGEMM 要求的 MN-major、TMA 对齐、UE8M0 打包布局,并打 format_ue8m0=True。MXFP4 的 scale 本来就是 2 的幂,cast 无精度损失。输出的权重本体保持 int8 packed。
路径 B:FP4 反量化(SGLANG_DSV4_FP4_DEQUANT=1 )为 FP8
1 | |
代码会逐个 expert 调 cast_e2m1fn_to_e4m3fn(w[e], s[e]),把 fp4 无损转 fp8(e2m1 → e4m3 是精度扩展,最多补 6 个尾数位,MAX_OFFSET_BITS=6),它会先将 int8 中的 mxfp4 数据单独提取出来,分别做 dequant,然后将量化组改为 128 个一组,再重新扩展到 FP8 的量级 + offset。随后 format_ue8m0=False、self.is_fp4_expert=False,交回路径 C ,做通用 block-FP8 逻辑继续处理。
路径 C:普通 block-FP8(is_fp4_expert=False)
1 | |
对 w13/w2 分别调用 requant_block_scale_ue8m0_for_deepgemm(...)。该函数的进入条件是四连与:
use_deepgemm_runner(确实选了 DeepGEMM runner);weight_block_size == [128, 128](权重量化 kernel 只支持这一种);not format_ue8m0(尚未重排过);should_deepgemm_weight_requant_ue8m0(...)(加载器侧判定:输出 dtype、形状对齐是否满足)。
满足则 requant_weight_ue8m0_inplace → 对 3D MoE 权重做 requant,见 requant_weight_ue8m0,并走 _requant_weight_ue8m0_grouped,逐 expert 三步:
block_quant_dequant:fp8 权重 + scale 还原为 bf16;quant_weight_ue8m0→per_block_cast_to_fp8:重新按 128×128 block 量化成 fp8,scale 保持 fp32;transform_scale_ue8m0(out_s, mn=n):先index_select(-2, arange(mn)//128)把 mn 方向 128 行共享一份 scale(MN-major packing 的粒度要求),再get_mn_major_tma_aligned_packed_ue8m0_tensor将 fp32 scale 打包成 UE8M0 int32、MN-major、TMA 对齐;返回时修复 DLPack 在K ≤ 128×4时塌缩的尾维 stride。
完成后 weight_scale.format_ue8m0 = True,运行时无需再转换。注意这是数值重量化(block 量化→反量化→重量化),目的是为了将 FP8 128 blockwise 的数据重新变为 DeepGemm 要求的 FP8 quant 类型,scale 粒度依然是 128x128,但 scale 存储会变为 MN-major packed 的 int32 存储方式。
路径 D:MXFP8(use_mxfp8=True)
最后我们来看 mxfp8 量化的处理方法,在 _process_mxfp8_moe_weights(layer, quantize=not is_checkpoint_fp8_serialized)。先平台断言:要求 SM100(或 ROCm gfx95)。随后按来源分两种:
- quantize=True(checkpoint 非 fp8 序列化,需要权重在线量化):
- deep_gemm 分支
_quantize_for_deepgemm把 (E, N, K) 拍平成 2D (E·N, K) mxfp8_group_quantize调用 triton 内函数,沿 K 轴每 32 个元素一组做标准 MX 量化_ue8m0_to_fp32把 8 位指数放回 fp32 的 exponent 位,得到"值为 2 的幂的 fp32"_pack_moe_scale_for_deepgemmDEEPGEMM_SCALE_UE8M0时走get_mn_major_tma_aligned_packed_ue8m0_tensor打包成 MN-major 的 int32 TMA 行对齐的 scale。- 一句话,就是将原先的权重变为了 deepgemm 需要的 FP8 quant 格式。
- deep_gemm 分支
- quantize=False(checkpoint 已是 MXFP8):deep_gemm 分支
_convert_ue8m0_scales_for_deepgemm(:2342-2350→:2264-2271):u8 scale → fp32 → 同样打包。
结尾 _copy_or_rebind(形状/dtype 一致则原地 copy,否则重绑参数)并设置 format_ue8m0=True 表示权重已经完成处理。MXFP8 权重布局保持 (E, 2I, K) fp8 e4m3,仅 scale 变为打包布局。
注:MXFP8 的
weight_block_size通常都是[1, 32]的颗粒度
2.1.3 运行时组装:DeepGemmMoeQuantInfo
每次前向 Fp8MoEMethod.apply 在 runner_backend.is_deep_gemm() 分支)组装量化信息:
block_quant(block-FP8 / MXFP8):block_shape = quant_config.weight_block_size,scale 直接引用w13_weight_scale_inv/w2_weight_scale_inv(2.1.2 已就绪);- per-tensor(
weight_block_size is None)时:把 per-tensor scale 展开成 block 伪 scale ——这段代码关键,它将形状1
2w13_scale = w13_weight_scale.unsqueeze(1).repeat_interleave(ceil(N/128), dim=1)
.unsqueeze(2).repeat_interleave(ceil(K/128), dim=2)(E, ceil(N/128), ceil(K/128))、block_shape=[128,128]。数值上每个 128×128 block 共享同一 per-tensor scale,这样做纯粹是为了让同一套 GEMM 接口吃下 per-tensor 检查点。
DeepGemmMoeQuantInfo 字段与约束:
1 | |
__post_init__ 断言:use_mxfp8 必须 block_shape == [1, 32] 且 DEEPGEMM_SCALE_UE8M0=True。
关键方法 scale_recipes(...) 把字段翻译为 GEMM 的 A/B(激活/权重)recipe:
| 情形 | 返回 (A, B) | 说明 |
|---|---|---|
use_mxfp8 |
((1, activation_block_size), (1, 32)) |
断言 ceil_div(K, ab*4) == activation_scale_width(打包后列数匹配) |
is_fp4_experts |
((1, 128), (1, 32)) |
激活 fp8 每 128 一份;权重 fp4 每 32 一份 |
| 其余 | (None, None) |
交给 deep_gemm 默认 128×128 处理 |
MXFP4 若走 Mxfp4MoEMethod.apply(mxfp4.py:1578-1606),则等价地硬编码 DeepGemmMoeQuantInfo(block_shape=[128,128], is_fp4_experts=True),但实际 sglang 实现中没有加入 DeepGemm FP4 x FP4 的处理计算逻辑
另外记几个后续阶段会用到的 MoeRunnerConfig 字段(参考代码 moe_runner/base.py:37-69):
swiglu_limitgemm1_clamp_limitfloat,对激活函数的输出值做一些限制,防止产生极大异常值甚至溢出。在低精度量化下,MoE 路由或矩阵乘法容易产生极大的异常值(Outliers)。如果不加限制,这些异常值会导致量化缩放因子(Scale)失调,甚至引发 FP8/FP4 计算溢出(Overflow)。强制钳位可以截断极端值,保障底层 MoE Kernel 的数值稳定性gemm1_alphafloat,SwiGLU 激活函数中 SiLU 部分的缩放系数silu_mul_keep_fp32bool,表示是否需要用 FP32 来表示 SiLU 结果top_kint,MoE 路由后,决定每个 Token 被激活并分配给的 Experts 的数量num_local_expertsint,在当前并行策略下,当前卡分配到的本地 Experts 数量intermediate_size_per_partition:int,当前并行策略下,Experts 的中间隐藏层大小
2.2 ② GEMM1 前:路由、dispatch 与进料
2.2.1 模型层:路由与三段式的入口
DeepSeek V4 的 Decoder 层仍然是变种的 transformer,先跑 attention,再调用 self.mlp(hidden_states) 计算 MoE/MLP 层(deepseek_v4.py:2604,self.mlp = deepseek_v2.DeepseekV2MoE)。
若使用 data parallel attention,DP attention 场景下 MoE 前向会被包进 _run_moe_ffn_dp_sync,调用点发生在 DP attention 与 EP MoE 之间做输入/输出 gather 或 scatter(详细见 §2.6.3)。
- 一般地,
self.topk(hidden_states)产出TopKOutput(topk_ids/topk_weights)。针对 DeepSeek V4 的路由参数,有特别的 override :use_grouped_topk=False、sqrtsoftplus_log1p、fused_gate_packed_ids(model_type == "deepseek_v41"时),并把quant_config.is_fp4_experts透传给TopK)——让路由输出格式对齐 FP4 MoE 后端的约束。 - 选择走向:
should_use_mega_moe→ MegaMoE 融合路径(不在本文范围);_enable_a2a_moe,a2a ∈ {deepep, mooncake, nixl, mori, ascend_fuseep, flashinfer, deepep_v2})→forward_deepep)走 EP 并行- 否则,
forward_normal
experts 由 get_moe_impl_class(quant_config))决定(构造于 deepseek_v2.py:665-679):a2a ∈ {mori, deepep, deepep_v2, mooncake, nixl, pplx} 时是 DeepEPMoE,否则是 FusedMoE。V4 走 DeepGEMM 时通常 a2a=deepep,因此实例是 DeepEPMoE。
前向尾部 forward_normal 主要处理残差和 allreduce 处理,下文 §2.6.3 统一说明。
2.2.2 FusedMoE 的三段式骨架
MoE 实现类的前向主流程在 FusedMoE.forward_impl 中实现(fused_moe_triton/layer.py):
1 | |
_dispatch_with_pre_quant:dispatch 前可选的 pre-quant(是否预量化取决于 dispatcher 与配置);run_moe_core调用MoeRunner.run,执行真正 MoE 计算- dispatcher 由
create_moe_dispatcher)按 a2a backend 五分支选出:standard / deepep(normal+LL 共用MaybeTboDeepEPDispatcher)/ deepep_v2 / flashinfer。
而 DeepEPMoE)在这里的角色是:当 deep_gemm runner + Fp8Config 组合下为 True(其余条件还包括 deepep_v2 / humming / aiter / npu 等)时, DeepEPMoE.forward_impl)会使用 super().forward_impl(...),复用上面的现代三段式;否则才走它自带的旧式 dispatch/combine 实现。这就是为什么 DeepSeek V4 的 EP 形态最终与 FusedMoE 汇到同一段代码。
2.2.3 MoeRunner:pre_permute → core → post_permute 的编排
MoeRunner.run:略去本文不关心的代码后,主体结构就是三句话:
1 | |
PermuteMethodPool 以 (dispatch_format, runner_backend) 为字典的方式注册),如表:
| dispatch_format | pre_permute(moe_runner/deep_gemm.py) |
触发场景 |
|---|---|---|
standard |
pre_permute_standard_to_deep_gemm |
无 a2a / TP-MoE / SM120 |
flashinfer |
pre_permute_flashinfer_to_deep_gemm |
FlashInfer one-sided A2A |
deepep_ll |
pre_permute_deepep_ll_to_deep_gemm |
DeepEP 低延迟模式 |
deepep_normal |
pre_permute_deepep_normal_to_deep_gemm |
DeepEP 普通模式 |
deepep_v2 |
pre_permute_deepep_v2_to_deep_gemm |
DeepEP v2(强制 deep_gemm) |
五种都在做同样三件事:激活量化(如未量化)→ 布局重排 → 写 running_state。产物统一是 DeepGemmRunnerInput:
1 | |
2.2.4 五种 pre permute 方式
1)standard:
-
先处理 SM120:
deep_gemm_sm120.maybe_pre_permute(...)若命中直接返回;命中不了则 assert 拒绝,因为 SM120 DeepGemm 的 grouped GEMM 只能支持 standard-layout activation 布局。 -
masked 布局,处理后会直接喂给 DeepGEMM masked grouped GEMM,所谓的 masked grouped GEMM 接口可以看第三节对 DeepGEMM 的简介,简单来说 masked 接口是用内存换简单(小 batch / capture 默认),它给每个 Experts 分一个简单固定的内存空间,用不上的打 masked 遮掩,当条件允许时,
moe_ep_deepgemm_preprocess会一步完成“按 expert 对齐 slab + 激活量化”,产出masked_m / src2dst / hidden / scale;以及expected_m。他们分别指代:masked_m:每个 local experts 分给真实有效行数,即 experts 对输入 在 3D 布局 [E_local, m_max, K] 中真正需要计算的行数。通过fused_moe_dispatch_index_triton_kernel计算得到expected_m:每个 experts 在当前hidden_states的大小下分配到的 token 数的期望,调优 gemm 时使用src2dst记录 permute 过程中当前 rank 如何重排 token 的表,同样也是fused_moe_dispatch_index_triton_kernel计算得到
-
compact 布局 处理 DeepGEMM contiguous grouped GEMM,contiguous grouped GEMM 后面我们也有说到。在处理更大的 token 时,contiguous gemm 会更省内存(因为它只会保留真实有效的 token),token 也是按照 [M_total, K] 专家连续平铺。
get_contiguous_layout_alignment,DeepGEMM contiguous 布局要求的 MK 对齐(每个专家段的起始和长度都必须是它的倍数)。_get_compact_all_tokens(...),这是在计算最坏路由下的 padding 上界,保证不会越界。fused_moe_dispatch_index统计每 expert 需要的 token 数,但还需要pad_expert_counts对齐到 contiguous 的要求和all_tokens的对齐要求- 量化:
sglang_per_token_group_quant_fp8做 per-token-group 量化。使用 UE8M0 时(scale_width, all_tokens)使用 int32 4个一组,列主序布局方式;否则(all_tokens, scale_width)使用 fp32; ep_scatter(...),expert_alignment=block_e)散到 expert 连续段;- 返回
running_state:src2dst、all_tokens、contiguous_layout_alignment=block_e
何时 masked、何时 compact 由
_should_use_masked_standard_layout决定:先看 clamped/swizzled 激活是否被支持_masked_activation_unsupported_reason:swiglu_limit或 megamoe 场景要求D//8 ≥ E、group_size==128、D % (4*group)==0)、再看SGLANG_DEEPGEMM_STANDARD_LAYOUT是否强制、否则按内存预算_estimate_masked_standard_layout_peak_bytesvsfree_memory × fraction)自动选择(capture 模式禁 masked)。详见附录 B。
2)flashinfer 部分不细聊了,它仅接受 BF16 未量化 dispatch,后转交 standard 实现。
接下来是针对 DeepEP 模式的处理,先回顾一个前提:三种 DeepEP 模式的差别,本质是 dispatch(all-to-all 收发)交给 pre_permute 的"半成品"长什么样。pre_permute 要做的,就是把这个半成品整理成 DeepGEMM 能直接使用的 DeepGemmRunnerInput——要么是 masked [E_local, m_max, K],每 expert 一块固定空间、用 masked_m 标注真实行数,要么是 contiguous 平铺 [all_tokens, K],各 expert 段紧挨着、用 m_indices 标注每行属于哪个 expert。
3)deepep_ll(低延迟模式):dispatch 直接把结果摆成了 masked 布局,并一同计算了 masked_m 和 expected_m。所以这个函数几乎什么都不做,原样透传,设置 use_masked_gemm=True,activation_scale_block_size 固定写 128(LL dispatch 出口就是 128 分组的 FP8)即可。
4)deepep_normal(普通模式):与 LL 相反,dispatch 只给"按到达顺序堆在一起的 token",外加一个 num_recv_tokens_per_expert(每个 expert 各收到多少条)。这种显然与 contiguous 布局更接近,但需要自己把这些乱序 token 按 expert 重排成连续段:
- 先算总量
all_tokens = sum(num_recv_tokens_per_expert)),据此分配输出激活缓冲(all_tokens, K) - scale 形状分两种:Blackwell 的 UE8M0 走 int32 打包 + 列主序
(ceil_div(K//128,4), all_tokens).transpose(0,1);其余走普通 fp32 行主序(all_tokens, K//128) ep_scatter一步完成重排:把乱序 token 按 recv 计数散进连续段,同时产出m_indices(行→expert)和output_index(记下行原来在哪,回程时靠它把结果发回,存入),最后设置use_masked_gemm=False。
5)deepep_v2:v2 dispatcher 在 GPU 上直接算好了每个 expert 的前缀和 每个 experts 要接收到的 tokens 数量 psum_num_recv_tokens_per_expert,并带两个开关:is_expanded(token 是否已在 dispatch 阶段展开到 topk 份、基本就位)和 use_masked_gemm。按这两个开关分三条路,目标都是凑出 masked 或 contig 输入:
is_expanded || use_masked:已展开的 token 只差填入到 masked 槽这一步,expand_to_masked_slab直接把前缀和描述的数据变为 masked 3D 布局,产出 masked GEMM 输入is_expanded || ¬masked:同样已展开,但要 contiguous 布局,于是只需给每行标 expert 号——ep_expand_init_m_indices_from_psum按前缀和填m_indices,尾部没用上的行留-1¬expanded:token 还是乱序到达,退化成和 deepep_normal 一样的重排流程,只是散开时改用 v2 的前缀和ep_scatter_from_psum,scale 缓冲的 UE8M0 / fp32 分配公式与 deepep_normal 完全相同。
到这一步,两种 GEMM 形态(masked / contig)与四种量化配方(bf16 / block-fp8 / mxfp8 / mxfp4)的输入都已备好,进入 GEMM1。
2.3 ③ GEMM1 计算
入口 DeepGemmRunnerCore.run(deep_gemm.py:359-393):
1 | |
use_masked_gemm |
权重 dtype | 方法 |
|---|---|---|
| False | bf16 | _run_bf16_contiguous_gemm |
| False | fp8 / int8-packed | _run_contiguous_gemm |
| True | bf16 | _run_masked_bf16_gemm |
| True | fp8 / int8-packed | _run_masked_gemm |
注意 MXFP4 的权重经 view(torch.int8) 后 dtype 是 int8,因此和 fp8 共用“量化分支”;contiguous_layout_alignment_scope 只对 contig 生效(masked 行对齐由 slab 自身保证)。
2.3.1 contig 形态的 GEMM1 _run_contiguous_gemm
我们重点看 FP8 和 FP4 的 GEMM 计算。首先请各位读者注意,MXFP8、MXFP4 激活为 32 一组,FP8 block wise 激活为 128 一组;记住这一点后,我们再看一下权重 scale 部分:
quant_info.scale_recipes 中写死了 MXFP8 的 weight recipe 为 32,而 activation recipe 则是 follow 激活函数的量化方式,通常也是FP8 blockwise(1,128)或者 MXFP8 (1,32); MXFP4 时(is_fp4_experts=1)因为
固定为 weight recipe 为 32(MXFP4),而 activation recipe 则是 FP8 Blockwise,所以是(1,128)
1 | |
然后接着往下,会有一段针对 Hopper 架构的 TMA 对齐代码:tma_align_input_scale(hidden_states_scale):
1 | |
它将 input scale 按照 MN-major (列主序)方式排布,16 字节对齐,并且做 M 方向 的padding。目的是为了与 DeepEP v2 对齐。
然后就是调用 deep_gemm 做一个 GEMM1 计算:
1 | |
GEMM1 计算结束后,还有一个比较令人在意的细节是,程序在非 CUDA graph 时对 hidden states 做了 dispose 操作(CUDA Graph 时地址会被固化进入 graph,不能随意变动)
1 | |
这是考虑到 GEMM1 计算完成后,hidden states 这片内存空间中的数值其实已经用不上了,它只被 gate-up GEMM 消费,而且它又很占地方,所以通过 dispose_tensor 立刻将显存归还给 caching allocator,供之后的计算函数复用空间,从而压低单层 MoE 的峰值显存。x.set_(torch.empty((0,), device=x.device, dtype=x.dtype)) 这个语句操作很聪明,set_() 把 Tensor 的 storage 换成 0 字节空张量,于是可以让指向这片地址的张量别名同时失效,存储确定性地归还 allocator,但直接用 del 就做不到这一点。
2.3.2 masked 形态的 GEMM1 _run_masked_gemm
GEMM1 前的 scale 收拾:UE8M0 时若 scale 还是 fp32(dtype != torch.int),先断言各维 4 对齐,再使用 _cast_to_e8m0_with_rounding_up:fp32 将指数位提取出来,然后根据值变换为 uint8,再做 transpose(1,2).contiguous().transpose(1,2) 变成 MN-major 对齐布局;非 UE8M0 走 get_mn_major_tma_aligned_tensor,同样也是 MN-major 布局,但要 TMA 对齐,该函数功能见 deep gemm 3.6.1 章节。
1 | |
然后就是使用 GEMM1 masked gemm 计算完成,其余都和 contiguous 的一样。
1 | |
OOM 时报错信息会建议 SGLANG_OPT_DG_MASKED_M_CAP=1 收缩 M 上限。该环境变量通过将 pad 上限改为 round_up(max(masked_m), 256) 而非 round_up(rank_tokens, 256),即从按照 rank 的全部 token 预留内存(最坏情况就是全部 token 都集中到一个 experts 上),改为当下最热 experts 实际接收到的 token 数取 256 对齐,但代价是每层 MoE 多一次 D2H 同步和 fused_moe_dispatch_index 操作。
2.3.3 BF16
- contig bf16:
grouped_gemm_nt_bf16_contig×2 +_legacy_silu_and_mul(无量化); - masked bf16:
grouped_gemm_nt_bf16_masked×2 +silu_and_mul_masked_fwd;不支持 overlap
2.4 ④ GEMM1 与 GEMM2 间:激活与量化
GEMM1 返回的 gateup_output 是 (all_tokens, 2I) 或 (E, m, 2I) 的 bf16,而在一般量化情况下(FP8 / MXFP8 / MXFP4),GEMM2 的 A 侧不能是 BF16,必须已经量化处理后的。所以这一阶段只有两件事:做 gated 激活(把 gate / up 两半成 I 宽),以及做激活和量化。
为什么这两件事要挤进同一个 kernel?2I 因为如果 先算激活写回 bf16、再读回来量化 两步走,内存就需要多搬两遍;而融合 kernel 只读一遍 bf16、写一遍 fp8 与 scale,这也是为什么大家都在尽量将 kernel 融合的原因。
再强调一个贯穿全节的约束是 scale 的产出布局。GEMM2 的 A 侧 scale 由 deep_gemm 直接消费:在 UE8M0 类型下必须是 packed int32、MN-major、TMA 对齐(三条生产线的来历见附录 A)。
2.4.1 contig 形态:三个互斥分支
contig 形态的激活与量化分三条互斥支路,先给一张对照表:
| 触发条件 | 激活实现 | scale 产出 |
|---|---|---|
activation == "situ"(Kimi K3) |
UE8M0:Triton _situ_mul_quant_contig_kernel;否则纯 torch |
UE8M0:_cast_to_e8m0_with_rounding_up;否则行主序 fp32 |
use_swizzle or silu_mul_keep_fp32 |
DSV4 JIT silu_and_mul_contig_post_quant |
create_per_token_group_quant_fp8_output_scale 按消费布局直接分配 |
| 其余(含 DSV4 默认) | silu_and_mul_clamp 或 _legacy_silu_and_mul |
sglang_per_token_group_quant_fp8,UE8M0 时列主序 packed |
1)situ 分支,这是给 Kimi K3 用的 SiTU 激活:
UE8M0 平台先跑 Triton 的 _situ_mul_quant_contig_kernel 把 SiTU、amax 统计与分组量化一次做完,再用 _cast_to_e8m0_with_rounding_up 把 fp32 scale 向上取整到 2 的幂。为什么取整方向不能反:deep_gemm 的既定约定是 sf = ceil_to_ue8m0(amax / 448) 再 x / sf(fp8_utils.py:1972-1974),向上取整保证 x / sf ≤ 448;一旦向下取整,amax / sf 就超过了 448,量化那一步会直接饱和。这个 Triton kernel 的顺序是“先用精确 amax 量化、再把 scale 向上取整”,结果只会让反量化值略微偏大,同样不会溢出。
非 UE8M0 平台则退回纯 torch 计算,最后交给 sglang_per_token_group_quant_fp8(column_major_scales=False, ...) 出 fp32 行主序 scale:
1 | |
2)融合量化分支(use_swizzle or silu_mul_keep_fp32):先用 create_per_token_group_quant_fp8_output_scale 按 UE8M0 的消费布局把 scale 缓冲分配好,再调 DSV4 JIT 的 silu_and_mul_contig_post_quant 一次完成 SiLU、钳位与分组量化:
1 | |
分配函数在 UE8M0 下按 (K_g/4, aligned_m) 分配 int32、再转成 (m, K_g/4) 的视图:MN 方向补齐到 4、K 方向 4 个 uint8 打包进一个 int32,内存形态就是 deep_gemm 要的 MN-major,省掉了后续任何一次转置。
use_swizzle 在 __init__ 里定义为 megamoe ∧ deep_gemm_sm120.use_swizzle()(deep_gemm.py:355-357),DeepSeek V4 + DeepEP 下恒为 False,所以这条支路实际由 silu_mul_keep_fp32 触发。
为什么要 silu_mul_keep_fp32 这个开关:通用融合量化 kernel 会把 SiLU 的中间结果先 round 到 bf16 再量化,等于多做了一次 bf16 舍入;silu_mul_keep_fp32 让 kernel 在 fp32 里算完 SiLU 直接量化,少一次精度损失。它只对 FP8 专家权重开放(deep_gemm.py:367-368)。
3)默认分支:先激活算得 BF16,再调通用量化 kernel。有 swiglu_limit 时用 silu_and_mul_clamp(把钳位融进激活,语义与 _apply_swiglu_limit 的兜底实现一致:up 双向钳、gate 只钳上限),否则走 _legacy_silu_and_mul;随后 sglang_per_token_group_quant_fp8(column_major_scales=UE8M0, scale_tma_aligned=UE8M0, scale_ue8m0=UE8M0)——三个 flag 绑在一起,是因为“列主序”与“TMA 对齐”在 UE8M0 下本来就是同一个 int32 packed 布局的两面。DSV4 的 contig 主路径落在这里:它既不满足 swizzle 条件、默认也没开 silu_mul_keep_fp32,只能走这条默认分支;swiglu_limit 是否生效取决于模型 config 里带不带这个字段(deepseek_v2.py:677 是 getattr(config, "swiglu_limit", None)):带就用 silu_and_mul_clamp,不带就走 _legacy_silu_and_mul。
2.4.2 masked 形态:varlen 激活量化
masked 形态(deep_gemm.py:790-824)多一层麻烦:输入是 (E, m_pad, 2I) 的 slab,每个 expert 只有前 masked_m[e] 行是真实数据。激活 kernel 必须以 masked_m 为上界逐个 expert 跳行,也就是代码里的 varlen。
situ走_varlen_deep_gemm_situ_mul_quant,底层是 CUDA JIT 的situ_and_mul_masked_post_quant,量化 group 在这里被直接改写成 128(deep_gemm.py:791-800), SiTU 分支的激活量化粒度固定为 128;- 其余走
_varlen_deep_gemm_silu_mul_quant。进函数之前先算num_real_tokens(deep_gemm.py:802-812):只有use_mxfp8、DEEPGEMM_SCALE_UE8M0、topk_ids、src2dst四者齐备时才把真实 token 数传进去,因为只有“token 已展开且知道源行映射”的布局才真的数得清真实行;这个数用于把 kernel grid 限制在真实行上,避免按 padded_m 空转。
_varlen_deep_gemm_silu_mul_quant 内部还有三个子分支(deep_gemm.py:1559/1600/1634,打包细节见附录 A):
gemm1_alpha(oai-swiglu):Triton 的silu_and_mul_masked_post_quant_fwd,因为通用量化 kernel 还没有 activation-kind 这个维度,oai-swiglu 暂时只能留在 Triton 实现上。use_packed需要同时满足 UE8M0、num_real_tokens非空、G % 4 == 0、D % (group*4) == 0;packed 时先分配(E, G//4, N)的 int32,再transpose(-1,-2)把内存形态转成 MN-major。swiglu_limit/swizzle/silu_mul_keep_fp32(DSV4 主战场):DSV4 JIT 的silu_and_mul_masked_post_quant。这里有三条硬断言——N % 4 == 0、G % 4 == 0、D // 8 >= E(deep_gemm.py:1601-1604):前两条是 packed scale 与向量化的对齐要求,最后一条来自 kernel 的线程编排——block 里开D / 8个线程,而get_work要用前E个线程各读一个 expert 的masked_m做前缀和,所以必须D / 8 >= E。不满足就在激活量化处直接报错,没有退回其它分支的后路。scale 同样 packed +transpose(-1,-2)。- 默认:通用的
per_token_group_quant(fuse_silu_and_mul=True、masked_m=...、expected_m=ceil_div(num_real_tokens*topk, E)、column_major_scales=True)。它自己分配输出,scale 直接落成 deep_gemm 消费的列主序布局,所以调用方在 GEMM2 前的get_mn_major变换会短路。注意这个通用 kernel 会把 SiLU 中间值 round 到 bf16——这正是上面silu_mul_keep_fp32存在的理由。
2.5 ⑤ GEMM2 计算
GEMM2 和 GEMM1 调的是同一族 grouped 接口,差别只是 A 侧换成了刚量化好的 down 输入、B 侧换成 (E, K, I) 的 w2、N 方向从 2I 变回 K。真正值得单独拎出来说的只有三处:输出进对称内存、A 侧 recipe 要重算、masked 形态还要承担 combine 的 overlap。
2.5.1 contig 形态(deep_gemm.py:597-626)
1 | |
三处安排各有各的理由:
- 对称内存:
down_output是 GEMM2 的输出,也是 core 前向里唯一进 NCCL symmetric memory pool 的张量(注释就写在deep_gemm.py:593-596):它是这条计算链里唯一要参与下游通信的结果,放进池里就能让 all-reduce / reduce_scatter 走低延迟的对称路径。gateup_output、down_input_fp8这些中间 buffer 留在默认 allocator 上,是为了不让 pool 的占用被中间激活撑大。回程的(M, H)输出同理——post_permute_deep_gemm_to_standard也把它分配在池里(deep_gemm.py:1260-1265)。 - scale 再对齐:非 UE8M0(Hopper)平台刚量化出来的
down_input_scale是行主序 fp32;GEMM1 前那次tma_align_input_scale只处理了输入激活,这里的 A 侧 scale 是新产出的,必须照同样规格转成列主序加 16 字节对齐。UE8M0 平台因为量化时已经直接产出消费布局,这一步自然跳过。 - recipe 重算:
scale_recipes给出的 A 侧 recipe 依赖 K——GEMM1 的 K 是 hiddenH,GEMM2 的 K 是 intermediateI,两者不同。对 MXFP8 而言它还顺带断言ceil_div(K, 32*4) == activation_scale_width(打包后列数必须对得上),这个断言本身也得按新的 K 重新算一遍。
2.5.2 masked 形态(deep_gemm.py:830-896)
masked 形态同样把 (E, m, K) 的 down_output 放进对称内存(deep_gemm.py:855-860):
1 | |
并且额外多做了两件事:
- scale 的最后一跳打包:
use_mxfp8且DEEPGEMM_SCALE_UE8M0且down_input_scale还不是 int32 时,此时需要调整 sacle 为 UE8M0 的格式,需要调deep_gemm.get_mn_major_tma_aligned_packed_ue8m0_tensor(deep_gemm.py:833-844)。正常路径上各激活分支产出的已经是 packed int32,这一步是兜底;非 UE8M0 平台则走 wrapper 的get_mn_major_tma_aligned_tensor(deep_gemm.py:845-848)。
1 | |
-
combine 与 down GEMM 的双流 overlap:
down_gemm_overlap_args非空时先start_event.record(),再根据平台不同选择合适的 block 数值,具体为max_block_n = 160 if (H20 且 expected_m ≤ 64) else 256,连同overlap_args一起传给 GEMM。GEMM 分块计算,combine 在另一条流上等第一批 block 完成就开跑;这种 overlap 模式是将 GEMM2 和之后的 allreduce 做了通算重合,此时 GEMM2 的返回值是
(block_m, threshold),这里原样写回的meta_overlap_args,供 DeepEP 的low_latency_combine判断“前多少行已经算完、可以开始合并发送”。H20 上把max_block_n压到 160,这应该是一个经验值,H20 上计算的 N 方向的 tile 更小(因为算力小),于是第一批 block 就能更早完成,那么通算的 overlap 更容易真正叠起来。
1 | |
两个形态还有一个共同收尾:GEMM1 用完的输入激活在 GEMM1 之后就被 dispose_tensor 掉了(contig deep_gemm.py:467-468、masked deep_gemm.py:783-784),也就是 §2.3.1 讲过的“把 storage 换成 0 字节空张量、立刻归还 caching allocator”。所以峰值显存基本只包含当前这次 GEMM 的输入与输出,而不是整条链路的中间激活之和。
2.6 ⑥ GEMM2 后处理
2.6.1 post_permute:五种回程
post_permute 和 pre_permute 是镜像关系:pre_permute 把 dispatch 的“半成品”整理成 grouped 布局,post_permute 则要把 grouped 结果还原成各 dispatcher 的 combine 能消费的形态。但回程比进料多了一道题——top-k 加权与 routed_scaling_factor 到底在哪一层计算。五种回程的差别,本质上就是这道题的五种答案:
| dispatch_format | post_permute | 加权折叠位置 |
|---|---|---|
| standard | post_permute_deep_gemm_to_standard |
post_reorder_deepgemm 内,随 src2dst 累加一并乘 |
| flashinfer | post_permute_deep_gemm_to_flashinfer |
复用 standard,再转成 bf16 payload |
| deepep_ll | post_permute_deep_gemm_to_deepep_ll |
留给 combine |
| deepep_normal | post_permute_deep_gemm_to_deepep_normal |
ep_gather 内 |
| deepep_v2 | post_permute_deep_gemm_to_deepep_v2 |
expanded 分支在这里,非 expanded 在 ep_gather 内 |
standard 最典型:post_reorder_deepgemm 中使用 post_reorder_deepgemm_triton_kernel 按 src2dst 把同一 token 的 top-k 份结果累加回 (M, H),并在写回时把 routed_scaling_factor 一并算进去,所以模型层不需要再单独做一次比例缩放。输出同样走对称内存,写完立刻 dispose_tensor 掉 GEMM 的大 buffer。
1 | |
但在 SM120 上,:deep_gemm_sm120.maybe_post_permute 命中就直接返回,交给它自己的重排实现 ep_gather。
flashinfer 只是把 standard 的结果再包一层:它要求 payload 是 bf16,不是就直接 TypeError;转成 FlashinferCombineInput 之后,由 combine 侧的 A2A 完成回程。
deepep_ll 最短:dispatch 阶段就是 masked 布局、combine 阶段本来就要消费 topk_ids / topk_weights,所以这里原样透传,一个权重都不乘。
deepep_normal 把“收回 + 加权 + 归约”压进一次 ep_gather(hidden, topk_ids, topk_weights, output_index, gather_out)(deep_gemm.py:1477),output_index 就是 pre_permute 时 ep_scatter 顺手记下的回程映射。
deepep_v2 要看 dispatch 时 token 有没有展开,分三种情况:
-
展开且 masked:
masked_slab_to_expand把(E, m, H)的 slab 按前缀和摊回每个 route 一行。这里还分两种模式:no_combine时只做展开、不乘权重,把topk_weights一并交给 routewise finalizer;否则在 kernel 里把权重乘掉,combine 只负责求和:1
2
3
4
5
6
7
8
9def _fwd_kernel_masked_slab_to_expand(...):
for j in tl.range(worker, count, NUM_WORKERS):
src = (e * MAX_M + j).to(tl.int64)
dst = (start + j).to(tl.int64)
v = tl.load(input_tensor_ptr + src * input_tensor_stride0 + off, mask=mask)
if HAS_W:
w = tl.load(weight_ptr + dst)
v = (v.to(tl.float32) * w).to(v.dtype)
tl.store(output_tensor_ptr + dst * output_tensor_stride0 + off, v, mask=mask) -
展开但不 masked:token 已经就位,而 expanded combine 不消费权重,所以这里显式做
hidden * topk_weights.unsqueeze(-1)(deep_gemm.py:1891-1896)。 -
未展开:与 deepep_normal 同构。
no_combine时按output_index >= 0把结果沿 route 维散回(M, top_k, H)、非本地 route 填 0(deep_gemm.py:1902-1919),否则就是ep_gather。
2.6.2 combine 与模型尾部
回到 FusedMoE.forward_impl 的最后一步:dispatcher.combine(combine_input) 完成跨 rank 的合并与再通信,把结果还原成 (M, H)(fused_moe_triton/layer.py:1586-1589)。这一步同样要使用 NCCL Symm 技术,包裹在 use_symmetric_memory 里,combine 的输出直接落进 pool,然后 all-reduce 才能能走对称通信;出来之后再按入口记录的 origin_hidden_states_dim 切回原始宽度并 contiguous()(fused_moe_triton/layer.py:1592-1594)。
模型层的尾部(deepseek_v2.py:1368-1393)做三件事:
- routed 与 shared 相加:
maybe_fuse_routed_scale_and_shared_add。这里要澄清一点:对 DeepGEMM 路径而言,routed_scaling_factor已经在post_reorder_deepgemm里折进写回了,这个函数实际只做routed += shared的原地相加;函数里那套alpha=routed_scaling_factor的融合分支,是给 flashinfer trtllm 这类“把 scaling 融进 top-k 权重”的后端准备的。1
2
3
4
5
6final_hidden_states = maybe_fuse_routed_scale_and_shared_add(
self.experts,
final_hidden_states,
None if self._shared_expert_tp1 else shared_output,
self.routed_scaling_factor,
) - mhc 统计点与 post-experts 归约:DeepSeek V4 且 TP>1 时,会在归约前先给 mhc post fusion 打一个
start_stats_before_all_reduce()统计点;随后post_experts_all_reduce把 EP 组与 MoE-TP 组的归约收在一次里(两者都生效时合并成一次 TP all-reduce,deepseek_v2.py:1385)。这一步会被should_skip_post_experts_all_reduce(is_tp_path=True)跳过——那正是 §2.6.3 里mlp_reduce_scatterscoped 标记的用途:DP attention 下由 combine 之后的 reduce_scatter 一并完成归约,避免双归约。1
2
3
4
5
6
7
8
9
10
11if (
self.is_deepseek_v4
and self.tp_size > 1
and not should_skip_post_experts_all_reduce(is_tp_path=True)
):
from sglang.srt.layers.moe.mhc_post_fusion import current_mhc_post_fusion
mhc = current_mhc_post_fusion()
if mhc is not None:
mhc.start_stats_before_all_reduce()
final_hidden_states = post_experts_all_reduce(final_hidden_states) - TP1 shared 的后置相加:shared expert 没做 TP 切分时(
_shared_expert_tp1),每个 rank 算出的 shared 输出完全相同,所以它被安排在 all-reduce 之后才相加,否则会被归约重复累加 tp_size 次。注意第 1 步传给maybe_fuse_routed_scale_and_shared_add的shared这时是None(deepseek_v2.py:1371),正是为了把这次相加推迟到这里。1
2
3
4
5
6if (
shared_output is not None
and self._shared_expert_tp1
and should_add_replicated_moe_output()
):
final_hidden_states += shared_output
为什么归约放在模型层,而不是 MoE 层内部?可以看到, FusedMoE.reduce_results 的传参默认是 False,forward_impl 里的兜底 all_reduce 只在显式配置时才触发(fused_moe_triton/layer.py:1596-1597)。这是因为,MoE 层“身在此山中”,它要处理太多地细节,看不清上层用的是 TP-MoE 还是 EP,更不知道 attention 侧的并行形态(EP 的跨 rank 归约已经在 combine 里做完,DP-attn 有 reduce_scatter );故只能把“要不要归约、归约哪一组”交给最了解并行形态的 deepseek_v2 模型层决定,才不会出现 EP 与 all_reduce 各做一遍、或者 DP-attn 与 all_reduce 重复归约的错误。
2.6.3 V4 的 DP attention 变体:_run_moe_ffn_dp_sync
之前小节对 MoE 的叙述有一个隐含前提:本 rank 手上的 token 就是这层 MoE 要算的全部 token。然而,在 DeepSeek V4 在 attention 数据并行(attn DP)× MoE 张量/专家并行下打破了这个前提——当每个 rank 只有自己算出来的部分 token 时,expoerts 不论是按 intermediate 切在 TP 上(单卡只算出半个 MLP),还是按 EP 切在不同 rank 上,都是没办法准确计算了。于是 DeepSeek V4 在每层 MoE 前向上又加了一层抽象,将 MoE 整体收进 _run_moe_ffn_dp_sync,由它决定“进 MoE 前怎么把 token 凑齐、出 MoE 后怎么把结果还回去”。
三条形态互斥,判定都带 not _use_cp:
1 | |
| 形态 | MoE 之前 | MoE 之后 | 归约落在哪 |
|---|---|---|---|
TP-MoE + attn DP(_use_tp_moe_gather) |
dp_gather_replicate 拼全局 token |
reduce_scatterv / 等分片 reduce_scatter / dp_scatter |
回程的 reduce_scatter;MoE 内部 all_reduce 被 mlp_reduce_scatter=True 跳过 |
EP + attn TP(_use_tp_attn_a2a_scatter) |
按 attn_tp_rank 把 token tensor_split 一份给本 rank |
attn_tp_all_gather 拼回 |
EP 的 all2all 里;尾部只 gather、不再 reduce |
CP(_use_cp) |
a2a==none 走 dsa_cp_gather_hidden_states,否则直接 all2all |
dsa_cp_reduce_scatter_hidden_states |
CP 分片的 reduce_scatter |
三者都不命中时,可以是纯 TP-MoE,函数退化成一次裸的 self.mlp(...),前后不加任何搬运——因为这时 token 就全在本 rank,各 TP rank 算完自己的专家分片后交给 post_experts_all_reduce 求和即可。如果 EP + DP,且 attn_tp_size=1,那没有 gather、没有 split、没有 reduce_scatter。因为在这个配置下每个 rank 手上的 token 是自己的、且完整的,不同 rank 的 token 互不重叠。
1)TP-MoE + attn DP:先 gather 全局 token,再 reduce_scatter 还回去
TP-MoE 下专家按 intermediate 维切在 TP 组上,单卡只算出 MLP 的一部分和,所以必须“所有 token 都过一遍所有 rank 上的 experts”,然而因为 attn DP ,每个 rank 都只有一部分 token,所以方法就是先把 token gather 都拿回来:
1 | |
注意这里必须是“复制”语义的 gather 而不是 partial-sum:因为 self_attn 已经在 attention TP 上 all_reduce 过,各 rank 的 hidden 其实是同一份 tensor 的不同部分。
在结束阶段, reduce_scatterv 则是同一个道理的反向:一次算子完成“跨 TP reduce 求和,再把 token 切片各自拷贝到对应 shard 回去”,好让各个 rank 继续下一层的计算,这两个操作合并到一起就是 reduce scatter。当然,此时 MoE 内部的 post-experts all_reduce 必须被跳过,否则就多算了一次。
1 | |
mlp_reduce_scatter 正是 §2.6.2 里 should_skip_post_experts_all_reduce(is_tp_path=True) 读到的那个 per-forward 标志——一边写、一边读,靠 get_forward() 的 scoped 上下文传递。
三条回程按能力从强到弱排:
reduce_scatterv:变长的 gatherv/scatterv 配对,分片长度由get_dp_global_num_tokens()给出。前提是SGLANG_DP_USE_GATHERV(默认关)、SUM_LEN padding、attn_tp_size == 1且tp_size == attn_dp_size(is_dp_gatherv_active,dp_attention.py:793-810),否则两边长度对不上,直接调用pynccl_comm.reduce_scatter(output, input_, sizes=sizes)- 等分片
reduce_scatter:MAX_LEN(各 rank 等长 padding、gatherv 不生效)时走这条,开关是SGLANG_DP_USE_REDUCE_SCATTER,要求tp_size == attn_dp_size, 调用pynccl_comm.reduce_scatter(output, input) dp_scatter:兜底。这时mlp_reduce_scatter为 False,MoE 内部 all_reduce 照常做,那就相当于先 all_reduce,再拷贝本 rank 切片。
顺带一个可选优化:SGLANG_DP_SHARED_EXPERT_LOCAL(deepseek_v4.py:368)打开且 shared expert 是 replicated(TP1)时,shared 的输出改成在 gather 之前只用本地 token 算(deepseek_v4.py:3837-3838),等回程之后再加到本 rank 切片上(deepseek_v4.py:3902-3907)。shared expert 是逐 token 的 MLP,“在 M_local 行上算”与“在 M_global 行上算完再取切片”结果一致,但前者只要 1/dp_size 的行数——这正是 §2.6.2 第 3 点“TP1 shared 在归约之后相加”那份代价的补丁。
2)EP + attn TP:token 均分给各 rank,回程 all_gather
EP 下 experts 切在不同 rank 上,token 由 dispatch/combine 的 all2all 自己找到对应的 rank,不需要 gather 全局;但 attn TP 组内每个 rank 握着同一批 token ,让每个 rank 都把全量 token 来跑一遍 MoE 纯属浪费,为此,按 attn_tp_rank 把 token 均分成 attn_tp_size 份、每个 rank 只留一部分 token shard(deepseek_v4.py:3843-3846),回程 attn_tp_all_gather 拼回(deepseek_v4.py:3908-3912)。
1 | |
回程只 gather、不 reduce,是因为每份切片算出的都是完整结果(该 token 的 top-k 专家输出已由 combine 求和),只是分散在不同 rank 上。
细节:input_ids / input_ids_global 要跟着一起切(deepseek_v4.py:3849-3852)。V4 有按 token id 路由的 hash MoE 层(deepseek_v2.py:1871-1877),路由依据和 token 一旦错位就全错;而 DSpark 这类不带 id 的层则是“有就切、没有就跳过”。
3)CP:用 CP 专用 gather 或直接走 all2all
CP(prefill context parallel)把一条序列按 context 切成几段放在不同 rank 上,所以 _use_cp 为真时上面两条都不成立,MoE 前必须把 token 汇聚:a2a == none 用专用的 gather dsa_cp_gather_hidden_states;走 all2all 则要求 moe_a2a_backend ∈ {deepep, megamoe, mori}(断言 deepseek_v4.py:3824-3831)——因为只有这几个后端有“按 token 送去专家”的能力。
reduce scatter 部分也有专用的 dsa_cp_reduce_scatter_hidden_states。专用的 all gather 和 reduce scatter 都是按照 rank 拼接到了 hidden states tensor 内。每个 CP rank 的真实行都落在自己那段 slot 里,不是整个缓冲的前缀,所以在 a2a==none 时,因为 CP 下 gather 后的缓冲是按 CP rank 拼的,真实行是所有的行,而非仅仅前 N 行,所以必须在 MoE 期间把有效行数设成整块缓冲大小,否则 router 的前缀掩码会误删后面 rank 的真实 token。
所以对 DeepSeek V4 在 Blackwell 上用 DeepEP 的典型部署(attn 侧 TP、MoE 侧 EP):先切掉 attn TP 组内重复的 token 副本,再交给 DeepEP 做 all2all,最后 all_gather 拼回来;而 DeepEP 这一层内部实际执行的仍然是 §2.4/§2.5 讲的 DeepGEMM MoE 中的三段式。
总结一下,不同并行方式下 MoE 相关的 token 通信和内存行为:
| 并行方式 | MoE 前 | dispatch | combine | MoE 后 |
|---|---|---|---|---|
| attn TP × TP-MoE 纯 TP | — | 本地重排 | 本地加权 | 1 次 all_reduce |
| attn DP × TP-MoE | gather 成全量 | 本地重排 | 本地加权 | 1 次 reduce_scatter |
| attn TP × EP | 切掉重复副本 | all2all 发出 | all2all 收回 | 1 次 all_gather |
| attn DP × EP | — | all2all 发出 | all2all 收回 | — |
3. DeepGEMM 接口详解:参数与职责
调用链分两层:
1 | |
wrapper 层做参数适配、sanity check、对齐 scope 与 num_sms 管理;库侧负责 JIT 编译与 kernel 派发。下面先给 wrapper 接口总表,再逐个展开。
3.1 wrapper 接口总表
wrapper(entrypoint.py) |
职责 |
|---|---|
grouped_gemm_nt_f8f8bf16_masked |
masked fp8/fp4 grouped GEMM(支持 down-GEMM overlap) |
grouped_gemm_nt_bf16_masked |
masked bf16 grouped GEMM |
grouped_gemm_nt_f8f8bf16_contig |
contig fp8/fp4 grouped GEMM(m==0 提前返回) |
grouped_gemm_nt_bf16_contig |
contig bf16 grouped GEMM |
gemm_nt_f8f8bf16 |
非 grouped fp8 稠密 GEMM(shared expert down_proj 等) |
gemm_nt_mxfp8_f8f8bf16 |
非 grouped MXFP8 稠密 GEMM |
gemm_nt_bf16bf16f32 |
非 grouped bf16 稠密 GEMM |
tf32_hc_prenorm_gemm |
V4 的 HC 预归一化 GEMM(TF32) |
四个 grouped 接口与 runner 四分支一一对应(§2.3/§2.5 的调用点);四个非 grouped 接口服务于模型里的稠密层(deepseek_v2.py:397、kernels/ops/gemm/fp8_kernel.py:48、kernels/ops/gemm/bf16_fp32.py:157、deepseek_v4.py:340 / :2892)。
3.2 grouped_gemm_nt_f8f8bf16_masked:masked fp8/fp4 GEMM
完整签名:
1 | |
逐参数:
| 参数 | 形状 / 类型 | 含义 |
|---|---|---|
lhs[0] |
(E, m, K) fp8 e4m3(或 fp4 packed) |
激活值;m 是每 expert slab 行数(对齐后的上界),K = hidden_size |
lhs[1] |
(E, m, G/4) int32(UE8M0 packed)或 (E, m, G) fp32 |
激活 scale;G = K / group(fp8 128,mxfp8 32) |
rhs[0] |
(E, N, K) fp8;MXFP4:(E, N, K//2) int8 |
权重值(fp4 以 2/half 打包) |
rhs[1] |
(E, N/128, K/32) int32 等 |
权重 scale(MN-major、TMA 对齐、UE8M0 packed) |
out |
(E, m, N) bf16 |
输出,由调用方分配(对称内存可选) |
masked_m |
(E,) int32 |
第 e 个 expert 只算前 masked_m[e] 行,其余行属 padding |
expected_m |
int | 调优提示:expect_m 用于库内选 kernel 配置(MoE 侧传 ceil_div(M*topk, 全局E)) |
overlap_args |
DownGemmOverlapArgs{num_sms, signal, start_event} |
非空时开启 down-GEMM overlap(见下) |
max_block_n |
int = 256 | 库侧 N 方向 tile 上限;overlap 场景下调小可降低单 tile 延迟 |
recipe_a / recipe_b |
(int,int) 或 None |
仅非 None 时作为 kwargs 透传;(None, None) 时库按默认 128×128 解读 |
内部依次做四件事(:59-97):
- 形状探测:
num_groups, _, k = lhs[0].shape、_, n, _ = rhs[0].shape; _sanity_check_input(lhs/rhs)(:314-328):UE8M0 模式下校验 fp32 scale 已是 2 的幂(ceil_to_ue8m0(x) == x,已打包的 int32 直接跳过);_ensure_cuda(:100-106)保证 tensor 在 CUDA 上;compile_utils.deep_gemm_execution_hook(expected_m, n, k, num_groups, kernel_type)(:69-71→compile_utils.py:436-451):运行期遇到未预热形状时记录/补编(附录 D);configure_deep_gemm_num_sms(overlap_args.num_sms)(:72-74→:301-311):with 块内set_num_sms,退出恢复。overlap 模式下把一部分 SM 让给通信;
最后调用 deep_gemm.fp8_m_grouped_gemm_nt_masked(lhs, rhs, out, masked_m, expected_m, **fp4_kwargs, **overlap_kwargs)(:81-97),其中 overlap_kwargs = dict(enable_overlap=True, max_block_n=max_block_n, signal=overlap_args.signal) 仅在 overlap 时附加——这三个是 sgl-deep-gemm wheel 在 masked 接口上的 overlap 扩展(上游 v0.2.0 tag 的等价签名不含它们)。
OOM 时的辅助通道:_run_masked_gemm 在报错信息里提示 SGLANG_OPT_DG_MASKED_M_CAP=1(把每 expert 容量从 round_up(rank_tokens,256) 收缩到 round_up(max(masked_m),256),代价是每层一次 D2H 同步,deep_gemm.py:761-770)。
3.3 grouped_gemm_nt_f8f8bf16_contig:contig fp8/fp4 GEMM
完整签名(:132-139):
1 | |
与 masked 的差异:
- 激活是
(M, K)(M = all_tokens,expert 连续段 + 对齐 padding)而非(E, m, K);m_indices告诉 kernel 每一行属于哪个 expert(grouped layout 的描述符); - 没有
masked_m / expected_m / overlap参数——contig 形态目前不参与 down-GEMM overlap; m == 0直接提前返回(:144-145),纯 EP 且本 rank 无 token 时是一个纯 no-op;- M/K 对齐由外层 scope 保证:
DeepGemmRunnerCore.run用contiguous_layout_alignment_scope(alignment)(deep_gemm.py:374)把 pre_permute 算出的block_e设为全局对齐,deep_gemm 的 M/K 对齐断言在该 scope 内成立。
3.4 BF16 两个接口
grouped_gemm_nt_bf16_masked(a, b, d, masked_m, expected_m)(:109-129)与 grouped_gemm_nt_bf16_contig(a, b, d, m_indices)(:162-170):
- 输入是裸 Tensor(没有 scale 元组),分别下推
deep_gemm.m_grouped_bf16_gemm_nt_masked/m_grouped_bf16_gemm_nt_contiguous; - 只做 execution hook(warmup 记账),没有 sanity check / num_sms / recipe;
- masked bf16 不支持 overlap(
deep_gemm.py:961注释)。
3.5 非 grouped 接口:MoE 之外的四条 GEMM
MoE 之外,wrapper 还暴露 4 个非 grouped 接口,服务模型其他位置的矩阵乘。它们共享同一骨架——「_sanity_check_input → deep_gemm_execution_hook → 库调用」,没有 grouped 维度与 recipe 组装:
3.5.1 gemm_nt_f8f8bf16(:219-237)——普通 fp8 GEMM
1 | |
- 形状探测
m, k = lhs[0].shape、n, _ = rhs[0].shape,num_groups = 1; - 两侧 sanity check 后下推
deep_gemm.fp8_gemm_nt(lhs, rhs, out)(:233-237); - 调用方:dense MLP 的 down_proj(
deepseek_v2.py:397),即非 MoE 的 block-fp8 线性层。
3.5.2 gemm_nt_mxfp8_f8f8bf16(:240-263)——MXFP8 GEMM
签名同上。与 3.5.1 的差异:
- 显式传
recipe_a=(1, 32), recipe_b=(1, 32)并下推deep_gemm.fp8_fp4_gemm_nt; disable_ue8m0_cast = lhs[1].dtype == torch.int and rhs[1].dtype == torch.int(:253):两侧 scale 都已是打包 int32 时跳过库内 UE8M0 转换;- 调用方:mxfp8 权重矩阵乘分派(
kernels/ops/gemm/fp8_kernel.py:48)。
3.5.3 gemm_nt_bf16bf16f32(:266-277)——bf16→fp32 高精度 GEMM
1 | |
- 裸 Tensor、无 scale,下推
deep_gemm.bf16_gemm_nt; - 调用方:
kernels/ops/gemm/bf16_fp32.py:157——_linear_bf16_fp32_algo == "deep_gemm"时走这条,否则回退 cuBLAS。
3.5.4 tf32_hc_prenorm_gemm(:280-289)——DSV4 HyperConnection 的 prenorm
1 | |
x @ fn的同时把 rows 平方和累加进sqrsum(后续rsqrt用),fp32 中间精度(tf32 输入);x.shape[0] == 0直接返回;无 execution hook、无 sanity check,纯直通(:287-289)——它不参与 JIT 预热记账;- 调用方:
deepseek_v4.py:340(prefill 侧 mix 投影)与:2892(decode 侧),DSV4 独有 HyperConnection 结构的一部分。
3.6 辅助与配置接口
3.6.1 get_mn_major_tma_aligned_tensor
把普通权重/激活 scale 转成 MN-major、TMA 对齐布局,非 UE8M0 平台激活侧调用。
docstring 里记录了仓库作者遇到的 use-after-free 事故并做了防御(:25-36):sgl-deep-gemm 的快速路径在「输入已是目标布局」时会跨 TVM-FFI 返回一个 非持有(non-owning)的 from_blob 别名;而调用方习惯写 x = get_mn_major_tma_aligned_tensor(x)——重绑结果后原存储被释放,GEMM 仍透过别名读取,一旦 allocator 复用该块就是 UAF(表现为 NaN logits 或 CUDA graph capture 期的 “pointer resides on host memory”)。wrapper 因此检测 out.data_ptr() == sf.data_ptr() 时直接返回原 tensor 以保住所有权。
3.6.2 get_contiguous_layout_alignment / contiguous_layout_alignment_scope(:173-194 / :197-216)
- getter:读库侧
get_mk_alignment_for_contiguous_layout()(兼容返回 tuple 的版本);若库提供了get_theoretical_mk_alignment_for_contiguous_layout,则按per_group_m = ceil_div(expected_m, num_groups)求理论对齐值——只要理论值 < 全局值就下调,反之保持全局值(:184-194); - scope:contextmanager,
setter(alignment)进入、setter(previous)退出恢复;库侧缺 getter/setter 或 alignment 为 None 时退化为 no-op; - 消费方:
DeepGemmRunnerCore.run(deep_gemm.py:374)把 pre_permute 算出的对齐包在整个 GEMM 区段外,contig 路径的 M/K 对齐断言在该 scope 内成立;JIT 预热侧由compile_utils以相同 scope 编译对齐一致的 kernel。
3.6.3 configure_deep_gemm_num_sms(:301-311)
contextmanager:num_sms 为 None 或 JIT 关闭时 no-op;否则块内 set_num_sms(num_sms)、退出 set_num_sms(original)。overlap 场景用它给通信让出 SM(deep_gemm.py:72-74)。
3.6.4 update_deep_gemm_config(:292-298)
启动期一次性:SGLANG_DEEPGEMM_PDL 开启且库有 set_pdl 时调用 deep_gemm.set_pdl(True)(注释强调它会在 fork 后、绑定 GPU 之后执行,避免提前初始化 CUDA 状态),然后转交 compile_utils.update_deep_gemm_config(gpu_id)。
3.6.5 _sanity_check_input(:314-328)
由 SGLANG_DEEPGEMM_SANITY_CHECK 控制(默认关,:44)。逻辑三层:已经是 int(打包 UE8M0)直接放过;非 DEEPGEMM_SCALE_UE8M0 放过;否则断言 ceil_to_ue8m0(x_scale) == x_scale——fp32 scale 必须已是 2 的幂,否则库内 round-up 会静默改变数值。
3.7 库侧签名对照(sgl-deep-gemm 0.2.0)
SGLang 依赖 SGL 维护的 DeepGEMM 发行版(import deep_gemm)。以下签名摘自上上游 GitHub v0.2.0 tag 的 deep_gemm/__init__.py,作为 wrapper 调用面的底账:
1 | |
同名别名(fp8 与 fp4 共用实现,按输入 dtype 分派):fp8_fp4_gemm_nt = fp8_gemm_nt = fp4_gemm_nt;m_grouped_fp8_fp4_gemm_nt_masked = fp8_m_grouped_gemm_nt_masked = m_grouped_fp8_gemm_nt_masked = m_grouped_fp4_gemm_nt_masked;contiguous 版同理三别名。
参数语义:
| 参数 | 语义 |
|---|---|
a / b / d |
激活 / 权重 / 输出;a、b 可为 (tensor, scale) 元组或裸 Tensor——库内 _parse_tensor_or_tuple 把裸 Tensor 补成 (t, [1.0]) 的假 scale,所以 bf16 接口在 wrapper 里可以裸传 |
masked_m |
masked 布局每 expert 有效行数 (E,) int32 |
expected_m |
平均每 expert 行数期望,供库内选 kernel 配置(MoE 侧传 ceil_div(M*topk, 全局E)) |
grouped_layout |
contig 布局的行 → expert 映射 (M,) int32,需满足 get_mk_alignment_for_contiguous_layout 对齐 |
recipe / recipe_a / recipe_b |
scale 分组规则,(block_m, block_n) 或 (block_m, block_n, block_k)(二元组库内补 None);A 侧=激活、B 侧=权重 |
compiled_dims |
JIT 编译的特化维名('nk' 表示按 N/K 维特化;dense 版默认空串) |
disable_ue8m0_cast |
True 跳过库内「fp32 scale → UE8M0」转换;输入已是 packed int32 时必须 True,否则二次 cast |
use_psum_layout / ensure_zero_padding / expected_m_for_psum_layout |
v0.2.0 的 psum(partial-sum)布局选项;wrapper 未启用,走默认 |
c / alpha |
dense 版:可选累加目标 C 与输出缩放 alpha |
sf / mn / k / is_sfa / psum_layout |
transform_sf_into_required_layout:把任意 scale 整理为目标布局;is_sfa 标明是激活侧 |
wrapper → 库侧对应表(未列参数均走库侧默认):
| wrapper 接口 | 库侧调用 | 显式传参 |
|---|---|---|
grouped_gemm_nt_f8f8bf16_masked |
m_grouped_fp8_gemm_nt_masked |
recipe_a/b(非 None 时);overlap 时追加 enable_overlap / max_block_n / signal * |
grouped_gemm_nt_f8f8bf16_contig |
m_grouped_fp8_gemm_nt_contiguous |
recipe_a/b(非 None 时) |
grouped_gemm_nt_bf16_masked |
m_grouped_bf16_gemm_nt_masked |
— |
grouped_gemm_nt_bf16_contig |
m_grouped_bf16_gemm_nt_contiguous |
— |
gemm_nt_f8f8bf16 |
fp8_gemm_nt |
— |
gemm_nt_mxfp8_f8f8bf16 |
fp8_fp4_gemm_nt |
recipe_a/b=(1,32)、disable_ue8m0_cast |
gemm_nt_bf16bf16f32 |
bf16_gemm_nt |
— |
tf32_hc_prenorm_gemm |
tf32_hc_prenorm_gemm |
num_splits |
* 这三个是 sgl-deep-gemm wheel 在 masked 接口上的 overlap 扩展,上游 v0.2.0 tag 的等价签名不含它们(§3.2)。
3.8 MoE 数据布局一览
hidden_states_scale 的 K 维宽度由激活量化 group(block fp8 为 128、mxfp8 为 32)决定,与权重 scale 相互独立——DeepGemmRunnerInput.activation_scale_block_size(deep_gemm.py:271-273)专门记录这一点。UE8M0 模式下,K 维的每 4 个 fp32 scale 会用 _cast_to_e8m0_with_rounding_up 打包成 1 个 int32(打包后宽度 ÷4)。
| 张量 | masked 布局 | contig 布局 |
|---|---|---|
| 激活值 | (E, m_pad, K) fp8 |
(M_all, K) fp8 |
| 激活 scale | (E, m_pad, K/bs)(UE8M0 时 K/bs/4 的 int32) |
(M_all, K/bs) 同理 |
| 权重 | (E, 2I, K) / (E, K, I) fp8;MXFP4 为 (E, 2I, K//2) / (E, K, I//2) int8 |
同左 |
| 权重 scale | 由 recipe 决定(fp8 block128:K/128;MXFP4:(1,128)/(1,32)),MN-major + TMA 对齐 + UE8M0 打包 int32 | 同左 |
| 分组描述 | masked_m:(E,) int32 |
m_indices:(M_all,) int32 |
| GEMM1 输出 | (E, m_pad, 2I) bf16 |
(M_all, 2I) bf16 |
其中 bs = 激活量化 group;m_pad 是每 expert 的对齐容量(round_up(rank_tokens, 256),或 SGLANG_OPT_DG_MASKED_M_CAP=1 时收缩到 round_up(max(masked_m), 256));M_all 为 routed 行数 + 对齐 padding。
4. 环境与平台要求
前面所有接口都有隐式前提:它们只在满足特定平台、版本、开关时才会被选中或允许运行。本节把「量化方案 × 并行方式 × runner backend × 环境变量」四个维度的硬性要求汇总,每条都给出代码依据。
4.1 量化方案
| 方案 | 平台 / 硬件 | scale 约定 | 依据 |
|---|---|---|---|
| BF16 | DeepGEMM runner 本身要求 SM90+ | 无 scale | utils/common.py:298-321 |
| FP8 block(fp32 scale) | SM90+ | MN-major + TMA 对齐(DEEPGEMM_NEED_TMA_ALIGNED_SCALES) |
configurer.py:61 |
| FP8 block(UE8M0) | SM100 / SM120 / SM121 | UE8M0 packed int32,无需再 TMA 对齐 | configurer.py:58-60 |
| MXFP8 | SM100(CUDA)或 ROCm gfx95;gfx942 在加载期转成 block-fp8 | 必须 UE8M0,weight_block_size 强制 [1, 32] |
fp8.py:2126-2134、fp8.py:304-308 |
| MXFP4 experts(W4A8) | DEEPGEMM_BLACKWELL(SM100);DSV4 默认开启 |
UE8M0;recipe (1,128)/(1,32) | environ.py:1525、deep_gemm.py:332-333 |
| cutlass fp8(对照) | SM90 / SM100 / SM120;CUDA 12.0+ (SM90) 或 12.4+ (SM89) | block quant | fp8.py:1322-1331 |
几个值得展开的点:
- 量化方法如何声明平台门槛:
get_min_capability(fp8.py:318-328)返回 NPU→0、MUSA→31、MXFP8+gfx95→95、MXFP8-需转换→94、默认 block-fp8→80 / MXFP8→100;SGLang 启动时用它过滤当前 GPU 可用的量化方法; - gfx942 特例:
_mxfp8_to_block_fp8_required为真时 MXFP8 权重在加载期直接转为 block-fp8(门槛 94),而不是运行期支持 MXFP8; - MXFP8 MoE 的硬断言(
fp8.py:2126-2134):(_is_cuda and is_sm100) or (_is_hip and _is_gfx95_supported),否则直接RuntimeError; - MXFP4 的加载路径:
SGLANG_DSV4_FP4_EXPERTS(默认 True)决定 DSV4 专家走 MXFP4;SGLANG_DSV4_FP4_DEQUANT=True时运行期反量化回 FP8(environ.py:1525-1527)。
4.2 并行方式
| 并行 / 通信形态 | 要求与约束 | 依据 |
|---|---|---|
| EP + deepep(v1) | 每 rank 缓冲 SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK(默认 128);internode_ll 用 FINISHED_SUM_TAG=1024,单 rank 发往另一 rank 的 token 数必须 < 1024;NVSHMEM_QP_DEPTH 自动抬到 max(1024, 2·(max+1)) |
deepep.py:78-85、:393-398 |
| deepep_v2 | 强制 deep_gemm runner(BF16 或 FP8 blockwise checkpoint);ElasticBuffer 自适应通信 SM/QP(SGLANG_DEEPEP_V2_NUM_SMS=0);FP8 dispatch 需要 SGLang FP8 量化 kernel 可导入 |
runner.py:85-94、deepep_v2.py:91-119 |
| mooncake | SGLANG_MOONCAKE_EP_NUM_MAX_DISPATCH_TOKENS_PER_RANK(默认 128),断言 ≤ 1024(同 FINISHED_SUM_TAG 约束) |
mooncake.py:156-162 |
| nixl | SGLANG_NIXL_EP_NUM_MAX_DISPATCH_TOKENS_PER_RANK,断言 ≤ 1024 |
nixl.py:228-233 |
| DP attention(no-EP,TP-MoE) | gatherv 路径需 SGLANG_DP_USE_GATHERV(默认 off);reduce_scatter 路径需 SGLANG_DP_USE_REDUCE_SCATTER(HIP 默认开)且 tp_size == attn_dp_size |
deepseek_v4.py:3749-3913 |
| CP(prefill context parallel) | a2a == none 走 dsa_cp_gather/reduce_scatter;否则断言 a2a ∈ {deepep, megamoe, mori} |
deepseek_v4.py:3819-3831 |
DP attention 无 EP 时的 combine 三形态(deepseek_v4.py:3775-3802):reduce_scatterv(变长、gatherv 对称逆操作,会跳过 MoE 内部 post-experts all_reduce 改由它一并完成 SUM,避免双归约)→ reduce_scatter(MAX_LEN 等长场景)→ dp_scatter(兜底);三者的共同前提都是 mlp_reduce_scatter=True 的 scoped 标记。
4.3 runner backend
| backend | 要求 | 依据 |
|---|---|---|
deep_gemm |
需 ENABLE_JIT_DEEPGEMM:sm≥90 或 MUSA≥31;SM120/121 还要探测到 fp8_einsum、m_grouped_fp8_fp4_gemm_nt_contiguous、transform_sf_into_required_layout 三个 API 才开启 |
configurer.py:17-29,55 |
hpc_ops |
仅 SM90(HPC-Ops kernel 只发 sm90a);且只支持 FP8 blockwise / per-tensor 量化 MoE,量化方法回退到别的 runner 时启动即报错 | runner.py:76-83、:150-159 |
deepep_v2(a2a 维度) |
只与 deep_gemm runner 兼容(见 §4.2) | runner.py:85-94 |
silu_mul_keep_fp32(config 开关) |
仅 deep_gemm runner 支持;要求 gated SiLU、无 gemm1_alpha、FP8 专家权重 |
runner.py:64-68、deep_gemm.py:340-347,367-368 |
4.4 环境变量与开关
| 变量 | 默认 | 作用 |
|---|---|---|
SGLANG_ENABLE_JIT_DEEPGEMM |
True | 全链路总开关(environ.py:1172) |
SGLANG_DEEPGEMM_STANDARD_LAYOUT |
“auto” | masked GEMM 布局决策(:1176) |
SGLANG_DEEPGEMM_MASKED_MEMORY_BUDGET_FRACTION |
0.25 | masked 布局显存预算比例(:1177) |
SGLANG_OPT_DG_MASKED_M_CAP |
False | masked 每 expert 容量收缩到 round_up(max(masked_m), 256),OOM 缓解(:1183) |
SGLANG_OPT_DG_COMPACT_EAGER |
False | 宽 DP eager prefill 用 compact 路由存储(:1186) |
SGLANG_OPT_MASK_DP_PAD_MOE |
False | DP attention MAX_LEN pad 行从 MoE dispatch 剔除(topk_ids → -1)(:1192) |
SGLANG_JIT_DEEPGEMM_PRECOMPILE |
True | 启动期 JIT 预热(:1193) |
SGLANG_JIT_DEEPGEMM_FAST_WARMUP |
False | 快速预热(只编高频形状)(:1194) |
SGLANG_JIT_DEEPGEMM_COMPILE_WORKERS |
4 | 编译并行进程数(:1195) |
SGLANG_DG_CACHE_DIR |
~/.cache/sglang/deep_gemm |
JIT 产物缓存目录(:1198) |
SGLANG_DG_USE_NVRTC |
False | 改用 NVRTC 编译后端(:1199) |
SGLANG_DEEPGEMM_SANITY_CHECK |
False | UE8M0 scale 合法性校验(:1201) |
SGLANG_DEEPGEMM_PDL |
True | PDL(programmatic dependent launch)(:1202) |
SGLANG_DEEPGEMM_ON_H20 |
off | H20 特化:masked GEMM 的 max_block_n 降到 160(deep_gemm.py:89,866-869) |
SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK |
128 | DeepEP v1 per-rank 通信缓冲(:1253) |
SGLANG_DEEPEP_V2_NUM_MAX_DISPATCH_TOKENS_PER_RANK |
128 | DeepEP v2 per-rank 通信缓冲(:1255) |
SGLANG_DEEPEP_V2_NUM_SMS |
0 | v2 通信 SM 数(0 = ElasticBuffer 自动选择)(:1257) |
SGLANG_DSV4_FP4_EXPERTS |
True | DSV4 专家按 MXFP4 加载(:1525) |
SGLANG_DSV4_FP4_DEQUANT |
False | 运行期把 FP4 专家反量化回 FP8(:1527) |
SGLANG_DP_USE_REDUCE_SCATTER |
HIP 平台默认开 | DP-attn MAX_LEN combine 用 reduce_scatter(:876) |
SGLANG_DP_USE_GATHERV |
off | DP-attn gather 用 all_gatherv(dp_attention.py:656 读取) |
5. 并行形态对照
同一条三段式骨架,因 a2a backend 与 attention 并行方式不同,进料(pre_permute)与回程(post_permute)有六种组合:
| 场景 | dispatcher | pre_permute | GEMM 形态 | 回程与归约 |
|---|---|---|---|---|
| 纯 TP(a2a=none,无 attn DP) | StandardDispatcher | standard | masked 或 compact | post_reorder 加权归并;模型层 post_experts_all_reduce(TP>1) |
| TP-MoE + attn DP(a2a=none) | StandardDispatcher | standard(外包 §2.6.3 的 gather/scatter) | masked 或 compact | 可选 reduce_scatterv / reduce_scatter 替代 all_reduce |
| FlashInfer one-sided A2A | FlashinferDispatcher | flashinfer(转 standard 实现) | compact | 转 FlashinferCombineInput,combine 再通信 |
| DeepEP Normal(EP) | DeepEPDispatcher normal | deepep_normal(ep_scatter + output_index) | compact | ep_gather 按 output_index 收回 |
| DeepEP LL | DeepEPDispatcher LL | deepep_ll(masked 直通) | masked | combine 内加权 |
| DeepEP v2 | DeepEPDispatcher v2 | deepep_v2(三分支) | masked / contig / expanded | masked_slab_to_expand / ep_gather / routewise |
两个容易混淆的维度:
- TP-MoE 与 EP 的切分方式不同:TP-MoE 时每个 rank 持有全部 expert 的一部分 intermediate 维(N 切分),路由后仍要走 TP all_reduce(或 §2.6.3 的 reduce_scatter);EP 时每个 rank 持有一部分 expert 的全部权重,token 通过 all2all 找专家,归约在
ep_gather/ combine 里完成,无需 all_reduce。 - masked 与 compact 是两种内存布局,与 dispatcher 无关(选择逻辑见附录 B):masked 是
(E, m, K)的「每 expert 一段对齐 slab」;compact 是(all_tokens, K)的「expert 连续段 + padding」。前者走*_masked接口并以masked_m描述有效行,后者走*_contig接口并以m_indices做行到专家的映射。
6. 量化配方对照
四个配方在 §2 六个阶段中的行为正面对比(平台门槛见 §4.1):
| 维度 | FP8 block128 | MXFP8 | MXFP4 experts(V4 默认) | BF16 |
|---|---|---|---|---|
| 权重加载处理 | requant_block_scale_ue8m0_for_deepgemm(dequant→requant) |
_process_mxfp8_moe_weights |
transform_sf_into_required_layout(recipe=(1,32)) |
— |
| 权重值 | (E, 2I, K) e4m3 |
(E, 2I, K) e4m3 |
(E, 2I, K//2) int8(packed 2×fp4) |
(E, 2I, K) bf16 |
| 权重 scale | (E, 2I/128, K/128) packed int32 / fp32 |
(E, 2I/128, K/32) packed |
(E, 2I/128, K/32) packed |
— |
| 激活量化(运行期) | 128 组;UE8M0 列主序 packed | 32 组 | 128 组(激活仍是 fp8) | 无 |
| GEMM recipe (A/B) | (None, None) | (1,32)/(1,32) | (1,128)/(1,32) | — |
运行期差异三条:
- UE8M0 打包动作只在
DEEPGEMM_SCALE_UE8M0下发生:_cast_to_e8m0_with_rounding_up、transpose(-1,-2)、scale 宽度 ÷4 这类操作;非 UE8M0 平台反而要把 scale 经get_mn_major_tma_aligned_tensor对齐(deep_gemm.py:750-753); _DEEPGEMM_ON_H20特判:masked down GEMM overlap 的max_block_n从 256 降到 160(deep_gemm.py:866-869);- SM120 早退:standard pre_permute 的
maybe_pre_permute直接返回,只消费 standard-layout 激活,且 masking/overlap 相关分支不可用(deep_gemm.py:1246-1250)。
7. 小结
- 一副骨架:
dispatch → pre_permute → DeepGemmRunnerCore.run → post_permute → combine。DeepGEMM runner 的全部差异都收敛在 pre/post_permute 的布局转换与 core 的四个 GEMM 分支里; - 两个正交开关:布局(masked / compact)× 配方(bf16 / fp8 / mxfp8 / mxfp4)。dispatcher 决定 pre/post_permute 的实现;权重类型与
DeepGemmMoeQuantInfo决定 core 的走向; - 接口分三层:模型层(
DeepGemmRunnerInput/QuantInfo)→ runner core(shape 推导、recipe、激活量化)→ wrapper(entrypoint.py八个接口:sanity check、execution hook、num_sms 管理)→ deep_gemm 库(JIT 编译的 grouped/dense GEMM)。每层的参数职责在 §3 逐一展开; - Blackwell 的实质变化:UE8M0 int32 packed scale(MN-major TMA)与 FP4/MXFP8 配方。SGLang 在加载期就把权重与权重 scale 整理成 deep_gemm 最终消费的布局(requant / transform / pack),运行期只负责激活侧量化与布局重排(§2.1、附录 A);
- V4 的特有之处:MoE 复用
DeepseekV2MoE;is_fp4_experts默认打开(recipe(1,128)/(1,32));swiglu_limit存在(走 V4 JIT 激活);DP attention 下有_run_moe_ffn_dp_sync协调 gather/scatter 与 all_reduce 的替代关系(§2.6.3)。
附录 A:UE8M0——从 fp32 到 int32 packed 的三条生产线
UE8M0 是只保留指数的 8-bit 浮点(无尾数、无符号,值域即 2 的幂),deep_gemm 要求 scale 以 MN-major、TMA 对齐、4 个 uint8 打包进 1 个 int32 的形式存储。样本分别来自三条生产线:
- 权重加载期(fp4 → UE8M0):
transform_sf_into_required_layout(recipe=(1,32), disable_ue8m0_cast=False),两条调用线——fp8 量化方法的is_fp4_expert分支(fp8.py:2056-2074)与纯 MXFP4 量化方法(mxfp4.py:650-674,先经float8_e8m0fnu → float32的精确往返);库内完成 cast + 布局重排; - 权重加载期(fp8-block / mxfp8):
requant_block_scale_ue8m0_for_deepgemm(fp8_utils.py:1719-1750)→transform_scale_ue8m0(fp8_utils.py:1854-1878);调用点fp8.py:2085-2098;mxfp8 的_pack_moe_scale_for_deepgemm(fp8.py:2248-2262)同款; - 运行期激活量化:
- contig silu 融合:
silu_and_mul_contig_post_quant(scale_ue8m0=UE8M0, transposed=UE8M0),scale 缓冲由create_per_token_group_quant_fp8_output_scale(column_major_scales=UE8M0, scale_tma_aligned=UE8M0, scale_ue8m0=UE8M0)直接按 packed 布局分配(deep_gemm.py:530-552); - masked silu:
per_token_group_quant(scale_ue8m0=UE8M0, column_major_scales=True)自带 packed 布局输出(deep_gemm.py:1635-1643,注释说明调用方的get_mn_majortransform 会短路); - masked situ 三分支:JIT kernel 输出后补
transpose(-1,-2)(deep_gemm.py:1528 / 1595 / 1627); - 兜底:
_cast_to_e8m0_with_rounding_up(deep_gemm.py:96-106;调用点:501contig、:747masked)。
- contig silu 融合:
_cast_to_e8m0_with_rounding_up 的位运算:把 fp32 看成 int32,取指数位 exp = bits >> 23、尾数 mant = bits & 0x7FFFFF;当「尾数非零且指数非 0xFE(inf/nan 保护),或指数为 0 时尾数足够大」时指数 +1(round up),再取低 8 位作为 uint8,最后 transpose(1,2).contiguous().transpose(1,2) 转 MN-major 对齐。这也是 _sanity_check_input 断言「fp32 scale 必须已是 2 的幂」的原因——非 2 的幂会被 round-up cast 改变数值。
附录 B:masked 还是 compact——自动选择的过滤链
_should_use_masked_standard_layout(deep_gemm.py:196-249)依次过滤:
SGLANG_OPT_DG_COMPACT_EAGER=1且非 capture → 强制 compact(逃生舱);_masked_activation_unsupported_reason(:175-193)给出理由 → compact。理由只在「clamped/swizzled 激活」(swiglu_limit存在或 megamoe)下出现,条件为D//8 < E、group_size != 128、D % (4*group) != 0之一(DSV4 JIT kernel 的硬约束);- 环境变量
SGLANG_DEEPGEMM_STANDARD_LAYOUT:masked/compact直接定夺;auto继续; - auto:CUDA Graph capture 中 → compact(capture 内不能查显存);否则估算 masked 峰值(
_estimate_masked_standard_layout_peak_bytes:132-167:E × padded_m × 单行峰值字节,单行峰值取三项差的最大值)与预算min(free_memory) × SGLANG_DEEPGEMM_MASKED_MEMORY_BUDGET_FRACTION比较——放得下就 masked,放不下就 compact。
几何对比:masked 用 (E, m, K) slab + masked_m/expected_m,workspace 是 E × padded_m 量级;compact 用 (all_tokens, K) + m_indices/src2dst,all_tokens 由 _get_compact_all_tokens(:252-259)给出上界:block_e × (非空 expert 数 + 剩余 tokens 摊入的块数)。
附录 C:ep_scatter 家族与三个索引
布局重排的底层 kernel(kernels/ops/moe/ep_moe_kernels.py)按场景分三支:
ep_scatter:standard compact / deepep_normal 使用——按tokens_per_expert(或 recv 计数)把 token 与激活 scale 散进 expert 连续段,同时产出m_indices(行→expert)与src2dst/output_index(回程映射);expert_alignment=block_e保证每 expert 段按 GEMM 对齐要求 padding;ep_scatter_from_psum:deepep_v2 非 expanded 分支,用原生 psum 前缀和替代计数数组;moe_ep_deepgemm_preprocess(masked):一步融合「路由分桶 + slab 填充 + 激活量化」,直接产出(E, m, K)与src2dst。
三个索引的分工(易混):
| 索引 | 形状 | 语义 | 消费者 |
|---|---|---|---|
m_indices |
(M,) int32 | grouped 行 → expert id | contig GEMM |
src2dst |
(M,) int32 | 输出行 → 原 token 槽位 | post_reorder_deepgemm |
output_index |
(M,) int32 | 回程映射(v2 中 -1 = 非本地) | ep_gather / v2 的 route 还原 |
附录 D:启动期 JIT 预热(compile_utils.py)
deep_gemm 是 JIT 编译的库,SGLang 在启动/捕获前做形状预热:
- 缓存与编译后端:
DG_JIT_CACHE_DIR(:46)、DG_JIT_USE_NVRTC(:51)、update_deep_gemm_config(:54-105)设置SGLANG_DG_CACHE_DIR/SGLANG_DG_USE_NVRTC等库侧全局; _local_rank_compile_lock(:120-140):flock文件锁防止多进程重复编译;_maybe_compile_deep_gemm_one_type_all(:144-186):对_BUILTIN_M_LIST(:37,一组预置 M)逐类型编译,contig 类型按m % alignment == 0过滤(:201-206);- 显存预算不足时收缩
max_m(:213-234); - compile mode 区分 warmup/运行模式(
:241-252); - 多组 warmup executor 覆盖 contig/masked × fp8/bf16(
:263 / 338 / 352 / 368 / 384 / 404 / 414); deep_gemm_execution_hook(:436-451)在运行期遇到未预热形状时记录/补编;- PP 场景
pp_parallel_deep_gemm_warmup(:454-542)。
附录 E:平台差异速查
| 项 | SM90 | SM100(含 103) | SM120/121 |
|---|---|---|---|
ENABLE_JIT_DEEPGEMM |
✓ | ✓ | ✓(额外探测 fp8_einsum 等 API) |
DEEPGEMM_BLACKWELL |
✗ | ✓ | ✗ |
DEEPGEMM_SCALE_UE8M0 |
✗ | ✓ | ✓ |
DEEPGEMM_NEED_TMA_ALIGNED_SCALES |
✓ | ✗ | ✗ |
| 本文主路径 | FP8/BF16 | 全部四配方 | 独立 sm120 模块(standard layout only) |
(is_sm100 在 SGLang 中即 capability[0] == 10,覆盖 100/103;平台判定见 utils/common.py:326-334。注:MUSA 平台在 configurer.py:55 也参与 ENABLE_JIT_DEEPGEMM 判定(capability ≥ 31),且 get_min_capability 对 MUSA 返回 31(fp8.py:318-328);但 DEEPGEMM_BLACKWELL / DEEPGEMM_SCALE_UE8M0 仅按 capability 判定,MUSA 上不成立。)
附录 F:代码位置速查
至此,DeepSeek V4 在 Blackwell 上走 DeepGEMM MoE 的完整路径已经闭合:从 DeepseekV2MoE 的路由,到五种 dispatch 布局的进料转换、四个 GEMM 分支的 core 执行、八个 wrapper 接口的最终下推,以及 post_permute 与 combine 的回程归约。文中所有行号基于本地 release/v0.5.21 检出逐一核对;后续版本若有重构,请以 moe_runner/deep_gemm.py 与 deep_gemm_wrapper/entrypoint.py 的最新实现为准。
