跳到主要内容
读懂 CuTe

更多笔记 / 第 15 章

读懂 CuTe 的 make_tiled_mma:一条 MMA 怎样铺成一块矩阵

读 CuTe 的矩阵乘代码时,经常会遇到这样的配置:

auto mma = make_tiled_mma(
SM80_16x8x16_F16F16F16F16_TN{},
Layout<Shape<_2,_2,_1>>{});

指令名里有 16×8×16,后面又跟着一个 2×2×1。它们相乘以后,是一条更大的 Tensor Core 指令,还是几组线程各算一部分?线程 32 和线程 64 各自算哪里?后面若再加一个 Tile<_64,_32,_16>,线程数量会不会继续增加?

先把第一个问题的结果画出来。上述配置使用四个 warp,共 128 个线程,在一个 K 分块中计算 32×16 的输出:

N = 0~7 N = 8~15
┌──────────────────┬──────────────────┐
M = 0~15 │ warp 0,线程 0~31 │ warp 2,线程 64~95│
│ 一块 16×8 的输出 │ 一块 16×8 的输出 │
├──────────────────┼──────────────────┤
M = 16~31 │ warp 1,线程 32~63│ warp 3,线程 96~127│
│ 一块 16×8 的输出 │ 一块 16×8 的输出 │
└──────────────────┴──────────────────┘

每个 warp 的这一步,都沿 K 方向使用 16 个元素。

每个 warp 执行的仍是原来的 m16n8k16 MMA。make_tiled_mma 把指令本身的 lane 分工、多个 warp 的排列和后续重复计算的坐标组织起来,让线程可以对 A、B、C 取得相互配合的访问视图。

下面先看一条 MMA 内部的分工,再沿源码扩展到四个 warp、较大的输入矩阵和第三个参数。配套程序会逐项核对文中的坐标。

先固定矩阵和方向​

本文沿用前面几篇文章的指令:

using Op = SM80_16x8x16_F16F16F16F16_TN;

它用 FP16 的 A、B 计算 FP16 的结果,输出块为 16×8,一次沿 K 累加 16 项。本文讨论的是 SM80 这条由一个 warp 的 32 个 lane 协作执行的指令;其他架构的 MMA,其线程组和 fragment 规则要看相应 trait。

CuTe 对三个操作数的逻辑方向约定为:

A:(M,K)
B:(N,K)
C:(M,N)

D(m,n) = C(m,n) + Σ A(m,k) * B(n,k)

这里 B 的第一个坐标是 N。与通常把右矩阵写成 B_math(K,N) 的数学记法对应,有 B(n,k)=B_math(k,n)。这个坐标约定与实际内存怎样排列分开考虑。

指令名的 TN 对应底层 MMA 的操作数形式,不是给任意 Tensor 设置内存 stride 的开关。后面给 Tensor 配置按行连续或按列连续的 layout,才决定这些坐标落到哪里。

本文用 shape:stride 表示布局。例如 (2,2,1):(1,2,4) 将坐标 (wm,wn,wk) 映射为 wm+2*wn+4*wk。大小为 1 的维度只能取坐标 0,其 stride 不影响合法坐标的结果。CuTe 将一个维度称为 mode,mode 也可以继续嵌套。

一条 MMA 已经规定了 lane 持有哪些值​

先只创建一个 warp 的配置:

auto mma_one = make_tiled_mma(Op{});

这时基本 tile 为 (16,8,16)。一条指令需要的逻辑元素数为:

操作数矩阵大小每 lane 的逻辑元素数
A16×16=2568 个 half
B8×16=1284 个 half
C / D16×8=1284 个 half

这些值的分布由 MMA_Traits<Op> 规定。构造 TiledMMA 时,不能像普通 copy 那样随意把“每线程的小矩形”传进去;底层指令已经要求特定 lane 持有特定值。

从 C 的四个值开始读​

本例 C 的 Atom 布局是:

using CLayout = Layout<
Shape <Shape < _4,_8>, Shape < _2,_2>>,
Stride<Stride<_32,_1>, Stride<_16,_8>>>;

它将 (lane,value) 映射为 16×8 输出块的逻辑坐标编号。CuTe 按 shape 解码整数坐标时,第一个 mode 先变化,因此这个编号是:

q = m + 16*n

这还不是某个按行连续存储矩阵的内存偏移。

令 lane 编号为 l,值编号为 v。在线程 shape (4,8) 和值 shape (2,2) 中,标量坐标分别被拆成:

lane → (l%4, l/4)
value → (v%2, v/2)

代入 stride:

q = 32*(l%4) + l/4 + 16*(v%2) + 8*(v/2)
= [l/4 + 8*(v/2)] + 16*[2*(l%4) + v%2]

所以:

m = l/4 + 8*(v/2)
n = 2*(l%4) + v%2

l = 0~31,v = 0~3

几个 lane 的四个输出位置为:

lane值 0值 1值 2值 3
0C(0,0)C(0,1)C(8,0)C(8,1)
1C(0,2)C(0,3)C(8,2)C(8,3)
4C(1,0)C(1,1)C(9,0)C(9,1)
31C(7,6)C(7,7)C(15,6)C(15,7)

每个 lane 持有相隔 8 行的两对元素。32 个 lane 合起来,正好覆盖整个 16×8 输出块。

A 的八个值和 B 的四个值​

同一个 trait 中,A、B 的布局为:

ALayout = ((4,8),(2,2,2)):((32,1),(16,8,128))
BLayout = ((4,8),(2,2)):((16,1),(8,64))

A 的输出编号按 qA=m+16*k 解码,B 则按 qB=n+8*k 解码。与 C 同样展开,可得到:

A,v = 0~7:
m = l/4 + 8*((v/2)%2)
k = 2*(l%4) + v%2 + 8*(v/4)

B,v = 0~3:
n = l/4
k = 2*(l%4) + v%2 + 8*(v/2)

例如 lane 0 的需求是:

A 的值 0~7:
(0,0), (0,1), (8,0), (8,1),
(0,8), (0,9), (8,8), (8,9)

B 的值 0~3,坐标方向为 (N,K):
(0,0), (0,1), (0,8), (0,9)

这里的“值”是逻辑 half 元素。该指令的底层接口将两个 half 打包在一个 32 位寄存器参数中,因此每 lane 的 A 对应四个 uint32_t 参数,B 和 C/D 各对应两个。不能把“8 个 A 值”直接读成“8 个 32 位寄存器”。

make_tiled_mma 的构造函数做了什么​

函数可以接收一个底层 Operation,也可以接收已经封装的 MMA_Atom。传入 Op{} 时,会先转到:

return make_tiled_mma(MMA_Atom<MMA_Op>{}, thr_layout, permutations);

接收 Atom 的重载主体是:

auto thr_layout_mnk = append<3>(thr_layout, Layout<_1,_0>{});
auto permutation_mnk = append<3>(permutations, _);

return TiledMMA<MMA_Atom<MMA_Op>,
decltype(thr_layout_mnk),
decltype(permutation_mnk)>{mma_atom, thr_layout_mnk};

可以把三个参数的职责先列出来:

参数本例规定什么
Atom / OperationOp{}指令形状、参与线程,以及 A/B/C 的 lane-value 分工
线程布局Layout<Shape<_2,_2,_1>>{}Atom 的线程组沿 M、N、K 怎样排列、怎样编号
permutation / tile默认 (_,_,_)各方向参与分块的范围,以及范围内部的坐标顺序

append<3> 用来把不足三维的配置补到 M、N、K 三个方向。线程布局缺少的方向补成 1:0,permutation 缺少的方向补成 _。因此 Layout<Shape<_2,_2>>{} 在这里也可以表示沿 M、N 各放两组,K 方向只有一组。

构造过程没有接收 A、B、C 指针,也没有执行加载或乘加。它建立后续分区所使用的布局与类型。线程创建、block 的大小和 grid 的划分,仍然由调用它的 kernel 决定。

第二个参数怎样把一个 warp 扩展成四个​

回到开头的配置:

auto mma = make_tiled_mma(Op{}, Layout<Shape<_2,_2,_1>>{});

这里的 (2,2,1) 描述 Atom 线程组的排列。对于本文的 SM80 指令,一个 Atom 线程组恰好是一个 warp。默认紧凑布局让 M 方向先变化:

warp_id = wm + 2*wn

wm = 0~1,wn = 0~1,wk = 0

TiledMMA 内部将这个布局与 Atom 自己的线程 ID 布局组合:

using ThrLayoutVMNK = decltype(
tiled_product(AtomThrID{}, AtomLayoutMNK{}));

本例 AtomThrID 是 32:1。组合后打印为:

ThrLayoutVMNK = (32,2,2,1):(1,32,64,0)

映射方向为:

(lane,wm,wn,wk) → 线程编号

t = lane + 32*wm + 64*wn

这里 VMNK 中的 V 表示 Atom 内部的线程维,也就是源码注释里的 ThrV。它与前面每 lane 的值编号 v 不是同一件事;“值”在 thrfrg_C 的注释中称为 FrgV。

第三个参数保持默认时,各方向的基本 tile 大小就是 Atom 尺寸乘上该方向的线程组数:

TileM = 16 × 2 = 32
TileN = 8 × 2 = 16
TileK = 16 × 1 = 16

总线程数为 32×2×2×1=128。注意,size(mma) 对应线程数量,tile_shape(mma) 才是 (32,16,16)。

get_slice 从线程编号反查位置​

实际使用时,线程调用:

auto thr_mma = mma.get_slice(threadIdx.x);

源码先做:

auto thr_vmnk = thr_layout_vmnk_.get_flat_coord(thr_idx);

它根据线程布局反查当前线程在 Atom 内部及 M、N、K 方向的位置。对于本例:

lane = t%32
wm = (t/32)%2
wn = t/64
wk = 0

于是前面的 C 坐标公式增加了 warp 所在块的偏移:

m = lane/4 + 8*(v/2) + 16*wm
n = 2*(lane%4) + v%2 + 8*wn

四个 warp 的 lane 0 各自持有:

线程(wm,wn)C 的四个值
0(0,0)(0,0), (0,1), (8,0), (8,1)
32(1,0)(16,0), (16,1), (24,0), (24,1)
64(0,1)(0,8), (0,9), (8,8), (8,9)
96(1,1)(16,8), (16,9), (24,8), (24,9)

这张表就对应开头那张四块输出的图。

换 stride,会换掉 warp 的排列​

若将第二个参数改为:

Layout<Shape<_2,_2,_1>, Stride<_2,_1,_0>>{}

线程组的编号变成:

warp_id = 2*wm + wn
ThrLayoutVMNK = (32,2,2,1):(1,64,32,0)

此时 warp 1 在右上,warp 2 在左下。线程总数和 tile 尺寸都相同,改变的是哪个 warp 负责哪块矩阵。get_slice 会依据新的布局反查坐标;不能继续套用前一组 wm/wn 的拆解公式。

本文使用的线程布局都连续覆盖一段线程编号。对更一般的嵌套或带空洞布局,需要检查实际 ID 映射,再安排参与的线程。

partition_C 怎样把分工应用到一个 Tensor​

现在准备一个按行连续存储的 32×16 输出 Tensor:

auto C = make_tensor(
make_gmem_ptr(c_ptr),
make_layout(make_shape(_32{}, _16{}),
make_stride(_16{}, _1{})));

auto thr_mma = mma.get_slice(threadIdx.x);
auto tC = thr_mma.partition_C(C);

partition_C 保留 C 的存储,根据 thrfrg_C(C.layout()) 建立访问视图,再固定当前线程。下面按 thrfrg_C 的顺序,看看这些布局运算分别处理哪一层。

先按第三个参数划分 M、N​

源码:

auto t_tile = make_tile(permutation_mnk<0>(),
permutation_mnk<1>());
auto t_tensor = logical_divide(ctensor, t_tile);

第三个参数为 _ 的方向,permutation_mnk<I>() 返回:

size<I>(AtomShape_MNK{}) * size<I+1>(get_thr_layout_vmnk())

所以本例的 t_tile 相当于 (32,16)。对恰好一个 tile 的输入:

t_tensor 的坐标结构:((32,1),(16,1))
M N

每个方向先分为 tile 内坐标与 tile 外剩余坐标。本例的剩余大小都是 1。第三个参数若传入非平凡 Layout,元素的访问顺序也会在这一步改变,后面会单独举例。

