Prhub

#54418 [Bugfix][Spec Decode] Keep default CUDA graph sizes memory-safe

原始 PR 作者 khluu 合并时间 2026-08-31 02:02 文件变更 3 提交数 1 评论 3 代码增减 +73 / -67

执行摘要

投机解码 CUDA graph 默认尺寸回归修复,防止引擎启动 OOM

PR #50488 为了覆盖最宽 uniform decode 批次,在默认配置中把 CUDA graph capture 尺寸扩展到 max_num_seqs * (1 + num_speculative_tokens),导致 H200 nightly CI 出现两类失败:suffix 配置 FULL graph capture OOM,DFlash 配置 graph memory 把可用 KV cache 压到 0.94 GiB(实际需要 5.12 GiB)。PR body 明确指出目标:"Fix the default CUDA-graph memory regression introduced by #50488 without reverting its full speculative-decode sizing work.",即在不放弃动态 tier 推导与 off-stride 覆盖的前提下,把默认行为收回到平台内存安全护栏内。

值得精读。该 PR 是"窄修复 vs 全量 revert"决策的典型样本:展示了如何用平台默认 ceiling 作为内存安全护栏、如何把 CI 回归的精确失败宽度直接 pin 进单元测试参数,以及如何在配置层为性能与安全性做取舍。对维护投机解码、torch.compile 与配置层默认值的工程师有直接参考价值。

讨论亮点

维护者 njhill 直接批准(APPROVED):"Thanks @khluu, LGTM!",无追加技术意见。

更值得关注的是 PR body 中关于方案取舍的说明:Draft #54352 是全量 revert #50488(移除全部 621 行,包括动态 tier 推导与安全 off-stride 覆盖),而本 PR 是更窄的修复——保留 #50488 的 sizing 逻辑,只禁止不安全的默认扩张;若本 PR 通过 H200 两个门禁,#54352 可关闭。此外 PR 声明使用 AI 辅助(Sherlock/OpenAI Codex),并明确要求作者 Kevin 逐行审查并最终负责。

实现拆解

  1. 核心过滤(vllm/config/vllm.py):在 VllmConfig._set_cudagraph_sizes 中,uniform_decode_sizes 的推导式加入 if n * query_len <= max_cudagraph_capture_size 过滤。该 ceiling 在 max_num_tokens 折叠之前应用,因此默认生成的任何 capture 尺寸都不超过平台默认护栏;注释同步从“这些尺寸会抬高最终 max”改为“保留平台默认 ceiling,更大批次回退 eager”。
  2. 配置语义更新(vllm/config/compilation.py):CompilationConfig.max_cudagraph_capture_size 的 docstring 更新,明确“Uniform decode sizes are appended only within this default ceiling”,消除与实现不一致的文档描述。
  3. 测试重构(tests/compile/test_config.py):原 test_default_cudagraph_capture_size_covers_widest_uniform_decode 更名为 test_default_cudagraph_capture_size_respects_platform_ceiling,参数化增加 widest_is_captured 列,并加入 H200 精确回归宽度(128,16 -> 2176;256,24 -> 6400)。断言翻转:max_cudagraph_capture_size 现在等于 token_grid_max,所有默认尺寸均小于等于平台 ceiling;test_real_vllm_config_caps_widest_ngram_decode_batch 和序列并行测试同步从“544 in”翻转为“544 not in”且 max == 512;新增 test_single_speculative_token_does_not_raise_default_capture_size,验证 MTP depth 1 场景(300 请求 -> 600 token)也不突破 ceiling。
  4. 验证配套:本地 tests/compile/test_config.py 中相关用例 26 passed / 1 skipped,pre-commit 全通过;模型级评估未在本地运行,依赖 H200 Buildkite 的 spec-decode-ngram-suffix 与 spec-decode-dflash-nightly 两个门禁作业。
文件 模块 状态 重要度
vllm/config/vllm.py 配置层 modified 6.16
tests/compile/test_config.py 配置测试 modified 6.98
vllm/config/compilation.py 配置层 modified 4.8

关键符号

