LLM 推理引擎的内存管理与调度:PagedAttention、Prefix Cache 与 Continuous Batching

高性能 LLM 推理引擎面对的问题,与操作系统出奇地相似:有限内存要服务大量生命周期不同的任务;任务随时到达和退出;共享数据应该复用;长任务不能长期阻塞短任务;吞吐、公平性和延迟无法同时无限提高。

从这个角度看,PagedAttention 不只是一个 Attention Kernel,Continuous Batching 也不只是“把 Batch 做大”。它们共同构成了一套面向 Token 的内存管理和调度系统。

推理引擎中的 KV 内存平面与 Token 调度平面

图 1:分页内存管理允许请求细粒度进入和退出,动态调度则把释放出来的块转化为更高有效 Batch。

一、概念映射

操作系统 LLM 推理引擎
虚拟页 请求的逻辑 KV Block
物理页 GPU 上的物理 KV Block
页表 Block Table
写时复制 并行采样中的 KV Copy-on-Write
引用计数 共享 KV Block 的生命周期
LRU Prefix Cache 淘汰
进程队列 Waiting/Running 请求队列
时间片或预算 每轮可调度 Token Budget
抢占 释放 KV 后重计算或换出

这个类比不是说 vLLM 内部实现了完整操作系统,而是说明两者面对的是同一类资源分配问题。

二、连续分配为什么会浪费 KV Cache

假设系统为每个请求提前预留一段连续 KV Cache。问题很快出现:

2.1 内部碎片

系统按照最大输出长度预留,但请求可能提前遇到 EOS。没有使用的预留空间无法立刻服务其他请求。

2.2 外部碎片

请求不断到达和结束后,显存中会留下大小不一的空洞。空闲空间总量也许足够,却找不到一段满足新请求的连续区域。

2.3 过度预留

生成长度不可预测。如果不提前预留,Decode 增长时可能搬迁;如果按上限预留,又会浪费大量空间。

KV Cache 的困难不在于一次分配,而在于它会随着每个请求逐 Token 增长。

三、PagedAttention:把连续逻辑空间映射到离散物理块

PagedAttention 将 KV Cache 切成固定 Token 数的 Block。请求看到的是连续的逻辑块,实际物理块可以分散在显存中。

1
2
3
4
5
请求逻辑块:  [0] [1] [2] [3]
│ │ │ │
Block Table: 12 3 27 8
│ │ │ │
物理块池: ... [3] ... [8] ... [12] ... [27] ...

Attention Kernel 根据逻辑块号查询 Block Table,再计算物理块地址和块内偏移。这样请求不需要一段连续的物理显存。

3.1 碎片如何变化

  • 外部碎片基本被固定大小块消除;
  • 每个请求只有最后一个未填满 Block 产生有限内部浪费;
  • Decode 时按需申请新块,不必按最大长度提前预留;
  • 请求结束后,物理块可以直接回到空闲池。

Block Size 仍然需要权衡:

  • 太大:最后一块浪费增加,细粒度复用下降;
  • 太小:Block Table 更大,管理和寻址开销增加。

四、共享、引用计数和 Copy-on-Write

分页之后,不同请求的 Block Table 可以指向同一个物理 KV Block。

典型场景包括:

  • 多个请求拥有相同系统 Prompt;
  • Beam Search 或并行采样共享同一 Prompt;
  • 多轮对话复用已经计算过的前缀。

共享块需要引用计数。只读阶段多个请求共同引用;当某个分支需要修改共享尾块时,再执行 Copy-on-Write:为它分配新块并复制必要内容。

这与 fork() 后父子进程共享内存页、写入时再复制的思想一致。

五、Prefix Cache:把共享从同时发生扩展到先后发生

PagedAttention 解决了“如何共享物理块”,Prefix Cache 进一步解决“如何发现历史请求已经计算过相同前缀”。

5.1 vLLM 的块哈希

一个块的哈希不仅取决于当前 Token,还要包含父块哈希。这样同一段 Token 出现在不同上下文中时,不会被错误认为是相同前缀。

可以把第 ii 个块的键抽象为:

hi=H(hi1,  tokensi,  model_id,  adapter_id,  extra)h_i=H(h_{i-1},\;tokens_i,\;model\_id,\;adapter\_id,\;extra)

