KV Cache 与推理服务内存管理

2720 字
14 分钟
KV Cache 与推理服务内存管理

Datawhale
Datawhale

大家好,我是芯缘,是 Datawhale 社区发起的 “llm-algo-leetcode 推理优化方向”组队学习活动的助教。

  • 2026年8月运营助教
  • 2026年9月专业助教

PagedAttention#

问题:

  • 在线请求不会同时开始或同时结束,静态预留造成的空洞。
  • 最大序列长度来分配浪费,KV Cache 的显存碎片化

解法:PagedAttention (vLLM):

借鉴操作系统的虚拟内存管理。把显存切分成固定大小的 Block(比如 1 个 Block 存 16 个 Token)。在生成时,按需分配物理 Block,并通过 Block Table 记录逻辑 Token 序列到物理块的映射。

Block Table[logical_block_id]=physical_block_id\text{Block Table}[logical\_block\_id] = physical\_block\_id

KV Cache 变成“可按块寻址、可按需增长”的结构:prefill 申请所需 Block,decode 只在跨越边界时补 Block,计算阶段再依据块表访问离散缓存。

vLLM 的层次如下:

vLLM Serving
│
├── Scheduler
│ └── 动态 batching / 调度
│
├── KV Cache Manager
│ └── Block 分配 / 释放 / Block Table
│
└── Attention
└── 根据 Block Table
对非连续 KV Cache 进行 Attention
↑
PagedAttention 核心计算思想

Scheduler部分如下:

Scheduler
│
schedule 一个 batch
│
┌───────────┴───────────┐
↓ ↓
WAITING RUNNING
│ │
↓ ↓
prefill(req) decode_one_token(req)
│ │
│ ↓
│ paged_attention_forward()
│ │
└───────────┬───────────┘
↓
GPU KV Cache
│
Block Table
│
↓
Physical Blocks
如果生成结束:
↓
finish(req)
↓
Block → FREE_BLOCKS
阶段输入/触发状态变化需要检查的边界
Prefillprompt tokens按 prompt 所需容量申请多个物理 Block,并建立 Block Table空闲物理 Block 是否足够;最后一个 Block 可不满
Decode每次新增 token写入当前 Block;当前 Block 满时再申请新物理 Block,并更新 Block Table新 Block 分配失败时如何处理/调度
Release请求完成/终止归还该请求占用的物理 Block,并删除/清空对应 Block Table防止重复释放;共享 Block 时需考虑引用计数
Reuse后续请求需要空间从空闲 Block 池重新分配之前释放的物理 Block同一请求获得的物理 Block无需连续

KV Cache Manager如下:

GPU KV Cache
┌─────────────────────────┐
│ Physical Block Pool │
│ │
│ 0 1 2 3 4 ... 17 │
└─────────────────────────┘
↑
│ allocate / free
│
KV Cache Manager
↑
│
┌──────────┴──────────┐
│ │
Request A Request B
Block Table Block Table
[17, 3, 25] [8, 11]
│ │
↓ ↓
逻辑 KV: 逻辑 KV:
Block 0 → 17 Block 0 → 8
Block 1 → 3 Block 1 → 11
Block 2 → 25
Decode:
当前 Block 有空间
↓
继续写
当前 Block 满
↓
从 Pool 申请新 Block
↓
追加到 Block Table
Request Finish:
Block Table 中所有 Block
↓
归还 Pool

伪代码如下:

# ==========================================
# 0. 初始化:GPU 上预留 KV Cache Block Pool
# ==========================================
GPU_KV_CACHE = PhysicalBlockPool(
num_blocks = N,
block_size = 16 tokens
)
FREE_BLOCKS = [0, 1, 2, ..., N-1]
# 每个 Request 维护自己的 Block Table
Request:
tokens
block_table = []
# Logical Block 0 -> Physical Block 17
# Logical Block 1 -> Physical Block 3
# Logical Block 2 -> Physical Block 25
# block_table = [17, 3, 25]
# ==========================================
# 1. Prefill
# ==========================================
prefill(request):
prompt_len = len(request.tokens)
num_blocks_needed = ceil(prompt_len / BLOCK_SIZE)
if FREE_BLOCKS.size < num_blocks_needed:
无法调度该请求
return
for i in range(num_blocks_needed):
physical_block = FREE_BLOCKS.pop()
request.block_table.append(physical_block)
model_forward(request)
# ==========================================
# 2. Decode:每生成一个 token
# ==========================================
decode_one_token(request):
next_position = request.seq_len
logical_block_id = next_position // BLOCK_SIZE
offset_in_block = next_position % BLOCK_SIZE
# 情况 A:已有 Block 还有位置
if logical_block_id < len(request.block_table):
physical_block =
request.block_table[logical_block_id]
# 情况 B:跨越 Block 边界->按需申请新的物理 Block
else:
if FREE_BLOCKS.empty():
无法继续调度 / 需要抢占其他请求
return
physical_block = FREE_BLOCKS.pop()
request.block_table.append(physical_block)
# Attention 根据 Block Table
# 找到这个请求之前所有 KV 的物理位置
attention(
query = current_query,
block_table = request.block_table,
kv_cache = GPU_KV_CACHE
)
# 新 token 产生的 K/V 写入对应物理 Block
GPU_KV_CACHE[
physical_block,
offset_in_block
] = new_KV
request.seq_len += 1
# ==========================================
# 3. Attention:根据 Block Table 访问 KV
# ==========================================
paged_attention(query, request):
for logical_block_id in request需要访问的所有逻辑Block:
# 查表
physical_block_id = request.block_table[logical_block_id]
# 直接读取对应物理 Block
K, V = GPU_KV_CACHE[physical_block_id]
# 对该 Block 做 Attention
accumulate_attention(query, K, V)
# ==========================================
# 4. 请求结束
# ==========================================
finish(request):
for physical_block in request.block_table:
FREE_BLOCKS.push(physical_block)
request.block_table.clear()

RadixAttention#

问题:

  • 多个请求可能包含相同的系统提示词、对话历史、Few-shot 示例或搜索路径,但普通推理会为每个请求重复执行相同前缀的 Prefill。
  • 请求结束后若立即释放全部 KV Cache,后续请求即使拥有相同前缀,也无法复用已经计算过的 KV。

解法:RadixAttention (SGLang):

系统维护一棵以 Token 序列为 Key、以 KV Cache 物理位置为 Value 的 Radix Tree(压缩前缀树)。树中的一条边可以保存一段 Token,而不是像普通 Trie 那样每个 Token 都建立一个节点。公共 Token 前缀因此只对应一条共享路径,其 KV Cache 也可以被多个请求复用。

新请求到达时,系统先用完整 Prompt 在树中做最长前缀匹配(Longest Prefix Match):

H=max⁡jlcp⁡(prompt_tokens,cached_pathj)H = \max_j \operatorname{lcp}(prompt\_tokens, cached\_path_j)

其中 H 是命中的 Token 数量,即 hit_len。Prompt 随后被划分为:

Prompt=Cached Prefix+Uncached Suffix\text{Prompt} = \text{Cached Prefix} + \text{Uncached Suffix}

RadixAttention 先用 Radix Tree 找到最长缓存前缀,复用 Prefix 的 KV,只计算 Suffix;计算结果继续插入树中,请求结束后也不立即释放,显存不足时再淘汰当前没有请求使用的缓存节点。

SGLang Serving
│
├── Scheduler
│ └── 请求排序 / continuous batching
│
├── Radix Cache
│ └── Match / Insert / Split / Evict
│ Token Prefix → KV Cache indices
│
├── KV Cache Allocator
│ └── KV slot / Page 的分配与释放
│
└── Attention
└── 根据命中的 KV indices
读取分页存放的 GPU KV Cache

Scheduler 部分如下:

Scheduler
│
接收新请求 req
│
↓
match_prefix(req.prompt)
│
┌───────────┴───────────┐
↓ ↓
Cached Prefix Uncached Suffix
直接复用 KV prefill_suffix()
│ │
└───────────┬───────────┘
↓
insert() / Split
│
↓
Decode
│
请求结束 finish()
│
KV 保留在 Radix Tree 中
│
显存不足时 evict()
操作输入核心行为结果
Match新请求 prompt在 Radix Tree 中做最长前缀匹配得到可直接复用的 KV 和 hit_len
Prefill未命中的 suffix只计算 prompt[hit_len:]避免重复计算已命中 prefix
Insert新计算出的 token + KV把新的 KV 路径加入 Radix Tree;必要时内部 Split新 KV 可以被未来请求复用
Finish请求完成/终止把可复用路径留在树中,并解除该请求对路径的锁定KV 不因请求结束而立即释放
Evict显存压力从可淘汰叶子中选择 Victim,删除节点并释放 KV给新请求腾出 KV 空间;正在被请求使用的路径不能淘汰

这里的 Split 不是一个独立的 Serving 阶段。它是 Match 或 Insert 在压缩边内部遇到匹配终点或分叉点时,用来维护树结构的内部操作。

例如树中原来只有一条路径:

root
│
[A B C D E]

现在插入 [A B C X Y],两条路径在 C 之后分叉,原节点会被拆成:

root
│
[A B C]
/ \
[D E] [X Y]

[A B C] 及其 KV 只保留一份,两个请求分别沿不同的 Suffix 继续向下。

Radix Cache 的结构如下:

RadixNode:
tokens # 从 parent 到当前节点的压缩 Token 段
kv_indices # 这段 Token 对应的 GPU KV 位置
children # 下一 Token → 子节点
parent
lock_ref # 是否有正在运行的请求使用该路径
last_access # 供 LRU 等淘汰策略使用
ROOT
│
[system prompt tokens]
KV: [7, 12, 31, ...]
/ \
[user question A] [user question B]
KV: [18, 22, ...] KV: [5, 16, ...]
│
[model answer A]
KV: [9, 27, ...]

伪代码如下。为突出主线,省略了 Page 对齐、Chunked Prefill、分布式同步和 Host Cache 等工程细节。

