前缀相同,缓存为何不命中?
运行环境与问题描述
测试环境为 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¹ | 第二轮总输入 | 第二轮首次命中 | 原样重复第二轮命中 |
|---|---|---|---|---|---|---|---|
| 短回答 | 1857 | 200 | 完整 | 24 | 2081 | 0 | 0 |
| 长回答 | 1857 | 2000 | 完整 | 24 | 3881 | 0 | 1536 |
| 长输入、长回答 | 3714 | 2000 | 完整 | 23 | 5737 | 1536 | 3072 |
| 回答中途分叉 | 1857 | 2000 | 截取约 1000 token | 未单独统计 | 2872 | 0 | 0 |
| 更长回答 | 1857 | 4000 | 完整 | 24 | 5881 | 3072 | 3072 |
¹ 完整回答组按 第二轮总输入 − 首轮输入 − 首轮输出 计算。
简要结论:
- 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 的 K | 4(KV heads)÷ 2(TP)× 256(head dim)× 2 字节 | 1024 字节 |
| 单个 token 的 K+V | 1024 × 2 | 2048 字节 |
| 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 | 首次命中 | 原样重复命中 |
|---|---|---|
| 895 | 0 | 0 |
| 896 | 0 | 0 |
| 897 | 0 | 896 |
| 1792 | 0 | 0 |
| 1793 | 0 | 1792 |
| 2688 | 0 | 0 |
| 2689 | 0 | 2688 |
结论:
- 897-token 输入仅有一个完整块,重复仍命中 896,证明当前服务没有统一回退一个块。
- 896、1792等输入重复请求无法命中缓存,是因为缓存没有保存用于预测下一个 token 的最终计算结果,所以需要重算最后一个输入 token 来得到它。
版本演进:退块、跳过退块与后继 token 哈希
MTP 块尾 KV 依赖块外的后继 token,而 GDN 主模型的递推状态本身不依赖下一个 token。例如 公共块 + x 与 公共块 + y 的普通块哈希相同,但块尾 MTP KV 可能不同。因此,取消退块必须同时处理这项依赖。
| 版本/实现 | 处理方式 |
|---|---|
| 0.23 | MTP 匹配后丢弃最后一个块;调度器提前一块保存 GDN 快照,以便从退块后的边界恢复。 |
| 0.30.0rc2 | 对单实例及纯 PD producer 跳过退块,同时取消 GDN 检查点提前一块。 |
| 0.33.0 | 将 MTP 所需后继 token 纳入缓存哈希;匹配成功后取消退块及 GDN 配套回退。 |
0.33 的方案是让缓存键覆盖依赖,而非无条件跳过退块:
A:公共块 | x
B:公共块 | y → 后继 token 不同,该块不匹配
C:公共块 | x → 后继 token 相同,允许复用而无须退块
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐

所有评论(0)