Prhub

#46701 [Core][V1] Support trace_decode_token_ids for deterministic decode replay

原始 PR 作者 zllion 合并时间 2026-08-20 15:12 文件变更 13 提交数 63 评论 63 代码增减 +931 / -9

执行摘要

新增 trace_decode_token_ids 实现确定性解码重放

PR body 明确其目标:让请求跟随固定 token trace 并返回模型对该 trace 的真实 logprobs,用于“Replaying the same trace under different configs (dtype, quantization, attention/MoE backend, TP size, etc.) ... apples-to-apples measure of numerical divergence”;以及 RL 训练中“recompute logprobs for a rollout's exact tokens under the training engine, so the rollout↔train logprob gap can be measured and minimized”。同时解释为何必须走 decode 路径而非 prefill 打分:许多模型 prefill 与 decode 使用不同的算子或内核,只有逐 token 走 decode 路径才能忠实复现解码分布。

值得精读。重点关注:采样后注入再算 logprob 的顺序设计、以 GPU 状态推导 step 避免 CPU 同步、UVA StagedWriteTensor 的批量写入模式、以及“配置开关 + 输入归一化 + 统一校验”三层防护。对想扩展 MRV2 sampler 或实现类似重放/调试功能的工程师是很好的参考。合并前建议在 GPU 上跑通 examples/generate/trace_replay_offline.py 端到端验证。

讨论亮点
  • njhill 在首轮 review 明确只能加到 MRV2,因为 MRV1 即将废弃;最终提交专门删除 MRV1 实现并拒绝 MRV1 请求。
  • depthfirst-app 报告 HIGH 级越界读:未校验 vocab_size 时,trace token 被用作 torch.gather 索引,可越界读 GPU 内存并回传给多租户调用者;随后在 _validate_trace_replay 增加 vocab 校验。
  • Codex 与 depthfirst-app 分别指出 stop 字符串未清理、trace 超出 max_model_len 会写穿 GPU 缓冲、condense() 未裁剪 trace 列表造成无界增长、prompt_logprobs 下 kernel 覆盖错误行,均在后续提交逐一修复。
  • njhill 认为为 niche 功能分配 max_num_reqs x max_model_len 大张量不合理,要求加配置开关;新增 --enable-trace-replay 并按需分配。
  • 关于不兼容组合的处理,最终由“fallback with warning”改为直接拒绝:njhill 表示“Requests should fail rather than silently falling back”。

实现拆解

  1. 采样参数与统一校验vllm/sampling_params.py 新增 trace_decode_token_ids: list[int] | None 字段,并在 from_optional/clone 中透传。_validate_trace_replayverify() 中统一完成非空、n==1、非负整数、vocab 范围及投机解码冲突等检查,避免注入越界 token。
  2. 配置开关与 V2 限制vllm/config/vllm.pyvllm/config/model.py 增加 enable_trace_replay(CLI --enable-trace-replay),_verify_trace_replay_config 要求 Model Runner V2;vllm/engine/arg_utils.py 接入参数,未开启时请求被拒绝。
  3. 输入归一化vllm/v1/engine/input_processor.py_normalize_trace_replay_paramsmax_tokens 覆盖为 trace 长度与剩余上下文的最小值、强制 ignore_eos、清空 eos_token_id/stop_token_ids/stop 字符串,并按剩余上下文截断 trace,防止写穿 max_model_len 宽的 GPU 缓冲。
  4. GPU 执行内核vllm/v1/worker/gpu/sample/trace_replay.py 新增 TraceReplayState(UVA 后备 StagedWriteTensor)与 Triton kernel _trace_replay_kernelsampler.py 在采样和 logprob 计算之间调用 apply_trace,以 total_len - prompt_len 推导 step,原地覆盖 sampled,logprobs 仍来自真实分布。apply_staged_writesany_trace 门控保证无 trace 请求零额外开销。
  5. 测试与示例:新增 3 个测试文件覆盖字段校验、输入归一化与内核行为;examples/generate/trace_replay_offline.py 演示先贪婪生成再逐 token 重放并断言一致。
