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

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 毛刺。解法:①分块 prefillP/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 GBInfiniBand 只有 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() 快 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.28GBInfiniBand 只有 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_seqsenable_chunked_prefill(有长输入必开)。⭐守 P99 延迟最直接的手段是限制 max_num_seqs——压测出"并发→P99"曲线,让超出的排队而不是拖垮所有人

下一节 👉 18-量化.md

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