再切出单条指令的输出块​

源码按 Atom 的 M、N 尺寸进行 zipped_divide:

auto c_tile = make_tile(make_layout(size<0>(AtomShape_MNK{})),
make_layout(size<1>(AtomShape_MNK{})));
auto c_tensor = zipped_divide(t_tensor, c_tile);

概念上的坐标结构变成:

((AtomM,AtomN),(剩余M,剩余N))
(16,8) (2,2)

本例这一步会消去上一层多余的 size-1 结构,实际打印的 layout 为:

((16,8),(2,2)):((16,1),(256,8))

第一个组合 mode 表示一条 16×8 MMA 内部的位置,第二个表示这个 Atom 块放在哪里。沿 M 跨一个 Atom 块要走 16 行,因此 stride 是 16×16=256;沿 N 跨一个 Atom 块要走 8 列,因此 stride 是 8。

用 Atom 的 TV 布局替换块内坐标​

接着:

auto tv_tensor = c_tensor.compose(AtomLayoutC_TV{}, _);

AtomLayoutC_TV 就是前面已经展开的 CLayout。它把 (lane,value) 映射到 Atom 块内的 (m,n) 编号。与 c_tensor 复合以后,得到:

((Atom内线程,Atom内值),(剩余M,剩余N)) → C 的存储偏移

第二个参数 _ 表示保留 Atom 块之外的坐标。

例如 Atom 内 lane 0 的值 2,对应块内 (8,0)。C 每行跨度为 16,所以这里的存储偏移是 8×16=128。前面 CLayout 单独返回的逻辑编号却是 8:复合到真实 Tensor 以后,才换算成实际存储偏移。

把剩余块分给 M、N 方向的线程组​

最后一次 zipped_divide 使用线程组数 (2,2):

auto thr_tile = make_tile(_,
make_tile(make_layout(size<1>(thr_layout_vmnk_)),
make_layout(size<2>(thr_layout_vmnk_))));
auto thr_tensor = zipped_divide(tv_tensor, thr_tile);

结果的输入结构可读成:

((lane,(wm,wn)),(Atom内值,(RestM,RestN)))

前一半都用于选择线程:Atom 内部选 lane,外部选 M、N 方向的线程组。后一半是该线程需要持有的值和重复计算的部分。

ThrMMA::partition_C 从 get_slice 保存的坐标中取出 (lane,wm,wn),固定前一半,再将值和剩余方向展开为顶层 mode。对于当前 32×16 输入,返回:

shape = ((2,2),1,1)
stride = ((1,128),0,0)

第一维大小是 2×2=4,后两维各为 1。线程 0 的四个存储偏移为 0、1、128、129,对应 C(0,0)、C(0,1)、C(8,0)、C(8,1)。

线程 32 的返回 layout 相同,但视图起点推进到 C(16,0);线程 64 的起点则是 C(0,8)。只比较 layout,不看数据起点,不能判断两个线程是否访问同一份数据。

A、B 怎样与四块输出配合​

A 的分区沿 (M,K) 进行,B 沿 (N,K) 进行。源码与 C 的步骤相同,只是换成对应方向和对应 Atom TV 布局。线程坐标也各取所需的两项:

partition_A 选择 (lane,wm,wk)
partition_B 选择 (lane,wn,wk)
partition_C 选择 (lane,wm,wn)

这三个选择解释了数据为什么会重复。

在 2×2×1 的配置中,warp 0 和 warp 2 的 M、K 坐标一样。它们计算不同的输出列,但需要同一块 A。warp 0 和 warp 1 的 N、K 坐标一样,则需要同一块 B。

A 的 M 块 B 的 N 块 输出块
warp 0 上半 左半 左上
warp 1 下半 左半 左下
warp 2 上半 右半 右上
warp 3 下半 右半 右下

因此对 A(32,16)、B(16,16)、C(32,16):

操作数每 lane 的值数全部线程持有的值数不同矩阵元素数
A8128×8=102432×16=512
B4128×4=51216×16=256
C4128×4=51232×16=512

