← All notes

vLLM KV Cache:从历史重算到容量公式

Table of contents

本篇要回答什么

自回归模型已经生成过的 token,为什么还需要保存 K 和 V?缓存究竟省掉了什么,为什么长上下文 decode 仍然可能很慢?单卡、多卡、MHA、GQA 和 MQA 下,KV Cache 容量又应怎样计算?

核心结论是:KV Cache 用显存和历史 K/V 读取,换掉旧 token 在后续 decode step 中重复经过所有 Transformer 层的计算。它消除历史位置的重复投影与 MLP,却没有消除新 query 对可见历史的注意力读取。

0. K 和 V 到底怎样产生

先只看一个 decoder attention 层。上一层交给它的隐藏状态记作

\[X\in\mathbb{R}^{B\times T\times H},\]

其中 $B$ 是 batch size,$T$ 是本轮实际处理的 token 数,$H$ 是 hidden size。经过归一化后,对同一个 $X$ 做三组线性投影:

\[Q=XW_Q+b_Q,\qquad K=XW_K+b_K,\qquad V=XW_V+b_V.\]

若有 $N_q$ 个 query heads、$N_{kv}$ 个 KV heads、每个 head 的维度为 $D$,权重与输出 shape 是:

对象 shape 最后一维的含义
$W_Q$ $[H,N_qD]$ 所有 query heads 拼接
$W_K$ $[H,N_{kv}D]$ 所有 key heads 拼接
$W_V$ $[H,N_{kv}D]$ 所有 value heads 拼接
$Q$ $[B,T,N_q,D]$ reshape 后的 queries
$K,V$ $[B,T,N_{kv},D]$ reshape 后的 keys/values

工程实现通常把三次 GEMM 融成一次:

\[[Q\mid K\mid V]=XW_{QKV},\]

其中 $W_{QKV}$ 在输出维拼接三组权重。fused QKV projection 只减少 kernel launch 和输入读取,不表示 Q、K、V 的语义相同,也不表示它们共享权重。投影结果仍会按各自长度切开并 reshape。

下面是一个可以独立运行的 shape 演示:

import torch

B, T, H = 2, 3, 16
N_Q, N_KV, D = 4, 2, 4       # GQA:两个 query heads 共享一个 KV head
x = torch.randn(B, T, H)
w_qkv = torch.randn(H, (N_Q + 2 * N_KV) * D)

qkv = x @ w_qkv
q_end = N_Q * D
k_end = q_end + N_KV * D
q = qkv[..., :q_end].view(B, T, N_Q, D)
k = qkv[..., q_end:k_end].view(B, T, N_KV, D)
v = qkv[..., k_end:].view(B, T, N_KV, D)

assert q.shape == (2, 3, 4, 4)
assert k.shape == v.shape == (2, 3, 2, 4)
print(q.shape, k.shape, v.shape)

输入是隐藏状态和一张教学用 fused 权重;输出是拆分、reshape 后的 Q/K/V。代码不含 bias、Q/K norm、量化权重、TP 切分和真实 vLLM 的 packed parameter loader,所以只能证明 shape 与切片关系,不能证明生产 kernel 的布局或性能。

RoPE 为什么作用于 Q、K,而通常不作用于 V

注意力分数来自 $QK^T$,位置信息必须进入“某个 query 与某个 key 的匹配关系”。旋转位置编码(Rotary Position Embedding,RoPE)对位置 $i,j$ 的 Q/K 施加旋转:

\[\tilde q_i=R_iq_i,\qquad \tilde k_j=R_jk_j,\]

从而

\[\tilde q_i^T\tilde k_j=q_i^TR_i^TR_jk_j=q_i^TR_{j-i}k_j.\]

分数自然依赖相对位置 $j-i$。V 是分数确定以后被加权汇总的“内容”;若也旋转 V,输出内容会额外依赖其绝对位置,而且不能仅凭同一组 attention weights 恢复原内容。因此常见 RoPE decoder 对 Q、K 旋转而不旋转 V。这里说的是常见结构,不是所有位置编码方案的定律。

