Prhub

#38160 [Feature] Support BF16 and batch-invariant inference with DeepEP v2

原始 PR 作者 ch-wan 合并时间 2026-09-16 05:30 文件变更 16 提交数 7 评论 0 代码增减 +706 / -40

执行摘要

DeepEP v2 支持 BF16 与批量不变的确定性推理

DeepEP v2 此前只接受 128x128 blockwise FP8 专家,BF16 checkpoint 在层初始化就被拒绝;同时 #35758 给 _fwd_kernel_ep_scatter_2 增加 expert_start/num_experts 后未同步更新 #35634 引入的 ep_scatter_from_psum 调用者,导致 prefill 阶段报 TypeError: dynamic_func() missing 2 required positional arguments。更隐蔽的问题是:TP reduce-scatter 的浮点求和顺序取决于接收 rank,PR 描述指出「As a serving batch grows, a token can move to another rank and enter the first MoE layer with different bits」,即使开启确定性推理,token 的位级结果仍随批次变化——collective 回归显示未修复前 8,192 个 BF16 元素中有 2,550 个(31.1%)在不同目标 rank 上结果不同。

值得精读。这是一个「通信集合正确性工程」的完整样本:如何把 rank 相关的浮点求和变成 rank 无关、如何用配置校验避免静默错误、如何用 mock 观察 wire 格式、如何设计多 GPU 位级回归。重点看三个设计决策:create_moe_dispatcher 把 quant_method 传入所有后端分支(当前仅 deepep_v2 消费,未来可复用);wire dtype 跟随时 checkpoint 而非自由开关;确定性 reduce-scatter 用 input.clone() 保持调用契约的细节。若团队正在做确定性推理或多卡一致性治理,本 PR 的测试方法论(4 卡批量 × 目标 rank 不变性 + atol=0, rtol=0 断言)可直接借鉴。

讨论亮点

该 PR 的 GitHub 页面没有留下公开 review 评论(comments_count=0、review_comments_count=0),但 commit 5a24eda 明确标注「[Fix] Address DeepEP v2 review feedback」,说明合入前存在一轮 review 迭代。从 commit 序列与 PR 描述可以还原四个核心设计决策:

  • wire dtype 必须跟随 checkpoint 而不是自由配置:get_deepep_v2_dispatcher_output_dtype 在显式 flag 与 checkpoint 矛盾时直接抛错,PR 描述强调「Require the quantization method when creating the dispatcher so an omitted argument cannot silently select FP8」——这是典型的防静默错误设计。
  • 确定性 reduce-scatter 用 all-reduce + 本地切片,而非改造原生 reduce-scatter:因为问题本质是求和顺序依赖接收 rank,而 all-reduce 让所有 rank 拿到同一归约结果;代价是通信量上升与额外临时 buffer,作者明确标注「End-to-end performance overhead has not been benchmarked」。
  • ElasticBuffer receive sorting 保持关闭:专家计算行独立、combine 走 handle 的 slot 指针,sort 的动态索引与 decode CUDA graph 捕获不兼容,属于正确性优先于性能的取舍。
  • 第 6 个 commit(5f190a7)补上了 reduce_scatterv:commit message 指出 should_use_dp_reduce_scatterv() 在 DP attention(attn_dp_size>1)时把 combine 路由到该路径,且 dp_attention.py 直接使用它——最初只改 reduce_scatter_tensor 会漏掉 DP 部署场景,这是 review 或自测中发现的关键缺口。

实现拆解