文件 模块 状态 重要度
vllm/v1/worker/gpu/sample/trace_replay.py 采样器 added 8.81
vllm/sampling_params.py 采样参数 modified 7.29
vllm/v1/engine/input_processor.py 输入处理 modified 7.27
vllm/v1/worker/gpu/sample/sampler.py 采样器 modified 6.19
vllm/config/vllm.py 引擎配置 modified 5.83
examples/generate/trace_replay_offline.py 示例脚本 added 8.12
tests/v1/worker/test_gpu_trace_replay.py 内核测试 added 7.39
tests/v1/engine/test_input_processor_trace_replay.py 引擎测试 added 7.4
tests/v1/sample/test_trace_replay_params.py 参数测试 added 7.16

关键符号

_validate_trace_replay _normalize_trace_replay_params TraceReplayState.add_request TraceReplayState.apply_staged_writes TraceReplayState.apply_trace apply_trace_tokens _trace_replay_kernel _verify_trace_replay_config

关键源码片段

vllm/sampling_params.py core-logic

定义新字段与统一校验入口,拦截空列表、n>1、非法 / 越界 token 及投机解码等不兼容组合,是请求准入的第一道防线。

# vllm/sampling_params.py 中的校验逻辑片段
def _validate_trace_replay(
    self,
    model_config: ModelConfig,
    speculative_config: SpeculativeConfig | None,
) -> None:
    # 统一校验 trace replay 的请求级兼容性
    trace = self.trace_decode_token_ids
    if trace is None:
        return
​
    # 空 trace 没有意义
    if len(trace) == 0:
        raise ValueError('trace_decode_token_ids must be a non-empty list.')
​
    # trace 是逐请求单序列,n > 1 时 kernel 的 idx_mapping 长度对不上
    if self.n != 1:
        raise ValueError('trace_decode_token_ids requires n=1.')
​
    # 非法 token id(负数、非 int)会在注入后被当作 gather 索引
    if not all(isinstance(t, int) and t >= 0 for t in trace):
        raise ValueError(
            'trace_decode_token_ids must contain non-negative integers.'
        )
​
    # 超出 vocab 的 token 会越过 [batch, vocab_size] logprobs 的张量边界,
    # 造成 GPU 越界读并可能把内存内容作为 logprob 返回给用户
    vocab_size = model_config.get_vocab_size()
    out_of_vocab = [t for t in trace if t >= vocab_size]
    if out_of_vocab:
        raise VLLMValidationError(
            'trace_decode_token_ids contains out-of-vocab token ids: '
            + str(out_of_vocab)
        )
​
    # 投机解码的 draft 路径会改写 sampled,与强制注入冲突
    if speculative_config is not None:
        raise ValueError(
            'trace_decode_token_ids is not supported with speculative '
            'decoding.'
        )
vllm/v1/engine/input_processor.py core-logic

归一化 trace 语义:覆盖 max_tokens、强制 ignore_eos、清除 EOS/stop 条件并按剩余上下文截断 trace,防止提前终止与 GPU 写穿。

# vllm/v1/engine/input_processor.py 中的归一化逻辑片段
@staticmethod
def _normalize_trace_replay_params(
    processor: InputProcessor,
    sampling_params: SamplingParams,
    prompt_len: int,
) -> None:
    # 把 trace replay 的生成语义应用到请求级参数上,必须在
    # update_from_generation_config 之后调用,以便覆盖其缓存的 EOS 状态
    trace = sampling_params.trace_decode_token_ids
    if trace is None:
        return
​
    # trace 不能超出剩余上下文,否则会写穿 max_model_len 宽的 GPU 行
    max_tokens = min(
        len(trace),
        processor.model_config.max_model_len - prompt_len,
    )
    sampling_params.max_tokens = max_tokens
    sampling_params.min_tokens = 0
​
    # trace 内部的 EOS 或 stop 不代表生成提前结束,必须全部清除
    sampling_params.ignore_eos = True
    sampling_params.eos_token_id = None
    sampling_params.stop_token_ids = []
    sampling_params.stop = None
    sampling_params.all_stop_token_ids = set()
​
    # 截断后的 trace 才是实际要重放的序列,与 max_tokens 保持一致
    sampling_params.trace_decode_token_ids = trace[:max_tokens]

