运行环境与问题描述

测试环境为 Ascend 910B、vLLM 0.23、Qwen3.5-27B,张量并行度为 2(TP=2),开启前缀缓存,并分别测试启用和关闭 MTP 的情况。

启用 MTP 时,对同一条 1857-token 输入重复请求,前缀缓存命中数始终为 0。服务日志显示缓存块大小为 1536 tokens,输入已超过一个完整块,为何仍无法复用?

进一步测试发现,即使关闭 MTP,较短请求也未必能复用此前长请求的相同前缀。

这涉及两个条件:MTP 回退后是否还有完整块可用,以及复用边界是否保存了 GDN 状态快照。

原因分析

MTP 需要将当前位置的 hidden state 与下一个 token 配对,因此块内最后一个位置依赖块外的下一个 token。当前块的哈希匹配,并不能保证该 token 也相同。为满足这一依赖,开启 MTP 后,缓存仍按完整块匹配最长前缀,但最终复用范围需排除最后一个匹配块;GDN 则从提前保存的对应边界快照恢复,使各层从同一位置继续计算。

以每块 4 个 tokens 为例:

请求 A:a b c d | e f g h | i ...
请求 B:a b c d | e f g h | z ...
         块 1       块 2

块 1 的最后一个位置依赖 h_d 和 e。其中 h_d 是主模型在 d 位置输出的 hidden state,e 位于已匹配的块 2 内,因此依赖一致。块 2 的最后一个位置则依赖 h_h 和后续的 i/z,无法直接复用。实现上保留块 1,重新计算块 2,使保留块的依赖全部落在已确认匹配的范围内。

本次输入为 1857 tokens,仅包含一个完整的 1536-token 块。MTP 按整块回退后,可复用块数量降为 0。因此,输入超过一个缓存块,并不保证能够命中。

另一个限制来自 GDN。线性注意力恢复计算需要对应位置的递推状态快照,只有普通 Attention 的 K、V 并不足够。align 模式仅在调度步于块边界结束时保存快照,不保证每个历史块边界都有快照。因此,即使前缀匹配,缺少恢复边界上的 GDN 快照也会导致未命中。

在 vLLM 0.23 的 align 模式下,调度器先计算输入末尾的完整块边界;开启 MTP 后,再将目标缓存边界提前一个块,并强制在该位置结束一次 prefill,以保存对应的 GDN 状态快照。这样,后续请求匹配缓存时,即使 Full Attention 因 MTP 丢弃最后一个匹配块,GDN 仍能从提前保存的边界恢复。例如,输入为 3714 tokens、块大小为 1536 时,目标保存边界由 3072 提前到 1536。这是首次计算时提前保存快照,而不是复用时将 GDN 状态倒退一块。 参见 调度器源码 _mamba_block_aligned_split()。

实验验证

每组实验先发送一次请求建立缓存,再发送具有相同前缀的第二次请求。缩短输入取原请求的前 1857 tokens,追加输入保留原请求的完整前缀。3714 tokens 即 1857 × 2。

MTP首次输入第二次输入命中结果
开启3714 tokens相同输入1536 tokens
开启3714 tokens截取前 1857 tokens未命中
开启3714 tokens追加 200 tokens,共 3914 tokens可命中
关闭1857 tokens相同输入1536 tokens
关闭1857 tokens追加 200 tokens,共 2057 tokens可命中
关闭3714 tokens截取前 1857 tokens未命中

启用 MTP 时,3714-token 输入包含两个完整块,回退一块后仍可命中 1536 tokens;缩短为 1857 tokens 后,仅有一个完整匹配块,回退后无法复用。该结果与前述机制一致。

关闭 MTP 后,1857-token 输入可以正常建立并复用 1536-token 缓存,但“先长后短”仍未命中。这与快照保存位置有关:1857-token 请求的最近完整块边界为 1536,3714-token 请求则为 3072。在本次调度下,长请求没有在 1536 位置留下可恢复的 GDN 快照,后续短请求便无法从该位置恢复计算。

因此,前缀相同只是复用条件之一,还需满足状态快照和 MTP 回退约束。长请求建立的缓存,不一定能被具有相同前缀的短请求复用。

若需要在所有块边界保存 GDN 快照,可设置 mamba_cache_mode="all",代价是更高的显存占用。“所有块边界”并非每个 token,且该设置不会取消 MTP 回退要求。上述实验未包含切换到 all 后的复测及显存测量。

补充实验:多轮聊天复用 decode 缓存

MTP 仍然开启,日志确认块大小为 1536 tokens。

