Prhub

#41069 [Core] Account for `num_gpu_blocks_override` in `max_model_len` checks

原始 PR 作者 njhill 合并时间 2026-04-29 06:44 文件变更 5 提交数 6 评论 2 代码增减 +128 / -60

执行摘要

修复 KV 缓存块数覆盖未影响 max_model_len 检查的 bug

Issue #35541 报告称,当用户设置较低的 num_gpu_blocks_override 时,vLLM 会无限期挂起。PR 描述指出:当前验证和自动设置 max_model_len 没有考虑 num_gpu_blocks_override。由于调度器基于实际分配的块数工作,override 值应优先于测量值。没有此修复,一个在 max_model_len 内但不适合 num_gpu_blocks_override 块的请求会导致调度器永久挂起。

此 PR 值得精读,特别关注 _pool_bytes_per_block 如何桥接不同层组规格,以及 get_kv_cache_configs 中覆写内存的计算方式。它属于核心调度路径的稳健性修复,设计决策(以块为单位而非字节)有明确的讨论背景。若正在维护或扩展 KV 缓存相关逻辑,理解此改动有助于避免同类问题。

讨论亮点

gemini-code-assist[bot]: 建议在 get_kv_cache_configs 中使用 logger.info_once 而非 logger.info,以避免分布式环境下多进程重复输出日志。该建议未被采纳,最终代码仍使用 logger.info。(状态:未解决)
ivanium: 倾向于合并此 PR 而非更大规模的重构(基于块而非字节),因为未来可能需要让模型(如 DSV4)自定义配置,保留原始 available_memory 更灵活。需要进一步讨论,但当前修复已足够。(状态:已解决,PR 被批准)

实现拆解

  1. 新增 _pool_bytes_per_block 辅助函数vllm/v1/core/kv_cache_utils.py
    计算一个块的字节数,该函数复用与 get_kv_cache_config_from_groups 中相同的除数逻辑,以准确反映共享 KV 缓存池中每个块占用的内存。支持三种场景:单一 uniform 层组、DeepSeekV4 MLA 多规格层组、以及其他混合层组。

  2. 修改 get_kv_cache_configs 函数vllm/v1/core/kv_cache_utils.py
    在循环处理每个 worker 的 KV cache 规格前,先根据 num_gpu_blocks_override 调整 available_memory。通过 num_gpu_blocks_override * _pool_bytes_per_block() 计算实际可用内存,并覆盖原来的测量值。这样后续的 auto-fit 和 admission check 都基于覆写后的块数。

  3. 简化 may_override_num_blocksget_num_blocksvllm/v1/core/kv_cache_utils.py
    移除 may_override_num_blockssuppress_log 参数,将日志移到调用点(只在 get_kv_cache_configs 中记录一次)。get_num_blocks 简化为一层调用,不再传递 suppress_log

  4. 调整调用点适配vllm/v1/worker/gpu_model_runner.py
    修改 _init_minimal_kv_cache_for_profiling 中对 get_kv_cache_config_from_groups 的调用,移除不再存在的 suppress_log 参数,对应 API 变更。

  5. 更新相关测试适配新行为tests/v1/core/test_kv_cache_utils.pytests/v1/e2e/general/test_async_scheduling.pytests/compile/h100/test_startup.py
    - 新增两个单元测试:test_auto_fit_max_model_len_respects_num_gpu_blocks_overridetest_check_enough_kv_cache_memory_respects_num_gpu_blocks_override,分别验证 auto-fit 和准入检查使用覆写后的块数。
    - 在 test_async_scheduling.py 的 preemption 测试中显式传入 max_model_len=512,以免新的检查因默认值过大而失败。
    - 在 test_startup.py 中将 num_gpu_blocks_override 从 8 提高到 32(以及 16),以满足 SWA 模型更严格的准入要求。

文件 模块 状态 重要度
vllm/v1/core/kv_cache_utils.py KV 缓存 modified 8.16
tests/v1/core/test_kv_cache_utils.py 测试 modified 6.43
vllm/v1/worker/gpu_model_runner.py 模型运行器 modified 4.53
tests/v1/e2e/general/test_async_scheduling.py 调度测试 modified 4.44
tests/compile/h100/test_startup.py 启动测试 modified 3.96

关键符号

may_override_num_blocks _pool_bytes_per_block get_kv_cache_configs get_num_blocks

关键源码片段

vllm/v1/core/kv_cache_utils.py core-logic

核心实现文件:新增 `_pool_bytes_per_block`、修改 `get_kv_cache_configs` 应用 override、简化 `may_override_num_blocks` 和 `get_num_blocks`。所有关键逻辑变更均发生在此。

def _pool_bytes_per_block(kv_cache_groups: list[KVCacheGroupSpec]) -> int:
    """
    计算共享 KV 缓存池中一个块的实际字节数,
    该值应等于 `get_kv_cache_config_from_groups` 将 `available_memory`
    转换为 `num_blocks` 时使用的除数。
    用于在 `num_gpu_blocks_override` 生效后计算有效缓存容量。
    """
    # 单一 uniform 层组:直接取第一组的 page_size
    if len(kv_cache_groups) == 1 and isinstance(
        kv_cache_groups[0].kv_cache_spec, UniformTypeKVCacheSpecs
    ):
        return kv_cache_groups[0].kv_cache_spec.page_size_bytes
