音乐
暂未播放
FlashAttention 完全拆解(二):序列维并行、Warp 工作划分与 FlashDecoding
系列回顾:上一篇讲到了哪里#
上一篇拆解了 FlashAttention-1(2022 年,斯坦福大学 Tri Dao 等人,NeurIPS 2022):它用分块(tiling)、在线 softmax(online softmax)、反向重计算三件套,把注意力计算的 HBM 访问量从标准实现的 Θ(N2) 压到 Θ(N2d2/M),并证明了这是精确注意力的 IO 复杂度下界;显存占用从 O(N2) 降到 O(N),在 A100 上比 PyTorch 标准实现快 2–7.6 倍,数学上精确无近似。
但上一篇结尾也指出了它的短板:在 A100 上只达到理论峰值 FLOPs 的 25–40%(FlashAttention-2 论文中的评估)。它不再受 HBM 限制,却受制于自己对 GPU 并行模型的使用方式。本文接续这个系列,把 FlashAttention-2(2023 年 7 月 Tri Dao 发布于 arXiv 的技术报告,arXiv:2307.08691)彻底拆开:它的算法微调、序列维并行、warp 工作划分如何把峰值利用率推到 50–73%,以及它留下的解码(decode)阶段短板,如何由同年底的 FlashDecoding 技术补上。
为什么 FlashAttention-1 到不了 GEMM 的速度#
先精确回顾上一篇末尾点出的三个问题——它们是 FlashAttention-2 全部设计的出发点。
问题一:并行度只在 batch 和 head 两个维度。 FlashAttention-1 的每个线程块(thread block)负责一个注意力头,整个 GPU 上同时有 batch × head 个线程块。A100 有 108 个流式多处理器(streaming multiprocessor,SM),当 batch × head 远小于 108 时,大量 SM 空闲。而长序列场景恰恰是 batch 小的场景(显存放不下大 batch),比如 batch = 1、32 头时只有 32 个线程块,GPU 一半以上的算力闲置。序列长度维明明是注意力里数据量最大的维度,却没有参与并行。
问题二:warp 之间的分工(work partitioning)不合理。 一个线程块内通常有 4 个 warp 协作。FlashAttention-1 把 K、V 切给不同的 warp(论文称为 split-K 方案):每个 warp 算出 QKT 的一个切片后,必须把切片写进共享内存、同步、再归约相加,才能继续下一步的 PV 乘法。这一轮共享内存读写和同步是纯粹的开销,占据了 kernel 的大量执行时间。
问题三:非矩阵乘操作(non-matmul FLOPs)太贵。 这是最容易被忽视、却最致命的一点。现代 GPU 的矩阵乘有专用的张量核心(Tensor Core),但 softmax 里的指数、除法、比较、重缩放这些逐元素操作没有专用硬件。以 A100 为例:FP16/BF16 矩阵乘的理论峰值是 312 TFLOPS,而非矩阵乘的 FP32 计算只有 19.5 TFLOPS——每一个非 matmul FLOP 的代价是 matmul FLOP 的 16 倍(这是 FlashAttention-2 论文 3.1 节的原话)。要想让吞吐超过峰值的一半,就必须把尽可能多的指令周期花在矩阵乘上。
FlashAttention-2 的三项改进正好逐一回应这三个问题:算法微调削减非 matmul FLOPs;前向把外层循环换成 Q 块、反向换成 K、V 块,从而把序列维变成可并行维;warp 分工从”切 K、V“改成”切 Q“,消除 warp 间通信。下面逐一拆解。
改进一:算法微调,把非 matmul FLOPs 减到最少#
推迟归一化:维护未缩放(un-scaled)的输出累加器#
回顾 FlashAttention-1 前向里输出累加器的更新公式(以两个块为例,上标 (1)、(2) 表示处理顺序):
O(2)=diag(ℓ(1)/ℓ(2))−1O(1)+diag(ℓ(2))−1eS(2)−m(2)V(2)其中 ℓ(1)、ℓ(2) 是处理完第 1、2 个块后的行归一化常数(指数和),m(1)、m(2) 是行最大值,S(2) 是当前块的分数矩阵,V(2) 是当前的值块,diag(⋅) 把向量变成对角矩阵以便逐行缩放。这个更新里有两个除以 ℓ 的操作:旧的累加器 O(1) 要按 ℓ(1)/ℓ(2) 重缩放,新块贡献 eS(2)−m(2)V(2) 要按 ℓ(2) 归一化。每处理一个块都要做这两轮逐元素除法——对 Br×d 的输出累加器来说,每轮是 Br⋅d 次非 matmul 操作。
FlashAttention-2 的观察是:这两个除法其实可以推迟到最后。softmax 的归一化是”整行统一除以行和”,这个除法什么时候做都不影响比例关系,只要保证在写回 HBM 之前做了就行。于是论文改为维护一个”未缩放”版本的输出:
O~(2)=diag(em(1)−m(2))−1O~(1)+eS(2)−m(2)V(2)注意三个变化:第一,累加器 O~ 从头到尾不除以 ℓ,只是在新块的最大值更大时按 em(1)−m(2) 这个因子重缩放(把基准从旧最大值切到新最大值);第二,新块的贡献直接以更新后的最大值 m(2) 为基准做指数,不需要像 FlashAttention-1 那样先按块局部最大值 m 算一遍 P~、再乘修正因子 em~−m(2)——这两者在数学上恒等:
em(1)−m(2)⋅FA-1 先算eS(2)−m~(2)=FA-2 直接算eS(2)−m(2)第三,最终只做一次归一化:
O=diag(ℓ(Tc))−1O~(Tc)Tc 是 K、V 块的总数。这一趟除法在整个块的处理循环之外,成本可以忽略。
算一下省了什么:FlashAttention-1 每轮迭代要做 2⋅Br⋅d 次归一化除法(旧累加器和新贡献各一轮)加 Br⋅Bc 次修正因子乘法(对分数块逐元素);FlashAttention-2 每轮只有 Br⋅d 次重缩放(旧累加器乘标量)和必需的 Br⋅Bc 次指数。以典型块大小 Br=Bc=64、d=128 为例,FlashAttention-1 每轮多出约 2⋅64⋅128=16384 次逐元素除法,全部是非 matmul 操作——按前面 16 倍代价折算,相当于 26 万次 matmul FLOP 的时间。这个改动看起来小,但省掉的全是最贵的操作。
反向只存一个标量:logsumexp#
FlashAttention 的反向需要 softmax 的归一化概率来重算梯度。FlashAttention-1 为每个 Q 块保存两个统计量向量:行最大值 mi 和归一化常数 ℓi。FlashAttention-2 的观察是:反向真正需要的只有它们的组合——log-sum-exp(对数求和指数):
Li=mi+log(ℓi)为什么一个标量就够?因为归一化概率可以改写为:
Pij=ℓieSij−mi=eSij−mi−logℓi=eSij−Li反向时只需从 HBM 读入 Li 这一个 Br 维向量,直接在片上算出 exp(Sij−Li),就得到了归一化的概率块 Pi(j)——两个统计量向量(m 和 ℓ)合成了一个,省了一半的统计量存储和读写。数值上也是安全的:ℓi 是至少包含一个 e0=1 的指数和,所以 ℓi∈[1,Bc],logℓi∈[0,logBc] 是个很小的数,Li 的精度和 mi 相当。
因果掩码:跳过一半的块#
因果注意力(causal attention)下,分数矩阵 Sij 中列索引大于行索引的条目被置为 −∞,即矩阵的右上三角完全没用。FlashAttention 的分块天然支持这一点:凡是”所有列索引都大于行索引”的块(长序列下约占一半),整个块的 QKT 和 PV 计算都可以跳过。论文实测,跳过这些块带来约 1.7–1.8 倍的加速(相对不跳因果块的实现)。这不只是省 FLOPs——跳过的块也意味着对应的 K、V 加载被跳过,HBM 流量同样减半。
改进二:三层并行——序列维终于被用上#
FlashAttention-1 的并行只有两层:batch 和 head。FlashAttention-2 补上了第三层——序列长度维。这一步是前向和反向分开设计的,因为两者能”免费”并行的方向不同。
前向:外层循环换成 Q 块,每块承包一行#
FlashAttention-1 的循环结构是外层遍历 K、V 块、内层遍历 Q 块:每个线程块负责一个 (batch, head) 对,遍历完整条序列的 K、V。FlashAttention-2 把循环顺序对调——外层遍历 Q 块,内层遍历 K、V 块,于是每个线程块只需要负责一个 Q 行块:加载自己的 Qi,遍历所有 Kj、Vj 块,把输出 Oi 算完写回。不同线程块之间完全不需要通信——论文原话是”外层循环(序列长度维)是天然可并行的(embarrassingly parallel)”。
这样一来,前向的线程块总数从 batch × head 变成 batch × head × ⌈N/Br⌉。上一节的例子(batch = 1、32 头、序列长度 4096、Br=64)从 32 个线程块变成 32 × 64 = 2048 个线程块,A100 的 108 个 SM 全部满载,长序列场景的占用率(occupancy)问题彻底解决:

