FlashAttention 完全拆解(二):序列维并行、Warp 工作划分与 FlashDecoding

8378 字
42 分钟
FlashAttention 完全拆解(二):序列维并行、Warp 工作划分与 FlashDecoding

系列回顾:上一篇讲到了哪里#

上一篇拆解了 FlashAttention-1(2022 年,斯坦福大学 Tri Dao 等人,NeurIPS 2022):它用分块(tiling)、在线 softmax(online softmax)、反向重计算三件套,把注意力计算的 HBM 访问量从标准实现的 Θ(N2)\Theta(N^2) 压到 Θ(N2d2/M)\Theta(N^2 d^2 / M),并证明了这是精确注意力的 IO 复杂度下界;显存占用从 O(N2)O(N^2) 降到 O(N)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 把 KKVV 切给不同的 warp(论文称为 split-K 方案):每个 warp 算出 QKTQK^T 的一个切片后,必须把切片写进共享内存、同步、再归约相加,才能继续下一步的 PVPV 乘法。这一轮共享内存读写和同步是纯粹的开销,占据了 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;前向把外层循环换成 QQ 块、反向换成 KKVV 块,从而把序列维变成可并行维;warp 分工从”切 KKVV“改成”切 QQ“,消除 warp 间通信。下面逐一拆解。

改进一:算法微调,把非 matmul FLOPs 减到最少#

推迟归一化:维护未缩放(un-scaled)的输出累加器#

回顾 FlashAttention-1 前向里输出累加器的更新公式(以两个块为例,上标 (1)、(2) 表示处理顺序):

O(2)=diag((1)/(2))1O(1)+diag((2))1eS(2)m(2)V(2)\mathbf{O}^{(2)} = \mathrm{diag}\left(\ell^{(1)} / \ell^{(2)}\right)^{-1} \mathbf{O}^{(1)} + \mathrm{diag}\left(\ell^{(2)}\right)^{-1} e^{\mathbf{S}^{(2)} - m^{(2)}} \mathbf{V}^{(2)}

其中 (1)\ell^{(1)}(2)\ell^{(2)} 是处理完第 1、2 个块后的行归一化常数(指数和),m(1)m^{(1)}m(2)m^{(2)} 是行最大值,S(2)\mathbf{S}^{(2)} 是当前块的分数矩阵,V(2)\mathbf{V}^{(2)} 是当前的值块,diag()\mathrm{diag}(\cdot) 把向量变成对角矩阵以便逐行缩放。这个更新里有两个除以 \ell 的操作:旧的累加器 O(1)\mathbf{O}^{(1)} 要按 (1)/(2)\ell^{(1)}/\ell^{(2)} 重缩放,新块贡献 eS(2)m(2)V(2)e^{\mathbf{S}^{(2)} - m^{(2)}}\mathbf{V}^{(2)} 要按 (2)\ell^{(2)} 归一化。每处理一个块都要做这两轮逐元素除法——对 Br×dB_r \times d 的输出累加器来说,每轮是 BrdB_r \cdot d 次非 matmul 操作。

FlashAttention-2 的观察是:这两个除法其实可以推迟到最后。softmax 的归一化是”整行统一除以行和”,这个除法什么时候做都不影响比例关系,只要保证在写回 HBM 之前做了就行。于是论文改为维护一个”未缩放”版本的输出:

O~(2)=diag(em(1)m(2))1O~(1)+eS(2)m(2)V(2)\tilde{\mathbf{O}}^{(2)} = \mathrm{diag}\left(e^{m^{(1)} - m^{(2)}}\right)^{-1} \tilde{\mathbf{O}}^{(1)} + e^{\mathbf{S}^{(2)} - m^{(2)}} \mathbf{V}^{(2)}

注意三个变化:第一,累加器 O~\tilde{\mathbf{O}} 从头到尾不除以 \ell,只是在新块的最大值更大时按 em(1)m(2)e^{m^{(1)} - m^{(2)}} 这个因子重缩放(把基准从旧最大值切到新最大值);第二,新块的贡献直接以更新后的最大值 m(2)m^{(2)} 为基准做指数,不需要像 FlashAttention-1 那样先按块局部最大值 mm 算一遍 P~\tilde{\mathbf{P}}、再乘修正因子 em~m(2)e^{\tilde{m} - m^{(2)}}——这两者在数学上恒等:

