Prhub

#50894 [Bugfix] Scale KV page size for hidden states extraction with TP

原始 PR 作者 orestis-z 合并时间 2026-09-14 22:07 文件变更 2 提交数 12 评论 19 代码增减 +73 / -0

执行摘要

TP 下 hidden state 提取按需放大 KV page,修复启动断言崩溃

Issue #51016 报告:使用 --speculative-config '{"method": "extract_hidden_states", ...}' 时,poolside/Laguna-S-2.1(117.6B MoE,混合 sliding/full attention,48 层)在 TP=1 可启动,TP ≥ 2 直接在 KVCacheSpecBase.post_init 抛 AssertionError(assert self.page_size_padded >= real_page_size)。根因是 TP 把 KV head 分片(num_kv_heads / TP)压缩了 common KV page,而 hidden-state cache spec 存的是完整未分片的 hidden_size,每 token 成本可能反超被压缩的 page;issue 明确指出这“typically [affects] large models that require TP ≥ 2 for serving”。PR body 给出的方案是:在把 hidden-state 规格对齐到 common page 之前,当 hidden-state 每 token 成本超过 common page 时放大所有目标 group 的 block size(“Scale up all target group block sizes when the hidden-state per-token cost exceeds the common page, before aligning hidden-state specs”)。

值得精读,尤其是维护 vllm/v1/core/kv_cache_utils.py 或调试 extract_hidden_states + TP 组合的人。关注三个设计决策:一是用 cdiv(max_per_token, common_page) 求最小整数倍数,而不是无条件翻倍,控制显存浪费;二是同一 scale 同时作用于 block_size 与已显式设置的 page_size_padded,维持 spec 内部比例;三是把缩放放在 math.gcd 之前,使 hidden-state 层仍能取到合法公约数。同时建议阅读者顺手确认两件事:复合 UniformTypeKVCacheSpecs 场景下子 spec 是否被漏缩,以及后续是否需要为 scale ≥ 3、MambaSpec 与已 padded 的 spec 补测试用例。

讨论亮点