VllmConfig._set_cudagraph_sizes CompilationConfig.max_cudagraph_capture_size test_default_cudagraph_capture_size_respects_platform_ceiling test_default_cudagraph_capture_sizes_keep_all_sizes_bounded test_real_vllm_config_caps_widest_ngram_decode_batch test_single_speculative_token_does_not_raise_default_capture_size

关键源码片段

vllm/config/vllm.py core-logic

核心修复所在:`_set_cudagraph_sizes` 生成 uniform decode 尺寸时增加平台默认 ceiling 过滤,从根上杜绝默认 capture 扩张到 2176/6400 token。

# vllm/config/vllm.py — VllmConfig._set_cudagraph_sizes 关键分支的整理片段
# 平台默认 ceiling 是内存安全护栏:非 datacenter Blackwell 为 512,
# datacenter Blackwell 为 1024。用户显式配置 capture sizes 时不受此限制,
# 但默认推导出的任何尺寸都不得越过它。
def _set_cudagraph_sizes(self):
    default_max_graph_size = (
        1024 if current_platform.is_device_capability_family(100) else 512
    )
    # uniform decode 中每个请求携带目标 token + draft token,
    # 因此 decode_query_len = 1 + num_speculative_tokens。
    decode_query_len = self.uniform_decode_query_len
    max_cudagraph_capture_size = min(
        max_num_seqs * decode_query_len * 2,
        default_max_graph_size,
    )
​
    # 动态投机调度下 draft 宽度随 batch size 变化,按 tier 生成 decode 尺寸,
    # 避免较窄的 tier 全部退化为 eager(#50488 引入的推导逻辑,予以保留)。
    # decode_tiers 来自动态 schedule 稠密查找表,形如 (query_len, tier_max_reqs)。
​
    uniform_decode_sizes = sorted(
        {
            n * query_len
            for query_len, tier_max_reqs in decode_tiers
            for n in request_counts(tier_max_reqs)
            # 本 PR 的核心修复:生成时就按平台 ceiling 过滤,杜绝默认 capture
            # 扩张到 2176/6400 token 导致引擎启动 OOM 或 KV cache 被挤压。
            if n * query_len <= max_cudagraph_capture_size
        }
    )
​
    max_num_tokens = self.scheduler_config.max_num_batched_tokens
    max_cudagraph_capture_size = min(max_num_tokens, max_cudagraph_capture_size)
​
    if self.compilation_config.cudagraph_capture_sizes is not None:
        # 用户显式指定:去重、过滤 max_num_tokens、升序,不再受默认 ceiling 约束。
        cudagraph_capture_sizes = sorted(
            set(s for s in cudagraph_capture_sizes if s <= max_num_tokens)
        )
    else:
        # 默认路径:先按 1/2/4/8/16 步长生成 token 网格,再追加 max_num_tokens
        # 与 ceiling 内的 uniform decode 尺寸;超出 ceiling 的宽批次运行时回退 eager。
        cudagraph_capture_sizes = [1, 2, 4] + list(
            range(8, min(max_cudagraph_capture_size + 1, 256), 8)
        ) + list(range(256, max_cudagraph_capture_size + 1, 16))
        if max_num_tokens <= max_cudagraph_capture_size:
            cudagraph_capture_sizes.append(max_num_tokens)
        cudagraph_capture_sizes += [
            size for size in uniform_decode_sizes if size <= max_num_tokens
        ]
        cudagraph_capture_sizes = sorted(set(cudagraph_capture_sizes))
    # 后续:序列并行场景过滤 TP 不可整除的尺寸,并与用户显式 max 值做一致性校验
tests/compile/test_config.py test-coverage

回归防护主体:将 H200 nightly 两个精确失败宽度(2176/6400)参数化 pin 进单元测试,并把所有断言翻转为 " 默认尺寸不超过平台 ceiling"。

