本文是《大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术》系列的第 5 篇(共十五篇)。上一篇:Scheduler:GPU 这一轮到底给谁用?;下一篇:GPU 执行:如何让每个 Token 算得更快?。
NOTE 本文基于 vLLM v0.27.1(tag
6e448d0, 2026-08-11)源码剖析。文中文件路径、类名和函数名均以该版本为准;vLLM 迭代很快,阅读时请以你手上的版本对照。
设想一个场景:
你有一张 80 GB 的卡,同时来了 100 个请求。第一个请求最后只生成了 300 个 token,第二个生成了 3000 个,第三个一路写到 20K。问题在于:这三个数字,你在请求到达的那一刻一个都不知道。
如果按传统做法,给每个请求划一块连续显存来放它的 KV Cache,那你只能按”最坏情况”预留——按模型支持的最大长度划。于是那个只生成 300 token 的请求,占着一块够装 32K token 的地。100 个请求这么一摊,卡就满了,尽管真正装了有效数据的可能不到三成。
显存不是被模型吃掉的,是被”不确定性”浪费掉的。
PagedAttention 的核心思想,用一句话就能说完:
别给请求整块连续空间。把 KV Cache 切成固定大小的小块,用多少申请多少,块与块之间不要求相邻。
这样”预留”就消失了——因为不再需要预判总长度,只需要在写满当前块时再要一块。这个思路你大概率见过:它就是操作系统的虚拟内存分页。
本篇的核心问题是:
这些请求已经计算过的历史状态(KV Cache),应该放在哪里、如何复用、何时释放,才能不让”不确定性”吃掉显存?1
一、总览:从分页管理到瘦身
1. 三个层面的问题
下面我们从传统做法的具体代价讲起,再看这套页表思想是怎么被搬到 GPU 上的:先看 PagedAttention 的数学本质与 vLLM 中对应的数据结构;再沿一次请求的生命周期看 KV 块如何被分配、写入、读取和归还;最后回答”KV Cache 还能更小吗”——系统管理层的 Prefix Cache 复用、模型架构层的 GQA / MLA 瘦身、数值层的量化压缩,以及显存之外的分层存储。这三层解决的是不同的问题,可以叠加。
2. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | PagedAttention 的数学本质与源码实现 | 碎片灾难、页表思想的映射、核心数据结构关系 |
| 三 | KV Cache 的写入、读取与生命周期 | 从 Prefill 批量写入、Decode 逐 slot 追加到完成/抢占时归还 |
| 四 | KV Cache 还能更小吗 | Prefix Cache、GQA/MQA、MLA、KV 量化、Offloading 与 Swapping |
| 五 | 本文小结 | |
| 六 | 自测 | 5 道题 |
二、PagedAttention 的数学本质与源码实现
1. 痛点:传统显存分配的碎片灾难
在 PagedAttention 出现之前,每个请求的 KV Cache 必须在 GPU 显存中预分配一段连续内存,其长度等于模型支持的最大序列长度。这造成了两类碎片:
GPU HBM (80 GB),每个请求按 max_len=2048 预留连续空间
┌──────────────────────────────────────────────────────────┐
│ Req A ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ↑实际用 500 ↑ 内部碎片:1548 tokens 的空间白占 │
│ Req B ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░ 剩余空间凑不出一整段连续的 2048 → Req C 进不来 ░░░ │
│ (外部碎片:总量其实够,但不连续) │
└──────────────────────────────────────────────────────────┘
| 碎片类型 | 成因 | 后果 |
|---|---|---|
| 内部碎片 | 按最大长度预留,实际用不了那么多 | 主要浪费来源 |
| 外部碎片 | 已释放的空间不连续 | 总量够却无法分配给新请求 |
总浪费率有多大?PagedAttention 论文(SOSP’23)测得当时的 SOTA 系统中,真正存放有效 KV 的显存只占 20.4% ~ 38.2%——也就是约 60% ~ 80% 被碎片和预留吃掉了。
2. 页表思想的映射:操作系统虚拟内存在推理系统中的重现
PagedAttention 的灵感直接来自操作系统的虚拟内存管理。核心思想是将连续的逻辑地址空间映射到不连续的物理页框:
| 操作系统虚拟内存 | PagedAttention | |
|---|---|---|
| 连续的逻辑视图 | 虚拟地址空间(Page 0,1,2,3…) | 逻辑 token 序列(Blk 0,1,2…,每块 16 个 token) |
| 映射表 | Page Table(VP → PF) | Block Table(虚拟块 → 物理块) |
| 不连续的物理载体 | 物理页框 Physical Frame | 物理 KV 块 Physical Block(GPU HBM 上) |
| 分配单位 | 一页(如 4 KB) | 一块(block_size 个 token 的 K/V) |
| 好处 | 进程看到连续内存,实际零散存放 | 请求看到连续序列,KV 实际零散存放 |
映射关系是这样的——注意物理块号完全不需要连续:
%% 图:页表思想的映射:逻辑块连续,Block Table 把它们映到不连续的物理 KV 块
graph LR
subgraph LOG["逻辑视图(连续)"]
B0["Blk 0<br/>t₁–t₁₆"] --- B1["Blk 1<br/>t₁₇–t₃₂"] --- B2["Blk 2<br/>t₃₃–t₄₈"]
end
subgraph BT["Block Table"]
T["VB0 → PB7<br/>VB1 → PB13<br/>VB2 → PB21"]
end
subgraph PHY["物理 KV 块(不连续)"]
P7["PB 7"]
P13["PB 13"]
P21["PB 21"]
end
B0 --> T
B1 --> T
B2 --> T
T --> P7 & P13 & P21
在 vLLM 中,每个物理块(Physical Block)存储固定数量 token 的 K 和 V 张量:
# KV Cache 物理布局 (单层)
# shape: [num_physical_blocks, block_size, num_kv_heads, head_dim]
# 例: [2048 blocks, 16 tokens/block, 8 heads, 128 dim]
kv_cache = torch.zeros(num_blocks, block_size, num_kv_heads, head_dim,
dtype=torch.float16, device="cuda")
3. 源码解密:核心数据结构关系
%% 图:KV Cache 核心数据结构关系:Request → KVCacheBlocks → KVCacheBlock → GPU HBM 上的 kv_cache[block_id]
graph TD
R["<b>Request</b>(逻辑层,不碰显存)<br/>request_id / prompt_token_ids / output_token_ids<br/>num_computed_tokens / block_hashes / status"]
R -->|"1:N,经 KVCacheManager"| KB
KB["<b>KVCacheBlocks</b><br/>blocks: tuple[Sequence[KVCacheBlock], …]<br/>外层 = KV Cache Group,内层 = 该 Group 的物理块序列"]
KB --> B
B["<b>KVCacheBlock</b>(物理块元数据)<br/>block_id / ref_cnt / block_hash<br/>prev_free_block ⇄ next_free_block(FreeBlockQueue 双向链表)"]
B -->|"block_id 索引"| H
H[("<b>GPU HBM</b><br/>kv_cache[block_id]<br/>[block_size, num_kv_heads, head_dim]")]
顺着箭头从上到下跟踪:Request 只是逻辑层,不直接触及 GPU 显存;KVCacheBlock 才是物理块的元数据,它同时挂在 Request 的 blocks 列表和 BlockPool 的 free/cached 列表里;block_id 最终索引到 GPU HBM 中的一片连续显存,里面放的是一整块 block_size 个 token 的 K 和 V。这三层映射是理解后面 Prefix Cache 复用和 Preemption 释放的基础。
回到我们的例子:2050 个 prompt token,
block_size=16,于是需要⌈2050/16⌉ = 129个块(前 128 块装满 2048 个 token,第 129 块只装 2 个)。生成完 300 个 token 后,序列长 2350,共占 147 个块。注意一个巧合般的细节:2000 个 token 的 system prompt 恰好是 125 个整块。这不是偶然设计,但它揭示了 Prefix Cache 的一个硬约束——只有装满的块才会被缓存,所以可复用的边界永远对齐到
block_size。第四章的 Prefix Cache 一节就用这一点算账。
BlockPool(vllm/v1/core/block_pool.py)管理所有物理块的分配和回收。它的设计有一个容易看漏的关键点:没有单独的”缓存块”集合。所有 ref_cnt == 0 的块——无论是从未用过的空块,还是刚被请求释放、但仍带着 block hash 的”缓存块”——都挂在同一条 free_block_queue 双向链表上,按释放时间排序。带 hash 的块同时还被 cached_block_hash_to_block 索引着,供 Prefix Cache 查找。
# vllm/v1/core/block_pool.py (简化)
class BlockPool:
def __init__(self, num_gpu_blocks: int, enable_caching: bool, ...):
self.free_block_queue = FreeKVCacheBlockQueue(num_gpu_blocks) # 所有 ref_cnt==0 的块,LRU 序
self.cached_block_hash_to_block = BlockHashToBlockMap() # hash → 块(Prefix Cache 索引)
def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
# 不够就直接报错——调用方(KVCacheManager.allocate_slots)在调用前已确认数量足够
if num_blocks > self.get_num_free_blocks():
raise ValueError(...)
ret = self.free_block_queue.popleft_n(num_blocks) # 从链表头取:最久未用的先被拿走
for block in ret:
self._maybe_evict_cached_block(block) # 若这块还挂着 hash,摘掉 → 这就是"驱逐"
block.ref_cnt += 1
return ret
def free_blocks(self, ordered_blocks):
# ref_cnt--,归零则放回链表:没 hash 的块插到头部(先被分走),
# 有 hash 的块追加到尾部(尽量保留);hash 不清除,块仍可被 Prefix Cache 命中
...
def touch(self, blocks):
# Prefix Cache 命中:把 ref_cnt==0 的块从空闲链表摘出来复用,ref_cnt++
...
def cache_full_blocks(self, request, blocks, ...):
# 将满块的 hash 注册到 cached_block_hash_to_block
...
于是 LRU 驱逐不是一个显式步骤,而是分配的副作用:空闲链表头部的块就是最久没被访问的块,把它分出去时顺手摘掉它的 hash,这块缓存就”被驱逐”了。反过来,Prefix Cache 命中一个 ref_cnt == 0 的块时,touch() 把它从链表中摘出——命中即续命。这套设计让”空闲块”和”缓存块”共享同一份容量:缓存永远填满所有当前没在用的显存,且不需要任何后台淘汰线程。
用一个 6 块的小池子把这四个动作(get_new_blocks / free_blocks / 驱逐 / touch)在链表上走一遍,就能看清”空闲”和”缓存”是怎么共享同一条队列的:
free_block_queue:只放 ref_cnt==0 的块。head = 最先被分走;tail = 最近释放
[n*] 表示该块仍挂着 block_hash(可被 Prefix Cache 命中)
T0 启动,6 块全空闲
head → [0][1][2][3][4][5] ← tail
T1 Req A get_new_blocks(3):从 head 依次 popleft
head → [3][4][5] ← tail A 持有 0,1,2(ref_cnt=1)
T2 Req A 完成,free_blocks(reversed(blocks))
块 0,1 写满、带 hash → append 到 tail(按块序反向:1 先入,0 后入)
块 2 未写满、无 hash → prepend 到 head(最先被淘汰)
head → [2][3][4][5][1*][0*] ← tail
↑ ↑ 序列末尾的块排在前缀块之前 → 先被淘汰
T3 Req C get_new_blocks(5):popleft 2,3,4,5 和 1*
分到 1* 时 _maybe_evict_cached_block 摘掉它的 hash —— 这就是"驱逐"
head → [0*] ← tail C 持有 2,3,4,5,1(ref_cnt=1)
T4 Req B 到来,前缀与 A 相同:
块 0 hash 命中 → touch():从链表摘出,ref_cnt 0→1(续命,无需重算)
块 1 hash 已被摘 → 未命中 → 需 get_new_blocks(1),但链表已空 → 等待
head → (空) ← tail B 持有 0
T2 里有个容易忽略的细节:KVCacheManager.free() 是把请求的块反序交给 free_blocks() 的,于是同一个请求里越靠后的块越靠近 head、越先被淘汰,而前缀块尽量留到最后——这和 Prefix Cache “命中总是从前缀开始”的访问模式是配套的。
块分配的布局(引自 allocate_slots() 注释):
|<──── computed ────>|<─ new_computed ─>|<─ external ─>|<── new ──>|<─ lookahead ─>|
|<── to be computed ──────>|
|<────────── to be allocated ──────>|
|<────────── to be cached ──────────>|
computed: 之前已缓存在 KV Cache 中的 tokens(Prefix Cache 命中)
new_computed: 本轮新计算但已完成的 tokens
external: 从远端传输的 KV 块(PD 分离场景)
new: 需要本轮计算的新 tokens
lookahead: 为 Speculative Decoding 预留的额外 tokens
这个布局也揭示了 Scheduler 为什么不能按请求类型硬分类。一轮迭代中一个请求可能同时覆盖 computed(prefix cache 命中)、new(需要新计算)和 lookahead(spec decode 预留),不同请求在这个轴上的位置各不相同。token 预算模型统一处理这些区间,而不是按”prefill 请求”和”decode 请求”分开。
三、KV Cache 的写入、读取与生命周期
上一章讲的是「块从哪来」,这一章讲「块怎么被用完再还回去」——一次请求从 Prefill 批量写入,到 Decode 逐 slot 追加,最后在完成或被抢占时归还,构成 KV Cache 的完整生命周期。
KV Cache 生命周期全景
┌─────── Prefill 阶段 ──────┐ ┌────── Decode 阶段 ──────┐
│ │ │ │
│ prompt tokens: │ │ 每步 1 个新 token: │
│ [t₁, t₂, ..., tₙ] │ │ [tₙ₊₁], [tₙ₊₂], ... │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ┌─────────────────┐ │ │ ┌──────────┐ │
│ │ 批量写入 KV │ │ │ │ 增量追加 │ │
│ │ Cache 块 │ │ │ │ 1 个 slot │ │
│ │ │ │ │ │ │ │
│ │ Block 0: [t₁~t₁₆]│ │ │ │ Block 2: │ │
│ │ Block 1: [t₁₇~t₃₂]│ │ │ │ 追加 tₙ₊₁ │ │
│ │ Block 2: [t₃₃~tₙ] │ │ │ └──────────┘ │
│ └─────────────────┘ │ │ │
└────────────────────────────┘ └──────────────────────────┘
┌─────── Attention 读取 ──────────────────────────────────┐
│ │
│ Block Table (虚拟→物理映射): │
│ req_42 → [PhyBlock_7, PhyBlock_13, PhyBlock_21] │
│ │
│ Attention Kernel 通过 Block Table 索引读取: │
│ for each query position: │
│ for each block in block_table[req]: │
│ K_block = kv_cache[block_id, :, :key_heads, :] │
│ V_block = kv_cache[block_id, :, :val_heads, :] │
│ score += Q @ K_blockᵀ │
│ attn_out = softmax(scores) @ V_blocks │
└─────────────────────────────────────────────────────────┘
┌─────── 生命周期终结 ────────────────────────────────────┐
│ │
│ 请求完成: │
│ Scheduler → KVCacheManager.free(request) │
│ → ref_cnt-- 对所有块 │
│ → ref_cnt == 0 的块归还 free_block_queue │
│ → 有 block_hash 的块进入 LRU 缓存(Prefix Cache) │
│ → 无 hash 的块立即回收 │
│ │
│ 抢占: │
│ → 释放所有块 │
│ → num_computed_tokens = 0 (需从头重算) │
│ → 但 Prefix Cache 命中可跳过部分重算 │
└─────────────────────────────────────────────────────────┘
① 写入——两个阶段的写法完全不同:
| 阶段 | 写入方式 | 例子 |
|---|---|---|
| Prefill | 批量写入若干整块 | Block 0: t₁~t₁₆、Block 1: t₁₇~t₃₂、Block 2: t₃₃~tₙ |
| Decode | 每步增量追加 1 个 slot | Block 2 尾部追加 tₙ₊₁,写满了才要新块 |
② 读取——Attention Kernel 不认识”请求”,只认 Block Table:
Block Table: req_42 → [PB_7, PB_13, PB_21]
for each query position:
for block_id in block_table[req]:
K_block = kv_cache[block_id, :, :key_heads, :]
V_block = kv_cache[block_id, :, :val_heads, :]
scores += Q @ K_blockᵀ
attn_out = softmax(scores) @ V_blocks
③ 归还——这一步决定了块能不能被别人复用:
| 触发 | 动作 | 关键后果 |
|---|---|---|
| 请求完成 | KVCacheManager.free(request):所有块 ref_cnt-- |
ref_cnt == 0 才真正归还 free_block_queue |
↳ 块有 block_hash |
进入 LRU 缓存 | 留给 Prefix Cache 复用 |
| ↳ 块无 hash(未写满) | 立即回收 | 无法复用 |
| 被抢占 | 释放所有块 + num_computed_tokens = 0 |
需重算,但 Prefix Cache 命中可跳过大部分 |
注意倒数第二行:块只有”写满”才会被缓存。这解释了为什么 Prefix Cache 的命中粒度是 block_size,而不是单个 token。
上表里的 ref_cnt-- 之所以要”归零才真正归还”,是因为一个物理块可能同时被多个请求持有。沿着贯穿全文的例子,跟踪那 125 个 system prompt 块的引用计数怎样随事件变化:
| 时刻 | 事件 | 125 个前缀块的 ref_cnt |
块此刻在哪 |
|---|---|---|---|
| ① | Req A 到达,allocate_slots() → get_new_blocks() |
0 → 1 | 从 free_block_queue 头部弹出,归 A |
| ② | A 的 Prefill 写满这些块 → cache_full_blocks() |
1 | hash 注册进 cached_block_hash_to_block |
| ③ | Req B 到达,get_computed_blocks() 命中 → touch() |
1 → 2 | 同一份物理块被 A、B 共享,不复制 |
| ④ | A 完成(或被抢占),free() |
2 → 1 | 不归还——B 还在用 |
| ⑤ | B 完成,free() |
1 → 0 | 追加到 free_block_queue 尾部,hash 保留,仍可命中 |
| ⑥a | Req C 带同样 system prompt 到来 → touch() |
0 → 1 | 从链表中间摘出,缓存续命 |
| ⑥b | 或者 Req D 要新块,get_new_blocks() 弹到它 |
0 → 1 | _maybe_evict_cached_block() 摘掉 hash,缓存被驱逐 |
也就是说 ref_cnt 只回答”有几个请求正在用”,块是否可复用由 hash 是否还在决定;两者独立,这是第四章 Prefix Cache 一节的前提。
四、KV Cache 还能更小吗:复用、压缩与分层存储
在进入具体手段之前,先立一个分层框架——这三层解决的是完全不同的问题,不应该混为一谈:
| 层级 | 手段 | 解决的问题 |
|---|---|---|
| 系统管理层 | PagedAttention、Prefix Cache | 已经要存这么多,显存怎么管才不浪费 |
| 模型架构层 | MQA / GQA / MLA | 本来到底需要存多少 |
| 数值层 | FP8 / INT8 量化 | 每个 KV 元素占几个字节 |
三者是正交的,可以叠加:MLA 减少了要存的量,PagedAttention 管理这些量的摆放,FP8 再把每个元素压小。下面按这个顺序展开。
1. Prefix Cache:重复计算复用
在生产环境中,大量请求共享相同的 System Prompt(如 ChatGPT 的系统指令可能占 2000+ tokens)。Prefix Cache 的核心思想是:如果两个请求的前缀 token 完全相同,它们可以共享同一份 KV Cache 块。
%% 图:Prefix Cache 的复用:Request A prefill 后注册 125 个满块,Request B 经链式哈希全部命中,只需 prefill 最后一块
sequenceDiagram
participant A as Request A<br/>[SysPrompt 2000] + "Hi"
participant P as BlockPool<br/>(hash → block)
participant B as Request B<br/>[SysPrompt 2000] + "Bye"
Note over A,P: 链式哈希:每块的 hash 依赖前驱块
A->>P: Prefill 2000 tokens<br/>Blk0=hash(t₁…t₁₆)=0xABC1<br/>Blk1=hash(0xABC1, t₁₇…t₃₂)=0xDEF2 …
P-->>P: 125 个满块全部注册进缓存
B->>P: get_computed_blocks()
P-->>B: 0xABC1 命中 → 0xDEF2 命中 → … 125 块全中(ref_cnt++)
Note over B: 只需 Prefill "Bye" 那 1 个块<br/>省下 2000 tokens 的计算 + 一整份 KV 显存
vLLM 使用链式哈希确保前缀匹配的正确性——每个块的哈希值依赖其前驱块的哈希,因此只有完全相同的前缀序列才会产生相同的哈希链。下图把三种情况摆在一起:B 与 A 共享前缀(命中 + 分叉),C 只在第一个 token 上与 A 不同(全部未命中):
链式哈希:h_i = hash(h_{i-1}, 本块 16 个 token, extra_keys),h_0 前驱 = NONE_HASH
查找:逐块沿链比对 hash,第一次未命中即停止,之后的块全部重算
Blk 0 Blk 1 ... Blk 124 Blk 125
Req A h0=hash(NONE, h1=hash(h0, h124=hash(h123, "Hi" 未写满
t1..t16) t17..t32) ...) → 不参与 hash
│0xABC1 │0xDEF2 │0x77F0 │
▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│PB 7 │ │PB 13 │ ... │PB 91 │ │PB 40 │ A 私有
│ref=2 │ │ref=2 │ │ref=2 │ │ref=1 │
└──────┘ └──────┘ └──────┘ └──────┘
▲ ▲ ▲ ┌──────┐
│命中 │命中 │命中 │PB 55 │ B 私有
Req B h0 相同 ───────► h1 相同 ──── ... ──► h124 相同 ─────►│ref=1 │ "Bye"
125 块共享 └──────┘ 分叉
Req C h0'=hash(NONE, t1',t2..t16) ≠ h0 → 未命中,查找停止
即使 t2..t2000 与 A 完全相同,h1'=hash(h0',…) 也全部不同 → 一块都复用不了
由此可见”命中”不是单块比对,而是从链头开始的一段连续匹配:分叉点之前的块共享同一份物理块(ref_cnt 累加),分叉点及之后各自申请新块;而链头一旦不同,后面再像也没用——这正是链式哈希保证正确性的方式,代价是命中的粒度只能是”前缀”。
回到我们的例子:那 2000 token 的 system prompt 是 125 个整块。第一个请求跑完后它们全部进入缓存;第二个请求带着同样的 system prompt 到来时,这 125 块全部命中,只需要 prefill 用户那 50 个 token。
省下多少?按第一篇「一个贯穿全文的例子」的量算:
- 计算:2000 token 的 prefill 不用做了,TTFT 从约 92 ms 掉到 5 ms 量级
- 显存:这 125 块(约 625 MB 的 KV)在两个请求间共享同一份物理块,靠
ref_cnt计数,不是复制在真实的多租户服务里,system prompt 往往被成百上千个请求共享——这就是为什么 Prefix Cache 是性价比最高的优化之一。
# vllm/v1/core/kv_cache_utils.py (签名照抄, 函数体简化)
def hash_block_tokens(
hash_function: Callable[[Any], bytes],
parent_block_hash: BlockHash | None,
curr_block_token_ids: Sequence[int],
extra_keys: tuple[Any, ...] | None = None,
) -> BlockHash:
"""链式哈希:当前块哈希 = f(前驱块哈希, 本块 token ids, 额外键)"""
if not parent_block_hash:
parent_block_hash = NONE_HASH # 首块的哈希起点
return BlockHash(
hash_function((parent_block_hash, tuple(curr_block_token_ids), extra_keys))
)
这个签名里有两个细节值得留意,它们不是实现噪音:
- 哈希函数是注入进来的,不是 Python 内建的
hash(),返回值也是 bytes 而非 int(可选sha256_cbor、xxhash_cbor等)。因为块哈希要在多个 worker、甚至跨节点(PD 分离、LMCache)之间对得上,必须可控且可复现。 - 链条起点
NONE_HASH默认是随机的。init_none_hash()在未设置PYTHONHASHSEED时取os.urandom(32),即每个进程一个随机起点;只有显式设置PYTHONHASHSEED才会变成确定值。这是一个刻意的安全默认:随机起点让块哈希无法被外部预测,避免跨租户的缓存探测;而想让多进程/多节点共享同一份 prefix cache,就必须放弃这个默认。可复现性和不可预测性在这里是一对取舍,vLLM 默认选了后者。 extra_keys是隔离用的。 同一串 token 在不同 LoRA adapter、不同多模态输入、不同cache_salt、不同 prompt embeds 下不能复用同一份 KV,这些维度都由generate_block_hash_extra_keys()收集进extra_keys。也就是说,”前缀相同”的判定比”token 序列相同”严格——这是 prefix cache 的正确性边界。
2. GQA / MQA:模型结构级 KV Cache 瘦身
KV Cache 的大小与 KV head 数量成正比。Grouped-Query Attention (GQA) 和 Multi-Query Attention (MQA) 通过减少 KV head 数来缩减 KV Cache:
以 8 个 Query head 为例,三种变体的差别只在于几个 Q head 共享一份 K/V:
| 变体 | Q heads | K/V heads | 每 token 每层 KV 大小 | 相对 MHA |
|---|---|---|---|---|
| MHA | 8 | 8(一对一) | 2 × L × S × H × d |
100% |
| GQA | 8 | 2~4(分组共享) | 2 × L × S × G × d |
1/2 ~ 1/8 |
| MQA | 8 | 1(全部共享) | 2 × L × S × 1 × d |
1/8 ~ 1/64 |
MHA Q: [1][2][3][4][5][6][7][8]
K: [1][2][3][4][5][6][7][8] ← 一个 Q 配一个 K/V
GQA Q: [1][2][3][4][5][6][7][8]
K: [ G1 ][ G2 ][ G3 ][ G4 ] ← 每 2 个 Q 共享一份
MQA Q: [1][2][3][4][5][6][7][8]
K: [ K1 ] ← 全部 Q 共享一份
代价是表达能力:K/V head 越少,KV Cache 越小,但模型区分不同注意力模式的自由度也越低。GQA 是目前公认的甜点区——这也是为什么 Llama 3 全系都用 GQA。
| 模型 | 注意力类型 | num_heads | num_kv_heads | KV Cache 比例 |
|---|---|---|---|---|
| GPT-3 175B | MHA | 96 | 96 | 100% |
| Llama 3 70B | GQA | 64 | 8 | 12.5% |
| Llama 3 8B | GQA | 32 | 8 | 25% |
| Falcon 7B | MQA | 71 | 1 | 1.4% |
| DeepSeek V3 | MLA | 128 | - | ~2% (存 576 维 latent,非 128×128 的完整 KV) |
3. MLA:从 KV Cache 到 Latent Cache
DeepSeek V2/V3 提出的 Multi-head Latent Attention (MLA) 是一种更激进的 KV Cache 压缩方案。它不存储完整的 K、V 张量,而是存储一个低维的 latent 向量:
| 传统 MHA / GQA | MLA(DeepSeek) | |
|---|---|---|
| 存的是什么 | K [Hkv, d] + V [Hkv, d] |
c_kv [kv_lora_rank] + k_pe [qk_rope_head_dim] |
| 每 token 每层 | 2 × Hkv × d bytes |
(kv_lora_rank + qk_rope_head_dim) × sizeof(dtype) |
| 实例 | Llama-70B(GQA-8):2×8×128×2B = 4 KB |
DeepSeek V3:(512+64)×2B ≈ 1.1 KB |
MLA 的运作分两步——存的时候压缩,用的时候还原:
%% 图:MLA 的两步:Prefill 时经 kv_a_proj 压成低维 latent 存进 KV Cache,Decode 时经 kv_b_proj 还原全维 K、V
graph LR
subgraph E["编码(Prefill)"]
H[hidden] -->|kv_a_proj| C["c_kv(低维 latent)"] --> KV[("KV Cache<br/>只存 latent")]
end
subgraph D["解码(Decode,朴素做法)"]
KV2[("KV Cache")] --> C2[c_kv] -->|kv_b_proj| KVF["K, V(恢复全维)"] --> AT[Attention]
end
%% 关键:表达 KV Cache 的传递,完美解决排版问题
KV -.->|读取/复用| KV2
但朴素做法有个致命问题:每一步 Decode 都要把全部历史 token 的 latent 解压回全维 K/V,那省下的显存又变成了带宽开销。真正的关键优化叫 “吸收”(Absorbing)——把解压矩阵 W_uk 预先融合进 W_q,于是可以直接在 latent 空间做 Attention,完全不解压。这一手的工程细节留到第 7.4.1 节展开。
压缩效果:相比同规模 MHA 可缩减一个数量级以上;相比 Llama 式 GQA-8(4 KB/token/layer)约缩减 3~4x。
vLLM 中 MLA 的实现位于 vllm/model_executor/layers/mla.py,通过 MLAAttentionSpec 定义其特殊的 KV Cache 规格(存储 latent 而非完整 KV)。
4. KV Cache Quantization:数值压缩与带宽优化
除了结构级的压缩(GQA/MQA/MLA),还可以通过数值量化进一步压缩 KV Cache:
| 格式 | 每元素 | 相对 FP16 | 精度影响 |
|---|---|---|---|
| FP32 | 4 bytes | 2.0× | 基准(训练精度) |
| FP16 / BF16 | 2 bytes | 1.0× | 标准推理精度 |
| FP8 (E4M3) | 1 byte | 0.5× | 轻微损失,实践中最安全的选择 |
| INT8 | 1 byte | 0.5× | 需要校准,按 head / channel 量化 |
| INT4 | 0.5 bytes | 0.25× | 显著损失,很少用于 KV Cache |
算一遍实际规模(Llama-70B、GQA-8、80 layers、seq_len=4096):
| 场景 | 计算 | 结果 |
|---|---|---|
| 单请求 FP16 | 4 KB/token/layer × 4096 × 80 | 1.34 GB |
| 单请求 FP8 | 2 KB/token/layer × 4096 × 80 | 0.67 GB(省 50%) |
| 并发 8 路 FP16 | 1.34 GB × 8 | 10.7 GB |
单请求看着不大,但注意最后一行:KV Cache 是”并发数 × 上下文长度”的乘积,这才是它压垮显存的方式。
量化粒度决定了精度与开销的平衡,scale 分得越细越准、但元数据越多:
| 粒度 | 含义 | 精度 |
|---|---|---|
| Per-tensor | 整个 KV Cache 共享 1 个 scale | 最差 |
| Per-token | 每个 token 一个 scale | 较好 |
| Per-head | 每个 head 一个 scale | 精细 |
| Per-channel | 每个 channel 一个 scale | 最优 |
| Per-group | 每 G 个元素共享 scale | 灵活折中 |
实现上分两个动作:Quantize-on-write(写入时即以低精度存储)和 Dequantize-on-read(读取时反量化,或直接在 attention kernel 内处理)。是否启用取决于模型、dtype、backend 与配置。
KV Cache 量化与 PagedAttention 在设计上可以组合:量化后的 KV 仍按 block 粒度管理,同时需要记录相应 scale 或格式元数据。具体是否支持、如何存储 scale、是否在 attention kernel 内完成反量化,取决于 v0.27.1 中对应模型、dtype 和 attention backend 的实现。
注意:Softmax 对 Key 的误差特别敏感(因为指数函数会放大误差),长上下文下量化误差可能累积。FP8 是实践中最安全的 KV Cache 量化格式。
5. KV Cache Offloading 与 Swapping
当 GPU 显存不足时,vLLM 支持将 KV Cache 卸载到更低层的存储:
| 层级 | 容量 | 带宽 | 延迟 | 存什么 |
|---|---|---|---|---|
| GPU HBM(热) | 10–80 GB | 3.35 TB/s (H100) | ~ns | 活跃请求的 KV |
| CPU DRAM(温) | 256 GB–2 TB | ~200 GB/s | ~100 ns | 被抢占请求的 KV(经 PCIe Gen5,64 GB/s) |
| NVMe SSD(冷) | 1–16 TB | ~7 GB/s | ~10 μs | 长期前缀缓存(较新的方向) |
当 GPU 显存不够时,有三种应对策略,代价各不相同:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| Recomputation(重算) | 直接释放 KV,恢复时从头算 | 无传输开销,不占 CPU 内存 | 浪费 GPU 算力 |
| Swapping(换出) | KV 块 GPU→CPU,恢复时回传 | 保留了已算结果 | 吃 PCIe 带宽 |
| Quantization + Offload | 量化后再换出 | 传输量减半 | 额外精度损失 |
vLLM V1 当前主要使用 Recomputation 策略(_preempt_request() 中将 num_computed_tokens 置零),因为在 Prefix Cache 存在的情况下,重算的实际成本远低于理论最坏情况——大部分前缀块仍在缓存中可复用。
📂 本章源码导航
KV Cache 与 PagedAttention
| 想看什么 | 从哪开始 |
|---|---|
| 块的分配与释放(核心) | vllm/v1/core/kv_cache_manager.py → allocate_slots() |
| 物理块池、LRU 驱逐、Prefix Cache 注册 | vllm/v1/core/block_pool.py |
块哈希、extra_keys、NONE_HASH |
vllm/v1/core/kv_cache_utils.py → hash_block_tokens() |
| 各类 KV Cache 规格(含 MLA) | vllm/v1/kv_cache_interface.py |
| MLA 层实现 | vllm/model_executor/layers/mla.py |
| KV 量化 | vllm/model_executor/layers/quantization/ |
五、本文小结
- 显存不是被模型吃掉的,是被”不确定性”浪费掉的:请求的最终长度在到达时未知,按最坏情况预留连续显存会造成大量内部碎片。
- PagedAttention 把 KV Cache 切成固定大小的块,用多少申请多少、块间不要求相邻——这就是操作系统虚拟内存分页在推理系统中的重现;vLLM 中对应
KVCacheManager、BlockPool、KVCacheBlock与 Block Table 这组数据结构。 - 一次请求的 KV 生命周期是:Prefill 批量写入 → Decode 逐 slot 追加 → 完成或被抢占时归还;块只有”写满”才会进入 Prefix Cache,所以复用粒度是
block_size而不是单个 token。 - 让 KV Cache 更小有三个正交层面:系统管理层(PagedAttention、Prefix Cache)解决”怎么管才不浪费”,模型架构层(MQA / GQA / MLA)解决”本来要存多少”,数值层(FP8 / INT8 量化)解决”每个元素占几个字节”。
- Prefix Cache 依靠链式哈希与
ref_cnt让多个请求共享同一份物理块;在例子里 2000 token 的 system prompt 对应 125 个整块,第二个请求全部命中,只需 prefill 用户那 50 个 token。 - 显存不够时有 Recomputation、Swapping、量化后 Offload 三种策略;vLLM V1 目前主要用重算,因为在 Prefix Cache 存在时重算的实际代价远低于理论最坏情况。
六、自测
-
block_size = 16、Llama-3-70B TP8:一个块多大?一个 2050 token 的请求要几个块、最后一块浪费多少?答案
每卡每 token 320 KB / 8 = 40 KB,一块 640 KB;\(\lceil 2050 / 16 \rceil = 129\) 块,最后一块只用 2 个 slot、浪费 14 / 16——分页后碎片上限是一块,不是整个最大长度。
-
Prefix Cache 的键为什么是“链式哈希”而不是本块 token 的哈希?
答案
同样的 16 个 token 在不同前缀之后的 K、V 不同(attention 依赖全部前文),只有“前缀完全相同”的块才能复用;链式哈希 = hash(前一块哈希, 本块 token),天然编码了整个前缀。
extra_keys还要带 LoRA 名与多模态哈希。 -
2000 token 的 system prompt + 50 token 用户问题,第二个请求命中多少块、还要 prefill 多少 token?为什么不是 2000 全省?
答案
125 个整块(2000 / 16)全部命中,还要 prefill 50 个 token——但如果 system prompt 是 2005 个 token,最后 5 个落在未满的块里不进缓存,那 5 个也要重算。复用粒度是块。
-
ref_cnt在 Prefix Cache 里做什么?一个被两个请求共享的块什么时候真正释放?答案
记录有多少请求正引用该物理块;请求完成时
ref_cnt -= 1,为 0 时块进空闲队列尾部(仍保留哈希、可再被命中,LRU 淘汰时才真正失去内容)。所以共享块在两个请求都完成后才可能被复用。 -
让 KV Cache 变小的三个层面各是什么?把 Llama-3-70B 从 MHA(64 头)换成 GQA(8 头)、再 FP8,每 token 从多少到多少?
答案
系统层(分页、prefix cache:管得不浪费)、架构层(GQA / MLA:本来要存多少)、数值层(量化:每元素几字节);MHA 64 头 BF16 每 token 2.56 MB → GQA 8 头 320 KB → FP8 160 KB,缩 16 倍。
下一篇
-
放在哪里:按块放。请求到达时不知道它会生成 300 还是 20K 个 token,按最坏情况预留连续显存会让有效数据不到三成;PagedAttention 把每个请求的 KV 切成固定大小(
block_size,如 16 token)的块,用多少申请多少、块之间不要求相邻——KVCacheManager管每个请求的块列表、BlockPool管空闲块与引用计数、Block Table 是逻辑块到物理块的映射,attention kernel 经它查地址(第二章)。如何复用:Prefix Cache——块只有写满才进缓存,用链式哈希(前一块的哈希 + 本块 token)做键、ref_cnt让多个请求共享同一物理块;2000 token 的 system prompt 是 125 个整块,第二个请求全部命中,复用粒度是块不是 token(第四章)。何时释放:Prefill 批量写入 → Decode 每步追加一个 slot、块满了再申请 → 完成或被抢占时归还;ref_cnt为零的块进空闲队列尾、仍可被后来者命中(第三章)。让 KV 更小的三个正交层面:系统管理层(分页、prefix cache)、模型架构层(MQA / GQA / MLA)、数值层(FP8 / INT8);显存不够时 V1 主要用重算而不是 swap(第四章)。 ↩
系列:大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术
- 为什么 LLM Serving 比传统 DL 推理难?
- 如何衡量一个 LLM Serving 系统?
- 鸟瞰 vLLM:一个请求如何穿过整个推理系统?
- Scheduler:GPU 这一轮到底给谁用?
- KV Cache:LLM Serving 的第一号内存问题
- GPU 执行:如何让每个 Token 算得更快?
- 解码的扩展:采样、投机解码与结构化输出
- Multi-GPU:一张卡不够时如何扩展?
- 模型适配:如何跟上变化极快的模型世界?
- 请求形态的扩展:multi-LoRA 与多模态
- 硬件解耦:如何不让芯片差异污染 Serving 核心?
- PD 分离:从资源混部走向计算解耦
- Serving Infra 的下一站:从模型执行器到分布式智能操作系统
- 回到源码:一次请求在 vLLM 内部的真实旅程
- 系列总结与通关自测
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/kv-cache-memory-core.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。