Prhub

#34759 [DSpark] Fix EP1 decode performance regression

原始 PR 作者 ormandj 合并时间 2026-08-14 08:00 文件变更 1 提交数 3 评论 3 代码增减 +9 / -3

执行摘要

DSpark 按需分配 num_token_non_padded,修复 EP1 解码性能回归

根据 PR body,回归源于 PR #33465 使 DraftBlockProposer 在每次 speculative decoding 步骤无条件构造 CUDA int32 标量,而 num_token_non_padded 仅在 enable_num_token_non_padded() 为真时被消费;MoE EP size 1 场景下谓词为假,设备分配完全未被使用。ormandj 在 issue 评论中确认目标是修复这一 CUDA EP1 decode 性能回归,并附带 matched before/after/fixed profiling 结果。

值得快速浏览:改动极小但定位精准,是典型的避免热路径上无意义设备分配的微优化案例;同时展示了性能回归修复应附 matched profiling 的实践。合并历史中测试被移除这一点值得关注——如果团队希望长期守住两条开关路径的行为,建议后续补一个针对 _make_num_token_non_padded 的轻量单测。

讨论亮点

本次 PR 没有独立的 review 评论,hnyls2002 直接 approve。核心讨论发生在 issue 评论:ormandj 说明修复目标与 matched profile(before/#33465 后/fixed),hnyls2002 触发 /rerun-test,三个 DSpark 测试全部通过。值得注意的取舍是:PR body 声称的双路径单测最终被移除(提交 drop non-guarding num_token_non_padded test),合并版本仅含源码改动。

实现拆解

  1. 定位回归源:DraftBlockProposer._run_forward(位于 python/sglang/srt/speculative/dspark_components/dspark_draft.py)在构造 ForwardBatch 时,将 num_token_non_padded 参数改为无条件内联构造 torch.tensor(draft_num_tokens, dtype=torch.int32).to(device, non_blocking=True)。由于该字段只在 enable_num_token_non_padded() 为真时被消费,MoE EP size 为 1 时谓词为假,每次投机解码步骤都会产生未被使用的设备分配,造成平均每步 6%~9% 的耗时回退。
  2. 新增按需分配辅助函数:在模块顶层新增 _make_num_token_non_padded(num_tokens, device) -> Optional[torch.Tensor],先判定 enable_num_token_non_padded(),为假时直接返回 None 跳过分配;为真时保留原有 torch.int32 设备标量与 non_blocking=True 语义。
  3. 替换调用点:_run_forward 中 ForwardBatch 的 num_token_non_padded 改为调用 _make_num_token_non_padded(draft_num_tokens, device),num_token_non_padded_cpu 保持原有 draft_num_tokens 不变,避免影响依赖 CPU 侧数值的路径。
  4. 测试与配套:PR body 声称补充了两条路径的单元覆盖,但最终提交(drop non-guarding num_token_non_padded test)移除了对应测试,合并结果仅包含源码改动;重跑的 test_basic_sanity_dspark.py、test_dspark_dp_tier.py、test_dspark_draft_path_default.py 均通过。
文件 模块 状态 重要度
python/sglang/srt/speculative/dspark_components/dspark_draft.py 投机解码 modified 5.97

关键符号

_make_num_token_non_padded

关键源码片段

python/sglang/srt/speculative/dspark_components/dspark_draft.py core-logic

唯一变更文件,也是 DSpark draft 前向与投机解码的核心路径。新增 _make_num_token_non_padded 按需分配 num_token_non_padded,并替换 _run_forward 中无条件设备分配,修复 EP1 性能回归。

# python/sglang/srt/speculative/dspark_components/dspark_draft.py# 集中管理 num_token_non_padded 设备标量的分配。
# 该字段只在 enable_num_token_non_padded() 为真时被下游消费,
# 例如 MoE EP size 为 1 时开关为假,若仍执行 torch.tensor(...).to(device, ...),
# 每个投机解码步骤都会产生一次无意义的设备分配,这正是 PR #33465 引入的
# CUDA EP1 decode 性能回归根因。开关关闭时返回 None 以跳过分配。
def _make_num_token_non_padded(
    num_tokens: int, device: str | torch.device
) -> Optional[torch.Tensor]:
    if not enable_num_token_non_padded():
        return None
    return torch.tensor(num_tokens, dtype=torch.int32).to(device, non_blocking=True)
​
​
# DraftBlockProposer._run_forward 中构造 ForwardBatch 的调用点:
# 原实现内联无条件分配,现改为按需创建;CPU 侧的 num_token_non_padded_cpu
# 保持不变,作为 shape 校验与 graph capture 的稳定回退值。
draft_num_tokens = bs * gamma
draft_forward_batch = ForwardBatch(
    forward_mode=ForwardMode.TARGET_VERIFY,
    batch_size=bs,
    input_ids=draft_block_ids.flatten(),
    req_pool_indices=batch.req_pool_indices,
    seq_lens=prefix_lens,
    out_cache_loc=draft_cache_loc,
    seq_lens_sum=draft_seq_lens_sum,
    seq_lens_cpu=draft_seq_lens_cpu,
    positions=draft_positions,
    input_embeds=draft_input_embeds,
    spec_algorithm=SpeculativeAlgorithm.DSPARK,
    spec_info=self._draft_block_spec_info,
    capture_hidden_mode=CaptureHiddenMode.NULL,
    num_token_non_padded=_make_num_token_non_padded(draft_num_tokens, device),
    num_token_non_padded_cpu=draft_num_tokens,
)

评论区精华

回归修复的动机与性能验证基线 性能

ormandj 在 issue 评论中说明:本 PR 修复 PR #33465 无条件 DSpark 设备标量分配导致的 CUDA EP1 decode 性能回归,并在 PR body 给出 before/after/fixed 三组 profile;hnyls2002 随后要求重跑三个 DSpark 测试。

结论:1-gpu-h100 与 ubuntu-latest 两个 runner 上三个测试全部通过,无回归。 · 已解决

测试覆盖的取舍 测试

PR body 声称新增两条路径单测,但提交历史显示最后一条提交删除了 non-guarding 的 num_token_non_padded 测试,最终变更集中在一个源码文件。

结论:未保留针对开关两条路径的直接断言,依赖现有 DSpark 测试覆盖主路径。 · info

风险与影响

行为一致性:返回 None 依赖下游只在 enable_num_token_non_padded() 为真时消费该字段;若某后端(如 NPU 上的 Kimi-K3)在开关为假时仍读取,会出现隐性 None 解引用。PR 声明开关与消费一一对应,但没有单元测试直接守护该契约。CUDA graph 兼容:decode CUDA graph 捕获时若依赖该张量存在,None 可能影响 replay;本 PR 保留 num_token_non_padded_cpu=draft_num_tokens 作为 CPU 侧回退,风险较低,且 profile 中 graph kernel 数与对照组一致,构成间接验证。性能收益范围:实测收益仅在 EP1(MoE EP size 1)+ CUDA 场景显著,其他配置下行为不变。

对用户的收益:DeepSeek 系列 DSpark decode 在 EP1 配置下每步延迟降低约 7%~9%(Blackwell TP2 实测),等效提高输出吞吐。对系统的风险:改动位于投机解码热路径(dspark_draft.py 的 _run_forward),但分支判定极轻,不会引入额外开销。对团队的意义:撤销了 #33465 的副作用,同时保留 NPU padding 开关路径,是跨硬件(CUDA/NPU)特性共存的清理。

投机解码热路径变更 双路径单测未保留 行为依赖启用开关

关联 Issue

#33465 [Kimi-K3][NPU] Support Kimi-K3 on NPU

完整报告

参与讨论