实现按四步推进:

  1. 修复 contiguous scatter 缺参:在 python/sglang/kernels/ops/moe/ep_moe_kernels.py 的 ep_scatter_from_psum 调用处补传 expert_start=0 和本地专家数,因为 DeepEP v2 dispatch 已经 rebase 了接收到的专家 ID,全局索引不需要再次翻译;代码路径是 deepep_v2 的 prefill permute(_fwd_kernel_ep_scatter_2 的 v2 调用者)。配套新增 test/registered/kernel/moe/test_deepep_v2_contig_scatter.py,用 BF16 无 scale 与 FP8 带 scale 两组 CUDA 用例锁定行为,两个用例在 PR base 上均失败。

  2. 建立 wire dtype 决策链:python/sglang/srt/layers/moe/fused_moe_triton/layer.py 的 create_moe_dispatcher 新增 quant_method 参数,先经 _deepep_v2_experts_are_fp8(not isinstance(quant_method, UnquantizedFusedMoEMethod))判断专家格式,再调用 python/sglang/srt/layers/moe/utils.py 新增的 get_deepep_v2_dispatcher_output_dtype 解析 --deepep-dispatcher-output-dtype:显式 flag 与 checkpoint 矛盾时抛 ValueError,避免漏传参数静默选错 FP8;_validate_deepep_v2_quant_method 同时放行 UnquantizedFusedMoEMethod。python/sglang/srt/layers/moe/token_dispatcher/deepep_v2.py 的 _DeepEPv2Impl 与 DeepEPv2Dispatcher 构造函数新增 use_fp8_dispatch,_get_buffer 把它透传给 ElasticBuffer,dispatch 中 BF16 分支直接透传激活、不分配 scale;python/sglang/srt/layers/moe/moe_runner/deep_gemm.py 的 pre_permute_deepep_v2_to_deep_gemm 接受「无 scale 的 BF16 输出」并跳过 input_tensor_scale 分配。

  3. 实现确定性 reduce-scatter:python/sglang/srt/distributed/parallel_state.py 的 reduce_scatter_tensor 在 SGLANG_ENABLE_DETERMINISTIC_INFERENCE 下改为 all_reduce(input.clone()) 后按 rank_in_group 本地切片;新增 _deterministic_reduce_scatterv 用前缀和 offset 支持不等长分片,reduce_scatterv 同步接入。克隆输入是为了保持调用契约——部分调用者把输出别名到输入切片。同时移除 python/sglang/srt/arg_groups/moe_hook.py 中 DeepEP v2 与确定性推理互斥的拒绝逻辑;ElasticBuffer receive sorting 保持关闭,因为专家计算行独立、combine 走 slot 指针,且 sort 的动态索引与 decode CUDA graph 捕获不兼容。

  4. 测试与 CI 配套:新增 test/registered/unit/layers/moe/test_deepep_v2_wire_dtype.py(CPU mock,观察 ElasticBuffer 收到的 wire 格式)、test/registered/kernel/communication/test_deterministic_reduce_scatter.py(4 卡回归,覆盖 BF16/FP16/FP32 × eager/CUDA graph,按生产 NCCL 通道数配置)、test/manual/ep/test_deepep_v2_deterministic.py(人工 logprob 探针,校验服务器开启确定性且禁用 radix cache);test/registered/unit/distributed/test_parallel_state.py 覆盖确定性路径的输入保持与 rank 分片选择,test/registered/unit/server_args/test_server_args.py 把 deterministic + deepep_v2 从拒绝翻转为接受,test/registered/unit/layers/moe/test_hpc_ops_runner_guard.py 覆盖新量化契约。

文件 模块 状态 重要度
python/sglang/srt/layers/moe/fused_moe_triton/layer.py 专家层 modified 7.49
python/sglang/srt/distributed/parallel_state.py 通信层 modified 7.36
python/sglang/srt/layers/moe/token_dispatcher/deepep_v2.py 专家分发 modified 6.06
python/sglang/srt/layers/moe/utils.py 专家工具 modified 6.52
python/sglang/srt/layers/moe/moe_runner/deep_gemm.py 内核适配 modified 6.04
python/sglang/kernels/ops/moe/ep_moe_kernels.py 内核封装 modified 4.4
test/registered/unit/layers/moe/test_deepep_v2_wire_dtype.py 格式测试 added 7.39
test/registered/kernel/moe/test_deepep_v2_contig_scatter.py 内核测试 added 7.17
test/registered/kernel/communication/test_deterministic_reduce_scatter.py 确定性测试 added 6.63
test/registered/unit/distributed/test_parallel_state.py 通信测试 modified 6.45

