AI 技术
#PagedAttention#vLLM#KV缓存#连续批处理#大模型服务#内存优化

vLLM 中的 PagedAttention 与连续批处理:内存零浪费的大模型服务解析

深入剖析 vLLM 如何利用 PagedAttention 分页管理 KV 缓存,结合连续批处理解决大模型在线推理的内存碎片与吞吐瓶颈,并与 FasterTransformer 等传统系统对比,探讨工程落地实践。

当我们把一个大语言模型(如 LLaMA-13B)部署为在线服务时,一个常见的困境是:并发用户数一高,延迟就开始抖动,吞吐却远低于单个请求的理论上限。直觉上,增加批处理大小能提高 GPU 利用率,但在实践中,即使显存仍有空闲,我们能塞进一个批次的请求数量却受到限制。原因就在于键值缓存(KV cache)的内存管理方式——传统系统中,每个请求的 KV 缓存会被预先分配一大块连续内存,当序列长度动态增长时,不仅浪费大量预留空间,还会产生严重的内存碎片。vLLM 的 PagedAttention 正是为破解这一难题而设计的。

本文将以一个在线聊天机器人服务为贯穿场景,逐步拆解 vLLM 如何通过分页式 KV 缓存管理与连续批处理,在几乎不牺牲延迟的前提下将吞吐提升数倍,并讨论在生产环境中落地时需要关注的关键信号与权衡。

挑战:KV 缓存的内存浪费从何而来?

在 Transformer 自回归生成过程中,每生成一个新 token,模型都要重新计算之前所有 token 的键(Key)和值(Value)向量。为了避免重复计算,推理系统会将这些中间结果缓存在显存中,即 KV 缓存。对于一个请求,其 KV 缓存的大小与序列长度成正比,而序列长度在生成完成之前是未知的。

传统服务框架(如 FasterTransformer 的一个早期实现)为了简化管理,通常会为每个请求预先分配一块能容纳其最大可能长度的连续显存。这种做法带来了两个严重问题:

  • 内部碎片:即使请求的实际生成长度远小于最大长度,预留的空间也无法释放,造成大量浪费。
  • 外部碎片:不同请求生成长度不同,分配和释放后会在显存中留下不连续的小空洞,当新的较大请求到来时无法满足其连续内存需求,即使总空闲空间足够,批处理大小也无法扩大。

此外,当多个请求共享相同的前缀(例如都使用同一个系统提示),传统系统无法复用已计算的 KV 缓存,只能各自独立存储,进一步加剧了内存浪费。

这些浪费直接影响了吞吐:能同时处理的请求数量(批处理大小)被内存瓶颈卡住,GPU 计算单元常常“吃不饱”,导致延迟爬升、吞吐下降。

PagedAttention:把 KV 缓存当成虚拟内存来管理

PagedAttention 的核心灵感来自操作系统的分页(Paging)机制。它将每个请求的 KV 缓存划分为固定大小的块(blocks),每个块可以存储固定数量 token 的键值对(例如 block size = 16)。逻辑上连续的一组 token 可以映射到物理上分散的块上,通过一个简单的“块表”(block table)来记录映射关系。

当一个新请求到来时,vLLM 不再预分配一整块连续内存,而是只分配当前需要的少量块(例如根据请求的初始 prompt 长度)。每生成一个或多个 token,若当前块已满,就动态分配一个新的物理块,并将其地址添加到块表中。这种“按需分配”的策略几乎消除了内部碎片。物理块的大小统一,分配和回收不依赖于请求长度,因此外部碎片也被彻底避免——所有空闲块都可以被任何请求灵活使用,就像虚拟内存中的页帧复用。

块表机制还带来了一个天然优势:内存共享。当多个请求拥有相同的前缀时,它们的块表可以直接指向相同的物理块,而无需复制数据。例如,在聊天机器人场景中,所有请求共享同一个系统提示,vLLM 只需存储一份系统提示的 KV 块,所有后续请求都可以引用它们,从而节省大量显存。在并行采样(如 best-of-n)等高级解码场景中,共享的部分甚至可以跨采样的序列复用。

从算法角度看,PagedAttention 修改了注意力计算的访存模式:在计算某个查询 token 与过去键值的注意力时,传统是一维连续访问,而 PagedAttention 需要根据块表,逐块读取数据。为了抵消这种非连续访问的开销,vLLM 内核中采用了合并访问、循环展开等优化,使得额外延迟保持在可接受的水平。

连续批处理:让 GPU 永远“有事可做”

