# PR #48668 完整报告

- 仓库：`vllm-project/vllm`
- 标题：[V1][Metrics] Preserve prefix-cache stats on zero-output steps
- 合并时间：2026-08-09 09:58
- 原文链接：http://prhub.com.cn/vllm-project/vllm/pull/48668

---

# 执行摘要

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

# 功能与动机

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。

# 实现拆解

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`（模块 离线引擎；类别 source；类型 core-logic；符号 step）: 本 PR 唯一改动文件，核心修复点在 LLMEngine.step() 的 stats 记录守卫：移除 len(outputs.outputs) > 0 条件以保留零输出步的 scheduler_stats，并将 iteration_stats 构造与输出步绑定。

关键符号：LLMEngine.step

## 关键源码片段

### `vllm/v1/engine/llm_engine.py`

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

```python
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

```

# 评论区精华

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，无未解决疑虑。

- iteration_stats 的 None 判断应放在构造处而非 record 调用处 (design): 作者采纳建议，在第 2 步 process_outputs 处提前构造判断，代码更简洁且与 AsyncLLM 行为一致。
- 多行注释精简为单行 (style): 作者按建议精简注释，最终 head 版本为单行注释。
- len(outputs.outputs) > 0 改为 truthiness 判断 (style): 作者采纳，最终代码使用 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 等依赖前缀缓存的场景提供了可信的监控指标，使端到端缓存活性检查可观测。影响程度为低 - 中：代码面极小，但对依赖指标做决策的流程有实质修正作用。
- 风险标记：缺少测试覆盖 , 零输出步记录频率增加 , 指标读数语义变化

# 关联脉络

- PR #43559 hybrid-Mamba KV-cache 正确性修复（PR body 提及）: 本 PR 的指标修复正是为验证该 hybrid-Mamba 前缀缓存修复（liveness probe 命中数）而诊断出来的；离线 LLM 路径的 prefix-cache 计数此前被零输出步永久丢弃，两者配套使端到端缓存活性检查可观测。
- PR #48361 本 PR 的源 PR（PR body 说明按评审要求拆分）: 本 PR 按评审者要求从 #48361 中拆分出来，隔离 Metrics 修复与更大的功能改动，避免耦合。
- PR #46384 PR body 提及的已合并变更（与本文正交）: PR body 声明 #46384 / #47782 均不触及 llm_engine.py，与本修复正交，用于澄清非重复改动。