# ==========================================
# 0. 初始化:Radix Tree + GPU KV Allocator
# ==========================================
KV_ALLOCATOR = KVCacheAllocator(total_slots = N)
ROOT = RadixNode(
tokens = [],
kv_indices = [],
children = {},
parent = None,
lock_ref = 1
)
# ==========================================
# 1. Split:在压缩边内部暴露分叉点
# 这是 Match / Insert 的内部操作
# ==========================================
split_node(node, split_pos):
# 原节点: [A B C D E]
# split_pos = 3
# 拆分后: [A B C] -> [D E]
suffix_node = RadixNode(
tokens = node.tokens[split_pos:],
kv_indices = node.kv_indices[split_pos:],
children = node.children,
parent = node
)
for child in suffix_node.children.values():
child.parent = suffix_node
node.tokens = node.tokens[:split_pos]
node.kv_indices = node.kv_indices[:split_pos]
node.children = {
suffix_node.tokens[0]: suffix_node
}
return node
# ==========================================
# 2. Match:寻找最长缓存前缀
# ==========================================
match_prefix(prompt):
node = ROOT
position = 0
matched_kv = []
while position < len(prompt):
child = node.children.get(prompt[position])
if child 不存在:
break
common_len = longest_common_prefix(
prompt[position:],
child.tokens
)
if common_len == 0:
break
# 整条压缩边都匹配,继续向下
if common_len == len(child.tokens):
matched_kv.extend(child.kv_indices)
position += common_len
node = child
continue
# 命中终点落在压缩边内部
# Split 后,命中的 Prefix 形成完整节点
prefix_node = split_node(child, common_len)
matched_kv.extend(prefix_node.kv_indices)
position += common_len
node = prefix_node
break
return MatchResult(
hit_len = position,
kv_indices = matched_kv,
last_node = node
)
# ==========================================
# 3. Prefill:只计算没有命中的 Suffix
# ==========================================
prefill_suffix(request, match_result):
hit_len = match_result.hit_len
suffix = request.prompt[hit_len:]
if KV_ALLOCATOR.free_slots < len(suffix):
evict(len(suffix) - KV_ALLOCATOR.free_slots)
if KV_ALLOCATOR.free_slots < len(suffix):
无法调度该请求
return OOM
new_kv_slots = KV_ALLOCATOR.allocate(len(suffix))
# Prefix 的 KV 已经存在,只计算 Suffix
model_forward_prefill(
tokens = suffix,
prefix_kv = match_result.kv_indices,
output_kv = new_kv_slots
)
return new_kv_slots
# ==========================================
# 4. Insert:将 Token 路径与 KV 引用插入树中
# ==========================================
insert(tokens, kv_indices):
node = ROOT
position = 0
while position < len(tokens):
child = node.children.get(tokens[position])
# 没有公共前缀:剩余部分直接成为新压缩边
if child 不存在:
new_node = RadixNode(
tokens = tokens[position:],
kv_indices = kv_indices[position:],
parent = node
)
node.children[tokens[position]] = new_node
return new_node
common_len = longest_common_prefix(
tokens[position:],
child.tokens
)
# 整条已有边都匹配,继续向下
if common_len == len(child.tokens):
position += common_len
node = child
continue
# 在已有边内部发生分叉
branch_node = split_node(child, common_len)
position += common_len
# 新路径恰好结束在分叉点
if position == len(tokens):
return branch_node
new_node = RadixNode(
tokens = tokens[position:],
kv_indices = kv_indices[position:],
parent = branch_node
)
branch_node.children[tokens[position]] = new_node
return new_node
return node
# ==========================================
# 5. Evict:显存不足时淘汰未被使用的叶子
# ==========================================
evict(required_slots):
released = 0
while released < required_slots:
victim = choose_evictable_leaf(
policy = LRU,
condition = node.lock_ref == 0
)
if victim 不存在:
break
KV_ALLOCATOR.free(victim.kv_indices)
released += len(victim.kv_indices)
parent = victim.parent
del parent.children[victim.tokens[0]]
# parent 失去最后一个 child 后,也可能成为可淘汰叶子
update_evictable_state(parent)
return released
# ==========================================
# 6. 请求结束:缓存 KV,而不是立即释放
# ==========================================
finish(request):
insert(
tokens = request.all_tokens,
kv_indices = request.kv_indices
)
# 请求不再使用这条路径,允许未来在显存压力下淘汰
unlock_path(request.last_node)
request.last_node = None
request.state = FINISHED
# 注意:这里不调用 KV_ALLOCATOR.free()
# KV 仍由 Radix Tree 持有,供未来请求复用

完整生命周期可以概括为:

新请求到达
↓
Match:找到最长缓存 Prefix
↓
Lock:防止正在使用的路径被淘汰
↓
Prefill:只计算未命中的 Suffix
↓
Insert:把新 KV 路径加入树,必要时内部 Split
↓
Decode
↓
Finish:解除锁定,但 KV 继续留在树中
↓
显存不足
↓
Evict:从未锁定的叶子开始回收 KV

PagedAttention 的 finish() 与 RadixAttention 的 finish() 有一个关键区别:

PagedAttention(不考虑 Prefix Cache):
Request Finish
↓
物理 Block 立即归还 Free Pool
RadixAttention:
Request Finish
↓
KV 继续由 Radix Tree 持有
↓
未来相同 Prefix 可以复用
↓
显存有压力时才 Evict

因此,RadixAttention 不是简单地“多建一棵树”,而是改变了 KV Cache 的生命周期:从“请求私有、结束即释放”变为“按前缀共享、按需淘汰”。

参考资料#

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

评论区

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
--
总访问量
--
访客数
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
「社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
190
分类
26
标签
265
总字数
2,145,681
运行时长
0 天
最后活动
0 天前

文章目录