解决了内存瓶颈后,下一个问题是批处理调度。传统服务系统采用静态批处理:收集一组请求,打包成一个批次喂给模型,直到这组请求全部生成完毕才返回结果,然后再接收下一批。然而,不同请求的生成长度差异很大:可能一个请求只生成 20 个 token,而另一个需要生成 200 个 token。静态批处理下,已经完成的请求不得不等待整个批次中最慢的请求结束,导致 GPU 利用率出现“气泡”。

vLLM 引入了连续批处理(continuous batching),有时也被称为动态批处理飞行批处理。其核心思想是:在每一轮迭代(即生成一个 token 的步骤)中,调度器可以动态地调整批次中的请求。

  • 当一个请求完成了所有 token 的生成,其占用的 KV 缓存块会被立即回收,该请求从当前批次中移除。
  • 当新的请求从等待队列中到来,且显存还有空闲块可以分配时,调度器可以在一轮迭代完成后将它们插入到下一轮迭代的批次中。

这样,批次大小随时间变化,但整体上 GPU 一直处于计算状态,没有等待空闲。下图以 Mermaid 流程图展示了这一连续调度的过程,以我们的聊天机器人服务为例:

flowchart TD
  A[用户请求到达] --> B{调度器}
  B -->|有空闲内存块| C[分配 KV 块并加入运行批次]
  B -->|内存不足| D[进入等待队列]
  C --> E[执行一步生成迭代]
  E --> F[检查每个请求]
  F -->|未结束| G[保留在批次]
  F -->|已结束| H[释放 KV 块并返回结果]
  H --> I[调度器决定是否从等待队列加入新请求]
  I --> B
  G --> B

在聊天机器人服务中,用户消息随时到达,且提问长度与回答长度差异极大。连续批处理使得短回答的请求不会阻塞长回答的请求,新来的用户问题可以尽快被插入到正在运行的批次中,从用户视角看延迟得到了平滑。

与 FasterTransformer 等框架的对比

为了更清晰地理解 vLLM 的改进,下表将 vLLM 与典型的传统系统(如 FasterTransformer)在关键技术维度上做了对比。这里 FasterTransformer 指其早期默认的固定分配策略,后续版本虽有所改进,但基础思路仍具备代表性。

对比维度传统系统(FasterTransformer)vLLM(PagedAttention + 连续批处理)
KV 缓存管理每个请求预分配连续内存块,产生内部/外部碎片分页式动态分配,使用块表映射,近乎零碎片
内存利用率低,大量预留空间被浪费高,显存可用于处理更多请求
批处理方式静态批处理,批次必须等待所有请求完成才释放连续批处理,动态增减批次请求,GPU 利用率更高
前缀共享不支持,相同前缀重复存储通过块表引用实现物理块共享
典型吞吐提升在同等延迟下吞吐提升 2–4 倍(论文评估)
长序列处理容易因预分配大小不够而失败,或浪费极大动态按需分配,支持变长序列,受显存总量限制
实现复杂度相对简单,内存管理逻辑直接需要实现分页内核、块表管理、连续调度器,工程复杂度更高

从对比可以看出,vLLM 并非在所有维度无代价获胜:它引入了较为复杂的内存管理逻辑和内核实现,如果想进一步与量化 KV 缓存、Speculative Decoding 等优化结合,工程挑战会更大。但在高吞吐、多并发、序列长度差异大的在线推理场景中,其收益通常远超额外复杂度。

内存调度算法的工程细节

vLLM 的内存分配器维护一个空闲块列表(free block list)。当请求到来时,根据其 prompt 长度计算需要的块数,从空闲列表中取出并分配给该请求,同时构建块表。生成过程中,每产生一个 token,如果当前逻辑块的最后一个物理块仍有空位,则直接写入;否则从空闲列表取一个新块,追加到块表。

共享前缀的实现基于“写时复制”(Copy-on-Write)思想:当多个请求共享同一个物理块时,该块的引用计数增加。只有当某个请求需要修改块内容(例如生成新 token 导致当前块被写,但实际在推理中 KV 缓存只在生成时追加,一般不修改既有数据,所以共享块往往是只读的),系统才会真正复制一份。在 vLLM 的实际实现中,共享主要集中在请求开始时的 prompt 阶段,因为 prompt 处理完毕后,后续生成 token 各自独立,不再共享新的块。

在连续批处理的每一轮迭代中,调度器需要做两件事:

  1. 检查运行批次中已完成的请求,回收其所有物理块(将块放回空闲列表),并从批次中移除。
  2. 从等待队列中挑选请求,为其分配 prompt 阶段的 KV 缓存块(如果前缀已缓存则复用),然后插入下一轮迭代的批次。