论文图 2 的左边展示的就是这个方案:注意力矩阵(N×N 的网格)被横向切成行块,标注为 worker 1、2、3、4……的线程块各承包一行块,互不重叠、互不通信。图里没有画出的细节是:每个 worker 内部仍然要按列遍历所有 K、V 块,也就是说同一个 K、V 块会被 Tr 个不同的 worker(不同的行块)各读取一次——这是交换循环顺序的 IO 代价:K、V 的读取量从 FlashAttention-1 的每元素 1 次变成每元素 Tr=N/Br 次,而 Q、O 反过来从”被读 Tc 次”变成只读 1 次。代入 Br=Θ(M/d),K、V 的总读取量是 Nd⋅N/Br=Θ(N2d2/M)——与上一篇推导的 IO 复杂度完全相同,仍然贴着精确注意力的下界。循环交换改变的只是”谁被重复读”,不改变总流量的量级,这是它划算的前提。
值得一提的渊源:论文明确致谢了 Phil Tillet——“交换循环顺序(外层行块、内层列块)以及序列维并行,最早由 Phil Tillet 在 Triton 的实现(fused-attention 教程)中提出并实现”。FlashAttention-2 的贡献是把这套想法在 CUDA 里推到极致。
反向:每个线程块承包一列 K、V,dQ 用原子操作#
反向传播的循环结构正好相反。看反向的依赖关系:梯度 dKj 是 ∑idSijTQi,梯度 dVj 是 ∑iPijTdOi——对同一个 K、V 块 j 的梯度,需要累加所有 Q 块 i 的贡献;而 dQi 则需要累加所有 K、V 块 j 的贡献。所以反向的”干净并行方向”是列:让每个线程块负责一个 K、V 列块,内层遍历所有 Q 行块。
这样设计的好处是:dKj 和 dVj 在块内(寄存器/共享内存里)就能完整累积,循环结束时一次性写回 HBM——不需要任何跨线程块通信。代价是 dQi 的累加必须跨线程块完成:每个负责列块 j 的线程块都要给 dQi 加上自己的贡献。论文的处理是用原子加(atomic add)通信:每个线程块从 HBM 加载 dQi,加上自己的贡献后写回,多个线程块对同一位置的写入由硬件原子操作保证不丢数据。论文图 2 的右边画的就是这个方案:worker 1、2、3、4……各承包注意力矩阵的一列块。
这里有个值得思考的”为什么”:为什么反向不也按行块并行(每个线程块负责一个 Q 行块)?那样的话 dQ 就能在块内完整累积,但 dK、dV 就要变成跨块累加、需要原子操作了。两者必有一方要跨块。论文选择让 dQ 承担原子操作,因为:第一,dK、dV 的梯度量是 K、V 矩阵本身的大小(N×d),让它们完整在块内累积、只写一次 HBM,省的是最贵的 HBM 往返;第二,dQ 的更新是”加载-修改-写回”模式,原子加对它来说只是多一个硬件保证,开销可控。这个取舍在后来的 FlashAttention-3/4 里也被继承了。
MQA/GQA:不复制 KV 头,反向跨头求和#
多查询注意力(Multi-Query Attention,MQA)和分组查询注意力(Grouped-Query Attention,GQA)是推理时代最常用的注意力变体:多个查询头共享同一组 K、V 头,以压缩 KV 缓存。实现上不需要真的把 KV 头复制多份参与计算——FlashAttention-2 通过对头索引的隐式映射完成”一个 KV 头服务多个 Q 头”的访存。但反向传播里,被隐式共享的 K、V 头收到的梯度来自多个查询头,必须跨头求和。这部分逻辑在 kernel 里显式处理:同一 K、V 头的 dK、dV 在写回前先把各查询头的贡献加起来。这个能力让 FlashAttention-2 天然适配 Llama 2/3 这类 GQA 模型,也是后面 FlashDecoding 能直接用 GQA 配置做基准的前提。
改进三:Warp 工作划分——从切 K、V 到切 Q#
线程块级别的并行解决了 SM 占用率,但每个线程块内部还有 4 个或 8 个 warp 要分工。这里 FlashAttention-1 的做法同样有缺陷,FlashAttention-2 的修改同样直击要害。
split-K 为什么慢:每次迭代都要跨 warp 归约#
FlashAttention-1 的线程块内分工是:K、V 切给 4 个 warp,Q 让所有 warp 共享。每个 warp 用自己的 K、V 切片和完整的 Q 相乘,得到 QKT 的一个”列切片”。问题在于:softmax 是对整行做的,而一行被切在了 4 个 warp 手里——每个 warp 手里只有这行的一部分分数,谁都没法单独算出 softmax。于是所有 warp 必须先把各自的 QKT 切片写进共享内存、同步(__syncthreads)、再把 4 份切片相加拼出完整的分数行,然后才能做 softmax、乘 V:

上图里可以清楚地看到:4 个 warp 各自持有 K、V 的一个切片,它们算出的 QKT 部分结果需要通过共享内存归约(图中央的合并步骤)才能得到完整的分数。这一轮共享内存写入、同步、读取,每次内层迭代都要做一次,是 FlashAttention-1 前向最显著的固定开销。
切 Q 为什么快:warp 之间零通信#
FlashAttention-2 的思路是:既然问题出在”一行被切碎了”,那就让每个 warp 拥有完整的一行——把 Q 切给 4 个 warp,K、V 让所有 warp 共享(从共享内存广播读取):

每个 warp 现在拥有一个 Q 的行切片(Q 块的一”横条”),它可以独立地:与完整的 Kj 相乘得到自己那部分分数、对自己那部分行做 softmax(行统计量不需要跨 warp 归约——行没有被切)、与 Vj 相乘得到自己那部分输出。整个前向循环中,warp 之间不需要任何通信,共享内存只承担 K、V 的广播读取。这一改动消除了 FlashAttention-1 里每轮迭代的共享内存写-同步-读归约,是 FlashAttention-2 前向加速的最大单项来源。
反向的 warp 分工:同样避免 split-K,但仍需同步#
反向的依赖关系比前向复杂得多——Q,K,V,O,dO,dQ,dK,dV 八个矩阵之间互相牵扯,没有办法做到 warp 之间零通信。但论文的原则不变:尽量避免 split-K 那种”切片-归约”模式。反向里每个 warp 同样按 Q 的行块分工(每个 warp 负责一个 Qi 行切片的梯度),由于 dK、dV 的累积需要跨行切片进行,warp 之间仍需要一定量的同步——但相比 split-K 的完整归约,共享内存读写量已经大幅下降,实测反向也有可观的加速(见性能一节)。
块大小的调优:为什么是 {64, 128} × {64, 128}#
块大小 Br×Bc 的选取遵循一个权衡:块越大,每个块的共享内存读写次数越少(K、V 复用次数更多),但寄存器占用和共享内存总量越大。块大小超过某个阈值后,要么寄存器溢出(register spilling)导致性能骤降,要么共享内存需求超过硬件容量、kernel 直接无法启动。论文的做法是:每个头维度 d 只在 {64,128}×{64,128} 四种组合里手工挑选,依据是设备共享内存大小和头维度。之所以不做自动调优,是因为候选只有 4 个、手工测一轮很快,论文把自动调优留给了未来工作(后来的 vLLM、Triton 等实现了运行时 autotune)。
完整算法:前向与反向#
把三个改进合起来,就是论文的 Algorithm 1(FlashAttention-2 前向)和 Algorithm 2(反向)。逐行看一遍。
前向(Algorithm 1)#
1输入:Q, K, V ∈ R^{N×d} 位于 HBM,块大小 B_c、B_r2 1: 把 Q 分成 T_r = ⌈N/B_r⌉ 个行块,K、V 各分成 T_c = ⌈N/B_c⌉ 个列块3 2: 把输出 O 分成 T_r 个块,把 logsumexp L 分成 T_r 个块4 3: for i = 1 to T_r do // 外层循环:遍历 Q 行块(序列维并行)5 4: 加载 Q_i 到片上 SRAM6 5: 在片上初始化 O_i = 0、ℓ_i = 0、m_i = -∞7 6: for j = 1 to T_c do // 内层循环:遍历 K、V 列块8 7: 加载 K_j、V_j 到片上 SRAM9 8: 在片上计算 S_i^{(j)} = Q_i K_j^T // 矩阵乘 1(张量核心)10 9: 在片上计算 m_i^{(j)} = max(m_i^{(j-1)}, rowmax(S_i^{(j)}))1110: 在片上计算 P̃_i^{(j)} = exp(S_i^{(j)} - m_i^{(j)}) // 直接用新最大值1211: 在片上计算 ℓ_i^{(j)} = e^{m_i^{(j-1)} - m_i^{(j)}} · ℓ_i^{(j-1)} + rowsum(P̃_i^{(j)})1312: 在片上计算 O_i^{(j)} = e^{m_i^{(j-1)} - m_i^{(j)}} · O_i^{(j-1)} + P̃_i^{(j)} V_j // 矩阵乘 21413: end for1514: 在片上计算 O_i = ℓ_i^{-1} · O_i^{(T_c)} // 唯一的归一化,循环外1615: 在片上计算 L_i = m_i^{(T_c)} + log(ℓ_i^{(T_c)}) // 反向要用的 logsumexp1716: 把 O_i、L_i 写回 HBM1817: end for1918: 返回输出 O 和 logsumexp L与 FlashAttention-1 的 Algorithm 1 对比,逐行的差异点:
- 第 3 行:外层循环从”遍历 K、V 块”变成”遍历 Q 块”,序列维成为线程块级并行维(改进二的核心);
- 第 10 行:指数直接以更新后的最大值 mi(j) 为基准,省掉了 FlashAttention-1 中”先按块局部最大值算 P~、再乘修正因子”的 Br×Bc 逐元素乘(改进一的核心);
- 第 12 行:输出累加器只做 exp 因子重缩放,不除以 ℓ——归一化推迟到第 14 行循环外的唯一一次(改进一的核心);
- 第 15 行:只存 Li=mi+logℓi 这一个统计量,而不是 mi 和 ℓi 两个(改进一的核心)。
注意第 9–12 行的两个小向量 mi、ℓi 是每个 Q 块内部的状态,随着内层循环推进被不断更新;它们和第 5 行的初始化一起,构成了”在线 softmax”在两块之间的完整状态传递——这套机制我们在上一篇推导过精确性,这里只是把更新的形状从”每步归一化”改成了”推迟归一化”,数学等价性不受影响。
反向(Algorithm 2)#
1输入:Q, K, V, O, dO ∈ R^{N×d} 位于 HBM,向量 L ∈ R^N,块大小 B_c、B_r2 1: 把 Q、O、dO、dQ 分成 T_r 个块;把 K、V、dK、dV 分成 T_c 个块3 2: 在 HBM 初始化 dQ = 04 3: 计算 D = rowsum(dO ∘ O),按行块分成 T_r 份,写回 HBM // ∘ 是逐元素乘5 4: for j = 1 to T_c do // 外层循环:遍历 K、V 列块(序列维并行)6 5: 加载 K_j、V_j 到片上 SRAM7 6: 在片上初始化 dK_j = 0、dV_j = 08 7: for i = 1 to T_r do // 内层循环:遍历所有 Q 行块9 8: 加载 Q_i、O_i、dO_i、dQ_i、L_i、D_i 到片上 SRAM10 9: 在片上重算 S_i^{(j)} = Q_i K_j^T // 矩阵乘(重计算)1110: 在片上计算 P_i^{(j)} = exp(S_i^{(j)} - L_i) // 只需 logsumexp1211: 在片上计算 dV_j ← dV_j + (P_i^{(j)})^T dO_i // 矩阵乘,块内累积1312: 在片上计算 dP_i^{(j)} = dO_i V_j^T // 矩阵乘1413: 在片上计算 dS_i^{(j)} = P_i^{(j)} ∘ (dP_i^{(j)} - D_i)1514: 在片上计算 dQ_i ← dQ_i + dS_i^{(j)} K_j // 跨块累加 → 原子操作1615: 在片上计算 dK_j ← dK_j + (dS_i^{(j)})^T Q_i // 矩阵乘,块内累积1716: end for1817: 把 dK_j、dV_j 写回 HBM1918: end for2019: 返回 dQ、dK、dV逐行要点:
- 第 3 行:Di=rowsum(dOi∘Oi) 是 softmax 反向的修正项(上一篇推导过),只需一行归约就能在整个反向开始前一次性算好;
- 第 4 行:外层循环遍历 K、V 列块——反向的序列维并行方向(改进二);
- 第 9–10 行:S 和 P 不存储、片上重算;重算概率只需要 exp(S−Li),m、ℓ 两个统计量合并成了 L 一个(改进一);
- 第 11、15 行:dVj、dKj 在块内寄存器/共享内存中累积完整,循环结束才写回 HBM——零跨块通信(改进二);
- 第 14 行:dQi 的更新是唯一需要跨线程块共享的状态,采用原子加(改进二);
- 计数:前向 2 次矩阵乘,反向 5 次矩阵乘(第 9、11、12、14、15 行)——这就是论文用”反向 FLOPs = 前向 × 2.5”来计算基准 FLOPs 的原因。
性能表现:数字说话#
论文在 A100 80GB SXM4 上做了完整基准:序列长度 512 到 16K,总 token 数固定为 16K(batch 随之缩小),hidden size 2048,头维度 64 或 128(32 头或 16 头),对比 PyTorch 标准实现、FlashAttention-1、Triton 版 FlashAttention、xformers(CUTLASS 实现)。
注意力本身的加速。 前向+反向合计,FlashAttention-2 相对 FlashAttention-1 快 1.7–3.0 倍,相对 Triton 版快 1.3–2.5 倍,相对 PyTorch 标准实现快 3–10 倍;最高达到 230 TFLOPS,即 A100 理论峰值的 73%——这是 FlashAttention-1 时代(25–40%)不敢想的数字,已经逼近 GEMM 的效率。单独看前向:相对 Triton 版快 1.3–1.5 倍;单独看反向:快约 2 倍——反向的收益更大,因为反向的共享内存读写和原子操作开销原本更重。
端到端训练吞吐。 用 8 张 A100 训练 GPT 风格模型(1.3B 和 2.7B,上下文 2K 或 8K):
| 模型配置 | 无 FlashAttention | FlashAttention-1 | FlashAttention-2 |
|---|---|---|---|
| GPT-3 1.3B,2K 上下文 | 142 TFLOPs/s | 189 TFLOPs/s | 196 TFLOPs/s |
| GPT-3 1.3B,8K 上下文 | 72 TFLOPs/s | 170 TFLOPs/s | 220 TFLOPs/s |
| GPT-3 2.7B,2K 上下文 | 149 TFLOPs/s | 189 TFLOPs/s | 205 TFLOPs/s |
| GPT-3 2.7B,8K 上下文 | 80 TFLOPs/s | 175 TFLOPs/s | 225 TFLOPs/s |
(数据来源:论文表 1;FLOPs 按 Megatron-LM 的公式 6⋅seqlen⋅参数量+12⋅层数⋅hidden⋅seqlen2 计算,即注意力部分不因因果掩码减半,这是社区惯例口径。)
读这张表能发现三个有意思的现象。第一,FlashAttention-2 相对 FlashAttention-1 的端到端收益是 1.3 倍(2.7B/8K 配置 175→225),比注意力单独测的 1.7–3.0 倍小——因为注意力只占模型总计算的一部分,端到端收益被权重矩阵乘摊薄了。第二,上下文从 2K 涨到 8K,无 FlashAttention 的基线吞吐暴跌近一半(1.3B:142→72;2.7B:149→80),而 FlashAttention-2 几乎不掉(196→220 甚至更高)——注意力变成瓶颈后,FlashAttention 把瓶颈搬走了。第三,225 TFLOPs/s 对应 72% 的模型 FLOPs 利用率(MFU)——整个模型端到端跑出 72% 的 MFU,说明注意力和 GEMM 都不再是短板。
H100 上的表现。 论文顺手把同一份代码跑在 H100 上(没有使用 TMA、第四代 Tensor Core 等新指令),最高 335 TFLOPS,并预计用上新指令后还能再快 1.5–2 倍——这个预测后来由 FlashAttention-3/4 兑现,本站的 FlashAttention 完全拆解(四) 已经讲过完整故事。
因果掩码与头维度。 因果掩码跳过约一半块带来 1.7–1.8 倍加速(相对无掩码)。头维度方面,kernel 支持到 256(官方仓库 README 确认前向、反向都支持 head dimension 256)——超出 d = 128 的部分靠把 Q 块沿 d 维继续切分给更多 warp 来摊平寄存器压力。
FlashDecoding:长上下文解码的最后一公里#
FlashAttention-2 把训练(prefill 其实也受益)推到了 GEMM 效率,但解码(decode)阶段它还有一个重大盲区——这个盲区在 2023 年 10 月由 Stanford CRFM 与 PyTorch 团队的 Tri Dao、Daniel Haziza、Francisco Massa、Grigory Sizov 在 Flash-Decoding 博客 中揭出,并给出了解决方案,随 flash-attention v2.2 发布。
解码阶段为什么是另一回事#
解码是自回归生成:每生成一个 token 需要一次前向,每次前向里注意力只处理一个查询——Q 只有 1 行(query length = 1)。这直接摧毁了 FlashAttention-2 前向的并行结构:并行度 = batch × head × (Q 块数) = batch × head × 1。再看博客给出的数字:batch = 1 时,FlashAttention 只能用到 A100 不到 1% 的计算资源(batch = 1、32 头 = 32 个线程块,还要每个线程块读完整条序列的 KV)。
而长上下文场景恰恰是 batch 小的场景——显存要装下巨大的 KV 缓存,装不下大 batch。于是出现了一个荒谬的处境:序列越长,注意力越慢(KV 读取量线性增长),GPU 利用率越低(线程块越少),两者叠加,解码速度随上下文急剧恶化。
还有一个反直觉的点:即使 batch 不小,注意力也会成为瓶颈。注意力需要读取的 KV 数据量与 batch 成正比(每个请求都要读自己的完整 KV 缓存),而模型其他部分(权重矩阵乘)只与模型大小有关。batch 增大时,注意力的 HBM 流量线性增长,很快压过权重读取。博客里列出的 CodeLlama 34B 解码成本中,注意力占主导的正是这个原因。
FlashAttention-2 在解码场景的正确用法是把 KV 按序列切成块、把块分配给线程块——这正是 FlashAttention-1 时代的旧方案(split-K),但 FA-2 的 warp 分工(切 Q)在解码时退化了:Q 只有一行,没法切给 4 个 warp。所以解码需要一套新的并行策略。
三步方案:把 KV 拆给所有 SM,再用 logsumexp 合并#
FlashDecoding 的核心想法一句话:把序列长度维当作并行维,让每个线程块只处理 KV 的一个切片,算完各部分后用在线 softmax 的统计量把结果合并。具体分三步(博客原文的流程):
第一步:把 K、V 切成小块。 这一步不涉及任何 GPU 操作——切片只是完整张量的视图(view),没有数据搬运。
第二步:每个线程块独立计算”部分注意力”。 每个线程块用 FlashAttention 的完整机制(分块 + 在线 softmax),算查询与自己那块 KV 切片的注意力:得到未归一化的部分输出 O~c,同时为每一行额外写下一个标量——该切片的 log-sum-exp Lc=mc+logℓc。块的数量 = ⌈N/Bc⌉,当上下文很长时,即使 batch = 1 也有大量线程块,A100 的 108 个 SM 全部被填满。
第三步:归约合并。 第二个 kernel 把所有切片的贡献按 logsumexp 加权合并。设 KV 被切成 C 块,第 c 块的统计量是 (mc,ℓc)、未归一化输出是 O~c,全局最大值为 m=maxcmc,则:
ℓ=c=1∑Cemc−mℓc,O=ℓ1c=1∑Cemc−mO~c其中 emc−m 把第 c 块的基准从自己的局部最大值切到全局最大值——这正是在线 softmax 的重缩放因子。整个算法本质上是把在线 softmax 用到了两层:切片内部一层(FlashAttention 原有的机制),切片之间一层(上面的合并公式)。合并时对 m 用原子求最大(atomic max)、对 ℓ 和 O~ 用原子加(atomic add),一次 kernel 完成。
工程上它落地为两个 kernel:第一个 kernel 算部分输出和每行每块的 logsumexp 标量,第二个 kernel 做合并。相比 FlashAttention 的单 kernel 方案,代价是部分输出 O~c 要写一次 HBM、读一次 HBM(每个切片一份),但这个流量只有 O(Nd/Bc) 量级,与长上下文下 KV 本身的读取量相比微不足道。
效果:解码速度几乎与上下文长度无关#
博客用 CodeLlama 34B(Llama-2 架构)做了端到端基准:batch = 1,序列长度从 512 到 64K,对比 PyTorch 标准注意力、FlashAttention v2(2.2 版本之前)、FasterTransformer 的注意力 kernel,以及一个理论上界(读完整模型权重 + 完整 KV 缓存所需的时间):