1. 模型处理一个 token,预测的是下一个 token

设 prompt 有 $P=4$ 个 token,需要生成 $G=5$ 个 token $y_1,\ldots,y_5$。prefill 处理完整 prompt 后直接产生 $y_1$;之后处理 $y_1$ 才产生 $y_2$。

模型处理的输入 新得到的输出
4 个 prompt token $y_1$
$y_1$ $y_2$
$y_2$ $y_3$
$y_3$ $y_4$
$y_4$ $y_5$

请求在 $y_5$ 返回后结束,不必再把 $y_5$ 输入模型,否则得到的是并不需要的 $y_6$。因此缓存路径实际处理的位置数是

\[P+(G-1)=4+4=8.\]

没有缓存时,为生成每个输出都要重新送入完整已有前缀,总位置数是

\[\sum_{g=0}^{G-1}(P+g) =GP+\frac{G(G-1)}2 =4+5+6+7+8=30.\]

若用稠密 attention score 数量做教学估算,无缓存为

\[4^2+5^2+6^2+7^2+8^2=190,\]

缓存路径则是一次 $4\times4$ prefill,加上四个新 query 分别读取长度 5、6、7、8 的历史:

\[4^2+(5+6+7+8)=42.\]

这些数字说明依赖规模,不等同于 profiler 的真实 FLOPs、HBM byte 或延迟。高性能 causal kernel 不一定物化完整方阵。

2. 每层究竟缓存什么

对普通 MHA/GQA decoder,一层的逻辑 KV shape 可以写成:

K: [tokens, num_kv_heads, head_dim]
V: [tokens, num_kv_heads, head_dim]

设:

  • $L$:需要缓存的层数;
  • $T$:缓存 token 数;
  • $N_{kv}$:KV head 数;
  • $D$:每个 head 的维度;
  • $e$:每个元素的 byte 数。

普通 KV payload 为

\[C_{KV}=2LTN_{kv}De.\]

最前面的 2 代表 Key 和 Value。以 28 层、8 个 KV heads、head dimension 128、BF16 为例,每 token 为

\[2\times28\times8\times128\times2 =114688\ \text{bytes} =112\ \text{KiB}.\]

4096 tokens 的理论 payload 是 448 MiB。这仍未包含 block 对齐、scale、allocator、graph buffer 和 workspace。

3. MHA、GQA、MQA 改变的是 $N_{kv}$

Query heads 与 KV heads 不必相等:

结构 KV heads KV payload 特征
MHA 通常 $N_{kv}=N_h$ 每个 query head 有独立 K/V
GQA $1<N_{kv}<N_h$ 多个 query heads 共享一组 K/V
MQA $N_{kv}=1$ 所有 query heads 共享一组 K/V

若 query heads 为 16,而三种配置的 $N_{kv}$ 分别为 16、8、1,在其余条件相同时 payload 比例是 $16:8:1$。这只是容量比例,不是延迟或吞吐比例;query 行数、投影、通信和 kernel 路径没有按同一比例消失。

也不能普遍用 hidden size $H$ 代替 $N_{kv}D$。某个模型恰好满足二者相等,只是配置巧合;GQA 模型常有 $N_{kv}D<H$。

以 $B=2,T=128,N_q=16,D=64$ 为例:

结构 $N_{kv}$ Q shape K/V shape 每个 KV head 服务几个 Q heads
MHA 16 [2,128,16,64] [2,128,16,64] 1
GQA 4 [2,128,16,64] [2,128,4,64] 4
MQA 1 [2,128,16,64] [2,128,1,64] 16

计算时可把 KV heads 逻辑广播到对应的 query groups,但缓存里没有必要真的复制为 $N_q$ 份。缓存容量因此跟 $N_{kv}$ 相关,而 attention 输出仍有 $N_q$ 个 heads。

