FlashAttention 怎么做?online softmax 怎么更新?
不减 FLOPs,为什么还快?
先弄清楚:每个概念是什么
- HBM vs SRAM
- HBM 是片外的 GPU memory,大但慢。SRAM 是每个 SM 上的片上存储,快但小(数字见 GPU memory hierarchy)。
- 标准 attention 慢在哪
- \(S = QK^\top\)、\(P = \mathrm{softmax}(S)\) 都是 \(s\times s\),算完写回 HBM、下一步再读出来。softmax 计算很少,时间花在搬运上。例:\(s = 4096\),每个 head 的 \(S\) 就有约 1678 万个数。
- Online softmax
- 一行分块读进来,只记 running max \(m\) 和 running sum \(\ell\)。新块让 max 变大,就把旧的 \(\ell\) 和输出乘 \(e^{m_{\text{old}}-m_{\text{new}}}\) 校正。例:一行 (1, 3 | 2, 5)。第一块后 \(m=3\),\(\ell = e^{-2}+1 = 1.135\);第二块后 \(m=5\),\(\ell = 1.135\,e^{-2} + e^{-3} + 1 = 1.203\),和一次算完相同。
- Tiling
- Q 切成 \(B_r\) 行一块,K、V 切成 \(B_c\) 行一块,每次在 SRAM 里算一个 \(B_r\times B_c\) 的 score block,不写回 HBM。
- Recomputation
- backward 要用 \(P\),但只存输出 \(O\) 和每行的 logsumexp,用时按块重算 \(S\)、\(P\)。
看图:Q、K、V 怎么分块,什么留在 SRAM
30 秒回答
Standard attention writes the s×s score and probability matrices to HBM and reads them back, so it needs O(s²) memory and is bound by memory traffic, not FLOPs. FlashAttention splits Q into blocks of B_r rows and K, V into blocks of B_c rows, computes each B_r×B_c score block in SRAM, and never writes it out. Softmax needs the whole row, so it uses online softmax: keep a running row max m and sum ℓ, and when a new block raises the max, rescale the old sum and output by exp(m_old − m_new). It's exact, FLOPs stay O(s²d), extra memory is O(s), and backward recomputes the blocks.
推导 / 关键公式
online softmax:每来一块 \(K_j, V_j\)
\[ m' = \max\big(m,\ \operatorname{rowmax} S_{ij}\big),\qquad \tilde P_{ij} = e^{S_{ij} - m'} \] \[ \ell' = e^{m - m'}\,\ell + \operatorname{rowsum} \tilde P_{ij} \] \[ O' = e^{m - m'}\,O + \tilde P_{ij} V_j \]全部块走完再 \(O \leftarrow O/\ell\)。\(e^{m-m'}\le 1\),不会 overflow。
为什么快:数 HBM 读写,不数 FLOPs
\(d\) 是 head dim,\(M\) 是 SRAM 大小:
\[ \text{standard: } \Theta(sd + s^2),\qquad \text{Flash: } \Theta(s^2d^2/M) \]\(d\) = 64–128、\(M\) ≈ 100 KB 时 \(d^2 \ll M\)(FLOPs 见 Transformer 复杂度)。
shape
| 块 | shape | 放在哪 |
|---|---|---|
| \(Q_i,\ O_i\) | \([B_r, d]\) | SRAM;\(O_i\) 最后写回 HBM |
| \(K_j,\ V_j\) | \([B_c, d]\) | SRAM |
| \(S_{ij},\ \tilde P_{ij}\) | \([B_r, B_c]\) | 只在 SRAM,用完就丢 |
| \(m_i,\ \ell_i\) | \([B_r]\) | SRAM;最后每行存一个 logsumexp,共 \([s]\),给 backward |
手撕代码(NumPy,按 K/V 分块)
def flash_attn(Q, K, V, Bc=16):
s, d = Q.shape
m = np.full(s, -np.inf) # 每行 running max
l = np.zeros(s) # 每行 running exp-sum
O = np.zeros((s, d)) # 还没除以 l 的输出
for j in range(0, s, Bc): # 每次只读一块 K_j, V_j
S = Q @ K[j:j+Bc].T / np.sqrt(d) # [s, Bc]
m_new = np.maximum(m, S.max(1))
P = np.exp(S - m_new[:, None])
a = np.exp(m - m_new) # 旧结果的 rescale 系数
l = a * l + P.sum(1)
O = a[:, None] * O + P @ V[j:j+Bc]
m = m_new
return O / l[:, None]
Q, K, V = np.random.default_rng(0).normal(size=(3, 100, 32))
S = Q @ K.T / np.sqrt(32) # 对照:直接 softmax
P = np.exp(S - S.max(1, keepdims=True))
ref = P / P.sum(1, keepdims=True) @ V
assert np.allclose(flash_attn(Q, K, V), ref)
必答点
- 瓶颈是 HBM 读写,不是 FLOPs:\(s\times s\) 的 \(S\)、\(P\) 不写回 HBM
- tiling:Q 按 \(B_r\) 行、K/V 按 \(B_c\) 行分块,SRAM 里算 \([B_r, B_c]\)
- online softmax:维护 \(m\)、\(\ell\);max 变大时旧值乘 \(e^{m-m'}\)
- exact;FLOPs 不变,额外 memory \(O(s)\),HBM 读写 \(\Theta(s^2d^2/M)\)
- backward 只存 \(O\) 和 logsumexp,按块 recompute
追问
FLOPs 更多了,为什么反而更快?
论文 Fig. 2(A100、GPT-2 medium):recompute 让 FLOPs 从 66.6 增到 75.2 GFLOPs,但 HBM 读写从 40.3 降到 4.4 GB,forward + backward 从 41.7 ms 降到 7.3 ms。attention 是 memory-bound(见 roofline)。
\(B_r\)、\(B_c\) 怎么定?
由 SRAM 大小 \(M\) 定:\(B_c = \lceil M/4d\rceil\),\(B_r = \min(\lceil M/4d\rceil, d)\),保证 \(Q_i, K_j, V_j, O_i\) 同时放得下。
FlashAttention-2 改了什么?
中途不除 \(\ell\)、最后除一次,只存 logsumexp \(L = m + \log\ell\),减少用不上 Tensor Core 的 non-matmul FLOPs。外层循环改成 Q 块,沿 sequence 维分给不同 thread block。causal mask 下整块被 mask 的直接跳过。
多卡(ring attention)怎么合并两段结果?
每段算出归一化好的 \(O_k\) 和 logsumexp \(L_k\):\(L = \log(e^{L_1} + e^{L_2})\),\(O = e^{L_1-L}O_1 + e^{L_2-L}O_2\),和 online softmax 是同一个式子(见 Context Parallel)。
来源Dao et al. 2022, FlashAttention §2.1(p.3)、§3.1 Algorithm 1 + Theorem 1(p.5)、Theorem 2 + Fig. 2(p.6)、App. B.3–B.4(pp.19–20)· Dao 2023, FlashAttention-2 §3.1–3.2(pp.5–8)· Milakov & Gimelshein 2018 §3 Algorithm 3(pp.2–3)