tokens_i 相同但父哈希不同,说明它们之前的上下文不同,不能共享同一份 KV。模型、LoRA Adapter、Cache Salt 等会改变计算语义的信息也必须进入键空间。实现通常只缓存已经填满的块,因为未填满尾块还会在 Decode 中继续写入,不适合作为稳定的共享对象。

新请求到来后,系统逐块查询缓存:

1
2
命中块 → 增加引用计数 → 跳过对应 Prefill
未命中 → 分配新块 → 正常计算

Prefix Cache 主要降低重复前缀的 Prefill,因此最直接改善 TTFT,而不是 Decode 的 TPOT。

5.2 LRU 为什么还要结合引用计数

正在被请求使用的块不能淘汰,即使它很久没有被“新请求”命中。引用计数为 0 的缓存块才进入可淘汰集合,然后按 LRU 等策略回收。

5.3 RadixAttention

SGLang 使用 Radix Tree 表示 Token 前缀。公共前缀天然共享树路径,适合结构化、多轮和前缀复用明显的工作负载。

Hash Block 与 Radix Tree 没有绝对优劣:前者容易与分页块池结合,后者能更直接表示变长前缀结构。实际收益最终取决于 Prompt 是否真的重复。

六、Static Batching 的槽位空洞与队头阻塞

Static Batching 与 Continuous Batching 时间线

图 2:Static Batching 中短请求完成后的槽位保持空闲;Continuous Batching 在迭代边界立即补入新请求。

假设一个 Batch 有四个请求,分别需要生成 20、50、200、500 个 Token。Static Batching 通常要等最慢的请求完成,短请求结束后的槽位无法立即加入新任务。

结果是:

  • 短请求被长请求拖累;
  • 已完成请求占据 Batch 位置;
  • GPU 有效 Batch 逐渐缩小;
  • 尾延迟和吞吐同时恶化。

这类似让一组任务组成固定班级,必须等所有成员完成才能接收下一批。

七、Continuous Batching:每个 Token 都是一个调度点

Continuous Batching 在每次模型迭代后重新安排请求:

  1. 移除已经结束的请求;
  2. 释放或保留它们的 KV Block;
  3. 从等待队列选择新请求;
  4. 为本轮请求分配 Token 和 KV 预算;
  5. 组成新的 Batch 执行。

这是一种 Iteration-level Scheduling。调度单位不再是“完整请求 Batch”,而是“一轮需要处理的 Token”。

PagedAttention 与 Continuous Batching 互相依赖:如果 KV Cache 只能整段连续分配,请求频繁进入退出会带来昂贵搬迁;如果没有动态调度,分页节省的空间也难以转化成更高并发。

调度循环可以简化为下面的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def schedule_step(waiting, running, token_budget, block_pool):
batch = []

# Decode 请求优先获得本轮所需 Token,保护输出流畅度。
for req in running:
if req.finished:
block_pool.release(req.blocks)
continue
if token_budget >= 1 and block_pool.can_append(req):
batch.append((req, 1))
token_budget -= 1

# 剩余预算用于新 Prefill 或尚未完成的 Prefill Chunk。
while waiting and token_budget > 0:
req = waiting.peek()
computable = min(req.remaining_prompt_tokens, token_budget)
allocatable = block_pool.max_allocatable_tokens(req)
scheduled = min(computable, allocatable)
if scheduled == 0:
break
batch.append((req, scheduled))
token_budget -= scheduled
update_request_state(req, scheduled, waiting, running)

return batch

真实 vLLM V1 的策略比这段伪代码复杂,且版本会持续变化;这里想表达的稳定结构是:请求状态、Token 预算和 KV Block 预算必须在一次决策中共同满足。

八、长 Prefill 为什么会干扰 Decode

Decode 请求每轮只需要少量 Token,希望稳定、频繁地获得 GPU 时间。一个超长 Prompt 的 Prefill 如果一次执行完,会占据 GPU 较长时间,导致同批 Decode 请求的 TPOT 突然升高。

8.1 Chunked Prefill

Chunked Prefill 把长 Prompt 切成多个片段:

1
2
3
4
长 Prefill: [========================]

切块后: [====] [====] [====] [====]
Decode: | | | |