3.1 Prefill、decode 与缓存写入的完整时间线

对长度为 $P$ 的 prompt,prefill 一次产生 $P$ 个位置的 Q/K/V。causal mask 使位置 $i$ 只能读到 $0..i$;所有层的 K/V 被写入缓存,最后一个 prompt 位置的 logits 用来采样 $y_1$。

下一轮 decode 只把 $y_1$ 的 hidden state 送过各层:

本层输入 x_new [B,1,H]
  → Q_new [B,1,N_q,D]
  → K_new,V_new [B,1,N_kv,D]
  → 对 Q_new/K_new 应用当前位置的 RoPE
  → 把 K_new,V_new 写入该请求的新 slot
  → Q_new 读取 K_cache,V_cache 的全部可见历史
  → attention output [B,1,N_q,D]
  → output projection + residual + MLP

无缓存与有缓存的教学伪代码分别是:

# 教学伪代码:无缓存。每一轮重新处理完整 prefix。
tokens = prompt[:]
for _ in range(max_new_tokens):
    logits = model.forward(tokens)       # 长度 P, P+1, P+2, ...
    new_token = sample(logits[-1])
    tokens.append(new_token)

# 教学伪代码:有缓存。prefill 后每轮只投影一个新位置。
logits, cache = model.prefill(prompt)    # 写入 prompt 的 K/V
tokens = prompt[:]
for step in range(max_new_tokens):
    new_token = sample(logits[-1])
    tokens.append(new_token)
    if step + 1 == max_new_tokens:
        break                            # 最后输出不用再送回模型
    logits, cache = model.decode(new_token, cache)

输入都是 prompt 和最大输出长度;输出是生成 token。第二段的长期状态是逐层 cache,shape 从每层 [P,N_kv,D] 增长到 [P+step,N_kv,D]。它对应前文的 $P+G-1$ 位置计数。伪代码省略 batch、EOS、采样参数、分页 slots、推测 token、抢占和分布式通信,不能用于测量真实加速比。

4. 多卡时必须计算 rank-local KV

分布式部署中,真正占用某张卡的是本 rank 保存的层和 KV heads:

\[C_{rank}=2L_{local}T_{local}N_{kv,local}De.\]

Tensor Parallelism(TP)通常切分同一层的 heads。若 $N_{kv}\ge p$ 且可整除,TP size 为 $p$ 时每 rank 保存 $N_{kv}/p$ 个 KV heads。但当 TP ranks 多于 KV heads 时,一个 head 不能被切成零点几份,具体实现可能复制 KV heads,使每 rank 至少有一组。

以 8 个 KV heads 为例,vLLM v0.20.0 的 Qwen3 路径可概括为:

TP 每 rank KV heads BF16 KiB/token(28 层、$D=128$)
1 8 112
2 4 56
4 2 28
8 1 14
16 1(复制) 14

因此“所有显存都除以 TP size”并不可靠。

Pipeline Parallelism(PP)切的是层,所以公式中的 $L$ 应换成本 stage 的 $L_{local}$。Data Parallelism(DP)通常复制服务实例并把不同请求分给 replicas,不会自动把单请求 KV 分散到两个副本,也不会把不同副本的空闲 block 合成一个共享池。

global rank、local rank 与 TP rank

rank 是参加分布式计算的进程编号,常见部署是一 worker process 管一张 GPU,但概念上二者仍不同:

  • global rank:整个作业中的编号,范围 0..world_size-1;
  • local rank:当前机器上的编号,常用于选择本机 GPU;
  • TP rank:当前 tensor-parallel group 内的编号;
  • PP rank:当前 pipeline 中的 stage 编号;
  • DP rank:当前 replica 编号。

例如 DP=2、每个副本 TP=4 时,global ranks 可以是 [0,1,2,3] 和 [4,5,6,7] 两个 TP group。global rank 5 的 TP rank 是 1,而不是 5。若再引入 PP,不能再用 GPU 编号猜它保存哪些层或 heads。