评论区精华

out-of-vocab token 导致 GPU 越界读 安全

depthfirst-app 报告 HIGH:未校验 vocab_size 时,trace token 作为 torch.gather 索引可越界读 GPU 内存并回传给多租户调用者。zllion 最初回复已在 sampling_params.py 检查,但后续仍补全了 vocab 校验。

结论:在 SamplingParams._validate_trace_replay 增加 vocab 范围校验,测试覆盖 out-of-vocab 拒绝。 · 已解决

仅支持 Model Runner V2,移除 MRV1 实现 设计

njhill 明确指出 we will be deprecating MRV1 very soon,希望只加到 MRV2;aoshen02 也在 issue 评论中询问 model runner v2 实现。

结论:最终提交删除 MRV1 实现,MRV1 下请求被拒绝并提示需要 V2。 · 已解决

trace 与 stop 条件冲突导致提前终止 正确性

Codex P1 指出 stop 字符串未被清理,detokenizer 会截断 trace;aoshen02 也发现 ignore_eos 无法阻止 check_stop 使用缓存的 eos_token_id。

结论:归一化时清空 stop 字符串、EOS 与 stop token,并强制 ignore_eos。 · 已解决

condense() 未裁剪 trace 列表造成无界增长 正确性

Codex P2 指出重复 shrink/grow 循环中 trace_decode_token_ids 与其余字段不同步裁剪,长服务中列表无界增长。

结论:在 InputBatch.condense() 中补充 trace_decode_token_ids 的裁剪。 · 已解决

大显存缓冲需要配置门控 性能

njhill 认为为 niche 功能分配 max_num_reqs x max_model_len 大张量不合理,应通过配置参数控制是否创建。

结论:新增 --enable-trace-replay 开关,未开启时不分配缓冲且拒绝请求。 · 已解决

prompt_logprobs 下 kernel 覆盖错误行 正确性

Codex P1:启用 prompt_logprobs 时 sampled 扩展为每 logit 位置一行,idx_mapping 只有每请求一项,kernel 覆盖首行而非最终生成行,导致 replay 不生效且 logprob 错位。

结论:拒绝 trace 与 prompt_logprobs 组合使用。 · 已解决

不兼容组合 fallback 还是拒绝 设计

初始文档声称投机解码、n>1 时 fallback 到普通采样并告警;Codex P2 与 njhill 均认为静默 fallback 语义不对,应直接失败。

结论:改为直接拒绝,并更新示例文档说明所有被拒组合。 · 已解决

风险与影响

  • 核心采样路径每步引入一次 Triton kernel 启动;若 any_trace 门控或 sticky 状态在请求槽位复用或回收时未正确重置,可能误注入历史 trace。
  • _normalize_trace_replay_paramsTraceReplayState.add_request 都对 trace 长度有隐含假设(不超过 max_model_len),两处逻辑必须保持同步,否则会写穿 GPU 缓冲。
  • InputBatch.condense() 已补裁剪 trace 列表,但这类平行维护的字段(类似 spec_token_ids)在后续新增字段时容易遗漏。
  • 越界读风险虽已通过 vocab 校验缓解,但校验集中在 SamplingParams.verify(),若存在绕过 verify 的内部调用路径,仍有隐患。
  • MRV2-only 策略意味着 MRV1 用户无法使用;TraceReplayState 依赖 RequestStatetotal_len/prompt_len 内部布局,随 MRV2 重构可能漂移。
  • 用户侧:为调试、RL 与数值对比提供官方、确定性的 logprob 重算能力;默认关闭,普通推理无感知。
  • 系统侧:开启开关后每请求预留 max_num_reqs x max_model_len x 4B 显存(UVA 缓冲);关闭时零额外显存。
  • 团队侧:统一了“指定 token 序列取 logprob”的入口,替代各团队自建补丁;SamplingParams 中调试/RL 参数分组也为后续参数提供了范式。
核心采样路径每步注入 GPU 越界读 / 写风险(已缓解) 大显存缓冲依赖配置开关 MRV2-only 限制 与多特性组合需显式拒绝

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论