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 瓶颈

v1 的主要突破是 IO-aware,但线程块并行和 warp 内分工还不够理想;部分归并需要经过 shared memory 和同步。

v2 改进

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 launch:grid 主要是 batch、head、num_splits 源码位置
// 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 launch:grid.x 直接是 query block 源码位置
// 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、长序列的训练和推理更友好。

v1 grid.xbatch
v1 grid.yhead
v1 grid.znum_splits,提升占用率但有合并成本
v2 grid.xquery block,直接增加 sequence 维并行

第二处源码差异:kernel 主循环怎么组织

v1:加载 Q/K/V 后,循环内做 QK、softmax、PV,并经 shared memory 做 O reduction 源码位置
// 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:从最后一个 K/V block 往前扫描,先处理需要 mask 的块,再处理完整块,acc_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
共同数学流程:online softmax 不变,变的是并行和数据路径
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。

1. 固定一个 CUDA block:它负责一段 Q 行
Q block
[kBlockM, head_dim]
纵轴:query token横轴:head_dim

blockIdx.x 选中哪一段 Q 行。比如 kBlockM=128,这个 CUDA block 就负责 128 个 query token。

2. K/V block 沿 key/value token 维度切
K/V block
[kBlockN, head_dim]
纵轴:key/value token横轴:head_dim

K/V block 不是按 head 切;head 已经由 blockIdx.z 选定。这里是在同一个 head 内,沿 token 序列切 K/V。

Q block
128 行 Q
多个 warp 分这些行
×
K block 转置
64 行 K
CTA 内共享
=
S tile = QK^T
S tile
[kBlockM, kBlockN]

这个小分数矩阵只在片上参与 softmax,不写完整 N x N 到 HBM。

P tile
softmax 后
[kBlockM, kBlockN]
×
V block
64 行 V
CTA 内共享
+=
O block 累加器
O partial
[kBlockM, head_dim]

每个 warp 只负责其中一部分 Q 行对应的 O 行,acc_o 尽量留在寄存器。

把三种 block 分清

矩阵 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

先定义 block 的维度

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:sliced-K 思维

v1 更接近把 K/V 的不同部分分给不同 warp。每个 warp 得到一部分中间输出后,需要通过 shared memory 和同步把结果合并到最终 O。 这能减少 HBM,但在 Tensor Core 已经很快时,shared memory 往返和同步会变成更明显的开销。

v2:sliced-Q 思维

v2 更倾向让不同 warp 负责不同 Q 行。K/V tile 对所有 warp 共享,每个 warp 维护自己负责行的 softmax 状态和 output 累加器,最终少做跨 warp 归并。

v1:O reduction 相关路径 源码位置
// 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:warp/MMA layout 由 kNWarps 和 TiledMMA 描述 源码位置
// 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>;
v1 warp0..3分 K/V 切片,产生局部 O
v1 shared memory写入、同步、归并
v2 warp0..3分 Q 行,每个 warp 对应输出行
v2 output更少跨 warp 通信,直接写回自己的 O 行

第四处差异: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 利用率。

录视频时的讲解脚本

1. 先讲共同点 两者都不改变 attention 数学,都是 exact attention。核心仍然是 tiling + online softmax,节省 HBM 流量。
2. 打开 launch 源码 对比 v1 的 grid(b, h, num_splits) 和 v2 的 grid(num_m_block, b, h)。这里是 v2 并行度提升最直观的入口。
3. 打开 kernel 主循环 v1 让观众看 Q/K/V load、softmax、PV、shared memory reduction;v2 看 softmax_rescale_o 和 gemm_A_in_regs 如何循环累加。
4. 解释 warp 分工 用 sliced-K 到 sliced-Q 的图讲为什么 v2 减少 shared memory 通信。重点是每个 warp 最终负责自己的 Q 行和 O 行。
5. 总结 tradeoff v2 更快,但工程复杂度更高,kernel traits、不同 head dim、causal/dropout/varlen 分支更多。面试中要能说清楚“为什么快”和“代价是什么”。

面试速答

练习题与追问

练习 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 官方博客。