A、B 每个逻辑元素在不同 warp 中各出现两次,C 则覆盖一次。这里的重复描述的是各线程 fragment 的需求;是否能在某一层缓存或共享加载结果,取决于内核的数据搬运实现。布局构造本身没有生成一次跨 warp 广播。

输入扩大以后,后两个 mode 怎样工作​

保持 mma 不变,将输入扩大成:

A:(64,32)
B:(32,32)
C:(64,32)

也就是 M=64、N=32、K=32。

基本 tile 仍为 (32,16,16)。同样 128 个线程继续分区,会得到:

partition_A:((2,2,2),2,2) // (Atom值, M重复, K重复)
partition_B:((2,2), 2,2) // (Atom值, N重复, K重复)
partition_C:((2,2), 2,2) // (Atom值, M重复, N重复)

第一维是 Atom 自己规定的值分组。后面两个大小为 2 的 mode,分别来自各方向多出来的工作。

以 C 为例,将重复坐标记为 rm、rn,则:

m = lane/4 + 8*(v/2) + 16*wm + 32*rm
n = 2*(lane%4) + v%2 + 8*wn + 16*rn

v = 0~3,rm = 0~1,rn = 0~1

线程 0 的四组输出是:

(rm,rn)四个 C 坐标
(0,0)(0,0), (0,1), (8,0), (8,1)
(1,0)(32,0), (32,1), (40,0), (40,1)
(0,1)(0,16), (0,17), (8,16), (8,17)
(1,1)(32,16), (32,17), (40,16), (40,17)

对于按行连续存储的 C,完整分区 layout 为:

((2,2),2,2):((1,256),1024,16)

其中 Atom 内跨 8 行的 stride 是 8×32=256,M 方向重复一次跨 32 行,即 32×32=1024;N 方向重复一次跨 16 列,即 stride 16。

同样,A 的坐标公式增加 32*rm 和 16*rk,B 增加 16*rn 和 16*rk。每 lane 此时需要 32 个 A 值、16 个 B 值和 16 个累加器值。

这些重复工作仍由原来的四个 warp 完成。输入 Tensor 变大,不会让 partition_A/B/C 创建更多线程。

partition 与 partition_fragment 分别返回什么​

典型调用会接着创建 fragment:

auto tA = thr_mma.partition_A(A);
auto tB = thr_mma.partition_B(B);
auto tC = thr_mma.partition_C(C);

auto rA = thr_mma.partition_fragment_A(A);
auto rB = thr_mma.partition_fragment_B(B);
auto rC = thr_mma.partition_fragment_C(C);

tA/tB/tC 是原 Tensor 的线程访问视图。对于本文的 SM80 Atom,rA/rB/rC 则提供该线程用于 MMA 的值存储,设备代码通常将其保存在寄存器中。它们刚创建时还没有加载 A、B,也没有获得可用的初始累加值。

对于前面的较大输入,程序打印:

fragment_A = ((2,2,2),2,2):((1,2,4),8,16)
fragment_B = ((2,2), 2,2):((1,2), 4,8)
fragment_C = ((2,2), 2,2):((1,2), 4,8)

它们保留分区的逻辑 shape,但存储布局已经不同。以 C 为例,原 Tensor 视图的 stride 为 ((1,256),1024,16),fragment 的 stride 则为 ((1,2),4,8):需要累加的 16 个值在本线程的存储中紧凑排列。

因此,不能拿 tC 的真实矩阵偏移去索引 rC。两者通过相同的逻辑坐标对应元素,底层存储各有自己的布局。

这里的结论限定于本文采用的寄存器 MMA。MMA_Atom::make_fragment_A/B 也支持由 trait 指定的其他 fragment 类型,有些会创建特定视图,不能把所有架构的 partition_fragment 都概括为“分配一组普通寄存器”。

第三个参数:tile 范围与范围内的重排​

构造函数将第三个参数称为 permutations。它的每个方向可以是 _、静态整数或静态 Layout。默认值是 (_,_,_)。

源码中的两个函数把它的行为直接联系起来:

// 省略 static_assert
auto permutation_mnk() const {
auto perm = get<I>(PermutationMNK{});
return conditional_return(
is_underscore<decltype(perm)>{},
size<I>(AtomShape_MNK{}) * size<I+1>(get_thr_layout_vmnk()),
perm);
}

