Prhub

#31838 Fix pad-row top-k masking with custom_routing_function under DP attention

原始 PR 作者 hanming-lu 合并时间 2026-07-22 02:49 文件变更 6 提交数 5 评论 8 代码增减 +189 / -8

执行摘要

修复 custom routing 下 pad-row mask 缺失及 prefill replay 中 num_token_non_padded 计算错误

select_experts 在 custom_routing_function 分支断言 num_token_non_padded is None,任何使用自定义路由函数的模型都无法传入该参数。在带有 CUDA-graph padding 的 DP attention 下,padded row 的 router logits 是垃圾数据,若不 mask,padded row 会保留 unmasked top-k expert ids,导致 per-expert dispatch 计数倾斜并可能溢出 fused EP MoE 内核的 per-expert buffer,最终输出 NaN 或错误结果。

值得精读:展示了在 CUDA-graph 动态形状与 MoE 路由交互时的边界处理,尤其是 post_fill 钩子的运用方式以及如何利用 host int 避免 replay 时 host-to-device copy。设计决策清晰,代码注释详细,是学习 SGLang MoE + 图执行系统的良好案例。

讨论亮点

PR 无公开 review 讨论,作者自行测试后合并。作者在最后一条评论中提到内部测试了 kl + stress accuracy,CI 全通过。

实现拆解

  1. 删除自定义路由分支的断言:在 python/sglang/srt/layers/moe/topk.pyselect_experts 中删除 assert num_token_non_padded is None,并添加注释说明 padding-unaware 的自定义路由输出在后处理 _post_process_topk_ids 中会被 mask(CUDA 上 padded row 设为 -1,HIP 上设为 0 并 zero 权重)。
  2. 提取 attn-TP shard 边界计算:在 python/sglang/srt/model_executor/forward_batch_info.py 中新增 _attn_tp_local_shard_bounds 函数,返回当前 attn-TP rank 的 tokens_per_rankrank_offset;重构 compute_local_num_token_non_padded 使用新函数;新增整数版本 compute_local_num_token_non_padded_cpu 用于 replay 时避免 host-to-device copy。
  3. 在 prefill registry 添加 post_fill 钩子:在 python/sglang/srt/model_executor/cuda_graph_buffer_registry.pybuild_prefill_registry 中,当 enable_num_token_non_padded 时注册 _prefill_num_token_non_padded_post_fill 作为 num_token_non_padded 槽位的 post_fill。该钩子利用 fb.num_token_non_padded_cpu(全局未调整计数)和 ctx.padded_num_tokens(bucket 大小)重新计算 local count,仅在 require_gathered_buffer=Trueenable_prefill_cp=False 时生效。同时为 build_prefill_registry 新增 require_gathered_bufferenable_prefill_cp 参数。
  4. 在 prefill CUDA-graph runner 传入新参数:在 python/sglang/srt/model_executor/runner/prefill_cuda_graph_runner.py 中,向 build_prefill_registry 传递 require_gathered_bufferenable_prefill_cp
  5. 新增测试覆盖:在 test/registered/unit/model_executor/test_cuda_graph_buffer_registry.py 中增加 TestPrefillNumTokenNonPaddedPostFill 测试类,验证不同 attn-tp rank 下 post_fill 正确使用 bucket shard 而非原始 FB 值(rank 0 应返回 bucket/attn_tp,rank 1 应正确 clamp 到真实 padded 行数);在 test/registered/moe/test_topk_padded_region.py 中增加 TestSelectExpertsCustomRoutingPadMask 测试类,验证 select_experts 接受 num_token_non_padded 且 custom router 输出中的 padded row 被 mask 为 -1,真实行保持不变。
文件 模块 状态 重要度
python/sglang/srt/layers/moe/topk.py MoE 路由 modified 5.47
python/sglang/srt/model_executor/forward_batch_info.py 批信息 modified 7.21
python/sglang/srt/model_executor/cuda_graph_buffer_registry.py 图注册表 modified 7.06
test/registered/unit/model_executor/test_cuda_graph_buffer_registry.py 图注册表测试 modified 7.03
test/registered/moe/test_topk_padded_region.py MoE Padded 区域测试 modified 6.5
python/sglang/srt/model_executor/runner/prefill_cuda_graph_runner.py 预填充执行器 modified 5.16

关键符号

select_experts compute_local_num_token_non_padded_cpu _attn_tp_local_shard_bounds _prefill_num_token_non_padded_post_fill build_prefill_registry

关键源码片段

python/sglang/srt/layers/moe/topk.py core-logic

核心 bug 修复:删除 custom_routing_function 分支的断言,允许传递 num_token_non_padded,并依赖后处理 mask padded region。

# python/sglang/srt/layers/moe/topk.py
# 在 select_experts 函数中,原本自定义路由分支有断言 :
# assert num_token_non_padded is None, ...
# 删除该断言并添加注释 :
else:
    # custom_routing_function 本身对 padding 不感知,其 padded row 输出是垃圾数据。
    # 但这是安全的,因为下面的 _post_process_topk_ids 会在 logical->physical 重映射后
    # 将 num_token_non_padded 及之后的行 mask 掉(CUDA 上 topk_ids 设为 -1,
    # HIP 上设为 0 并 zero 权重)。
    assert not apply_routed_scaling_factor_on_output, "Not implemented"
    topk_weights, topk_ids = custom_routing_function(
        hidden_states=hidden_states,
        gating_output=router_logits,
        topk=topk_config.top_k,
        renormalize=topk_config.renormalize,
    )
    # 后续 shared path 会调用 _post_process_topk_ids 进行 pad mask