PP 传的是 stage 间 hidden states,而不是把前一 stage 的 KV 搬到后一 stage:

token → PP stage 0(本地层 + 本地 KV)
      → hidden states [scheduled_tokens,H]
      → PP stage 1(本地层 + 本地 KV)→ logits

vLLM 推理只做前向,不做训练反向传播。训练时才会把 $\partial loss/\partial h$ 沿 PP 反向传回,并在 DP group 同步参数梯度;这不属于 KV Cache serving 主线。

下面的容量计算器把全局配置转换成每 rank 的 payload:

from math import ceil

def local_kv_heads(total_kv_heads: int, tp: int) -> int:
    if total_kv_heads >= tp:
        assert total_kv_heads % tp == 0
    else:
        assert tp % total_kv_heads == 0
    return max(1, total_kv_heads // tp)

def kv_bytes(*, layers, tokens, kv_heads, head_dim,
             bytes_per_elem=2, tp=1, pp=1, block_size=None):
    assert layers % pp == 0
    local_layers = layers // pp
    local_heads = local_kv_heads(kv_heads, tp)
    allocated_tokens = tokens
    if block_size is not None:
        allocated_tokens = ceil(tokens / block_size) * block_size
    per_rank = (2 * local_layers * allocated_tokens
                * local_heads * head_dim * bytes_per_elem)
    return {
        "local_layers": local_layers,
        "local_kv_heads": local_heads,
        "allocated_tokens": allocated_tokens,
        "bytes_per_rank": per_rank,
    }

qwen = kv_bytes(layers=28, tokens=4096, kv_heads=8,
                head_dim=128, tp=4, pp=2, block_size=16)
assert qwen["local_layers"] == 14
assert qwen["local_kv_heads"] == 2
assert qwen["bytes_per_rank"] == 56 * 1024**2  # 56 MiB
print(qwen)

输入是模型结构、token 数、dtype byte、TP/PP 和可选 block size;输出是本 rank 的层数、heads、取整 slots 和 payload。状态不发生变化。公式就是 $2L_{local}T_{allocated}N_{kv,local}De$。真实 vLLM 还会按 cache group/spec、非均匀 PP 层分配、scale、对齐和可用显存建立 pool,因此这只是常规 attention 的容量核算器。

5. 有效 KV、block-rounded KV 与 KV pool

假设 block size 为 16,两个请求长度为 17 和 32:

请求 有效 token 分配 block 分配 slots 末块未用
A 17 2 32 15
B 32 2 32 0
合计 49 4 64 15

这会产生三个不同的容量口径:

有效 KV:           49 × 每 token byte
block-rounded KV:  64 × 每 token byte
KV pool:           引擎启动时预留的全部物理 blocks

请求释放只会让 blocks 回到 vLLM 的空闲队列,整个 pool 未必归还 CUDA。因此一次请求前后的 memory_allocated() 差值不能精确表示该请求的有效 KV。

把前面的 Qwen3 数值代入:每 token 112 KiB,49 个有效 tokens 是

\[49\times112\ \mathrm{KiB}=5488\ \mathrm{KiB}\approx5.36\ \mathrm{MiB},\]

而 64 个已分配 slots 是 7 MiB,末块 15 个空 slots 占约 1.64 MiB。KV pool 可能是数 GiB;“池已占显存”“某请求已获 block”“slot 已写入有效 K/V”是三个不同状态。

6. KV Cache 省了什么,没省什么

第 $g$ 个 decode step 的逻辑 shape 仍近似为:

Q_new: [batch, num_query_heads, 1, head_dim]
K/V:   [batch, num_kv_heads, history, head_dim]

新 query 仍要读取可见历史 K/V。于是 KV Cache 的收益与成本应分开记账:

  • 省掉:旧 token 的 RMSNorm、QKV/输出投影、MLP 与旧 query attention 的重复执行;
  • 保留:当前 query 对历史 K/V 的读取与 attention;
  • 新增:长期 KV 容量、prefill/decode 写入、block 管理与正确性约束;
  • 可能出现的新瓶颈:HBM、kernel launch、CPU 调度或跨卡通信。

$G=1$ 时只有一次 prefill,没有后续 step 可复用,缓存路径在计算位置数上没有优势。长上下文与高并发会放大重算收益,也同时放大 KV 容量和带宽压力。

再看一个长序列例子:$P=2048,G=128$,无缓存的投影位置总数为

\[128\times2048+\frac{128\times127}{2}=270272,\]

有缓存则为 $2048+127=2175$,相差约 124.26 倍。这不是 124.26 倍 wall-clock 加速:decode 的窄 GEMM、每层权重读取、历史 KV 的 HBM 流量、调度、IPC、collective 和 kernel launch 都还存在。

特殊解码的 cache 生命周期

  • beam search:分叉前的 blocks 可共享,分叉后每条 beam 独立增长;引用计数必须避免过早释放共享前缀;
  • speculative decoding:候选 token 可暂时产生 KV,最终只提交被接受的前缀,并丢弃/回滚其余候选;
  • sliding window:逻辑上只读最近窗口,但物理 block 能否立刻回收取决于 cache spec 与 backend;
  • prompt logprobs:可能要求保留/计算不同位置的 logits,不能套用只取最后位置的最小时间线;
  • sequence continuation:若服务要为下一次续写保留最后输出的已计算状态,停止边界可能从 $P+G-1$ 变为 $P+G$。

四种容易混淆的 KV 技术

技术 复用范围 数据位置变化 主要目标
普通 KV Cache 同一请求的后续 decode 通常不变 避免历史重算
APC 兼容请求之间的完整前缀块 通常不变 跳过命中前缀 prefill
KV offload 同一 cache 生命周期 GPU 与 CPU/慢层级之间 换取 GPU 容量
KV transfer / P-D 分离 prefill 节点到 decode 节点 跨进程/节点传输 避免 decode 节点重算 prefill

关闭 APC 不会关闭普通 KV Cache;APC 命中也不会缓存最终答案。

7. 公式的适用边界

公式 $2LTN_{kv}De$ 针对普通、每层显式保存 K/V 的 attention。以下情况应先查看模型配置和 vLLM 生成的 cache spec:

  • MLA 缓存压缩 latent 与位置相关分量;
  • sliding-window 层只需逻辑可见窗口,但物理回收依赖 backend;
  • hybrid model 可能混合 full、sliding 与 state-space cache;
  • encoder-decoder 还有 cross-attention KV;
  • KV 量化带有额外 scale;
  • offload、transfer 和 P/D disaggregation 改变存储位置与传输成本。

常见误区

  • “KV Cache 把生成从 $O(n^2)$ 变成 $O(n)$”:必须说明对哪个阶段、变量和成本项而言。
  • “GQA 的 KV 小四倍,所以服务快四倍”:容量比例不能直接变成速度比例。
  • “TP=8,所以单卡 KV 一定除以 8”:KV heads 不足时可能复制。
  • “关闭 APC 就关闭了 KV Cache”:APC 是跨请求前缀复用,普通自回归 KV Cache 仍然存在。

源码阅读入口(v0.20.0)

  • vllm/model_executor/models/qwen3.py:全局与本地 query/KV heads;
  • vllm/v1/kv_cache_interface.py:不同 attention 类型的 cache spec;
  • vllm/config/cache.py:cache dtype 与容量配置;
  • vllm/v1/core/kv_cache_manager.py:请求如何取得和释放物理 blocks。

本篇总结

KV Cache 的正确心智模型不是“保存所有中间结果”,而是每层保存可供未来 query 使用的历史 K/V。它显著减少历史位置的重复层计算,但把系统推向新的容量、带宽和生命周期问题;PagedAttention、Prefix Caching 与 Scheduler 正是对这些问题的进一步回答。


系列首页 · 下一篇:vLLM V1 架构

资料