Prhub

#54646 [Core][MRV2] Freeze gc during V2 CG capture; skip per-descriptor cleanup

原始 PR 作者 njhill 合并时间 2026-09-01 10:37 文件变更 4 提交数 1 评论 5 代码增减 +78 / -69

执行摘要

冻结捕获期 GC,V2 捕获提速约 6 倍

PR body 指出 V2 runner 的 capture_model 没有像 V1 runner 的 _freeze_gc 那样在 CUDA graph 捕获期间冻结/禁用 GC,导致捕获暴露在 gc 周期风险下(例如 Triton kernel 在捕获中途被 finalize 并卸载 module,使捕获中的图失效);同时 breakable wrapper 的 per-descriptor gc.collect() + empty_cache() 既必要又昂贵:每个 descriptor 约 0.3s,整体捕获时间被放大约 10 倍(PR #54127 的 CI 上捕获从 8s 膨胀到 70s,H200 MIG,且 memory profiling 阶段每个 engine 支付两次)。

值得精读。核心设计决策有三点:一是把 V1 的 _freeze_gc 抽取为公共 context manager 而非复制,避免两套 runner 行为漂移;二是以 gc.isenabled() 作为跳过清理的条件,巧妙复用 freeze 的副作用传递状态,但这也构成隐式契约,阅读时需注意;三是保留 VLLM_ENABLE_CUDAGRAPH_GC opt-out,兼顾依赖 GC 行为的特殊场景。建议关注后续是否补充 freeze 恢复语义的测试。

讨论亮点

benchislett 在 vllm/compilation/breakable_cudagraph.py:374 提出疑问:GC 禁用时 empty_cache 是否也应省略?为什么?njhill 确认:是的,应同时省略,以避免承受不必要的 per-descriptor 开销。该讨论澄清了冻结清理与逐 descriptor 清理的职责边界——bulk capture 下前者已统一完成,后者只服务于 freeze 之外的懒捕获路径。两位 reviewer(khluu、benchislett)均批准,无未解决疑虑。

实现拆解

  1. 抽取公共 GC 冻结工具(vllm/utils/gc_utils.py):新增 freeze_gc_for_cudagraph_capture() context manager,逻辑与 V1 原 _freeze_gc 完全一致——进入时先执行一次 gc.collect(),然后 gc.freeze() + gc.disable() 冻结堆并禁用自动 GC;退出时 gc.unfreeze() 并统一 gc.collect(),最后按进入前的 gc.isenabled() 状态恢复。VLLM_ENABLE_CUDAGRAPH_GC 环境变量继续作为 opt-out,未新增配置项。
  2. V2 runner 接入(vllm/v1/worker/gpu/model_runner.py):capture_model() 用 with freeze_gc_for_cudagraph_capture(): 包裹从 empty_cache()、encoder/decoder 捕获到读取 end_free_gpu_memory 的完整流程,原本的显式 gc.collect() 被移除(freeze 内部已覆盖),内存统计与计时逻辑保持不变。
  3. V1 runner 去重(vllm/v1/worker/gpu_model_runner.py):删除 23 行内联 _freeze_gc 实现,替换为 _freeze_gc = staticmethod(freeze_gc_for_cudagraph_capture),既有调用点零改动,行为等价。
  4. breakable wrapper 条件化清理(vllm/compilation/breakable_cudagraph.py):_capture() 中的 gc.collect() + torch.accelerator.empty_cache() 改为仅在 gc.isenabled() 时执行——bulk capture 期间 GC 已被禁用,gc.isenabled() 返回 False 从而跳过每个 descriptor 约 0.3s 的清理;freeze 之外的懒捕获(lazy capture)仍保留完整清理,内存回收语义不变。
  5. 测试与配置配套:无测试文件变更;未新增配置或 schema 改动。现有 Buildkite CI(#86452)覆盖回归验证。
文件 模块 状态 重要度
vllm/utils/gc_utils.py 工具层 modified 6.95
vllm/v1/worker/gpu/model_runner.py 模型运行 modified 7.25
vllm/v1/worker/gpu_model_runner.py 模型运行 modified 6.42
vllm/compilation/breakable_cudagraph.py 图编译 modified 5.89

关键符号

freeze_gc_for_cudagraph_capture capture_model _capture _freeze_gc

关键源码片段

vllm/utils/gc_utils.py core-logic

新增 freeze_gc_for_cudagraph_capture context manager,是本次变更的核心逻辑载体,从 V1 runner 抽取并供 V2 runner 与 breakable wrapper 共用,决定捕获期间 GC 冻结与恢复的语义。

freeze_gc_for_cudagraph_capture(vllm/utils/gc_utils.py)

import gc
from contextlib import contextmanager
​
import vllm.envs as envs
​
​
@contextmanager
def freeze_gc_for_cudagraph_capture():
    """Freeze and disable gc for the duration of bulk CUDA graph capture.
​
    A gc cycle during stream capture can invalidate the captured graph,
    e.g. a finalized Triton kernel unloads its module. Opt out with
    VLLM_ENABLE_CUDAGRAPH_GC=1.
    """
    # 记住进入前的开关状态,退出时按原状恢复,而不是无条件 enable
    gc_was_enabled = gc.isenabled()
    # 进入捕获前先做一次完整回收,保证后续冻结时堆是干净的
    gc.collect()
    # 环境变量提供 opt-out,某些场景可能依赖捕获期间维持 GC 行为
    should_freeze = not envs.VLLM_ENABLE_CUDAGRAPH_GC
    if should_freeze:
        gc.freeze()
        # gc.disable 能防止捕获中途触发 gc 周期,避免 Triton kernel
        # finalize 时卸载 module 导致已捕获的图失效
        gc.disable()
    try:
        yield
    finally:
        if should_freeze:
            try:
                gc.unfreeze()
                # 退出后统一回收一次,替代 bulk capture 期间逐 descriptor 的清理
                gc.collect()
            finally:
                if gc_was_enabled:
                    gc.enable()
                else:
                    gc.disable()
vllm/v1/worker/gpu/model_runner.py core-logic

V2 runner 的 capture_model 是整个捕获流程的入口,本 PR 用 freeze 上下文包裹它,消除捕获中 GC 周期导致图失效的风险,并移除显式 gc.collect()。

capture_model 捕获入口(vllm/v1/worker/gpu/model_runner.py)

@torch.inference_mode()
def capture_model(self) -> int:
    assert self.cudagraph_manager is not None
    capture_encoder = (
        self.model_state.supports_mm_inputs
        and self.model_state.encoder_runner.has_cudagraph()
    )
    capture_decoder = self.cudagraph_manager.needs_capture()
    if not capture_encoder and not capture_decoder:
        return 0
​
    start_time = time.perf_counter()
    # 用公共 freeze 上下文包住整个 bulk capture:期间的 gc 周期
    # 可能 finalize Triton kernel 并卸载其 module,使捕获中的图失效
    with freeze_gc_for_cudagraph_capture():
        torch.accelerator.empty_cache()
        start_free_gpu_memory = torch.accelerator.get_memory_info()[0]
​
        with self.maybe_setup_dummy_loras(self.lora_config):
            if capture_encoder:
                self.model_state.encoder_runner.capture()
            if capture_decoder:
                input_buffers = self.input_buffers
                if self.pcp_manager is not None:
                    input_buffers = self.pcp_manager.input_buffers
                self.cudagraph_manager.capture(
                    self.model,
                    self.model_state,
                    input_buffers,
                    self.intermediate_tensors,
                    self.block_tables,
                    self.attn_groups,
                    self.kv_cache_config,
                    pcp_manager=self.pcp_manager,
                    has_lora=self.lora_config is not None,
                    use_aux_hidden_state_outputs=self.use_aux_hidden_state_outputs,
                    lora_capture_hook=create_lora_capture_hook(
                        self.lora_config, self
                    ),
                )
                if self.speculator is not None:
                    with use_workspace_lane(self._draft_workspace_lane):
                        self.speculator.capture()
                if self.adaptive_verification is not None:
                    with self.step_timing.collect() as timings:
                        for batch in self.adaptive_verification.batches_to_profile(
                            self.cudagraph_manager.captured_token_counts()
                        ):
                            self._dummy_run(**batch)
                    self.adaptive_verification.set_initial_cost_curves(timings)
​
        end_free_gpu_memory = torch.accelerator.get_memory_info()[0]
​
    elapsed_time = time.perf_counter() - start_time
    cuda_graph_size = start_free_gpu_memory - end_free_gpu_memory
    logger.info(
        "Graph capturing finished in %.0f secs, took %.2f GiB",
        elapsed_time,
        cuda_graph_size / (1 << 30),
    )
    return cuda_graph_size
vllm/compilation/breakable_cudagraph.py core-logic

per-descriptor 清理是捕获时间被放大 10 倍的主因,条件化跳过是该性能回归的直接修复点,且需与 freeze 上下文配合保持语义。

_capture 条件化清理(vllm/compilation/breakable_cudagraph.py)

def _capture(
    self,
    entry: _BreakableEntry,
    args: tuple[Any, ...],
    kwargs: dict[str, Any],
) -> Any:
    validate_cudagraph_capturing_enabled()
    entry.input_addresses = self._collect_tensor_addresses(args, kwargs)
​
    if self.graph_pool is not None:
        set_graph_pool_id(self.graph_pool)
    else:
        set_graph_pool_id(current_platform.graph_pool_handle())
​
    # 手动驱动 capture_begin/end 绕过了 torch.cuda.graph() 自带的
    # gc + empty_cache,因此这里原本要模拟它的 pre-capture 清理。
    # 但 bulk capture 已由 freeze_gc_for_cudagraph_capture 统一清理过,
    # 每个 descriptor 重复执行约耗时 0.3s,成为捕获时间被放大 10 倍的主因,
    # 所以在 gc 被禁用(冻结)时跳过;lazy capture 仍保留完整清理。
    if gc.isenabled():
        gc.collect()
        torch.accelerator.empty_cache()
    # 同步 offloader 的 copy stream,避免 pre-capture 的预取泄漏进图
    get_offloader().sync_prev_onload()
​
    capture = BreakableCUDAGraphCapture(pool=self.graph_pool)
    with capture:
        output = self.runnable(*args, **kwargs)
        # 在最后一个 segment 关闭前 join offloader 的 copy stream,
        # 否则后续 forward 会报 "unjoined stream" 错误
        get_offloader().join_after_forward()
        # 在 capture 上下文内转弱引用,让 cudagraph pool 立即复用内存
        output = weak_ref_tensors(output)
​
    entry.capture = capture
    entry.output = weak_ref_tensors(output)
    return output

评论区精华

GC 禁用时是否应省略 empty_cache 性能

benchislett 在 vllm/compilation/breakable_cudagraph.py:374 提问:GC 禁用时 empty_cache 是否也应省略?为什么?

结论:njhill 确认应同时省略,目的是避免不必要的 per-descriptor 开销;empty_cache 与 gc.collect 一起由 freeze 上下文的统一清理覆盖。 · 已解决

风险与影响

正确性风险低:冻结逻辑与 V1 原实现逐行等价,V1 路径行为不变,且 VLLM_ENABLE_CUDAGRAPH_GC=1 可完全退出冻结行为。主要风险是 gc.isenabled() 作为跳过 per-descriptor 清理的条件,隐含“bulk capture 一定运行在 freeze 下”的契约;当前由 capture_model 唯一入口保证,但若未来新增批量捕获调用路径且未处于 freeze 上下文,可能跳过必要的内存回收。此外 gc.freeze()/disable() 是全局状态修改,若捕获中途异常,context manager 的 finally 仍会恢复状态,但该路径没有专门测试覆盖。本 PR 未附测试文件,VLLM_ENABLE_CUDAGRAPH_GC 开关与冻结恢复的状态组合建议补一条回归测试。

影响所有启用 CUDA graph(含 MRV2/breakable 路径)的用户:引擎启动与 memory profiling 阶段显著提速,H200 上初始化 28.9s -> 8.2s,且消除了捕获中途被 GC 周期失效的潜在不稳定。对 V1 runner 无行为变化(逻辑等价);breakable 懒捕获路径保持原有清理语义,不影响在线捕获正确性。团队层面,公共工具落入 vllm.utils.gc_utils,后续断点捕获、profiling 等场景可复用。影响程度中等偏上,主要在启动路径而非在线推理路径。

核心路径变更 缺少测试覆盖 全局 GC 状态修改 环境变量可退出

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论