Prhub

#48668 [V1][Metrics] Preserve prefix-cache stats on zero-output steps

原始 PR 作者 puririshi98 合并时间 2026-08-09 09:58 文件变更 1 提交数 14 评论 12 代码增减 +7 / -7

执行摘要

修复离线 LLMEngine 零输出步丢弃 prefix-cache 计数

PR body 明确指出:Scheduler.make_stats() 每步都会 drain prefix-cache 命中/查询计数器并挂到(可能为空的)EngineCoreOutputs 上,而离线 LLMEngine.step() 只在 len(outputs.outputs) > 0 时记录指标,因此「prefix-cache counters consumed on a step that emits no request output ... are drained from the scheduler but never recorded, and are lost permanently」,同步 LLM 路径的 prefix-cache 命中率被静默低估。该问题在验证 hybrid-Mamba 修复(#43559)时被实测暴露:warm 命中 num_computed=4224,而 vllm:prefix_cache_hits 指标读回为 0。

改动很小,值得花几分钟通读。重点学习点是「计数器由下游每步 drain,但记录有输出门控」这类统计丢失的根因模式,以及评审中对 iteration_stats 构造时机的简化(把 None 判断放到源头而不是调用点)。若团队依赖 prefix-cache 指标,建议后续补一个 zero-output step 的回归测试,防止此类丢失再次引入。

讨论亮点

njhill 在 review 中主导了两次简化,作者全部采纳:

  • iteration_stats 判断前移:不要把 iteration_stats if outputs.outputs else None 写在第 4 步 record 调用处,而是直接在第 2 步构造处写成 IterationStats() if self.log_stats and outputs.outputs else None(原话:"Undo this line and make the change suggested above instead"),并称这样"makes it more consistent with the async side"。
  • 注释与写法精简:把大段多行注释压缩为单行 # Record even when this step produced no request outputs.,并使用 truthiness 判断 if outputs.outputs: 替代 len(outputs.outputs) > 0
    最终 njhill 以 "Thanks @puririshi98" APPROVED,无未解决疑虑。

实现拆解

  1. 定位根因:LLMEngine.step() 第 4 步记录 stats 的守卫条件是 self.logger_manager is not None and outputs.scheduler_stats is not None and len(outputs.outputs) > 0。由于 EngineCore 每步(无论有无输出)都返回 scheduler_stats,零输出步(如非最终 prefill chunk 命中缓存)会让计数器被 drain 后无记录地丢弃。
  2. 放宽记录守卫:在 vllm/v1/engine/llm_engine.py 中删除 len(outputs.outputs) > 0 条件,只要 scheduler_stats 存在就调用 logger_manager.record(),并保留一行注释说明零输出步也需记录。
  3. 限制 iteration_stats 构造:将 iteration_stats = IterationStats() if self.log_stats else None 改为 IterationStats() if self.log_stats and outputs.outputs else None。原因是 IterationStats 只由请求输出填充,零输出步传入空对象会给迭代 token 直方图添加 0 样本行的噪音;此调整也采纳了 njhill「与 async 侧更一致」的建议。
  4. 保持间隔日志门控do_log_stats_with_interval() 仍只在 outputs.outputs 非空时调用,避免空转步反复触发间隔日志。
  5. 验证方式(无自动化测试):PR 未新增测试文件,验证依赖 GB200 实测(Nemotron-Super-120B-A12B-BF16, TP4, in-process LLM),修复方向通过指标读数 0 → 4224 得到确认。建议后续补充「零输出步仍记录 scheduler_stats、不记录 iteration_stats」的回归测试。
文件 模块 状态 重要度
vllm/v1/engine/llm_engine.py 离线引擎 modified 6.23

关键符号

LLMEngine.step

关键源码片段

vllm/v1/engine/llm_engine.py core-logic

本 PR 唯一改动文件,核心修复点在 LLMEngine.step() 的 stats 记录守卫:移除 len(outputs.outputs) > 0 条件以保留零输出步的 scheduler_stats,并将 iteration_stats 构造与输出步绑定。

def step(self) -> list[RequestOutput | PoolingRequestOutput]:
    if self.should_execute_dummy_batch:
        self.should_execute_dummy_batch = False
        self.engine_core.execute_dummy_batch()
        return []
​
    # 1) 从 EngineCore 取回本步输出(可能为空,例如纯调度 / 预取步)。
    with record_function_or_nullcontext("llm_engine step: get_output"):
        outputs = self.engine_core.get_output()
​
    # 2) 处理 EngineCoreOutputs。
    # 仅在“开启日志统计且本步确有请求输出”时才构造 IterationStats:
    # 它只由请求输出填充,零输出步传 None,避免迭代 token 直方图
    # 出现 0 样本(与 AsyncLLM 侧行为保持一致)。
    with record_function_or_nullcontext("llm_engine step: process_outputs"):
        iteration_stats = (
            IterationStats() if self.log_stats and outputs.outputs else None
        )
        processed_outputs = self.output_processor.process_outputs(
            outputs.outputs,
            engine_core_timestamp=outputs.timestamp,
            iteration_stats=iteration_stats,
        )
        self.output_processor.update_scheduler_stats(outputs.scheduler_stats)
​
    # 3) 中止因 stop string 完成的请求。
    with record_function_or_nullcontext("llm_engine step: abort_requests"):
        self.engine_core.abort_requests(processed_outputs.reqs_to_abort)
​
    # 4) 记录指标。
    # Scheduler.make_stats() 每步都会 drain prefix-cache 命中 / 查询计数器,
    # 并挂到(可能为空的) EngineCoreOutputs 上;如果本步无输出就不记录,
    # 这些计数器会永久丢失(例如非最终 prefill chunk 命中缓存时)。
    with record_function_or_nullcontext("llm_engine step: record_stats"):
        if self.logger_manager is not None and outputs.scheduler_stats is not None:
            self.logger_manager.record(
                scheduler_stats=outputs.scheduler_stats,
                iteration_stats=iteration_stats,
                mm_cache_stats=self.renderer.stat_mm_cache(),
            )
            # 间隔日志仍只在有输出的步触发,避免空转步频繁刷日志。
            if outputs.outputs:
                self.do_log_stats_with_interval()
​
    return processed_outputs.request_outputs

评论区精华

iteration_stats 的 None 判断应放在构造处而非 record 调用处 设计

njhill 建议不要在记录处写 `iteration_stats if outputs.outputs else None`,应直接改为 `IterationStats() if self.log_stats and outputs.outputs else None`,称此举 "makes it more consistent with the async side"。

结论:作者采纳建议,在第 2 步 process_outputs 处提前构造判断,代码更简洁且与 AsyncLLM 行为一致。 · 已解决

多行注释精简为单行 style

njhill 要求删除大段解释性多行注释,只保留 `# Record even when this step produced no request outputs.` 单行注释。

结论:作者按建议精简注释,最终 head 版本为单行注释。 · 已解决

len(outputs.outputs) > 0 改为 truthiness 判断 style

njhill 提出 nit 建议,用 `if outputs.outputs:` 替代 `len(outputs.outputs) > 0`,保持风格统一。

结论:作者采纳,最终代码使用 truthiness 判断。 · 已解决

风险与影响

改动集中在 vllm/v1/engine/llm_engine.py 的 LLMEngine.step(),仅影响离线/同步引擎路径,在线 AsyncLLM 路径不受影响。潜在风险点:一是每个零输出步都会调用 logger_manager.record(),记录频率增加,需确认 record 对空 iteration_stats 无副作用(PR body 声明既有路径已容忍 None),理论上 CPU/存储开销略有上升;二是该改动没有配套测试,缺少对「零输出步仍记录 scheduler_stats、不记录 iteration_stats」的自动化回归保护;三是指标读数语义变化,依赖旧(低估)读数的监控或基准在升级后会出现跳变,属预期行为变化但需周知。

用户侧:使用 LLM.generate()/LLMEngine 做离线评估、benchmark 或 CI 指标采集的用户将看到真实的 prefix-cache 命中率,例如 #43559 的 liveness probe(liveness_probe_hits 0 → 4224);在线 vllm serve 用户无感知。系统侧:每个 step 多一次 record 调用,开销轻微,不影响输出内容与精度。团队侧:为 hybrid-Mamba、MTP/EAGLE 等依赖前缀缓存的场景提供了可信的监控指标,使端到端缓存活性检查可观测。影响程度为低-中:代码面极小,但对依赖指标做决策的流程有实质修正作用。

缺少测试覆盖 零输出步记录频率增加 指标读数语义变化

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论