auto tile_size_mnk() const {
return size(permutation_mnk<I>());
}

_ 使用 Atom 尺寸与线程组数的乘积;显式配置则直接交给前面 logical_divide 的第一步。tile_size 取该配置的 size。

只传更大的整数范围​

例如:

auto mma_large_tile = make_tiled_mma(
Op{},
Layout<Shape<_2,_2,_1>>{},
Tile<_64,_32,_16>{});

这里没有指定非平凡的坐标置换。M、N 方向的 tile 范围扩大为 64 和 32,线程布局仍是:

ThrLayoutVMNK = (32,2,2,1):(1,32,64,0)
tile_shape = (64,32,16)

还是四个 warp、128 个线程。一份 64×32 输出 tile 内,每个 warp 需要处理 2×2 个 Atom 输出块,每 lane 有 16 个 C 值。对相配的 A、B、C,分区 shape 为:

A:((2,2,2),2,1)
B:((2,2), 2,1)
C:((2,2), 2,2)

将同样大小的 Tensor 交给前面默认第三参数的 mma,也会得到这些线程工作量。区别是 tile_shape 以及 get_layoutA/B/C_TV() 所描述的参考范围:前者仍报告基本 (32,16,16),这里则将额外重复也包含在显式的 (64,32,16) tile 内。

所以第三个参数不等同于“把第二个参数再乘一遍”。它能扩展每线程在一个所声明 tile 内的工作,而不增加参与线程。

用 Layout 改变 M 方向的坐标顺序​

再看一个可以手算的重排:

using PermM = Layout<Shape<_8,_4>, Stride<_4,_1>>;

auto mma_perm = make_tiled_mma(
Op{},
Layout<Shape<_2,_2,_1>>{},
Tile<PermM, Underscore, Underscore>{});

PermM 一共覆盖 32 个位置。对输入整数坐标 i=0~31,它按 shape (8,4) 解码,再计算:

p(i) = 4*(i%8) + i/8

几个位置的结果为:

原坐标 i017815162431
重排后 p(i)04281292331

它是对 0~31 的一一重排,tile 大小仍为 (32,16,16),线程数量仍为 128。可以先用原来的公式求出虚拟 M 坐标,再将它代入 p:

i = lane/4 + 8*(v/2) + 16*wm
m = p(i)
n = 2*(lane%4) + v%2 + 8*wn

于是四个 warp 的 lane 0 输出变为:

线程原来的 C 坐标重排后的 C 坐标
0(0,0), (0,1), (8,0), (8,1)(0,0), (0,1), (1,0), (1,1)
32(16,0), (16,1), (24,0), (24,1)(2,0), (2,1), (3,0), (3,1)
64(0,8), (0,9), (8,8), (8,9)(0,8), (0,9), (1,8), (1,9)
96(16,8), (16,9), (24,8), (24,9)(2,8), (2,9), (3,8), (3,9)

A 的 M 方向也应用相同的 p,所以所需输入与输出行仍然对应;B 没有 M 方向,保持原来的分工。

此时开头那张“每个 warp 占据一个连续 16×8 矩形”的图已经不适用。warp 0 负责余数为 0、1 的那些行,warp 1 负责余数为 2、3 的那些行,余数按 4 计算。完整输出仍被四个 warp 覆盖一次。

变化的是各个 lane 的 fragment 槽位对应哪些矩阵坐标。底层执行的仍是同一条指令,Tensor 的原始数据也没有因为构造 mma_perm 而被搬动。

本文选的范围能被 Atom 与线程组的分块整除,重排也是完整双射。配置一般 Layout 时,需要检查它是否适合相应的划分,不能将任意 shape、stride 都当成有效 permutation。

K 方向的线程组,与 K 方向的循环​

前面较大输入的 K=32,使用的线程组数仍为 (2,2,1)。每个 warp 沿 K 处理两个 16 项分块,并在自己的同一组累加器中继续累加。

如果改的是第二个参数:

auto mma_split_k = make_tiled_mma(
Op{}, Layout<Shape<_1,_1,_2>>{});

情况就不同了。现在有两个 warp,分别处在 K 方向的两组:

ThrLayoutVMNK = (32,1,1,2):(1,0,0,32)
tile_shape = (16,8,32)

warp 0:使用 k = 0~15
warp 1:使用 k = 16~31

它们读取不同的 A、B 分块,却对应同一个 16×8 输出范围。前面源码中 partition_C 只选择 (lane,wm,wn),没有选择 wk;因此线程 0 和线程 32 的 C 视图指向相同的四个逻辑输出位置。

如果两个 warp 分别从零累加,它们得到:

warp 0:P0(m,n) = Σ(k= 0..15) A(m,k)*B(n,k)
warp 1:P1(m,n) = Σ(k=16..31) A(m,k)*B(n,k)

最终结果需要把 P0 和 P1 加起来。make_tiled_mma 和本文使用的线程局部 gemm 不会自动完成跨 warp 归约,不能让两个 warp 都把各自部分和直接覆盖写回同一个 C。若原算式还有初始 C,也要在归约过程中安排它只被加入一次。

因此,单个 warp 遍历两个 K 分块,与两个 warp 分别处理 K 的一半,是两种不同的执行方式。配套程序对这个例子检查的是 A、B 各覆盖一次、C 覆盖两次的坐标关系,不把它当成已经完成归约的矩阵乘。

真正的 MMA 在哪里执行​

对于已经准备好共享内存数据的 kernel,本文四 warp 配置的典型使用过程可写成:

// kernel 内:128 个线程参与,sA/sB 已经写好并完成所需同步。
// sA 的逻辑方向为 (M,K),sB 为 (N,K),gC 为 (M,N)。
auto mma = make_tiled_mma(Op{}, Layout<Shape<_2,_2,_1>>{});
auto thr_mma = mma.get_slice(threadIdx.x);

auto rA = thr_mma.partition_fragment_A(sA);
auto rB = thr_mma.partition_fragment_B(sB);
auto rC = thr_mma.partition_fragment_C(gC);

auto copyA = make_tiled_copy_A(
Copy_Atom<SM75_U32x4_LDSM_N, half_t>{}, mma);
auto copyB = make_tiled_copy_B(
Copy_Atom<SM75_U32x2_LDSM_N, half_t>{}, mma);
auto thr_copyA = copyA.get_slice(threadIdx.x);
auto thr_copyB = copyB.get_slice(threadIdx.x);

auto tAsA = thr_copyA.partition_S(sA);
auto tBsB = thr_copyB.partition_S(sB);
auto tArA = thr_copyA.retile_D(rA);
auto tBrB = thr_copyB.retile_D(rB);

copy(copyA, tAsA, tArA);
copy(copyB, tBsB, tBrB);
clear(rC);
gemm(mma, rA, rB, rC);
copy(rC, thr_mma.partition_C(gC));

这是一段说明调用关系的代码片段,计算的是从零开始的 A×B。完整内核还需要准备指针、共享内存及同步,并选择满足 ldmatrix 地址、对齐和矩阵方向要求的共享内存布局。A、B 的加载路线可接着看从共享内存到 MMA:读懂 A、B 的 ldmatrix 与 retile_D。

make_tiled_copy_A/B 从 MMA 取得操作数的 TV 分工,retile_D 将已有的 MMA fragment 组织成 Copy 所需的目的视图。加载完成以后,gemm 才消费这些值。

对于本文的寄存器 fragment,gemm 把形状看成:

A:(V_A, M重复, K重复)
B:(V_B, N重复, K重复)
C:(V_C, M重复, N重复)

它沿 K 重复维循环,再遍历 M、N 的输出组合,最终把各 Atom 内部的值交给 MMA_Atom::call。该函数经过 mma_unpack,调用底层 Operation 的 fma,执行 mma.sync.aligned.m16n8k16.row.col.f16.f16.f16.f16。

对于 M=64、N=32、K=32 的例子,每个 warp 有 2×2×2=8 个 Atom 乘加步骤;每一步由该 warp 的 32 个 lane 共同参与。这是八次 warp 级指令执行,不是 32 条互不相关的标量矩阵乘。

这些形状也给出了复用方式:同一份 rA(_,rm,rk) 可以参与不同 rn 的输出,同一份 rB(_,rn,rk) 可以参与不同 rm 的输出。MMA 的输入和输出通过分区坐标对应起来。

