连续批处理与 PagedAttention 原理

本文深入讲解大模型推理的两项核心优化。先讲清静态批处理、动态批处理到连续批处理的演进与显存吞吐差异,再剖析 PagedAttention 如何把 KV Cache 切分为固定大小 block 并用 block table 建立逻辑到物理映射,使显存碎片从 60% 到 80% 的浪费降到 4% 以内。文中给出 vLLM 实测吞吐与一段可运行的连续批处理调度模拟代码。

连续批处理与 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 最直观的收益。

调度器的内部机制

连续批处理的调度逻辑看似简单,工程实现却有不少细节。理解这些细节,才能看懂引擎的日志与调优参数。

迭代循环的四个阶段

每个推理迭代大致经历四个阶段:

  1. 调度阶段:块管理器检查可用显存,决定本迭代接纳哪些新请求、哪些运行中的请求继续、哪些需要被抢占。
  2. 组装批次:把本迭代要处理的序列组成批次,包含新请求的预填充部分与运行中请求的解码部分。
  3. 前向计算:GPU 执行一次前向,为每个序列生成下一个 token。
  4. 后处理:更新每个序列的生成进度,检查是否遇到停止条件,释放已结束序列占用的 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 KV8K 上下文单序列
Llama-3-8B328128128KB1.0GB
Llama-3-70B808128320KB2.5GB
Qwen2.5-7B28412856KB0.44GB
Qwen2.5-32B648128256KB2.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 80G83201.0 倍
静态批处理A100 80G326802.1 倍
vLLM 0.6.3A100 80G6424507.7 倍
vLLM 0.6.3A100 80G12831009.7 倍
vLLM 0.6.3H100 80G128460014.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 生成耗时上升说明批次过大或带宽受限

逐步排查路径

  1. 先看 GPU 利用率。若很低,说明批次没填满,检查并发是否不足或调度是否被阻塞。
  2. 再看抢占次数。若频繁,下调 max-model-len 或提高 gpu-memory-utilization。
  3. 然后看 TTFT 分布。若长尾严重,开启分块预填充。
  4. 最后看缓存命中率。若有共享前缀却命中率低,检查前缀缓存配置。

压测的正确姿势

用真实流量回放而非合成流量,因为合成流量的长度分布往往过于均匀,测不出长尾问题。压测时同时记录吞吐、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 分离复用硬件特性。理解了这一层抽象,就能更快地看懂后续各种推理优化技术的动机与取舍。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「LLMOps」更多文章

  1. 语义缓存与 Prompt 缓存
  2. 结构化输出与函数调用
  3. 多智能体编排与工作流引擎