想象一家社区图书馆。旧管理员定下一条规矩:每位读者进馆,都要领一本空白手抄本,图书馆按“你有可能读到的最多页数”,提前在固定书架上占好一整块位置——哪怕你只翻了十页,也占走百页的空间。读者一多,书架就被塞满半空的手抄本,新读者反而借不到书。新管理员换了一套玩法:把所有书拆成标准大小的活页,装进统一的信封袋,散放在公共书架上;读者读到哪一页,才取哪一袋;读同一本书前几章的人,可以合看同一叠活页;谁想在自己的页上写批注,就先复印一份再动笔。同样一面书架墙,能同时服务的读者翻了好几倍。PagedAttention(分页注意力),就是大模型推理世界里的这位新管理员:它把大模型推理时那份跟随序列增长的“上下文记忆”,像活页一样分块存放、按需分配,让相同前缀的请求共享同一批块,必要时才写时复制。理解它,你就拿到了大模型推理引擎显存优化的一把钥匙。
掌握 PagedAttention,你就能看懂 vLLM 等推理引擎的内存管理内核,学会用“分页 + 共享”思维调优大模型部署的显存与吞吐,这是 LLM 推理工程师最值钱的核心技能之一。
可视化演示
展示PagedAttention的块表映射机制、跨请求前缀KV块共享与引用计数、以及连续批处理动态调度过程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
块表映射机制 逻辑块通过块表映射到物理块 灵感来自操作系统虚拟内存分页 逻辑块i → 块表[i] → 物理块 非连续物理存储消除显存碎片
显存痛点
故事从旧管理员的“手抄本”开始:每位读者按“可能读到的最大页数”领一整块固定书架,哪怕实际只读十页。传统的大模型推理框架也是如此——它不知道用户会问多长,只能按最大序列长度一次性预分配一块连续显存,这笔账就记在名叫 KV Cache 的“上下文记忆账本”上。
Transformer 每生成一个新 token,都要重新计算它与历史所有 token 的注意力。为了避免重复算,推理引擎把每个历史 token 的 Key(键)和 Value(值)张量缓存在显存里,这就是 KV Cache。它的体积随并发请求数、序列长度线性增长,是大模型推理中最主要的显存消耗者之一。
然而传统分配方式有两个致命问题:一是内部碎片——按最大长度申请、实际只用了很少一部分,剩下的空间空置;二是外部碎片——不同请求先后结束,释放出大小不一的零散块,彼此不连续,新请求的大块需求再也拼不回去,实际显存利用率常常只有 20% 到 40%。
旧管理员的账本越来越厚,直到新管理员发现:与其按页数囤书,不如按需取页——这正是 PagedAttention 改革的起点。
- 双份矩阵注意力既要 Key 又要 Value,两个矩阵都要缓存,所以最前面乘 2。
- 层数累加Transformer 每一层都有独立的 KV 缓存,L 代表模型层数。
- 每层维度d_kv 是每层 KV 张量的特征维度,由 KV 头数与每个头的维度决定。
- 总 token 数S 是序列长度,b 是批大小,二者相乘得到需要缓存的总 token 数。
- 精度字节bytes 是每个元素占用的字节数,FP16 精度下为 2 字节。
分页核心思想
新管理员把书拆成标准活页、装进统一信封袋。PagedAttention 的灵感正来自这个朴素直觉,以及操作系统里更古老的前辈——虚拟内存分页:进程的逻辑地址空间可以很大,但物理内存按固定大小的页框分配,用多少占多少。
PagedAttention 把 KV Cache 切成固定大小的块(Block),每块默认装 16 个 token 对应的 KV 数据。请求侧不再按“最大长度”申请,而是随生成进度按需分配:每生成一批 token,只申请装得下它们的块。从逻辑上看,请求的序列仍然是 0、1、2…连续编号的;但物理上,这些块可以被放在显存的任意空闲位置,中间可以有空隙——就像书架上散落的信封袋,中间空着几个格子完全不影响借阅。
逻辑连续、物理分散,这是分页思想最核心的一步。相比旧方案“必须找一整块连续显存”,新方案只需要“有位置放下一个块”即可。
从此,显存账本从“按最大页数囤书”变成“按实际页数取袋”,只是散落的信封袋要靠一张借阅清单才能找回——那就是下一节的块表。
- 实际使用量S_used 是请求当前实际生成的 token 数,而不是预留的最大值。
- 块大小B_block 是每个物理块的容量,vLLM 默认 16 个 token 一块。
- 向上取整最后一块往往装不满,但也要占一个块,所以用向上取整。
块表映射机制
读者手里的借阅清单记录了“我在读的第几页,放在书架的哪个信封袋”。PagedAttention 为每个请求维护一张块表(Block Table),本质上就是逻辑块号到物理块号的映射数组。
计算过程分两步:给定一个逻辑块号 i,查块表得到物理块地址;再算出该 token 在块内的偏移。因为块大小固定,偏移只需一次取模运算,开销极小。传统方案里,Attention 计算要求 KV 在显存中连续摆放,而 PagedAttention 的注意力算子会拿着块表去“按图索骥”——先定位物理块,再读块内数据,物理上是否连续已经不重要了。
这彻底解除了“连续大块”的紧箍咒:一块显存刚被释放,无论它夹在哪两块之间,都能立刻被新请求复用,外部碎片问题迎刃而解。
有了这张借阅清单,散落的信封袋就能按序被找到。而清单带来的另一份惊喜是:既然大家都拿着同一个信封袋的编号,那这袋活页是不是可以一起看?
- 查表得物理块号逻辑块号 i 作为下标,从块表中取出物理块号 pid。
- 块内偏移token 在整条序列里的位置 j,对块大小取模,得到它在块内的偏移 off。
前缀共享机制
图书馆里很多人读的是同一本书,至少前几章完全一样。旧管理员给每人发一整套手抄本,纯属浪费;新管理员允许大家合看同一叠活页,只有当某人要在上面写自己的批注时,才复印一份再动笔。
大模型场景中同样存在大量“相同前几章”:聊天机器人的系统提示词、少样本示例、RAG 里固定的问题模板,都是所有请求共享的公共前缀。由于它们的 token 序列完全一致,产生的 KV 块也完全一致——PagedAttention 允许这些请求共享同一批物理块。
共享通过引用计数(Ref Count)管理:每块记录当前有几个请求在引用。共享块是只读的。当某个请求需要在一个已被共享的块上继续写新 token 时,不能直接改,而要触发写时复制(Copy-on-Write, COW):新分配一个物理块,把旧块内容拷贝进去,再写入新 token,同时把旧块的引用计数减一。
合看活页让同样的书架服务更多人,这正是 PagedAttention 在真实负载中显存收益的最大来源之一。
- 引用计数ref(b) 表示物理块 b 被多少请求共同引用,是判断能否直接写的关键。
- 写时复制引用数大于 1 时块只读,写入前必须复制新块,保证互不干扰。
- 节省的显存共享前缀共 N_p 个块,每个块占 B_block × bytes 字节,R 个请求共享相比各自独享,省下 R-1 份。
显存收益分析
新管理员的改革成效如何?看看书架的“空置率”就一目了然。旧方案的书架被三样东西占着:真正的内容、预留下的空白页、以及拼不回完整区间的缝隙。分页后,前两项被大幅压缩,最后一项几乎清零。
内部碎片:不再按最大长度预分配,只按实际使用申请块,唯一的浪费是每个请求最后那个没装满的块——平均半块而已。请求多了,这笔开销被摊薄到可以忽略。外部碎片:物理块可以放在任意空闲位置,不存在“必须有连续大块”的限制,彻底消除。
vLLM 论文在真实负载上的报告显示:相比 FasterTransformer、Orca 等当时的主流系统,vLLM 的吞吐量提升了 2 到 4 倍,KV Cache 显存浪费从传统方式的 60% 到 80% 降到了接近零——书架从“半空状态”变成“几乎满员运营”。
同样的显存,现在能吞吐更多请求——收益的兑现,最终落到工程实现上。
- 浪费率定义分配出去但没被用上的显存占比,传统方案里这就是空洞的度量。
- 分页后的内部碎片每个请求只有最后一个块平均空半块,占比约为 2 倍块数分之一。
vLLM 工程落地
新管理员的玩法要落地,光有活页还不够,还需要一套图书管理系统:谁在借哪袋、哪袋空闲、哪袋被多少人共用,都要实时记账。大模型推理框架 vLLM 就是这个“管理系统”。
vLLM 是第一个把 PagedAttention 工程化的推理引擎,包含三个关键角色:调度器(Scheduler)决定下一轮让哪些请求运行;块管理器(Block Manager)负责物理块的分配、释放、共享与写时复制记账;PagedAttention 算子以 CUDA kernel 的形式实现“按块表寻址”的分块注意力计算,每次前向都高效定位分散的 KV 块。
部署调优时,最常碰到的参数有:--block-size(默认 16,调节分块粒度)、--gpu-memory-utilization(默认 0.9,控制 KV 可用显存上限)、--max-num-seqs(最大并发序列数)以及 --enable-prefix-caching(开启前缀缓存,让相同前缀的块直接命中复用)。
系统就绪后,真正的收益要在具体业务场景中兑现——活页图书馆终于可以开门迎客了。
- 已分配显存M_alloc 是当前已经分配给 KV 块的显存总量。
- 可用上限M_gpu 是 GPU 总显存,u_util 是允许 KV 占用的比例上限(默认 0.9)。
- 利用率指标两者相除得到 KV 显存利用率,越接近 1 说明分页调度越紧凑。
应用与部署
图书馆开门后最热闹的三个服务区:一是共读会,二是会议室问答,三是考前刷题班——它们分别对应多轮对话、RAG 文档问答和批量评测。
多轮对话中,所有会话共享同一段系统提示词(常占 prompt 的 30% 到 50%),大家“合看”这段前缀的 KV 块,只有各自的对话内容按需分块。RAG 长文档问答中,一篇文档被多个问题反复引用,文档的 KV 只需计算一次,所有问题共享对应物理块,省下的不仅是显存还有重复计算。批量评测时,同一份指令模板被喂给成千上万个测试用例,前缀缓存命中率极高,吞吐显著提升。
调优时建议盯住两个指标:前缀命中率(提示词里有多少 token 的 KV 直接命中缓存)和KV 利用率(上一节的 U_kv)。实践上把公共指令固定在一段、把易变内容放在后面,就能让共享收益最大化。
从“每人一本手抄本”到“合看活页、按需取袋”,这座图书馆终于让相同显存服务了更多读者——这就是 PagedAttention 的全部故事。
- 命中 token 数S_hit 是提示词中直接命中共享缓存的 token 数量。
- 提示词总长S_prompt 是完整提示词的长度,两者比值衡量前缀共享的甜蜜程度。
AI 应用场景
多轮对话服务的系统提示词共享
阶段:推理部署与在线服务
解决的问题:传统服务中每个并发会话独立分配完整 KV Cache,系统提示词和开场白被每个用户重复存一份,显存随会话数直线爆炸。PagedAttention 让所有会话共享相同系统提示词的物理块,只有用户各自的对话部分按需分块,显存峰值明显下降。
具体案例:某客服机器人线上并发 2000 路会话,系统提示词约 800 token,占总 prompt 的 40%。切到 vLLM 并开启前缀缓存后,KV 显存峰值下降约 35%,单卡可承载并发会话数从 80 提升到 140,P95 首 token 延迟保持不变。
RAG 长文档批量问答的前缀复用
阶段:推理优化与检索增强生成
解决的问题:RAG 场景中多个问题共享同一篇长文档,传统做法是每个问题都重新编码一遍文档,算力与显存双重浪费。分页注意力让文档的 KV 只计算一次,所有问题共享对应的物理块,代价仅是引用计数加一。
具体案例:法律问答平台将一份 10 万字的合同切片后作为公共上下文,批量回答 100 个相关问题。启用共享前缀后,文档编码次数从 100 次降为 1 次,整体吞吐提升约 2.5 倍,单次问答的首 token 时延从 4 秒降到 1.2 秒。
离线评测与大模型压测
阶段:模型评测与性能压测
解决的问题:跑公开基准时,所有测试样例共享同一份指令前缀;普通框架重复编码指令,评测吞吐被无效计算拖累。PagedAttention 的前缀共享让指令 KV 只算一次,测试样例快速复用,batch 可以开得更大。
具体案例:评测团队用 vLLM 给 7B 模型跑 MMLU 一万题,开启 --enable-prefix-caching 后,相同指令前缀命中缓存,评测吞吐从每秒 180 条提升到 420 条,GPU 显存占用反而下降了近三成。
Python 演示
import math class BlockManager: """模拟 PagedAttention 的物理块管理与共享""" def __init__(self, total_blocks, block_size): self.total_blocks = total_blocks self.block_size = block_size self.refs = [0] * total_blocks # 物理块引用计数 self.free_pool = list(range(total_blocks)) # 空闲物理块队列 self.tables = {} # 请求 -> 逻辑块到物理块映射 self.used = {} # 请求 -> 已写入 token 数 def _alloc(self): pid = self.free_pool.pop(0) self.refs[pid] += 1 return pid def prefill(self, req_id, num_tokens): """预填充提示词 KV, 按需申请 ceil(num_tokens/block_size) 个块""" need = math.ceil(num_tokens / self.block_size) self.tables[req_id] = [self._alloc() for _ in range(need)] self.used[req_id] = num_tokens return self.tables[req_id] def share(self, req_id, src_id, num_tokens): """前缀共享: 复用 src 请求前 num_tokens 对应的物理块, 引用计数加一""" n = math.ceil(num_tokens / self.block_size) shared = self.tables[src_id][:n] for pid in shared: self.refs[pid] += 1 self.tables[req_id] = list(shared) self.used[req_id] = num_tokens return shared def append_token(self, req_id): """生成一个新 token: 满块则分配新块, 共享块则写时复制""" blocks = self.tables[req_id] pos = self.used[req_id] % self.block_size if pos == 0: blocks.append(self._alloc()) else: last = blocks[-1] if self.refs[last] > 1: new_pid = self._alloc() # 先复制内容到新块再写入 self.refs[last] -= 1 blocks[-1] = new_pid self.used[req_id] += 1 def free(self, req_id): """请求结束, 归还其占用的所有物理块""" for pid in self.tables.get(req_id, []): self.refs[pid] -= 1 del self.tables[req_id] del self.used[req_id] def show_table(mgr, req_id): blocks = " | ".join("L%d->P%d" % (i, p) for i, p in enumerate(mgr.tables[req_id])) print("%s 块表: %s" % (req_id, blocks)) def main(): mgr = BlockManager(total_blocks=12, block_size=4) mgr.prefill("sys", 8) # 系统提示词, 占 2 块 mgr.share("A", "sys", 8) # A 共享前缀 for _ in range(3): mgr.append_token("A") # A 生成 3 个 token, 按需取第 3 块 mgr.share("B", "sys", 8) # B 也共享相同前缀 for _ in range(2): mgr.append_token("B") # B 生成 2 个 token mgr.share("C", "A", 9) # C 从 A 的第 9 token 处分支, 模拟 beam search mgr.append_token("C") # 触发写时复制, 新块替换共享块 print("物理块引用计数 refs:", mgr.refs) for rid in ("sys", "A", "B", "C"): show_table(mgr, rid) no_share = sum(math.ceil(mgr.used[r] / mgr.block_size) for r in mgr.tables) used = sum(1 for r in mgr.refs if r > 0) print("无共享需 %d 块, 分页+共享只用 %d 块, 节省 %d 块" % (no_share, used, no_share - used)) if __name__ == "__main__": main()
self.refs = [0] * total_blocks每个物理块一个引用计数,数组下标就是物理块号,像图书馆登记每袋活页被几个读者合看。pid = self.free_pool.pop(0)从空闲队列取出一个物理块号,保证按需分配且永不重复。need = math.ceil(num_tokens / self.block_size)向上取整算出需要几个块,最后一块没装满也算一整块。self.refs[pid] += 1前缀共享的关键:每块引用计数加一,多个请求共用而不复制数据。new_pid = self._alloc()写时复制的核心:共享块不能改,先分配一个新物理块。self.refs[last] -= 1复制完成后原共享块引用计数减一,相当于该请求从共享中退出。no_share = sum(math.ceil(mgr.used[r] / mgr.block_size) for r in mgr.tables)假设完全不复用,算出朴素方案需要的总块数,用于对比收益。used = sum(1 for r in mgr.refs if r > 0)统计当前真正被占用的物理块数,二者相减就是共享省下的显存。
常见误区
误区PagedAttention 把 KV Cache 压缩小了,所以显存占用才降下来——它一定是个类似量化的压缩算法。
正解分页一点也不压缩 KV 数据本身,K 和 V 张量一个不少、精度不变。它做的是内存管理:把整块连续预分配改成定长块按需分配,把“申请了没用”的内部空洞和“散了拼不回”的外部碎片解救出来。类比图书馆:新管理员不是把书页内容改小,而是让多人合看同一叠活页、只取需要的页。
为何常见:看到“减少显存浪费”就直觉联想到压缩,忽略了分页本质是操作系统的内存管理思想,而不是数据压缩技术。
误区既然多个请求共享同一个物理块,那谁都能在自己的逻辑页上继续写新 token,反正有块表。
正解共享块是只读的。一旦引用计数大于 1,任何写入都必须先触发写时复制:新分配一块、拷贝旧内容、再写入新 token,同时把原块引用计数减一。就像合租书架上公共层的活页,谁要贴自己的标签,得先复印一份再动笔,否则就把别人的笔记涂掉了。
为何常见:日常编程里“拿到地址就能写”的习惯根深蒂固,忽略了并发共享场景下写入冲突的破坏性。
误区块设得越小,尾部浪费越少,所以 block-size 越小越优。
正解块小确实让尾部内部碎片变少,但块数变多,块表变大、寻址开销上升、GPU 按块并行的效率也会下降;块大则尾部浪费增加。vLLM 默认 16 token 是大量实验后的权衡值,调参时应综合吞吐和显存实测,而不是单看碎片指标。
为何常见:只盯着“内部碎片”这一个指标做局部优化,忽略了块表与 kernel 开销的全局代价,犯了只见树木不见森林的错。
误区PagedAttention 只对长对话和长文档有用,短请求 token 少,分不分页无所谓。
正解短请求同样受益。即使没有前缀共享,按需分配也消除了预分配带来的内部空洞,而物理块任意放置让释放的碎片立刻可复用,外部碎片对所有请求一视同仁地消失。共享收益看的是“前缀占比 × 共享请求数”,前缀短时收益小,但内存管理收益始终存在。
为何常见:只看到论文里长上下文的大数字,低估了碎片消除这种“默认收益”的普适价值。
面试题
Q1. PagedAttention 解决大模型推理中的什么核心问题?
- A.训练时的梯度消失
- B.KV 显存碎片和浪费← 正确答案
- C.模型权重精度下降
- D.输入数据加载太慢
答案:B。PagedAttention 是显存管理技术,目标是消灭 KV Cache 预分配造成的内部碎片和连续分配造成的外部碎片。它不涉及梯度、权重精度或数据加载,就像新管理员解决的是书架占用问题,而不是书的内容问题。
Q2. 大模型推理中的 KV Cache 主要存储什么?
- A.缓存每层的键值张量← 正确答案
- B.存储模型全部权重
- C.记录输入词表映射
- D.保存优化器动量
答案:A。KV Cache 缓存的是 Transformer 每层产生的 Key 和 Value 张量,用于避免生成新 token 时重复计算历史注意力。模型权重属于静态参数,词表映射和优化器动量都属于训练期概念,与推理缓存无关。
Q3. 传统 KV Cache 分配方式的主要缺陷是什么?
- A.按最大长度一次性预分配← 正确答案
- B.按实际长度动态扩缩
- C.使用更高精度数据类型
- D.在 CPU 上保存缓存
答案:A。传统方案为每个请求按最大可能序列长度预分配一整块连续显存,实际往往只用一小部分,产生大量内部空洞;请求结束释放出的零散碎片又无法拼接复用,形成外部碎片。B 恰好是分页想要实现的目标,C 和 D 与缺陷成因无关。
Q4. 块表(Block Table)的核心作用是什么?
- A.记录逻辑块到物理块的映射← 正确答案
- B.保存注意力权重参数
- C.压缩 Key 和 Value 数据
- D.计算生成 token 的概率
答案:A。块表是每个请求维护的映射数组,逻辑块号通过查表得到物理块号,配合块内偏移即可定位任意 token 的 KV。它既不存权重,也不压缩数据,更不参与概率计算,只负责“地址翻译”,相当于读者手里的借阅清单。
Q5. 某个物理块被多个请求共享(引用计数大于 1),一个新请求要往里写入 token 时会怎样?
- A.直接在共享块上覆写
- B.系统报错并终止推理
- C.写时复制分配新块← 正确答案
- D.把当前块大小减半
答案:C。共享块是只读的,直接覆写会破坏其他请求的数据。正确做法是写时复制:新分配一个物理块,把原块内容拷贝过去再写新 token,同时原块引用计数减一。这是操作系统 COW 思想在推理引擎中的移植。
Q6. PagedAttention 相比传统方案带来的收益包括哪些?
- A.消除内部碎片浪费← 正确答案
- B.消除外部碎片限制← 正确答案
- C.无需再维护块表
- D.支持前缀块共享← 正确答案
答案:ABD。按需分配消除预分配导致的内部碎片,物理块任意放置消除外部碎片,引用计数让相同前缀的物理块可被多请求共享。但块表恰恰是分页新增的管理结构,不可能“无需维护”,所以 C 错误。
Q7. 关于块大小 B 的选取,正确的权衡认识是什么?
- A.B 越小块表越大且越长← 正确答案
- B.块越大寻址速度越快
- C.B 与模型层数严格正比
- D.B 越小 KV 精度越低
答案:A。块越小,相同 token 数对应的物理块数越多,块表越长、开销越大;块大则尾部浪费增加,所以需要折中。寻址速度由块表查找机制决定,与块大小不构成“越快”关系;块大小与层数、KV 精度都没有正比关系。
Q8. 以下哪些场景适合利用前缀共享?
- A.固定系统提示词对话← 正确答案
- B.少样本示例前缀← 正确答案
- C.RAG 长文档问答← 正确答案
- D.每个请求随机话题
答案:ABC。前缀共享的前提是请求间存在完全相同的 token 序列前缀。固定系统提示词、少样本示例、RAG 文档都是典型的公共前缀,而“随机话题”意味着每个请求前缀各不相同,共享收益接近于零。
Q9. 关于写时复制(COW)机制,正确的说法有哪些?
- A.共享块内容不允许直接修改← 正确答案
- B.复制后原块引用计数减一← 正确答案
- C.每次生成都必须复制
- D.复制出的新块归请求独占← 正确答案
答案:ABD。COW 的前提是引用计数大于 1,此时共享块只读;复制出新块后原块引用减一,新块由发起请求独占写入。只有写入共享块时才复制,独占块直接写,所以“每次生成都复制”错误。
Q10. 某请求已生成 45 个 token,块大小为 16,至少需要多少个物理块?
- A.2 个物理块
- B.3 个物理块← 正确答案
- C.4 个物理块
- D.16 个物理块
答案:B。物理块数等于 ceil(45 / 16) = 3。前两块各装 16 个 token 恰好装满,第三块只装 13 个 token,产生半个块量级的内部碎片——这正是分页后唯一残留的尾部浪费,远小于传统方案按最大长度预分配的空洞。