python/sglang/srt/model_executor/forward_batch_info.py data-contract

新增辅助函数 _attn_tp_local_shard_bounds 和整数版本 compute_local_num_token_non_padded_cpu,重构原函数以复用公共逻辑。

# python/sglang/srt/model_executor/forward_batch_info.pydef _attn_tp_local_shard_bounds(num_tokens_per_dp: int) -> Tuple[int, int]:
    """返回当前 attn-TP rank 的连续 shard 的 (tokens_per_rank, rank_offset)。"""
    parallel = get_parallel()
    tokens_per_rank = num_tokens_per_dp // parallel.attn_tp_size
    return tokens_per_rank, tokens_per_rank * parallel.attn_tp_rank
​
​
def compute_local_num_token_non_padded(
    global_num_token_non_padded: torch.Tensor,
    num_tokens_per_dp: int,
) -> torch.Tensor:
    """将全局计数(当前 DP rank 内)转为本地 attn-TP rank 的计数。"""
    tokens_per_rank, rank_offset = _attn_tp_local_shard_bounds(num_tokens_per_dp)
    return torch.clamp(
        global_num_token_non_padded - rank_offset,
        0,
        tokens_per_rank,
    )
​
​
def compute_local_num_token_non_padded_cpu(
    global_num_token_non_padded: int,
    num_tokens_per_dp: int,
) -> int:
    """整数版本,用于 replay 时直接在 host 计算,然后通过 Tensor.fill_ 写入 GPU buffer。"""
    tokens_per_rank, rank_offset = _attn_tp_local_shard_bounds(num_tokens_per_dp)
    return min(max(global_num_token_non_padded - rank_offset, 0), tokens_per_rank)
python/sglang/srt/model_executor/cuda_graph_buffer_registry.py data-contract

新增 prefill registry 的 post_fill 钩子 _prefill_num_token_non_padded_post_fill,在 replay 时根据 bucket 大小重新计算 local num_token_non_padded。

# python/sglang/srt/model_executor/cuda_graph_buffer_registry.py
# 在 build_prefill_registry 函数内的 slot 注册部分
if enable_num_token_non_padded:
    from sglang.srt.model_executor.forward_batch_info import (
        compute_local_num_token_non_padded_cpu,
    )
​
    def _prefill_num_token_non_padded_post_fill(buf, fb, ctx):
        # FB tensor 中的 num_token_non_padded 是基于 RAW 长度本地化的,
        # 但 replay 会将 token 数填充到 capture bucket,从而移动了 attn-TP shard 边界。
        # 如果直接复制 FB 的值,当 raw < bucket 时,pad mask 会错误地覆盖真实 token。
        # 因此需要根据 bucket 大小(ctx.padded_num_tokens)重新计算本地计数。
        # 该逻辑仅在使用 gathered buffer 且未启用 prefill context parallelism 时生效。
        if require_gathered_buffer and not enable_prefill_cp:
            buf.fill_(
                compute_local_num_token_non_padded_cpu(
                    global_num_token_non_padded=fb.num_token_non_padded_cpu,
                    num_tokens_per_dp=ctx.padded_num_tokens,
                )
            )
​
    slots.append(
        GraphSlot(
            "num_token_non_padded",
            lambda _bs2, _mt: (1,),
            torch.int32,
            axis="none",
            post_fill=_prefill_num_token_non_padded_post_fill,
        )
    )

评论区精华

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

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

风险与影响

  • 核心路径变更select_experts 是 MoE 路由关键函数,删除断言并依赖后处理 mask 可能让旧的调用方在未传入 num_token_non_padded 时行为不变,但任何忘记传入 num_token_non_padded 的 custom router 场景将失去保护(之前断言会直接报错,现在 silent 地产生垃圾输出)。不过后处理 _post_process_topk_ids 实际上在 shared path 已处理这种情况。
  • CUDA-graph replay 依赖_prefill_num_token_non_padded_post_fill 仅在 require_gathered_bufferenable_prefill_cp=False 时触发,若未来其他图模式(如 breakable prefill context parallelism)未正确设置这两个参数,可能导致仍使用错误的 local count。
  • HIP 平台测试跳过TestSelectExpertsCustomRoutingPadMask 跳过了 HIP 平台,DP attention + HIP 场景可能未被覆盖,不过 HIP 路径有独立 mask 逻辑。
  • 性能影响post_fill 仅执行整数计算和 fill_,影响极小。

影响范围:主要影响使用自定义路由函数(custom_routing_function)且启用 DP attention + CUDA-graph padding 的模型(如内部部署的模型)。这些用户在升级后应不再遇到 NaN 输出。对于未使用 custom_routing_function 或 DP attention 的用户,无行为变化。影响程度:修复了正确的功能性 bug,提升稳定性。测试覆盖了回归场景。

自定义路由路径变更 CUDA-graph replay 依赖 缺少 HIP 测试覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论