每组依次请求:首轮 A 生成回答 → 第二轮 B 携带「首轮问题 + 回答 + 新问题」→ 原样重复 B。第二轮首次命中用于观察首轮 decode 缓存,重复 B 则作为第二轮 prefill 缓存的对照。

场景首轮输入首轮输出接回回答第二轮新增 token¹第二轮总输入第二轮首次命中原样重复第二轮命中
短回答1857200完整24208100
长回答18572000完整24388101536
长输入、长回答37142000完整23573715363072
回答中途分叉18572000截取约 1000 token未单独统计287200
更长回答18574000完整24588130723072

¹ 完整回答组按 第二轮总输入 − 首轮输入 − 首轮输出 计算。

简要结论:

  • decode 缓存可以复用。
  • 该公共前缀覆盖 3 个完整块,MTP 回退一块后为 2 × 1536 = 3072,与实测一致。
  • 回退不是唯一约束:2000-token 回答组第二轮首次仍为 0,说明不能只按完整块数量预测命中,还需要对应边界的可恢复状态。

vllm 0.30.0rc2 缓存块从 1536 变为 896 tokens?

缓存块大小取决于模型状态大小、缓存精度、TP 配置及后端页布局。以下对比保持模型配置、TP=2 和缓存精度相同,说明 vLLM 0.23 与 0.30.0rc2 的计算差异。

每层、每张卡的状态大小为:

存储对象计算方式大小
单个 token 的 K4(KV heads)÷ 2(TP)× 256(head dim)× 2 字节1024 字节
单个 token 的 K+V1024 × 22048 字节
GDN 递推状态48(heads)÷ 2(TP)× 128 × 128 × 4(FP32 字节数)1,572,864 字节
卷积状态—61,440 字节

vLLM 0.23:按单独 K 的大小计算

使用递推状态大小除以单独 K 的每 token 大小。结果已满足 128-token 对齐,因此块大小为 1536 tokens。后续分配 K、V,并额外计入卷积状态:

块大小 = 1,572,864 ÷ 1024 = 1536 tokens
填充后的 Mamba 页大小 = 1536 × 2048 + 61,440 = 3,207,168 字节

对应的启动日志为:

Setting attention block size to 1536 tokens to ensure that attention page size is >= mamba page size.

vLLM 0.30.0rc2:按总状态与 K+V 的大小计算

vllm/platforms/interface.py 将递推状态与卷积状态合并,再除以 K+V 的每 token 大小,最后向上对齐到 128 tokens 的整数倍:

状态总大小 = 1,572,864 + 61,440 = 1,634,304 字节
所需 token 数 = 1,634,304 ÷ 2048 = 798
块大小 = 128 × ⌈798 ÷ 128⌉ = 896 tokens
对齐后的页大小 = 896 × 2048 = 1,835,008 字节
填充比例 =(1,835,008 − 1,634,304)÷ 1,634,304 ≈ 12.28%

对应日志的关键内容为:

Setting attention block size to 896 tokens ...
Padding mamba page size by 12.28% ...

块大小下降来自更紧凑的页布局,模型状态本身并未缩小。

补充实验:0.30.0rc2 的块边界命中

vLLM 0.30.0rc2,MTP 开启,块大小 896 tokens。每组使用独立随机前缀,通过 /v1/completions 传入精确 token ID,先建立缓存,再原样重复请求;每次生成 1 token。

输入 token首次命中原样重复命中
89500
89600
8970896
179200
179301792
268800
268902688

结论:

  • 897-token 输入仅有一个完整块,重复仍命中 896,证明当前服务没有统一回退一个块。
  • 896、1792等输入重复请求无法命中缓存,是因为缓存没有保存用于预测下一个 token 的最终计算结果,所以需要重算最后一个输入 token 来得到它。

版本演进:退块、跳过退块与后继 token 哈希

MTP 块尾 KV 依赖块外的后继 token,而 GDN 主模型的递推状态本身不依赖下一个 token。例如 公共块 + x 与 公共块 + y 的普通块哈希相同,但块尾 MTP KV 可能不同。因此,取消退块必须同时处理这项依赖。

版本/实现处理方式
0.23MTP 匹配后丢弃最后一个块;调度器提前一块保存 GDN 快照,以便从退块后的边界恢复。
0.30.0rc2对单实例及纯 PD producer 跳过退块,同时取消 GDN 检查点提前一块。
0.33.0将 MTP 所需后继 token 纳入缓存哈希;匹配成功后取消退块及 GDN 配套回退。

0.33 的方案是让缓存键覆盖依赖,而非无条件跳过退块:

A:公共块 | x
B:公共块 | y  → 后继 token 不同,该块不匹配
C:公共块 | x  → 后继 token 相同,允许复用而无须退块
Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