讨论集中在两处,均由维护者 benchislett 提出并被作者采纳:其一是在 diff 上追问 “is there no helper function for ‘ensure page size is at least N’?”——初版实现把缩放逻辑(计算 max_per_token、cdiv 求 scale、遍历 group 做 replace)直接内联在 get_kv_cache_groups 里,作者回复 “extracted into helper function”,最终抽出 _ensure_min_page_size 并让它同时返回更新后的 common page,使主流程收敛为一行调用。其二是对 helper docstring 的 nit(line 2241),benchislett 直接在 suggestion 中给出补充文本,说明这是为“hidden state extraction”服务——hidden states 存放在 KV cache 中并通过自定义 hidden state connector 回传客户端,作者按建议合并。最终意见为 “One more comment, otherwise approved. Will mark as ready, please run CI once last comment is addressed.”,随后进入 CI 循环。另有流程性信息:claude[bot] 声明该 PR 来自 fork,自动 review 被禁用,需维护者手动触发;mergify[bot] 多次提示 main 分支漂移导致的冲突,作者共触发 4 次 CI build(#87686、#88334、#88803、#88806)。

实现拆解

  1. 问题定位在 get_kv_cache_groups(vllm/v1/core/kv_cache_utils.py)的 hidden-state 收尾分支:先由 get_uniform_page_size 得到 common_page,再为每个 hidden spec 计算 per_token = num_kv_heads * head_size * get_dtype_size(dtype),随后 replace(spec, block_size=new_bs, page_size_padded=common_page)。TP > 1 时 attention spec 的 num_kv_heads 已被分片,common_page 缩小,而 hidden spec 的 num_kv_heads/head_size 仍然承载完整 hidden_size,于是 per_token > common_page,page_size_padded 小于真实每 token 成本,触发 KVCacheSpecBase.__post_init__ 断言。
  2. 新增缩放 helper _ensure_min_page_size(groups, common_page, hidden_specs) -> tuple[list[KVCacheGroupSpec], int]:取所有 hidden spec 中最大的 max_per_token;若 max_per_token <= common_page 直接原样返回(TP = 1 与常规模型零开销早退);否则 scale = cdiv(max_per_token, common_page)、common_page *= scale,并对每个 group 用 replace(s, block_size=s.block_size * scale) 生成新 spec。对于 AttentionSpec/MambaSpec 且 page_size_padded is not None 的 spec,额外把 page_size_padded * scale 一并放大,避免 block_size 与 padded page 的比例关系被破坏。
  3. 主流程接入:在 hidden-state 分支的 common_page = get_uniform_page_size([g.kv_cache_spec for g in groups]) 之后插入一行 groups, common_page = _ensure_min_page_size(groups, common_page, hidden_specs)。顺序很关键——缩放发生在 group_block_size = math.gcd(*(g.kv_cache_spec.block_size for g in groups)) 之前,因此 hidden-state 层算出的 new_bs 仍是缩放后所有 group block size 的公约数,共享 block table 的整除前提不被破坏;wasted_bytes 日志与 aligned = replace(spec, block_size=new_bs, page_size_padded=common_page) 逻辑保持不变。
  4. 行为变化:修复把“不可能对齐”变成“可对齐但有少量浪费”。以新增测试的数值推导,scale = cdiv(6144, 4096) = 2,目标 group 的 block size 从 16 变 32、common page 从 4096 变 8192,hidden-state 层最终得到 block_size = 1、page_size_padded = 8192,每 block 浪费 2048 字节(25%)。这也意味着在 hidden state 提取场景下,用户 --block-size 指定的值可能被内部改写。
  5. 测试配套:tests/v1/core/test_kv_cache_utils.py 新增 test_hidden_states_with_tp_scales_page_size,用 new_kv_cache_spec(block_size=16, num_kv_heads=2, head_size=64, dtype=bfloat16) 模拟 TP=4 分片后的 4096 字节 page,用 HiddenStateCacheSpec(num_kv_heads=6, head_size=512, dtype=bfloat16) 构造 6144 字节的每 token 成本,断言 hidden-state 层仍在分组中且非 hidden group 的 block size 被放大;该用例在修复前正是触发断言的组合。其余提交为 8 次 main 同步 merge、1 次采纳 reviewer docstring 建议的 co-authored commit 与 1 次 ruff 格式化,无返工式重写。
文件 模块 状态 重要度
vllm/v1/core/kv_cache_utils.py KV 缓存管理 modified 7.21
tests/v1/core/test_kv_cache_utils.py KV 缓存管理 modified 5.73

关键符号

_ensure_min_page_size get_kv_cache_groups _largest_divisor_at_most test_hidden_states_with_tp_scales_page_size

关键源码片段

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

唯一源码改动文件:新增 `_ensure_min_page_size` 并在 `get_kv_cache_groups` 的 hidden-state 分支接入,是断言崩溃的直接修复点。

# vllm/v1/core/kv_cache_utils.py —— 本次新增的缩放 helper
def _ensure_min_page_size(
    groups: list[KVCacheGroupSpec],
    common_page: int,
    hidden_specs: dict[str, HiddenStateCacheSpec],
) -> tuple[list[KVCacheGroupSpec], int]:
    """Scale up group block sizes so the common page is at least as large as
    the biggest hidden-state per-token cost.
​
    This protects the hidden state extraction feature, where we store hidden states in
    the KV cache, and return them to clients using a custom hidden state connector.
​
    Returns the (possibly scaled) groups and updated common page size.
    """
    # hidden state 存的是完整 hidden_size,不做 TP 分片,
    # 因此其每 token 成本可能大于已被 num_kv_heads / TP 缩小后的 common page。
    max_per_token = max(
        spec.num_kv_heads * spec.head_size * get_dtype_size(spec.dtype)
        for spec in hidden_specs.values()
    )
    if max_per_token <= common_page:
        # page 已经足够大(例如 TP = 1):完全不缩放,保持既有显存布局。
        return groups, common_page
​
    # 向上取整得到最小的整数缩放倍数,避免多放大一档白白占用显存。
    scale = cdiv(max_per_token, common_page)
    common_page *= scale
    scaled: list[KVCacheGroupSpec] = []
    for g in groups:
        s = g.kv_cache_spec
        kw: dict[str, int] = {"block_size": s.block_size * scale}
        # 显式 padded 过的 Attention / Mamba spec 必须同步放大,
        # 否则 block_size 与 page_size_padded 的比例关系会被破坏。
        if isinstance(s, (AttentionSpec, MambaSpec)) and s.page_size_padded is not None:
            kw["page_size_padded"] = s.page_size_padded * scale
        scaled.append(KVCacheGroupSpec(g.layer_names, replace(s, **kw)))
    return scaled, common_page
​
​
# get_kv_cache_groups 的 hidden-state 收尾分支(前置分支已处理 uniform spec、
# packed groups、unify fallback 等场景,此处只保留与本修复相关的部分)
    # Add hidden-state layers back with page aligned to the common page.
    if hidden_specs:
        common_page = get_uniform_page_size([g.kv_cache_spec for g in groups])
        # TP may shrink the common page below hidden-state per-token cost.
        groups, common_page = _ensure_min_page_size(groups, common_page, hidden_specs)
        # 注意顺序:先缩放再取 gcd,hidden-state 层的 block size 才仍是
        # 缩放后所有 group block size 的公约数,共享 block table 的前提不被破坏。
        group_block_size = math.gcd(*(g.kv_cache_spec.block_size for g in groups))
        for name, spec in hidden_specs.items():
            per_token = spec.num_kv_heads * spec.head_size * get_dtype_size(spec.dtype)
            max_block_size = max(common_page // per_token, 1)
            new_bs = _largest_divisor_at_most(group_block_size, max_block_size)
            wasted_bytes = common_page - new_bs * per_token
            logger.info(
                "Using block size %d for hidden-state cache layer %s; "
                "page alignment wastes %d bytes (%.2f%%) per block",
                new_bs,
                name,
                wasted_bytes,
                wasted_bytes / common_page * 100,
            )
            aligned = replace(spec, block_size=new_bs, page_size_padded=common_page)
            groups.append(KVCacheGroupSpec([name], aligned))
​
    _annotate_eagle_groups(vllm_config, kv_cache_spec, groups)
    _warn_if_unannotated_eagle_mamba(vllm_config, groups)
    return groups
tests/v1/core/test_kv_cache_utils.py test-coverage

新增回归用例,用 TP = 4 分片后的 KV page 与更大的 hidden-state 每 token 成本复现原断言场景,并校验 block size 被放大。

# tests/v1/core/test_kv_cache_utils.py
def test_hidden_states_with_tp_scales_page_size():
    """When TP shrinks KV pages below the hidden-state per-token cost,
    get_kv_cache_groups must scale up target block sizes so that the
    common page accommodates the unsharded hidden states."""
    # 模拟 TP = 4 把 8 个 KV head 切成每 rank 2 个:
    # KV page = block_size(16) * num_kv_heads(2) * head_size(64) * dtype(2)
    # = 4096 字节。
    kv_spec = new_kv_cache_spec(
        block_size=16,
        num_kv_heads=2,
        head_size=64,
        dtype=torch.bfloat16,
    )
    # hidden-state 每 token 成本 = num_hidden_states(6) * hidden_size(512)
    # * dtype(2) = 6144 字节,已经超过 4096 字节的 KV page。
    hs_spec = HiddenStateCacheSpec(
        block_size=16,
        num_kv_heads=6,
        head_size=512,
        dtype=torch.bfloat16,
    )
    specs = {
        "target.0.attn": kv_spec,
        "target.1.attn": kv_spec,
        "cache_only_layers.48": hs_spec,
    }
​
    groups = get_kv_cache_groups(_grouping_config(), specs)
​
    # 断言 1:hidden-state 层仍在分组中,且 KVCacheSpecBase 的
    # page_size_padded >= real_page_size 断言不再被触发。
    all_layers = {name for g in groups for name in g.layer_names}
    assert "cache_only_layers.48" in all_layers
​
    # 断言 2:目标 group 的 block size 被放大。
    # 推导:scale = cdiv(6144, 4096) = 2,即 block size 16 -> 32,
    # common page 4096 -> 8192;hidden-state 层则落到 block_size = 1、
    # page_size_padded = 8192,每 block 浪费 2048 字节(25%)。
    for g in groups:
        if "cache_only_layers.48" not in g.layer_names:
            assert g.kv_cache_spec.block_size > kv_spec.block_size

评论区精华

内联的 page 缩放逻辑是否应抽成独立 helper 设计

benchislett 在 `vllm/v1/core/kv_cache_utils.py` 的 diff 上直接追问 “is there no helper function for ‘ensure page size is at least N’?”;初版实现把 max_per_token 计算、cdiv 求 scale、遍历 group 做 replace 全部内联在 `get_kv_cache_groups` 里。

结论:作者回复 “extracted into helper function”,抽出 `_ensure_min_page_size` 并让其同时返回缩放后的 groups 与更新后的 common_page,主流程收敛为一行调用,可读性与复用性提升。 · 已解决(resolved)

helper docstring 需要解释 hidden_specs 的业务含义 documentation

benchislett 在第 2241 行给出 suggestion,指出读者可能不知道 hidden_specs 指什么,建议在 docstring 中补充 hidden state extraction 的上下文(hidden states 存入 KV cache 并通过自定义 hidden state connector 返回客户端)。

结论:建议文本被完整合并(commit a883956 以 Co-authored-by 形式采纳),helper 的 docstring 明确了它保护的是 hidden state extraction 特性。 · 已解决(resolved)

fork PR 的自动化 review 与合并前 CI 流程 other

claude[bot] 说明该 PR 来自 fork,自动 review 被禁用,需要维护者手动触发;mergify[bot] 多次提示因 main 分支漂移产生冲突,作者先后触发 Buildkite CI #87686、#88334、#88803、#88806 并做过一次 retry。

结论:benchislett 以 “One more comment, otherwise approved. Will mark as ready, please run CI once last comment is addressed.” 批准并在最后一条意见解决后放行,PR 最终合并(merged_by: benchislett)。 · 已解决(resolved)

风险与影响

1) 回归面:_ensure_min_page_size 会放大所有非 hidden group 的 block_size,因此在 extract_hidden_states 场景下,--block-size 的用户配置可能被内部改写(如 16 → 32)。这会改变 KV cache 物理页布局、prefix cache 的复用粒度(hash 以 block 为单位)以及 attention backend / CUDA graph 对 block size 的隐含假设,属于“仅在特定特性开启时触发、但触发后是全局行为改变”的类别。
2) 兼容性:缩放只对 isinstance(s, (AttentionSpec, MambaSpec)) and s.page_size_padded is not None 的 spec 同步放大 padded page;若 _get_kv_cache_groups_uniform_page_size 某条路径产出的 group spec 是复合的 UniformTypeKVCacheSpecs(内部子 spec 各自持有 block_size),当前实现只替换外层 block_size,内层子 spec 与其 page_size_padded 是否随之同步无法从现有材料确认,存在比例不一致的隐患(材料不足以定论,建议维护者复核该分支)。
3) 性能/内存:scale 用 cdiv 取最小整数倍,未过度放大;但 hidden-state 层在新 common page 下 new_bs 可能退化为 1(测试数值下即为 1),意味着每个 token 一个 block,block table 与调度开销上升;同时目标 group block 变大也会降低 prefix cache 的复用粒度,KV cache 可用 block 数在固定显存预算下相应减少。
4) 测试覆盖:新增用例只覆盖 scale = 2、单 group、全为 AttentionSpec 的组合;未覆盖 scale ≥ 3、MambaSpec、page_size_padded 已显式设置、packed groups 提前返回分支以及 --disable-hybrid-kv-cache-manager 路径。
5) 正确性边界:wasted_bytes = common_page - new_bs * per_token 在缩放后保证非负,缩放逻辑本身不改变 hidden state 的写入量,只改变对齐方式,因此不存在数据截断风险。

用户侧:依赖 extract_hidden_states 采集训练数据的大模型(如 117.6B MoE 的 poolside/Laguna-S-2.1)在 TP ≥ 2 时从“启动即崩”恢复为可正常启动并产出 hidden states;TP = 1 或 hidden state 成本未超 page 的模型走早退分支,行为与显存布局完全不变。系统侧:KV cache group 构建新增一次页大小协商决策,block size 不再严格等于用户配置值,调度器分配给各 group 的 block 数与 prefix cache 命中粒度会随之变化;hidden-state 层与目标注意力层仍需共享 block table,本次改动保证该整除关系成立。团队侧:kv-cache-manager 子系统需要把“block size 可被内部放大以容纳 hidden-state 每 token 成本”纳入既有语义认知,后续改动该文件的开发者需要同时检查这条缩放路径。

核心路径变更 TP 场景 block size 被内部放大 prefix cache 粒度变化 复合 spec 缩放未验证 测试数值组合单一

关联 Issue

#51016 [Bug]: extract_hidden_states fails with TP>1 when KV page size < hidden-state per-token cost

完整报告

参与讨论