连续批处理与 PagedAttention 是 vLLM 一举成名的两块基石,也是现代 LLM 推理引擎的通用范式。理解它们,才能明白为什么同样一张 A100,朴素实现只能跑出几百 tokens/s,而 vLLM 能跑到两千以上。本文不满足于结论,而是把调度与显存两层的机制拆开讲透。
先给一个全局图景:连续批处理解决的是「算力被浪费在等待」的问题,PagedAttention 解决的是「显存被浪费在碎片」的问题。二者一个管时间、一个管空间,合在一起才让 GPU 的利用率逼近理论上限。这也是 推理引擎对比 中三大引擎性能差异的根源。
批处理的三个世代
要理解连续批处理的价值,必须先把它的前两代讲清楚。
静态批处理
静态批处理(Static Batching)是最朴素的方案:攒一批请求,凑成一个固定大小的批次,一起前向,等整批全部生成完毕再返回。
它的致命缺陷是长短请求互相拖累。假设一批里有 4 个请求,输出长度分别是 10、50、200、300 token。静态批处理必须等到最长的 300 完成,前三个请求早就生成完了,却要一直占着槽位。GPU 在大部分时间里只为一个请求工作,算力利用率极低。
同时,静态批处理需要为每个请求预分配最大长度的 KV Cache。如果 max-model-len 是 8192,即使实际只用了 300,也要按 8192 预留,显存浪费惊人。
动态批处理
动态批处理(Dynamic Batching)允许批次在请求层面重组:新请求到达时,如果当前批次有空位就加入,但仍在批次边界上做决策。它比静态好,但没有解决根本问题——一旦批次开始,中途无法插入或移除单个序列,短请求依然要等长请求。
连续批处理
连续批处理(Continuous Batching),又称迭代级调度(Iteration-level Scheduling),把调度粒度从「批次」降到「单次前向迭代」。每生成一个 token,调度器就重新审视:哪些序列已完成可以移出,哪些新序列可以加入。
这带来的变化是根本性的:
- 序列一生成完 EOS,立刻释放槽位,新请求马上补位。
- 批次大小在每个迭代都可能不同。
- GPU 几乎时刻处于饱和状态。
从「整批等待」到「逐个补位」,这是吞吐提升的主要来源之一。
三代对比
| 方案 | 调度粒度 | 短请求阻塞 | 显存预分配 | 典型 GPU 利用率 |
|---|---|---|---|---|
| 静态批处理 | 批次 | 严重 | 按最大长度 | 20% 到 40% |
| 动态批处理 | 批次边界 | 部分 | 按最大长度 | 40% 到 60% |
| 连续批处理 | 单次迭代 | 几乎消除 | 按需分页 | 70% 到 90% |
PagedAttention 原理
连续批处理解决了时间维度的浪费,PagedAttention 解决空间维度的浪费。
KV Cache 为什么是瓶颈
Transformer 推理时,每个已生成的 token 都要缓存其 Key 与 Value,供后续注意力计算使用。KV Cache 的大小为:
KV Cache 字节数 = 2 * num_layers * num_kv_heads * head_dim * seq_len * batch * dtype_bytes
以 Llama-3-8B(32 层、8 个 KV 头、head_dim 128、FP16)为例,单个 token 的 KV Cache 约为 128KB。一个 8192 上下文、并发 64 的请求集合,KV Cache 就需要约 64GB,几乎吃满一张 A100 80GB。而模型权重本身只有 16GB。
结论很清晰:在生产推理中,KV Cache 而非模型权重才是显存的主要消耗者。管理好 KV Cache,就管理好了并发能力。
朴素方案的碎片问题
朴素实现为每个序列分配一段连续显存,长度按最大可能长度预留。这产生两类碎片:
- 内部碎片:预分配 8192 但只用了 300,浪费的 7892 无法被其他序列使用。
- 外部碎片:不同序列的连续段之间产生无法利用的空隙。
两者叠加,实测显存浪费常达 60% 到 80%。也就是说,一张 80GB 的卡,只有 16GB 到 32GB 真正用于 KV Cache。
分页设计
PagedAttention 的核心思想来自操作系统的虚拟内存分页:把 KV Cache 切成固定大小的 block,不再要求序列在物理上连续。
- block 是显存分配的最小单位,默认 16 个 token。
- 一个序列的逻辑 KV 被切分为若干逻辑 block。
- 每个逻辑 block 通过 block table 映射到一个物理 block。
- 物理 block 可以分散在显存的任意位置。
这样,分配以 block 为单位按需进行,内部碎片最多只有一个 block 的尾部(小于 16 个 token 的空间),外部碎片则几乎消失。
block table 与地址映射
block table 是每个序列一张的映射表。注意力计算时,内核根据 block table 把逻辑位置翻译成物理地址,再读取对应的 K 与 V。由于 block 内 16 个 token 是连续的,读取仍然是高效的向量化访问。
映射过程可以类比:
- 逻辑位置
p属于第p // 16个逻辑 block。 - 该逻辑 block 在 block table 中的第
p // 16项给出物理 block 编号b。 - 物理偏移为
b * 16 + (p % 16)。
这层间接寻址的成本,远小于它带来的显存利用率提升。
前缀共享
分页还顺带带来了前缀共享能力。多个请求如果系统提示相同,它们的前缀 block 可以指向同一批物理 block,只需在 block table 里记录引用计数。在少样本提示或固定系统提示场景中,这能省下大量重复计算与显存。
为什么 block 内保持连续
一个自然的问题是:既然都分页了,为什么不干脆把 block 做到最小,比如一个 token 一块?
答案在访存的局部性。block 内 16 个 token 在物理上连续,注意力内核可以对这段连续内存做向量化读取,充分利用显存带宽。如果每个 token 都单独成块,块表会膨胀 16 倍,间接寻址开销也会显著上升,反而拖慢推理。
因此 16 这个默认值是「碎片控制」与「访存效率」之间的平衡点:块足够小以限制内部碎片,又足够大以维持高效访存。
碎片对比
| 方案 | 内部碎片 | 外部碎片 | 可用显存利用率 | 并发能力 |
|---|---|---|---|---|
| 朴素连续分配 | 高(按最大长度) | 高 | 20% 到 40% | 低 |
| 仅连续批处理 | 高 | 高 | 40% 到 60% | 中 |
| PagedAttention | 极低(小于 1 block) | 近乎为零 | 大于 96% | 高 |
显存碎片从 60% 到 80% 的浪费,降到不足 4%,这是 PagedAttention 最直观的收益。
调度器的内部机制
连续批处理的调度逻辑看似简单,工程实现却有不少细节。理解这些细节,才能看懂引擎的日志与调优参数。
迭代循环的四个阶段
每个推理迭代大致经历四个阶段:
- 调度阶段:块管理器检查可用显存,决定本迭代接纳哪些新请求、哪些运行中的请求继续、哪些需要被抢占。
- 组装批次:把本迭代要处理的序列组成批次,包含新请求的预填充部分与运行中请求的解码部分。
- 前向计算:GPU 执行一次前向,为每个序列生成下一个 token。
- 后处理:更新每个序列的生成进度,检查是否遇到停止条件,释放已结束序列占用的 block。
这个循环以毫秒级频率重复,是连续批处理的心跳。
块管理器
块管理器(Block Manager)是 PagedAttention 的显存分配器,维护两套数据结构:
- 物理块池:记录哪些物理 block 空闲、哪些被占用,以及每个被占用 block 的引用计数。
- 逻辑到物理映射:每个序列一张 block table,记录其逻辑 block 到物理 block 的对应关系。
块管理器的关键操作包括分配、释放与拷贝写(copy-on-write)。拷贝写用于前缀共享场景:当两个序列共享同一物理 block,其中一个需要写入新 token 时,块管理器先复制该 block 再写入,保证另一个序列不受影响。这与操作系统内存分页的写时复制如出一辙。
调度策略
引擎通常支持多种调度策略:
| 策略 | 规则 | 适用场景 |
|---|---|---|
| 先到先服务 | 按到达顺序接纳 | 通用默认 |
| 优先级 | 高优先级请求先调度 | 有分级 SLO 的业务 |
| 最短作业优先 | 预估输出短的先调度 | 降低平均延迟 |
| 公平调度 | 按租户配额分配 | 多租户平台 |
生产环境最常见的是先到先服务加优先级抢占的组合。
抢占策略
当显存不足以接纳新请求时,调度器需要抢占正在运行的序列。vLLM 支持两种回收方式:
- 重算(Recompute):直接丢弃被抢占序列的 KV Cache,恢复时用已生成的 token 重新做一次前向。显存立即释放,代价是重复计算。
- 换出(Swap):把 KV Cache 拷贝到 CPU 内存,恢复时拷贝回来。保留计算结果,代价是 PCIe 带宽与主机内存。
选择依据:
- 序列较短、抢占不频繁时,重算的浪费可接受,优先重算。
- 序列很长、重算代价高时,换出更划算,但需要足够的 CPU 内存与 PCIe 带宽。
- vLLM 的默认策略会结合两者,并在日志中报告抢占次数,可用于判断显存压力。
KV Cache 显存计算实战
做容量规划时,必须能估算 KV Cache 占用。公式如下:
KV 字节数 = 2 * L * H_kv * D * S * B * P
其中 L 是层数,H_kv 是 KV 头数,D 是 head_dim,S 是序列长度,B 是并发批大小,P 是每个数值的字节数(FP16 为 2)。系数 2 来自 Key 与 Value 各一份。
下表给出常见模型在 FP16 下单 token 的 KV Cache 大小:
| 模型 | 层数 | KV 头数 | head_dim | 单 token KV | 8K 上下文单序列 |
|---|---|---|---|---|---|
| Llama-3-8B | 32 | 8 | 128 | 128KB | 1.0GB |
| Llama-3-70B | 80 | 8 | 128 | 320KB | 2.5GB |
| Qwen2.5-7B | 28 | 4 | 128 | 56KB | 0.44GB |
| Qwen2.5-32B | 64 | 8 | 128 | 256KB | 2.0GB |
几个推论:
- Llama-3-8B 权重约 16GB,但 8K 上下文下每个并发序列就要 1GB KV,64 并发即 64GB。这解释了为什么 KV Cache 才是并发瓶颈。
- 70B 的 KV Cache 是 8B 的 2.5 倍,且权重本身需要多卡,容量规划要同时考虑两者。
- 使用 GQA(分组查询注意力)的模型 KV 头数少,KV Cache 显著更小,这是现代模型普遍采用 GQA 的原因之一。
按这张表可以快速反推:一张 A100 80GB,扣掉权重 16GB 与激活开销,约 60GB 可用于 KV Cache,在 8K 上下文下最多支持约 60 个并发序列。这就是 max-num-seqs 的物理上限。
与操作系统分页的异同
PagedAttention 的名字直接借鉴了操作系统分页,但两者并非完全对应。
| 维度 | 操作系统分页 | PagedAttention |
|---|---|---|
| 分配单位 | 页(通常 4KB) | block(默认 16 token) |
| 映射结构 | 页表 | block table |
| 缺页处理 | 从磁盘换入 | 抢占后换出或重算 |
| 写时复制 | 进程 fork 时使用 | 前缀共享时使用 |
| 回收策略 | LRU 等 | 优先级与显存压力驱动 |
| 目的 | 隔离进程、扩展地址空间 | 消除碎片、支持前缀共享 |
相同点是都用固定大小块加间接映射来消除碎片;不同点是 PagedAttention 的块生命周期更短、回收更频繁,且引入了「重算」这种操作系统没有的回收方式。
vLLM 实测吞吐
以下为公开基准与实测的参考数字,模型 Llama-3-8B,FP16,输入 512、输出 256 token。
| 配置 | 硬件 | 并发 | 吞吐 tokens/s | 相对朴素实现 |
|---|---|---|---|---|
| HuggingFace 朴素 | A100 80G | 8 | 320 | 1.0 倍 |
| 静态批处理 | A100 80G | 32 | 680 | 2.1 倍 |
| vLLM 0.6.3 | A100 80G | 64 | 2450 | 7.7 倍 |
| vLLM 0.6.3 | A100 80G | 128 | 3100 | 9.7 倍 |
| vLLM 0.6.3 | H100 80G | 128 | 4600 | 14.4 倍 |
可以看到两个台阶:从朴素到静态批处理约 2 倍,从静态批处理到连续批处理加 PagedAttention 又是 3 到 4 倍。后一个台阶正是本文两项技术的贡献。
需要注意,并发从 64 提到 128 时吞吐增长放缓,因为此时瓶颈可能转移到算力或显存带宽,而非调度与显存碎片。
代码 模拟连续批处理调度器
下面这段代码用一个最小化的调度器模拟连续批处理的核心逻辑:每个迭代接纳新请求、推进已运行请求、移除已完成请求。运行它可以看到「迭代级补位」如何让并发槽位始终保持填满。
"""模拟连续批处理调度器,观察槽位利用率。"""
from dataclasses import dataclass, field
@dataclass
class Request:
rid: int
total_tokens: int
generated: int = 0
@property
def done(self) -> bool:
return self.generated >= self.total_tokens
@dataclass
class Scheduler:
max_slots: int
queue: list = field(default_factory=list)
running: list = field(default_factory=list)
step: int = 0
history: list = field(default_factory=list)
def submit(self, req: Request) -> None:
self.queue.append(req)
def _admit(self) -> None:
while self.queue and len(self.running) < self.max_slots:
self.running.append(self.queue.pop(0))
def tick(self) -> None:
self._admit()
for req in self.running:
req.generated += 1
self.running = [r for r in self.running if not r.done]
self.history.append(len(self.running))
self.step += 1
def utilization(self) -> float:
if not self.history:
return 0.0
return sum(self.history) / (len(self.history) * self.max_slots)
def simulate_static(reqs, max_slots):
"""静态批处理:整批必须等最慢的完成。"""
steps = 0
for i in range(0, len(reqs), max_slots):
batch = reqs[i:i + max_slots]
steps += max(r.total_tokens for r in batch)
return steps
def simulate_continuous(reqs, max_slots):
sched = Scheduler(max_slots=max_slots)
for r in reqs:
sched.submit(r)
while sched.running or sched.queue:
sched.tick()
return sched.step, sched.utilization()
if __name__ == "__main__":
lengths = [10, 50, 200, 300, 15, 80, 220, 40]
reqs = [Request(rid=i, total_tokens=n) for i, n in enumerate(lengths)]
static_steps = simulate_static(reqs, max_slots=4)
cont_steps, util = simulate_continuous(reqs, max_slots=4)
print(f"静态批处理总迭代数: {static_steps}")
print(f"连续批处理总迭代数: {cont_steps}")
print(f"连续批处理槽位利用率: {util:.1%}")
print(f"迭代数下降比例: {(static_steps - cont_steps) / static_steps:.1%}")
在真实引擎中调用连续批处理只需开启相应参数,vLLM 默认即启用:
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Meta-Llama-3-8B-Instruct \
--gpu-memory-utilization 0.9 \
--max-num-seqs 256 \
--max-model-len 8192 \
--block-size 16 \
--enable-chunked-prefill \
--port 8000
其中 --block-size 16 就是 PagedAttention 的 block 大小,--max-num-seqs 256 是连续批处理的并发上限。
分块预填充与调度公平性
连续批处理解决了长短输出的互相阻塞,但预填充(prefill)与解码(decode)之间的冲突是另一个维度的问题。
预填充与解码的资源竞争
预填充是算力密集的:一次性处理整个提示的注意力计算,FLOPS 消耗高。解码是显存带宽密集的:每个迭代只处理一个 token,但要把全部权重读一遍。把两者混在同一个批次里,会互相拖慢。
更麻烦的是,一个超长提示的预填充可能耗时数百毫秒,期间所有正在解码的序列都被阻塞,造成 TTFT 的长尾尖峰。
分块预填充如何缓解
分块预填充(Chunked Prefill)把长提示的预填充拆成若干固定大小的 chunk(例如 2048 token),每个迭代只处理一个 chunk,与解码请求混批。这样长提示不再独占算力,而是被切分到多个迭代中平滑执行。
代价是长提示自身的 TTFT 略有上升(因为被切分),但整体延迟分布更均匀,P99 显著改善。
| 配置 | 长提示 TTFT | 短请求 P99 阻塞 | 推荐场景 |
|---|---|---|---|
| 不分块 | 较短 | 严重 | 无长提示的场景 |
| 分块预填充 | 略长 | 显著改善 | 长短混合流量 |
调度公平性
在高并发下,调度策略直接影响延迟分布。一个常见问题是「饥饿」:持续到来的新请求不断挤占算力,导致某些运行中的序列迟迟无法完成。
缓解手段包括:
- 为运行中序列保留最低配额,防止被新请求饿死。
- 使用优先级或配额调度,保障关键业务的延迟。
- 限制单批次的预填充 token 总量,避免长提示挤占解码算力。
性能剖析方法
调优不能靠猜。下面是定位连续批处理与显存瓶颈的实用方法。
关注的关键指标
| 指标 | 含义 | 异常信号 |
|---|---|---|
| 批次大小 | 每迭代平均序列数 | 远低于 max-num-seqs 说明并发不足 |
| GPU 利用率 | SM 与显存带宽占用 | 长期低于 60% 说明调度有问题 |
| 抢占次数 | 单位时间 preempt 计数 | 持续非零说明显存过紧 |
| 缓存命中率 | 前缀复用比例 | 有共享提示却偏低说明未启用 |
| TTFT P99 | 长尾首 token 延迟 | 远高于 P50 说明有长提示阻塞 |
| TPOT | 每 token 生成耗时 | 上升说明批次过大或带宽受限 |
逐步排查路径
- 先看 GPU 利用率。若很低,说明批次没填满,检查并发是否不足或调度是否被阻塞。
- 再看抢占次数。若频繁,下调 max-model-len 或提高 gpu-memory-utilization。
- 然后看 TTFT 分布。若长尾严重,开启分块预填充。
- 最后看缓存命中率。若有共享前缀却命中率低,检查前缀缓存配置。
压测的正确姿势
用真实流量回放而非合成流量,因为合成流量的长度分布往往过于均匀,测不出长尾问题。压测时同时记录吞吐、TTFT、TPOT 与抢占次数,才能得到完整的画像。
常见坑清单
坑一 block 大小设置不当
block 太小(如 1)会让 block table 过大,间接寻址开销上升;block 太大(如 128)会放大内部碎片,尾部浪费增多。16 是经过验证的默认值,除非有明确的性能剖析依据,否则不建议改动。
坑二 忽视 preemption 的代价
当显存不足时,vLLM 会抢占(preempt)部分序列。抢占不是免费的:它要么把 KV Cache 换出到 CPU,要么直接丢弃并重算。高抢占率意味着显存压力已经过大,应下调 max-num-seqs 或 max-model-len。
坑三 recompute 与 swap 选择错误
- recompute:丢弃被抢占序列的 KV Cache,恢复时重新计算。省显存,但浪费算力,适合序列较短、显存紧张的场景。
- swap:把 KV Cache 换到 CPU 内存,恢复时换回。省算力,但依赖 PCIe 带宽,适合显存中等紧张、序列较长的场景。
默认策略会自适应,但如果观察到大量抢占,应主动评估哪种更划算。
坑四 把并发数拉满
max-num-seqs 越大,吞吐越高但单请求 TTFT 越长,因为每个序列分到的算力变少。生产环境要按 SLO 设定,而不是一味求大。
坑五 忽略 chunked prefill 的作用
不开分块预填充时,一个超长提示的预填充会独占算力,阻塞所有正在解码的请求,造成 TTFT 长尾。长提示场景几乎必须开启。
坑六 前缀共享被浪费
如果应用有固定系统提示却没做前缀缓存规划,等于放弃了分页带来的共享红利。多轮对话与少样本场景尤甚,可参考 KV Cache 优化 。
坑七 用平均值掩盖长尾
连续批处理让平均吞吐很好看,但长尾请求可能因为频繁抢占而极慢。监控要看 TTFT 与 TPOT 的 P95、P99,而非均值。
坑八 混淆吞吐与并发
吞吐是每秒处理的 token 或请求数,并发是同时在处理的序列数。二者相关但不等价。压测时必须同时报告,否则无法判断容量瓶颈在哪。
小结
连续批处理与 PagedAttention 分别从时间和空间两个维度消除浪费。连续批处理把调度粒度降到单次迭代,让 GPU 在序列生成结束时立即补位,几乎消除长短请求的互相阻塞;PagedAttention 把 KV Cache 切成固定大小的 block 并引入 block table 映射,把显存碎片从 60% 到 80% 的浪费降到 4% 以内,同时带来前缀共享能力。
两者的组合效果是数量级的:从朴素实现的几百 tokens/s 到 vLLM 的两千以上,主要来自这两项技术。这也是为什么它们成为现代推理引擎的通用范式。
工程上要记住几条实践原则:block 大小用默认 16、并发上限按 SLO 设定而非拉满、长提示开启 chunked prefill、密切关注 preemption 与长尾延迟。掌握这些,就掌握了推理性能调优的主要杠杆。
要把这些技术放到完整的产品链路中理解,可以回到 LLMOps 全景 ;显存与量化层面的进一步优化,可参考本组的量化与显存专题。
从更宏观的视角看,连续批处理与 PagedAttention 代表了一种通用思路:把粗粒度的资源分配改造成细粒度、按需、可复用的机制。这一思路在推理引擎的其他环节反复出现——前缀缓存复用计算、分块预填充复用算力、PD 分离复用硬件特性。理解了这一层抽象,就能更快地看懂后续各种推理优化技术的动机与取舍。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。