em(1)m(2)eS(2)m~(2)FA-1 先算=eS(2)m(2)FA-2 直接算e^{m^{(1)} - m^{(2)}} \cdot \underbrace{e^{\mathbf{S}^{(2)} - \tilde{m}^{(2)}}}_{\text{FA-1 先算}} = \underbrace{e^{\mathbf{S}^{(2)} - m^{(2)}}}_{\text{FA-2 直接算}}

第三,最终只做一次归一化:

O=diag((Tc))1O~(Tc)\mathbf{O} = \mathrm{diag}\left(\ell^{(T_c)}\right)^{-1} \tilde{\mathbf{O}}^{(T_c)}

TcT_cKKVV 块的总数。这一趟除法在整个块的处理循环之外,成本可以忽略。

算一下省了什么:FlashAttention-1 每轮迭代要做 2Brd2 \cdot B_r \cdot d 次归一化除法(旧累加器和新贡献各一轮)加 BrBcB_r \cdot B_c 次修正因子乘法(对分数块逐元素);FlashAttention-2 每轮只有 BrdB_r \cdot d 次重缩放(旧累加器乘标量)和必需的 BrBcB_r \cdot B_c 次指数。以典型块大小 Br=Bc=64B_r = B_c = 64d=128d = 128 为例,FlashAttention-1 每轮多出约 264128=163842 \cdot 64 \cdot 128 = 16384 次逐元素除法,全部是非 matmul 操作——按前面 16 倍代价折算,相当于 26 万次 matmul FLOP 的时间。这个改动看起来小,但省掉的全是最贵的操作。

反向只存一个标量:logsumexp#

FlashAttention 的反向需要 softmax 的归一化概率来重算梯度。FlashAttention-1 为每个 QQ 块保存两个统计量向量:行最大值 mim_i 和归一化常数 i\ell_i。FlashAttention-2 的观察是:反向真正需要的只有它们的组合——log-sum-exp(对数求和指数)

Li=mi+log(i)L_i = m_i + \log(\ell_i)

为什么一个标量就够?因为归一化概率可以改写为:

Pij=eSijmii=eSijmilogi=eSijLiP_{ij} = \frac{e^{S_{ij} - m_i}}{\ell_i} = e^{S_{ij} - m_i - \log \ell_i} = e^{S_{ij} - L_i}

反向时只需从 HBM 读入 LiL_i 这一个 BrB_r 维向量,直接在片上算出 exp(SijLi)\exp(S_{ij} - L_i),就得到了归一化的概率块 Pi(j)\mathbf{P}_{i}^{(j)}——两个统计量向量(mm\ell)合成了一个,省了一半的统计量存储和读写。数值上也是安全的:i\ell_i 是至少包含一个 e0=1e^0 = 1 的指数和,所以 i[1,Bc]\ell_i \in [1, B_c]logi[0,logBc]\log \ell_i \in [0, \log B_c] 是个很小的数,LiL_i 的精度和 mim_i 相当。

因果掩码:跳过一半的块#

因果注意力(causal attention)下,分数矩阵 SijS_{ij} 中列索引大于行索引的条目被置为 -\infty,即矩阵的右上三角完全没用。FlashAttention 的分块天然支持这一点:凡是”所有列索引都大于行索引”的块(长序列下约占一半),整个块的 QKTQK^TPVPV 计算都可以跳过。论文实测,跳过这些块带来约 1.7–1.8 倍的加速(相对不跳因果块的实现)。这不只是省 FLOPs——跳过的块也意味着对应的 KKVV 加载被跳过,HBM 流量同样减半。

改进二:三层并行——序列维终于被用上#

FlashAttention-1 的并行只有两层:batch 和 head。FlashAttention-2 补上了第三层——序列长度维。这一步是前向和反向分开设计的,因为两者能”免费”并行的方向不同。

前向:外层循环换成 Q 块,每块承包一行#