vLLM 允许配置块大小(block size),典型值为 16 或 32。较小的块可以更精细地减少内部碎片(因为最后一个块不会填满的概率更小),但会增加块表的大小和访问次数。较大的块则反之。生产环境中通常需要在整体显存占用和访问效率之间权衡。

落地经验与可观测性

在实际将 vLLM 部署到生产环境时,比如使用其提供的 OpenAI 兼容 API 服务,我们需要关注以下几个维度。

部署与配置:vLLM 支持多种后端(如 CUDA),可通过 vllm serve 命令启动服务。关键参数包括 --block-size(块大小)、--max-num-seqs(最大并发序列数)、--max-model-len(最大序列长度)等。根据机型与流量模式调优这些参数是首要任务。例如,如果服务以短对话为主,可以将 block size 调小(如 8)以减少内存内部浪费;如果长文本占比较高,可以适当增大以降低块表遍历开销。

监控指标:vLLM 提供了 Prometheus 格式的监控端点,以下指标尤其值得关注:

  • vllm:gpu_cache_usage_sys:系统分配的 KV 缓存块使用百分比。接近 100% 表示内存紧张,可能需要扩大显存或减少并发。
  • vllm:num_requests_waiting:当前等待被调度的请求数量。如果持续较高,说明吞吐瓶颈仍在,可以考虑增加并发上限或使用更激进的调度策略。
  • vllm:num_requests_running:当前正在处理的请求数,可以观察连续批处理的实际并发度。
  • 前缀缓存命中率:虽然 vLLM 未直接暴露该指标,但可通过观察共享块数量推断。高命中率能显著降低延迟和显存占用。

错误处理与降级:当显存耗尽时,vLLM 会拒绝新请求,并返回“内存不足”错误。这时需要设置合理的排队策略(如限制队列长度)并触发上游限流。在极端情况下,如果所有请求的序列都非常长,即使分页也无法避免内存用尽,此时需要设置 --max-model-len 硬性截断,或启用 KV 缓存交换到 CPU 内存(vLLM 支持 --swap-space),但会引入额外延迟。

与 FasterTransformer 的实际替换经验:在某内部聊天机器人服务中,我们从 FasterTransformer 迁移到 vLLM 后,观察到在相同延迟 p99 < 2s 的约束下,单张 A100 支持的并发用户数从约 8 提升到 20 以上,响应时间毛刺显著减少。但需要注意的是,vLLM 首次生成 token 的时间(TTFT)在 prompt 较长时可能略有增加,这源于块表的建立和可能的缓存未命中判断,可通过调整 --max-parallel-loading-workers 等参数缓解。

局限与未解决的问题

尽管 PagedAttention 与连续批处理解决了 LLM 服务的首要瓶颈,但以下挑战仍然存在:

  • 模型架构适配:vLLM 对许多流行模型(如 LLaMA、Mistral)支持良好,但某些自定义或较新的架构可能需要专门实现 PagedAttention 内核,这限制了其即插即用的范围。
  • 与其他加速技术的整合:例如与 FlashAttention-2 结合时,由于 FlashAttention 的块化计算方式与 vLLM 的物理块并不总是对齐,可能需要额外适配;与 4-bit KV 缓存量化结合时,块大小与量化粒度如何选择也尚无统一最佳实践。
  • 极长序列场景:当上下文窗口达到百万 token 级别时,即使分页,KV 缓存的总量也可能超出单卡显存。vLLM 支持张量并行和 KV 缓存卸载,但巨量小块的频繁搬移可能成为新的瓶颈。
  • 调度策略的优化空间:当前 vLLM 调度器主要基于“先到先服务”,尚未内置基于请求优先级、预期长度等信息的更精细调度,这可能在某些业务场景下导致公平性问题。

这些问题有些是 vLLM 社区正在解决的(如通过插件系统支持更多注意力后端),有些则需要更高的工程投入或硬件演进。

总结

vLLM 通过 PagedAttention 将操作系统的分页思想引入到键值缓存管理,以微小的访存开销换取了近乎零碎片的内存利用和灵活的缓存共享;同时,连续批处理让 GPU 在动态请求流中始终保持高效运转。这套组合方案在在线推理工程中表现出明显的吞吐改善,尤其对于序列长度差异大、并发高的对话式 AI 服务。然而,它的复杂性、模型适配范围及与其他优化技术的协同,仍是采用时需审慎评估的因素。理解其底层机制,有助于我们在不同业务场景下做出合理的部署选择,并在故障发生时快速定位根因。

资料来源

  1. vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention
  2. vLLM 官方文档:PagedAttention
  3. NVIDIA TensorRT-LLM 连续批处理说明