FlashAttention-2 相比 FlashAttention-1,到底改了什么
这页按源码导读的方式讲:先看 launch 维度,再看 kernel 主循环,最后看 warp 内 work partitioning。 结论不是“数学公式变了”,而是 v2 在保持 exact attention 的前提下,把并行度、warp 分工和非矩阵计算开销重新整理了一遍。
一句话区别
v1 和 v2 都是 exact attention,都用 tiling 和 online softmax 避免把完整 S = QK^T 和 P = softmax(S) 写回 HBM。
v1 的主要突破是 IO-aware,但线程块并行和 warp 内分工还不够理想;部分归并需要经过 shared memory 和同步。
v2 主要改 work partitioning:更多 thread block 覆盖 query block,warp 内按 Q 切分,让每个 warp 直接负责自己的 output 行。
源码地图:先知道看哪里
| 层级 | FlashAttention v1.0.9 | FlashAttention v2.0.9 | 讲解重点 |
|---|---|---|---|
| Python/C++ API | csrc/flash_attn/fmha_api.cpp |
csrc/flash_attn/flash_api.cpp |
参数检查、打包 QKV/KV、选择 fwd/bwd launch。 |
| launch 模板 | fmha_fwd_launch_template.h |
flash_fwd_launch_template.h |
grid 维度、动态 shared memory、head dim dispatch。 |
| forward kernel | fmha_fprop_kernel_1xN.h |
flash_fwd_kernel.h |
QK、mask、softmax、PV、O 写回的核心流程。 |
| tile/warp 配置 | fmha/kernel_traits.h |
kernel_traits.h |
blockM/blockN/headDim/warps 与 MMA layout。 |
第一处源码差异:launch grid 怎么铺
// v1: fmha_fwd_launch_template.h
if (params.num_splits <= 0) {
params.num_splits = num_splits_heuristic_fwd(
params.b * params.h, nSMs, ctas_per_sm, max_splits);
}
dim3 grid(params.b, params.h, params.num_splits);
kernel<<<grid, Kernel_traits::THREADS, smem_size, stream>>>(params);
// v2: flash_fwd_launch_template.h
const int num_m_block =
(params.seqlen_q + Kernel_traits::kBlockM - 1) / Kernel_traits::kBlockM;
dim3 grid(num_m_block, params.b, params.h);
kernel<<<grid, Kernel_traits::kNThreads, smem_size, stream>>>(params);
怎么讲这段源码
v1 会根据 batch * heads 和 SM 数量估计 num_splits,目的是让 occupancy 不要太低,但 split 太多会带来额外 HBM 读写和中间结果合并。
v2 直接把 query sequence 切成 m_block,让 grid.x 覆盖 query block;这对小 batch、少 head、长序列的训练和推理更友好。
第二处源码差异:kernel 主循环怎么组织
// v1 源码讲解用压缩版
gmem_k.load(); gmem_q.load(); gmem_v.load();
gmem_q.commit(smem_q);
gmem_v.commit(smem_v);
gmem_k.commit(smem_k);
__syncthreads();
for (int l = blockIdx.z; l < steps; l += step_stride) {
gemm_q_k(acc_p); // P score = Q * K^T
softmax.unpack_noscale(acc_p);
softmax.apply_mask(mask);
softmax.reduce_max(...);
softmax.scale_apply_exp(...);
softmax.reduce_sum_before_sync_(...);
softmax.pack(frag_p);
gemm_cl(acc_o, frag_p, frag_v); // O partial = P * V
smem_o.store(acc_o, 0);
__syncthreads();
softmax.reduce_sum_after_sync_(...);
smem_o.load(out);
store O or temporary O;
}
// v2 源码讲解用压缩版
clear(acc_o);
for (masking_step...) {
flash::gemm(acc_s, Q, K); // S = QK^T
apply_mask_if_needed(scores);
softmax_rescale_o(scores, max, sum, acc_o, scale);
rP = convert_type(scores);
flash::gemm_A_in_regs(acc_o, rP, V);
}
for (; n_block >= 0; --n_block) {
flash::gemm(acc_s, Q, K);
softmax_rescale_o(scores, max, sum, acc_o, scale);
rP = convert_type(scores);
flash::gemm_A_in_regs(acc_o, rP, V);
}
// epilogue: normalize acc_o, write O and LSE
for each query block:
load Q block
m = -inf; l = 0; acc = 0
for each K/V block:
S = Q @ K.T
S = apply_mask(S)
m_new = max(m, rowmax(S))
P = exp(S - m_new)
acc = acc * exp(m - m_new) + P @ V
l = l * exp(m - m_new) + rowsum(P)
m = m_new
O = acc / l
怎么讲这段源码
v1 和 v2 都遵循同一个 online softmax 数学流程。源码层面最容易讲清楚的区别是:
v1 中 smem_o.store、__syncthreads、reduce_sum_after_sync_ 这类操作体现了跨 warp 归并路径;
v2 中 acc_o 在主循环中持续累加,最后 epilogue 统一归一化和写回,warp 之间的 output 责任边界更清楚。
先用矩阵图理解 block 和 warp
这里有两个不同层级的 “block”:矩阵里的 Q block / K block / V block 是算法上的 tile; CUDA 里的 thread block / CTA 是执行这些 tile 的线程组。FA2 中可以粗略理解为:一个 CUDA block 负责一个 Q block,然后循环扫描多个 K/V block。
[kBlockM, head_dim]
blockIdx.x 选中哪一段 Q 行。比如 kBlockM=128,这个 CUDA block 就负责 128 个 query token。
[kBlockN, head_dim]
K/V block 不是按 head 切;head 已经由 blockIdx.z 选定。这里是在同一个 head 内,沿 token 序列切 K/V。
多个 warp 分这些行
CTA 内共享
[kBlockM, kBlockN]
这个小分数矩阵只在片上参与 softmax,不写完整 N x N 到 HBM。
[kBlockM, kBlockN]
CTA 内共享
[kBlockM, head_dim]
每个 warp 只负责其中一部分 Q 行对应的 O 行,acc_o 尽量留在寄存器。
矩阵 block 是数据 tile:Q[某 128 行, :]、K/V[某 64 行, :]。
CUDA block 是执行单位:一组线程负责一个 Q block。
warp 是 CUDA block 里的 32 个线程小组:在 FA2 的直觉模型里,多个 warp 分摊 Q block 的不同行,共享当前 K/V block。
第三处源码差异:warp 内 work partitioning
Q block 是沿 query 序列维切出来的一段 Q 行,形状近似 [kBlockM, head_dim]。
K/V block 是沿 key/value 序列维切出来的一段 K/V 行,形状近似 K: [kBlockN, head_dim]、V: [kBlockN, head_dim]。
所以这里的 block 主要是按 token/sequence 维度切,不是按 head 维切;head 和 batch 已经在 grid.y/grid.z 上分开了。
v1 更接近把 K/V 的不同部分分给不同 warp。每个 warp 得到一部分中间输出后,需要通过 shared memory 和同步把结果合并到最终 O。 这能减少 HBM,但在 Tensor Core 已经很快时,shared memory 往返和同步会变成更明显的开销。
v2 更倾向让不同 warp 负责不同 Q 行。K/V tile 对所有 warp 共享,每个 warp 维护自己负责行的 softmax 状态和 output 累加器,最终少做跨 warp 归并。
// v1: mapping changes, so recompute row max
softmax.reduce_max_after_sync_(p_max_o, rows);
smem_o.store(acc_o, 0);
__syncthreads();
softmax.reduce_sum_after_sync_(p_sum_o, rows);
smem_o.load(out);
// v2: kernel_traits.h
static constexpr int kNWarps = kNWarps_;
static constexpr int kNThreads = kNWarps * 32;
static constexpr int kBlockM = kBlockM_;
static constexpr int kBlockN = kBlockN_;
using TiledMma = TiledMMA<
MMA_Atom_Arch,
Layout<Shape<Int<kNWarps>,_1,_1>>,
ValLayoutMNK>;
第四处差异:v2 减少非 matmul 开销
FA2 论文和博客把一个重要点说得很直接:A100 上 Tensor Core 的 matmul 很快,非 matmul 的 FP32 操作、mask、rescale、shared memory 同步相对更贵。 所以 v2 不是只盯着 FLOPs,而是尽量把主循环拆成更规整的路径。
| 优化点 | 源码上看什么 | 讲解口径 |
|---|---|---|
| masking 和 non-masking 分离 | n_masking_steps 之后进入普通 for (; n_block >= 0; --n_block) |
边界块和 causal 块需要 mask;内部完整块走更短路径。 |
| acc_o 寄存器累加 | clear(acc_o) 后多次 gemm_A_in_regs(acc_o, ...) |
输出累加器尽量留在寄存器,最后统一 normalize 和写回。 |
| sequence 维并行 | grid(num_m_block, params.b, params.h) |
固定同一个 batch 和 head 时,blockIdx.x 可以取多个 query 行块。每个 CUDA block 负责一段 Q 行,并独立扫描全部 K/V,所以同一个 head 里的不同 Q 行块可以并行,提高 SM 利用率。 |
录视频时的讲解脚本
grid(b, h, num_splits) 和 v2 的 grid(num_m_block, b, h)。这里是 v2 并行度提升最直观的入口。
softmax_rescale_o 和 gemm_A_in_regs 如何循环累加。
面试速答
- FA2 相比 FA1 的本质区别:不是数学公式变化,而是更好的并行度和 work partitioning。
- 源码第一眼看哪里:先看 launch grid,再看 forward kernel 主循环,再看 kernel traits 中的 block/warp/MMA 配置。
- v1 为什么还不够:IO 降下来了,但 threadblock/warp 分工导致 occupancy 和 shared memory 通信仍有瓶颈。
- v2 为什么更快:sequence 维度增加 CTA 并行,warp 按 Q 行切分,减少跨 warp 通信,减少非 matmul FLOPs。
- 什么时候收益明显:长序列、小 batch、少 head、大模型训练或推理中 attention 占比高时更明显。
练习题与追问
练习 1:手推 online softmax
设一个 Q block 扫描两个 K/V tile,手动写出每次 tile 后的 m、l、acc_o 更新。重点验证 rescale 之后旧 tile 的贡献不会丢。
练习 2:画出数据生命周期
分别标出 Q、K、V、S、P、O 在 HBM、shared memory、register 中停留的位置,并说明 FlashAttention 为什么减少 S/P 的 HBM 写读。
练习 3:对比 v1 与 v2 work partition
从 launch grid、warp 分工、shared memory 通信、sequence 维并行四个角度写对照表。不要只说 v2 更快,要写出更快的机制。
面试追问
如果 head dim、sequence length、causal mask、dropout、varlen batch 同时变化,你会先检查 kernel traits、grid、mask、还是 softmax 状态?给出排查顺序和证据。
参考资料
Dao-AILab/flash-attention 官方仓库; v1.0.9 csrc/flash_attn/src; v2.0.9 csrc/flash_attn/src; FlashAttention v1 论文; FlashAttention-2 论文; FlashAttention-2 官方博客。