Prhub

#39574 [misc] Fix tool-call index, graph padded-row count, and prefill-graph input_embeds refresh

原始 PR 作者 hnyls2002 合并时间 2026-09-16 07:37 文件变更 8 提交数 12 评论 4 代码增减 +90 / -34

执行摘要

修复工具调用序号、图填充计数契约、prefill 嵌入刷新等五项缺陷

PR body 逐条给出了五个独立缺陷的机理:

1) 非流式 _process_tool_calls 按 detector 的 tool_index 编号,而该值对多数 detector 是工具在请求中的位置,同一工具两次调用都得到 index=0,与流式 delta 的 0/1 编号不一致;
2) decode graph runner 无条件分配 num_token_non_padded 槽位、capture 时写入、仅 EP 下 replay 刷新,导致非 EP 时静态 ForwardBatch 携带 capture 时的陈旧计数,而 eager batch 的 ForwardBatch.init_new 只在 EP 下填充该值;
3) 标准 MoE 路径未像 a2a 路径那样把 padded rows 路由到 expert -1;
4) 外层 forward 不传 input_embeds 时 prefill 图回放闭包不刷新槽位,图会读取上一批次嵌入;
5) FlashInfer >= 0.6.18 按内容定型 do_finalize=False 的权重缓冲,旧的 fp32->bf16 位重解释只对旧版本正确,会损坏真实 fp32 权重。

值得精读。两个设计决策有学习价值:① 用"槽位存在性"表达数据契约——槽位不存在即契约无效,让 CUDA graph 静态批与 eager 批在两条路径上共享同一不变量,避免再次出现"graph 携带 stale 状态、eager 携带 None"的分叉;② replay 闭包处理输入槽位的模式——能从 kwargs、位置参数逐步回退到现场计算(get_input_embeddings),可复用于其他 graph runner 的输入刷新。建议合并前向作者确认非 EP 下其他 num_token_non_padded 读取点的审计情况。

讨论亮点

