副标题: PagedAttention 借用 OS 虚拟内存思想管理 KV Cache,Continuous Batching 让 GPU 不再等人,RadixAttention 进一步用前缀树实现"相同前缀不重算"------三层递进,拆解推理引擎的底层逻辑
一、引子 🎯
先看一个对比。用 HuggingFace Transformers 默认方式跑 Llama 2 7B 推理,和用 vLLM 跑同一个模型:
| 方案 | 吞吐量 | 显存利用率 |
|---|---|---|
| HuggingFace Transformers(默认) | ~1x | ~20-30% |
| vLLM | 10-24× | ~96% |
同样的 GPU,同样的模型。差距不在硬件------在 KV Cache 的管理方式。
这篇文章专注拆解三层递进的核心机制:
- PagedAttention --- 为什么把 KV Cache 当"虚拟内存"管,显存利用率就能从 20% 跳到 96%?
- Continuous Batching --- 为什么 PagedAttention 是动态组批的物理前提?Chunked Prefill 又是什么?
- 前缀缓存演进 --- vLLM 的哈希表方案 vs SGLang 的 Radix Tree 方案,为什么数据结构的选择在多轮对话中差了 5×?
每层机制都不是独立存在的------下一层依赖上一层提供的物理基础。
二、KV Cache:推理引擎要解决的根本问题
2.1 自回归解码与 KV 缓存
LLM 推理是自回归的:每生成一个新 token,都要用它之前所有 token(输入 prompt + 已生成输出)的 Key 和 Value 来计算注意力。
生成第 1 个 token:计算 prompt token 1..N 的 attention
生成第 2 个 token:计算 prompt token 1..N + 第1个输出 token 的 attention
生成第 3 个 token:计算 prompt token 1..N + 第1,2个输出 token 的 attention
...
不缓存 KV 的代价:每生成一个新 token 都要重算所有历史 token 的 K 和 V------计算量随序列长度呈 O(n²) 增长。所以 KV 必须缓存。
2.2 显存到底被吃了多少?
KV Cache 的显存占用有一个精确的计算公式:
KV Cache Size = 2 × batch_size × seq_len × num_layers × num_kv_heads × head_dim × dtype_bytes
其中:
2 = K + V(两份缓存)
batch_size = 同时处理的请求数
seq_len = 每个请求的 token 总数(prompt + 已生成)
以 Llama 2 7B(32 层,32 KV heads,head_dim=128,FP16)为例:
| 场景 | 模型权重 | KV Cache | 总显存 |
|---|---|---|---|
| 1 个请求,2K 上下文 | 14 GB | 67 MB | ~14 GB |
| 32 个请求,2K 上下文 | 14 GB | 2.1 GB | ~16 GB |
| 32 个请求,128K 上下文 | 14 GB | ~70 GB | ❌ 远超任何单卡 |
长上下文下,KV Cache 可以是模型本身的 5 倍。 这不是边缘场景------Agent、RAG、多轮对话都在往长上下文方向走。
2.3 传统分配方案的三个死穴
死穴 1:预留分配(Internal Fragmentation)
传统做法:请求进来时,按照 max_tokens(最大生成长度)一次性分配一块连续内存。
请求设定 max_tokens=2048
实际只生成了 50 个 token
→ 1998 个 token 的 KV 空间全浪费
→ 显存利用率 50/2048 = 2.4%
死穴 2:外部碎片(External Fragmentation)
不同请求的 KV Cache 长度不同,释放后留下的空洞无法被新请求利用:
显存布局: [请求A: 300 tokens] [空洞: 200] [请求B: 500 tokens] [空洞: 100]
新请求需要 350 tokens → 放不进任何空洞 → 尽管总空闲 300 tokens,无法分配
死穴 3:预填充(Prefill)与解码(Decode)的节奏不匹配
自回归推理分为两个物理特征完全不同的阶段:
| 阶段 | 做什么 | 瓶颈在哪 |
|---|---|---|
| Prefill | 一次性处理整个 prompt,计算所有 token 的 K/V,生成第一个输出 token | Compute-bound(GPU 算力是瓶颈) |
| Decode | 每步生成一个 token,只需计算最新 token 的 Q,其余 K/V 从缓存读 | Memory-bound(显存带宽是瓶颈) |
Prefill 是 compute-bound 的密集计算,Decode 是 memory-bound 的带宽受限。静态 batching 把这两个阶段混在一起------一个长 prompt 的 prefill 会卡住所有其他请求的 decode,导致"慢的拖死快的"。
三、PagedAttention:把 KV Cache 当虚拟内存管
3.1 核心洞见
vLLM 团队的 insight 来自操作系统教科书:OS 不会给每个进程分配一整块连续物理内存,而是用分页机制------物理内存切成固定大小的 Page(4KB),进程看到的是连续的虚拟地址,实际物理内存分散在不同 Page 里。
KV Cache 为什么不能这样做?
PagedAttention 把每个请求的 KV Cache 切成固定大小的 Block (默认 16 tokens/Block),用一张 Block Table(映射表)管理 "逻辑 Block 序号 → 物理 Block 地址" 的映射。
传统分配(连续数组):
KV Cache for Req A: [████████████████████████████████████████]
一整块连续内存,预分配 2048 tokens,实际用了 50
浪费: 1998 tokens × KV_cache_per_token ≈ 几十 MB 白费
PagedAttention(分页 + Block Table):
逻辑 Block 0 ──→ 物理 Block 7
逻辑 Block 1 ──→ 物理 Block 3 ← 只分配实际用到的 Block
逻辑 Block 2 ──→ 物理 Block 12
逻辑 Block 3 ──→ 物理 Block 5
浪费: 最后一个 Block 内不满 16 token 的部分
(< 16 tokens × KV_cache_per_token ≈ < 1 MB)
物理 Block 不需要连续。 Block 7 和 Block 3 在显存里可能相距甚远------但 Block Table 维护了逻辑顺序,attention 计算时按表查物理地址即可。
3.2 内存分配与回收机制
分配:按需增长
一个请求最初只分配少量 Block(比如足够存 prompt 的长度)。每生成 16 个新 token,才向全局空闲 Block 池申请一个新 Block。如果池子空了,触发回收。
回收:引用计数 + LRU
每个物理 Block 有一个引用计数器。vLLM 的淘汰策略:
1. 优先淘汰 ref_count = 0 的 Block(没有任何请求在使用)
2. 在 ref_count = 0 的 Block 中按 LRU 淘汰(最近最少使用)
3. 如果仍然不够 → 抢占(preemption):驱逐某个活跃请求的所有 Block,
后续需要时重新计算(recompute)或从 CPU 内存换回(swap)
共享:Copy-on-Write
如果两个请求共享同一段前缀(比如同样的 system prompt),它们的 Block Table 可以指向相同的物理 Block,ref_count 设为 2。只有当某个请求的生成内容超出了共享前缀范围时,才为它分配新的物理 Block------此时共享 Block 保持不变(ref_count 减为 1)。
两个请求共享 system prompt(1000 tokens ≈ 63 Blocks):
Req A Block Table: [共享Block_1][共享Block_2]...[共享Block_63][独有Block_A64]...
Req B Block Table: [共享Block_1][共享Block_2]...[共享Block_63][独有Block_B64]...
↑ 前 63 个 Block 是同一块物理内存,ref_count = 2
3.3 Attention 计算如何适配分页?
有了分页后,attention 的 K/V 不再连续。GPU kernel 在计算时需要查 Block Table 来定位物理地址:
传统 attention kernel(连续):
K_ptr = kv_cache + seq_start // 直接偏移访问
PagedAttention kernel(分页):
for block_idx in 0..num_blocks:
physical_idx = block_table[block_idx] // ← 查映射表
K_ptr = kv_cache + physical_idx * block_size
对当前 block 内的 16 个 token 计算 attention
增加的寻址开销(查一次 Block Table + 多一次指针跳转)远小于省下的显存带来的吞吐提升------显存利用率从 20% 到 96%,batch size 可以大 5 倍。
3.4 Block 大小为什么是 16?
| Block 大小 | 优点 | 缺点 |
|---|---|---|
| 大(如 128) | 映射表小,attention 寻址少 | 退回连续分配老路,浪费多 |
| 小(如 1) | 零浪费,精确匹配 | 映射表巨大,attention 需要查太多次表 |
| 16(vLLM 默认) | 平衡浪费和开销 | --- |
vLLM 选择了 16 作为平衡点。但 SGLang 后来把它改成了 1------后面会解释为什么以及代价是什么。
3.5 vLLM 的自动前缀缓存(APC)
PagedAttention 的 Block 机制天然支持前缀共享------但需要一个查找机制来判断"新请求的前缀是不是已经有人算过了"。
vLLM 的 APC 使用哈希表方案:
1. 为新请求的每个 Block 计算一个哈希值(基于 token 内容)
2. 在全局哈希表中查找:hash → 已有物理 Block?
3. 如果命中 → 复用该 Block(ref_count++)
4. 如果未命中 → 分配新 Block,计算 KV,写入哈希表
5. 一旦某个 Block 哈希不匹配 → 后续 Block 的哈希也不同(链式依赖)→ 复用截止
哈希表的优点: 实现简单,O(1) 查找。缺点: 只能匹配完整的 16-token Block------如果两个请求的前缀在 Block 中间分叉(比如第 20 个 token 不同),第 2 个 Block 的哈希全变了,只能复用前 1 个 Block。这就是 Block 对齐损失。
四、Continuous Batching:GPU 不再等人
4.1 Prefill 和 Decode 为什么必须分开调度?
回到第二章提到的两个阶段:
Prefill(一次处理整个 prompt):
计算量: O(n²) --- 每个 token 都要和所有 token 做 attention
GPU 状态: 算力打满,Compute-bound
耗时: 取决于 prompt 长度(几百 ms 到几秒)
Decode(每步生成一个 token):
计算量: O(n) --- 只有最新 token 需要和 KV Cache 做 attention
GPU 状态: 带宽打满,Memory-bound
耗时: 每步约 10-50ms(取决于模型大小和显存带宽)
传统静态 batching 的问题:一个长 prompt 的 prefill(Compute-bound 的几秒)卡住了所有其他请求的 decode(每步只需要几十 ms)。而且一旦有人做完,它的空位不能被新请求填补------因为 KV Cache 是连续分配的,释放后留下碎片。
4.2 Continuous Batching 的核心机制
vLLM 的做法:每生成一步 token,重新组一次批。
调度循环(每步一次):
1. SCHEDULE:
a. 所有正在 decode 的请求优先调度(保证延迟稳定)
b. 用剩余的 token 预算调度 chunked prefill(把长 prompt 切成小块)
c. 有请求完成 → 释放其 Block → 从等待队列调度新请求
2. FORWARD PASS:
模型跑一次前向传播(prefill chunk + decode token 混在一起算)
3. POSTPROCESS:
检查 stop 条件(EOS、stop string、max_tokens)
完成的请求 → 释放 KV Cache Block,通知客户端
未完成的 → 回到步骤 1
关键:步骤 1-3 在每生成一个 token 后重复。 一个请求完成了,下一步就换新请求进来。GPU 不会因为等待"最慢的那个人"而空闲。
4.3 Chunked Prefill:不让长 prompt 卡死所有人
如果不用 chunked prefill,一个 8192 token 的 prompt 会在一次 prefill 中垄断 GPU 几秒钟------期间所有 decode 请求都要等。
Chunked prefill 把这个长 prompt 切成小块(受 max_num_batched_tokens 控制,默认 2048),然后把 prefill 的小块和 decode 混在一起调度:
Step 1: Prefill Req_A tokens 1-2048 + Decode Req_B/C/D
Step 2: Prefill Req_A tokens 2049-4096 + Decode Req_B/C/D
Step 3: Prefill Req_A tokens 4097-6144 + Decode Req_B/C/D/E (E 刚到达)
Step 4: Prefill Req_A tokens 6145-8192 + Decode Req_B/C/D/E
Step 5: All decode (包含 Req_A 的第一个生成 token)
收益: 长 prompt 的 TTFT(首个 token 延迟)虽然略增,但不会影响其他请求的 decode 延迟------prefill 的密集计算被"切碎"分散到了多个 step 中。
4.4 抢占:显存不够怎么办?
当 Block 池耗尽时,vLLM 有两种方式回收资源:
| 策略 | 做法 | 代价 |
|---|---|---|
| Recompute | 丢弃某个请求的所有 KV Cache Block,需要时重新跑 prefill | 重新计算的开销(但 prefill 本来就快) |
| Swap | 把 Block 换出到 CPU 内存,需要时换回 | CPU↔GPU 传输带宽开销 |
vLLM 默认选择 recompute------因为 PagedAttention 下 prefill 已经很快,重新算一遍比在 CPU 和 GPU 倒腾数据更省时间。
五、前缀缓存演进:哈希表 vs 基数树
5.1 两种数据结构解决同一个问题
PagedAttention 解决了"怎么存不浪费",Continuous Batching 解决了"怎么排不等"---但还有一个问题没碰:
如果新请求的前缀和某个已完成请求的前缀一模一样,为什么还要重新算一遍 KV?
两个请求共享 system prompt(2000 tokens)是很常见的场景。vLLM 的 APC(3.5 节)用哈希表做 Block 级复用,SGLang 的 RadixAttention 用 Radix Tree(基数树)做 Token 级复用。两种方案都能复用前缀------但精度和适用场景差别很大。
vLLM APC(哈希表):
哈希链:
Block 0: hash(token[0:16]) ──→ 缓存命中?✅
Block 1: hash(Block[0].hash + token[16:32]) ──→ 命中?✅
Block 2: hash(Block[1].hash + token[32:48]) ──→ 命中?❌
→ 从 Block 2 开始必须重新计算 KV
→ Block 0-1 被复用(32 tokens)✓,Block 2-... 全重算
一旦某个 Block 不匹配,其哈希变了,后续所有 Block 的哈希链都断掉------只能复用分歧点之前的 Block。而且 Block 是 16 token 的固定单位------如果分歧发生在 token 20,token 17-19 也不能被复用(它们和 token 0-15 在同一个 Block 1 中)。
SGLang RadixAttention(Radix Tree):
基数树结构(边 = token 序列,节点 = KV Cache):
根
└── token[0:1999] (system prompt, 2000 tokens)
├── token[2000:2099] (用户问题1: "什么是PagedAttention?")
│ └── token[2100:...](助手回复1...)
└── token[2000:2099] (用户问题2: "什么是RadixAttention?")
└── token[2100:...](助手回复2...)
新请求 = system prompt + "什么是PagedAttention?" + 新一轮对话
→ 沿树匹配到最长前缀 (system prompt + 整个历史对话)
→ 只计算新增 token 的 KV
5.2 Radix Tree 的节点分裂机制
RadixAttention 的核心操作是节点分裂(node split)。当一个新请求的前缀和树中某个节点"部分匹配"时:
当前树:
节点 A: "你好,请问什么是PagedAttention"
新请求: "你好,请问什么是RadixAttention"
匹配过程:
1. 从根出发,找到节点 A
2. 逐 token 比对: "你好,请问什么是" ← 10 tokens 完全匹配
3. 第 11 个 token: "P" vs "R" ← 不匹配
节点分裂:
分裂前: [节点 A: "你好,请问什么是PagedAttention"]
分裂后: [公共节点: "你好,请问什么是"]
├── [子节点: "PagedAttention" → 原 KV Cache 的尾部]
└── [子节点: "RadixAttention" → 新分配的 KV Cache]
新请求复用公共节点的 10 个 token 的 KV Cache ✓
只计算后续 14 个 token ✓
这种 token 级精确匹配 + 节点动态分裂,是 Radix Tree 对比哈希表的核心优势。
5.3 为什么 page_size 从 16 降到 1
vLLM 的 Block 对齐损失(Block 内 16 个 token 必须全匹配)在 Radix Tree 中不存在------因为树是 token 级的。SGLang 把 Block 大小设为 1(page_size=1),每个物理 Block 只存 1 个 token 的 KV 对。
| vLLM APC (page_size=16) | SGLang RadixAttention (page_size=1) | |
|---|---|---|
| 前缀匹配精度 | 16-token 对齐边界 | 逐 token 精确匹配 |
| 分歧发生在 token 20 | 只能复用前 16 tokens | 可以复用前 20 tokens |
| 元数据开销 | Block Table 小 | Block Table 大(×16) |
| 主要适用场景 | 模板化批量推理(前缀完全相同) | 多轮对话、Agent(前缀动态变化) |
代价: 映射表大小 ×16------从"每 16 token 一条映射"变成"每 token 一条映射"。但在实际部署中,省下的 KV Cache 重算开销(尤其在多轮对话中可能重算几千个 token)远大于增加的元数据开销。
5.4 淘汰策略:树感知 vs 平坦 LRU
两种方案在"显存不够了该删谁"这件事上也不同:
vLLM APC(平坦 LRU):
每个 Block 是独立的,按最后访问时间排序,最久没用的先删。不考虑 Block 之间的树形依赖。
SGLang RadixAttention(树感知 LRU):
从叶子节点开始删除,向父节点方向传播:
树结构: [公共前缀: 1000 tokens, ref_count=3]
├── [叶子A: 200 tokens, ref_count=1]
├── [叶子B: 500 tokens, ref_count=1, LRU 最旧]
└── [叶子C: 300 tokens, ref_count=1]
淘汰叶子B:
1. 删除叶子B的 500 tokens KV Cache
2. 检查父节点: ref_count 从 3 降为 2 > 0 → 保留
3. 如果某个父节点的 ref_count 降为 0 且没有其他子节点 → 继续向上删除
为什么这个设计好? 公共前缀(ref_count 最高的节点)是缓存经济性最高的部分------多个请求都在用。树感知淘汰优先删叶子(只有一个请求在用),保护主干(多个请求共享),最大化缓存的"复用价值"而不是只看"最近用了没有"。
5.5 数字说话:一个 RAG 场景的对比
100 并发用户,每个请求 = 400-token system prompt + 3 篇检索文档(600 token/篇),文档之间有 60% 重叠:
| 方案 | 每批次 Prefill Token 数 | 减少幅度 |
|---|---|---|
| 无缓存 | 225,000 | baseline |
| vLLM APC(哈希表) | 125,000 | -44% |
| SGLang RadixAttention(基数树) | 65,000 | -71% |
Prefill 减少 71% 意味着:
- 吞吐提升约 3.5×(prefill 是 compute-bound 瓶颈)
- TTFT 降低约 60%(不需要重算已被其他请求算过的前缀)
- KV Cache 显存节省约 40%(共享前缀只存一份)
六、三层递进的架构总结
把三个机制放在同一张图里,它们之间的依赖关系清晰可见:
Layer 1: PagedAttention(物理基础)
· 把 KV Cache 切成 Block,用 Block Table 管理映射
· 消除了连续分配的内外部碎片
· 显存利用率: 20% → 96%
· 提供了 Copy-on-Write 共享和按需分配的能力
↓ 没有 Layer 1,Layer 2 不可能实现 ↓
Layer 2: Continuous Batching(调度层)
· 每步 token 后重新组批,有人做完就换人
· Chunked Prefill: 把长 prompt 切碎混入 decode 调度
· Prefill/decode 分离调度: decode 优先,prefill 填空
· GPU 不再因为等最慢的请求而空闲
↓ Layer 2 让 KV Cache 的利用率问题解决了
↓ 但它没解决同一个前辍算两次的浪费 ↓
Layer 3: 前缀缓存(复用层)
vLLM APC (哈希表) SGLang RadixAttention (基数树)
· Block 级复用 · Token 级复用
· 16-token 对齐损失 · 节点分裂,任意位置匹配
· 平坦 LRU 淘汰 · 树感知淘汰,保护共享主干
· 批量推理胜出 · 多轮对话/Agent 胜出 (3-5×)
每一层都依赖前一层提供的物理基础。 PagedAttention 的分页使 Continuous Batching 成为可能(消除了碎片约束),PagedAttention 的 Block 共享语义使前缀缓存成为可能(Block Table 可以指向相同的物理 Block)。
七、vLLM vs SGLang:选型指南
| 维度 | vLLM | SGLang |
|---|---|---|
| 核心 KV 管理 | PagedAttention(16 token/Block) | PagedAttention + page_size=1 |
| 前缀缓存 | 哈希表 Block 级复用 | Radix Tree Token 级复用 |
| 淘汰策略 | 平坦 LRU | 树感知 LRU(从叶子到根) |
| Continuous Batching | ✅ | ✅ |
| Chunked Prefill | ✅ | ✅ |
| 结构化输出 | 基础支持 | ✅ 原生正则/EBNF 约束解码 |
| 单轮简单生成 | 接近 | 接近 |
| 多轮对话/Agent/RAG | baseline | 3-5× 吞吐提升 |
| 稳定性 | 生产验证成熟 | 快速增长,偶有 bug |
| 社区 | 54K+ stars | 快速增长中 |
| 学习曲线 | 1-2 天上手 | 3-5 天 |
一句话: 单轮生成、批量推理 → vLLM;多轮对话、Agent、RAG → SGLang。两者 API 兼容 OpenAI 格式,换框架基本不换代码。混用(vLLM 负责简单场景,SGLang 负责复杂 Agent 场景)也是成熟实践。
八、总结
回到开篇的问题:同样的 GPU、同样的模型,为什么 HuggingFace 默认实现和 vLLM 差了 10-24×?
答案不在更好的硬件,而在 KV Cache 的三层递进式管理:
HuggingFace Transformers(默认):
KV Cache: 连续数组预分配 显存利用率: ~20%
Batching: 静态(等所有人做完) GPU 利用率: ~50%
前缀复用: 无
vLLM:
KV Cache: 分页虚拟内存 显存利用率: ~96%
Batching: 动态(有人做完就换人) GPU 利用率: ~95%+
前缀复用: 哈希表 Block 级
SGLang (vLLM + RadixAttention):
KV Cache: 前缀树 Token 级共享 多轮显存: 再省 30-50%
Batching: 同 vLLM 多轮吞吐: 3-5× vLLM
前缀复用: 基数树 Token 级 + 树感知淘汰
这三个机制不是平行的功能,而是依赖的阶梯。 不理解 PagedAttention 的分页原理,就无法理解为什么 Continuous Batching 可行;不理解哈希表和基数树的差异,就无法理解为什么同样的场景、同样的模型,两个引擎的吞吐可以差 5×。
附录:进一步阅读
- vLLM 论文 (SOSP 2023): Efficient Memory Management for Large Language Model Serving with PagedAttention
- SGLang 论文 (NeurIPS 2024): SGLang: Efficient Execution of Structured Language Model Programs
- DigitalOcean: Continuous Batching Mechanics: The Scheduler Behind vLLM, TGI, and SGLang
- vLLM 项目: github.com/vllm-project/vllm
- SGLang 项目: github.com/sgl-project/sglang
- SGLang RadixAttention 深入分析: datawhalechina/llm-algo-leetcode
- Runpod 对比测试: SGLang vs vLLM: KV Cache Performance