执行摘要
- 一句话:修复工具调用序号、图填充计数契约、prefill 嵌入刷新等五项缺陷
- 推荐动作:值得精读。两个设计决策有学习价值:① 用"槽位存在性"表达数据契约——槽位不存在即契约无效,让 CUDA graph 静态批与 eager 批在两条路径上共享同一不变量,避免再次出现"graph 携带 stale 状态、eager 携带 None"的分叉;② replay 闭包处理输入槽位的模式——能从 kwargs、位置参数逐步回退到现场计算(get_input_embeddings),可复用于其他 graph runner 的输入刷新。建议合并前向作者确认非 EP 下其他 num_token_non_padded 读取点的审计情况。
功能与动机
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 权重。
实现拆解
按 5 个独立修复拆分实现:
-
工具调用序号修复(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 顺序保留。
-
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() 判断(逻辑等价)。
-
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 路径的既有守卫保持一致。
-
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() 现场计算嵌入并拷入静态槽位。
-
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(模块 图执行器;类别 source;类型 data-contract;符号 DecodeCudaGraphRunner): decode CUDA graph runner 是 num_token_non_padded 契约修复的核心:刷新逻辑改为槽位判空,非 EP 下不再写入 stale 计数,直接消除 Kimi-VL-A3B 场景的 fused MoE kernel 非法内存访问;同时清理死代码 ngram_embedding_n/k。
python/sglang/srt/model_executor/runner_utils/buffers.py(模块 静态缓冲;类别 source;类型 data-contract;符号 DecodeInputBuffers.create): DecodeInputBuffers 的数据契约变更入口:字段类型改为 Optional[torch.Tensor],create 仅在 EP 下分配槽位,从源头保证 graph 批与 eager 批遵循同一规则。
python/sglang/srt/model_executor/runner/base_runner.py(模块 执行器基类;类别 source;类型 data-contract;符号 _allocate_decode_buffers, _dummy_run): eager/dummy 侧的配套契约修复:_allocate_decode_buffers 条件化分配、_dummy_run 写入判空,保证 dummy run 与真实回放路径一致。
python/sglang/srt/model_executor/runner/prefill_cuda_graph_runner.py(模块 图执行器;类别 source;类型 data-contract;符号 _execute_body_capture, replay_layer_forward): prefill 图回放闭包在 ie 缺失时由 input_ids 现场计算嵌入,修复图读取上一批次嵌入的陈旧数据问题。
python/sglang/srt/models/deepseek_v2.py(模块 MoE 路由;类别 source;类型 data-contract;符号 DeepseekV2MoE.forward, forward_normal, forward_normal_dual_stream): DeepSeekMoE 标准路径(forward_normal / forward_normal_dual_stream)透传 num_token_non_padded 到 top-k,使填充行在 EP + 标准 dispatcher 下路由到 expert -1,与 a2a 路径一致。
python/sglang/srt/layers/moe/moe_runner/flashinfer_trtllm.py(模块 MoE 内核;类别 source;类型 core-logic;符号 _make_deferred_finalize_output): deferred finalize 的 ABI 适配:删除会损坏真实 fp32 权重的旧位重解释,改为按内容校验 bf16/fp32,适配 FlashInfer >= 0.6.18。
python/sglang/srt/entrypoints/openai/serving_chat.py(模块 聊天服务;类别 source;类型 core-logic;符号 _process_tool_calls): 非流式工具调用序号修复的源码入口:ToolCall.index 从 detector 的 tool_index(工具在请求中的位置)改为调用序数,与流式 delta 对齐。
test/registered/unit/entrypoints/openai/test_serving_chat.py(模块 服务测试;类别 test;类型 test-coverage;符号 test_non_streaming_tool_call_index_is_the_call_ordinal): 新增回归测试 test_non_streaming_tool_call_index_is_the_call_ordinal,用 mock parser 验证同一工具两次调用的 index 为 [0, 1],防止序号退化。
关键符号:_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
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
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
非流式工具调用序号修复的源码入口: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,无调用时维持原类型。
评论区精华
本 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。
-
CI 首跑失败与 stale count 引发的非法内存访问 (testing): 当前 head 通过且输出与 main 完全一致,混合批 client 重复回放 6 次;修复正是本 PR 的第 2/3 项。
- num_token_non_padded 的 graph/eager 契约统一 (correctness): 槽位仅在 enable_num_token_non_padded() 时分配,所有写入点判空;graph 与 eager 遵循同一规则:计数无效时即 None(PR body 原文 no count unless it is live)。
- deferred finalize 权重 dtype 的版本适配 (correctness): 改为按内容校验 bf16/fp32 并直接透传,moe_finalize_fuse_shared 本就接受两种 dtype。
风险与影响
- 风险:
- 数据契约变更(主要风险):
num_token_non_padded 变为 Optional 后,非 EP 路径上所有直接解引用该槽位的代码会得到 None。本 PR 覆盖了 decode runner、dummy run、MoE top-k 传递,但仓库中其他读取点(调度器、DP attention、PP 统计等)需审计;建议合并后跑 EP/非 EP 双档回归矩阵。
- 路由行为变更:EP 开 + 标准 dispatcher 时,CUDA graph 填充行由"路由到真实专家"变为"路由到 expert -1",expert 分布统计与负载均衡特征会变化。这是有意修复,但可能影响在线集群的 EPLB 表现,需观察。
- FlashInfer 版本依赖:若实际部署 FlashInfer < 0.6.18,仍可能返回"按 routing_logits dtype 分配、写入 bf16 的 fp32 缓冲",新逻辑不再做兼容位重解释而是直接放行 fp32。PR 说明
moe_finalize_fuse_shared 双 dtype 支持,但仍依赖该 kernel 对 fp32 的最终处理正确,建议验证目标版本组合。
- 额外嵌入计算:prefill 回放仅在 ie 缺失时调用
get_input_embeddings(),为纯文本 batch 新增一次嵌入计算;该调用发生在 eager 回放闭包内、不进入图捕获,GPU 开销可控但属于新路径。
- 工具调用行为变更:非流式多调用同一工具的
index 语义从"均为 0"变为"调用序数",依赖旧语义的客户端会受影响(这是与流式对齐的正确性修复)。
- 影响:对用户:DeepSeek 系列(含 DSV4)在 EP + CUDA graph 下的推理稳定性与正确性提升,非流式工具调用序号与流式响应一致。对系统:消除一类静态批陈旧计数导致的 GPU 非法内存访问(属于稳定性修复);非 EP 路径少分配一个 int32 标量,内存影响可忽略。对团队:单文件小改动、独立 commit 便于回滚与逐项 review,但 num_token_non_padded 契约变化需要 EP/非 EP 双档回归确认无其他解引用点遗漏。
- 风险标记:CUDA graph 静态批契约变更, EP/非 EP 行为差异需回归, 依赖 FlashInfer 版本行为, 填充行路由行为变更, 非流式工具调用 index 行为变更
关联脉络
- PR #38176 keeping router GEMM in fp32 for deterministic inference (DeepSeek V3/V4): 同改 python/sglang/srt/models/deepseek_v2.py,同属 DeepSeek V3/V4 推理正确性修复线:一个管 router GEMM 精度、一个管填充行路由,两者共同保障 EP + CUDA graph 下 MoE 确定性。
- PR #39328 Fix first-token metadata and reused attention-layer indexing: 同为 runner 组件正确性修复(cuda_graph_setup.py),处理"静态图状态复用前一次即时数据"这一共同模式,与本 PR 的 input_embeds 槽位陈旧问题同源。
- PR #39474 [qwen 3.8 next] reuse old cuda stream instead of endlessly creating streams: 同为 CUDA graph 捕获语义修复(流复用),与本 PR 的 capture/replay 正确性主题呼应,均属于 runner 层图执行稳定性建设。
- PR #38984 [Intra-node PD][DSV4] Pack all layers into one batch for INTRA_NODE_NVLINK path: DSV4 家族性能/正确性演进之一,与本 PR 同属 DeepSeek V4 在 PD/EP 场景下的稳定性建设,可对照阅读 MoE 路径的演进脉络。
参与讨论