📑 本页目录(点开跳转)
17 · 连续批处理与 PagedAttention
⏱ 38 分钟 | ⭐⭐ vLLM 快 20 倍的两个原因
🎯 一句话
静态批处理让快的请求等慢的,朴素显存管理浪费 60-80% 的 KV Cache。
这一章的两个技术分别解决这两件事 ——
它们合起来就是现代推理引擎和 model.generate() 之间的全部差距。
🐌 一、问题一:静态批处理在浪费什么
传统做法:凑齐一批请求 → 一起跑 → 全部跑完 → 再收下一批
请求A(输出 10 token) ████░░░░░░░░░░░░░░░░ ← 早就完了,但在等
请求B(输出 20 token) ████████░░░░░░░░░░░░ ← 也在等
请求C(输出 500 token) ████████████████████ ← 大家等它
请求D(输出 15 token) ██████░░░░░░░░░░░░░░ ← 也在等
↑ 实际计算 ↑ 纯浪费
💀 两个后果:
① GPU 在为"已完成的请求"做无意义的计算
② 新来的请求必须等这一整批结束才能进来(排队延迟极高)
🔑 问题的根源:LLM 的输出长度是不可预测的。 一个请求可能输出 10 个 token,另一个 2000 个 —— 差 200 倍。 静态批处理让整批的效率被最长的那个请求决定。
⭐ 二、连续批处理(Continuous Batching)
⭐ 核心思想:以【迭代】为单位调度,而不是以【请求】为单位
每生成一个 token 之后:
① 检查有没有请求完成了 → 立刻踢出,释放它的 KV Cache
② 检查队列里有没有新请求 → 立刻加进来
③ 继续下一个 token
→ batch 的成员在【每一步】都可能变化
时间 ──────────────────────────────►
槽位1 [A A A A][E E E E E E E E E] ← A 完成后 E 立刻补上 ⭐
槽位2 [B B B B B B B B][F F F F F]
槽位3 [C C C C C C C C C C C C C C]
槽位4 [D D D][G G G G G][H H H H H]
↑ 随时进出,GPU 一直满载
| 收益 | 说明 |
|---|---|
| 吞吐 | 提升 5-20 倍(取决于输出长度的方差)⭐ |
| 排队延迟 | 从"等一整批"降到"等一个 token 的时间" |
| GPU 利用率 | 从锯齿状变成持续满载 |
💡 为什么收益这么大:输出长度方差越大,静态批处理浪费越多。 真实流量里长短请求混杂,方差极大 —— 所以连续批处理的收益在生产环境里比 benchmark 还明显。
⚠️ Prefill 和 Decode 混在一起的麻烦
⚠️ 新请求进来要做 Prefill(算力密集、耗时长)
而在跑的请求在做 Decode(一步很快)
→ 如果把一个 4000 token 的 Prefill 插进来,
这一步会变得很慢 → 所有正在 decode 的请求【都卡了一下】
→ TPOT 出现毛刺 💀
✅ 三种解法:
① 【分块 Prefill】(chunked prefill)⭐
把长 Prefill 切成小块,分散到多个迭代里
② 【Prefill/Decode 分离】(P/D disaggregation)⭐
用不同的 GPU 池分别做 Prefill 和 Decode
③ 限制每步的 Prefill token 预算
⭐ P/D 分离是 2024 年之后大规模部署的重要趋势: Prefill 和 Decode 的硬件需求完全不同(第 15 章)—— Prefill 要算力,Decode 要带宽。分开部署可以各自用最合适的卡和并行策略。
💸 P/D 分离的那笔账:KV 要跨机搬
上面那句"分开部署"听起来是免费的午餐,它不是。
⚠️ 分开之后多了一件原本不存在的事:
Prefill 节点算完整个输入的 KV Cache
→ 这堆 KV 必须【搬到】Decode 节点去
→ 因为 Decode 那一步要读它
搬多少?直接用第 16 章的公式接着算。以 Llama-3 70B(80 层、GQA-8、head_dim 128、BF16) 为例:
每 token 每层 = 2 × 8 × 128 × 2 字节 = 4 KB
× 80 层 = 320 KB / token ⭐
→ 4000 token 的输入:320 KB × 4000 ≈ 1.28 GB 要搬这么多
再看这条链路有多宽(附录 A 的带宽表):
| 链路 | 带宽 | 搬 1.28 GB 要多久 |
|---|---|---|
| HBM(同一张卡内,不用搬) | 2000 GB/s | — |
| NVLink(同一台机器) | 450–900 GB/s | 1.4–2.8 ms |
| InfiniBand(跨机)⚠️ | 25–50 GB/s | 26–51 ms 💀 |
| 以太网 | 1–12 GB/s | 107 ms – 1.3 s 💀💀 |
⚠️ 注意 InfiniBand 那一行:它只有 HBM 的约 1/60。 这就是 P/D 分离真正的代价 —— 你把一件"本来在显存里现成就有"的事,变成了一次跨机传输。 这条链路直接加在 TTFT 上:算完 Prefill 还要再等几十毫秒才能开始吐第一个字。
所以它什么时候划算、什么时候不划算:
| 情况 | 判断 | 为什么 |
|---|---|---|
| 输入长、输出也长 | ✅ 划算 | 传输是一次性的,摊到几百个 decode step 上可以忽略 |
| 超大规模、Prefill 和 Decode 各自要几十上百张卡 | ✅ 划算 ⭐ | 两边能各自选卡型和并行度,省下的钱远大于传输代价 |
| 输入短(几百 token) | ❌ 别分 | 搬运的固定开销比省下来的还多 |
| 输出短(比如只出几十个 token) | ❌ 别分 | 传输摊不开,直接把 TTFT 拉高 |
| 低并发 / 单机就够 | ❌ 别分 ⭐ | 分块 Prefill 已经能解决 TPOT 毛刺,别为了架构好看付这笔钱 |
| 只有以太网 | ❌ 别分 💀 | 上表最后一行,几百毫秒起步 |
⭐ 顺序不要搞反:先上分块 Prefill(一个参数的事), 只有当规模大到"Prefill 和 Decode 想用不同卡型 / 不同并行度"时,P/D 分离才开始值。
💡 业界有专门做这层 KV 传输的组件(Mooncake、LMCache 这类), 核心思路都是把 KV 当成一份可以跨节点存取和复用的缓存: 传输和计算重叠(一层算完就开始传这一层)、复用已有前缀(不用重传)、 必要时经 CPU 内存中转。⚠️ 但它们改变的是常数,不是量级 —— 上面那张带宽表是物理事实,先看你的输入输出长度配不配得上这笔搬运费。
📄 三、PagedAttention:把虚拟内存搬过来
问题(第 16 章):为每个请求预分配连续显存 → 利用率只有 20-40%
⭐ 灵感:操作系统怎么解决同样的问题?
→ 虚拟内存分页!物理内存分成固定大小的页,
进程看到的是连续的虚拟地址,实际映射到分散的物理页
PagedAttention:
· KV Cache 切成固定大小的【块】(block,通常 16 个 token)
· 每个请求有一张【块表】(block table),记录它的逻辑块 → 物理块的映射
· 物理块从全局池里按需分配,【不需要连续】⭐
请求A 的块表: [物理块 7, 物理块 3, 物理块 15]
请求B 的块表: [物理块 2, 物理块 9]
↑ 物理上完全分散,逻辑上连续
⭐ 效果:
· 内部碎片:最多浪费【一个块】(<16 token),而不是整个 max_len
· 外部碎片:完全消除(所有块大小一样)
· 利用率:20-40% → 【90%+】⭐
→ 能开的 batch 大 2-4 倍 → 吞吐跟着涨
⭐ 意外的收益:写时复制的共享
因为 KV Cache 现在是【按块管理 + 引用计数】的,
多个请求可以【共享物理块】:
① 前缀共享:相同的系统提示词 → 共享那些块(第 16 章的前缀缓存)
② 并行采样:同一个 prompt 生成 n 个候选 → 共享 prompt 的块 ⭐
③ Beam Search:各个 beam 共享公共前缀
→ 分歧的时候才【写时复制】(copy-on-write)新块
💡 并行采样场景下,显存能省 50%+
🔗 上面 ②③ 两条都是「解码策略」的名字,这一章只把它们当负载形态用,没讲它们各自在干什么: 《大模型全景导论》06b · 解码策略。 ⭐ 去那里主要为了两件对容量规划很实际的事: ① Beam Search 和「并行采样 n 个候选」看着都是 n 条路,用途完全相反: beam 是全局按累计对数概率排序取前 k,目标是「最可能的那一句」,所以 k 条 beam 常常只差一两个词; 并行采样是 n 次独立抽签,目标恰恰是「不一样的 n 个结果」。 ⚠️ 那一章的明确结论:要 n 个不同的候选就采样 n 次,别用 beam search。 ② beam 的成本是 k 份 KV Cache——这正是块共享在救的东西,也是它值得在这一节被点名的原因; 但块共享只能省掉公共前缀那部分,分歧之后每条 beam 的显存还是各算各的。 那一章还有一个真实的搬运事故:把翻译服务调好的
num_beams=5原样搬到「生成营销文案」上。🔑 这是 PagedAttention 最漂亮的地方: 它解决碎片问题的同时,顺带让"共享前缀"变成几乎免费的。 而共享前缀在 Agent、多候选生成、树搜索里到处都是。
🧰 四、实际怎么用
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
tensor_parallel_size=2, # 张量并行(第 12 章)
gpu_memory_utilization=0.90, # ⭐ 留 10% 余量,调高能装更多 KV
max_model_len=8192, # ⭐ 别设太大,它决定预留空间
enable_prefix_caching=True, # ⭐ 前缀缓存(第 16 章)
kv_cache_dtype="fp8", # ⭐ KV 量化,能开 2 倍 batch
enable_chunked_prefill=True, # ⭐ 避免长 prefill 造成 TPOT 毛刺
)
outs = llm.generate(prompts, SamplingParams(temperature=0.7, max_tokens=512))
四个最该调的参数:
| 参数 | 作用 | 调法 |
|---|---|---|
gpu_memory_utilization |
留给 KV Cache 的显存比例 | 从 0.90 起,OOM 就降 ⭐ |
max_model_len |
最大上下文 | 按实际需求设,设大了白白预留 ⭐ |
max_num_seqs |
最大并发请求数 | 吞吐/延迟的权衡旋钮 |
enable_chunked_prefill |
分块 prefill | 有长输入时必开 |
📊 五、调度策略:吞吐和延迟的旋钮
每一步,调度器要决定:接纳多少新请求?
激进(尽量塞满)→ 吞吐高,但单请求延迟差
保守(限制并发)→ 延迟好,但吞吐低
⚠️ 还有一个风险:【抢占】
如果 KV Cache 不够了,正在跑的请求会被踢出(swap 或 recompute)
→ 那个请求的延迟会突然飙升
| 策略 | 适合 |
|---|---|
| FCFS(先来先服务) | 公平,默认 |
| 优先级队列 | 区分付费/免费用户 |
| 最短作业优先 | 降低平均延迟(但需要预测输出长度,难) |
限制 max_num_seqs |
⭐ 保证 P99 延迟的最直接手段 |
💡 一个实用建议:用
max_num_seqs来守 SLO。 先压测出"并发数 → P99 TPOT"的曲线, 找到满足 SLO 的最大并发数,把它设成上限 —— 超出的排队,而不是拖垮所有人。
🔗 和站内其他章的关系
| 相关的地方 | 这里的位置 |
|---|---|
| 第 15 章 批处理提吞吐 | 本章是怎么在动态请求下做到 ⭐ |
| 第 15 章 Prefill/Decode 性质不同 | 混合调度的麻烦 + P/D 分离 |
| 第 16 章 碎片浪费 60-80% | PagedAttention 解决它 ⭐ |
| 第 16 章 前缀缓存 | 靠 PagedAttention 的块共享实现 |
| 第 20 章 SLO | 调度策略的目标 |
| 《大模型全景导论》06 推理优化 PagedAttention 一节 | 那边只说"借鉴了虚拟内存";块表、写时复制、抢占这三件事在这里 ⭐ |
| 《模型上线之后》18 成本与容量 3 秒抖动变成 22 分钟不可用 | 本章的"抢占"正是这种排队雪崩的起点 —— 所以 max_num_seqs 要按压测设死 ⚠️ |
✅ 检查点
- 静态批处理浪费在哪?根源是什么?
- 连续批处理的核心思想是什么?吞吐提升多少?
- 为什么真实生产环境里连续批处理的收益比 benchmark 还明显?
- Prefill 插进 Decode 批次里会造成什么问题?三种解法是什么?
- P/D 分离的动机是什么?
- P/D 分离的代价是什么?70B 模型 4000 token 的输入,要搬多少 KV?跨机走 IB 大概要多久?
- 什么时候【不该】上 P/D 分离?该先做什么?
- PagedAttention 借鉴了什么思想?它怎么消除两种碎片?
- KV Cache 利用率从多少提到多少?
- PagedAttention 的"意外收益"是什么?举三个受益场景。
- 守 P99 延迟最直接的手段是什么?
👀 答案
- 浪费在已完成的请求还占着槽位空转,新请求要等整批结束。根源:LLM 的输出长度不可预测(可能差 200 倍),整批效率被最长的请求决定。
- 以迭代为单位调度而不是以请求为单位——每生成一个 token 后就检查完成的踢出、新来的加入。吞吐提升 5-20 倍。
- 因为输出长度方差越大浪费越多,而真实流量里长短请求混杂、方差极大。
- 长 Prefill(算力密集)插进来会让这一步变很慢,所有正在 decode 的请求都卡一下 → TPOT 毛刺。解法:①分块 prefill ②P/D 分离 ③限制每步 prefill token 预算。
- 因为 Prefill 要算力、Decode 要带宽,硬件需求完全不同,分开部署可以各自用最合适的卡和并行策略。
- 代价是 Prefill 算出来的 KV Cache 必须跨机搬到 Decode 节点。70B(80 层、GQA-8、head_dim 128、BF16)每 token 320 KB,4000 token 就是 约 1.28 GB;InfiniBand 只有 25–50 GB/s(HBM 的约 1/60)→ 26–51 ms,而且这段时间直接加在 TTFT 上。
- 输入短、输出短、低并发、只有以太网这四种情况都不该分。该先做的是分块 Prefill——它只是一个参数,已经能解决 TPOT 毛刺;只有规模大到"Prefill 和 Decode 想用不同卡型/不同并行度"时,P/D 分离才开始值。
- 借鉴操作系统的虚拟内存分页。KV Cache 切成固定大小的块,每个请求一张块表记录逻辑块→物理块映射,物理块不需要连续。内部碎片最多浪费一个块(<16 token),外部碎片因为块大小统一而完全消除。
- 从 20-40% 提到 90%+,能开的 batch 大 2-4 倍。
- 块共享(写时复制)——因为是按块管理+引用计数。三个场景:前缀共享(相同系统提示词)、并行采样(同一 prompt 生成 n 个候选,省 50%+ 显存)、Beam Search(各 beam 共享公共前缀)。
- 限制
max_num_seqs。先压测出"并发数→P99 TPOT"曲线,找到满足 SLO 的最大并发设成上限,超出的排队而不是拖垮所有人。
🛑 可以停在这里
⚡ 走神救援
⭐⭐vLLM 比
model.generate()快 20 倍就靠这两个技术。问题一:静态批处理——凑齐一批一起跑完再收下一批,已完成的请求占着槽位空转、新请求要等整批结束;根源是⭐LLM 输出长度不可预测(可能差 200 倍),整批效率被最长的请求决定。⭐连续批处理:以迭代为单位调度而非以请求为单位——每生成一个 token 就踢出完成的、加入新的,吞吐提升 5-20 倍;真实流量方差大,所以生产收益比 benchmark 还明显。⚠️麻烦是 Prefill 插进 Decode 批次会造成 TPOT 毛刺(长 prefill 让那一步变慢,所有 decode 请求都卡一下)→ 三解法:⭐分块 prefill、⭐P/D 分离(Prefill 要算力、Decode 要带宽,硬件需求完全不同,2024 后大规模部署的趋势)、限制 prefill 预算。💸⚠️但 P/D 分离不是免费的:分开之后 Prefill 算出的 KV 必须跨机搬到 Decode 节点——70B(80 层、GQA-8)每 token 320KB,4000 token 输入就要搬 1.28GB;InfiniBand 只有 25–50GB/s,是 HBM 的约 1/60 → 26–51ms,而且这段时间直接加在 TTFT 上(走以太网更是几百毫秒起)。⭐所以:输入长且输出也长、或规模大到两边想用不同卡型时才划算;输入短/输出短/低并发/只有以太网都别分——⭐顺序别搞反,先上分块 Prefill(一个参数的事),它已经能解决 TPOT 毛刺。业界的 Mooncake / LMCache 这类 KV 传输层能靠传输与计算重叠、复用已有前缀把常数压下来,但改变不了带宽的量级。问题二:显存碎片(第16章,利用率仅 20-40%)→ ⭐⭐PagedAttention 把操作系统的虚拟内存分页搬过来:KV Cache 切成固定大小的块(通常 16 token),每请求一张块表映射逻辑块→物理块,物理块不需要连续 → 内部碎片最多浪费一个块、外部碎片完全消除、利用率提到 90%+,batch 大 2-4 倍。⭐意外收益:块共享 + 写时复制——前缀共享、并行采样(省 50%+ 显存)、Beam Search 共享公共前缀;它解决碎片的同时顺带让"共享前缀"几乎免费。四个最该调的参数:gpu_memory_utilization(0.90 起)、⭐max_model_len(设大了白白预留)、max_num_seqs、enable_chunked_prefill(有长输入必开)。⭐守 P99 延迟最直接的手段是限制max_num_seqs——压测出"并发→P99"曲线,让超出的排队而不是拖垮所有人。
下一节 👉 18-量化.md