FlashAttention-1 的循环结构是外层遍历 KKVV 块、内层遍历 QQ 块:每个线程块负责一个 (batch, head) 对,遍历完整条序列的 KKVV。FlashAttention-2 把循环顺序对调——外层遍历 QQ 块,内层遍历 KKVV,于是每个线程块只需要负责一个 QQ 行块:加载自己的 QiQ_i,遍历所有 KjK_jVjV_j 块,把输出 OiO_i 算完写回。不同线程块之间完全不需要通信——论文原话是”外层循环(序列长度维)是天然可并行的(embarrassingly parallel)”。

这样一来,前向的线程块总数从 batch × head 变成 batch × head × N/Br\lceil N / B_r \rceil。上一节的例子(batch = 1、32 头、序列长度 4096、Br=64B_r = 64)从 32 个线程块变成 32 × 64 = 2048 个线程块,A100 的 108 个 SM 全部满载,长序列场景的占用率(occupancy)问题彻底解决:

FlashAttention-2 的线程块并行方案(论文图 2):前向(左)每个线程块负责注意力矩阵的一行块(一个 Q 块),反向(右)每个线程块负责一列块(一个 K、V 块);红色虚线框标出每个 worker 的计算范围(来源:arXiv:2307.08691 图 2)
FlashAttention-2 的线程块并行方案(论文图 2):前向(左)每个线程块负责注意力矩阵的一行块(一个 Q 块),反向(右)每个线程块负责一列块(一个 K、V 块);红色虚线框标出每个 worker 的计算范围(来源:arXiv:2307.08691 图 2)

论文图 2 的左边展示的就是这个方案:注意力矩阵(N×NN \times N 的网格)被横向切成行块,标注为 worker 1、2、3、4……的线程块各承包一行块,互不重叠、互不通信。图里没有画出的细节是:每个 worker 内部仍然要按列遍历所有 KKVV 块,也就是说同一个 KKVV 块会被 TrT_r 个不同的 worker(不同的行块)各读取一次——这是交换循环顺序的 IO 代价:KKVV 的读取量从 FlashAttention-1 的每元素 1 次变成每元素 Tr=N/BrT_r = N/B_r 次,而 QQOO 反过来从”被读 TcT_c 次”变成只读 1 次。代入 Br=Θ(M/d)B_r = \Theta(M/d)KKVV 的总读取量是 NdN/Br=Θ(N2d2/M)N d \cdot N/B_r = \Theta(N^2 d^2 / M)——与上一篇推导的 IO 复杂度完全相同,仍然贴着精确注意力的下界。循环交换改变的只是”谁被重复读”,不改变总流量的量级,这是它划算的前提。

值得一提的渊源:论文明确致谢了 Phil Tillet——“交换循环顺序(外层行块、内层列块)以及序列维并行,最早由 Phil Tillet 在 Triton 的实现(fused-attention 教程)中提出并实现”。FlashAttention-2 的贡献是把这套想法在 CUDA 里推到极致。

反向:每个线程块承包一列 K、V,dQ 用原子操作#

反向传播的循环结构正好相反。看反向的依赖关系:梯度 dKj\mathbf{dK}_jidSijTQi\sum_i \mathbf{dS}_{ij}^T \mathbf{Q}_i,梯度 dVj\mathbf{dV}_jiPijTdOi\sum_i \mathbf{P}_{ij}^T \mathbf{dO}_i——对同一个 KKVVjj 的梯度,需要累加所有 QQii 的贡献;而 dQi\mathbf{dQ}_i 则需要累加所有 KKVVjj 的贡献。所以反向的”干净并行方向”是列:让每个线程块负责一个 KKVV 列块,内层遍历所有 QQ 行块。

