在大语言模型推理系统中,显存碎片化是限制吞吐和并发的核心瓶颈之一。自回归生成过程需要为每一个请求维护键值缓存(KV Cache),而传统实现会按照最大可能长度一次性预留连续显存。这种做法在请求长度差异巨大时会产生严重的外部碎片与内部浪费。PagedAttention 与连续批处理(Continuous Batching)正是针对该问题提出的系统级解法,前者重构了显存的管理粒度,后者重构了请求的调度粒度,两者结合可显著提升单机推理服务的有效显存利用率。

显存碎片化的根源与传统方案的缺陷
标准 Transformer 推理在生成第 t 个 token 时,需要将之前所有位置的 Key 和 Value 张量参与注意力计算。这意味着每个请求都要持有一块随序列增长而增长的 KV 缓存。最朴素的实现是在请求进入时,根据模型支持的最大上下文长度(例如 4096 或 8192)直接分配一整块连续显存。假设集群中混合了闲聊短句(32 token)和长文档摘要(4000 token),短请求也会占用与长请求相同的显存配额,显存内部碎片率极高。
更麻烦的是外部碎片。当某些长请求结束、显存释放后,留下的空闲块往往无法满足新长请求所需的连续空间,尽管总体空闲显存足够,调度器却只能拒绝或排队。传统静态批处理(Static Batching)将一批请求锁定在同一个 batch 中,必须等最慢的请求生成完毕才能整体释放显存并接收下一批,这进一步拉长了显存被无效占用的周期。我们可以用一个简单对比来看问题规模:
| 方案 | 显存预留方式 | 批次释放单位 | 典型碎片率 |
|---|---|---|---|
| 传统连续分配 | 按最大长度预分配 | 整个 batch | 40%至60% |
| PagedAttention加连续批处理 | 按页动态分配 | 单请求 | 小于15% |
上表说明,碎片化并非单纯靠堆硬件能解决,必须在内存管理与调度层面重新设计。这也是为什么现代推理引擎如 vLLM、TensorRT-LLM 都引入了类似分页的机制。
PagedAttention 的底层原理与实现细节
PagedAttention 的核心思想是模仿操作系统的虚拟内存分页。它将每个请求的 KV 缓存划分为固定大小的块(page,通常容纳若干 token 的 KV,例如 16 个 token 一页)。逻辑上请求看到的是连续的页号序列,物理上这些页可以散布在显存任意位置。一个映射表(block table)记录逻辑页到物理页的对应关系。当请求生成新 token 时,若当前页已满,调度器从空闲页池分配一个新物理页并追加映射,不再要求连续。
在注意力计算时,CUDA kernel 需要根据 block table 将非连续的物理页正确 gather 到计算布局中。由于页内是连续的,页间通过索引跳转,大部分高性能实现会用融合 kernel 减少显存搬运。下面给出一个简化的块表管理与注意力调用的伪代码,展示分页如何避开连续分配:
import torch
class PagedKVCache:
def __init__(self, num_pages, page_size, hidden_dim):
# 物理页池,形状: (num_pages, 2, page_size, hidden_dim)
self.phys = torch.zeros(num_pages, 2, page_size, hidden_dim)
self.page_size = page_size
self.free_pages = list(range(num_pages))
# 每个请求维护逻辑页到物理页的映射
self.block_tables = {}
def append_token(self, req_id, layer_kv):
# layer_kv: (2, 1, hidden_dim) 新 token 的 k,v
if req_id not in self.block_tables:
self.block_tables[req_id] = []
pages = self.block_tables[req_id]
if len(pages) == 0 or self._is_page_full(req_id):
pid = self.free_pages.pop()
pages.append(pid)
pid = pages[-1]
offset = self._filled_in_page(req_id)
self.phys[pid, :, offset:offset+1, :] = layer_kv
def _is_page_full(self, req_id):
return self._filled_in_page(req_id) >= self.page_size
def _filled_in_page(self, req_id):
# 简化: 用全局计数模拟
return self._counters.get(req_id, 0) % self.page_size
上述代码刻意省略了跨请求共享前缀(prompt 共享)的能力,而在真实系统里,PagedAttention 还支持写时复制(copy-on-write),让多个请求复用同一系统提示词物理页,进一步压缩显存。这种细粒度管理使得长尾短请求不再侵占连续大块显存,外部碎片几乎消失。
值得注意的是,分页引入的间接寻址会带来少量额外开销,但通过批处理 kernel 融合,实际端到端延迟往往反而下降,因为更高的并发让 GPU 计算单元更饱和。工程上页大小是重要调优参数:过小会增加映射表与调度负担,过大则退化回连续分配。经验上 16 到 32 token 每页在多数模型上较优。
连续批处理如何与分页协同提升吞吐
连续批处理(Continuous Batching)解决的是调度粒度问题。它不再以固定 batch 为生命周期单位,而是每一步生成后都重新扫描等待队列:已完成请求立刻出队并释放其物理页,新到达请求可随时入队分配页。这样 GPU 上始终跑着满编的计算任务,显存占用随真实序列长度浮动,而非峰值预留。
当连续批处理遇到 PagedAttention,两者的优势相乘。假设某时刻正在生成的请求平均已用 500 token,传统方案却按 4096 预留,连续批处理即便频繁换入换出也受限于连续空间;而分页下,新请求只需零碎空闲页即可启动,调度器可以用一个紧凑循环实现如下逻辑:
def schedule_step(waiting, running, cache):
# 先回收已完成
for r in list(running):
if r.finished:
cache.free_pages.extend(cache.block_tables.pop(r.id))
running.remove(r)
# 再尽量接纳新请求
while waiting and cache.free_pages:
req = waiting.pop(0)
cache.block_tables[req.id] = []
running.append(req)
# 对 running 做一步前向
for r in running:
kv = model.step(r)
cache.append_token(r.id, kv)
该循环每个迭代都是连续批处理的一步,配合分页缓存,显存不再被死批占据。实测中,在同样的 A100 80GB 上服务 LLaMA-13B,传统静态批处理吞吐约 1800 token/s,而 vLLM 风格的分页加连续批处理可达 3200 token/s 以上,且尾延迟更稳定。
落地时还需注意与前端限流、优先级队列的配合。若请求优先级差异大,应在调度循环中优先补充高优请求,但始终保证空闲页能被低优短请求利用,避免高优长请求饿死系统。通过监控空闲页水位与排队长度,可以动态限制最大并发,防止极端情况下分页表过大影响映射效率。
在工程框架中落地与调优建议
对于大多数团队,不必从零实现 PagedAttention,可直接采用 vLLM、TGI 或 TensorRT-LLM。以 vLLM 为例,启动时可指定 --block-size 与 --max-num-seqs 来控制页大小与最大连续批内请求数。若业务多为短问答,适当减小 block-size 并调高 max-num-seqs 能进一步榨干显存。
另一个常见误区是认为连续批处理会大幅增加调度 CPU 开销。实际上调度逻辑远轻于 GPU 计算,且分页让内存操作更局部。建议在上线前用生产混合流量做压测,观察显存占用曲线是否平滑跟随并发,而非出现阶梯式空洞。若仍见碎片,多半是 block-size 过大或某些请求强制预留了特殊缓冲,可检查框架配置中是否禁用了前缀缓存共享。
总体来看,PagedAttention 与连续批处理不是孤立优化,而是显存管理与请求调度的协同重构。理解其原理有助于在自研推理服务或排查性能问题时,快速定位是页映射低效、调度不公还是模型 kernel 瓶颈,从而做出针对性改进。
PagedAttentionContinuous_Batching显存碎片化修改时间:2026-08-17 03:28:17