​
    # 所有层组均为 uniform 类型(例如 DeepSeekV4 的完整 MLA + SWA)
    if all(
        isinstance(g.kv_cache_spec, UniformTypeKVCacheSpecs) for g in kv_cache_groups
    ):
        # DeepseekV4: 共享布局按每个 page_size 桶的最大层元组数对齐
        full_mla_spec = cast(UniformTypeKVCacheSpecs, kv_cache_groups[0].kv_cache_spec)
        layer_tuple_page_bytes = sum(full_mla_spec.get_page_sizes())
        num_layer_tuples = max(
            cast(UniformTypeKVCacheSpecs, g.kv_cache_spec).get_num_layer_tuples()
            for g in kv_cache_groups
        )
        return layer_tuple_page_bytes * num_layer_tuples
​
    # 其他混合场景:取最大的组大小 × 统一 page_size
    group_size = max(len(g.layer_names) for g in kv_cache_groups)
    page_size = get_uniform_page_size([g.kv_cache_spec for g in kv_cache_groups])
    return page_size * group_size# 在 get_kv_cache_configs 中应用 override(位于循环前):
# bytes_per_block = _pool_bytes_per_block(kv_cache_groups)
# if vllm_config.cache_config.num_gpu_blocks_override is not None:
# # 覆写可用内存,使后续计算基于实际可分配的块数
# override = vllm_config.cache_config.num_gpu_blocks_override
# available_memory = [override * bytes_per_block] * len(kv_cache_specs)
# logger.info(...)
tests/v1/core/test_kv_cache_utils.py test-coverage

新增两个单元测试,专门验证 `num_gpu_blocks_override` 对 auto-fit 和 admission check 的影响,是对核心逻辑的直接覆盖。

def test_auto_fit_max_model_len_respects_num_gpu_blocks_override():
    """
    Auto-fit 必须基于覆写后实际可用的缓存块数来确定 max_model_len,
    而非原始测量内存。否则 auto-fit 可能选择一个不再适合覆写缓存的值。
    """
    model_config = ModelConfig(max_model_len=16384)
    model_config.original_max_model_len = -1 # 请求 auto-fit 模式
    vllm_config = VllmConfig(model_config=model_config)
    # 无论可用内存多大,只分配 32 块
    vllm_config.cache_config.num_gpu_blocks_override = 32
​
    mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2
    kv_cache_specs = {
        "layer_1": new_kv_cache_spec(), # block_size=16
        "layer_2": new_kv_cache_spec(),
    }
    # 提供充足的原始内存(每层 1024 块,足以支持 max_model_len=16384)
    large_available_memory = mem_per_block_per_layer * 2 * 1024
​
    get_kv_cache_configs(vllm_config, [kv_cache_specs], [large_available_memory])
​
    # 32 块 * 16 tokens/ 块 = 512 token 槽位,max_model_len 必须自动调整到 ≤512
    assert 0 < vllm_config.model_config.max_model_len <= 32 * 16
​
​
def test_check_enough_kv_cache_memory_respects_num_gpu_blocks_override():
    """
    准入检查必须使用覆写后的缓存块数,而不是原始内存。
    否则启动时可能接受一个实际上放不下的 max_model_len。
    """
    model_config = ModelConfig(max_model_len=16384)
    vllm_config = VllmConfig(model_config=model_config)
    # 32 块对于 max_model_len=16384 太小(需 1024 块)
    vllm_config.cache_config.num_gpu_blocks_override = 32
​
    mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2
    kv_cache_specs = {
        "layer_1": new_kv_cache_spec(),
        "layer_2": new_kv_cache_spec(),
    }
    large_available_memory = mem_per_block_per_layer * 2 * 1024
​
    with pytest.raises(ValueError, match="max seq len"):
        get_kv_cache_configs(vllm_config, [kv_cache_specs], [large_available_memory])

评论区精华

使用 logger.info_once 避免分布式日志重复 style

gemini-code-assist 建议在 `get_kv_cache_configs` 中使用 `logger.info_once` 替代 `logger.info`,以免在多 worker 场景下输出多条相同日志。

结论:未采纳,最终代码仍使用 `logger.info`,但日志只打印一次(位于循环外)。 · unresolved

偏小侵入修复 vs. 更大规模的基于块重构 设计

ivanium 评论倾向于当前 PR 而非改为基于块计算所有内存,因为保留原始 `available_memory` 可为未来模型自定义提供更多自由。

结论:PR 被批准合并,该设计权衡留待后续讨论。 · 已解决

风险与影响

  • 计算准确性_pool_bytes_per_block 对 DeepSeekV4 等混合规格的计算是否正确?若计算错误会导致调整后的 available_memory 偏差,影响 auto-fit 和准入检查。单元测试覆盖了基础场景,但未覆盖所有可能的层组混合。
  • 分布式日志冗余:覆写日志在每个 worker 进程各输出一次,可能污染日志。虽未采用 info_once,但影响轻微。
  • 回归风险:移除 suppress_log 参数可能影响其他调用方(如 CUDA graph profiling 中临时设置 override),但已验证没有其他调用,且 _init_minimal_kv_cache_for_profiling 已适配。
  • 配置兼容性:用户可能需要调整 num_gpu_blocks_override 值,因为新的准入检查更严格(例如 test_startup.py 需要从 8 提高到 32)。若之前配置过低,升级后启动会失败。
  • 用户:修复了因 num_gpu_blocks_override 设置过小导致调度器挂起的 bug;使用该选项的用户需确保值足够大,否则启动时准入检查会明确拒绝。
  • 系统:提升了调度器可靠性和可预测性。auto-fit 机制现在会基于实际缓存容量裁剪 max_model_len,避免后续分配失败。
  • 团队:变更集中在 kv_cache_utils.py,接口小幅调整(移除 suppress_log),影响范围可控。未来重构需考虑此处的设计权衡。
核心调度路径 分布式日志冗余 配置兼容性需验证

关联 Issue

#35541 [Bug]: vLLM hangs indefinitely with low `num_gpu_blocks_override`

完整报告

参与讨论