🏠 总目录📚 本教程 连续批处理 ← →
📑 本页目录(点开跳转)

17 · 连续批处理与 PagedAttention

⏱ 38 分钟 | ⭐ vLLM 快 20 倍的两个原因


🎯 一句话

静态批处理让快的请求等慢的,朴素显存管理浪费 60-80% 的 KV Cache。 这一章的两个技术分别解决这两件事 —— 它们合起来就是现代推理引擎和 model.generate() 之间的全部差距。

静态批处理短请求做完也得干等灰格 = 白白浪费的算力连续批处理空位立刻被新请求填上橙色 = 中途插进来的新请求同样的 4 条槽位,利用率天差地别横轴是时间步,每一格是一次前向⭐ 关键洞察:生成任务的长度是【事先不知道】的,所以静态批一定会有大量空转连续批处理在每一步结束后重新组批 —— 吞吐常能提升好几倍
同样 4 条槽位,灰格就是白白浪费掉的算力。⭐ 关键洞察:生成任务的长度事先不知道,所以静态批一定会有大量空转。连续批处理在每一步结束后重新组批,空位立刻被新请求填上 —— 吞吐常能提升好几倍。

🐌 一、问题一:静态批处理在浪费什么

流程图

传统做法:凑齐一批请求→一起跑→全部跑完→再收下一批
请求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:把虚拟内存搬过来

连续分配(预留最长长度)请求1请求2请求3灰格全是预留但用不上的显存浪费常常超过 60%分页(按需分配小块)012345678物理页请求105请求21268请求33逻辑上连续,物理上可以散着放和操作系统的虚拟内存分页,是同一个思想⭐ 因为 KV Cache 的长度事先不知道,预留就一定浪费、不预留就可能不够分页把「必须连续」这个约束去掉了 —— 于是能装下的并发请求数大幅提升额外好处:多个请求共享同一段前缀时,可以直接共享物理页(写时复制)
和操作系统的虚拟内存分页是同一个思想。⭐ KV Cache 长度事先不知道 —— 预留就一定浪费,不预留就可能不够;分页把「必须连续」这个约束去掉了。额外好处:共享前缀时可以直接共享物理页。

信息关系

问题(第 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 要按压测设死 ⚠️

✅ 检查点

  1. 静态批处理浪费在哪?根源是什么?
  2. 连续批处理的核心思想是什么?吞吐提升多少?
  3. 为什么真实生产环境里连续批处理的收益比 benchmark 还明显?
  4. Prefill 插进 Decode 批次里会造成什么问题?三种解法是什么?
  5. P/D 分离的动机是什么?
  6. P/D 分离的代价是什么?70B 模型 4000 token 的输入,要搬多少 KV?跨机走 IB 大概要多久?
  7. 什么时候【不该】上 P/D 分离?该先做什么?
  8. PagedAttention 借鉴了什么思想?它怎么消除两种碎片?
  9. KV Cache 利用率从多少提到多少?
  10. PagedAttention 的"意外收益"是什么?举三个受益场景。
  11. 守 P99 延迟最直接的手段是什么?
👀 答案
  1. 浪费在已完成的请求还占着槽位空转,新请求要等整批结束。根源:LLM 的输出长度不可预测(可能差 200 倍),整批效率被最长的请求决定。
  2. 以迭代为单位调度而不是以请求为单位——每生成一个 token 后就检查完成的踢出、新来的加入。吞吐提升 5-20 倍。
  3. 因为输出长度方差越大浪费越多,而真实流量里长短请求混杂、方差极大。
  4. 长 Prefill(算力密集)插进来会让这一步变很慢,所有正在 decode 的请求都卡一下 → TPOT 毛刺。解法:①分块 prefill ②P/D 分离 ③限制每步 prefill token 预算。
  5. 因为 Prefill 要算力、Decode 要带宽,硬件需求完全不同,分开部署可以各自用最合适的卡和并行策略。
  6. 代价是 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 上。
  7. 输入短、输出短、低并发、只有以太网这四种情况都不该分。该先做的是分块 Prefill——它只是一个参数,已经能解决 TPOT 毛刺;只有规模大到"Prefill 和 Decode 想用不同卡型/不同并行度"时,P/D 分离才开始值。
  8. 借鉴操作系统的虚拟内存分页。KV Cache 切成固定大小的块,每个请求一张块表记录逻辑块→物理块映射,物理块不需要连续。内部碎片最多浪费一个块(<16 token),外部碎片因为块大小统一而完全消除。
  9. 从 20-40% 提到 90%+,能开的 batch 大 2-4 倍。
  10. 块共享(写时复制)——因为是按块管理+引用计数。三个场景:前缀共享(相同系统提示词)、并行采样(同一 prompt 生成 n 个候选,省 50%+ 显存)、Beam Search(各 beam 共享公共前缀)。
  11. 限制 max_num_seqs。先压测出"并发数→P99 TPOT"曲线,找到满足 SLO 的最大并发设成上限,超出的排队而不是拖垮所有人。

🛑 可以停在这里

⚡ 走神救援

⭐ vLLM 比 model.generate() 快一个量级就靠这两个技术。

问题一是静态批处理:凑齐一批跑完再收下一批,做完的请求占着槽位空转。根源是 ⭐ 输出长度不可预测(可能差两个数量级),整批效率被最长的那个请求决定。连续批处理把调度单位从「请求」换成「迭代」——每生成一个 token 就踢出完成的、放进新的。⭐ 真实流量方差大,所以生产收益比 benchmark 上还明显。

⚠️ 代价是 Prefill 插进 Decode 批次会造成 TPOT 毛刺。三个解法里 ⭐ 顺序千万别搞反:先上分块 Prefill(一个参数的事,已经能解决毛刺)。💸 P/D 分离不是免费的:Prefill 算出的 KV 要跨机搬——七十亿级模型几千 token 的输入就是 GB 量级,而机间带宽比显存低约两个数量级,这段时间直接加在 TTFT 上。⭐ 所以只有输入长且输出也长、或规模大到两边想用不同卡型时才划算。

问题二是显存碎片(利用率只有两三成)。PagedAttention 把操作系统的虚拟内存分页搬过来:KV Cache 切成固定块、每请求一张块表,⭐ 物理块不需要连续——内部碎片最多浪费一个块、外部碎片完全消除。

⭐ 意外收益是块共享加写时复制:前缀共享、并行采样、Beam Search 共享公共前缀几乎免费。

⭐ 守 P99 最直接的手段是限制最大并发序列数——让超出的排队,而不是拖垮所有人。 ⚠️ 另外 max_model_len 设大了是白白预留显存。

下一节 👉 18-量化.md

打卡记录保存在你的浏览器里,首页能看到总进度