关键符号

create_moe_dispatcher _deepep_v2_experts_are_fp8 _validate_deepep_v2_quant_method get_deepep_v2_dispatcher_output_dtype DeepEPv2Dispatcher.__init__ _DeepEPv2Impl.__init__ _DeepEPv2Impl._get_buffer _DeepEPv2Impl.dispatch pre_permute_deepep_v2_to_deep_gemm reduce_scatter_tensor reduce_scatterv _deterministic_reduce_scatterv ep_scatter_from_psum

关键源码片段

python/sglang/srt/layers/moe/fused_moe_triton/layer.py core-logic

dispatcher 装配入口:`create_moe_dispatcher` 新增 `quant_method` 参数并按专家格式解析 `use_fp8_dispatch`;`_validate_deepep_v2_quant_method` 放行 BF16 专家,是 BF16 支持的总开关。

def create_moe_dispatcher(
    moe_runner_config: MoeRunnerConfig,
    quant_method: FusedMoEMethodBase,
) -> BaseDispatcher:
    a2a_backend = get_moe_a2a_backend()
    # 其他后端分支省略:none / megamoe / deepep v1 / flashinfer 等保持原样。
    if a2a_backend.is_deepep_v2():
        # wire dtype 必须跟随 expert 权重量化方式:DeepEP v2 把接收缓冲
        # 直接交给 DeepGEMM 分组 GEMM,量化方式不一致会在 GEMM 入口出错。
        output_dtype = get_deepep_v2_dispatcher_output_dtype(
            _deepep_v2_experts_are_fp8(quant_method)
        )
        return DeepEPv2Dispatcher(
            group=get_tp_group().device_group,
            router_topk=moe_runner_config.top_k,
            num_experts=moe_runner_config.num_experts,
            num_local_experts=moe_runner_config.num_local_experts,
            hidden_size=moe_runner_config.hidden_size,
            params_dtype=moe_runner_config.params_dtype,
            use_fp8_dispatch=output_dtype is DispatcherOutputDtype.FP8,
        )
​
​
def _deepep_v2_experts_are_fp8(quant_method) -> bool:
    # 除未量化 BF16(UnquantizedFusedMoEMethod)外,当前 deepep_v2
    # 支持的其余量化方法都是 blockwise FP8。
    return not isinstance(quant_method, UnquantizedFusedMoEMethod)
python/sglang/srt/distributed/parallel_state.py core-logic

确定性推理的核心修复:`reduce_scatter_tensor` 与 `reduce_scatterv` 在确定性开关下改为 all-reduce + 本地切片,消除求和对接收 rank 的依赖;属于全局分布式通信层路径。

def reduce_scatter_tensor(self, output: torch.Tensor, input: torch.Tensor):
    if envs.SGLANG_ENABLE_DETERMINISTIC_INFERENCE.get():
        assert input.numel() == output.numel() * self.world_size
        # 原生 reduce-scatter 的浮点求和顺序由接收 rank 决定:同一条 token
        # 随批次增长换到其他 rank 后,进入第一个 MoE 层时已是不同位模式。
        # all-reduce 让所有 rank 拿到同一归约结果,再做本地切片,
        # 求和的 rank 依赖就此消除;克隆 input 保持调用契约,
        # 兼容输出别名到输入切片的调用者。
        reduced = self.all_reduce(input.clone())
        output.copy_(
            reduced.reshape(-1)
            .narrow(0, self.rank_in_group * output.numel(), output.numel())
            .view_as(output)
        )
    elif _is_npu or _is_cpu:
        # TODO: add optimized reduce_scatter_tensor kernel for cpu
        self._reduce_scatter_tensor(output, input)
    elif self._maybe_aiter_reduce_scatter(output, input):
        return
    else:
        reg_reduce_scatter_tensor(output, input, group_name=self.unique_name)
