音乐
暂未播放
KV Cache 与推理服务内存管理

大家好,我是芯缘,是 Datawhale 社区发起的 “llm-algo-leetcode 推理优化方向”组队学习活动的助教。
- 2026年8月运营助教
- 2026年9月专业助教
PagedAttention#
问题:
- 在线请求不会同时开始或同时结束,静态预留造成的空洞。
- 最大序列长度来分配浪费,KV Cache 的显存碎片化
解法:PagedAttention (vLLM):
借鉴操作系统的虚拟内存管理。把显存切分成固定大小的 Block(比如 1 个 Block 存 16 个 Token)。在生成时,按需分配物理 Block,并通过 Block Table 记录逻辑 Token 序列到物理块的映射。
KV Cache 变成“可按块寻址、可按需增长”的结构:prefill 申请所需 Block,decode 只在跨越边界时补 Block,计算阶段再依据块表访问离散缓存。
vLLM 的层次如下:
1vLLM Serving2│3├── Scheduler4│ └── 动态 batching / 调度5│6├── KV Cache Manager7│ └── Block 分配 / 释放 / Block Table8│9└── Attention10 └── 根据 Block Table11 对非连续 KV Cache 进行 Attention12 ↑13 PagedAttention 核心计算思想Scheduler部分如下:
1 Scheduler2 │3 schedule 一个 batch4 │5 ┌───────────┴───────────┐6 ↓ ↓7 WAITING RUNNING8 │ │9 ↓ ↓10 prefill(req) decode_one_token(req)11 │ │12 │ ↓13 │ paged_attention_forward()14 │ │15 └───────────┬───────────┘16 ↓17 GPU KV Cache18 │19 Block Table20 │21 ↓22 Physical Blocks23
24
25如果生成结束:26 ↓27 finish(req)28 ↓29Block → FREE_BLOCKS| 阶段 | 输入/触发 | 状态变化 | 需要检查的边界 |
|---|---|---|---|
| Prefill | prompt 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如下:
1 GPU KV Cache2 ┌─────────────────────────┐3 │ Physical Block Pool │4 │ │5 │ 0 1 2 3 4 ... 17 │6 └─────────────────────────┘7 ↑8 │ allocate / free9 │10 KV Cache Manager11 ↑12 │13 ┌──────────┴──────────┐14 │ │15 Request A Request B16
17Block Table Block Table18[17, 3, 25] [8, 11]19 │ │20 ↓ ↓21
22逻辑 KV: 逻辑 KV:23
24Block 0 → 17 Block 0 → 825Block 1 → 3 Block 1 → 1126Block 2 → 2527
28
29Decode:30当前 Block 有空间31 ↓32 继续写33
34当前 Block 满35 ↓36从 Pool 申请新 Block37 ↓38追加到 Block Table39
40
41Request Finish:42Block Table 中所有 Block43 ↓44归还 Pool伪代码如下:
1# ==========================================2# 0. 初始化:GPU 上预留 KV Cache Block Pool3# ==========================================4
5GPU_KV_CACHE = PhysicalBlockPool(6 num_blocks = N,7 block_size = 16 tokens8)9
10FREE_BLOCKS = [0, 1, 2, ..., N-1]11
12# 每个 Request 维护自己的 Block Table13Request:14 tokens15 block_table = []16# Logical Block 0 -> Physical Block 1717# Logical Block 1 -> Physical Block 318# Logical Block 2 -> Physical Block 2519# block_table = [17, 3, 25]20
21# ==========================================22# 1. Prefill23# ==========================================24
25prefill(request):26
27 prompt_len = len(request.tokens)28 num_blocks_needed = ceil(prompt_len / BLOCK_SIZE)29
30 if FREE_BLOCKS.size < num_blocks_needed:31 无法调度该请求32 return33
34 for i in range(num_blocks_needed):35
36 physical_block = FREE_BLOCKS.pop()37 request.block_table.append(physical_block)38
39 model_forward(request)40
41
42# ==========================================43# 2. Decode:每生成一个 token44# ==========================================45
46decode_one_token(request):47
48 next_position = request.seq_len49 logical_block_id = next_position // BLOCK_SIZE50 offset_in_block = next_position % BLOCK_SIZE51
52 # 情况 A:已有 Block 还有位置53 if logical_block_id < len(request.block_table):54 physical_block =55 request.block_table[logical_block_id]56
57 # 情况 B:跨越 Block 边界->按需申请新的物理 Block58 else:59 if FREE_BLOCKS.empty():60 无法继续调度 / 需要抢占其他请求61 return62 physical_block = FREE_BLOCKS.pop()63 request.block_table.append(physical_block)64
65
66 # Attention 根据 Block Table67 # 找到这个请求之前所有 KV 的物理位置68 attention(69 query = current_query,70 block_table = request.block_table,71 kv_cache = GPU_KV_CACHE72 )73 # 新 token 产生的 K/V 写入对应物理 Block74 GPU_KV_CACHE[75 physical_block,76 offset_in_block77 ] = new_KV78 request.seq_len += 179
80
81# ==========================================82# 3. Attention:根据 Block Table 访问 KV83# ==========================================84
85paged_attention(query, request):86 for logical_block_id in request需要访问的所有逻辑Block:87 # 查表88 physical_block_id = request.block_table[logical_block_id]89 # 直接读取对应物理 Block90 K, V = GPU_KV_CACHE[physical_block_id]91 # 对该 Block 做 Attention92 accumulate_attention(query, K, V)93
94
95# ==========================================96# 4. 请求结束97# ==========================================98finish(request):99 for physical_block in request.block_table:100 FREE_BLOCKS.push(physical_block)101 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=jmaxlcp(prompt_tokens,cached_pathj)其中 H 是命中的 Token 数量,即 hit_len。Prompt 随后被划分为:
RadixAttention 先用 Radix Tree 找到最长缓存前缀,复用 Prefix 的 KV,只计算 Suffix;计算结果继续插入树中,请求结束后也不立即释放,显存不足时再淘汰当前没有请求使用的缓存节点。
1SGLang Serving2│3├── Scheduler4│ └── 请求排序 / continuous batching5│6├── Radix Cache7│ └── Match / Insert / Split / Evict8│ Token Prefix → KV Cache indices9│10├── KV Cache Allocator11│ └── KV slot / Page 的分配与释放12│13└── Attention14 └── 根据命中的 KV indices15 读取分页存放的 GPU KV CacheScheduler 部分如下:
1 Scheduler2 │3 接收新请求 req4 │5 ↓6 match_prefix(req.prompt)7 │8 ┌───────────┴───────────┐9 ↓ ↓10 Cached Prefix Uncached Suffix11 直接复用 KV prefill_suffix()12 │ │13 └───────────┬───────────┘14 ↓15 insert() / Split16 │17 ↓18 Decode19 │20 请求结束 finish()21 │22 KV 保留在 Radix Tree 中23 │24 显存不足时 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 在压缩边内部遇到匹配终点或分叉点时,用来维护树结构的内部操作。
例如树中原来只有一条路径:
1root2 │3[A B C D E]现在插入 [A B C X Y],两条路径在 C 之后分叉,原节点会被拆成:
1 root2 │3 [A B C]4 / \5 [D E] [X Y][A B C] 及其 KV 只保留一份,两个请求分别沿不同的 Suffix 继续向下。
Radix Cache 的结构如下:
1RadixNode:2 tokens # 从 parent 到当前节点的压缩 Token 段3 kv_indices # 这段 Token 对应的 GPU KV 位置4 children # 下一 Token → 子节点5 parent6 lock_ref # 是否有正在运行的请求使用该路径7 last_access # 供 LRU 等淘汰策略使用8
9
10 ROOT11 │12 [system prompt tokens]13 KV: [7, 12, 31, ...]14 / \15 [user question A] [user question B]16 KV: [18, 22, ...] KV: [5, 16, ...]17 │18 [model answer A]19 KV: [9, 27, ...]伪代码如下。为突出主线,省略了 Page 对齐、Chunked Prefill、分布式同步和 Host Cache 等工程细节。
1# ==========================================2# 0. 初始化:Radix Tree + GPU KV Allocator3# ==========================================4
5KV_ALLOCATOR = KVCacheAllocator(total_slots = N)6
7ROOT = RadixNode(8 tokens = [],9 kv_indices = [],10 children = {},11 parent = None,12 lock_ref = 113)14
15
16# ==========================================17# 1. Split:在压缩边内部暴露分叉点18# 这是 Match / Insert 的内部操作19# ==========================================20
21split_node(node, split_pos):22
23 # 原节点: [A B C D E]24 # split_pos = 325 # 拆分后: [A B C] -> [D E]26
27 suffix_node = RadixNode(28 tokens = node.tokens[split_pos:],29 kv_indices = node.kv_indices[split_pos:],30 children = node.children,31 parent = node32 )33
34 for child in suffix_node.children.values():35 child.parent = suffix_node36
37 node.tokens = node.tokens[:split_pos]38 node.kv_indices = node.kv_indices[:split_pos]39 node.children = {40 suffix_node.tokens[0]: suffix_node41 }42
43 return node44
45
46# ==========================================47# 2. Match:寻找最长缓存前缀48# ==========================================49
50match_prefix(prompt):51
52 node = ROOT53 position = 054 matched_kv = []55
56 while position < len(prompt):57
58 child = node.children.get(prompt[position])59
60 if child 不存在:61 break62
63 common_len = longest_common_prefix(64 prompt[position:],65 child.tokens66 )67
68 if common_len == 0:69 break70
71 # 整条压缩边都匹配,继续向下72 if common_len == len(child.tokens):73 matched_kv.extend(child.kv_indices)74 position += common_len75 node = child76 continue77
78 # 命中终点落在压缩边内部79 # Split 后,命中的 Prefix 形成完整节点80 prefix_node = split_node(child, common_len)81 matched_kv.extend(prefix_node.kv_indices)82 position += common_len83 node = prefix_node84 break85
86 return MatchResult(87 hit_len = position,88 kv_indices = matched_kv,89 last_node = node90 )91
92
93# ==========================================94# 3. Prefill:只计算没有命中的 Suffix95# ==========================================96
97prefill_suffix(request, match_result):98
99 hit_len = match_result.hit_len100 suffix = request.prompt[hit_len:]101
102 if KV_ALLOCATOR.free_slots < len(suffix):103 evict(len(suffix) - KV_ALLOCATOR.free_slots)104
105 if KV_ALLOCATOR.free_slots < len(suffix):106 无法调度该请求107 return OOM108
109 new_kv_slots = KV_ALLOCATOR.allocate(len(suffix))110
111 # Prefix 的 KV 已经存在,只计算 Suffix112 model_forward_prefill(113 tokens = suffix,114 prefix_kv = match_result.kv_indices,115 output_kv = new_kv_slots116 )117
118 return new_kv_slots119
120
121# ==========================================122# 4. Insert:将 Token 路径与 KV 引用插入树中123# ==========================================124
125insert(tokens, kv_indices):126
127 node = ROOT128 position = 0129
130 while position < len(tokens):131
132 child = node.children.get(tokens[position])133
134 # 没有公共前缀:剩余部分直接成为新压缩边135 if child 不存在:136 new_node = RadixNode(137 tokens = tokens[position:],138 kv_indices = kv_indices[position:],139 parent = node140 )141 node.children[tokens[position]] = new_node142 return new_node143
144 common_len = longest_common_prefix(145 tokens[position:],146 child.tokens147 )148
149 # 整条已有边都匹配,继续向下150 if common_len == len(child.tokens):151 position += common_len152 node = child153 continue154
155 # 在已有边内部发生分叉156 branch_node = split_node(child, common_len)157 position += common_len158
159 # 新路径恰好结束在分叉点160 if position == len(tokens):161 return branch_node162
163 new_node = RadixNode(164 tokens = tokens[position:],165 kv_indices = kv_indices[position:],166 parent = branch_node167 )168 branch_node.children[tokens[position]] = new_node169 return new_node170
171 return node172
173
174# ==========================================175# 5. Evict:显存不足时淘汰未被使用的叶子176# ==========================================177
178evict(required_slots):179
180 released = 0181
182 while released < required_slots:183
184 victim = choose_evictable_leaf(185 policy = LRU,186 condition = node.lock_ref == 0187 )188
189 if victim 不存在:190 break191
192 KV_ALLOCATOR.free(victim.kv_indices)193 released += len(victim.kv_indices)194
195 parent = victim.parent196 del parent.children[victim.tokens[0]]197
198 # parent 失去最后一个 child 后,也可能成为可淘汰叶子199 update_evictable_state(parent)200
201 return released202
203
204# ==========================================205# 6. 请求结束:缓存 KV,而不是立即释放206# ==========================================207
208finish(request):209
210 insert(211 tokens = request.all_tokens,212 kv_indices = request.kv_indices213 )214
215 # 请求不再使用这条路径,允许未来在显存压力下淘汰216 unlock_path(request.last_node)217 request.last_node = None218 request.state = FINISHED219
220 # 注意:这里不调用 KV_ALLOCATOR.free()221 # KV 仍由 Radix Tree 持有,供未来请求复用完整生命周期可以概括为:
1新请求到达2 ↓3Match:找到最长缓存 Prefix4 ↓5Lock:防止正在使用的路径被淘汰6 ↓7Prefill:只计算未命中的 Suffix8 ↓9Insert:把新 KV 路径加入树,必要时内部 Split10 ↓11Decode12 ↓13Finish:解除锁定,但 KV 继续留在树中14 ↓15显存不足16 ↓17Evict:从未锁定的叶子开始回收 KVPagedAttention 的 finish() 与 RadixAttention 的 finish() 有一个关键区别:
1PagedAttention(不考虑 Prefix Cache):2Request Finish3 ↓4物理 Block 立即归还 Free Pool5
6
7RadixAttention:8Request Finish9 ↓10KV 继续由 Radix Tree 持有11 ↓12未来相同 Prefix 可以复用13 ↓14显存有压力时才 Evict因此,RadixAttention 不是简单地“多建一棵树”,而是改变了 KV Cache 的生命周期:从“请求私有、结束即释放”变为“按前缀共享、按需淘汰”。
参考资料#
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
音乐
暂未播放