图中最关键的信息:短上下文(512–2K)时所有方法表现接近,但随着序列长度从 512 涨到 64K,PyTorch 和 FasterTransformer 的吞吐断崖式下跌,FlashAttention v2 也持续下滑,只有 Flash-Decoding 基本保持水平——在长序列端,它端到端比 FlashAttention v2 快最多 8 倍,并且逼近”只读模型和 KV 缓存”的理论上界,说明解码瓶颈已经被压到了物理极限。
注意力本身提升更大。博客的组件级微基准(A100、FP16、batch = 1、16 个查询头 × 头维度 128、2 个 KV 头——GQA 配置,匹配 CodeLlama 34B 在 4 卡上的切分)显示:FlashDecoding 的注意力 kernel 比 FlashAttention v2 快最多 50 倍,且直到序列长度 32K 之前,注意力耗时几乎恒定:

“50 倍”和”8 倍”的差距很好解释:端到端解码里注意力只占总时间的一部分(模型权重读取、MLP、采样等都在),注意力快了 50 倍,端到端最多只能快 8 倍——剩下时间被其他部分占住了。这也说明 FlashDecoding 之后的优化空间已经不主要在注意力。
可用性。 FlashDecoding 随 flash-attention 2.2 版进入官方包,同时集成进 xFormers 0.0.22(xformers.ops.memory_efficient_attention 会自动根据问题规模在 FlashAttention 和 FlashDecoding 之间分派)。今天主流推理框架里的注意力内核——vLLM 的 PagedAttention 系列、FlashMLA、各家长上下文解码优化——都继承了”按序列切片并行 + logsumexp 合并”这个骨架,它是长上下文推理的又一个地基。
小结#
FlashAttention-2 没有改变 FlashAttention-1 的算法骨架(分块 + 在线 softmax + 重计算),它做的是三件”工程外科手术”,把利用率从 25–40% 推到 50–73%:
- 算法微调:输出归一化推迟到循环外、反向只存 logsumexp 一个标量、因果掩码跳过一半块——把最贵的非 matmul FLOPs(A100 上每个的代价是 matmul 的 16 倍)削减到最少;
- 序列维并行:前向外层循环换成 Q 块、反向外层循环换成 K、V 块——序列长度成为第三个并行维,长序列小 batch 场景的占用率问题消失,反向的 dK、dV 因此免于原子操作;
- Warp 分工:从”切 K、V“(split-K,每轮要共享内存归约)改成”切 Q“(每个 warp 独立完成自己那行块的完整 softmax 和输出,零通信)——消除共享内存读写和同步。
性能账:注意力相对 FlashAttention-1 快 1.7–3.0 倍,最高 230 TFLOPS(A100 峰值的 73%);端到端训练 225 TFLOPs/s(72% MFU);H100 上同一份代码就有 335 TFLOPS。而 FlashDecoding 把序列维并行推广到解码:每个线程块只算 KV 的一个切片、用 logsumexp 合并,batch = 1 的长上下文解码最高端到端 8 倍、注意力本身 50 倍加速,且直到 32K 上下文耗时几乎恒定。
这条技术路线的后续演进在本站已有覆盖:FlashAttention-3/4 用硬件新特性(TMA、warp 特化、异步流水线)继续压榨 Blackwell,FlashAttention 完全拆解(四) 讲了完整故事;FlashDecoding 的思想则渗透进了 vLLM、FlashMLA 等所有主流推理引擎。读完这两篇,FlashAttention 这条主线的”算法 → 并行 → 硬件”三层演进逻辑就齐了。
参考资料#
- FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning — FlashAttention-2 论文(2023 年 7 月 arXiv 技术报告),本文主要依据
- Flash-Decoding for long-context inference — Stanford CRFM 官方博客,FlashDecoding 的原始出处,含全部基准图
- FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness — FlashAttention-1 原始论文(NeurIPS 2022),本系列上一篇的主要依据
- Dao-AILab/flash-attention GitHub 仓库 — FlashAttention 官方开源实现,含 FlashDecoding(v2.2+)与 head dimension 256 支持说明
- Triton fused-attention 教程 — Phil Tillet 的 Triton 实现,论文致谢的”交换循环顺序 + 序列维并行”首创来源
- 从零开始用自定义 Triton 内核编写 FlashAttention-2 — 阿里云开发者社区,Triton 实现 FlashAttention-2 的完整教程
- FlashAttention: Making Attention I/O-Aware — Hugging Face 对 FlashAttention 系列的解读
- FlashAttention 完全拆解(一):IO 感知与分块注意力算法 — 本站文章,本系列第一篇,含在线 softmax 的完整推导
- FlashAttention 完全拆解(四):FlashAttention-4——面向 Blackwell 的算法-流水线协同设计 — 本站文章,FlashAttention 系列最新一代的拆解
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
部分内容可能已过时
评论区
分享你的想法,与大家交流讨论
音乐
暂未播放