​
​
def reduce_scatterv(
    self,
    input_: torch.Tensor,
    output: Optional[torch.Tensor] = None,
    sizes: Optional[List[int]] = None,
) -> torch.Tensor:
    world_size = self.world_size
    pynccl_comm = self.pynccl_comm
​
    # reduce_scatterv 不是边角路径:DP attention(attn_dp_size > 1)时
    # should_use_dp_reduce_scatterv() 会把 combine 路由到这里。
    if envs.SGLANG_ENABLE_DETERMINISTIC_INFERENCE.get():
        return self._deterministic_reduce_scatterv(input_, output, sizes)
​
    with pynccl_comm.change_state(enable=True):
        assert pynccl_comm is not None and not pynccl_comm.disabled, (
            "pynccl is required for reduce_scatterv"
        )
        if sizes is not None:
            assert len(sizes) == world_size
            assert input_.shape[0] == sum(sizes)
            chunk_size = sizes[self.rank_in_group]
        else:
            assert input_.shape[0] % world_size == 0
            chunk_size = input_.shape[0] // world_size
        output_shape = (chunk_size,) + input_.shape[1:]
        if output is None:
            output = torch.empty(
                output_shape, dtype=input_.dtype, device=input_.device
            )
        else:
            assert output.shape == output_shape
        pynccl_comm.reduce_scatter(output, input_, sizes=sizes)
        return output
​
​
def _deterministic_reduce_scatterv(
    self,
    input_: torch.Tensor,
    output: Optional[torch.Tensor],
    sizes: Optional[List[int]],
) -> torch.Tensor:
    # 与 reduce_scatter_tensor 同理,但支持不等长分片:offset 是前缀和。
    if sizes is not None:
        assert len(sizes) == self.world_size
        assert input_.shape[0] == sum(sizes)
        chunk_size = sizes[self.rank_in_group]
        offset = sum(sizes[: self.rank_in_group])
    else:
        assert input_.shape[0] % self.world_size == 0
        chunk_size = input_.shape[0] // self.world_size
        offset = chunk_size * self.rank_in_group
    output_shape = (chunk_size,) + input_.shape[1:]
    if output is None:
        output = torch.empty(output_shape, dtype=input_.dtype, device=input_.device)
    else:
        assert output.shape == output_shape
    # 克隆输入:即使 all_reduce 原地改写参数,也不破坏调用方的 input。
    reduced = self.all_reduce(input_.clone())
    output.copy_(reduced.narrow(0, offset, chunk_size))
    return output
python/sglang/srt/layers/moe/token_dispatcher/deepep_v2.py core-logic

BF16 wire 的实际执行点:`use_fp8_dispatch` 透传到 ElasticBuffer,dispatch 中跳过激活量化,是 BF16 通信的核心逻辑。

class _DeepEPv2Impl:
    def __init__(
        self,
        group,
        router_topk: int,
        num_experts: int,
        num_local_experts: int,
        hidden_size: int,
        scale_format: DeepEPv2Fp8ScaleFormat,
        num_max_dispatch_tokens_per_rank: int,
        use_fp8_dispatch: bool,
    ):
        self.group = group
        self.router_topk = router_topk
        self.num_experts = num_experts
        self.num_local_experts = num_local_experts
        self.hidden_size = hidden_size
        self.scale_format = scale_format
        self.num_max_dispatch_tokens_per_rank = num_max_dispatch_tokens_per_rank
        self.use_fp8_dispatch = use_fp8_dispatch