它可能用更长一些的 TTFT,换取更平滑的 Decode TPOT 和更好的调度公平性;实际变化取决于 Token Budget、队列状态和请求长度分布。

8.2 Token Budget

统一调度器可以把每轮处理能力表示为 Token Budget:

1
本轮预算 = Decode Token + 新请求 Prefill Token + Chunked Prefill Token

当长 Prefill 超过剩余预算时,只处理可以容纳的部分,下轮从 num_computed_tokens 继续。这比硬编码“先 Prefill 还是先 Decode”更容易统一不同请求状态。

九、抢占:显存不够时让谁退场

当物理 KV Block 用尽,调度器需要释放资源。常见恢复方式有:

  • 重计算:丢弃某请求的 KV,之后重新 Prefill;
  • 换出:将 KV 搬到 CPU 内存,需要时再搬回;
  • 延迟新请求:保持运行请求不变,让新请求继续等待。

选择取决于 PCIe 带宽、Prompt 长度、重计算成本和延迟 SLO。重计算看似浪费算力,但可能比大规模 CPU–GPU KV 交换更稳定。

十、吞吐、公平性与 SLO

调度器至少要同时考虑:

  • Batch 越大,权重读取的摊销通常越好;
  • Batch 太大,单轮时间和 TPOT 上升;
  • 长 Prompt 有利于填满计算,却可能阻塞 Decode;
  • 高缓存命中可以降低 TTFT,但缓存本身占用显存;
  • 抢占能避免 OOM,但会引入重计算或传输。

生产环境真正应该优化的是 Goodput:在 TTFT、TPOT 和错误率满足 SLO 的前提下,系统完成了多少有效请求。

十一、源码阅读路线

阅读 vLLM 时,可以围绕三个问题定位,而不是从仓库第一行开始:

  1. KV Block 如何申请、引用和释放;
  2. Scheduler 每轮如何决定哪些请求处理多少 Token;
  3. Attention Backend 如何根据 Block Table 读取 KV。

源码会持续演进,类名和文件路径可能变化,但“块池—请求状态—调度预算—执行后端”这四个角色比较稳定。

十二、技术 Q&A

Q1:PagedAttention 是否完全消除了 KV Cache 浪费?

它通过固定块和逻辑—物理映射基本消除了连续分配造成的外部碎片,也避免按最大长度过度预留;但每个请求的最后一个 Block 仍可能未填满,Block Table、引用计数和间接寻址也有管理成本。Block 越大尾块浪费越高,越小元数据和调度开销越高。

Q2:Prefix Cache 为什么主要改善 TTFT,且块哈希必须包含父块哈希?

命中缓存后,引擎可以跳过公共前缀的 Prefill,因此首 Token 更早产生;后续 Decode 仍要逐轮读取权重和历史 KV,所以 TPOT 通常不会直接缩短。由于 Transformer 的 KV 取决于当前 Token 之前的完整上下文,相同 Token Block 出现在不同父前缀后会产生不同 KV。将父块哈希纳入链式哈希,才能确保复用的是从起点到当前块都相同的完整前缀。

Q3:Continuous Batching 为什么依赖分页式 KV 管理?

迭代级调度要求请求可以每轮加入、退出和增长。若每个请求必须拥有一段按最大长度预留的连续 KV,新请求补位和旧请求释放会造成大量碎片或数据搬迁。固定块把请求生命周期的变化转化为块的申请与释放,使动态补位和显存回收的成本可控。

Q4:Chunked Prefill 与 Token Budget 共同决定了什么权衡?

Chunked Prefill 把长 Prefill 拆成较短片段,让 Decode 在片段之间获得 GPU 时间,从而降低 TPOT 抖动和 Head-of-Line Blocking;代价是长请求需要更多调度轮才能完成 Prefill,TTFT 可能增加。较大的 Token Budget 有利于形成大 Batch、提高吞吐并减少切块次数,但也会拉长单轮执行时间。两者需要结合请求长度分布和 TTFT/TPOT SLO 进行并发扫描,而不是独立地取极值。

十三、系列导航

上一篇:GPU 性能分析方法

下一篇:分布式训练的通信模型

十四、参考资料

论文与系统

文档与源码