这样设计的好处是:dKj\mathbf{dK}_jdVj\mathbf{dV}_j 在块内(寄存器/共享内存里)就能完整累积,循环结束时一次性写回 HBM——不需要任何跨线程块通信。代价是 dQi\mathbf{dQ}_i 的累加必须跨线程块完成:每个负责列块 jj 的线程块都要给 dQi\mathbf{dQ}_i 加上自己的贡献。论文的处理是用原子加(atomic add)通信:每个线程块从 HBM 加载 dQi\mathbf{dQ}_i,加上自己的贡献后写回,多个线程块对同一位置的写入由硬件原子操作保证不丢数据。论文图 2 的右边画的就是这个方案:worker 1、2、3、4……各承包注意力矩阵的一列块。

这里有个值得思考的”为什么”:为什么反向不也按行块并行(每个线程块负责一个 QQ 行块)?那样的话 dQ\mathbf{dQ} 就能在块内完整累积,但 dK\mathbf{dK}dV\mathbf{dV} 就要变成跨块累加、需要原子操作了。两者必有一方要跨块。论文选择让 dQ\mathbf{dQ} 承担原子操作,因为:第一,dK\mathbf{dK}dV\mathbf{dV} 的梯度量是 KKVV 矩阵本身的大小(N×dN \times d),让它们完整在块内累积、只写一次 HBM,省的是最贵的 HBM 往返;第二,dQ\mathbf{dQ} 的更新是”加载-修改-写回”模式,原子加对它来说只是多一个硬件保证,开销可控。这个取舍在后来的 FlashAttention-3/4 里也被继承了。

MQA/GQA:不复制 KV 头,反向跨头求和#