​
    def _get_buffer(self) -> ElasticBuffer:
        # ElasticBuffer 的单例缓存键包含 use_fp8_dispatch:BF16 与 FP8 层
        # 交错的 checkpoint 会在层切换处触发集体缓冲区重建,需要单独的生命周期覆盖。
        return ElasticBuffer(
            self.rank,
            self.hidden_size,
            self.router_topk,
            self.num_max_dispatch_tokens_per_rank,
            self.use_fp8_dispatch,
        )
​
    def dispatch(self, hidden_states, topk_output):
        # ... 前置校验省略 ...
        if not self.use_fp8_dispatch:
            # BF16 wire:激活原样进入 ElasticBuffer,不量化、不产生 scale;
            # 后续 DeepGEMM 预置换适配器也不再分配激活 scale 张量。
            dispatch_x = hidden_states
            use_tma_aligned_col_major_sf = False
        elif use_masked:
            # FP8 masked 路径:保持原有 per-token-group 量化与 scale 打包。
            _ensure_fp8_quant_available()
            _ue8m0 = self.scale_format.ue8m0
            dispatch_x = sglang_per_token_group_quant_fp8(
                hidden_states, _SCALE_BLOCK_SIZE, ue8m0=_ue8m0
            )
            # ... 其余 FP8 分支(TMA 对齐 scale 等)省略 ...

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 确定性路径的性能与通信开销:reduce_scatter_tensor 与 reduce_scatterv 在确定性模式下从原生 reduce-scatter 改为 all-reduce + 全输入临时 buffer + input.clone(),通信量和显存临时占用上升,低延迟 decode 场景可能受影响;PR 描述明确未做端到端基准。
  2. 确定性依赖底层 all-reduce 实现:4 卡回归固定 NCCL_ALGO=allreduce:tree 并关闭 custom all-reduce;如果生产环境走了自定义 all-reduce(如 FlashAttention custom AR)或其他 NCCL 算法,求和顺序是否仍稳定缺少运行时断言。
  3. 混合量化 checkpoint 未覆盖:ElasticBuffer 单例缓存键包含 use_fp8_dispatch,BF16/FP8 层交替会触发层边界的集体缓冲区重建,PR 描述明确标注「mixed-quantization checkpoints need separate lifecycle coverage」。
  4. BF16 dispatch 带宽成本:BF16 激活载荷约为 FP8 的两倍,ElasticBuffer 总分配不一定翻倍但通信压力上升。
  5. CI 状态混杂:合并时 PR Test(Base)通过,但 PR Test(Extra)与 AMD ROCm 10 失败;对改动分布式通信层的 PR 而言,非主 CI 的失败需要后续确认是否与本次变更相关。
  • 用户侧:--moe-a2a-backend deepep_v2 首次支持 BF16 checkpoint;--enable-deterministic-inference 下批次增长不再改变已有 token 的生成结果(修复前 31.1% 的 BF16 元素因 rank 迁移而位级不一致);FP8 用户的既有契约完整保留。
  • 系统侧:GroupCoordinator.reduce_scatter_tensor 与 reduce_scatterv 的确定性分支属于分布式通信层全局路径,所有 TP/DP 模型在确定性开关下都会走新逻辑,不只影响 MoE;非确定性模式保持原生路径,零行为变化。
  • 团队侧:新增 4 卡 B200 集体回归与 manual logprob 探针,CI 资源成本上升;PR 未附用户文档更新(checklist 中未勾选),后续需要补 --deepep-dispatcher-output-dtype 与确定性模式的行为说明。
  • 验证范围:模型验证仅覆盖同构 BF16/FP8 的 Qwen3 系 checkpoint、单机 direct 模式;多节点/混合传输、其他模型族、以及 GSM8K 精度数据(修复前测得)均未覆盖。
核心通信路径变更 确定性模式性能未量化 混合量化触发 buffer 重建 确定性依赖 all-reduce 底层实现 BF16 dispatch 带宽成本翻倍

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论