本 PR 无人工 review 评论(review_comments_count = 0),4 条 Issue 评论均为 CI 指令与机器人结果。最有信息量的讨论沉淀在 PR body 与 CI 首跑失败中:

  • 作者在 PR body 第 2 项给出契约统一表述:"Graph and eager batches now follow the same rule: no count unless it is live."——即槽位存在性即契约有效性,静态批与 eager 批共享同一不变量。
  • 第一次 /rerun-test 中 1-gpu-h100 的 test_vision_openai_server_a.py 失败(机器人首次报告 ❌),重跑后通过;作者定位到早期修订版因图 ForwardBatch 携带 stale num_token_non_padded 计数,在 fused MoE kernel 中触发 illegal memory access,当前 head 输出与 main 完全一致。
  • deferred finalize 的版本权衡:旧位重解释只对"以 routing_logits dtype 分配缓冲却写入 bf16"的 FlashInfer 版本正确;FlashInfer >= 0.6.18(flashinfer #3595)按内容定型,必须改为按内容校验 bf16/fp32。

实现拆解

按 5 个独立修复拆分实现:

  1. 工具调用序号修复(python/sglang/srt/entrypoints/openai/serving_chat.py):_process_tool_calls 将 for call_info in call_info_list 改为 for index, call_info in enumerate(call_info_list),ToolCall.index 直接使用调用序数,替代 getattr(call_info, "tool_index", None),保留原有 _process_tool_call_id 与 finish_reason 逻辑。配套回归测试 test_serving_chat.py::test_non_streaming_tool_call_index_is_the_call_ordinal 用 mock parser 构造同一工具两次调用(tool_index 均为 0),断言输出 index 为 [0, 1] 且 arguments 顺序保留。

  2. num_token_non_padded 契约收紧(runner_utils/buffers.py、runner/base_runner.py、runner/decode_cuda_graph_runner.py):DecodeInputBuffers 字段类型由 torch.Tensor 改为 Optional[torch.Tensor];DecodeInputBuffers.create 与 _allocate_decode_buffers 仅在 enable_num_token_non_padded() 为真时分配槽位;_dummy_run 与 decode 图回放的写入全部改为 if buffers.num_token_non_padded is not None 判空。外层判空后,decode runner 内 SP 本地计数换算省略了原先重复的 enable_num_token_non_padded() 判断(逻辑等价)。

  3. MoE 标准路径屏蔽填充行(models/deepseek_v2.py):DeepseekV2MoE.forward 从 forward_batch 提取 num_token_non_padded 并透传给 forward_normal 与 forward_normal_dual_stream,最终传入 self.topk(..., num_token_non_padded=...),与 forward_deepep 既有行为对齐;同时双流变体在 is_in_breakable_cuda_graph() 为真时跳过,与 hc 路径的既有守卫保持一致。

  4. prefill 图回放刷新嵌入(runner/prefill_cuda_graph_runner.py):_execute_body_capture 内嵌的 replay_layer_forward 在既无 input_embeds kwarg 也无第 4 个位置参数时,用 args[0](或 layer_kwargs["input_ids"])调用 model.get_input_embeddings() 现场计算嵌入并拷入静态槽位。

  5. deferred finalize ABI 适配(layers/moe/moe_runner/flashinfer_trtllm.py):_make_deferred_finalize_output 删除 fp32->bf16 位重解释,改为校验 dtype 属于 {bf16, fp32} 后直接透传。附带清理:删除 decode runner 中未被使用的 ngram_embedding_n/k 属性。

验证配套:除新增回归测试外,跑通 test_serving_chat.py、model_executor 单测、batch_overlap、dp-attention、LoRA MoE、EPLB 单测;Kimi-VL-A3B test_vision_openai_server_a.py(混合 image/text batch、decode CUDA graph、EP off)早期修订版触发 fused MoE kernel 非法内存访问,当前 head 通过且输出与 main 一致,混合批 client 重复回放 6 次。

文件 模块 状态 重要度
python/sglang/srt/model_executor/runner/decode_cuda_graph_runner.py 图执行器 modified 6.71
python/sglang/srt/model_executor/runner_utils/buffers.py 静态缓冲 modified 6.68
python/sglang/srt/model_executor/runner/base_runner.py 执行器基类 modified 6.15
python/sglang/srt/model_executor/runner/prefill_cuda_graph_runner.py 图执行器 modified 6.15
python/sglang/srt/models/deepseek_v2.py MoE 路由 modified 6.12
python/sglang/srt/layers/moe/moe_runner/flashinfer_trtllm.py MoE 内核 modified 5.94
python/sglang/srt/entrypoints/openai/serving_chat.py 聊天服务 modified 5.19
test/registered/unit/entrypoints/openai/test_serving_chat.py 服务测试 modified 5.49

关键符号

_process_tool_calls DecodeInputBuffers.create _allocate_decode_buffers _dummy_run replay_layer_forward DeepseekV2MoE.forward DeepseekV2MoE.forward_normal DeepseekV2MoE.forward_normal_dual_stream _make_deferred_finalize_output

关键源码片段

python/sglang/srt/model_executor/runner/decode_cuda_graph_runner.py data-contract

decode CUDA graph runner 是 num_token_non_padded 契约修复的核心:刷新逻辑改为槽位判空,非 EP 下不再写入 stale 计数,直接消除 Kimi-VL-A3B 场景的 fused MoE kernel 非法内存访问;同时清理死代码 ngram_embedding_n/k。

# decode 图回放前的缓冲准备阶段:按槽位是否存在决定是否刷新计数。
# 该槽位只在 enable_num_token_non_padded()(即 EP 场景)下分配,
# 非 EP 时保持 None,避免静态 ForwardBatch 携带 capture 时的陈旧
# 计数,进而导致 fused MoE kernel 读取越界(Kimi-VL-A3B 实测崩溃)。
attn_tp_sharded = self.model_runner.attn_tp_sequence_sharded(num_tokens)
if buffers.num_token_non_padded is not None:
    # 槽位存在 = 契约有效:写入全局 token 数,并视 attn-TP 分片
    # 情况换算为本地计数(SP on 时每个 rank 只持有分片后的 token)。
    buffers.num_token_non_padded[...] = num_tokens
    if not self.enable_prefill_cp and attn_tp_sharded:
        local = compute_local_num_token_non_padded(
            global_num_token_non_padded=buffers.num_token_non_padded,
            num_tokens_per_dp=num_tokens,
            sharded=True,
        )
        buffers.num_token_non_padded.copy_(local)
python/sglang/srt/model_executor/runner/prefill_cuda_graph_runner.py data-contract

prefill 图回放闭包在 ie 缺失时由 input_ids 现场计算嵌入,修复图读取上一批次嵌入的陈旧数据问题。

def replay_layer_forward(*args, **layer_kwargs):
    # 被捕获的 prefill body 图从静态 input_embeds 槽位读取激活;外层
    # model.forward(eager 执行)会把实时嵌入作为第 4 个位置参数或
    # input_embeds kwarg 传给 layer_model.forward。回放前必须把当前
    # 请求的嵌入拷入槽位,否则图会读到上一批次的嵌入。
    if self.buffer_registry.has_slot("input_embeds"):
        ie = layer_kwargs.get("input_embeds")
        if ie is None and ie_idx is not None and len(args) > ie_idx:
            ie = args[ie_idx]
        if ie is None:
            # 纯文本 batch 外层不传嵌入时,直接由 input_ids 现场计算,
            # 与多模态 batch 的 eager 路径语义保持一致。
            input_ids = args[0] if args else layer_kwargs["input_ids"]
            ie = self.model_runner.model.get_input_embeddings()(input_ids)
        self.buffer_registry.get_slot("input_embeds").slice_for(
            1, static_num_tokens
        )[: ie.shape[0]].copy_(ie)
    hs = self.backend.replay(shape_key, static_forward_batch, **kwargs)
    return _slice_output_rows(hs, raw_num_tokens) if full_path else hs
python/sglang/srt/entrypoints/openai/serving_chat.py core-logic

非流式工具调用序号修复的源码入口:ToolCall.index 从 detector 的 tool_index(工具在请求中的位置)改为调用序数,与流式 delta 对齐。

tool_calls = []
# 按调用序数编号,与流式 delta 保持一致;
# tool_index 表示工具在请求中的位置,同一工具被调用两次时
# 两个调用都是 0,不能再作为 index 使用。
for index, call_info in enumerate(call_info_list):
    tool_id = self._process_tool_call_id(call_info, history_tool_calls_cnt)
    tool_calls.append(
        ToolCall(
            id=tool_id,
            index=index,
            function=FunctionResponse(
                name=call_info.name,
                arguments=call_info.parameters,
            ),
        )
    )
# 后续 finish_reason 处理逻辑保持不变:
# 有调用时置为 tool_calls,无调用时维持原类型。

评论区精华

CI 首跑失败与 stale count 引发的非法内存访问 测试

第一次 /rerun-test 中 1-gpu-h100 的 test_vision_openai_server_a.py(Kimi-VL-A3B 混合 image/text batch、decode CUDA graph、EP off)失败;重跑通过。作者在 PR body 中说明:早期修订版因图 ForwardBatch 携带 stale num_token_non_padded 计数,在 fused MoE kernel 中触发 illegal memory access。

结论:当前 head 通过且输出与 main 完全一致,混合批 client 重复回放 6 次;修复正是本 PR 的第 2/3 项。 · 已解决

num_token_non_padded 的 graph/eager 契约统一 正确性

decode graph runner 无条件分配槽位、capture 时写入、仅 EP 下 replay 刷新;而非 EP 的 eager batch 在 ForwardBatch.init_new 中只填充 None。静态批携带 capture 时陈旧计数成为两条路径唯一的不一致点。

结论:槽位仅在 enable_num_token_non_padded() 时分配,所有写入点判空;graph 与 eager 遵循同一规则:计数无效时即 None(PR body 原文 no count unless it is live)。 · 已解决

deferred finalize 权重 dtype 的版本适配 正确性

旧 FlashInfer 版本以 routing_logits dtype 分配缓冲但写入 bf16,因此旧代码做 fp32->bf16 位重解释;FlashInfer >= 0.6.18(flashinfer #3595)按内容定型缓冲,位重解释会损坏真实 fp32 权重。

结论:改为按内容校验 bf16/fp32 并直接透传,moe_finalize_fuse_shared 本就接受两种 dtype。 · 已解决

风险与影响

  1. 数据契约变更(主要风险):num_token_non_padded 变为 Optional 后,非 EP 路径上所有直接解引用该槽位的代码会得到 None。本 PR 覆盖了 decode runner、dummy run、MoE top-k 传递,但仓库中其他读取点(调度器、DP attention、PP 统计等)需审计;建议合并后跑 EP/非 EP 双档回归矩阵。
  2. 路由行为变更:EP 开 + 标准 dispatcher 时,CUDA graph 填充行由"路由到真实专家"变为"路由到 expert -1",expert 分布统计与负载均衡特征会变化。这是有意修复,但可能影响在线集群的 EPLB 表现,需观察。
  3. FlashInfer 版本依赖:若实际部署 FlashInfer < 0.6.18,仍可能返回"按 routing_logits dtype 分配、写入 bf16 的 fp32 缓冲",新逻辑不再做兼容位重解释而是直接放行 fp32。PR 说明 moe_finalize_fuse_shared 双 dtype 支持,但仍依赖该 kernel 对 fp32 的最终处理正确,建议验证目标版本组合。
  4. 额外嵌入计算:prefill 回放仅在 ie 缺失时调用 get_input_embeddings(),为纯文本 batch 新增一次嵌入计算;该调用发生在 eager 回放闭包内、不进入图捕获,GPU 开销可控但属于新路径。
  5. 工具调用行为变更:非流式多调用同一工具的 index 语义从"均为 0"变为"调用序数",依赖旧语义的客户端会受影响(这是与流式对齐的正确性修复)。

对用户:DeepSeek 系列(含 DSV4)在 EP + CUDA graph 下的推理稳定性与正确性提升,非流式工具调用序号与流式响应一致。对系统:消除一类静态批陈旧计数导致的 GPU 非法内存访问(属于稳定性修复);非 EP 路径少分配一个 int32 标量,内存影响可忽略。对团队:单文件小改动、独立 commit 便于回滚与逐项 review,但 num_token_non_padded 契约变化需要 EP/非 EP 双档回归确认无其他解引用点遗漏。

CUDA graph 静态批契约变更 EP/ 非 EP 行为差异需回归 依赖 FlashInfer 版本行为 填充行路由行为变更 非流式工具调用 index 行为变更

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论