paged-attention-paper
Efficient Memory Management for Large Language Model Serving with PagedAttention
摘要全文;分页机制的实现细节与实验各节没有读
vLLM 与 PagedAttention 的论文。摘要原文:显存「可能因碎片和重复而被大量浪费」;方法受操作系统虚拟内存与分页启发;实现 KV 缓存显存近零浪费;同等延迟下吞吐提升 2-4 倍。2026-09-07 直接核对摘要。
打开原始来源一题一页 · 运行机制 · 也叫 KV cache、键值缓存
模型每多算一个位置(一个 token,不一定正好是一个汉字),前面所有位置的中间结果本来要重算一遍。存下来就不用重算,代价是随对话变长不断吃显存。
此刻在看:静态解释,这一页的 4 个部分都是这一种。
主张依据 2 类:原始论文、编辑解释。
核实状态:这一页没有需要核实的结论,所以这一维是空的。
表示此刻展示方式,不是准确性等级。
可多选,每种都有实际引用;「本站实验」需有记录。
附范围、时间及必要原因,不等于全页绝对正确。
这一页没有需要核实的结论,所以这一维是空的——不会因为它挂着论文就替它标一个「已核对」。
写到第 101 个位置时,前 100 个位置的中间结果直接从显存里取,只算新的这一个。
模型算下一个位置时,要用到前面所有位置的中间结果。如果什么都不存,算第 100 个位置就要把前 99 个位置的中间结果重算一遍,算第 1000 个位置要重算 999 个。越算越慢,而且慢得很快。
(这一段说的位置就是模型真正数的单位:一个位置是一个 token,不一定正好是一个汉字。日常把它说成「1 个字 ≈ 1 个位置」只是粗算;下面那个式子和第 2 级的旋钮数的都是位置。)
存下来之后,每个位置只算一次。这就是「上下文越长越贵」这句话的物理来源。
每个位置(这里用一个输入向量代表一个 token,不是一个汉字)在每一层都留下两个向量,一个叫 K,一个叫 V。要估它占多少,用这个式子:
KV 缓存字节数 ≈ B × L × N × 2 × H_kv × d_head × bytes_per_element
B 同时在跑的序列条数(batch)
L 层数
N 上下文里的位置数
2 K 和 V 各一份
H_kv KV 头数(用了 GQA/MQA 时它小于 Q 的头数)
d_head 每个头的维度
bytes_per_element 每个数占几字节(fp16 是 2)
这是逻辑张量存储估计:只算张量本身,不含显存分配器的碎片、临时缓冲区、以及多卡分片时的通信副本。按 1024² / 1024³ 换算就写 MiB / GiB;写 MB / GB 时用的是十进制的 10⁶ / 10⁹,两者在 GiB 量级差约 7%。要把它对到某个具体模型上,得先核对那个模型公布的 L、H_kv、d_head,不能照搬本页的假设配置。
在「完整保留全部历史、结构固定」这一种策略下,N 一涨,占用就跟着线性涨,一直到显存装不下为止。滑动窗口、前缀裁剪、KV 量化都会改变这条线,本页不覆盖它们。
早期做法是按「这次对话最长可能多长」一次性划一大块显存。实际用不满,剩下的就白占着。PagedAttention 的想法是把缓存切成固定大小的小块,用多少给多少,还能让多个请求共用相同开头的块。这正是操作系统管内存用了几十年的分页思路。
按暂停会把这一步的文字整段显示出来。这段字本来就是预先写好的,逐字出现只是展示方式,不是模型正在生成。 键盘:焦点在这个实验里时,← → 切换步骤,空格 播放或暂停。
省时间的办法是花显存,省显存的办法是把显存切细。想看真实测出来的数字,往下走第 3 级——注意那里数的是四项(账 B),和这里的旋钮不是同一把尺子。
左边是不缓存时 K/V 投影要算多少次,右边是缓存时。再看下面缓存占了多少,它只会一直往上。
这是账 A:只数 K 和 V 两个投影的乘加次数(MAC = 一次乘法加一次累加,不要当成 FLOPs)。 缓存那一路 = 2 × L × D² × N,不缓存那一路 = 2 × L × D² × N(N+1)/2,比值恰好是 2/(N+1)。 它不数 Q 投影、注意力打分和加权求和,所以这不是整体速度。 第 3 级那个脚本报的是另一笔账(账 B,四项都数),同一个配置下数字不一样,两笔账不能互相替代; 两边共用的 K/V 投影这一项由 tests/golden/kv-account-a.json 在两侧各核对一遍。 一个「位置」代表一个 token,不是一个汉字。这里的 D 是假定每层 K/V 向量的维度, 并假定 K、V 同维、投影是方阵;缓存元素数 2 × L × D × N 同样基于这个简化配置, 不代表任意模型的 KV 维度。「按每数 2 字节」是逻辑张量存储估计,不含分配器、额外缓冲与分片通信开销。 线性增长只对「完整保留全部历史、结构固定」这一种策略成立,不覆盖滑动窗口、裁剪与量化。
此刻在看:浏览器计算 + 教学脚本——2 个部分各自标着自己的那一种,没有一个徽章能概括整页。
主张依据 3 类:原始论文、本站实验、编辑解释。
部分核对 核实状态:部分核对 · 核于 2026-09-08。
表示此刻展示方式,不是准确性等级。
可多选,每种都有实际引用;「本站实验」需有记录。
附范围、时间及必要原因,不等于全页绝对正确。
想自己动手,看这个 一个约 4 秒跑完的脚本(预热 2 轮、测量 10 轮),量出缓存到底省了多少
此刻在看:历史记录展示 + 教学脚本 + 静态解释——3 个部分各自标着自己的那一种,没有一个徽章能概括整页。
主张依据 2 类:本站实验、编辑解释。
作者已逐字核对 核实状态:作者已逐字核对 · 核于 2026-09-08。
表示此刻展示方式,不是准确性等级。
可多选,每种都有实际引用;「本站实验」需有记录。
附范围、时间及必要原因,不等于全页绝对正确。
模型算下一个位置时要看一遍前面所有位置。如果什么都不存,算第 40 个位置就要把前 39 个连同这一个,从头过一遍全部两层。这不是比喻,是真的在重算,下面把它跑出来量一量。
这个脚本处理的是 40 个固定随机向量,没有词表、没有分词器,也没有文本解码,屏幕上不会出现一个汉字。所以下面一律说「位置」——在真实模型里一个位置对应一个 token,不是一个汉字。
两层注意力,每层维度 32。两个生成函数算的是同一件事,区别只在于前面那些位置要不要重算。注意上面那个函数:一层里先把所有位置都更新完,才进下一层——少了这一步,第二层的历史位置就还吃着原始输入,两条路径根本对不上。
def forward_all_layers(model, tokens, cnt=None):
"""不存:把整个前缀从头过一遍所有层。"""
layer_in = [list(t) for t in tokens]
per_layer = []
for layer in model:
keys = [matvec(layer["k"], t) for t in layer_in] # 全部重算
values = [matvec(layer["v"], t) for t in layer_in] # 全部重算
out = []
for i, t in enumerate(layer_in): # 每个位置只看 0..i
q = matvec(layer["q"], t)
out.append(attend(q, keys[: i + 1], values[: i + 1], cnt))
per_layer.append(out)
layer_in = out # 整层更新完才进下一层
return per_layer
def gen_with_cache(model, new_token, cache, cnt=None):
"""存:只算新位置的 K/V,前面的直接从缓存里取。"""
x = list(new_token)
for li, layer in enumerate(model):
cache[li]["k"].append(matvec(layer["k"], x)) # 只算这一个
cache[li]["v"].append(matvec(layer["v"], x)) # 只算这一个
q = matvec(layer["q"], x)
x = attend(q, cache[li]["k"], cache[li]["v"], cnt)
return x 全文 534 行,注意力、softmax 都是手写的,没有任何依赖;上面这段数值部分只占 130 行左右,剩下的是下面要说的计时方法学和那份机器可读记录。两条路径必须给出同一个结果,这条由 tests/demo/test_kv_cache.py 在 27 个配置、1080 个前缀上逐个核对。
python3 kv_cache.py KV 缓存:同一个计算的两种执行方式,在这台机器上各跑一遍
配置 2 层 × 每层维度 32 × 40 个位置 · 随机种子 0
一个位置就是一个输入向量。没有词表、没有分词器,屏幕上不会出现汉字。
计时口径 权重与输入向量在计时区外一次生成;乘加计数单独跑一趟,也不进计时区
一轮 = 完整跑完 40 个位置;缓存那一路每轮换一份新缓存
预热 2 轮,测量 10 轮,两路先后顺序逐轮交替(不缓存先 5 轮 / 缓存先 5 轮)
下面报 10 轮的中位数,方括号里是这几轮的最小值与最大值
「一轮总计」是那一轮各步测量值之和,不含轮与轮之间的开销
缓存占用 最后一列数的是「缓存里存了多少个数」,不是内存占用
Python 的 list 与 float 每个元素实际要几十字节,不等于「元素数 × 2 字节」
「每数 2 字节」是 fp16 的逻辑张量存储估计,属于第 2 级旋钮那笔账
纯 CPU 本机运行,没有测量任何 GPU 显存
运行环境 Python 3.14.7(CPython)· macOS-26.6.2-arm64-arm-64bit-Mach-O · arm64 · 10 逻辑核
运行日期 2026-09-08
脚本哈希 sha256 dac090c15fd670a6b115fc2b8a9f552bd3cb4297a8511378104d05d1d0f9261d
加速器 纯 Python、单线程、CPU;没有用加速器,所以不报告型号与同步耗时
位置 不缓存·本步 [最小, 最大] 缓存·本步 [最小, 最大] 中位数比 缓存占用
--------------------------------------------------------------------------------------
5 1.36 ms [ 1.31, 1.47] 0.28 ms [ 0.27, 0.30] 4.8× 640 个数
10 2.84 ms [ 2.74, 2.98] 0.30 ms [ 0.30, 0.32] 9.5× 1,280 个数
20 6.16 ms [ 5.98, 6.65] 0.35 ms [ 0.34, 0.44] 17.5× 2,560 个数
30 9.99 ms [ 9.76, 10.75] 0.40 ms [ 0.39, 0.51] 24.8× 3,840 个数
40 14.48 ms [ 14.10, 16.21] 0.45 ms [ 0.44, 0.50] 32.0× 5,120 个数
--------------------------------------------------------------------------------------
一轮总计 267.04 ms [261.88, 307.73] 14.41 ms [14.16, 15.88] 18.5×
总乘加次数 不缓存 6,507,520 缓存 350,720
缓存这一路是不缓存的 5.4%
(只统计 Q/K/V 投影与注意力这四项的乘加,不含 softmax、缩放和循环开销;
这个配置:2 层 × 维度 32 × 40 个位置,换配置数字就会变) 这段输出实际跑出来: 在哪里跑的:作者本机,arm64 macOS,纯 CPU 单线程,Python 3.14.7 你现在打开这一页不会有任何程序被执行。
这段输出是本页作者在 2026-09-08 逐字粘贴的真实运行结果,环境就写在上面第 8、9 行,脚本哈希写在第 10 行——脚本改一个字节,那串哈希就变,页面不重贴就会被 tests/demo/test_kv_cache.py 第 10 组挡住。毫秒那几列跟机器快慢和当次波动有关,你跑出来会不一样,所以它们在门禁里是被放过的;乘加次数、缓存占用、轮数与哈希是精确的,用固定随机种子,你跑出来应该一模一样。
上面那块「计时口径」不是客套话,它决定了这些毫秒能不能信。权重和输入向量在计时区外一次生成,乘加计数也单独跑一趟——计数器每次加一都是真实开销,混进计时区就成了在量计数器。一轮是把 40 个位置完整跑一遍,缓存那一路每轮换一份新缓存,否则第二轮会接着上一轮的历史往下算。先预热 2 轮再测量 10 轮,两条路径的先后顺序逐轮交替(各 5 轮),免得固定次序让先跑的那一路单方面吃亏。报的是这 10 轮的中位数,方括号里是最小值和最大值。
方括号不是装饰。看「一轮总计」那一行:不缓存的中位数是 267.04 毫秒,最大值却是 307.73——10 轮里有一轮慢了 15%。只跑一次而恰好撞上那一轮,这一页写出来就是另一个数字。另外,「一轮总计」是那一轮各步测量值之和,不是这一轮的墙钟时间,轮与轮之间的开销不在里面。
这次运行里,第 5 个位置的中位数是 1.36 毫秒,第 40 个位置是 14.48 毫秒,涨了十倍多。因为算第 40 个位置时,前面 39 个位置要连同这一个一起,从头过一遍全部两层。位置越多重算得越多:投影那部分跟着位置数线性涨,注意力打分那部分按平方涨,合起来比线性更陡。
到第 40 个位置时,缓存那一路的中位数比不缓存快 32.0 倍——这是这台机器这 10 轮的中位数之比,换台机器会变。不随机器变的是乘加次数:从 6,507,520 降到 350,720,缓存这一路只占 5.4%(分母是不缓存那一路的 6,507,520 次,只数 Q/K/V 投影与注意力这四项)。这也是推理框架普遍会实现它的原因——本站没有逐个统计过哪些框架实现了它。
耗时比和计数比是两笔不同的账,不要互相替代:前者随机器、随当次波动,中位数之比也只是这台机器这 10 轮的中位数之比;后者是这个配置下的精确整数。
第 2 级那个可拖的旋钮和这个脚本,报的不是同一个百分比,这不是谁算错了,是统计范围不同。旋钮只数 K 和 V 两个投影(账 A),脚本还要加上 Q 投影、注意力打分和加权求和(账 B)。同一个配置下,账 A 是 4.878%,账 B 是 5.389%(页面上写 5.4%)。两个数字都对,各自的分母和范围就写在下面这张表里,谁也不能替谁。
配置:2 层 × 维度 32 × 40 个位置,种子 0
账 A(第 2 级旋钮) 账 B(第 3 级脚本)
数什么 K 投影 + V 投影 Q/K/V 投影 + 打分 + 加权求和
不缓存 3,358,720 次乘加 6,507,520 次乘加
缓存 163,840 次乘加 350,720 次乘加
缓存/不缓存 2/41 = 4.878% 350,720/6,507,520 = 5.389%
为什么不同 分母里少了 Q 投影与注意力那两项,
而缓存那一路省下的正好集中在 K/V 投影上
两笔账都不含 softmax 的 exp 与除法、1/sqrt(d) 缩放乘、
循环与内存分配,所以两个「合计」都不是「所有计算」。
MAC = 一次乘法加一次累加,不要不加说明地当成 FLOPs。 两边共用的那一项(K/V 投影)由跨语言黄金样例 tests/golden/kv-account-a.json 双向核对——Node 侧 pnpm kv:accounts 比对旋钮的实现,Python 侧 pnpm kv:test 第 11 组真的跑一遍这个脚本再读计数器。不是靠在文档里写一句「两边一样」。
很多人以为有了缓存,每个位置的耗时就固定了。看第三列的中位数:0.28、0.30、0.35、0.40、0.45 毫秒,一路往上。但别把它读成「每一次都更慢」——方括号里那些范围就是当次抖动,这一次第 20、30、40 三个采样点的范围直接彼此重叠(0.34~0.44、0.39~0.51、0.44~0.50 毫秒),单挑某一轮出来看,第 20 个位置完全可能比第 40 个位置还慢。真正严格增长的是精确计数:算第 N 个位置时,注意力要做 N 次打分、N 次加权求和,这一项跟着 N 线性涨,和机器状态无关。
缓存去掉的是「重算」,去不掉「每一步都要看一遍前面所有位置」。所以长对话还是会越来越慢,只是慢得没那么快。
缓存占用从 640 个数涨到 5,120 个数,严格线性。这个极小模型每个位置只占 128 个数(2 层 × 32 维 × K 和 V 两份)。真实模型要按「批大小 × 层数 × 位置数 × 2 × KV 头数 × 每头维度 × 每个数几字节」估(式子的完整定义在第 1 级那张卡的正文里),层数几十、KV 头几个到几十、每头上百维,一个位置常常是 MiB 量级——具体多少得查那个模型公布的配置,不能照搬本页。上下文越长越贵,物理来源就在这一列。
这一列的单位是「个数」,不是内存占用,更不是显存读数——这一趟是纯 CPU 本机运行,没有测量任何 GPU 显存。真要按字节算也别拿这个数直接乘:Python 的 list 与 float 每个元素实际要几十字节,不等于「元素数 × 2 字节」;第 2 级旋钮那栏「按每数 2 字节」是 fp16 的逻辑张量存储估计,属于账 A,不含分配器、额外缓冲与分片通信。所以这一列能证明的只有一件事:随位置数严格线性增长,而且没有上限。
既然在「完整保留全部历史、结构固定」这一种策略下缓存会一直涨,剩下的问题就是别浪费。PagedAttention 论文摘要说的正是这件事:显存可能因碎片和重复被大量浪费,它借操作系统的分页思路把缓存切成小块,做到近零浪费,同等延迟下吞吐提升 2 到 4 倍。
这句是从论文摘要原文核对的,来源见页尾。
到这里读者要做的事已经做完了。下面这些参数是给想自己接着量的人用的:同一份结果加上 --json 就变成机器可读的 JSON,里面有 config(这次跑的是什么配置)、method(预热与测量轮数、统计量、两路顺序怎么平衡、哪些东西被排除在计时区外)、environment(Python 版本、平台、CPU、逻辑核数、日期)、script(脚本名与 sha256)、counts(四项乘加的分项与合计)、timing(每个采样点的中位数、最小值、最大值和全部样本,外加每一轮的总耗时与结束时的缓存长度)。可读摘要和这份 JSON 是同一次测量的两种渲染,不是跑了两次。
python3 kv_cache.py --json # 同一份结果,机器可读
python3 kv_cache.py --repeats 30 # 想让波动范围更可信就多测几轮
python3 kv_cache.py --tokens 80 --layers 4 # 换配置:位置数、层数、维度、种子都能改
python3 kv_cache.py --help # 全部参数 换了配置,页面上这些数字就全都不作数了——它们只属于「2 层 × 维度 32 × 40 个位置 × 种子 0」这一种配置。JSON 里的 config 段就是为了让别人一眼看出你跑的是哪一种。
这一页原先写的是「降到 8.5%、快 14.4 倍」。那组数字来自一个有错的旧脚本:它的「不缓存」路径每层只更新最后一个位置,历史位置进第二层时还带着原始输入,所以两条路径压根不是同一个计算的两种执行方式——从第 2 步起输出就差到 2.67。旧结果已归档为「旧实验实现有误,不作为等价性能对照」,不作数,也不保留原日期充当对照基线。
现在这版先证明两条路径数学等价(27 个配置逐前缀比对,实测最大绝对误差 6.7e-16),再报告数字。修好之后的比值比原来更好看,也照实写。2026-09-07 的那一版还有一处不能信:毫秒是单次采样、不预热、而且计数器一路跟着跑量出来的,只能读作「这台机器这一次的观测」。2026-09-08 按上面那套方法重量了一遍,所以毫秒那几列的数字也跟着变了——不是结论变了,是量法变了。
这一页是「KV 缓存」的第三级。卡片告诉你它是什么,场景演了一遍它怎么发生,这里让你自己把数字量出来。
整个脚本在作者机器上约 4 秒跑完(预热 2 轮、测量 10 轮),不需要显卡,不需要装任何东西。
在完整保留全部历史这一种策略下,缓存随位置数线性增长,一直吃显存。
在 2 层 × 维度 32 × 40 个位置的小模型上,按账 B 的口径,缓存这一路的乘加次数是不缓存的 5.4%。
模型每多写一个位置(一个 token,不一定正好是一个汉字),都要用到前面所有位置的中间结果。不存就得反复重算,存下来就不用重算;在「完整保留全部历史、结构固定」这一种策略下,缓存随位置数线性增长,一直吃显存。这就是「上下文越长越贵」的物理来源。
在这个 2 层、维度 32、40 个位置的小模型上,按账 B 的口径(Q/K/V 投影加注意力共四项),缓存这一路的乘加次数是不缓存的 5.4%,分母是不缓存那一路的 6,507,520 次。第 2 级旋钮报的是另一笔账(账 A,只数 K/V 投影),同配置下是 4.878%,两个数字不能互相替代。但每一步的耗时仍然在涨,缓存占用也一直在涨:它去掉的是「重算前面所有位置」,去不掉「每一步都要看一遍前面所有位置」。
上面那一栏是整条结论的公共条件;下面每一行另有自己的口径,所以单独摘走一行也带着口径。
| 这个数是什么 | 数值 | 它在什么范围内成立 |
|---|---|---|
| 账 B 口径下缓存这一路的乘加次数占不缓存的比例 | 5.4% | 账 B 只数 Q/K/V 投影与注意力这四项的乘加,不含 softmax、缩放与循环开销;配置是 2 层 × 维度 32 × 40 个位置 × 随机种子 0
分子 350,720 ÷ 分母 6,507,520 公式 350720 / 6507520 * 100 |
| 不缓存那一路的乘加次数 | 6,507,520 次 | 同一配置下账 B 的分母,是精确整数,与机器快慢无关 |
| 账 A 口径下缓存这一路的乘加次数占不缓存的比例 | 4.878% | 账 A 只数 K 与 V 两个投影,是第 2 级旋钮报的那笔账;同一配置下与账 B 不能互相替代
分子 163,840 ÷ 分母 3,358,720 公式 163840 / 3358720 * 100 |
这一页在 2026-09-07 之前写的是「缓存把乘加次数降到 8.5%、快 14.4 倍」。那组数字来自一个有错的旧脚本:它的「不缓存」路径每层只更新最后一个位置,历史位置进第二层时还带着原始输入,两条路径根本不是同一个计算的两种执行方式。
已失效 · 本站核对这条旧结论: · 核的范围:附录 A 在未改动的旧实现上复现了失效:单层通过,两层从第 2 步起输出就差到 2.66997(已归档,不作数,也不保留原日期充当对照基线;替代它的正式结论就在同一条目里)
paged-attention-paper
摘要全文;分页机制的实现细节与实验各节没有读
vLLM 与 PagedAttention 的论文。摘要原文:显存「可能因碎片和重复而被大量浪费」;方法受操作系统虚拟内存与分页启发;实现 KV 缓存显存近零浪费;同等延迟下吞吐提升 2-4 倍。2026-09-07 直接核对摘要。
打开原始来源