多查询注意力(Multi-Query Attention,MQA)和分组查询注意力(Grouped-Query Attention,GQA)是推理时代最常用的注意力变体:多个查询头共享同一组 KKVV 头,以压缩 KV 缓存。实现上不需要真的把 KV 头复制多份参与计算——FlashAttention-2 通过对头索引的隐式映射完成”一个 KV 头服务多个 Q 头”的访存。但反向传播里,被隐式共享的 KKVV 头收到的梯度来自多个查询头,必须跨头求和。这部分逻辑在 kernel 里显式处理:同一 KKVV 头的 dK\mathbf{dK}dV\mathbf{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 的线程块内分工是:KKVV 切给 4 个 warp,QQ 让所有 warp 共享。每个 warp 用自己的 KKVV 切片和完整的 QQ 相乘,得到 QKTQK^T 的一个”列切片”。问题在于:softmax 是对整行做的,而一行被切在了 4 个 warp 手里——每个 warp 手里只有这行的一部分分数,谁都没法单独算出 softmax。于是所有 warp 必须先把各自的 QKTQK^T 切片写进共享内存、同步(__syncthreads)、再把 4 份切片相加拼出完整的分数行,然后才能做 softmax、乘 VV

FlashAttention 的 split-K warp 分工(论文图 3(a)):K、V 被切给 4 个 warp,每个 warp 只算出 QK^T 的一个列切片,必须把切片写入共享内存、同步、归约后才能继续(来源:arXiv:2307.08691 图 3(a))
FlashAttention 的 split-K warp 分工(论文图 3(a)):K、V 被切给 4 个 warp,每个 warp 只算出 QK^T 的一个列切片,必须把切片写入共享内存、同步、归约后才能继续(来源:arXiv:2307.08691 图 3(a))

上图里可以清楚地看到:4 个 warp 各自持有 KKVV 的一个切片,它们算出的 QKTQK^T 部分结果需要通过共享内存归约(图中央的合并步骤)才能得到完整的分数。这一轮共享内存写入、同步、读取,每次内层迭代都要做一次,是 FlashAttention-1 前向最显著的固定开销。

切 Q 为什么快:warp 之间零通信#

FlashAttention-2 的思路是:既然问题出在”一行被切碎了”,那就让每个 warp 拥有完整的一行——把 QQ 切给 4 个 warp,KKVV 让所有 warp 共享(从共享内存广播读取):

FlashAttention-2 的 warp 分工(论文图 3(b)):Q 被切给 4 个 warp,每个 warp 用共享的 K、V 独立算完自己的行切片——softmax 和输出都在自己手里,warp 之间零通信(来源:arXiv:2307.08691 图 3(b))
FlashAttention-2 的 warp 分工(论文图 3(b)):Q 被切给 4 个 warp,每个 warp 用共享的 K、V 独立算完自己的行切片——softmax 和输出都在自己手里,warp 之间零通信(来源:arXiv:2307.08691 图 3(b))

每个 warp 现在拥有一个 QQ 的行切片(QQ 块的一”横条”),它可以独立地:与完整的 KjK_j 相乘得到自己那部分分数、对自己那部分行做 softmax(行统计量不需要跨 warp 归约——行没有被切)、与 VjV_j 相乘得到自己那部分输出。整个前向循环中,warp 之间不需要任何通信,共享内存只承担 KKVV 的广播读取。这一改动消除了 FlashAttention-1 里每轮迭代的共享内存写-同步-读归约,是 FlashAttention-2 前向加速的最大单项来源。

反向的 warp 分工:同样避免 split-K,但仍需同步#

反向的依赖关系比前向复杂得多——Q,K,V,O,dO,dQ,dK,dV\mathbf{Q}, \mathbf{K}, \mathbf{V}, \mathbf{O}, \mathbf{dO}, \mathbf{dQ}, \mathbf{dK}, \mathbf{dV} 八个矩阵之间互相牵扯,没有办法做到 warp 之间零通信。但论文的原则不变:尽量避免 split-K 那种”切片-归约”模式。反向里每个 warp 同样按 QQ 的行块分工(每个 warp 负责一个 QiQ_i 行切片的梯度),由于 dK\mathbf{dK}dV\mathbf{dV} 的累积需要跨行切片进行,warp 之间仍需要一定量的同步——但相比 split-K 的完整归约,共享内存读写量已经大幅下降,实测反向也有可观的加速(见性能一节)。

块大小的调优:为什么是 {64, 128} × {64, 128}#

块大小 Br×BcB_r \times B_c 的选取遵循一个权衡:块越大,每个块的共享内存读写次数越少(K、V 复用次数更多),但寄存器占用和共享内存总量越大。块大小超过某个阈值后,要么寄存器溢出(register spilling)导致性能骤降,要么共享内存需求超过硬件容量、kernel 直接无法启动。论文的做法是:每个头维度 dd 只在 {64,128}×{64,128}\{64, 128\} \times \{64, 128\} 四种组合里手工挑选,依据是设备共享内存大小和头维度。之所以不做自动调优,是因为候选只有 4 个、手工测一轮很快,论文把自动调优留给了未来工作(后来的 vLLM、Triton 等实现了运行时 autotune)。

完整算法:前向与反向#

把三个改进合起来,就是论文的 Algorithm 1(FlashAttention-2 前向)和 Algorithm 2(反向)。逐行看一遍。

前向(Algorithm 1)#

输入:Q, K, V ∈ R^{N×d} 位于 HBM,块大小 B_c、B_r
1: 把 Q 分成 T_r = ⌈N/B_r⌉ 个行块,K、V 各分成 T_c = ⌈N/B_c⌉ 个列块
2: 把输出 O 分成 T_r 个块,把 logsumexp L 分成 T_r 个块
3: for i = 1 to T_r do // 外层循环:遍历 Q 行块(序列维并行)
4: 加载 Q_i 到片上 SRAM
5: 在片上初始化 O_i = 0、ℓ_i = 0、m_i = -∞
6: for j = 1 to T_c do // 内层循环:遍历 K、V 列块
7: 加载 K_j、V_j 到片上 SRAM
8: 在片上计算 S_i^{(j)} = Q_i K_j^T // 矩阵乘 1(张量核心)
9: 在片上计算 m_i^{(j)} = max(m_i^{(j-1)}, rowmax(S_i^{(j)}))
10: 在片上计算 P̃_i^{(j)} = exp(S_i^{(j)} - m_i^{(j)}) // 直接用新最大值
11: 在片上计算 ℓ_i^{(j)} = e^{m_i^{(j-1)} - m_i^{(j)}} · ℓ_i^{(j-1)} + rowsum(P̃_i^{(j)})
12: 在片上计算 O_i^{(j)} = e^{m_i^{(j-1)} - m_i^{(j)}} · O_i^{(j-1)} + P̃_i^{(j)} V_j // 矩阵乘 2
13: end for
14: 在片上计算 O_i = ℓ_i^{-1} · O_i^{(T_c)} // 唯一的归一化,循环外
15: 在片上计算 L_i = m_i^{(T_c)} + log(ℓ_i^{(T_c)}) // 反向要用的 logsumexp
16: 把 O_i、L_i 写回 HBM
17: end for
18: 返回输出 O 和 logsumexp L

与 FlashAttention-1 的 Algorithm 1 对比,逐行的差异点:

  • 第 3 行:外层循环从”遍历 KKVV 块”变成”遍历 QQ 块”,序列维成为线程块级并行维(改进二的核心);
  • 第 10 行:指数直接以更新后的最大值 mi(j)m_i^{(j)} 为基准,省掉了 FlashAttention-1 中”先按块局部最大值算 P~\tilde{P}、再乘修正因子”的 Br×BcB_r \times B_c 逐元素乘(改进一的核心);
  • 第 12 行:输出累加器只做 exp\exp 因子重缩放,不除以 \ell——归一化推迟到第 14 行循环外的唯一一次(改进一的核心);
  • 第 15 行:只存 Li=mi+logiL_i = m_i + \log \ell_i 这一个统计量,而不是 mim_ii\ell_i 两个(改进一的核心)。

注意第 9–12 行的两个小向量 mim_ii\ell_i 是每个 QQ 块内部的状态,随着内层循环推进被不断更新;它们和第 5 行的初始化一起,构成了”在线 softmax”在两块之间的完整状态传递——这套机制我们在上一篇推导过精确性,这里只是把更新的形状从”每步归一化”改成了”推迟归一化”,数学等价性不受影响。

反向(Algorithm 2)#

输入:Q, K, V, O, dO ∈ R^{N×d} 位于 HBM,向量 L ∈ R^N,块大小 B_c、B_r
1: 把 Q、O、dO、dQ 分成 T_r 个块;把 K、V、dK、dV 分成 T_c 个块
2: 在 HBM 初始化 dQ = 0
3: 计算 D = rowsum(dO ∘ O),按行块分成 T_r 份,写回 HBM // ∘ 是逐元素乘
4: for j = 1 to T_c do // 外层循环:遍历 K、V 列块(序列维并行)
5: 加载 K_j、V_j 到片上 SRAM
6: 在片上初始化 dK_j = 0、dV_j = 0
7: for i = 1 to T_r do // 内层循环:遍历所有 Q 行块
8: 加载 Q_i、O_i、dO_i、dQ_i、L_i、D_i 到片上 SRAM
9: 在片上重算 S_i^{(j)} = Q_i K_j^T // 矩阵乘(重计算)
10: 在片上计算 P_i^{(j)} = exp(S_i^{(j)} - L_i) // 只需 logsumexp
11: 在片上计算 dV_j ← dV_j + (P_i^{(j)})^T dO_i // 矩阵乘,块内累积
12: 在片上计算 dP_i^{(j)} = dO_i V_j^T // 矩阵乘
13: 在片上计算 dS_i^{(j)} = P_i^{(j)} ∘ (dP_i^{(j)} - D_i)
14: 在片上计算 dQ_i ← dQ_i + dS_i^{(j)} K_j // 跨块累加 → 原子操作
15: 在片上计算 dK_j ← dK_j + (dS_i^{(j)})^T Q_i // 矩阵乘,块内累积
16: end for
17: 把 dK_j、dV_j 写回 HBM
18: end for
19: 返回 dQ、dK、dV

逐行要点:

  • 第 3 行Di=rowsum(dOiOi)D_i = \text{rowsum}(\mathbf{dO}_i \circ \mathbf{O}_i) 是 softmax 反向的修正项(上一篇推导过),只需一行归约就能在整个反向开始前一次性算好;
  • 第 4 行:外层循环遍历 KKVV 列块——反向的序列维并行方向(改进二);
  • 第 9–10 行SSPP 不存储、片上重算;重算概率只需要 exp(SLi)\exp(S - L_i)mm\ell 两个统计量合并成了 LL 一个(改进一);
  • 第 11、15 行dVj\mathbf{dV}_jdKj\mathbf{dK}_j 在块内寄存器/共享内存中累积完整,循环结束才写回 HBM——零跨块通信(改进二);
  • 第 14 行dQi\mathbf{dQ}_i 的更新是唯一需要跨线程块共享的状态,采用原子加(改进二);
  • 计数:前向 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):

