1. 从一次显存爆炸说起:为什么 Attention 的中间矩阵成了瓶颈
我第一次认真审视 Attention 的显存问题,是在一台 24G 显存的卡上跑一个序列长度 4096、batch size 8、head 数 32 的模型。按纸面计算,模型权重加优化器状态也就几个 G,结果前向刚跑完就 OOM 了。用torch.cuda.memory_summary()一查,峰值显存里最大的一块不是权重,而是那个形状为[batch, heads, seq, seq]的注意力分数矩阵——8 × 32 × 4096 × 4096 × 2 字节,光这一个中间张量就接近 8.6 GB,而且反向传播时还得再存一份用于计算梯度。
这就是标准 Attention 实现的结构性痛点。我们回顾一下教科书里的公式:
S = Q @ K^T # [B, H, N, N] P = softmax(S) # [B, H, N, N] O = P @ V # [B, H, N, D]问题就出在S和P这两个N × N的矩阵上。序列长度 N 翻倍,这两个矩阵的显存占用翻四倍。当 N 到 8K、16K 甚至 32K 时,它们会迅速吃掉所有显存,成为整个模型扩展的硬天花板。
FlashAttention 要解决的核心问题就是:能不能在不算出完整S和P的前提下,直接得到正确的输出O?答案是能,靠的是分块计算加在线 softmax 归约。而 Triton 则让我们能用几十行 Python 写出接近手写 CUDA 性能的 kernel,把这件事真正落地到工程里。
这篇文章我会从原理推导讲到 Triton 实战,包括分块策略怎么定、在线 softmax 的递推公式怎么推、Triton kernel 每一行在干什么、踩过哪些坑。适合已经懂基本 Attention 公式、想搞清楚 FlashAttention 到底"省"在哪、并且想自己动手写一版的读者。纯调包党也能看懂原理部分,但实战章节建议对着代码跑一遍。
2. 拆解 FlashAttention 的核心思路:不存中间矩阵凭什么还能算对
2.1 标准 Attention 的显存账本到底花在哪
先把账算清楚,才知道该省哪一笔。假设B=8, H=32, N=4096, D=128,数据类型 fp16:
| 张量 | 形状 | 元素数 | 显存(fp16) |
|---|---|---|---|
| Q/K/V | [B,H,N,D] | 8×32×4096×128 | 各 256 MB |
| S = QK^T | [B,H,N,N] | 8×32×4096×4096 | 8.6 GB |
| P = softmax(S) | [B,H,N,N] | 同上 | 8.6 GB |
| O | [B,H,N,D] | 8×32×4096×128 | 256 MB |
可以看到,Q/K/V/O 加起来才 1 GB 出头,而 S 和 P 各自 8.6 GB。更糟的是反向传播:为了算 Q/K/V 的梯度,标准实现需要把 P 和 softmax 的中间量(比如 row max、row sum)都存下来,显存占用进一步膨胀。这就是为什么很多长序列模型训练时,注意力层是绝对的显存大户。
注意:很多人以为省显存靠的是"用更小的数据类型",但 fp16 已经把 S/P 砍半了还是爆。真正的解法是根本不把 S/P 完整写出来,这才是 FlashAttention 的立足点。
2.2 分块计算:把大矩阵切成能塞进 SRAM 的小块
GPU 的存储层级是这样的:寄存器最快但极小,SRAM(共享内存)很快但只有几十到几百 KB,HBM(显存)很大但带宽相对慢。标准 Attention 的瓶颈往往不是算力,而是反复读写 HBM——S 写一遍、读一遍做 softmax、再写一遍、再读一遍做 P@V,每次都是几十 GB 的搬运。
FlashAttention 的思路是:把 Q、K、V 沿序列维度切成小块(block),每块大小控制在能放进 SRAM 的程度,然后在 SRAM 里完成Q_block @ K_block^T → softmax → @ V_block的全过程,只把最终结果 O 写回 HBM。中间那些N × N的块永远不落到显存里。
这里有个关键点:softmax 是按行归一化的,需要知道整行的最大值和指数和。如果分块算,每处理一个 K/V 块只能看到行的一部分,怎么保证最终结果和全局 softmax 一致?这就引出了在线 softmax。
2.3 在线 softmax:分块归约的数学核心
标准 softmax 对一行向量 x 的计算是:
m = max(x) p_i = exp(x_i - m) l = sum(p_i) out_i = p_i / l减去最大值 m 是为了数值稳定,防止 exp 溢出。现在假设我们分块处理,先看到第一块 x^(1),算出局部的 m1、l1 和部分输出;再看到第二块 x^(2),需要把之前的结果"修正"过来。递推公式是:
m_new = max(m_old, m_block) l_new = l_old * exp(m_old - m_new) + l_block * exp(m_block - m_new) O_new = (O_old * l_old * exp(m_old - m_new) + P_block @ V_block * exp(m_block - m_new)) / l_new直观理解:每当来了新的块,如果新的最大值更大,之前累积的那些指数和、输出都要按exp(旧max - 新max)这个因子缩小,因为它们的相对权重变小了。这个"缩放-累加"的过程就是 online softmax 的精髓。
实操心得:这个递推公式是 FlashAttention 正确性的命门。我第一次手写时把
exp(m_old - m_new)写反成了exp(m_new - m_old),结果数值直接爆炸,输出全是 NaN。调试时建议先用 N=64 这种小规模、和 PyTorch 标准实现逐元素对比,确认公式无误再放大。
2.4 为什么这样能省显存又能提速
省显存好理解:中间矩阵不落 HBM,显存占用从 O(N²) 降到 O(N)。提速的原因稍微反直觉——分块后计算量其实没减少(甚至因为重算多了点),但HBM 访问次数大幅下降。标准实现要读写 S/P 好几遍,FlashAttention 只在 SRAM 里倒腾,SRAM 带宽比 HBM 高一个数量级。所以它是典型的"用计算换访存",在访存受限的注意力场景里收益巨大。
反向传播时,FlashAttention 不存 P,而是在反向时用存下来的 O 和统计量(m、l)重新计算P。这就是所谓的"重计算"(recomputation),用一点额外算力换掉大量显存。实测在长序列下,这个 trade-off 非常划算。
3. Triton 工业级实战:手写一个可用的 FlashAttention Kernel
3.1 环境准备与 Triton 安装避坑
Triton 现在的安装比早期省心多了,但版本匹配依然是最大的坑。我的建议是:
# 推荐用官方 wheel,和 PyTorch 版本对齐 pip install triton # 验证安装和版本 python -c "import triton; print(triton.__version__)" python -c "import torch; print(torch.__version__, torch.version.cuda)"几个实测经验:
- Triton 对 CUDA 版本和 PyTorch 版本比较敏感。如果
import triton报找不到libcuda或符号错误,八成是版本错配,先对齐 PyTorch 官方推荐的组合。 - 在较新的卡上(比如 Hopper 架构),尽量用较新的 Triton,老版本对新架构的指令支持不全,性能会打折。
- 如果只是想用 FlashAttention 而不想自己写,直接
pip install flash-attn更省事,但自己写一遍对理解原理帮助极大,而且能针对特定 shape 做定制优化。
注意:网上有些教程会让你从源码编译 Triton,除非你要改编译器本身,否则没必要。编译耗时长且容易因为环境问题失败,用 wheel 就够了。
3.2 Kernel 的整体结构设计
我们要写的 kernel 计算O = softmax(QK^T / sqrt(D)) @ V。分工是这样的:
- 每个 program 负责一行 Q(或者几行),沿 K/V 的序列维度循环。
- 循环体内:加载一个 K 块和一个 V 块,算
Q_block @ K_block^T得到局部分数,做在线 softmax 更新,累加P_block @ V_block。 - 循环结束后,用最终的 l 归一化,写回 O。
为什么让每个 program 负责一行 Q?因为 softmax 是按 Q 的行做的,一行 Q 对应输出的一行,归约方向天然对齐,避免了跨 program 的通信。这是最经典也最好理解的划分方式。
3.3 核心 Kernel 代码逐段解析
下面是一个教学向但可运行的实现,我加了详细注释:
import torch import triton import triton.language as tl @triton.jit def flash_attn_fwd( Q, K, V, O, stride_qz, stride_qh, stride_qm, stride_qk, stride_kz, stride_kh, stride_kn, stride_kk, stride_vz, stride_vh, stride_vk, stride_vn, stride_oz, stride_oh, stride_om, stride_on, Z, H, N_CTX, BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_D: tl.constexpr, ): # 定位当前 program 负责的 (batch, head, query block) start_m = tl.program_id(0) off_hz = tl.program_id(1) off_z = off_hz // H off_h = off_hz % H # Q 的块指针 q_offset = off_z * stride_qz + off_h * stride_qh Q_block_ptr = tl.make_block_ptr( base=Q + q_offset, shape=(N_CTX, BLOCK_D), strides=(stride_qm, stride_qk), offsets=(start_m * BLOCK_M, 0), block_shape=(BLOCK_M, BLOCK_D), order=(1, 0), ) # K/V 的块指针(沿 N 维度循环) k_offset = off_z * stride_kz + off_h * stride_kh K_block_ptr = tl.make_block_ptr( base=K + k_offset, shape=(BLOCK_D, N_CTX), strides=(stride_kk, stride_kn), offsets=(0, 0), block_shape=(BLOCK_D, BLOCK_N), order=(0, 1), ) v_offset = off_z * stride_vz + off_h * stride_vh V_block_ptr = tl.make_block_ptr( base=V + v_offset, shape=(N_CTX, BLOCK_D), strides=(stride_vk, stride_vn), offsets=(0, 0), block_shape=(BLOCK_N, BLOCK_D), order=(1, 0), ) # 在线 softmax 的统计量 m_i = tl.full([BLOCK_M], float("-inf"), dtype=tl.float32) l_i = tl.zeros([BLOCK_M], dtype=tl.float32) acc = tl.zeros([BLOCK_M, BLOCK_D], dtype=tl.float32) q = tl.load(Q_block_ptr) scale = 1.0 / (BLOCK_D ** 0.5) # 沿 K/V 序列维度分块循环 for start_n in range(0, N_CTX, BLOCK_N): k = tl.load(K_block_ptr) qk = tl.dot(q, k) * scale # [BLOCK_M, BLOCK_N] m_ij = tl.max(qk, axis=1) # 当前块的局部行最大值 m_new = tl.maximum(m_i, m_ij) # 更新全局最大值 alpha = tl.exp(m_i - m_new) # 旧结果的缩放因子 p = tl.exp(qk - m_new[:, None]) # 当前块的指数 l_i = l_i * alpha + tl.sum(p, axis=1) acc = acc * alpha[:, None] v = tl.load(V_block_ptr) acc = tl.dot(p.to(v.dtype), v, acc) # 累加 P_block @ V_block m_i = m_new # 推进 K/V 指针到下一块 K_block_ptr = tl.advance(K_block_ptr, (0, BLOCK_N)) V_block_ptr = tl.advance(V_block_ptr, (BLOCK_N, 0)) acc = acc / l_i[:, None] # 最终归一化 o_offset = off_z * stride_oz + off_h * stride_oh O_block_ptr = tl.make_block_ptr( base=O + o_offset, shape=(N_CTX, BLOCK_D), strides=(stride_om, stride_on), offsets=(start_m * BLOCK_M, 0), block_shape=(BLOCK_M, BLOCK_D), order=(1, 0), ) tl.store(O_block_ptr, acc.to(O.dtype.element_ty))几个关键设计点值得展开说:
块指针(block pointer)是 Triton 里非常实用的抽象。它把"基地址 + 形状 + 步长 + 偏移"打包成一个对象,tl.advance可以自动推进偏移,省去了手算地址的麻烦,也更容易让编译器做访存优化。
alpha的缩放就是前面在线 softmax 公式里的exp(m_old - m_new)。注意acc和l_i都要乘这个因子,漏掉任何一个都会算错。
tl.dot的累加形式tl.dot(p, v, acc)直接把结果累加到acc上,省了一次显式的加法,也方便编译器做流水线。
3.4 启动配置与参数选择
kernel 写好了,怎么启动也有讲究:
def flash_attention(q, k, v): Z, H, N_CTX, D = q.shape o = torch.empty_like(q) BLOCK_M = 64 BLOCK_N = 64 grid = (triton.cdiv(N_CTX, BLOCK_M), Z * H) flash_attn_fwd[grid]( q, k, v, o, q.stride(0), q.stride(1), q.stride(2), q.stride(3), k.stride(0), k.stride(1), k.stride(2), k.stride(3), v.stride(0), v.stride(1), v.stride(2), v.stride(3), o.stride(0), o.stride(1), o.stride(2), o.stride(3), Z, H, N_CTX, BLOCK_M=BLOCK_M, BLOCK_N=BLOCK_N, BLOCK_D=D, ) return oBLOCK_M和BLOCK_N的选择直接影响性能,经验值如下:
| 场景 | BLOCK_M | BLOCK_N | 说明 |
|---|---|---|---|
| D=64, 短序列 | 128 | 64 | 块大一点,减少循环次数 |
| D=128, 长序列 | 64 | 64 | 平衡 SRAM 占用和并行度 |
| D=256 | 32 | 32 | 头维度大时块要缩小,否则 SRAM 放不下 |
实操心得:
BLOCK_M决定 grid 的第一维大小,也就是并行度。如果BLOCK_M太大,program 数量太少,GPU 占用率上不去;太小则每个 program 干的活太少,调度开销占比高。我一般从 64 起步,用ncu或者简单的计时脚本扫一遍 32/64/128 找最优点。
4. 性能验证与常见问题排查实录
4.1 正确性验证:先对齐再谈性能
写完 kernel 第一件事不是测速度,是验证数值正确性。我的标准流程:
torch.manual_seed(0) q = torch.randn(2, 4, 512, 64, device="cuda", dtype=torch.float16) k = torch.randn_like(q) v = torch.randn_like(q) # 参考实现 def ref_attn(q, k, v): scale = 1.0 / (q.shape[-1] ** 0.5) s = (q @ k.transpose(-2, -1)) * scale p = torch.softmax(s, dim=-1) return p @ v out_ref = ref_attn(q, k, v) out_tri = flash_attention(q, k, v) print("max abs diff:", (out_ref - out_tri).abs().max().item())fp16 下 max abs diff 在 1e-2 量级属于正常(累加误差),如果出现 NaN 或者 diff 到 1e0 量级,就是逻辑错了。常见错误来源:alpha缩放漏乘、scale位置放错、块指针偏移算错。
4.2 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 输出全 NaN | exp溢出,没减最大值 | 检查m_new更新和qk - m_new |
| 结果偏差大 | alpha缩放漏乘 acc 或 l | 对照在线 softmax 公式逐项检查 |
| 性能不如 PyTorch | 块太小或太大 | 扫 BLOCK_M/BLOCK_N 组合 |
| 编译报错 | Triton 版本与架构不匹配 | 升级 Triton,确认目标架构 |
| 显存没省下来 | 中间张量被意外保留 | 检查是否有.item()或调试打印触发同步 |
| 段错误/非法访存 | 块指针越界 | 确认 N_CTX 是 BLOCK_N 的整数倍或加边界 mask |
4.3 边界处理:序列长度不是块大小整数倍怎么办
上面的代码假设N_CTX能被BLOCK_N整除。实际中序列长度千奇百怪,必须处理边界。两种做法:
一是用 mask,在加载 K/V 时对越界位置填-inf(对分数)或 0(对 V),让 softmax 自动忽略。二是 padding 到整数倍,简单但浪费一点算力。我一般用 mask,虽然多几行代码,但更通用。
# 加载时加边界 mask 的思路 offs_n = start_n + tl.arange(0, BLOCK_N) mask_n = offs_n < N_CTX k = tl.load(K_block_ptr, boundary_check=(1,), padding_option="zero")Triton 的boundary_check会自动处理越界,配合padding_option指定填充值,比手写 mask 清爽。
4.4 反向传播要不要自己写
教学目的的话,前向搞懂就够了。但如果要真正训练,反向也得实现。FlashAttention 的反向比前向复杂不少,核心是:不存 P,用前向存下的 O、m、l 重算 P,然后按链式法则回传。Triton 官方仓库里有完整的反向实现可以参考。
我的建议是:生产环境直接用 flash-attn 库,它经过了大量优化和测试;自己写主要用于理解原理和做特定定制。别为了"纯手写"而在生产里埋雷。
5. 从原理到落地:几个容易被忽略的工程细节
5.1 数值稳定性不只是减最大值
在线 softmax 里,m_i初始化为-inf是个细节。第一次循环时alpha = exp(-inf - m_new),如果m_new也是-inf(比如整块都是 padding),会得到exp(nan)。所以初始化时要么保证至少有一个有效元素,要么对-inf情况做特殊处理。我在处理变长序列时踩过这个坑,padding 块全被 mask 掉后整行输出变 NaN。
5.2 数据类型转换的时机
tl.dot对输入类型有要求,fp16 输入通常累加到 fp32。注意p.to(v.dtype)这一步:p 是 fp32 的指数结果,转成 fp16 再和 v 做 dot,能省一半 SRAM 带宽,但会损失一点精度。如果对精度敏感,可以保持 fp32 做 dot(部分架构支持),代价是慢一些。这个取舍要看你的任务对数值误差的容忍度。
5.3 和现有框架的集成
自己写的 kernel 要接进 PyTorch 训练流程,需要包成torch.autograd.Function,前向调 kernel,反向调反向 kernel 或者用重计算。这一步的坑在于张量的 stride 和 contiguity——Triton kernel 假设输入是连续的,如果上游传来的是转置或切片视图,得先.contiguous(),否则地址算错。
提示:调试 kernel 时,把
N_CTX设成和BLOCK_N相等(只循环一次),能快速排除循环相关的 bug,把问题范围缩小到单块逻辑。
5.4 什么时候不该用 FlashAttention
不是所有场景都适合。序列很短(比如 N < 128)时,标准实现的开销本来就不大,FlashAttention 的分块和重计算反而可能更慢。另外,如果注意力有特殊结构(比如稀疏、滑窗、带位置偏置的复杂变体),标准 FlashAttention 不一定直接适用,需要针对性改造。判断标准很简单:当 N² 的中间矩阵成为显存或带宽瓶颈时,才值得上 FlashAttention。
6. 我个人的一些实战体会
写 FlashAttention 这件事,最大的收获不是最后那个能跑的 kernel,而是被迫把 softmax 的数值稳定性、GPU 的存储层级、访存和计算的关系这些平时"知道但没深究"的东西彻底想明白了。在线 softmax 那个递推公式,我推导了三遍才真正内化,第一遍照着抄能跑,第二遍改个块大小就错,第三遍才敢说自己懂了。
如果你也想动手,我的建议是:先用 N=64、D=64 这种最小规模把前向跑通,和 PyTorch 逐元素对齐;然后逐步放大序列长度,观察显存和耗时的变化曲线;最后再考虑反向和集成。别一上来就追求性能,正确性永远是第一位的。至于生产环境,flash-attn 库已经足够成熟,自己写的版本更适合作为理解工具和定制起点。踩过的坑都写在上面了,希望能帮你少走点弯路。