本文是《GPU Kernel 工程:从 CUDA 执行模型到 FlashAttention》系列的第 8 篇(共十一篇)。上一篇:Triton——块级编程与编译器的边界;下一篇:量化与融合 kernel——推理系统的其余部分。
前七篇分别处理了 GPU 的硬件结构与 Roofline、CUDA 执行模型、访存合并、shared memory 与 reduction(其中包括 online softmax)、GEMM 的分块、Tensor Core 与 CUTLASS、Triton。这一篇是它们的汇合点:attention 同时包含两个 GEMM(\(QK^T\) 与 \(PV\))、一个逐行的 reduction(softmax),以及推理时特有的内存访问模式(分页的 KV cache)。它是 Transformer 推理里最重要、也最难写好的 kernel。
总纲给这一篇提的核心问题是:
一个序列长度 4k、head dim 128 的 attention,标准实现和 FlashAttention 分别读写多少 HBM?1 decode 阶段每生成一个 token 要读多少 KV cache,这决定了什么?2
一、总览
1. 方法论与硬件基线
全文遵循同一个方法论:先算理论上应该多快(字节数 / FLOPs / Roofline),再看实现,再解释差距。硬件基线仍然是 A100 SXM 80GB 的公开标称值:HBM2e 约 2.0 TB/s,BF16 Tensor Core 约 312 TFLOPS(dense),ridge point 约 156 FLOP/byte;Hopper 相关内容随文标注 H100(约 3.35 TB/s、约 989 TFLOPS、ridge 约 295)。没有 GPU 可供实测,所有性能数字要么是可推导的理论下界,要么用”通常能达到”的量级区间给出。
2. 本文的边界
本篇不讨论 KV cache 的分页管理(block 的分配、换入换出、prefix 共享属于推理引擎调度层),只讨论分页之后 kernel 如何访问它。
3. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 先算账 | 记号与两个需要重述的结论、标准实现物化 S 与 P 的 HBM 流量 |
| 三 | FlashAttention | 分块、每个 tile 做什么、O(N²d²/M) 的 HBM 流量、FA 不省 FLOPs 甚至略多 |
| 四 | FlashAttention-2 与 -3 | 并行化与 warp 分工、Hopper only 的 FA3、反向传播的 recompute、源码结构 |
| 五 | 推理的两种形态 | prefill 由 GEMM 主导、decode 每 token 读全部 KV、GQA 在 kernel 层的含义 |
| 六 | PagedAttention | block table 间接寻址、vLLM 的 paged_attention_v1、v2 的 partition 就是 split-KV |
| 七 | 变长 batch、因果掩码与 sliding window | cu_seqlens 的 packed 布局、因果掩码在分块中的处理、sliding window |
| 八 | Triton 版与 CUDA 版的结构对照 | tutorial 06 的结构、完整 Triton FA 前向(因果 + GQA)、正确性与性能对照、triton_unified_attention、CUDA 核心循环骨架 |
| 九 | 后端生态与 vLLM 的选择 | FA2/3、FlashInfer、xFormers、cuDNN、Triton、SDPA 各自的位置与 vLLM 的选择逻辑 |
| 十 | 本文小结 | |
| 十一 | 自测 | 5 道题 |
二、先算账:标准 attention 读写多少 HBM
1. 记号与两个需要重述的结论
单个 head 的 attention 定义为:
\[S = \frac{QK^T}{\sqrt{d}}, \quad P = \mathrm{softmax}(S), \quad O = PV\]其中 \(Q, K, V \in \mathbb{R}^{N \times d}\),\(S, P \in \mathbb{R}^{N \times N}\)。\(N\) 是序列长度,\(d\) 是 head dim。本文的默认例子是 \(N = 4096\)、\(d = 128\)、BF16。
本篇会反复用到两个前面篇章的结论,这里自足地重新给出。
Roofline。 一个 kernel 的算术强度 \(I = \text{FLOPs} / \text{bytes}\)(对 HBM 而言),设备的 ridge point 是峰值算力除以峰值带宽。A100 BF16 的 ridge 是 \(312 / 2.0 \approx 156\) FLOP/byte:\(I\) 低于 156 的 kernel 受带宽限制(memory-bound),时间下界是字节数 / 2.0 TB/s;高于 156 的受算力限制(compute-bound),时间下界是 FLOPs / 312 TFLOPS。
Online softmax。 对一行 \(x_1, \dots, x_N\),朴素 softmax 要三遍:先求最大值 \(m\),再求 \(l = \sum_i e^{x_i - m}\),最后写出 \(e^{x_i - m} / l\)。online softmax 把前两遍合成一遍,维护一对运行统计量 \((m, l)\),每来一个新元素(或一批新元素)就更新:
\[m_{\text{new}} = \max(m, x_i), \qquad l_{\text{new}} = l \cdot e^{m - m_{\text{new}}} + e^{x_i - m_{\text{new}}}\]关键性质是:已经累积好的 \(l\) 可以在最大值变化时用一个因子 \(e^{m - m_{\text{new}}}\) 修正,不需要重新看一遍旧数据。FlashAttention 的全部数学,就是把这个修正因子同时施加到 \(O\) 上。
2. 标准实现:物化 S 与 P
标准的 attention 实现(就是 PyTorch 里 q @ k.transpose(-1, -2)、softmax、@ v 三行代码所做的事)要把 \(S\) 和 \(P\) 完整地写进 HBM 再读回来。数一下字节:
- \(Q, K, V, O\) 各是 \(4096 \times 128 \times 2\) B = 1 MiB;
- \(S = QK^T\) 有 \(N^2 = 4096^2 = 16{,}777{,}216\)(约 16M)个元素,BF16 下是 32 MiB,若用 FP32 存放则是 64 MiB;
- 第一个 GEMM 写 \(S\)(32 MiB),softmax 读 \(S\) 写 \(P\)(32 + 32 MiB),第二个 GEMM 读 \(P\)(32 MiB):与 \(N^2\) 成正比的流量至少 \(4 \times 32 = 128\) MiB;
- 加上 \(Q, K, V, O\) 的 4 MiB,共约 132 MiB。如果 softmax 之前还要加 mask、之后还有 dropout,每一步都再来一次 32 MiB 的读写。
FLOPs 呢?两个 GEMM 形状分别是 \([N, d] \times [d, N]\) 和 \([N, N] \times [N, d]\),各 \(2 N^2 d\),合计:
\[\text{FLOPs} = 4 N^2 d = 4 \times 16.78\text{M} \times 128 \approx 8.6\ \text{GFLOP}\]算术强度:
\[I \approx \frac{8.6 \times 10^9}{132 \times 1.05 \times 10^6\ \text{B}} \approx 62\ \text{FLOP/byte}\]62 低于 A100 的 ridge 156:标准 attention 在 A100 上是 memory-bound 的。它的时间下界由 HBM 决定:\(138\ \text{MB} / 2.0\ \text{TB/s} \approx 69\ \mu s\);而如果只看算力,\(8.6\ \text{G} / 312\ \text{T} \approx 28\ \mu s\)。中间那 40 µs 的差距,就是 \(S\) 和 \(P\) 在 HBM 上往返的代价。\(N\) 越大情况越糟:\(N^2\) 项随 \(N\) 平方增长,而 \(Q, K, V, O\) 只线性增长,序列到 32k 时 \(S\) 一个矩阵就是 2 GiB。
这就是 FlashAttention 要解决的问题:不是 FLOPs 太多,而是一个 \(N \times N\) 的中间结果不该被写出去。
%% 图:标准 attention 与 FlashAttention:前者三个 kernel 让 N² 的 S、P 往返 HBM,后者一个 kernel 让它们只存在于片上
flowchart LR
subgraph std["标准实现:三个 kernel,S 与 P 往返 HBM"]
direction TB
q1["Q, K(HBM,各 1 MiB)"] --> g1["GEMM #1"]
g1 -- "写 S 32 MiB" --> s1["S(HBM)"]
s1 -- "读 32 MiB" --> sm1["softmax"]
sm1 -- "写 P 32 MiB" --> p1["P(HBM)"]
p1 -- "读 32 MiB" --> g2["GEMM #2"]
v1["V(HBM,1 MiB)"] --> g2
g2 -- "写 O 1 MiB" --> o1["O(HBM)"]
end
subgraph fa["FlashAttention:一个 kernel,S/P 只存在于片上"]
direction TB
q2["Q_i, K_j, V_j tile<br/>(HBM → smem / 寄存器)"] --> g3["GEMM #1 → S_ij<br/>寄存器(FP32)"]
g3 --> sm2["online softmax → P̃_ij<br/>寄存器,同时更新 m, l"]
sm2 --> g4["GEMM #2:O_i += P̃_ij V_j<br/>寄存器累加器"]
g4 -- "遍历完所有 j 后写 1 次" --> o2["O(HBM,1 MiB)"]
end
std ~~~ fa
classDef hbm fill:#fde2e2,stroke:#c0392b
classDef chip fill:#dff5e1,stroke:#1e8449
classDef k fill:#dde9f7,stroke:#2e6da4
class q1,s1,p1,v1,o1,q2,o2 hbm
class g3,sm2,g4 chip
class g1,sm1,g2 k
红色是 HBM 上的张量:左边 N² 大小的 S、P 各进出 HBM 两次(128 MiB),右边只剩 Q、K、V、O 四个 N·d 大小的张量。
三、FlashAttention:分块 + online softmax + 不物化 S
1. 分块
把 \(Q\) 沿行切成大小为 \(B_r\) 的块 \(Q_1, \dots, Q_{N/B_r}\),把 \(K, V\) 沿行切成大小为 \(B_c\) 的块 \(K_1, \dots, K_{N/B_c}\) 与 \(V_1, \dots, V_{N/B_c}\)。对于 \(Q\) 的第 \(i\) 块,它需要的输出是:
\[O_i = \mathrm{softmax}\big( Q_i K^T \big) V = \frac{\sum_j e^{S_{ij} - m_i} V_j}{\sum_j \mathbf{1}^T e^{S_{ij} - m_i}}, \qquad S_{ij} = Q_i K_j^T \in \mathbb{R}^{B_r \times B_c}\]这里 \(m_i\) 是每一行的全局最大值(一个 \(B_r\) 维向量),\(e^{\cdot}\) 逐元素。分子和分母都是对 \(j\) 的求和——这正是 online softmax 能处理的形式。
2. 每个 tile 做什么
一个 thread block 负责 Q 的第 i 块(B_r 行),沿 K/V 的块 j = 1, 2, 3, ... 扫过去:
K^T (d × N) V (N × d)
┌────┬────┬────┬────┐ ┌──────────┐
│ K₁ │ K₂ │ K₃ │ K₄ │ 每次取一块 K_j (B_c 列) │ V₁ │
└────┴────┴────┴────┘ ├──────────┤
▲ │ V₂ │ 每次取对应的 V_j (B_c 行)
Q_i │ ├──────────┤
┌─────┐ ┌────┴────┐ ┌───────┐ │ V₃ │
│ B_r │ × │ K_j^T │ = │ S_ij │ B_r × B_c ├──────────┤
│ × d │ └─────────┘ │(寄存器)│ │ V₄ │
└─────┘ └───┬───┘ └──────────┘
常驻 │ 行 max → m_new;P̃ = exp(S - m_new);行和 → l̃
▼
┌───────────────────────────┐ ┌──────────┐
block 里的状态: │ m_i [B_r] l_i [B_r] │ O_i = │ O_i·α + │ α = exp(m_old - m_new)
│ O_i [B_r × d](未归一化) │ ◄────── │ P̃_ij V_j │ (第二个 GEMM)
└───────────────────────────┘ └──────────┘
j 走完 → O_i / l_i → 写回 HBM。S_ij、P̃_ij 从头到尾没有离开寄存器。
一个 thread block 拿到 \(Q_i\)(放进 shared memory 或直接进寄存器),维护三个运行量:行最大值 \(m_i \in \mathbb{R}^{B_r}\)(初值 \(-\infty\))、行分母 \(l_i \in \mathbb{R}^{B_r}\)(初值 0)、未归一化的输出累加器 \(O_i \in \mathbb{R}^{B_r \times d}\)(初值 0)。然后依次遍历 \(K_j, V_j\):
- 把 \(K_j\) 载入 shared memory,用 Tensor Core 算 \(S_{ij} = Q_i K_j^T\),结果留在寄存器里(FP32);
- 求 \(S_{ij}\) 每行的最大值 \(\tilde m_{ij}\),更新 \(m_{\text{new}} = \max(m_i, \tilde m_{ij})\);
- 算 \(\tilde P_{ij} = e^{S_{ij} - m_{\text{new}}}\)(寄存器里,逐元素 exp),行和 \(\tilde l_{ij}\);
- rescale 并累加:
- \(m_i \leftarrow m_{\text{new}}\)。
遍历完所有 \(j\) 后,\(O_i \leftarrow O_i / l_i\)(逐行除),写回 HBM。
对照第一节的 online softmax 递推,\(l_i\) 的更新一模一样;\(O_i\) 的更新只是把标量 \(e^{x_i - m_{\text{new}}}\) 换成了矩阵 \(\tilde P_{ij}\),把”加一个数”换成了”加一个 GEMM 结果 \(\tilde P_{ij} V_j\)“。旧的 \(O_i\) 是以旧最大值 \(m_i\) 为基准算出来的,最大值变大后需要乘上 \(e^{m_i - m_{\text{new}}} \le 1\) 修正——这就是 diag(...)。
整个过程中,\(S_{ij}\) 和 \(\tilde P_{ij}\) 只在寄存器与 shared memory 中存在,从未写到 HBM。\(N \times N\) 的矩阵被压成了 \(B_r \times B_c\) 的 tile,用完即弃。
3. HBM 流量:O(N²d²/M)
现在数 FlashAttention 的字节数。\(Q\) 读一次、\(O\) 写一次,各 1 MiB。\(K, V\) 呢?每个 \(Q\) 块都要把 \(K, V\) 从头到尾读一遍,所以 \(K, V\) 各被读 \(N / B_r\) 次:
\[\text{bytes}_{\text{FA}} = \underbrace{2 N d}_{Q, O} + \underbrace{\frac{N}{B_r} \cdot 2 N d}_{K, V} \ \text{(元素数)}\]\(B_r\) 由 shared memory 大小 \(M\) 决定:一个 tile 要同时放下 \(Q_i, K_j, V_j\) 和 \(S_{ij}\)(或它们中的一部分),Dao 等 2022 论文的选择是 \(B_c = M / (4d)\),\(B_r = \min(B_c, d)\),于是 \(N/B_r \approx 4Nd/M\),K/V 的流量约为 \(4Nd/M \cdot 2Nd = 8N^2d^2/M\),即 \(\Theta(N^2 d^2 / M)\)。对比标准实现的 \(\Theta(N^2)\):多了一个 \(d^2/M\) 的因子,\(d = 128\)、\(M = 100\) KB 时 \(d^2 / M = 16384 / 102400 \approx 0.16\),再除以 FP32 的 4 字节或 BF16 的 2 字节,就是数量级上的节省。
代入具体数字更直观。取 FlashAttention-2 常用的 \(B_r = 128\):
- \(K, V\) 各被读 \(4096 / 128 = 32\) 次,每次 1 MiB,共 64 MiB;
- 加 \(Q\) 读、\(O\) 写 2 MiB,合计 66 MiB;
- 标准实现是 132 MiB,约 2 倍;\(B_r = 256\) 时 K/V 只读 16 次,合计 34 MiB,约 4 倍。
“只有 2 倍”看起来不够惊艳,但注意两件事。第一,标准实现的 128 MiB 是 \(N^2\) 项,FlashAttention 的 64 MiB 是 \(N^2 d / B_r\) 项——\(B_r\) 随硬件(shared memory、寄存器)增大而增大,\(N\) 翻倍时前者翻 4 倍、后者也翻 4 倍,但常数因子相差 \(B_r / d \times\)(字节比)。第二,更实际的一点:这里 K、V 总共只有 2 MiB,A100 有 40 MB L2——32 次”重读”绝大部分命中 L2,真正到 HBM 的流量接近 4 MiB 的下界。标准实现的 32 MiB 中间矩阵则不可能待在 L2 里。因此对 4k 序列,FlashAttention 实际接近 memory 下界的 \(4\ \text{MiB} / 2.0\ \text{TB/s} \approx 2\ \mu s\),远低于计算下界 28 µs:它把 attention 从 memory-bound 推进了 compute-bound 区域。这才是 IO-aware 的真正收益。
算一下算术强度确认:以 66 MiB 计,\(I = 8.6\ \text{G} / 69\ \text{MB} \approx 125\);以实际 HBM 流量 4 MiB 计,\(I \approx 2000\)。无论哪种口径都在 156 附近或远高于它。
顺便说明一下循环顺序对这笔账的影响。上面按 “外层遍历 \(Q\) 块、内层遍历 \(K, V\) 块” 来数,这是 FlashAttention-2 的顺序;FlashAttention-1 论文里是反过来的——外层遍历 \(K_j, V_j\)(每块只从 HBM 载入一次),内层遍历 \(Q_i\),于是 \(Q\) 以及运行量 \(O_i, m_i, l_i\) 要被反复从 HBM 读进来、更新、写回去,共 \(N / B_c\) 遍。两种顺序的渐近流量都是 \(\Theta(N^2 d^2 / M)\),但 v2 的顺序让 \(O_i\) 全程留在寄存器,少了一份 \(N / B_c\) 倍的 \(O\) 读写,也少了它带来的同步——这是下一节 FA2 第一项改动的动机。两种循环顺序下,哪些量常驻片上、哪些量在 HBM 上往返,对照如下:
FA1:外层 K/V 块、内层 Q 块 每个 (batch, head) 一个 thread block
for j in K/V 块:
load K_j, V_j → smem HBM 读 1 次
for i in Q 块:
load Q_i, O_i, m_i, l_i ← HBM 每个 (i, j) 都读:共 N/B_c 遍
S_ij → P_ij → rescale → O_i += 寄存器
store O_i, m_i, l_i → HBM 每个 (i, j) 都写:共 N/B_c 遍
往返 HBM 的是 O(N×d);并行度 = batch × heads,block 内串行扫 j
FA2:外层 Q 块、内层 K/V 块 每个 (batch, head, i) 一个 thread block
load Q_i → 寄存器 HBM 读 1 次
O_i, m_i, l_i = 0 寄存器常驻
for j in K/V 块:
load K_j, V_j → smem HBM/L2 读,共 N/B_r 遍(多命中 L2)
S_ij → P_ij → rescale → O_i += 寄存器
store O_i / l_i → HBM 写 1 次
往返 HBM 的只剩 K/V;并行度 = batch × heads × N/B_r
4. 一个重要修正:FA 不省 FLOPs,甚至略多
FlashAttention 的 FLOPs 比标准实现略多:每个 tile 多了 \(B_r \times d\) 次乘法做 rescale(\(\mathrm{diag}(e^{m - m_{\text{new}}}) O_i\)),以及最后的 \(O_i / l_i\)。这些相对于 \(4N^2 d\) 的 GEMM 只是 \(O(N^2 d / B_c)\) 的小项,但确实存在。它的收益全部来自 IO,不来自算法上少算了什么。
还有一件容易被忽略的事:\(N^2\) 次 exp。Tensor Core 极快而 SFU(special function unit,做 exp、rsqrt 等)很慢。A100 每 SM 每周期能做 1024 次 dense BF16 FMA(Tensor Core),但 SFU 只有 16 次/周期。每个 \(S\) 元素对应 \(4d = 512\) FLOP = 256 次 FMA,用 Tensor Core 需要 \(256/1024 = 0.25\) 周期;1 次 exp 需要 \(1/16 \approx 0.06\) 周期——exp 占到了 matmul 时间的 1/4。H100 上 Tensor Core 快了 3 倍多而 SFU 没有同比例提升(Shah 等 2024 给的数字是约 989 TFLOPS matmul 对约 3.9 TFLOPS 特殊函数),这个比例进一步恶化到接近 1:2。所以从 FlashAttention-2 起,”减少非矩阵运算”和”让 exp 与 matmul 重叠”成了主要优化方向,而不是继续省 HBM 流量。
四、FlashAttention-2 与 FlashAttention-3
1. FlashAttention-2(Dao 2023):并行化与 warp 分工
FlashAttention-1 的外层循环遍历 \(K, V\) 块,内层遍历 \(Q\) 块,每个 thread block 负责一个 (batch, head)。这有两个问题:\(O_i, m_i, l_i\) 要在 HBM 上反复读写(因为内层换 \(Q\) 块);并行度只有 batch × heads,长序列、小 batch 时(推理常态)填不满 108 个 SM。
FA2 做了三处改动:
外循环改为 Q 块,并按 (batch, head, Q block) 三维并行。 每个 thread block 负责一个 \(Q_i\),遍历全部 \(K_j, V_j\)。\(O_i, m_i, l_i\) 全程留在寄存器,只在最后写一次。grid 大小变为 \(\text{batch} \times \text{heads} \times N / B_r\):单序列、32 head、4k 长度、\(B_r = 128\) 时有 \(32 \times 32 = 1024\) 个 block,足够填满 SM。这就是上面第二节按 \(Q_i\) 讨论的版本。
warp 分工从 “split K” 改为 “split Q”。 一个 block 里通常有 4 个 warp。FA1 让 4 个 warp 各持有 \(K_j\) 的一段(沿 \(B_c\) 切),每个 warp 算出 \(S_{ij}\) 的一个列切片;但 softmax 是按行归约的,行最大值和行和需要跨 4 个 warp 通过 shared memory 同步——每个 tile 都要 __syncthreads() 加 shared 读写。FA2 让 4 个 warp 各持有 \(Q_i\) 的若干行(\(B_r = 128\) 时每 warp 32 行,或用 16 行的 mma 形状),每个 warp 独立算出自己那些行对完整 \(K_j\) 的 \(S\)、独立做行 softmax、独立累加 \(O\)——warp 之间零通信。代价是 \(K_j, V_j\) 要被 4 个 warp 各读一遍(从 shared memory,不是 HBM),但这比每 tile 一次跨 warp 归约便宜得多。
FA1: "split K" —— 4 个 warp 各拿 K_j 的 1/4 列 FA2: "split Q" —— 4 个 warp 各拿 Q_i 的 1/4 行
K_j 的列: w0 │ w1 │ w2 │ w3 K_j 全部列(每个 warp 都读一遍 smem)
┌────────────────────────────┐ ┌────────────────────────────┐
│ w0 │ w1 │ w2 │ w3 │ Q_i 全部行 │ ◄──── warp 0 的 16/32 行 ────►│
S_ij │ w0 │ w1 │ w2 │ w3 │ S_ij │ ◄──── warp 1 ────────────────►│
│ w0 │ w1 │ w2 │ w3 │ │ ◄──── warp 2 ────────────────►│
│ w0 │ w1 │ w2 │ w3 │ │ ◄──── warp 3 ────────────────►│
└────────────────────────────┘ └────────────────────────────┘
行 max / 行和 横跨 4 个 warp 每一行完整地在一个 warp 手里
→ 每个 tile: smem 写 → __syncthreads → 读 → 归约 → 行归约只需 warp 内 shfl,warp 间零通信
延迟 rescale,减少非 matmul 运算。 上一节的算法每个 tile 都对 \(O_i\) 乘 \(\mathrm{diag}(e^{m - m_{\text{new}}})\),FA1 还在每个 tile 里除 \(l\)。FA2 只做前者(这是不可省的),除法只在最后做一次;同时用 exp2 替代 exp、把 \(1/\sqrt d\) 和 \(\log_2 e\) 预先合并成一个 scale 乘进 \(S\),让每个元素只剩一次乘法和一次 ex2 指令。
FA2 在 A100 上通常能达到 BF16 峰值的 50%–73%(论文数字),即前向大约 160–230 TFLOPS 量级;这已经是 GEMM 级别的效率。
2. FlashAttention-3(Shah 等 2024,Hopper only)
FA3 只针对 sm_90。它把 Hopper 的三项硬件特性(wgmma、TMA、更大的 shared memory)用在 attention 上,并用 warp specialization 解决 exp 慢的问题:
- wgmma + TMA:\(QK^T\) 与 \(PV\) 用 warpgroup 级的异步
wgmma(形状m64nNk16),\(K_j, V_j\) 用 TMA 一条指令搬进 shared memory,地址计算与搬运不再占用线程; - producer/consumer warp specialization:一个 producer warpgroup 只负责发 TMA 加载、维护 pipeline 的 barrier;两个 consumer warpgroup 做计算。生产者用
setmaxnreg让出寄存器给消费者; - 两个 consumer warpgroup 的 ping-pong:当 warpgroup 1 在做 \(S = QK^T\) 的 GEMM 时,warpgroup 2 在做上一 tile 的 softmax(exp、max、sum);然后互换。Tensor Core 与 SFU 同时忙碌,把上一节说的 “exp 占 matmul 时间的一半” 隐藏掉;
- 块内 GEMM–softmax 流水:单个 warpgroup 内也把第 \(j\) 块的 softmax 与第 \(j+1\) 块的 \(QK^T\) 重叠(软件流水,需要多一套 \(S\) 的寄存器);
- FP8:\(Q, K, V\) 用 FP8 e4m3 喂给 FP8 wgmma(H100 上约 1979 TFLOPS 标称);为控制精度做块级量化(per-block scale)以及”非相干处理”(用随机正交矩阵把离群值摊平);FP8 wgmma 要求 \(V\) 是 k-major 布局,需要在 shared memory 里做一次转置/布局重排。
没有 ping-pong:一个 warpgroup 串行,Tensor Core 在 softmax 期间空转
WG: [GEMM S_j][softmax_j][GEMM PV_j][GEMM S_j+1][softmax_j+1][GEMM PV_j+1] ...
Tensor Core ████████ ████████ ████████ ████████
SFU (exp) ████████ ████████
时间 ─────────────────────────────────────────────────────────────────────►
ping-pong:两个 consumer warpgroup 错半拍,一个做 GEMM 时另一个做 softmax
WG1: [GEMM S_j ][softmax_j ][GEMM PV_j ][GEMM S_j+1][softmax_j+1] ...
WG2: [GEMM S_k ][softmax_k ][GEMM PV_k ][GEMM S_k+1 ] ...
Tensor Core ██████████ ██████████ ██████████ ██████████ ██████████ ← 几乎不停
SFU (exp) ██████████ ██████████ ██████████ ██████████
时间 ─────────────────────────────────────────────────────────────────────►
(producer warpgroup 在旁边只管发 TMA、等 barrier,不占图)
FA3 论文报告 H100 上 BF16 前向达到 ~740 TFLOPS(约 75% 峰值),FP8 接近 1.2 PFLOPS。sm_80 上用不了这些,A100 上的最优解仍然是 FA2。
3. 反向传播的 recompute
训练时反向需要 \(P\) 来算 \(dV = P^T dO\) 和 \(dS\)。标准实现把 \(N^2\) 的 \(P\) 存下来(4k 序列、32 head、batch 8 就是 8 GiB)。FlashAttention 只保存 \(O\) 与每行的 logsumexp:
\[L_i = m_i + \log l_i\]反向时按同样的分块重新算 \(S_{ij} = Q_i K_j^T\),然后 \(P_{ij} = e^{S_{ij} - L_i}\)——因为 \(L_i\) 已知,一步到位,不需要再做 online 归约。代价是反向多算一次 \(QK^T\)(\(2N^2 d\) FLOPs),换来 \(N^2\) 的显存节省。这是典型的”用 FLOPs 换字节”:反向从 5 个 GEMM 变成 5 个 GEMM 加一次重算,总 FLOPs 从 \(2.5\times\) 前向变为约 \(2.5\times\) 前向再加 0.5,但全部 compute-bound,实际时间反而比读写 \(P\) 更短。前向输出的 lse 就是为此保留的接口,后面 Triton 版会照此存下 \(L_i\)。
4. FlashAttention 源码的结构
FlashAttention-2 的 CUDA 源码大致位于仓库的 csrc/flash_attn/src/ 目录。核心文件是 flash_fwd_kernel.h,其中 compute_attn_1rowblock 这个 device 函数就是”一个 thread block 处理一个 \(Q\) 行块”的实现(本机没有源码,以下凭对代码结构的了解描述,不给行号):
- 用
binfo(BlockInfo)从cu_seqlens_q/k取得当前序列的起止,算出这个 \(Q\) 块需要遍历的 KV 块范围n_block_min..n_block_max(因果或 sliding window 时缩小范围); - 用 CuTe 的
Tensor与Layout把 gmem 上的 \(Q, K, V\) 切成 tile,cp.async搬到 smem 的双缓冲; - 主循环分两段:先处理需要 mask 的块(对角块,
n_masking_steps次),再处理不需要 mask 的块;两段共用同一套 “gemm → softmax rescale → gemm” 逻辑,只是 mask 那段多了apply_mask_causal; flash::gemm用mma.sync m16n8k16算 \(S\),Softmax结构体(在softmax.h)里的softmax_rescale_o完成 \(m, l, O\) 的更新——正是第二节步骤 2–4;- \(P\) 从 FP32 累加器转成 BF16 后直接作为下一个 mma 的 A 操作数(fragment 布局是兼容的,不需要经过 shared memory);
- 收尾:\(O / l\),写 \(O\) 与 \(L\)。
后面第七节的 CUDA 骨架就是这个结构的浓缩。
五、推理的两种形态:prefill 与 decode
1. prefill:Q 很长,GEMM 主导
prefill 处理 prompt 的全部 token,\(Q\) 有 \(N_q\) 行、\(K, V\) 有 \(N_k = N_q\) 行(或加上已缓存的前缀)。这就是训练前向的形状,\(4N_q N_k d\) 的 FLOPs 配 \(O(N d)\) 级的 HBM 流量,compute-bound。FlashAttention-2/3 是为这个场景设计的,效率与 GEMM 同级。
现代推理引擎还有一种混合形态:chunked prefill 把一个长 prompt 切成若干 chunk,每步只 prefill 一个 chunk,并与其他请求的 decode token 拼进同一个 batch。此时一个 batch 里既有 \(N_q\) 为几百上千的请求,也有 \(N_q = 1\) 的请求,它们的 \(K, V\) 都是 “已缓存的前缀 + 本步新 token”。这正是 cu_seqlens(第六节)与 “统一 prefill/decode 的 kernel”(第七节)存在的原因:kernel 不能假设 \(Q\) 是长是短,只能按每个序列各自的 query_len 与 context_len 工作。
一个 chunked-prefill batch:请求 A 正在 prefill 第二个 chunk,B、C 在 decode
请求 q_len ctx_len Q 行(packed) 能看到的 key(位置 0 … ctx_len+q)
A 4 8 a0 ──► ████████▣
a1 ──► ████████▣▣
a2 ──► ████████▣▣▣
a3 ──► ████████▣▣▣▣ seq_len = 12
B 1 11 b0 ──► ███████████▣ seq_len = 12
C 1 5 c0 ──► █████▣ seq_len = 6
cu_seqlens_q = [0, 4, 5, 6](Q 共 6 行) seq_lens = [12, 12, 6](K/V 长度)
█ 已在 KV cache 里的前缀 ▣ 本步新 token(K/V 先写入 cache,再被本步读到)
同一个 kernel:各序列只是 (q_len, ctx_len) 两个数不同;A 的 4 行就是一个因果块
2. decode:Q 只有一行,每 token 读全部 KV
decode 每步只生成一个 token:\(Q\) 是 \(1 \times d\)(每个 head),但要对整个上下文的 \(K, V\) 做 attention。数一下 Llama-3-8B(32 层、8 个 KV head、\(d_{\text{head}} = 128\)、BF16)每个 token 的 KV cache:
\[32 \times 8 \times 128 \times 2\ (K, V) \times 2\ \text{B} = 131072\ \text{B} = 128\ \text{KiB}\]上下文长度 \(s\) 时,每生成一个 token 要读 \(128\ \text{KiB} \times s\):\(s = 4096\) 是 512 MiB,\(s = 8192\) 是 1 GiB。
它的算术强度低到什么程度?对一个 KV head、一个 token 位置,读 \(K\) 和 \(V\) 各 \(d\) 个元素(BF16 共 \(4d\) 字节),做 \(QK\) 的 \(2d\) FLOPs 加 \(PV\) 的 \(2d\) FLOPs。若 \(g\) 个 query head 共享这一份 KV(GQA,Llama-3-8B 是 32/8 = 4),FLOPs 乘 \(g\):
\[I_{\text{decode}} = \frac{4 d g}{4 d} = g\ \text{FLOP/byte(BF16 KV)}\]KV cache 用 FP8 存时分母减半,\(I \approx 2g\)。所以 decode attention 的算术强度约为 \(1\sim 2 \cdot g\) FLOP/byte,\(g = 4\) 时是 4–8,与 A100 的 ridge 156 相差一个半数量级以上——彻底 memory-bound,无论 kernel 写得多好,时间都等于 KV 字节数 / 带宽。
这回答了核心问题的第二半。一步 decode 的时间下界是:
\[t_{\text{decode}} \ge \frac{\text{权重字节}}{\text{BW}} + \frac{\text{batch} \times s \times 128\ \text{KiB}}{\text{BW}}\] 一步 decode 必须从 HBM 读的字节(Llama-3-8B,BF16,A100 2.0 TB/s;每格 = 1 GB)
batch×s = 4k 权重 ████████████████ 16 GB (8 ms) KV ▏0.5 GB → KV 占 3%
batch×s = 32k 权重 ████████████████ 16 GB KV ████ 4 GB → 20%
batch×s = 131k 权重 ████████████████ 16 GB KV ████████████████ 16 GB → 各占一半
batch×s = 262k 权重 ████████████████ 16 GB KV ████████████████████████████████ 32 GB → KV 成为主项
能动的杠杆: KV 量化 FP8(KV 条减半) · GQA(g 倍缩小) · 权重量化 INT4(权重条缩到 1/4)
Llama-3-8B 的 BF16 权重约 16 GB,在 A100 上读一遍约 8 ms。KV 那一项随 batch 与上下文线性增长:batch 1、\(s = 4096\) 时 512 MiB 只占权重的 3%;但当 \(\text{batch} \times s\) 超过 \(16\ \text{GB} / 128\ \text{KiB} \approx 131\text{k}\) 个 token(例如 batch 32、\(s = 4096\))时,KV 的读取时间超过权重的读取时间。这就是为什么推理引擎在长上下文、大 batch 时会把 KV cache 量化到 FP8、为什么 GQA/MQA 直接缩小 \(g\) 倍的 KV、以及为什么 decode attention kernel 的全部目标就是”以接近峰值带宽的速度把 KV 读一遍”。
3. GQA 在 kernel 层的含义
GQA 让 \(g\) 个 query head 共享一个 KV head,在参数与 KV cache 层面的节省是显然的。kernel 层的问题是:怎么保证 KV 真的只从 HBM 读一次,而不是 \(g\) 个 query head 各读一次?
Llama-3-8B: 32 个 query head,8 个 KV head,g = 4
朴素: 每个 query head 一个 block 打包: 同一 KV head 的 g 个 query head 进同一 tile
q head 0 ──► block 0 ──读 K/V head 0──┐ ┌ q head 0 (token t) ┐
q head 1 ──► block 1 ──读 K/V head 0──┤ 同一份 KV │ q head 1 (token t) │ 4 行
q head 2 ──► block 2 ──读 K/V head 0──┤ 被读 4 次 │ q head 2 (token t) │ × BLOCK_Q = 4 个 token
q head 3 ──► block 3 ──读 K/V head 0──┘ (靠 L2 兜底) │ q head 3 (token t) │
├ q head 0 (token t+1)┤ = BLOCK_M = 16 行
decode 时每个 block 是 M = 1 的 GEMV │ ... │
└ q head 3 (token t+3)┘
× K_j^T → 一条 mma 的 M 维填满
K/V head 0 只读一次,算术强度 × g
vLLM unified attention: query_pos = offs_m // g, head = kv_head*g + offs_m % g
做法是把共享同一 KV head 的 \(g\) 个 query head 放进同一个 thread block(或同一个 tile)。对 decode,\(g\) 个 \(1 \times d\) 的 query 恰好可以 pack 成一个 \(g \times d\) 的矩阵,作为 mma 的 M 维——原本 \(M = 1\) 的 GEMV 变成 \(M = g\) 的 GEMM,\(K_j\) 载入一次被 \(g\) 行复用,算术强度乘 \(g\)。vLLM 的 Triton unified attention 正是这么做的:BLOCK_M = 16(或 num_queries_per_kv 向上取到 2 的幂)、BLOCK_Q = BLOCK_M // num_queries_per_kv——一个 tile 的 16 行由 BLOCK_Q 个 token 位置 × \(g\) 个 query head 拼成。对 prefill,FA2 里 GQA 的处理是 grid 仍按 query head 展开、kernel 内部用 h_k = h_q / g 映射到 KV head,K/V 的复用交给 L2;FA3 与 FlashInfer 则会显式把同一 KV head 的 query head 打包进同一 tile。
六、PagedAttention:分页 KV cache 的 kernel 侧
1. block table 间接寻址
Kwon 等 2023 的 PagedAttention 把每个序列的 KV cache 切成固定大小的 block(vLLM 默认每 block 16 个 token,也常用 32),按需分配,物理上不连续;一张 block table 记录 “序列 \(s\) 的第 \(b\) 个逻辑块在第几个物理块”。kernel 读第 \(t\) 个 token 的 K 时:
\[\text{physical} = \text{block\_table}[s][\lfloor t / B \rfloor], \qquad \text{addr} = \text{physical} \times \text{block\_stride} + \text{head} \times \text{head\_stride} + (t \bmod B) \times \ldots\] 序列 s 的逻辑 KV(按 token 顺序) block_table[s] 物理 KV cache(所有序列共用的 block 池,每块 16 token)
token 0-15 = 逻辑块 0 ───────────► [0] = 7
token 16-31 = 逻辑块 1 ───────────► [1] = 2 blk: 0 1 2 3 4 5 6 7
token 32-47 = 逻辑块 2 ───────────► [2] = 5 ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
token 48-63 = 逻辑块 3 ───────────► [3] = 4 │其他 │其他 │ s:1 │其他 │ s:3 │ s:2 │ 空 │ s:0 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
序列 s 的 4 个块散落在 7、2、5、4,物理上不连续
读 token t 的 K: physical = block_table[s][t / 16] ← 一次 int32 查表(L1/L2 命中)
addr = physical × block_stride + head × head_stride + (t % 16) × ...
块内 16 个 token 连续(合并访问);块与块之间随机跳
即多一次查表(一个 int32 读,几乎总在 L1/L2 里),然后按物理块地址读连续的 \(B\) 个 token。对 kernel 而言,KV 不再是一个 \([N, d]\) 的连续矩阵,而是 \(N/B\) 个通过指针数组间接访问的 \([B, d]\) 小矩阵——这也是为什么 block size 不能太小:每个 block 内的读是连续合并的,块间是随机跳转。
block size 的取舍可以用字节数说清楚。一个 block、一个 KV head 的 K 是 \(B \times d \times 2\) 字节,\(B = 16\)、\(d = 128\) 时是 4 KiB(V 同样 4 KiB),正好是 32 条 128 字节 cache line——每次跳转之后至少有 4 KiB 的连续读,DRAM 的页开销与 TLB 开销被摊薄到可以忽略。\(B\) 再大一些(32、64)对 kernel 更友好(tile 边界与 block 边界更容易对齐,查表次数更少),但调度层的内部碎片会增加:每个序列最后一个块平均浪费 \(B/2\) 个 token 的空间。16 与 32 是两边都能接受的折中;FlashAttention 与 FlashInfer 的 paged 接口对 block size 的要求也大致在这个范围(FA 要求是 16 的倍数,与其 tile 的 k16 对齐有关)。
2. vLLM 的 CUDA kernel:paged_attention_v1
vLLM 的 csrc/attention/attention_kernels.cuh 里的 paged_attention_kernel(改写自 FasterTransformer 的 decoder masked MHA)是最早的 PagedAttention 实现。它是一个 decode 专用 kernel(\(Q\) 一行),不用 Tensor Core。以 v0.20.0 为准,模板参数与 grid:
// csrc/attention/attention_kernels.cuh (vLLM v0.20.0)
// Grid: (num_heads, num_seqs, max_num_partitions).
template <typename scalar_t, typename cache_t, int HEAD_SIZE, int BLOCK_SIZE,
int NUM_THREADS, vllm::Fp8KVCacheDataType KV_DTYPE,
bool IS_BLOCK_SPARSE,
int PARTITION_SIZE = 0> // Zero means no partitioning.
__device__ void paged_attention_kernel(
float* __restrict__ exp_sums, // [num_seqs, num_heads, max_num_partitions]
float* __restrict__ max_logits, // [num_seqs, num_heads, max_num_partitions]
scalar_t* __restrict__ out, // [num_seqs, num_heads, max_num_partitions, head_size]
const scalar_t* __restrict__ q, // [num_seqs, num_heads, head_size]
const cache_t* __restrict__ k_cache, // [num_blocks, num_kv_heads, head_size/x, block_size, x]
const cache_t* __restrict__ v_cache, // [num_blocks, num_kv_heads, head_size, block_size]
const int num_kv_heads, const float scale,
const int* __restrict__ block_tables, // [num_seqs, max_num_blocks_per_seq]
const int* __restrict__ seq_lens, // [num_seqs]
const int max_num_blocks_per_seq, ...)
paged_attention_v1.cu 的 launcher 用 dim3 grid(num_heads, num_seqs, 1)、NUM_THREADS = 128:一个 thread block 处理一个 (seq, head),4 个 warp。kernel 内部分三段。
第一段:QK。 线程被组织成 thread group,THREAD_GROUP_SIZE = max(32 / BLOCK_SIZE, 1):BLOCK_SIZE = 16 时每组 2 个线程,一个 warp 的 16 个 group 正好对应一个 block 的 16 个 token——每个 warp 一次处理一个 KV block,4 个 warp 轮转(block_idx += NUM_WARPS)。每个 group 负责一个 token 的完整 \(d = 128\) 维点积,组内线程各拿一半维度,每次读 16 字节(VEC_SIZE = 16 / (THREAD_GROUP_SIZE × sizeof(scalar_t)) = 4 个 BF16):
// csrc/attention/attention_kernels.cuh (vLLM v0.20.0), QK 段节选
const int* block_table = block_tables + seq_idx * max_num_blocks_per_seq;
for (int block_idx = start_block_idx + warp_idx; block_idx < end_block_idx;
block_idx += NUM_WARPS) {
const int64_t physical_block_number =
static_cast<int64_t>(block_table[block_idx]);
for (int i = 0; i < NUM_TOKENS_PER_THREAD_GROUP; i++) {
const int physical_block_offset = (thread_group_idx + i * WARP_SIZE) % BLOCK_SIZE;
const int token_idx = block_idx * BLOCK_SIZE + physical_block_offset;
K_vec k_vecs[NUM_VECS_PER_THREAD];
#pragma unroll
for (int j = 0; j < NUM_VECS_PER_THREAD; j++) {
const cache_t* k_ptr = k_cache + physical_block_number * kv_block_stride +
kv_head_idx * kv_head_stride + physical_block_offset * x;
const int vec_idx = thread_group_offset + j * THREAD_GROUP_SIZE;
const int offset1 = (vec_idx * VEC_SIZE) / x;
const int offset2 = (vec_idx * VEC_SIZE) % x;
k_vecs[j] = *reinterpret_cast<const K_vec*>(k_ptr + offset1 * BLOCK_SIZE * x + offset2);
}
float qk = scale * Qk_dot<scalar_t, THREAD_GROUP_SIZE>::dot(q_vecs[thread_group_offset], k_vecs);
if (thread_group_offset == 0) {
const bool mask = token_idx >= seq_len;
logits[token_idx - start_token_idx] = mask ? 0.f : qk;
qk_max = mask ? qk_max : fmaxf(qk_max, qk);
}
}
}
K cache 一个 (block, kv_head) 内的布局 [head_size/x][block_size][x]
BF16: x = 8(16 B),block_size = 16,head_size = 128 → 16 个 dim 组
token 0 token 1 token 2 ... token 15
dim 组 0 (d 0-7) [ 16 B ] [ 16 B ] [ 16 B ] ... [ 16 B ] ← 16 个 token 的同一组 dim 连续 = 256 B
dim 组 1 (d 8-15) [ 16 B ] [ 16 B ] [ 16 B ] ... [ 16 B ]
...
dim 组 15 [ 16 B ] [ 16 B ] [ 16 B ] ... [ 16 B ]
一个 warp = 16 个 thread group(每组 2 线程)= 16 个 token:
第 j 轮,group g 的线程 0 读 token g 的 dim 组 2j,线程 1 读 dim 组 2j+1
→ 32 段 16 B 落在两行上,每行 256 B 连续 = 2 条 cache line,完美合并
若按 [block_size][head_size](token 主序)存:16 个 token 的同一 dim 组相隔 256 B,
warp 的 16 次访问散在 16 条 cache line 上
注意 K cache 的布局 [num_blocks, num_kv_heads, head_size/x, block_size, x],其中 x = 16 / sizeof(cache_t)(BF16 时 8)。它把 head dim 拆成 head_size/x 组,每组 x 个元素,同一 token 的 x 个连续元素放在一起(最内维),而 block_size 个 token 在倒数第二维。这样一个 thread group 读一个 token 的第 offset1 组时,读到的是 16 字节连续数据;同一个 warp 的 16 个 group 读 16 个 token 的同一组,地址是连续的 16 × 16 = 256 字节——正好是 2 条 cache line,完美合并。这是 KV cache 布局要服从 kernel 访存模式的直接例子,也是为什么这个布局被叫作 “x-major”。
Qk_dot::dot 在组内做 shuffle 归约得到完整点积(attention_utils.cuh)。每个 group 的 0 号线程把 logit 写进 shared memory 的 logits[](FP32),并更新自己的 qk_max。
第二段:softmax。 先 warp 内 shuffle 归约 qk_max,再通过 red_smem 跨 warp 归约得到全序列最大值;然后所有线程 strided 遍历 logits[],原地替换为 \(e^{x - m}\) 并累加 exp_sum(block_sum),再乘 inv_sum 归一化。这是标准的 shared memory 两级 reduction,不是 online softmax——因为一个 block 拿到了整个序列的 logits(最长 padded_max_seq_len × 4 字节的 shared memory,这也限制了 v1 能处理的序列长度)。
第三段:PV。 每个 warp 再次遍历自己负责的 KV block,读 V(布局 [num_blocks, num_kv_heads, head_size, block_size],token 在最内维,每线程读 16 字节即 8 个 token 的同一个 head dim),乘上 logits[] 里对应的概率,累加到寄存器 accs[NUM_ROWS_PER_THREAD](每线程负责 head dim 的 8 行)。最后 warp 内 shuffle 归约、跨 warp 通过 shared memory 二分归约、warp 0 写出。
3. v2:partition 就是 split-KV
v1 的并行度是 num_seqs × num_heads 个 block。decode 时 batch 8、32 head 只有 256 个 block,每个 128 线程;108 个 SM 每个能驻留 2048 线程(16 个这样的 block),也就是只用到了硬件并发能力的 15% 左右,带宽拉不满。而 memory-bound kernel 的唯一目标就是拉满带宽。
%% 图:paged attention v1 与 v2:v1 一个 block 走完整个序列,v2 按 512 token 分 partition 再用 reduce kernel 合并
flowchart LR
subgraph v1["v1:grid (heads, seqs),一个 block 走完整个序列"]
direction TB
a1["block (h, s)<br/>遍历 KV block 0 … n"] --> b1["整序列 logits 进 smem<br/>两级 reduction 得 m, l"] --> c1["PV 累加 → O"]
end
subgraph v2["v2:grid (heads, seqs, partitions),每 512 token 一个 block"]
direction TB
p0["block (h, s, 0)<br/>token 0–511"] --> r0["(m₀, l₀, O₀)"]
p1["block (h, s, 1)<br/>token 512–1023"] --> r1["(m₁, l₁, O₁)"]
p2["block (h, s, 2)<br/>…"] --> r2["(m₂, l₂, O₂)"]
r0 & r1 & r2 --> red["reduce kernel<br/>m = max mᵢ<br/>l = Σ lᵢ·e^(mᵢ−m)<br/>O = Σ Oᵢ·lᵢ·e^(mᵢ−m) / l"]
end
v1 ~~~ v2
classDef lo fill:#fde2e2,stroke:#c0392b
classDef hi fill:#dff5e1,stroke:#1e8449
class a1,b1,c1 lo
class p0,p1,p2,r0,r1,r2,red hi
v1 的并行度是 seqs × heads 个 block;v2 乘上 partition 数,用一个额外的小 kernel 把各段的 \((m, l, O)\) 按 online softmax 的合并公式拼回来。paged_attention_v2.cu 的解法是 PARTITION_SIZE = 512:把序列按 512 个 token 切成 max_num_partitions 段,grid 变成 (num_heads, num_seqs, max_num_partitions),每个 block 只处理自己那 512 个 token(32 个 KV block),各自算出局部的 \((m, l, O)\) 写进 max_logits、exp_sums、tmp_out。然后 paged_attention_v2_reduce_kernel 合并:
// csrc/attention/attention_kernels.cuh (vLLM v0.20.0), reduce kernel 节选
// 全局最大值 max_logit 已由两级归约得到
float global_exp_sum = 0.0f;
for (int i = threadIdx.x; i < num_partitions; i += blockDim.x) {
float l = shared_max_logits[i];
float rescaled_exp_sum = exp_sums_ptr[i] * expf(l - max_logit);
global_exp_sum += rescaled_exp_sum;
shared_exp_sums[i] = rescaled_exp_sum;
}
__syncthreads();
global_exp_sum = block_sum<NUM_WARPS>(&red_smem[NUM_WARPS], global_exp_sum);
const float inv_global_exp_sum = __fdividef(1.0f, global_exp_sum + 1e-6f);
// Aggregate tmp_out to out.
for (int i = threadIdx.x; i < HEAD_SIZE; i += NUM_THREADS) {
float acc = 0.0f;
for (int j = 0; j < num_partitions; ++j) {
acc += to_float(tmp_out_ptr[j * HEAD_SIZE + i]) * shared_exp_sums[j] * inv_global_exp_sum;
}
from_float(out_ptr[i], acc);
}
这就是 online softmax 的合并公式:两段各有 \((m_a, l_a, O_a)\)、\((m_b, l_b, O_b)\)(\(O\) 已归一化或未归一化都可以推),合并后 \(m = \max(m_a, m_b)\),\(l = l_a e^{m_a - m} + l_b e^{m_b - m}\),\(O = (O_a l_a e^{m_a - m} + O_b l_b e^{m_b - m}) / l\)。它与第二节 tile 间的 rescale 是同一个公式,只是两个操作数换成了两个并行 block 的结果。
这个思路在 FlashAttention 仓库里叫 Flash-decoding(split-KV):decode 时 \(\text{batch} \times \text{heads}\) 不够填满 SM,就把 KV 序列切成 \(S\) 段,\(S\) 倍的 block 并行,最后一个小 kernel 合并。FA2/FA3 的 num_splits 参数、FlashInfer 的 split-KV plan、vLLM Triton kernel 的 “3D” 模式(NUM_SEGMENTS_PER_SEQ)都是同一个东西。vLLM 还有一个通用的 merge_attn_states kernel(csrc/attention/merge_attn_states.cu)专门做两段结果的合并,接口是 (prefix_output, prefix_lse, suffix_output, suffix_lse)——以 logsumexp 形式传 \((m, l)\),用 p_scale = e^{lse_p - lse_{max}} / (e^{lse_p - lse_{max}} + e^{lse_s - lse_{max}}) 加权。它被用在 cascade attention(多个请求共享前缀时,前缀部分算一次、后缀各算一次再 merge)和 chunked prefill 中。
顺带一句版本事实:在 v0.20.0 的 CUDA 路径上,V1 引擎的 attention 已经交给 FlashAttention、FlashInfer 或 Triton 后端;这套 paged_attention_v1/v2 主要还在 ROCm 等路径(rocm_aiter_fa.py)里使用。但它是理解”分页 KV 如何被 kernel 访问”最直接的教材。
七、变长 batch、因果掩码与 sliding window
1. cu_seqlens:无 padding 的 packed 布局
推理时一个 batch 里各请求长度不同。如果按最长的 pad 成 [B, N_max, d],短序列的 padding 位置白白浪费计算和显存。FlashAttention 的 varlen 接口改用 packed 布局:所有序列的 token 首尾相接排成 [total_tokens, H, d],另给一个前缀和数组:
padded [B, N_max, d](N_max = 5) packed [total_tokens, d] + cu_seqlens
seq 0 [ a0 a1 a2 · · ] ← 2 个 pad [ a0 a1 a2 │ b0 b1 b2 b3 b4 │ c0 c1 ]
seq 1 [ b0 b1 b2 b3 b4 ] ▲ ▲ ▲ ▲
seq 2 [ c0 c1 · · · ] ← 3 个 pad 0 3 8 10
15 个槽位,5 个是 pad(浪费 1/3)
seq_lens = [3, 5, 2]
cu_seqlens = [0, 3, 8, 10] # 长度 B+1
block 拿到 b 后: 行范围 [cu_seqlens[b], cu_seqlens[b+1])
kernel 里一个 block 拿到序列编号 \(b\) 后,用 cu_seqlens[b] 和 cu_seqlens[b+1] 找到自己 \(Q\) 的起止行、用 cu_seqlens_k 找到 \(K, V\) 的起止;grid 的 Q-block 维要覆盖 \(\sum_b \lceil N_b / B_r \rceil\)。vLLM 的 Triton kernel 用 query_start_len_ptr 上的二分查找(find_seq_idx)把一个一维的 program_id(0) 映射回 (seq_idx, q_block_local_idx);FA2 的 flash_attn_varlen_func 则把 grid 按 max_seqlen_q / B_r × batch 开、越界的 block 直接返回。vLLM 调用 FA 时,k/v 传的是整个 paged KV cache,KV 侧的长度用 seqused_k 而非 cu_seqlens_k 给出,再配 block_table 做分页寻址(vllm/v1/attention/backends/flash_attn.py)——这是 vLLM 维护的 vllm-flash-attn fork 加进 FA 的能力。
2. 因果掩码在分块中的处理
N = 8 块(B_r = B_c),S 按块划分: 同一张图加 sliding window(W = 3 块):
Q块↓ K块→ 0 1 2 3 4 5 6 7 Q块↓ K块→ 0 1 2 3 4 5 6 7
0 [ ◪ ][ ][ ][ ][ ][ ][ ][ ] 0 [ ◪ ][ ][ ][ ][ ][ ][ ][ ]
1 [ █ ][ ◪ ][ ][ ][ ][ ][ ][ ] 1 [ █ ][ ◪ ][ ][ ][ ][ ][ ][ ]
2 [ █ ][ █ ][ ◪ ][ ][ ][ ][ ][ ] 2 [ ◩ ][ █ ][ ◪ ][ ][ ][ ][ ][ ]
3 [ █ ][ █ ][ █ ][ ◪ ][ ][ ][ ][ ] 3 [ ][ ◩ ][ █ ][ ◪ ][ ][ ][ ][ ]
4 [ █ ][ █ ][ █ ][ █ ][ ◪ ][ ][ ][ ] 4 [ ][ ][ ◩ ][ █ ][ ◪ ][ ][ ][ ]
5 [ █ ][ █ ][ █ ][ █ ][ █ ][ ◪ ][ ][ ] 5 [ ][ ][ ][ ◩ ][ █ ][ ◪ ][ ][ ]
6 [ █ ][ █ ][ █ ][ █ ][ █ ][ █ ][ ◪ ][ ] 6 [ ][ ][ ][ ][ ◩ ][ █ ][ ◪ ][ ]
7 [ █ ][ █ ][ █ ][ █ ][ █ ][ █ ][ █ ][ ◪ ] 7 [ ][ ][ ][ ][ ][ ◩ ][ █ ][ ◪ ]
█ 完整块:无 mask,纯 GEMM + softmax(主循环第一段) 每个 Q 块只跑 W 个 tile:O(N²) → O(N·W)
◪ 对角块:块内逐元素 mask(主循环第二段) ◩ 左边界块:块内 mask 掉 j < i − W + 1
空白:整块跳过,不读 K/V、不算 右上仍然整块跳过
→ FLOPs 与 KV 读取都省约一半
因果 attention 里第 \(i\) 行只看 \(j \le i\) 列。对分块实现,这意味着 \(Q\) 块 \(i\) 只需要遍历 \(K\) 块 \(j \le i\)(上三角的块整个跳过),FLOPs 与 KV 读取量都省一半;只有对角块(\(j = i\),或 \(B_r \ne B_c\) 时的几个跨对角块)内部才需要逐元素 mask。所以实现上主循环分成两段:先跑 off-diagonal 的块(无 mask,纯 GEMM + softmax),再跑对角块(有 mask)。把 mask 分支从主循环里拿掉,让编译器为绝大多数迭代生成无分支的代码,这是 FA2 和 Triton tutorial 都采用的结构。
在 vLLM 这类 “context + 新 query” 的形态下,第 \(q\) 个新 token 的绝对位置是 context_len + q,它能看到的 key 范围是 [0, context_len + q];一个 \(Q\) 块能看到的最长前缀是块内最后一行的位置加一,kernel 据此算出要遍历的 tile 数(compute_tile_loop_bounds 里的 max_seq_prefix_len)。
3. sliding window
sliding window(Mistral 等模型用,窗口 \(W\))让第 \(i\) 行只看 \([i - W + 1, i]\)。分块上就是同时裁掉左边的块:只遍历与 \([i_{\min} - W + 1, i_{\max}]\) 相交的 KV 块(\(i_{\min}, i_{\max}\) 是本 \(Q\) 块的行范围),块内再逐元素 mask 掉 \(j < i - W + 1\) 的部分。vLLM 的 compute_tile_loop_bounds 里 tile_start = max(0, first_allowed_key // TILE_SIZE)、tile_end = (last_allowed_key // TILE_SIZE) + 1 就是这个裁剪;FA2 的 window_size=(left, right) 参数在 n_block_min 上做同样的事。上下文远长于 \(W\) 时,每个 \(Q\) 块只做 \(W / B_c\) 次 tile 迭代,attention 成本从 \(O(N^2)\) 降为 \(O(NW)\)。
一个细节:window 裁剪后,一行的某个 tile 可能整行被 mask(全 \(-\infty\)),此时 \(m_{\text{new}}\) 若仍是 \(-\infty\) 会让 \(e^{S - m_{\text{new}}}\) 变成 NaN。vLLM 的 softmax_step 里 m_j = tl.where(m_j > -inf, m_j, 0.0) 就是防这个。后面自己写的 kernel通过保证”每行访问的第一个 tile 至少有一个合法列”来规避,但在窗口场景下必须显式处理。
八、Triton 版 FlashAttention 与 CUDA 版的结构对照
1. Triton tutorial 06 的结构
Triton 官方 tutorial 06(fused attention)是最容易读懂的 FlashAttention-2 实现,结构如下:
_attn_fwd:一个 program 处理一个 (batch, head, \(Q\) 块),载入 \(q\),初始化m_i = -inf、l_i、acc;因果模式下调两次_attn_fwd_inner——STAGE=1跑 off-diagonal 块(无 mask),STAGE=2跑对角块(有 mask);最后acc / l_i,写O与lse;_attn_fwd_inner:for start_n in range(lo, hi, BLOCK_N),每步tl.dot(q, k)→ mask →m_ij = max(m_i, max(qk))→p = exp2(qk - m_ij)→alpha = exp2(m_i - m_ij)→l_i = l_i * alpha + sum(p)→acc = acc * alpha[:, None]→acc = tl.dot(p.to(bf16), v, acc)→m_i = m_ij;- 两个数值技巧:用
tl.math.exp2而非exp(直接映射到ex2.approx指令,省一次乘法),并把 \(\log_2 e\) 提前乘进qk_scale = sm_scale * 1.44269504,这样 \(e^{x \cdot s} = 2^{x \cdot s \cdot \log_2 e}\),全程在 log2 域工作;lse输出时再换回自然对数。
它和 CUDA 版的对应关系:Triton 的 tl.dot(q, k) 对应 CuTe 的 gemm(tiled_mma, tSrQ, tSrK, acc_s);tl.max(qk, 1) 对应 quad_allreduce_ 那种 warp 内按行的 shuffle 归约;acc * alpha[:, None] 对应 softmax_rescale_o;p.to(bf16) 后直接 tl.dot(p, v) 对应 CUDA 里把 \(S\) 的累加器 fragment 重解释为下一个 mma 的 A fragment。Triton 隐藏了 shared memory 的搬运、双缓冲、cp.async、fragment 布局与 bank conflict,代价是 FA3 那种 producer/consumer warp specialization 与两个 warpgroup 的 ping-pong 只能依赖编译器自动调度(Triton 3.x 在 Hopper 上能自动用 wgmma 与 TMA,也在推进自动 warp specialization),程序员没有像 CUDA 那样的手工控制权。
2. 实践一:完整的 Triton FlashAttention 前向(因果 + GQA)
下面是本篇的第一个实践:一个支持因果掩码与 GQA 的 FlashAttention-2 风格前向 kernel。输入布局 [B, H, N, d],\(K, V\) 的 head 数 \(H_{kv}\) 可以小于 \(H_q\),query head h 映射到 KV head h // g(\(g = H_q / H_{kv}\))。要求 BLOCK_M % BLOCK_N == 0、head dim 为 2 的幂。
# flash_attn_triton.py
import math
import torch
import torch.nn.functional as F
import triton
import triton.language as tl
LOG2E = 1.4426950408889634
LN2 = 0.6931471805599453
@triton.jit
def _attn_fwd_inner(
acc, l_i, m_i, q,
K_base, V_base, stride_kn, stride_vn,
offs_m, offs_d, qk_scale,
lo, hi, N_CTX,
BLOCK_N: tl.constexpr,
MASKED: tl.constexpr,
):
# 遍历 [lo, hi) 范围内的 KV 块;lo 是 BLOCK_N 的倍数
for start_n in range(lo, hi, BLOCK_N):
start_n = tl.multiple_of(start_n, BLOCK_N)
offs_n = start_n + tl.arange(0, BLOCK_N)
kv_mask = offs_n < N_CTX
# K^T tile: [HEAD_DIM, BLOCK_N]
k = tl.load(K_base + offs_n[None, :] * stride_kn + offs_d[:, None],
mask=kv_mask[None, :], other=0.0)
# S = Q K^T,FP32 累加,已含 1/sqrt(d) 与 log2(e)
qk = tl.dot(q, k) * qk_scale
if MASKED:
allowed = (offs_m[:, None] >= offs_n[None, :]) & kv_mask[None, :]
else:
allowed = kv_mask[None, :]
qk = tl.where(allowed, qk, float("-inf"))
# online softmax:新的行最大值、本块概率、修正因子
m_ij = tl.maximum(m_i, tl.max(qk, 1))
p = tl.math.exp2(qk - m_ij[:, None])
alpha = tl.math.exp2(m_i - m_ij)
l_i = l_i * alpha + tl.sum(p, 1)
acc = acc * alpha[:, None]
# V tile: [BLOCK_N, HEAD_DIM];P 转成 V 的 dtype 后直接进第二个 GEMM
v = tl.load(V_base + offs_n[:, None] * stride_vn + offs_d[None, :],
mask=kv_mask[:, None], other=0.0)
acc = tl.dot(p.to(v.dtype), v, acc)
m_i = m_ij
return acc, l_i, m_i
@triton.jit
def _attn_fwd(
Q, K, V, O, Lse,
stride_qz, stride_qh, stride_qm,
stride_kz, stride_kh, stride_kn,
stride_vz, stride_vh, stride_vn,
stride_oz, stride_oh, stride_om,
H_Q, N_CTX, sm_scale,
GROUP: tl.constexpr, # H_Q // H_KV
HEAD_DIM: tl.constexpr,
BLOCK_M: tl.constexpr,
BLOCK_N: tl.constexpr,
IS_CAUSAL: tl.constexpr,
):
start_m = tl.program_id(0)
off_hz = tl.program_id(1)
off_z = off_hz // H_Q
off_hq = off_hz % H_Q
off_hk = off_hq // GROUP # GQA:query head -> kv head
Q_base = Q + off_z * stride_qz + off_hq * stride_qh
K_base = K + off_z * stride_kz + off_hk * stride_kh
V_base = V + off_z * stride_vz + off_hk * stride_vh
O_base = O + off_z * stride_oz + off_hq * stride_oh
offs_m = start_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_d = tl.arange(0, HEAD_DIM)
q_mask = offs_m < N_CTX
q = tl.load(Q_base + offs_m[:, None] * stride_qm + offs_d[None, :],
mask=q_mask[:, None], other=0.0)
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, HEAD_DIM], dtype=tl.float32)
qk_scale = sm_scale * 1.4426950408889634
if IS_CAUSAL:
diag_lo = start_m * BLOCK_M
# 阶段一:对角线左侧的块,无 mask
acc, l_i, m_i = _attn_fwd_inner(
acc, l_i, m_i, q, K_base, V_base, stride_kn, stride_vn,
offs_m, offs_d, qk_scale, 0, diag_lo, N_CTX,
BLOCK_N=BLOCK_N, MASKED=False)
# 阶段二:对角块,逐元素 mask
acc, l_i, m_i = _attn_fwd_inner(
acc, l_i, m_i, q, K_base, V_base, stride_kn, stride_vn,
offs_m, offs_d, qk_scale, diag_lo, tl.minimum(diag_lo + BLOCK_M, N_CTX), N_CTX,
BLOCK_N=BLOCK_N, MASKED=True)
else:
acc, l_i, m_i = _attn_fwd_inner(
acc, l_i, m_i, q, K_base, V_base, stride_kn, stride_vn,
offs_m, offs_d, qk_scale, 0, N_CTX, N_CTX,
BLOCK_N=BLOCK_N, MASKED=False)
acc = acc / l_i[:, None]
# logsumexp(自然对数),供反向 recompute 使用
lse = (m_i + tl.math.log2(l_i)) * 0.6931471805599453
tl.store(Lse + off_hz * N_CTX + offs_m, lse, mask=q_mask)
tl.store(O_base + offs_m[:, None] * stride_om + offs_d[None, :],
acc.to(O.dtype.element_ty), mask=q_mask[:, None])
def flash_attn_fwd(q, k, v, causal=True, sm_scale=None,
BLOCK_M=128, BLOCK_N=64, num_warps=8, num_stages=3):
"""q: [B, Hq, N, d]; k, v: [B, Hkv, N, d]; 返回 (o, lse)。"""
B, Hq, N, D = q.shape
Hkv = k.shape[1]
assert k.shape == v.shape and k.shape[0] == B and k.shape[2] == N and k.shape[3] == D
assert Hq % Hkv == 0 and D in (16, 32, 64, 128, 256)
assert BLOCK_M % BLOCK_N == 0
assert q.stride(-1) == 1 and k.stride(-1) == 1 and v.stride(-1) == 1
if sm_scale is None:
sm_scale = 1.0 / math.sqrt(D)
o = torch.empty_like(q)
lse = torch.empty((B, Hq, N), device=q.device, dtype=torch.float32)
grid = (triton.cdiv(N, BLOCK_M), B * Hq)
_attn_fwd[grid](
q, k, v, o, lse,
q.stride(0), q.stride(1), q.stride(2),
k.stride(0), k.stride(1), k.stride(2),
v.stride(0), v.stride(1), v.stride(2),
o.stride(0), o.stride(1), o.stride(2),
Hq, N, sm_scale,
GROUP=Hq // Hkv, HEAD_DIM=D,
BLOCK_M=BLOCK_M, BLOCK_N=BLOCK_N, IS_CAUSAL=causal,
num_warps=num_warps, num_stages=num_stages,
)
return o, lse
几处值得逐行核对的地方:
- grid 与索引:
program_id(1)遍历 \(B \times H_q\),off_hk = off_hq // GROUP完成 GQA 映射;Lse是[B, Hq, N]连续张量,所以off_hz * N_CTX + offs_m正确。 - 不会出现 NaN:因果模式下每行访问的第一个 tile 是
start_n = 0(阶段一)或start_n = diag_lo(阶段二,此时阶段一为空),而diag_lo <= offs_m对块内所有行成立且diag_lo < N_CTX,所以每行至少有一个合法列,m_ij有限。后续 tile 整行被 mask 时p = exp2(-inf) = 0、alpha = exp2(0) = 1,安全。N_CTX以外的 padding 行不写回。 - \(P\) 直接进第二个 GEMM:
p.to(v.dtype)后tl.dot(p, v, acc),\(S\)、\(P\) 从未离开寄存器。 tl.dot(a, b, acc)的三参数形式把累加融合进 mma,Triton 3.x 支持。- 数值域:
qk_scale已含 \(\log_2 e\),全程exp2;输出lse时乘 \(\ln 2\) 换回自然对数,与 FlashAttention 的softmax_lse语义一致。
3. 正确性与性能对照
对照 PyTorch 的 F.scaled_dot_product_attention(PyTorch 2.5 起支持 enable_gqa=True,v2.10.0 当然支持):
def check(B=2, Hq=32, Hkv=8, N=4096, D=128, causal=True, dtype=torch.bfloat16):
torch.manual_seed(0)
q = torch.randn(B, Hq, N, D, device="cuda", dtype=dtype)
k = torch.randn(B, Hkv, N, D, device="cuda", dtype=dtype)
v = torch.randn(B, Hkv, N, D, device="cuda", dtype=dtype)
ref = F.scaled_dot_product_attention(q, k, v, is_causal=causal, enable_gqa=True)
out, lse = flash_attn_fwd(q, k, v, causal=causal)
# BF16 输出、FP32 累加,两边 P 都在 BF16 下喂入第二个 GEMM,
# 逐元素误差量级约 1e-2,故 atol 从默认的 1e-5 放宽到 1e-2
torch.testing.assert_close(out, ref, rtol=1.6e-2, atol=1e-2)
# lse 与 FP32 参考对照:逐 (batch, head) 物化一个 N x N 的 S(64 MiB),
# 避免一次性分配 [B, Hq, N, N]
g = Hq // Hkv
causal_mask = torch.ones(N, N, device="cuda", dtype=torch.bool).triu(1)
for b in range(B):
for h in range(Hq):
s = (q[b, h].float() @ k[b, h // g].float().T) / math.sqrt(D)
if causal:
s = s.masked_fill(causal_mask, float("-inf"))
torch.testing.assert_close(lse[b, h], torch.logsumexp(s, dim=-1),
rtol=1e-3, atol=1e-3)
print("ok")
def bench(B=1, Hq=32, Hkv=8, N=4096, D=128, causal=True, dtype=torch.bfloat16):
q = torch.randn(B, Hq, N, D, device="cuda", dtype=dtype)
k = torch.randn(B, Hkv, N, D, device="cuda", dtype=dtype)
v = torch.randn(B, Hkv, N, D, device="cuda", dtype=dtype)
flops = 4 * B * Hq * N * N * D * (0.5 if causal else 1.0)
ms_tri = triton.testing.do_bench(lambda: flash_attn_fwd(q, k, v, causal=causal))
ms_ref = triton.testing.do_bench(
lambda: F.scaled_dot_product_attention(q, k, v, is_causal=causal, enable_gqa=True))
print(f"triton : {ms_tri:.3f} ms {flops / ms_tri / 1e9:.1f} TFLOPS")
print(f"sdpa : {ms_ref:.3f} ms {flops / ms_ref / 1e9:.1f} TFLOPS")
if __name__ == "__main__":
check()
bench()
lse 的对照代码里每个 s 是一个 [N, N] 的 FP32 矩阵——就是本文第一节说不该物化的那 64 MiB;测试代码恰好用最笨的方式把它物化出来做参考,这没问题,因为测试只跑一次。容差方面:输出 out 是 BF16,两边的 \(P\) 都在 BF16 下喂给第二个 GEMM,但块的划分不同(SDPA 后端的 tile 大小与本 kernel 不一定一致),累加顺序不同带来的差异在 \(10^{-2}\) 量级,所以 atol 从系列默认的 1e-5 放宽到 1e-2;lse 是 FP32 累加得到的标量,与 FP32 参考对照可以用严格得多的 1e-3。如果 out 的误差明显超过 1e-2,通常不是容差问题,而是 mask 边界或 GQA 映射写错了。
关于结果的预期:A100 上 N = 4096、causal、32 head、\(d = 128\),FLOPs 为 \(4 \times 32 \times 4096^2 \times 128 / 2 \approx 137\) GFLOP,compute 下界 \(137 / 312 \approx 0.44\) ms。SDPA 在 A100 上走 FlashAttention-2 后端,文献与经验中通常达到 50%–70% 的 BF16 峰值,即 0.6–0.9 ms 的量级;未调参的 Triton 版本通常落在 FA2 的 70%–90%,自动调优 BLOCK_M/BLOCK_N/num_warps/num_stages 后可以接近。差距来源是第七篇讨论过的那些:Triton 的软件流水与寄存器分配不如手写 CuTe 精细、对角块的 mask 分支、以及 \(P\) 转 BF16 的布局转换。
4. vLLM 的 triton_unified_attention:一个 kernel 统一 prefill 与 decode
vLLM 的 vllm/v1/attention/ops/triton_unified_attention.py 把上面这个 kernel 推广到了推理引擎需要的全部形态——分页 KV、变长、GQA 打包、sliding window、split-KV——而且用同一个 kernel 同时处理 prefill 和 decode。它的 grid 是 (total_num_q_blocks, num_kv_heads[, num_segments]),program_id(1) 是 KV head 而不是 query head,一个 tile 的 BLOCK_M 行由 BLOCK_Q 个 token × num_queries_per_kv 个 query head 拼成:
# vllm/v1/attention/ops/triton_unified_attention.py (vLLM v0.20.0), 节选
q_block_global_idx = tl.program_id(0)
kv_head_idx = tl.program_id(1)
segm_idx = tl.program_id(2) if IS_3D else 0
(seq_idx, q_block_local_idx, cur_batch_in_all_start_index,
cur_batch_query_len, seq_len) = resolve_seq_and_query_len(
query_start_len_ptr, seq_lens_ptr, q_block_global_idx, num_seqs, BLOCK_Q)
offs_m = tl.arange(0, BLOCK_M)
offs_d = tl.arange(0, HEAD_SIZE_PADDED)
offs_t = tl.arange(0, TILE_SIZE)
query_pos = q_block_local_idx * BLOCK_Q + offs_m // num_queries_per_kv
query_offset_0 = cur_batch_in_all_start_index + query_pos
query_offset_1 = kv_head_idx * num_queries_per_kv + offs_m % num_queries_per_kv
offs_m // num_queries_per_kv 是 token 位置、offs_m % num_queries_per_kv 是组内的 query head——这就是第四节说的”把 \(g\) 个 query head pack 进 mma 的 M 维”。host 侧 BLOCK_M = 16 if num_queries_per_kv <= 16 else next_power_of_2(num_queries_per_kv),BLOCK_Q = BLOCK_M // num_queries_per_kv:Llama-3-8B(\(g = 4\))时一个 tile 是 4 个 token × 4 个 head;decode 的 cur_batch_query_len = 1 时 tile 里只有 4 行有效,其余被 query_mask_0 屏蔽,但 \(K, V\) 无论如何只读一次。
KV 侧的分页寻址在主循环里:
# 同文件,主循环节选
for j in range(loop_lo, loop_hi):
seq_offset = j * TILE_SIZE + offs_t
tile_mask = seq_offset < max_seq_prefix_len
physical_block_idx = tl.load(
block_tables_ptr + block_table_offset + seq_offset // BLOCK_SIZE
).to(tl.int64)
k_offset = (physical_block_idx[None, :] * stride_k_cache_0
+ kv_head_idx * stride_k_cache_2
+ offs_d[:, None] * stride_k_cache_3
+ (seq_offset % BLOCK_SIZE)[None, :] * stride_k_cache_1)
K_load = tl.load(key_cache_ptr + k_offset,
mask=dim_mask[:, None] & tile_mask[None, :], other=0.0)
...
S = tl.zeros(shape=(BLOCK_M, TILE_SIZE), dtype=tl.float32)
S += scale * tl.dot(Q, K)
S = tl.where(query_mask_1[:, None] & query_mask_0[:, None] & seq_mask, S, float("-inf"))
M, L, P, alpha = softmax_step(S, M, L)
acc = acc * alpha[:, None]
acc += tl.dot(P.to(V.dtype), V)
一个 TILE_SIZE(prefill 32、decode 16)个 token 的 tile 可能跨多个 KV block,所以 physical_block_idx 是一个向量:每个 token 各自查表,然后 seq_offset % BLOCK_SIZE 给出块内偏移——这是 Triton 里表达 block table 间接寻址最直接的写法(gather 式 load)。softmax_step 就是本文的 online softmax 一步。prefill/decode 的统一在于:cur_batch_query_len 是多少行、context_len 是多少、要遍历多少 tile,全部是运行时量,由 cu_seqlens(query_start_len_ptr)与 seq_lens 决定;kernel 代码不区分两种形态。
decode 时它还有 “3D” 模式(IS_3D):当 batch 里没有 prefill 且序列数不超过阈值时,grid 加第三维 num_par_softmax_segments,每个 program 只处理自己 segment 内的 tile(compute_tile_loop_bounds 里的 loop_lo/loop_hi 裁剪),把未归一化的 acc 和 (M, L) 写进 segm_* 缓冲,再由 reduce_segments kernel 合并——与 paged_attention_v2 的 partition + reduce 完全同构。
5. 实践二:CUDA 版核心循环骨架
CUDA 版本这里只给核心循环的骨架(不是完整可编译代码,省略了 include、shared memory 的搬运与双缓冲、fragment 的具体寄存器映射与边界处理)。目的是标出 FA2 的关键决策在 CUDA 层面对应到哪些指令与布局:
// 骨架:一个 block 4 个 warp,处理 BLOCK_M = 64 行 Q(每 warp 16 行),
// 每次迭代处理 BLOCK_N = 64 列 K/V,d = 128。mma.sync m16n8k16 BF16。
// 每个 warp 持有:
// Q fragment : 16 x 128,A 操作数,整个 kernel 常驻寄存器(8 个 k16 片)
// S/P 累加器 : 16 x 64 FP32,8 个 n8 片,每线程 4 个 float/片 -> 32 个 float
// O 累加器 : 16 x 128 FP32,16 个 n8 片 -> 64 个 float
// m[2], l[2] : 本线程负责的两行(mma 累加器布局里每线程覆盖 row=lane/4 和 row+8)
__device__ void attn_1rowblock_warp(/* ... */) {
float acc_o[16][4] = {0}; // O 累加器 fragment
float m_row[2] = {-INFINITY, -INFINITY};
float l_row[2] = {0.f, 0.f};
for (int n_block = n_block_min; n_block < n_block_max; ++n_block) {
// (0) K_j, V_j 已由 cp.async 搬进 smem(双缓冲,另一组在飞)
// (1) S = Q K^T:8 个 n8 片 x 8 个 k16 片 = 64 条 mma
float acc_s[8][4] = {0};
for (int kk = 0; kk < 8; ++kk) // 沿 d 的 k16 片
for (int nn = 0; nn < 8; ++nn) // 沿 BLOCK_N 的 n8 片
mma_m16n8k16_bf16(acc_s[nn], frag_q[kk], ldmatrix_k(smem_k, nn, kk));
// (2) mask(仅对角块)+ 行最大值:本线程 4 个 float/片 覆盖两行各两列,
// 先在线程内取 max,再与同一行的另外 3 个 lane 用 shfl_xor(1), (2) 归约
float m_new[2];
for (int r = 0; r < 2; ++r) {
float mx = m_row[r];
for (int nn = 0; nn < 8; ++nn) mx = fmaxf(mx, fmaxf(acc_s[nn][2*r], acc_s[nn][2*r+1]));
mx = fmaxf(mx, __shfl_xor_sync(0xffffffff, mx, 1));
mx = fmaxf(mx, __shfl_xor_sync(0xffffffff, mx, 2));
m_new[r] = mx;
}
// (3) P = exp2(S*scale - m_new),行和;同时用 alpha 缩放 O 与 l(延迟归一化)
for (int r = 0; r < 2; ++r) {
float alpha = exp2f(m_row[r] - m_new[r]);
l_row[r] *= alpha;
for (int nn = 0; nn < 16; ++nn) { acc_o[nn][2*r] *= alpha; acc_o[nn][2*r+1] *= alpha; }
float rs = 0.f;
for (int nn = 0; nn < 8; ++nn) {
acc_s[nn][2*r] = exp2f(acc_s[nn][2*r] * scale_log2e - m_new[r]);
acc_s[nn][2*r+1] = exp2f(acc_s[nn][2*r+1] * scale_log2e - m_new[r]);
rs += acc_s[nn][2*r] + acc_s[nn][2*r+1];
}
rs += __shfl_xor_sync(0xffffffff, rs, 1);
rs += __shfl_xor_sync(0xffffffff, rs, 2);
l_row[r] += rs; m_row[r] = m_new[r];
}
// (4) P 累加器 -> A fragment:m16n8k16 的 C 布局(每线程 2 行 x 2 列)与
// A 布局(每线程 2 行 x 2x2 列)在 n8 片两两拼接后恰好对齐,
// 只需把相邻两个 n8 片的 FP32 pack 成 4 个 bf16x2,无需经过 smem
uint32_t frag_p[4][4]; // 4 个 k16 片(BLOCK_N = 64 = 4 x 16)
pack_c_to_a_bf16(acc_s, frag_p);
// (5) O += P V:4 个 k16 片 x 16 个 n8 片 = 64 条 mma
for (int kk = 0; kk < 4; ++kk)
for (int nn = 0; nn < 16; ++nn)
mma_m16n8k16_bf16(acc_o[nn], frag_p[kk], ldmatrix_v_trans(smem_v, nn, kk));
// (6) __syncthreads() + 切换 smem 双缓冲、发起下一块的 cp.async
}
// 收尾:O /= l(每行一次除法),写回;lse = (m + log2(l)) * ln2
}
步骤 (4) 的 C→A fragment 复用值得单独画出来——这是 \(S \to P \to PV\) 能全程留在寄存器的原因:
mma.m16n8k16(BF16 → FP32)每个 lane 持有的元素:g = lane/4 (0..7), t = lane%4
C/D 累加器(16×8 FP32)一个 n8 片 A 操作数(16×16 BF16)一个 k16 片
列 2t 2t+1 列 2t 2t+1 2t+8 2t+9
行 g [ c0 c1 ] 行 g [ a0 a1 ] [ a4 a5 ]
行 g+8 [ c2 c3 ] 行 g+8 [ a2 a3 ] [ a6 a7 ]
└─reg0──┘ └─reg2──┘ (行 g)
└─reg1──┘ └─reg3──┘ (行 g+8)
S 的 8 个 n8 片 → P 的 4 个 k16 片:相邻两片拼一片,寄存器内 pack 成 bf16x2
frag_p[kk].reg0 = pack(acc_s[2kk][0], acc_s[2kk][1]) // 行 g, 列 0-7
frag_p[kk].reg1 = pack(acc_s[2kk][2], acc_s[2kk][3]) // 行 g+8, 列 0-7
frag_p[kk].reg2 = pack(acc_s[2kk+1][0], acc_s[2kk+1][1]) // 行 g, 列 8-15
frag_p[kk].reg3 = pack(acc_s[2kk+1][2], acc_s[2kk+1][3]) // 行 g+8, 列 8-15
同一 lane 在 C 里持有的 (行, 列) 与它要为 A 提供的 (行, k) 重合 → 不经 smem
标出几个复用点:(a) \(Q\) 的 A fragment 全程常驻寄存器,这是 “外循环遍历 Q 块” 的直接后果;(b) 步骤 (4) 的 C→A fragment 复用是 FA2 能在寄存器内完成 \(S \to P \to PV\) 的关键——m16n8k16 的累加器布局中每线程持有第 lane/4 行与第 lane/4 + 8 行的两对相邻元素,与 A 操作数布局中每线程持有的位置重合,只差一次 FP32→BF16 的 pack;(c) 行归约只需 shfl_xor 1 和 2 两步,因为 mma 布局里一行的 8 列分布在同一 quad 的 4 个 lane 上(”split Q” 让归约不出 warp);(d) \(V\) 需要转置载入(ldmatrix.trans),因为 \(PV\) 的 B 操作数要求 k-major——这就是 FA3 在 FP8 下需要显式重排 \(V\) 布局的原因(FP8 的 ldmatrix 没有对应的转置形式)。
九、后端生态与 vLLM 的选择
到 2026 年 5 月,生产环境里的 attention kernel 主要来自以下几家:
- FlashAttention-2 / 3(Dao AI Lab):
flash_attn_func/flash_attn_varlen_func,CUDA + CuTe 手写;FA2 支持 sm_80+,FA3 只支持 sm_90。原版不支持分页 KV,vLLM 维护的 vllm-flash-attn fork 给 varlen 接口加了block_table与seqused_k参数(上一节引用的调用),并保留 FA3 的scheduler_metadata(提前算好 split 策略,便于 CUDA graph)。 - FlashInfer:面向推理的 attention 库,JIT 编译 kernel 变体,
plan()/run()两段式接口(plan在 CPU 侧做 split-KV 的负载均衡与 workspace 分配,run只发 kernel),decode 与 paged prefill 是它的专长;vLLM 的flashinfer.py后端用BatchPrefillWithPagedKVCacheWrapper与BatchDecodeWithPagedKVCacheWrapper。 - xFormers:Meta 的
memory_efficient_attention,早期 vLLM 的默认后端之一,现在主要用于视觉模型等场景。 - cuDNN attention:NVIDIA 在 cuDNN 8.9+ 提供的 fused attention(Hopper 上很强),是 PyTorch
scaled_dot_product_attention的后端之一(与 FlashAttention-2 后端、memory-efficient 后端、math 后端并列,由torch.backends.cuda的开关和形状约束决定走哪个)。 - Triton 实现:vLLM 的
triton_unified_attention.py(上一节)、SGLang 的 Triton 后端、Triton tutorial 06 及其衍生。可移植性最好(ROCm 直接可用),性能在 Ampere 上通常落后手写 FA2 一到三成,在 Hopper 上落后 FA3 更多。 - PyTorch 的
scaled_dot_product_attention:不是一个独立 kernel 而是一个分发器,按输入的 dtype、head dim、是否有 mask、是否需要梯度等条件在 FlashAttention-2、cuDNN、memory-efficient(xFormers 派生)与 math 四个后端之间选择;torch.nn.attention.sdpa_kernel可以强制指定。本篇实践里的F.scaled_dot_product_attention(..., is_causal=True, enable_gqa=True)在 A100 上通常走 FlashAttention-2 后端,所以它既是正确性参考也是一个有意义的性能基线。
把上面几家按几个维度并排:
| 后端 | 实现方式 | 目标架构 | 分页 KV | 变长 / split-KV | 强项 | 在 vLLM v0.20.0 中 |
|---|---|---|---|---|---|---|
| FlashAttention-2 | CUDA + CuTe 手写,mma.sync |
sm_80+ | 原版无;vllm-flash-attn 加 block_table + seqused_k |
cu_seqlens;num_splits |
prefill,Tensor Core 利用率 | FLASH_ATTN(sm_80 默认) |
| FlashAttention-3 | CUDA + CuTe,wgmma / TMA / warp specialization | sm_90 only | 同上 | 同上,另有 scheduler_metadata 预先算 split |
Hopper prefill,FP8 | FLASH_ATTN(sm_90 默认,ALiBi 等回退 FA2) |
| FlashInfer | CUDA,JIT 编译变体,plan()/run() |
sm_80+ | 原生 paged | plan() 在 CPU 侧做 split-KV 负载均衡 |
decode、paged prefill | FLASHINFER(Blackwell 上排第一) |
| xFormers | CUDA(CUTLASS) | Ampere 前后均可 | 无 | 有 varlen | 通用、老硬件 | 早期默认,现主要视觉模型 |
| cuDNN attention | 闭源库,cuDNN 8.9+ | Ampere / Hopper,Hopper 上最强 | 无 | 有 | Hopper 训练 | 经 PyTorch SDPA 间接使用 |
| Triton 实现 | Triton(triton_unified_attention.py 等) |
任意,含 ROCm | 每 token 查表的 gather load | find_seq_idx 二分;3D 模式 segments |
可移植、易改(新 mask / KV 格式) | TRITON_ATTN(prefill/decode 统一) |
| PyTorch SDPA | 分发器,不是 kernel | 随后端 | 无 | 随后端 | 正确性参考 / 基线 | 本文测试的 reference |
选择的原则可以压缩成三条:prefill 追求 Tensor Core 利用率,优先 FA3(sm_90)或 FA2;decode 追求带宽利用率与并行度,split-KV 的策略质量比 GEMM 效率更重要,FlashInfer 与 FA3 的 scheduler 在这里下了最多功夫;需要非标准特性(新的 mask 形状、bias、KV 量化格式)时,Triton 版本的修改成本远低于 CUDA 版本,这是它在生产系统里一直有一席之地的原因。
%% 图:vLLM 选 attention 后端的流程:用户指定优先,否则按设备能力的优先级列表逐个 validate_configuration
flowchart TB
start["vLLM 启动:选 attention 后端"]
user{"--attention-backend /<br/>VLLM_ATTENTION_BACKEND 指定了?"}
forced["用指定后端<br/>(不通过校验则报错)"]
cap{"设备能力"}
order80["sm_80 / sm_90 优先级<br/>FLASH_ATTN → FLASHINFER → TRITON_ATTN → FLEX_ATTENTION"]
order100["sm_100+(Blackwell)优先级<br/>FLASHINFER → FLASH_ATTN → TRITON_ATTN → FLEX_ATTENTION"]
val["逐个调用 validate_configuration<br/>dtype · head size · block size · KV 量化 · ALiBi …"]
pick["取第一个通过的后端"]
fav{"FLASH_ATTN 内部:<br/>sm_90 且特性支持?"}
fa3["FA3"]
fa2["FA2"]
start --> user
user -- "是" --> forced
user -- "否" --> cap
cap -- "8.x / 9.x" --> order80 --> val
cap -- "10.x" --> order100 --> val
val --> pick --> fav
fav -- "是" --> fa3
fav -- "否(sm_80,或 ALiBi 等 FA3 不支持)" --> fa2
classDef d fill:#dde9f7,stroke:#2e6da4
classDef r fill:#dff5e1,stroke:#1e8449
class user,cap,fav d
class forced,pick,fa3,fa2 r
vLLM v0.20.0 的选择逻辑在 vllm/platforms/cuda.py:用户可用 --attention-backend(或环境变量 VLLM_ATTENTION_BACKEND)显式指定;不指定时按设备能力给出优先级列表,逐个调用各后端类的 validate_configuration 检查 dtype、head size、block size、KV cache 量化等约束,取第一个通过的。sm_80/sm_90 上的默认顺序是 FLASH_ATTN → FLASHINFER → TRITON_ATTN → FLEX_ATTENTION;Blackwell(compute capability 10.x)上把 FLASHINFER 提到最前。FLASH_ATTN 后端内部再由 fa_utils.get_flash_attn_version 决定 FA 版本:sm_90 优先 FA3,其余用 FA2(ALiBi 等 FA3 不支持的特性会回退到 FA2)。
十、本文小结
本篇把前七篇的工具用在了一个 kernel 上。回到核心问题:
标准 vs FlashAttention 的 HBM 读写(N = 4096、d = 128、单 head BF16):标准实现要物化 \(S\) 与 \(P\),\(N^2\) 项的流量至少 128 MiB、合计约 132 MiB,算术强度约 62 FLOP/byte,在 A100 上 memory-bound;FlashAttention 只读 \(Q\)、写 \(O\) 各 1 MiB,\(K, V\) 各重读 \(N / B_r\) 次,\(B_r = 128\) 时 64 MiB,理论 HBM 流量 \(\Theta(N^2 d^2 / M)\),而且这些重读大多命中 L2,实际 HBM 流量接近 4 MiB 的下界——attention 因此从 memory-bound 变成 compute-bound。它不省 FLOPs(还略多),省的是 IO。
decode 每 token 读多少 KV、这决定了什么:Llama-3-8B 每 token 128 KiB,上下文 \(s\) 时读 \(128\ \text{KiB} \times s\);算术强度约 \(g\) FLOP/byte(GQA 组大小),彻底 memory-bound。一步 decode 的时间下界是 (权重字节 + batch × s × 128 KiB) / 带宽:batch × s 超过约 131k token 时 KV 项超过权重项。这决定了 KV 量化、GQA、split-KV 这些手段的价值。
标准 attention vs FlashAttention(N=4096, d=128, 单 head, BF16, A100)
标准实现 FlashAttention (B_r=128)
Q/K/V/O 4 MiB Q 读 1 MiB + O 写 1 MiB
S (N^2) 写 32 + 读 32 MiB 不物化
P (N^2) 写 32 + 读 32 MiB 不物化(寄存器内)
K/V 重读 -- 32 次 x 2 MiB = 64 MiB(多数命中 L2)
合计(HBM 口径) ~132 MiB ~66 MiB(实际 HBM 接近 4 MiB)
FLOPs 8.6 GFLOP 8.6 GFLOP + rescale 小项
算术强度 ~62 FLOP/byte >=125(实际 ~2000)
Roofline memory-bound (~69 us) compute-bound (~28 us 下界)
FlashAttention 三代
并行维度 warp 分工 非 matmul 优化 硬件
FA1 (batch, head),外循环 K/V split-K -- sm_80
FA2 (batch, head, Q block) split-Q 延迟归一化、exp2 sm_80+
FA3 同 FA2 + warp specialization producer/consumer, 2 warpgroup ping-pong
GEMM-softmax 重叠、FP8 块量化 + 布局重排 sm_90 only
反向 只存 O 与 L = m + log l,重算 P = exp(S - L):省 N^2 显存,多 2N^2d FLOPs
prefill vs decode(Llama-3-8B, BF16)
Q 行数 每 token 读 KV 算术强度 瓶颈
prefill N_q(长) -- ~4N_q d / (...)高 Tensor Core
decode 1 128 KiB x s ~g (=4) HBM 带宽
decode 下界 (16 GB + batch x s x 128 KiB) / BW;batch x s > ~131k 时 KV 项占主导
vLLM PagedAttention kernel(v0.20.0, csrc/attention)
v1 grid (heads, seqs),128 线程,每 warp 一个 KV block,thread group 16 B 向量读 K
K 布局 [blocks, kv_heads, d/x, block_size, x],x = 16/sizeof(T)
logits 放 shared (FP32),两级 reduction softmax,再遍历 V 累加
v2 + PARTITION_SIZE=512,grid 加第三维,reduce kernel 用 online softmax 合并公式
merge_attn_states 两段 (O, lse) 的通用合并;Flash-decoding / split-KV 同构
后端(vLLM v0.20.0 CUDA 默认优先级)
FLASH_ATTN(sm_90 用 FA3,其余 FA2;vllm-flash-attn 加 block_table)
-> FLASHINFER(JIT, plan/run,decode 专长;Blackwell 上优先)
-> TRITON_ATTN(triton_unified_attention,prefill/decode 统一)
-> FLEX_ATTENTION
方法论上,这一篇再次验证了系列的主线:先数字节、再数 FLOPs、放到 Roofline 上看瓶颈在哪,再决定优化方向。FlashAttention 的每一代都对应瓶颈的一次转移——从 HBM 流量(FA1)到并行度与 warp 同步(FA2)再到 SFU 与 Tensor Core 的重叠(FA3);decode 的瓶颈从来都是带宽,所以 PagedAttention、split-KV、GQA 打包做的都是同一件事:让 KV 只读一次、让足够多的 SM 同时读。
十一、自测
-
\(N = 8192\)、\(d = 128\)、32 个 head、BF16:标准 attention 每层物化的 \(S\) 多大?FlashAttention 把它放在哪里?
答案
\(32 \times 8192^2 \times 2 = 4\) GiB;FlashAttention 每次只算一个 \(B_r \times B_c\)(如 128×64)的块,放在 shared / 寄存器里,用完丢弃。
-
FlashAttention 里 \(K, V\) 被重读多少次?\(B_r\) 由什么限制?
答案
\(N / B_r\) 次(\(Q\) 的每个行块各扫一遍全部 \(K, V\));\(B_r\) 受 shared memory 与寄存器限制——\(B_r \times d\) 的 \(Q\) 块、累加器 \(O\) 块与 \(K, V\) 块都要放进片上,A100 上 128 是常见值。
-
FlashAttention-2 相比 FA-1 改了什么让 Tensor Core 利用率从约 25–40% 到 50–70%?
答案
循环顺序换成外层 \(Q\) 块、内层 \(K, V\) 块(累加器留在寄存器,减少 shared 读写与重缩放次数);warp 之间按 \(Q\) 的行切分而不是按 \(K\) 切(去掉 warp 间归约);减少非 matmul 的 FLOPs(重缩放只在最后做)。
-
decode 阶段 batch 32、每条 8K 上下文、Llama-3-8B:一步读多少 KV cache?与权重比?时间下界多少(H100 3.35 TB/s)?
答案
\(32 \times 8192 \times 128\) KiB = 32 GiB,是权重 16 GB 的两倍;一步下界 (16 + 34) GB / 3.35 TB/s ≈ 15 ms——KV 已经是主要开销。
-
PagedAttention 的分页 KV 改变了什么、没改变什么?decode kernel 为什么要沿序列 split-K?
答案
改变:KV 以固定大小的 block 存放、kernel 通过 block table 查物理地址,消灭了预分配连续显存的碎片;不变:读的字节数一样多,仍是 memory-bound。batch 小、head 少时 block 数填不满 SM,把每个序列的 KV 切成多段并行读再归约(split-K),才能压满带宽。
下一篇
-
\(N = 4096\)、\(d = 128\)、单 head BF16:标准实现要物化 \(S = QK^T\) 与 \(P = \text{softmax}(S)\),两个 \(N^2\) 矩阵各写一次读一次,至少 128 MiB,加上 \(Q, K, V, O\) 共约 132 MiB;FLOPs \(4N^2 d = 8.6\) GFLOP,算术强度约 62,在 A100 上 memory-bound。FlashAttention 分块:\(Q\) 读一次、\(O\) 写一次各 1 MiB,\(K, V\) 各被重读 \(N / B_r\) 次(\(B_r = 128\) 时 32 次),共约 66 MiB、实际因 L2 更少;\(S\)、\(P\) 只存在于 shared / 寄存器,靠 online softmax 的 \((m, l)\) 逐块累加、最后一次重缩放——从 memory-bound 变成接近 compute-bound。详见第二章、第三章。 ↩
-
Llama-3-8B 每 token 的 KV 是 128 KiB,上下文 \(s\) 时每步读 \(128\ \text{KiB} \times s\),每读一个 K/V 元素只做 \(g\)(GQA 组大小,4)次 FLOP,彻底 memory-bound。这决定了:一步 decode 的时间下界 =(权重字节 + \(B \times s \times 128\) KiB)/ 带宽——\(B \times s\) 超过约 131k token 时 KV 流量超过权重,此后 decode 时间随上下文线性涨、与模型大小无关;所以 decode attention kernel 的目标是带宽利用率(split-K 沿序列并行填满 SM),PagedAttention 的分页只改地址计算不改字节数,KV 量化与 GQA / MLA 才能减字节。详见第五章、第六章。 ↩
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/attention-kernels-flashattention-and-pagedattention.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。