模型配置无 FlashAttentionFlashAttention-1FlashAttention-2
GPT-3 1.3B,2K 上下文142 TFLOPs/s189 TFLOPs/s196 TFLOPs/s
GPT-3 1.3B,8K 上下文72 TFLOPs/s170 TFLOPs/s220 TFLOPs/s
GPT-3 2.7B,2K 上下文149 TFLOPs/s189 TFLOPs/s205 TFLOPs/s
GPT-3 2.7B,8K 上下文80 TFLOPs/s175 TFLOPs/s225 TFLOPs/s

(数据来源:论文表 1;FLOPs 按 Megatron-LM 的公式 6seqlen参数量+12层数hiddenseqlen26 \cdot \text{seqlen} \cdot \text{参数量} + 12 \cdot \text{层数} \cdot \text{hidden} \cdot \text{seqlen}^2 计算,即注意力部分不因因果掩码减半,这是社区惯例口径。)

读这张表能发现三个有意思的现象。第一,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 的部分靠把 QQ 块沿 dd 维继续切分给更多 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 需要一次前向,每次前向里注意力只处理一个查询——QQ 只有 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)在解码时退化了:QQ 只有一行,没法切给 4 个 warp。所以解码需要一套新的并行策略。

三步方案:把 KV 拆给所有 SM,再用 logsumexp 合并#

