# 用 CuTe 读懂 FlashAttention:从一个 64 行的 Query 分块开始
第一次读 CuTe 写的 attention,往往还没看到 softmax,就被这几行拦住了:
```cpp
Tensor tCrQ = thr_mma.partition_fragment_A(sQ);
Tensor tXsQ = s2r_thr_copy_q.partition_S(sQ);
Tensor tXrQ = s2r_thr_copy_q.retile_D(tCrQ);
```
同一个 Q,为什么要准备这么多 tensor?`partition` 之后为什么还有 `retile`?它们什么时候真的搬了数据?
先看这几行最后要完成的事:一个线程块负责 64 个 query,把它们依次与 K、V 的各个分块计算,最后写出 64 行结果。上面的三个变量分别描述“计算需要哪些寄存器”“从 shared memory 的哪里读”“加载指令往哪些寄存器位置写”。真正加载时才调用 `copy`。
下面就跟着这 64 行 query 走一遍:先确定整个线程块的任务,再看数据怎样进入 shared memory 和寄存器,最后接上两个矩阵乘法之间的 softmax。遇到布局变换时,把当前线程实际持有的元素列出来,再读代码。
本文以 tiny-flash-attention 当前工作区的 [`flash_attention_cutlass/csrc/flash_attention.cu`](flash_attention_cutlass/csrc/flash_attention.cu) 为主线,对照 [`kernel_traits.h`](flash_attention_cutlass/csrc/kernel_traits.h) 和 [`utils.h`](flash_attention_cutlass/csrc/utils.h)。实现以 `dacda43` 为基础,当前工作区已统一变量命名;CuTe 接口另用本机 CUTLASS 4.8 检出版本 `0b55a2f6` 核对。仓库记录的 CUTLASS 子模块提交仍是 `751eb9a8`,因此下文会明确区分当前接口与旧教程里的写法。
阅读前只需要认识 CUDA 的 thread、warp、block,以及矩阵乘法和 softmax。这里选定 FP16、SM80 的指令路径,不展开 Hopper、Blackwell 专用的 attention 实现。后文的 C++ 片段用于对照源码,省略了重复的模板参数;能单独运行的例子会另行标明。
本文与项目中两份 CuTe 实现的局部变量均参照官方 [`sgemm_sm80.cu`](https://github.com/NVIDIA/cutlass/blob/0b55a2f6/examples/cute/tutorial/sgemm_sm80.cu) 命名:完整矩阵用 `mQ`,global memory 分块用 `gQ`,MMA 寄存器视图用 `tCrQ`,Copy 寄存器视图用 `tXrQ`。Q、K、V 等后缀保留 attention 的数据含义。旧版名称列在[文末对照表](#旧版命名与当前源码对照),方便阅读早期版本。
## 先固定一个能算清楚的例子
设 Q、K、V 都是连续存储的 `[B,H,L,D]` tensor:B 是 batch 数,H 是 head 数,L 是序列长度,D 是每个 head 的维度。先只看其中一个 batch、一个 head:
```text
Q、K、V:128×64
L = 128
D = 64
BlockM = 64
BlockN = 64
每个线程块:4 个 warp,共 128 个线程
`BlockM` 表示一次处理多少行 query,`BlockN` 表示一次读入多少行 key、value。这正是当前 Python 绑定路径使用的 `64×64、4 warp` 分块配置,head dimension 则由模板分派选择。
普通 attention 写成:
$$
S = sQK^T,\qquad P = \operatorname{softmax}_{\mathrm{row}}(S),\qquad O = PV,
其中通常取:
s = \frac{1}{\sqrt{D}}
本例的矩阵形状为:
Q[128,64] @ K.T[64,128] -> S[128,128]
P[128,128] @ V[128,64] -> O[128,64]
FlashAttention 将 K、V 分块读入,并在片上完成 softmax 和后面的矩阵乘法,使完整的 `128×128` 中间矩阵不必写入 global memory。序列更长时,省下的这份中间存储和读写才更可观;矩阵乘法的工作量仍随 L² 增长。
对于前 64 行 Q,线程块的任务是:
固定 Q[0:64,:]
第 0 轮:Q[0:64,:] @ K[0:64,:].T
更新 softmax 的状态
当前权重 @ V[0:64,:],更新输出分子
第 1 轮:Q[0:64,:] @ K[64:128,:].T
更新 softmax 的状态,并修正旧输出分子
当前权重 @ V[64:128,:],继续累加
最后:用分母归一化,写回 O[0:64,:]
这里有一个暂时留下的问题:第一轮还没见过后面的 key,softmax 的分母怎么确定?先记住源码保存了行最大值、分母和输出分子三份状态,等数据流走到 softmax 时再把它们算清楚。
## 这 64 行归哪个线程块,哪个线程
Q 的不同输出行可以独立计算,因此源码按 Q 的行分块并行启动线程块:
const int num_m_block =
(params.q_seqlen + Kernel_traits::kBlockM - 1)
/ Kernel_traits::kBlockM;
dim3 grid(num_m_block, params.bs * params.head, 1);
dim3 block(Kernel_traits::kNThreads);
本例 `B=H=1`,所以 grid 中有两个 block:
| 线程块 | 固定的 Q | 遍历的 K、V | 写回的 O |
|---|---|---|---|
| `blockIdx.x=0` | 第 0~63 行 | 全部 128 行 | 第 0~63 行 |
| `blockIdx.x=1` | 第 64~127 行 | 全部 128 行 | 第 64~127 行 |
**64 行 Q 是整个 block 的任务。** block 内的 128 个线程还要协作加载数据、执行矩阵乘法、做行归约。一个线程只保存其中一部分元素。
当前 MMA 配置把 4 个 warp 排在 M 方向,所以 warp 0 负责这个分块中的第 0~15 行,warp 1 负责第 16~31 行,依此类推。warp 内的线程编号称为 lane,取值为 0~31;这些线程再按 MMA 指令规定的方式分担各行。
同一批 64 行 query 会反复使用,每一轮只更换 K、V 分块。这样组织之后,Q 可以留在 shared memory,分数和输出累加器可以留在寄存器里。
## Tensor 先描述地址,local_tile 再选出分块
进入 kernel 后,先找到当前 batch、head 的起点,再把裸指针包装成 CuTe Tensor:
const int m_block = blockIdx.x;
const int base_id = blockIdx.y;
const int bs_head_offset = base_id * params.head_stride;
Tensor mQ = make_tensor(
make_gmem_ptr(reinterpret_cast<Element *>(params.q_ptr)
+ bs_head_offset),
make_shape(params.q_seqlen, Int<kHeadDim>{}),
make_stride(Int<kHeadDim>{}, Int<1>{}));
本例 `mQ` 的 shape 是 `(128,64)`,stride 是 `(64,1)`。因此访问 `mQ(m,d)`,对应的元素偏移为:
offset = m * 64 + d
Shape 描述每个维度有多大,Stride 描述该维度前进一步时偏移多少个元素,Layout 则把坐标映射到偏移。Tensor 在这套映射之外,再带上存储位置。
`mQ` 对应当前 batch、head 的完整 Q,K、V 的完整视图同样命名为 `mK`、`mV`。这里传入的是已有的 global memory 指针,`make_tensor` 创建访问视图。它没有另行分配一份 Q,也没有执行加载。后面遇到 `make_tensor<Element>(shape)` 时要留意:那种带元素类型、没有传入数据指针的写法,会创建拥有自己存储的 fragment。
代码里的 `_64`、`Int<64>` 表示编译期常量 64,`_` 表示保留这个维度。CuTe 可以利用这些静态信息,在编译时推导分块和布局。
接着,用 `local_tile` 取当前 block 的 Q 分块:
Tensor gQ = local_tile(
mQ,
make_tile(Int<kBlockM>{}, Int<kHeadDim>{}),
make_coord(m_block, _));
对 `mQ[128,64]` 使用 `tile[64,64]`,第一个分块坐标固定为 `m_block`,第二个分块坐标保留。所以本例的结果是:
gQ 的 shape:(64,64,1)
m_block=0:gQ(m,d,0) 对应 mQ(m,d)
m_block=1:gQ(m,d,0) 对应 mQ(64+m,d)
最后的 1 表示 D 方向只剩一个 tile。它不是 K、V 的迭代次数。源码每次更换 K 分块时,会重新调用:
gK = local_tile(
mK,
make_tile(Int<kBlockN>{}, Int<kHeadDim>{}),
make_coord(nbi + 1, _));
`local_tile` 也只产生视图。到现在,我们确定了要读哪一块数据,还没有把它搬进 shared memory。
## 128 个线程怎样把 Q 搬进 shared memory
CuTe 用 TiledCopy 描述一组线程如何协作拷贝。对于本例,`kernel_traits.h` 里的配置可以展开成:
using GmemTiledCopyQKV = decltype(make_tiled_copy(
Copy_Atom<SM80_CP_ASYNC_CACHEGLOBAL<cute::uint128_t>,
cutlass::half_t>{},
Layout<Shape<_16,_8>, Stride<_8,_1>>{},
Layout<Shape<_1,_8>>{}));
这里有三个部分:
| 配置 | 本例中规定的事情 |
|---|---|
| Copy Atom | 使用 128 位 `cp.async`,从 global memory 异步拷到 shared memory |
| Thread layout `(16,8):(8,1)` | 将 128 个线程组织为 16 行、每行 8 个线程 |
| Value layout `(1,8)` | 每个线程一次负责同行的 8 个 half,即 16 字节 |
于是一次覆盖 `16×64` 个元素:
逻辑第 0 行:thread 0 读列 0~7,thread 1 读列 8~15,……,thread 7 读列 56~63
逻辑第 1 行:thread 8 读列 0~7,……,thread 15 读列 56~63
……
逻辑第 15 行:thread 120~127
处理完整的 `64×64` Q tile,需要沿行方向重复四次。相邻线程读取相邻的 16 字节区间,形成连续的访问。
这些数字来自当前 `D=64` 的配置。源码实际上通过 `kBlockKSmem`、`kGmemElemsPerLoad` 等常量计算 Thread layout,换 D 后要重新推导。
### partition_S 和 partition_D 分别选出哪一端
kernel 中先取得当前线程的拷贝分工,再将它分别应用到源、目的 tensor:
typename Kernel_traits::GmemTiledCopyQKV copy_qkv;
auto thr_copy_qkv =
copy_qkv.get_thread_slice(threadIdx.x);
Tensor tQgQ = thr_copy_qkv.partition_S(gQ(_, _, 0));
Tensor tQsQ = thr_copy_qkv.partition_D(sQ);
flash::copy(copy_qkv, tQgQ, tQsQ);
`partition_S` 回答“当前线程从哪里读”,`partition_D` 回答“当前线程往哪里写”。它们返回各自的访问视图,最后一行才执行拷贝。`flash::copy` 是项目自己的小包装,会遍历分块中的剩余维度,再调用 `cute::copy`。
### 按官方例子的方式拆开变量名
官方 [GEMM 教程的命名说明](https://github.com/NVIDIA/cutlass/blob/0b55a2f6/media/docs/cpp/cute/0x_gemm_tutorial.md#copy-partitioning) 将 `tAsA` 读成“把分工 `tA` 应用到 tensor `sA`”。因此可以把一个名字拆成两段:
tQgQ = tQ + gQ:Q 拷贝分工下的 global memory 视图
tQsQ = tQ + sQ:同一拷贝分工下的 shared memory 视图
tCrQ = tC + rQ:MMA 分工下的 Q 寄存器视图
tXsQ = tX + sQ:shared memory 加载 Copy 的 Q 源视图
tXrQ = tX + rQ:同一个 Copy 的 Q 目的寄存器视图
前面的 `tQ`、`tC`、`tX` 区分线程分工,后面的 `g`、`s`、`r` 区分 global memory、shared memory、register。`t` 不需要解释成英文的 “to”。
官方 SM80 例子用 `tCrA/B/C` 表示 MMA 相关的 fragment,用 `tXsA/B`、`tXrA/B` 表示加载 Copy 的源、目的视图。本文沿用这套前缀,再将数据后缀扩展成 Q、K、V、P、S、O:
| 本文变量 | 表达的内容 |
| `mQ`、`gQ`、`sQ` | 当前 head 的完整 Q、当前 block 的 global memory Q 分块、shared memory Q 分块 |
| `tQgQ`、`tQsQ` | 同一 global→shared Copy 下,当前线程的源、目的视图 |
| `tCrQ`、`tCrK` | 当前线程参与 MMA 所需的 Q、K fragment |
| `tXsQ`、`tXrQ` | shared→register Copy 下,当前线程的源、目的视图 |
| `tCrS`、`tCrO` | 分数 S 和输出 O 的 MMA 累加器 |
注意 `tCrQ` 和 `tXrQ` 都是寄存器中的 Q,区别在于访问它们的分工;名字中的 C 也不表示这个 tensor 一定是 GEMM 的 C 操作数。具体如何分配元素,仍要看 `partition`、`retile` 和实际布局。
Copy 对象也沿用官方例子的写法:`copy_qkv`、`thr_copy_qkv` 表示 global→shared 的整组拷贝及其线程切片;`s2r_copy_q`、`s2r_thr_copy_q` 表示 shared→register 的相应对象,其中 `s2r` 是 shared to register。输出时用 `r2s` 表示相反方向。它们是对象名,不参与上面的 tensor 名字拆分。
## shared memory 为什么还要 Swizzle
global memory 中,Q 是普通行主序。shared memory 中,源码使用的是:
using SmemLayoutAtomQ = decltype(composition(
Swizzle<3,3,3>{},
Layout<Shape<_8,_64>, Stride<_64,_1>>{}));
using SmemLayoutQ = decltype(tile_to_shape(
SmemLayoutAtomQ{}, Shape<_64,_64>{}));
这是将本例的常量代入后的写法。`tile_to_shape` 将一个 `8×64` 的基本布局铺到整个 `64×64` tile。
shared memory 分为多个 bank。若一组线程需要访问不同地址,却集中落在相同 bank 上,访问可能被拆开处理,产生 bank conflict。适合 global memory 连续加载的排布,未必适合后面的 `ldmatrix`。
Swizzle 为逻辑坐标选择另一套物理偏移。以本例基本布局的一行起点为例,原偏移为 `x=row*64`,变换是:
swizzle(x) = x XOR ((x AND 0x1c0) >> 3)
row 0 1 2 3 4 5 6 7
原偏移 0 64 128 192 256 320 384 448
新偏移 0 72 144 216 288 360 432 504
这里的单位是 half 元素。低 3 位保持不变,因此 8 个 half 组成的 16 字节小组仍保持连续;不同小组的起点被重新安排,以配合 warp 的加载方式。
`partition_D(sQ)` 写入时使用这套布局,后面的 `partition_S(sQ)` 读取时也使用它。同一个 `sQ(m,d)` 始终代表同一个逻辑元素。
所以,Swizzle 的作用要结合具体的拷贝和加载指令理解。换 tile 大小或线程分工后,需要重新检查 bank 分布;不能仅凭加上 `Swizzle` 就断定所有访问都没有冲突。
## 先看一条 MMA,寄存器 fragment 才有具体含义
Q 进入 shared memory 后,接下来要计算 `Q_tile @ K_tile.T`。源码选择的 SM80 MMA Atom 是:
MMA_Atom<SM80_16x8x16_F32F16F16F32_TN>
MMA 是矩阵乘加。这个类型封装的 PTX 指令为:
```ptx
mma.sync.aligned.m16n8k16.row.col.f32.f16.f16.f32
{d0,d1,d2,d3},
{a0,a1,a2,a3},
{b0,b1},
{c0,c1,c2,c3};
一整个 warp 协作计算:
D = A @ B + C
A:16×16
B:16×8 按通常数学写法
C、D:16×8
`m16n8k16` 表示 M、N、K 的尺寸,类型顺序是 D、A、B、C。每个 lane 提供的寄存器为:
| 操作数 | 每个 lane 持有的逻辑值 | PTX 中的操作数 |
|---|---|---|
| A | 8 个 half | 4 个 32 位打包值 |
| B | 4 个 half | 2 个 32 位打包值 |
| C、D | 各 4 个 float | 各 4 个 FP32 值 |
两个 half 装在一个 32 位操作数中。因此后文说“8 个 half 槽位”,并不表示 8 个独立的硬件寄存器。
每个 lane 提供矩阵中的哪些位置,由指令约定。CuTe 的 MMA traits 将这份约定写成 `(thread,value) -> 矩阵位置` 的布局,常见名称是 `LayoutA_TV`、`LayoutB_TV`、`LayoutC_TV`。`TV` 就是 Thread、Value。
还有一个坐标顺序需要提前约定:CuTe 的 MMA 接口用 `A(M,K)`、`B(N,K)`、`C(M,N)`。于是第一个 GEMM 可以直接把 `K[n,d]` 当作 B tensor,表示:
C(m,n) += sum_d Q(m,d) * K(n,d)
这正好计算数学上的 `Q @ K.T`,不需要先在内存中复制出一份 K 的转置。类名中的 `TN` 和指令的 `.row.col` 对应操作数约定,不能把它们直接读成“先执行一次内存转置”。
### 从一条指令扩大到整个 block
当前 traits 使用:
using TiledMma = TiledMMA<
MMA_Atom<SM80_16x8x16_F32F16F16F32_TN>,
Layout<Shape<_4,_1,_1>>,
Tile<_64,_16,_16>>;
第二个参数将 4 个 warp 沿 M 方向组织起来。第三个参数在当前 CuTe 接口中名为 `PermutationMNK`,可以描述各方向的 tiling、排列;本例是整数 tiler,使 `tile_size` 得到 `(64,16,16)`。
这个 `64×16×16` 仍然由多条 `16×8×16` 指令完成。一个 `64×64 @ 64×64` 的 GEMM,还要沿 N、K 方向继续重复。每个 warp 在 N 方向执行多个 MMA,并复用已经加载的 Q 值。
旧文章里有时用 `ValLayoutMNK = Layout<Shape<_1,_2,_1>>` 讲“每线程重复”。当前项目虽保留了这个类型名,实际传给 `TiledMMA` 的第三个参数已经是上面的 `Tile`。读新接口时,应当沿当前调用路径查看 `PermutationMNK` 和 `tile_size`。
## partition_fragment 创建了什么
取得当前线程的 MMA 分工之后,源码创建 Q、K fragment:
auto thr_mma = mma.get_slice(threadIdx.x);
Tensor tCrK = thr_mma.partition_fragment_B(sK);
auto tCrS = partition_fragment_C(
mma, Shape<_64,_64>{});
clear(tCrS);
这里的 fragment 可以理解为“当前线程要持有的矩阵元素”。对这条 SM80 MMA,`partition_fragment_A/B` 根据线程分工创建寄存器存储,刚创建时还没有 Q、K 的值。`clear` 则确实将分数累加器清零。
打印本例的寄存器布局,shape 如下。为便于阅读,省略打印结果中的静态常量前缀 `_`:
tCrQ:((2,2,2),1,4)
tCrK:((2,2),8,4)
tCrS:((2,2),1,8)
三个顶层维度分别表示:
A fragment:(单条 MMA 的每线程 A 值,M 方向重复,K 方向重复)
B fragment:(单条 MMA 的每线程 B 值,N 方向重复,K 方向重复)
C fragment:(单条 MMA 的每线程 C 值,M 方向重复,N 方向重复)
CuTe 把一个维度叫作 mode,mode 自己还可以包含子维度。例如 `tCrQ` 最外层有三个 mode,所以 rank 是 3;它的第一维 shape 是 `(2,2,2)`,大小为 8。
本例每个线程保存 32 个 Q half、128 个 K half、32 个分数 float。K fragment 会在不同 warp 间重复,因为 4 个 warp 处理不同的 Q 行,却需要相同的 K。分数输出则恰好由 `128×32=4096` 个 float 覆盖整个 `64×64` tile。
这些数量只描述 fragment 的逻辑值。编译器最后用了多少寄存器,还要看生命周期、优化和是否发生溢出。
## ldmatrix 和 retile_D 怎样把寄存器填好
源码从 shared memory 读取 Q、K 时使用:
using SmemCopyAtom =
Copy_Atom<SM75_U32x4_LDSM_N, cutlass::half_t>;
虽然名字带 `SM75`,这条加载指令也用于这里的 SM80 路径。它封装的是:
ldmatrix.sync.aligned.x4.m8n8.shared.b16
{r0,r1,r2,r3}, [addr];
warp 一次协作加载四个 `8×8`、16 位元素的矩阵。每 8 个 lane 为其中一个矩阵提供八行的地址,32 个 lane 都接收结果,每个 lane 最终得到 4 个 32 位值,也就是 8 个 half。
先只看其中一个非转置的 `8×8` 加载。lane 0 提供第 0 行的地址,但这一行的 8 个 half 会分给 lane 0~3:
X(0,0) X(0,1) | X(0,2) X(0,3) | X(0,4) X(0,5) | X(0,6) X(0,7)
lane 0 | lane 1 | lane 2 | lane 3
因此,“我提供哪一行的地址”与“我的寄存器收到哪一对值”有不同的规则。CuTe 为 Copy 分别定义源端、目的端布局,正是为了表达这件事。`ldmatrix` 的行地址还要求 16 字节对齐,这也是前面保留 8 个 half 连续分组的原因之一。
### Q:加载和计算访问同一份寄存器
源码先让 Copy 采用 MMA 的 A 参考分工,再为当前线程创建源视图和目的视图:
auto s2r_copy_q = make_tiled_copy_A(
typename Kernel_traits::SmemCopyAtom{}, mma);
auto s2r_thr_copy_q = s2r_copy_q.get_thread_slice(threadIdx.x);
以 warp 0 的 lane 0 为例,一条 MMA 的 8 个 Q 值按顺序是:
槽位 0、1:Q(0,0)、Q(0,1)
槽位 2、3:Q(8,0)、Q(8,1)
槽位 4、5:Q(0,8)、Q(0,9)
槽位 6、7:Q(8,8)、Q(8,9)
本例的 `x4` 一次正好写入这 8 个 half。原来的 MMA 视图用 `(2,2,2)` 表示它们,Copy 视图将它们组织为一组 8 个值:
tCrQ 的 shape:((2,2,2),1,4)
tXrQ 的 shape:((8,1),1,4)
两个 tensor 使用同一个 `data()`。通过 Copy 视图写入后,MMA 视图马上就能访问这些值。`retile_D` 本身只计算访问布局,没有执行加载,也没有在线程间搬运寄存器数据。这里的 D 表示 Copy 的 destination。
### K:一次 x4 要从两个 N fragment 取值
一条 MMA 每个 lane 只需要 4 个 B half,而 `x4` 每个 lane 要写入 8 个。当前配置的 Copy 会将两个 N 方向的 B fragment 组织到一起。
对 warp 0 的 lane 0,第一组加载涉及:
| 来自哪个 MMA fragment | K 的逻辑元素 | 原 B fragment 中的 half 槽位 |
| 第 0 个 N fragment | `K(0,0),K(0,1),K(0,8),K(0,9)` | `0,1,2,3` |
| 第 1 个 N fragment | `K(8,0),K(8,1),K(8,8),K(8,9)` | `16,17,18,19` |
这里的行号 0、8 是 key 的位置。后四个值落在槽位 16~19,是因为原 `tCrK` 的 K 重复维度 stride 为 4,N 重复维度 stride 为 16。
`retile_D` 保留这个对应关系,让一次 Copy 可以写入这八个位置。随后 MMA 仍通过原来的 B 视图取值。
这也是不能随手将 `retile` 换成“把连续 8 个寄存器分为一组”的原因:对本例的 K,这一组在原 fragment 存储中并不连续。参考文章里 `x1` 的简单分组是理解入口,换成这里的 `x4` 后,需要结合实际的 MMA 布局重新看。
## 第一个 GEMM:沿 head dimension 累加分数
Q、K 的 Copy 视图准备好后,`gemm_smem` 负责 shared memory 到寄存器的加载,以及 QK 的矩阵乘加。下面沿用官方 GEMM 例子中的通用 A、B、C 命名:本次 A 对应 Q、B 对应 K、C 对应分数 S。`tXsA/B` 是 Copy 源视图,`tCrC` 是累加器。核心代码是:
Tensor tXrA = s2r_thr_copy_a.retile_D(tCrA);
Tensor tXrB = s2r_thr_copy_b.retile_D(tCrB);
cute::copy(s2r_copy_a,
tXsA(_, _, _0{}), tXrA(_, _, _0{}));
cute::copy(s2r_copy_b,
tXsB(_, _, _0{}), tXrB(_, _, _0{}));
#pragma unroll
for (int k_block = 0; k_block < size<2>(tCrA); ++k_block) {
if (k_block < size<2>(tCrA) - 1) {
tXsA(_, _, k_block + 1), tXrA(_, _, k_block + 1));
tXsB(_, _, k_block + 1), tXrB(_, _, k_block + 1));
}
cute::gemm(mma, tCrA(_, _, k_block), tCrB(_, _, k_block), tCrC);
在 QK 这次乘法中,M 是 query 行,N 是 key 行,K 是 head dimension。因此本例 `size<2>(tCrA)=4`,循环依次处理 D 的 `0~15、16~31、32~47、48~63`。
循环前先准备第 0 段;循环中先加载下一段,再计算当前段。这给编译器留下安排加载和计算的空间。这里的 shared memory 加载使用 `ldmatrix`,与 global memory 到 shared memory 的 `cp.async` 是两种指令,不能将所有 `copy` 都理解为异步拷贝。
四段累加结束后,寄存器中得到当前 `64×64` 分数块。Q 仍留在 shared memory,本项目会在后续 K、V 迭代中再次从 shared memory 加载它,并没有让整块 Q 跨所有迭代一直留在寄存器里。
## 分数分散在各个线程里,怎样逐行做 softmax
普通二维矩阵可以直接按行归约。MMA 的结果却先以 fragment 的方式排列。对 warp 0、lane 0,前几个分数槽位是:
r0=S(0,0) r1=S(0,1) r2=S(8,0) r3=S(8,1)
r4=S(0,8) r5=S(0,9) r6=S(8,8) r7=S(8,9)
源码先为同一份累加器创建按行、列访问的视图:
Tensor tCrS_rowcol = make_tensor(
tCrS.data(),
flash::convert_layout_acc_rowcol(tCrS.layout()));
本例 shape 的变化为:
MMA 视图:((2,2),1,8)
行列视图:((2,1),(2,8))
行 列
对于当前线程,这表示 2 行、每行 16 个值。lane 0 的两行是第 0、8 行;它持有的列为 `0,1,8,9,…,56,57`。lane 1 持有相同行的 `2,3,10,11,…,58,59`,lane 2、3 再补齐其余列。
因此,一整行的 64 个分数分布在同一 warp 内相邻的 4 个 lane 中。`utils.h` 的归约分两步进行:
thread_reduce_:当前线程先归约自己持有的 16 个列值
Allreduce<4>:再用 shuffle 合并四个 lane 的局部结果
这样得到完整行的最大值、分母和。`convert_layout_acc_rowcol` 只是换视图,跨 lane 的信息合并由后面的 shuffle 完成。
## 没看完所有 K,softmax 怎样继续算
现在回到开头留下的问题。对于一行 query,设已经处理过的 key 的原始分数是 $x_j=q\cdot k_j$,缩放系数为正数 $s$。保存三份状态:
m=\max_j x_j,\qquad
\ell=\sum_j e^{s(x_j-m)},\qquad
U=\sum_j e^{s(x_j-m)}v_j.
$m$ 是原始 QK 的最大值,$\ell$ 是当前分母,$U$ 是长度 D 的输出分子。最终结果是 $O=U/\ell$。
新来一个 K、V tile 后,按下面的顺序更新:
\begin{aligned}
m' &= \max\left(m,\max_{j\in\mathrm{new}}x_j\right),\\
\alpha &= e^{s(m-m')},\\
p_j &= e^{s(x_j-m')},\quad j\in\mathrm{new},\\
\ell' &= \alpha\ell+\sum_{j\in\mathrm{new}}p_j,\\
U' &= \alpha U+\sum_{j\in\mathrm{new}}p_jv_j.
\end{aligned}
关键是第二行。旧分子、旧分母都以旧最大值 m 为基准;最大值变成 m′ 后,给它们乘上同一个 $\alpha$,就能和新一块相加。旧分数不需要重新读出来。
### 用四个分数算一次 rescale
把 head dimension 暂时缩成 1,取 `s=1`,只是为了手算。第一块的分数为 `[1,2]`,V 为 `[10,20]`:
m = 2
l = exp(-1) + 1
U = 10*exp(-1) + 20
第二块的分数为 `[3,4]`,V 为 `[30,40]`,新最大值变成 4:
alpha = exp(2-4) = exp(-2)
l' = exp(-2)*(exp(-1)+1) + exp(-1)+1
= exp(-3)+exp(-2)+exp(-1)+1
U' = exp(-2)*(10*exp(-1)+20) + 30*exp(-1)+40
= 10*exp(-3)+20*exp(-2)+30*exp(-1)+40
`U'/l' ≈ 34.93`,正好等于对 `[1,2,3,4]` 一次做完整 softmax 后的加权和。第一块只留下了 `m、l、U`,就足够继续计算。
### 和 softmax_rescale_o 对上号
采用本文命名后,对应关系为:
| 数学量 | 本文变量 |
| 原始 QK 分数,随后原地变成当前块的指数权重 | `tCrS_rowcol`,与 `tCrS` 共享存储 |
| m | `rRowMax` |
| l | `rRowSum` |
| U | `tCrO` |
| s | `params.softmax_scale` |
第一块调用 `softmax_rescale_o<true>`,直接初始化最大值、分母;输出累加器此前已清零。后续块调用 `<false>`,更新最大值,用 `alpha` 缩放旧分母和旧输出,再加上当前块的分母。
当前块的 `P @ V` 则由紧接着的第二个 GEMM 完成。所以函数返回时,`tCrO` 的旧值已经 rescale,但本轮新的输出贡献还没加进去。
还有一个容易被函数名误导的地方:当前 `scale_apply_exp2` 的函数体实际调用 `expf`,kernel 也传入普通的 `params.softmax_scale`。虽然参数结构还保留 `softmax_scale_log2`,这条路径没有使用它。阅读时应按自然指数的公式理解,不能额外乘一次 `log2(e)`。
## 第二个 GEMM:分数怎样直接变成输入
第一个 GEMM 的累加器是 FP32,第二个 GEMM 的 A 输入需要 FP16。源码先转换类型,再变换寄存器视图:
Tensor rP = flash::convert_type_f32_to_f16(tCrS);
Tensor tCrP = make_tensor(
rP.data(),
flash::convert_layout_rowcol_Aregs<TiledMma>(tCrS_rowcol.layout()));
此时 `tCrS` 已经被 softmax 函数原地改成当前块的指数权重。这里变量名叫 P,但还没有除最终的分母;归一化被推迟到所有 K、V 分块计算结束。
两行分别做了两件事:第一行把 float 数值转换成 half,创建新 fragment;第二行沿用 `rP.data()`,为这些 half 建立第二次 MMA 的 A 视图。
对于本例、warp 0 的 lane 0,前八个 half 按存储顺序是:
P(0,0), P(0,1), P(8,0), P(8,1),
P(0,8), P(0,9), P(8,8), P(8,9)
与前面的 Q fragment 对照,就能看到它们正好是 A fragment 需要的那八个位置。布局变化为:
第一个 GEMM 的 C:((2,2),1,8) 共 32 个值
按行列访问: ((2,1),(2,8))
第二个 GEMM 的 A:((2,2,2),1,4) 共 32 个值
这里将两个相邻 N fragment 的值,组织成第二个 GEMM 中一段 K=16 的 A 输入。值都已经在当前 lane 里,因此这一步可以通过改视图完成,无需先写到 shared memory 再由其他线程重新加载。
这解释了融合两个 GEMM 为什么要考虑 MMA 的线程分工:需要检查第一个 GEMM 生成的值,能否在相同 lane 中组成第二个 GEMM 的 A 输入。仅仅看到两个 tensor 的元素总数相等,还不足以保证可以这样衔接。
### V 为什么需要转置视图和转置加载
第二个 GEMM 的数学形状是:
权重[64,64] @ V[64,64] -> 输出分子[64,64]
query,key key,dim query,dim
虽然本例的三个尺寸碰巧都是 64,它们的含义已经改变:第二个 GEMM 的 M 是 query,N 是输出的 dim,K 是 key。
CuTe 的 B tensor 要按 `(N,K)` 索引,所以这里需要按 `(dim,key)` 访问 V。源码在同一份 shared memory 上建立两种视图:
Tensor sV = make_tensor(
make_smem_ptr(shared_storage.smem_v.data()), SmemLayoutV{});
Tensor sVt = make_tensor(
make_smem_ptr(shared_storage.smem_v.data()), SmemLayoutVt{});
本例中,它们满足:
sV(key,dim) 与 sVt(dim,key) 指向同一个 half
没有在 shared memory 中再生成一份转置矩阵。写 V 时按 `sV` 的布局,给第二个 GEMM 加载时则按 `sVt` 的布局选择地址,并使用转置形式的加载 Atom:
using SmemCopyAtomTransposed =
Copy_Atom<SM75_U16x8_LDSM_T, cutlass::half_t>;
它对应 `ldmatrix.x4.trans`,将 shared memory 中的 16 位元素按转置规则分发到寄存器,配合 MMA 所需的 B 分工。逻辑转置视图和硬件的转置加载共同完成这段衔接。
源码中的 `sVtNoSwizzle` 用来创建 B 寄存器 fragment,提供所需的逻辑形状;真正加载 shared memory 的源视图来自带 Swizzle 的 `sVt`。两者在这里承担不同的工作。
最终,`gemm_A_in_regs` 使用已经在寄存器里的 `tCrP`,加载 V 的 B fragment,执行本轮 `tCrO += P @ V`。
FP16 输入和 FP32 累加能减少存储并使用 Tensor Core,但权重在进入第二个 GEMM 前会舍入为 FP16。因此上述 online softmax 公式在实数运算中等价,实现结果与完整 FP32 attention 通常有小幅数值差异。
## 把异步加载放回整条时间线
现在各个计算步骤都有了含义,可以读 kernel 中的调度顺序了。先看非 causal 的主循环:
循环前:异步加载 Q、K[0],提交拷贝组
第 i 轮:
等待 Q、K[i] 就绪,block 同步
发起 V[i] 的异步加载,提交拷贝组
计算 Q @ K[i].T
等待 V[i] 就绪,block 同步
如果还有下一轮,发起 K[i+1] 的异步加载,提交拷贝组
更新 online softmax,缩放旧输出分子
计算 P[i] @ V[i],累加新输出分子
重叠关系很直接:加载 `V[i]` 时计算 `QK[i]`,加载 `K[i+1]` 时计算 softmax 和 `P[i]V[i]`。
源码用下面几种操作分别控制它们:
| 操作 | 作用 |
| `flash::copy`,使用 `cp.async` Atom | 发起 global memory 到 shared memory 的异步拷贝 |
| `cute::cp_async_fence()` | 对应 `cp.async.commit_group`,将此前未提交的异步拷贝组成一组 |
| `flash::cp_async_wait<0>()` | 等待当前线程此前提交的拷贝组全部完成 |
| `__syncthreads()` | 让 block 内线程会合,安全使用协作加载的数据或复用缓冲区 |
`fence` 不表示“现在才开始拷贝”,也不表示拷贝已经完成。`wait` 等待当前线程的异步操作,后面还需要 block 同步,才能消费其他线程加载的部分。
本实现的 shared storage 分别只有一份 Q、一份 K、一份 V tile:K 的旧数据在本轮 QK 完成后被下一轮 K 覆盖,V 的旧数据在下一轮加载 V 时被覆盖。这里通过两个 GEMM 的先后关系安排预取,源码没有为 K、V 各自开两份 ping-pong 缓冲区。
这些同步点除了等待数据,也保护缓冲区的复用。例如,等待 V 后的 block 同步保证所有线程已经完成本轮 QK,随后才可以覆盖 `sK`。修改调度顺序时,需要同时跟踪“数据是否已到达”和“旧数据是否已被所有使用者读完”。
## causal mask 怎样找到当前元素的位置
开启 causal 后,第 q 行只能访问 `key <= q` 的元素。当前配置下,源码分两层处理。
首先少遍历整块位于未来的 K、V。对当前 Q block:
int seqlen_end = (m_block + 1) * kBlockM;
int n_block_max = cute::ceil_div(seqlen_end, kBlockN);
本例的第 0 个 Q block 只访问 `K[0:64]`,第 1 个 Q block 访问两个 K tile。
其次,在与对角线相交的分数块内,将 `key > query` 的位置设为 `-INFINITY`。这些位置经过指数函数后权重为 0。
难点在于,当前线程的 fragment 坐标不是整块矩阵的行列坐标。`mask_within_nblock` 根据当前 MMA 分工恢复位置。对本例,可以写成:
warp = threadIdx.x / 32
lane = threadIdx.x % 32
query = m_block*64 + warp*16 + lane/4 + 8*mi
key = nbi*64 + (lane%4)*2 + j + 8*nj
其中 `mi=0、1` 表示当前 lane 的两行,`j=0、1` 表示一对相邻列,`nj=0~7` 表示 N 方向重复。比较 `key > query`,就能决定是否屏蔽该值。
例如 warp 0、lane 1 持有第 0、8 行的第 2、3、10、11 等列。对 query 0,这些 key 全在未来;对 query 8,key 2、3 可以保留,key 10、11 必须屏蔽。
这份位置公式依赖当前的 TiledMMA。换 warp 排布或 MMA 指令后,需要一起修改。它实现的是本例等长 self-attention 的 causal 语义,也没有代替序列末尾的边界检查。
## 最后一轮之后,归一化并写回结果
循环结束时,`tCrO` 保存的是 U,`rRowSum` 保存的是 l。源码先创建输出的行列视图,再给每行乘上 `1/l`,得到最终 O。
同时计算:
\operatorname{LSE}=s\,m+\log\ell,
也就是这一行缩放后分数的 LogSumExp。这里的 m 是没有乘 s 的 QK 最大值,所以公式里还要补乘 s。
LSE 比完整的注意力矩阵小得多,可用于 backward 时重建 softmax 所需的信息;当前仓库的这条路径只实现 forward。由于一行的归约结果在四个 lane 中重复保存,写回 LSE 时还要选出代表 lane,避免四个线程同时写同一行。源码用 identity tensor 的矩阵坐标判断谁负责写。
### 输出为什么要经过 shared memory
MMA 输出的值仍分散在各个线程中。直接沿这个分工写 global memory,不一定适合连续、向量化的存储。源码先将结果转换成 FP16,再通过 shared memory 调整写回分工:
FP32 输出累加器
-> FP16 的 tCrO_fp16
-> shared memory 的 sO
-> 按 global memory 写回分工读取到各线程的临时寄存器
-> global memory 的 O
其中 `sO` 复用已经不再使用的 `sQ` 空间。写入 shared memory 的那一步使用:
Tensor tCrO_fp16 = flash::convert_type_f32_to_f16(tCrO);
auto r2s_copy_o = make_tiled_copy_C(
typename Kernel_traits::SmemCopyAtomO{}, mma);
auto r2s_thr_copy_o = r2s_copy_o.get_thread_slice(threadIdx.x);
Tensor tXrO = r2s_thr_copy_o.retile_S(tCrO_fp16);
Tensor tXsO = r2s_thr_copy_o.partition_D(sO);
cute::copy(r2s_copy_o, tXrO, tXsO);
这里改成 `retile_S`,因为已有的寄存器 fragment 是 Copy 的 source。它与前面的 `retile_D` 一样,只创建共享存储的访问视图。
block 同步之后,再按 `GmemTiledCopyO` 的分工从 `sO` 读入临时寄存器,写到 global memory。shared memory 在这里使计算分工和写回分工得以衔接。
## 自己核对一次:先打印布局,再比较数值
读完这些代码,最值得先试的是固定一组小尺寸,观察 layout 和结果。直接扩大维度、切换类型,会同时引入编译、边界和数值问题。
### 一个可以独立运行的布局打印程序
下面的程序在 CPU 上打印布局,不执行 GPU 加载。它显式指定 SM80 MMA,避免项目 traits 根据 `__CUDA_ARCH__` 在 host 编译时选到另一条分支。
项目根目录的 [`layout_probe.cu`](layout_probe.cu) 已包含下面的完整程序:
#include <cute/tensor.hpp>
#include <cstdio>
using namespace cute;
int main() {
TiledMma mma;
auto thr_mma = mma.get_slice(0);
// 只借用地址构造布局,不访问这些数据。
half_t storage[64 * 64]{};
auto sA = make_tensor(
make_smem_ptr(storage),
make_layout(Shape<_64,_64>{}, Stride<_64,_1>{}));
auto sB = sA; // 本例只打印布局,可以共用这份占位存储。
auto tCrA = thr_mma.partition_fragment_A(sA);
auto tCrB = thr_mma.partition_fragment_B(sB);
auto tCrC = partition_fragment_C(mma, Shape<_64,_64>{});
auto s2r_copy_a = make_tiled_copy_A(
Copy_Atom<SM75_U32x4_LDSM_N, half_t>{}, mma);
auto s2r_copy_b = make_tiled_copy_B(
auto s2r_thr_copy_a = s2r_copy_a.get_slice(0);
auto s2r_thr_copy_b = s2r_copy_b.get_slice(0);
auto tXrA = s2r_thr_copy_a.retile_D(tCrA);
auto tXrB = s2r_thr_copy_b.retile_D(tCrB);
print("tCrA: "); print(tCrA.layout()); print("\n");
print("tCrB: "); print(tCrB.layout()); print("\n");
print("tCrC: "); print(tCrC.layout()); print("\n");
print("tXrA: "); print(tXrA.layout()); print("\n");
print("tXrB: "); print(tXrB.layout()); print("\n");
在项目根目录中编译、运行。下面使用本文核对布局时的本机 CUTLASS 目录:
```bash
nvcc -std=c++17 -I/home/x/workspace/cutlass/include layout_probe.cu -o /tmp/layout_probe
/tmp/layout_probe
换机器时,将 include 路径改成自己的 CUTLASS 目录。省略静态常量前缀后,关键输出为:
tCrA: ((2,2,2),1,4):((1,2,4),0,8)
tCrB: ((2,2),8,4):((1,2),16,4)
tCrC: ((2,2),1,8):((1,2),0,4)
tXrA: ((8,1),1,4):((1,0),0,8)
tXrB: (((4,2),1),4,4):(((1,16),0),32,4)
冒号前是 shape,冒号后是 stride。最后一行的 `(4,2):(1,16)` 正好说明:一组八个 B half,来自四个连续槽位,以及相隔 16 的另一组四个槽位。
想进一步核对“当前 lane 持有矩阵的哪个位置”,可以创建 `make_identity_tensor(Shape<_64,_64>{})`,然后调用 `thr_mma.partition_A/B/C`。identity tensor 的值就是坐标,得到的分区会直接显示 `(行,列)`,比从指针偏移猜测更方便。
### 在已有 CUDA 扩展环境中验证 forward
仓库的 `make build` 会构建 Python 扩展,依赖 CUDA、PyTorch 和完整的 CUTLASS 头文件。首次使用需要先初始化仓库记录的子模块:
git submodule update --init --recursive
cd flash_attention_cutlass
make build
这会使用项目锁定的子模块版本;若有意改用另一版 CUTLASS,应同时检查构建配置和 API。前面的独立布局程序可以直接指向另外一份 CUTLASS 检出目录。
扩展安装后,在项目根目录运行 [`flash_attention_example.py`](flash_attention_example.py),用 FP16、连续存储、L 为 64 倍数的等长输入先做小规模比较:
python flash_attention_example.py
脚本的完整内容如下:
```python
import math
import torch
from attention_cutlass import flash_attention_v2_cutlass
torch.manual_seed(0)
B, H, L, D = 1, 1, 128, 64
q, k, v = [
torch.randn(B, H, L, D, device="cuda", dtype=torch.float16)
for _ in range(3)
]
scale = 1.0 / math.sqrt(D)
for causal in (False, True):
out, lse = flash_attention_v2_cutlass(q, k, v, causal, scale)
score = (q.float() @ k.float().transpose(-1, -2)) * scale
if causal:
mask = torch.ones(L, L, device="cuda", dtype=torch.bool).triu(1)
score.masked_fill_(mask, -torch.inf)
ref_out = torch.softmax(score, dim=-1) @ v.float()
ref_lse = torch.logsumexp(score, dim=-1)
torch.testing.assert_close(out.float(), ref_out, atol=3e-3, rtol=3e-3)
torch.testing.assert_close(lse, ref_lse, atol=1e-4, rtol=1e-4)
print("passed, causal =", causal)
这里显式传入 `1/sqrt(D)`。当前接口默认 scale 为 1;仓库 `test.py` 的示例则用了 `1/sqrt(SEQLEN)`,都不能直接当作常规 attention 的默认缩放方式。
本文的布局输出已用 CUTLASS `0b55a2f6` 编译核对,也逐项检查了全部 128 个线程的 C→A 元素坐标,以及 V 转置视图的地址对应关系。另从当前 `csrc` 提取原样的 forward 设备代码,绕过 PyTorch 绑定,在本机 GPU 上检查了 L=128、D=64/96/128,以及 L=192、D=32 的 causal、非 causal 两种模式。对这些 FP16 小规模随机输入,8 组检查的输出最大绝对误差低于 `3.22e-4`,LSE 最大绝对误差低于 `7.55e-7`。这些检查用于核对本文讨论的布局和计算路径;Python 扩展的完整构建、安装需在对应环境中另行验证。
## 当前教学实现有哪些边界
读源码时需要把“参数里有这个字段”和“kernel 已经实现这种能力”分开。当前版本保留了一些完整 FlashAttention 的接口和类型分派,但本文能沿代码确认的路径有明确限制:
| 项目 | 当前实现中需要留意的地方 |
| 序列尾块 | `flash::copy` 没有启用边界 predicate,输出和 LSE 写回也没有完整尾块保护。本文使用 64 的整数倍长度 |
| head dimension | 分派会向上选择 32 的倍数,但 kernel 用编译期 `kHeadDim` 作为行跨度,没有补齐任意 D 的 padding。本文选用与分派尺寸相等的 D |
| 布局、不同长度 | Q、K、V 使用同一个 head 起点偏移,行跨度也由模板固定。不能据此宣称已支持任意 stride、不同 Q/K 长度或 GQA/MQA |
| BF16 | 虽然有 BF16 MMA 分派,权重和输出转换仍写死为 `convert_type_f32_to_f16`。本文只讨论 FP16 路径 |
| CUDA stream | `run_flash_fwd` 接收 stream,但实际 launch 没有把它传入;Python 包装还执行设备同步 |
| backward | 返回 LSE,并不表示已经实现 backward |
这些限制也解释了为什么 `ceil_div`、模板分派、参数结构本身不足以证明一个输入已经受到完整支持。要扩展输入范围,需要沿加载、mask、计算、写回整条路径检查。
`standalone_src` 提供了无需 PyTorch 绑定的完整示例,但它和 `csrc` 是两份实现,参数结构、入口和默认配置并不完全同步。阅读本文时以 `csrc` 为准,切换到 standalone 后应重新核对相应配置。
## 回到开头的三行代码
现在再读它们:
第一行准备当前线程参与 MMA 所需的寄存器;第二行按加载指令的地址分工选择 shared memory 源;第三行给已有寄存器建立加载时使用的目的视图。随后调用 `copy`,数据才进入寄存器,再由 `gemm` 消费。
沿着同样的方法看整段 kernel,就能把那些长变量名落到具体工作上:固定一个 Q tile,流式读入 K、V;第一个 GEMM 产生分数,online softmax 保存可续算的状态,第二个 GEMM 把权重变成输出分子;最后归一化,将各线程的结果整理成适合写回的排列。
## 旧版命名与当前源码对照
本文、`csrc` 和 `standalone_src` 的 CuTe 实现已使用相同的命名。下表保留旧版名称,方便对照早期文章和代码;Q、K、V 等数学符号与 `partition_A/B/C` 这些 API 名称继续沿用。
| 旧版源码名称 | 当前源码与本文名称 | 对应的对象 |
| `Q`、`K`、`V`、`O` | `mQ`、`mK`、`mV`、`mO` | 当前 head 的完整矩阵视图 |
| `tiled_mma` | `mma` | TiledMMA 对象 |
| `gmem_tiled_copy_QKV` | `copy_qkv` | global→shared Copy |
| `gmem_thr_copy_QKV` | `thr_copy_qkv` | 上述 Copy 的线程切片 |
| `smem_tiled_copy_Q/K/V`、`smem_thr_copy_Q/K/V` | `s2r_copy_q/k/v`、`s2r_thr_copy_q/k/v` | Q、K、V 的 shared→register Copy 及线程切片 |
| `tSrQ`、`tSrK` | `tCrQ`、`tCrK` | MMA 的 Q、K 寄存器 fragment |
| `tSsQ` | `tXsQ` | Q 加载 Copy 的 shared memory 源视图 |
| `tSsK`、`tOrVt`、`tOsVt` | `tXsK`、`tCrVt`、`tXsVt` | K 的 Copy 源视图,以及 V 的 MMA、Copy 视图 |
| helper 内的 `tCrA_copy_view`、`tCrB_copy_view` | `tXrA`、`tXrB`;Q 的例子写作 `tXrQ` | 已有 fragment 的 Copy 视图 |
| helper 内的 `smem_tiled_copy_A/B`、`smem_thr_copy_A/B` | `s2r_copy_a/b`、`s2r_thr_copy_a/b` | 通用 A、B 加载 Copy 及线程切片 |
| helper 内的 `tCsA`、`tCsB` | `tXsA`、`tXsB` | 上述 Copy 的 shared memory 源视图 |
| helper 内的 `acc` | `tCrC` | 通用 GEMM 的累加器 |
| `rAccScore`、`rAccOut` | `tCrS`、`tCrO` | 分数和输出的累加器 |
| `scores` | `tCrS_rowcol` | 分数的行列视图 |
| `scores_max`、`scores_sum` | `rRowMax`、`rRowSum` | 每行最大值、分母的寄存器状态 |
| `tOrP` | `tCrP` | 第二个 GEMM 的 A fragment 视图 |
| `rO` | `tCrO_fp16` | 转换成 FP16、保留 MMA 排列的输出 fragment |
| `smem_tiled_copy_O`、`smem_thr_copy_O` | `r2s_copy_o`、`r2s_thr_copy_o` | 输出的 register→shared Copy 及线程切片 |
| `taccOrO`、`taccOsO` | `tXrO`、`tXsO` | 输出 Copy 的寄存器源、shared memory 目的视图 |
| `gmem_tiled_copy_O`、`gmem_thr_copy_O` | `copy_o`、`thr_copy_o` | 输出的 global memory 写回 Copy 及线程切片 |
| `LSE`、`lse` | `mLSE`、`rLSE` | LSE 的完整矩阵视图和寄存器状态 |
| `caccO`、`taccOcO`、`taccOcO_row` | `cO`、`tCcO`、`tCcO_row` | 输出元素坐标及其 MMA 分区、行坐标视图 |
`gQ/sQ`、`tQgQ/tQsQ`、`thr_mma`、`rP` 等本来就符合本文采用的命名方式,继续沿用。`rRowMax`、`rRowSum` 和 `_rowcol`、`_fp16` 后缀是本文用于说明状态、视图和类型的扩展,并非 CuTe API 的强制规则。
继续阅读源码时,可以按下面这几个入口定位:
| 想核对的内容 | 源码入口 |
| 当前分块和指令选择 | [`kernel_traits.h`](flash_attention_cutlass/csrc/kernel_traits.h):`Flash_fwd_kernel_traits` |
| block 分配、两次 GEMM、调度和写回 | [`flash_attention.cu`](flash_attention_cutlass/csrc/flash_attention.cu):`flash_attention_v2_cutlass_kernel` |
| QK、PV 的寄存器加载 | 同文件:`gemm_smem`、`gemm_A_in_regs` |
| online softmax 与布局衔接 | 同文件:`softmax_rescale_o`、`convert_layout_acc_rowcol`、`convert_layout_rowcol_Aregs` |
| 四个 lane 的行归约 | [`utils.h`](flash_attention_cutlass/csrc/utils.h):`thread_reduce_`、`Allreduce` |
| CuTe Copy 分区和 retile | [`copy_atom.hpp`](https://github.com/NVIDIA/cutlass/blob/0b55a2f6/include/cute/atom/copy_atom.hpp) |
| TiledMMA 的分工和 tiling | [`mma_atom.hpp`](https://github.com/NVIDIA/cutlass/blob/0b55a2f6/include/cute/atom/mma_atom.hpp) |
| 实际 MMA、ldmatrix 指令 | [`mma_sm80.hpp`](https://github.com/NVIDIA/cutlass/blob/0b55a2f6/include/cute/arch/mma_sm80.hpp)、[`copy_sm75.hpp`](https://github.com/NVIDIA/cutlass/blob/0b55a2f6/include/cute/arch/copy_sm75.hpp) |
| 指令规定的线程和值分布 | [`mma_traits_sm80.hpp`](https://github.com/NVIDIA/cutlass/blob/0b55a2f6/include/cute/atom/mma_traits_sm80.hpp)、[`copy_traits_sm75.hpp`](https://github.com/NVIDIA/cutlass/blob/0b55a2f6/include/cute/atom/copy_traits_sm75.hpp) |
本文实现参考 [FlashAttention](https://github.com/Dao-AILab/flash-attention)。算法背景可继续读 [FlashAttention](https://arxiv.org/abs/2205.14135) 和 [FlashAttention-2](https://arxiv.org/abs/2307.08691) 论文;想系统补 CuTe 的 Layout 运算,可对照 [CuTe 官方教程](https://docs.nvidia.com/cutlass/media/docs/cpp/cute/index.html)。