两者名字很像,但不是同一道题。FlashAttention 关心计算 attention 时搬了多少数据;PagedAttention 关心很多请求同时生成时,KV cache 怎样放。一个主要改变计算与读写方式,另一个主要改变缓存的组织方式。
先各算一个小例子,再讨论它们什么时候有用。
一行 attention,不一定要存下整行概率
固定一个 query,设它与 3 个 keys 的分数为 $[\log1,\log2,\log3]$,对应的标量 values 为 $[10,20,0]$。这是方便手算的例子,真实 value 是向量。
$$ \begin{aligned} o&=\frac{1\cdot10+2\cdot20+3\cdot0}{1+2+3}\\ &=\frac{50}{6}\approx8.3333. \end{aligned} $$
直接做法先存分数、softmax 概率,再乘 values。若每个 query 都这样展开,普通实现可能产生 $T\times T$ 的中间矩阵。我们其实只需要最后的加权结果,不一定要把所有中间概率写到显存里。
Online softmax:新块来了,旧统计怎样调整?
已经读过的分数最大值记为 $m$,保存两个量:
$$ \begin{aligned} \ell&=\sum_j e^{s_j-m},\\ u&=\sum_j e^{s_j-m}v_j. \end{aligned} $$
最终输出是 $u/\ell$。新块的最大分数是 $m_b$,更新到 $m'=\max(m,m_b)$ 时,旧统计要乘 $a=e^{m-m'}$:
$$ \begin{aligned} \ell'&=a\ell+\sum_{j\in b}e^{s_j-m'},\\ u'&=au+\sum_{j\in b}e^{s_j-m'}v_j. \end{aligned} $$
不是“每块分别 softmax,再平均结果”。块的归一化分母不同,直接平均会算错。
| 读到哪里 | $m$ | $\ell$ | $u$ |
|---|---|---|---|
| 前两个位置 | $\log2$ | 1.5 | 25 |
| 加入第三个位置 | $\log3$ | 2 | $50/3$ |
第二步把旧统计乘 $2/3$,再加新块。最后仍得到 $25/3$。第三个 value 虽然是 0,分母还是增加了,不能把这个位置忽略。
Online 不代表概率可以立刻定下来
只看到前两个位置时,概率是 $[1/3,2/3]$;第三个位置加入后,前两项就变成了 $[1/6,2/6]$。后面还有多少分数没读到,分母就还没定下来。
因此,online softmax 能一遍维护最大值和归一化分母,不代表读到一个元素就能输出它最终的概率。2018 年的原论文算法 3 仍有一个输出阶段。若要返回完整概率向量,还要重新读输入或保留所需数据;attention 则可以同时积累加权分子 $u$,最后只输出 $u/\ell$。这才是本例省下整行概率存储的原因。
两个块已经分别算完,怎样合起来?
块 A、B 各保存 $(m_A,\ell_A,u_A)$ 和 $(m_B,\ell_B,u_B)$。先选共同的最大值 $m=\max(m_A,m_B)$,再把两边换到同一个尺度:
$$ \begin{aligned} \ell&=e^{m_A-m}\ell_A+e^{m_B-m}\ell_B,\\ u&=e^{m_A-m}u_A+e^{m_B-m}u_B. \end{aligned} $$
比如本例前两项的加权结果是 $50/3$,第三项单独算是 0。直接把两个输出平均会得到 $25/3$,这次恰好与正确答案相同,因为两块的未归一化权重和都是 3。这个巧合不能当作算法。 换成分数 $[\log1,\log1,\log8]$、values $[10,20,0]$,两块平均变成 7.5,正确结果却是 3。合并统计量才会保留两块不同的权重。
实数运算中这些统计可以按不同顺序合并;浮点求和仍有舍入差异。整块都被 mask 时直接跳过,避免算 $e^{-\infty-(-\infty)}$;整行都被 mask 则没有可归一化的分布。
少一遍扫描,具体省在哪里?
按原论文算法 2 / 3 的计数,safe softmax 分别扫描输入来找最大值、求分母、算输出:每个元素读三次、写一次。Online normalizer 合并前两遍,变成读两次、写一次。这里数的是算法中的内存访问,不是 CPU 与 GPU 往返传输的次数。
两者时间复杂度仍是 $O(n)$。实际 kernel 能否把数据留在寄存器或共享内存、是否与后续算子融合,还会改变真正的 HBM 流量和耗时;不能拿扫描次数直接推算加速比。
用几十行代码核对,而不是先写 GPU kernel
下面是单行、标量 value 的教学实现。它验证数学,不模拟 GPU 性能。被 mask 的位置用负无穷表示,可以跳过;NaN、正无穷和可见位置的非有限 value 则报错。整行都无效时也明确报错,而不是默默输出 NaN。
import math
def streaming_attention(scores, values, block_size):
if (len(scores) != len(values) or not isinstance(block_size, int)
or isinstance(block_size, bool) or block_size < 1):
raise ValueError("Invalid shape or block size")
if any(score != -math.inf and not math.isfinite(score) for score in scores):
raise ValueError("Scores must be finite or -inf for masked positions")
if any(not math.isfinite(value) for score, value in zip(scores, values)
if score != -math.inf):
raise ValueError("Visible values must be finite")
running_max = -math.inf
normalizer = 0.0
numerator = 0.0
for start in range(0, len(scores), block_size):
pairs = [(score, value) for score, value in
zip(scores[start:start + block_size], values[start:start + block_size])
if score != -math.inf]
if not pairs:
continue
new_max = max(running_max, max(score for score, value in pairs))
correction = math.exp(running_max - new_max)
normalizer *= correction
numerator *= correction
for score, value in pairs:
weight = math.exp(score - new_max)
normalizer += weight
numerator += weight * value
running_max = new_max
if normalizer == 0:
raise ValueError("No valid keys")
return numerator / normalizer
scores = [math.log(1), math.log(2), math.log(3)]
assert math.isclose(streaming_attention(scores, [10, 20, 0], 2), 25 / 3)
可以把 block size 改成 1 或 3,再给所有分数加 1000。正确的归一化结果应保持一致;浮点误差按容差比较。实际实现还需处理向量 values、批次、不同 mask、dropout 和梯度。
FlashAttention 的收益,不等于少算所有配对
FlashAttention将分块计算与这些统计量结合,尽量在较快的片上存储中处理块,避免把完整 attention 中间矩阵反复写入、读出 HBM。反向阶段也可以重算部分量。片上存储和 HBM 的容量、带宽差多少,见 GPU 架构的内存层级;分块读进 shared memory 再反复使用的写法,见 CUDA 的 tiling。
对 dense attention,它仍计算允许的 query–key 配对,通常没有把二次方配对数变成线性。数学上的 exact attention 也不承诺不同运算顺序下逐 bit 相同。
短序列、特殊 mask、不同 head dimension 或不匹配的硬件,收益会不同。测试应同时比较输出和梯度,并分别测 prefill、decode;不要把某个 kernel 的速度提升直接当作端到端提升。
FA2 到 FA4 为什么还要继续改?
不再存整张矩阵以后,GPU 也未必已经忙满。计算怎样分给线程、不同操作能否重叠,仍会影响速度。
| 版本 | 主要改动 | 别误会成什么 |
|---|---|---|
| FA1 | 分块与 online softmax,减少 HBM 中间量读写 | 少看一部分 keys |
| FA2 | 减少非矩阵乘操作,改进线程块与 warp 之间的工作划分 | 换一个近似 attention 目标 |
| FA3 | 面向 Hopper,把异步搬运、矩阵乘与 softmax 更好地重叠;另有 FP8 路径 | 所有硬件都会同样受益,或只能算 FP8 |
| FA4 | 面向 Blackwell(B200 / GB200):全异步矩阵乘流水线和更大的块;软件模拟 exp,只在必要时 rescale;反向用 tensor memory 和 2-CTA 矩阵乘减少 shared memory 读写 | Hopper 上也该换 FA4,或作者报告的加速比在你的形状上同样成立 |
FA2中一个具体改动是让不同 warps 负责不同 Query 行,减少交换部分结果的工作。这里的 warp 间通信主要涉及片上 shared memory,不要和 GPU 显存 HBM 混在一起;几种内存谁能看见、放在哪里,见 CUDA 的内存空间。
FA3进一步利用 Hopper 的异步执行能力(TMA 等异步搬运,见 NVIDIA 各代 GPU)。它同时讨论 FP16 和 FP8;采用 FP8 时,量化误差要单独测,不能拿“exact attention”当作没有数值误差的保证。
FA4(2026-03)针对 Blackwell。这一代的 Tensor Core 吞吐翻倍,shared memory 带宽和算指数的单元却没跟着涨,瓶颈又回到了矩阵乘以外的部分,所以它主要减少 exp、rescale 和 shared memory 流量。作者报告:B200 上 BF16 最高比 cuDNN 9.13 快 1.3 倍、比 Triton 快 2.7 倍。这是作者自报的 kernel 数字,不是端到端结果。实现全部用嵌在 Python 里的 CuTe-DSL 写成。
判断要不要用某个版本,先看当前硬件、形状和 dtype 支不支持,再测端到端收益。
PagedAttention:逻辑连续,不要求物理连续
假设一个物理块能存 4 个 token 的 KV。请求 A 有 5 个 tokens,请求 B 有 3 个:
A 的逻辑块 0 → 物理块 7:[A0 A1 A2 A3]
A 的逻辑块 1 → 物理块 2:[A4 -- -- --]
B 的逻辑块 0 → 物理块 5:[B0 B1 B2 --]
共分配 3 块、12 个槽,使用 8 个槽,尾块空了 4 个槽。分块没有消灭所有浪费,但避免了每个请求都预留最大长度的大块连续内存。
A 再把 3 个新 tokens 送入模型、形成它们的 K/V 后,可填满已有尾块;再追加一个位置才需要新块。请求结束后,引用计数允许时释放对应块。两个请求若共享相同前缀与模型计算条件,可以共享相应缓存;写入共享尾块时需要 copy-on-write 或等价机制,不能把别人的前缀改掉。
PagedAttention 论文讨论了这种块映射与共享。这里的块号是自拟示意,不是 vLLM API。缓存是否可共享,还要核对权重、adapter、位置与多模态输入等条件,而不只是文本相同。
上图的尾块空槽叫内部碎片(internal fragmentation)。另一种情况是空闲空间散在几处,总量够,却放不下一段要求连续的大缓存,这是外部碎片(external fragmentation)。所有块大小相同,所以分页消除了外部碎片;按需分配小块,又把内部碎片限制在每个请求最后一个未填满的块里。块表和这些尾块空槽仍有成本。
还要分清论文里的共享机制与当前框架实际开放的功能。vLLM 的 prefix caching 说明目前按完整块复用缓存,并把前缀、token IDs 及 adapter / 多模态信息等纳入缓存键。不能据此假定它会复用任意半满尾块。
请求结束,不代表缓存立刻消失
按本次核对的 vLLM V1 10cc2f6,活跃请求持有引用;最后一个引用释放后,缓存块可进入空闲队列,但内容仍可能被再次命中。新分配复用该块时会移除旧映射。这是容量驱动的回收,不是“过 10 分钟自动失效”的 TTL。固定版本的 block pool分别处理缓存块与未缓存块,不能把所有空闲块都理解成同一条纯 LRU 队列。
区分这三件事:请求不再持有、缓存不能再命中、GPU 内存归还给系统。它们不是同一时刻,也不是同一个操作;预分配的池可以一直占着显存供后续使用。取消请求时这些引用怎样释放,见请求生命周期。
拿一个已计算好的前缀块看就更直观:
| 发生的事 | 活跃引用数 | 这个块处于什么状态? |
|---|---|---|
| 请求 A 持有它 | 1 | 仍在使用,不能给不相关内容覆盖 |
| 请求 B 命中同一前缀 | 2 | 两个请求共享,不是复制两份 KV |
| A 结束 | 1 | B 还在使用 |
| B 也结束 | 0 | 可以被回收,但旧缓存仍可能命中 |
| 新请求命中,或分配器复用它 | 1 | 前者复用旧内容;后者先撤销旧映射,再存新内容 |
例如共用前缀有 7 个 token,块大小为 4,那么完整块路径至多复用前 4 个,不是 7 个。若第 2 个 token 改了,第一个块已经不匹配,后面的同样文字也不能单独拼上去:那些 KV 是在另一个前缀下算出来的。这里讲普通 full attention;混合 attention 的 cache group、hash 粒度与物理块可能不一一对应,要按实际配置检查。
vLLM 和 SGLang,不是“谁更细就谁更快”
两者的索引结构(块哈希对 radix 树)、默认粒度和回收策略,对比见推理框架一篇。树节点能拆分,不代表所有配置都按单 token 复用。这里只拿上面 7 个 token 的共同前缀,算一笔页对齐的账。本次核对 SGLang 436d73d:普通模型默认走 UnifiedRadixCache,page size 默认是 1,只有部分后端或模型会调大;page_size > 1 时,匹配前先做页对齐。page size=1 时可以保留 7;page size=4 则向下对齐到 4。若 vLLM 使用基础默认值 16 作为块大小,同一个例子凑不满一个完整块,命中为 0。它说明配置会改变复用粒度,不说明哪个系统更快。
所以不写“高并发一定选 vLLM、agent 一定选 SGLang”。先拿自己的请求回放(replay),固定模型和预算;同一框架换 backend,也可能比换框架带来更大的差别。同一批请求该核对哪些条件、报告哪些延迟指标,见推理框架的对照实验。这里核对的是机制与公开实现,没有运行 serving 性能对比。
分页、连续组批、分块 prefill 不是同一个开关
| 机制 | 管哪件事 | 一个直接的问题 |
|---|---|---|
| PagedAttention | KV 放在哪里 | 请求变长时,能否不搬整段缓存? |
| Continuous batching | 下一步让哪些请求一起跑 | 一个请求结束了,新请求能否马上补位? |
| Chunked prefill | 长 prompt 一次处理多少 | 新来的长 prompt 会不会让正在输出的人等太久? |
假设只能同时处理 2 条请求,A、B、C 还分别需要 4、1、2 步 decode。为了只看调度,暂不计 prefill、到达时间和每步耗时差别。
| Decode 轮次 | 固定一批跑完再换下一批 | 每轮允许补位 |
|---|---|---|
| 1 | A + B,B 完成 | A + B,B 完成 |
| 2 | A | A + C |
| 3 | A | A + C,C 完成 |
| 4 | A,A 完成 | A,A 完成 |
| 5–6 | C,直到完成 | 已完成 |
补位把这个理想例子从 6 轮变成 4 轮,不等于实测延迟下降三分之一。真实请求还需要 prefill,也会争抢带宽与缓存。Chunked prefill 让长 prompt 分几次处理,代价是首 token 可能更晚;能换来更平稳的 decode。vLLM 的调优文档介绍了这类 TTFT / ITL 取舍。这里的 prefill chunk 大小也不是 KV 物理块大小。
两种优化怎样放在一起看?
| 问题 | 更相关的机制 | 仍需测量 |
|---|---|---|
| Prefill 的中间 attention 矩阵太大 | FlashAttention | 峰值显存、前向/反向误差、耗时 |
| 并发请求长短不同,缓存碎片大 | PagedAttention | 块利用率、吞吐、调度开销 |
| 每条请求的 KV 本体就很大 | GQA / MQA / MLA 等架构 | 质量、缓存大小、实际 kernel |
| 只看少量历史能否更快 | 稀疏注意力 | 被遗漏的信息、选择开销 |
这些方法可以组合,但组合效果取决于实现。要报告硬件、dtype、序列长度、batch 和计时边界;先确认瓶颈,再选优化,比把名字全打开更可靠。
本页术语 1
- 回放 replay
- 用固定输入重新执行一段数据或模型链路,检查结果是否一致