FlashDecoding 的核心想法一句话:把序列长度维当作并行维,让每个线程块只处理 KV 的一个切片,算完各部分后用在线 softmax 的统计量把结果合并。具体分三步(博客原文的流程):

第一步:把 K、V 切成小块。 这一步不涉及任何 GPU 操作——切片只是完整张量的视图(view),没有数据搬运。

第二步:每个线程块独立计算”部分注意力”。 每个线程块用 FlashAttention 的完整机制(分块 + 在线 softmax),算查询与自己那块 KV 切片的注意力:得到未归一化的部分输出 O~c\tilde{O}_c,同时为每一行额外写下一个标量——该切片的 log-sum-exp Lc=mc+logcL_c = m_c + \log \ell_c。块的数量 = N/Bc\lceil N / B_c \rceil,当上下文很长时,即使 batch = 1 也有大量线程块,A100 的 108 个 SM 全部被填满。

第三步:归约合并。 第二个 kernel 把所有切片的贡献按 logsumexp 加权合并。设 KV 被切成 CC 块,第 cc 块的统计量是 (mc,c)(m_c, \ell_c)、未归一化输出是 O~c\tilde{O}_c,全局最大值为 m=maxcmcm = \max_c m_c,则:

=c=1Cemcmc,O=1c=1CemcmO~c\ell = \sum_{c=1}^{C} e^{m_c - m} \, \ell_c, \qquad O = \frac{1}{\ell} \sum_{c=1}^{C} e^{m_c - m} \, \tilde{O}_c

其中 emcme^{m_c - m} 把第 cc 块的基准从自己的局部最大值切到全局最大值——这正是在线 softmax 的重缩放因子。整个算法本质上是把在线 softmax 用到了两层:切片内部一层(FlashAttention 原有的机制),切片之间一层(上面的合并公式)。合并时对 mm 用原子求最大(atomic max)、对 \ellO~\tilde{O} 用原子加(atomic add),一次 kernel 完成。

工程上它落地为两个 kernel:第一个 kernel 算部分输出和每行每块的 logsumexp 标量,第二个 kernel 做合并。相比 FlashAttention 的单 kernel 方案,代价是部分输出 O~c\tilde{O}_c 要写一次 HBM、读一次 HBM(每个切片一份),但这个流量只有 O(Nd/Bc)O(N d / B_c) 量级,与长上下文下 KV 本身的读取量相比微不足道。