与 make_tiled_copy 放在一起看​

前一篇 make_tiled_copy 从用户给定的线程布局和值布局出发,定义一套搬运分工。本篇的 Atom 则已经规定一条指令需要哪些 lane-value,构造函数在这个基础上排列线程组,并组织更多矩阵工作。

要决定的事情make_tiled_copymake_tiled_mma
底层操作Copy AtomMMA Atom
线程怎样排列第二个参数给出线程布局第二个参数给出 Atom 线程组的 M/N/K 布局
每线程有哪些值值布局结合 Copy Atom 组织Atom trait 加上矩阵重复与 permutation 决定
第三个参数本接口中的 val_layout各方向的 tile 范围或重排
取得原 Tensor 视图partition_S/Dpartition_A/B/C
执行操作copygemm 最终调用 MMA Atom

当数据将进入 MMA 时,直接采用 MMA 的 A/B TV 布局来构造 TiledCopy,便能把加载结果接到指令要求的槽位上。两套配置就在 make_tiled_copy_A/B 处连接。

打印布局,核对一次​

配套程序位于 cute_make_tiled_mma_trace.cu。在 CUTLASS 根目录运行:

nvcc -std=c++17 -arch=sm_80 -Iinclude \
cute_make_tiled_mma_trace.cu -o /tmp/cute_make_tiled_mma_trace
/tmp/cute_make_tiled_mma_trace

也可以用主机编译器运行布局检查:

g++ -x c++ -std=c++17 -O2 -Iinclude \
-I/usr/local/cuda/include -I/usr/local/cuda/include/cccl \
cute_make_tiled_mma_trace.cu -o /tmp/cute_make_tiled_mma_trace
/tmp/cute_make_tiled_mma_trace

程序使用 identity tensor,让分区结果直接返回矩阵坐标,再独立按本文的 lane、warp 和重复坐标公式核对。它遍历所有参与线程,检查七组配置:单 warp、四 warp、较大输入、交换 warp 顺序、显式 tile 范围、M 方向置换和 K 方向线程分工。

总计检查 20,992 个 A/B/C 坐标,检查每个矩阵元素出现的次数;当输入恰好等于声明的 tile 范围时,还逐项核对 get_layoutA/B/C_TV()。另外会打印 C 分区的中间布局,以及原 Tensor 视图与 fragment 的布局。

四 warp 配置的关键输出为:

ThrLayoutVMNK = (_32,_2,_2,_1):(_1,_32,_64,_0)
tile MNK = (_32,_16,_16)
partition_A shape = ((_2,_2,_2),_1,_1)
partition_B shape = ((_2,_2),_1,_1)
partition_C shape = ((_2,_2),_1,_1)
Verified 2048 coordinates; A/B/C multiplicity = 2/2/1.

较大输入的关键输出为:

partition_A shape = ((_2,_2,_2),_2,_2)
partition_B shape = ((_2,_2),_2,_2)
partition_C shape = ((_2,_2),_2,_2)
fragment_A layout = ((_2,_2,_2),_2,_2):((_1,_2,_4),_8,_16)
fragment_B layout = ((_2,_2),_2,_2):((_1,_2),_4,_8)
fragment_C layout = ((_2,_2),_2,_2):((_1,_2),_4,_8)
Verified 8192 coordinates; A/B/C multiplicity = 2/2/1.

CuTe 打印静态整数时带 _ 前缀,正文推导省略了这个前缀。第一维的嵌套结构保留了 Atom 的值分组,不能只凭外层逗号数量来数值个数。

这些检查在主机上完成,不启动 GPU kernel,也不执行 MMA 数值计算。它们验证的是构造、线程分工、坐标覆盖以及 TV 与 Tensor 分区的对应关系。

本文尺寸均能被相应分块整除。make_tiled_mma 不会自动处理边界、准备输入或选择累加器初始化方式;面对不能整除的矩阵,需要在外层切块与数据准备中安排有效范围和补零等处理。

对照源码​

本文依据当前工作区的 CUTLASS/CuTe C++ 实现,源码提交为 0b55a2f691d69981583568fd9eb69687b1f0de8a。