# tests/compile/test_config.py — 平台 ceiling 回归测试的整理片段
@pytest.mark.parametrize(
    ("max_num_seqs", "num_speculative_tokens", "widest_is_captured"),
    [
        # 无投机:保持历史公式的 2 倍余量,行为不变。
        (8, 0, True),
        (32, 0, True),
        # 投机但最宽 decode 批次仍在平台 ceiling 内:要求精确捕获。
        (64, 7, True),
        (256, 1, True),
        # 更宽的 decode 批次不得抬高内存安全 ceiling。
        (32, 16, False),
        (64, 16, False),
        # H200 回归精确宽度:DFlash 的 2176 token 批次与 suffix 解码的
        # 6400 token 批次,必须 pin 在测试里防止再次回退。
        (128, 16, False),
        (256, 24, False),
        # 离 stride 的宽度:ceiling 之上回退 eager,ceiling 之下仍精确捕获。
        (33, 16, False),
        (24, 16, True),
    ],
)
def test_default_cudagraph_capture_size_respects_platform_ceiling(
    max_num_seqs, num_speculative_tokens, widest_is_captured
):
    """Uniform decode 覆盖不得抬高平台默认 ceiling。"""
    # 组装 mock 配置并执行真实推导逻辑(略去辅助函数细节)
    VllmConfig._set_cudagraph_sizes(config)
​
    decode_query_len = 1 + num_speculative_tokens
    token_grid_max = min(
        max_num_seqs * decode_query_len * 2,
        default_max_graph_size,
    )
    widest_uniform_decode = max_num_seqs * decode_query_len
    # head 版本:最终 max 等于 token 网格上限,不再被 decode 尺寸抬高;
    # 只有 ceiling 内的 off-stride 宽度才要求精确出现在 capture 列表中。
    assert compilation_config.max_cudagraph_capture_size == token_grid_max
    assert (
        compilation_config.max_cudagraph_capture_size
        == compilation_config.cudagraph_capture_sizes[-1]
    )
    assert (
        widest_uniform_decode in compilation_config.cudagraph_capture_sizes
    ) is widest_is_captured

评论区精华

合并批准 other

维护者 njhill 直接批准:"Thanks @khluu, LGTM!",无额外技术意见或修改要求。

结论:批准合并,无未解决问题。 · 已解决

fork 来源 PR 的自动化 review 被禁用 other

claude[bot] 提示该 PR 来自 fork,自动 review 被禁用,维护者可手动触发 @claude review。

结论:未产生额外技术讨论。 · 已解决

风险与影响

  1. 默认行为变化:超过平台默认 ceiling 的 uniform decode 批次(如 544 token 以上的投机解码批次)默认不再捕获,运行时会回退 eager 执行,大 batch 投机解码场景延迟可能上升;用户可通过显式 max_cudagraph_capture_size 或 cudagraph_capture_sizes 恢复。
  2. 核心路径变更:VllmConfig._set_cudagraph_sizes 是 V1/V2 共用的默认配置路径,改动虽小但影响所有启用投机解码 + CUDA graph 的默认部署;无投机场景已由测试保证与历史公式逐位一致。
  3. 验证缺口:PR body 明确 model evaluation 未在本地运行,正确性依赖 H200 CI 两个门禁作业;测试 pin 了两个精确失败宽度,但未覆盖所有 shape 组合,逻辑上统一 ceiling 可保证内存安全,但性能回退面需要线上观察。
  4. AI 辅助变更:提交包含 Codex 合著与 CI agent 诊断,虽有人工审查声明,仍建议合入后关注相关 issue 反馈。

对用户:默认配置下投机解码 + CUDA graph 的场景不再因超大 graph capture 导致引擎启动 OOM 或 KV cache 被挤压,服务可启动性恢复;超 ceiling 的宽解码批次回退 eager,吞吐与延迟有可预期的轻微劣化。对系统:修复 H200 nightly CI 回归,恢复 spec-decode-ngram-suffix 与 spec-decode-dflash-nightly 门禁。对团队:保留了 #50488 的动态 tier 推导与 off-stride 覆盖成果,避免全量 revert 的返工,并为后续 capture 尺寸的显式调优保留了入口。

核心路径变更(VllmConfig 默认配置,V1/V2 共用) 默认行为回退:超大 decode 批次走 eager 模型级评估未本地运行,依赖 H200 CI 门禁 AI 辅助变更需人工审查确认

关联 Issue

#50488 [Bugfix][Spec Decode] Capture the widest uniform decode batch by default

完整报告

参与讨论