效果:解码速度几乎与上下文长度无关#

博客用 CodeLlama 34B(Llama-2 架构)做了端到端基准:batch = 1,序列长度从 512 到 64K,对比 PyTorch 标准注意力、FlashAttention v2(2.2 版本之前)、FasterTransformer 的注意力 kernel,以及一个理论上界(读完整模型权重 + 完整 KV 缓存所需的时间):

CodeLlama 34B 解码吞吐对比(来源:Stanford CRFM Flash-Decoding 博客):纵轴是解码速度(token/s),横轴是序列长度(512 到 64K,对数刻度)。PyTorch、FasterTransformer、FlashAttention v2 的速度都随序列长度急剧下滑,只有 Flash-Decoding 保持在接近理论上界的位置,端到端最高 8 倍加速
CodeLlama 34B 解码吞吐对比(来源:Stanford CRFM Flash-Decoding 博客):纵轴是解码速度(token/s),横轴是序列长度(512 到 64K,对数刻度)。PyTorch、FasterTransformer、FlashAttention v2 的速度都随序列长度急剧下滑,只有 Flash-Decoding 保持在接近理论上界的位置,端到端最高 8 倍加速

图中最关键的信息:短上下文(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 之前,注意力耗时几乎恒定:

注意力 kernel 微基准(来源:Stanford CRFM Flash-Decoding 博客):纵轴是注意力耗时(微秒),横轴是序列长度。FlashAttention 的耗时随序列长度线性增长,Flash-Decoding 在 64K 之前几乎保持常数——8 倍端到端加速正是来自这里
注意力 kernel 微基准(来源:Stanford CRFM Flash-Decoding 博客):纵轴是注意力耗时(微秒),横轴是序列长度。FlashAttention 的耗时随序列长度线性增长,Flash-Decoding 在 64K 之前几乎保持常数——8 倍端到端加速正是来自这里

“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 倍)削减到最少;
  • 序列维并行:前向外层循环换成 QQ 块、反向外层循环换成 KKVV 块——序列长度成为第三个并行维,长序列小 batch 场景的占用率问题消失,反向的 dK\mathbf{dK}dV\mathbf{dV} 因此免于原子操作;
  • Warp 分工:从”切 KKVV“(split-K,每轮要共享内存归约)改成”切 QQ“(每个 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 这条主线的”算法 → 并行 → 硬件”三层演进逻辑就齐了。

参考资料#

  1. FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning — FlashAttention-2 论文(2023 年 7 月 arXiv 技术报告),本文主要依据
  2. Flash-Decoding for long-context inference — Stanford CRFM 官方博客,FlashDecoding 的原始出处,含全部基准图
  3. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness — FlashAttention-1 原始论文(NeurIPS 2022),本系列上一篇的主要依据
  4. Dao-AILab/flash-attention GitHub 仓库 — FlashAttention 官方开源实现,含 FlashDecoding(v2.2+)与 head dimension 256 支持说明
  5. Triton fused-attention 教程 — Phil Tillet 的 Triton 实现,论文致谢的”交换循环顺序 + 序列维并行”首创来源
  6. 从零开始用自定义 Triton 内核编写 FlashAttention-2 — 阿里云开发者社区,Triton 实现 FlashAttention-2 的完整教程
  7. FlashAttention: Making Attention I/O-Aware — Hugging Face 对 FlashAttention 系列的解读
  8. FlashAttention 完全拆解(一):IO 感知与分块注意力算法 — 本站文章,本系列第一篇,含在线 softmax 的完整推导
  9. FlashAttention 完全拆解(四):FlashAttention-4——面向 Blackwell 的算法-流水线协同设计 — 本站文章,FlashAttention 系列最新一代的拆解

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

FlashAttention 完全拆解(二):序列维并行、Warp 工作划分与 FlashDecoding
https://pinghaoyang.com.cn/aigc/posts/flashattention-part-2/
作者
平昊阳
发布于
2026-08-26
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
--
总访问量
--
访客数
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
89
分类
18
标签
116
总字数
762,197
运行时长
0
最后活动
0 天前

文章目录