跳到主要内容

ldmatrix 怎样对上 MMA 的寄存器:从目的槽位反推源地址

读 CuTe 的 GEMM 代码时,经常会看到这样的加载过程:

auto tCrA = thr_mma.partition_fragment_A(sA);
auto tXsA = thr_copy.partition_S(sA);
auto tXrA = thr_copy.retile_D(tCrA);

copy(tiled_copy, tXsA, tXrA);

MMA 的布局规定 lane 0 的 A 值槽位 5 应当保存 A(0,9)。Copy Atom 的目的布局又把同一个 (lane=0,value=5) 映射成内部编号 129。这两张布局的输出不同,怎样保证最终取到正确的矩阵元素,并写进正确的槽位?

先给这个元素一个具体数值:

shared memory:A(0,9) 中存放数值 37
加载完成后: lane 0 的 tCrA(5,0,0) 中也应当是 37

MMA 布局确定槽位需要什么元素,Copy 布局确定哪个源位置会送到这个槽位。CuTe 将两套规则组合,计算源地址,并将指令输出绑定到已有 fragment 存储。 真正执行时,ldmatrix 按固定路由把数值 37 搬过去。

下面先从目的槽位反查源位置,再看源码怎样算出实际地址,最后走一遍加载和 MMA 的寄存器参数。全文始终沿用 A(0,9),源码对应 CUTLASS 提交 0b55a2f691d6。

先固定一个能手算的例子​

我们只看一个 warp,使用一条 SM80 MMA 和非转置的 ldmatrix.x4:

auto mma = make_tiled_mma(SM80_16x8x16_F16F16F16F16_TN{});
auto load_a = Copy_Atom<SM75_U32x4_LDSM_N, half_t>{};
auto tiled_copy = make_tiled_copy_A(load_a, mma);

一个 warp 有 32 个线程,warp 内的线程编号叫 lane,取值为 0…31。half 是 16 位浮点数,两个 half 可以打包到一个 32 位寄存器操作数中。

MMA 的计算尺寸为 M=16、N=8、K=16。本文只看 A:它是一个 16×16 矩阵,按 (m,k) 索引;每个 lane 提供 8 个 half,共同组成这 256 个 A 元素。

假设数据已经写入 shared memory,并且对参与加载的线程可见。基址满足 16 字节对齐,K 方向连续,每行 16 个 half:

// smem_a 指向已经准备好的 shared memory 数据。
auto sA = make_tensor(
make_smem_ptr(smem_a),
Layout<Shape<_16,_16>, Stride<_16,_1>>{});

auto thr_mma = mma.get_slice(lane);
auto thr_copy = tiled_copy.get_slice(lane);

auto tCrA = thr_mma.partition_fragment_A(sA);
auto tXsA = thr_copy.partition_S(sA);
auto tXrA = thr_copy.retile_D(tCrA);

本文用 shape:stride 表示布局。Shape 给出维度大小,stride 给出该维度走一步增加的元素偏移,因此:

sA.layout() = (16,16):(16,1)
元素偏移 = 16*m+k
字节偏移 = 2*(16*m+k)

后面用 0x1000 作为示例 shared memory 基址,便于手算地址。所有元素编号都从 0 开始,除法取整数;暂不展开 swizzle 和流水线。

第一步:明确目的槽位需要哪个 A 元素​

在 mma_traits_sm80.hpp 中,这条 MMA 的 A 布局为:

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

TV 是 thread、value 的缩写。输入的 T 是线程编号,本例就是 lane;V 是该线程内的逻辑值编号,以 half 为单位。输出将 A tile 的矩阵坐标按 q=m+16*k 编码。

取 lane 0、值编号 5:

lane 0 在 shape (4,8) 中展开为 (0,0)
value 5 在 shape (2,2,2) 中展开为 (1,0,1)

q = 32*0 + 1*0 + 16*1 + 8*0 + 128*1
= 144

m = q%16 = 0
k = q/16 = 9

因此,lane 0 的 A 值编号 5 应当保存 A(0,9)。partition_fragment_A(sA) 根据 MMA 的分工准备线程局部存储,本例中值编号 5 就对应 half 槽位 5。创建 fragment 时只准备槽位,真正读取数据要等到 copy。

现在可以把目标写成一个具体赋值:

// 只表达这个槽位的加载结果,实际搬运由 warp 协作的 ldmatrix 完成。
// 以下 tCrA 属于 lane 0。
tCrA(5,0,0) = sA(0,9);

这里同时出现了三个不同的数:

数值含义
q=144矩阵坐标 (0,9) 的逻辑编码
offset=9sA 中该元素的 half 存储偏移:16*0+9
37该位置实际存放的数据

在示例基址下,待取数据的地址是 0x1000+2*9=0x1012。接下来要确定:哪一个源 lane 应当提供包含这个元素的行地址?

第二步:从目的槽位反查 Copy 的源位置​

SM75_U32x4_LDSM_N 描述的是一条 ldmatrix.x4。它一次加载四个 8×8 的 16 位元素矩阵,每个目的 lane 得到四个 32 位输出,共 8 个 half。

Copy_Atom<...,half_t> 将 traits 中以 bit 为单位的布局转换成 half 单位。当前 Atom 的布局为:

S = AtomLayoutSrc = (32,8):(8,1)
D = AtomLayoutDst = (32,(2,4)):(2,(1,64))
R = AtomLayoutRef = D

源布局的值编号 p=0…7 表示一个源行内的 half;目的值编号 v=0…7 的 (2,4) 表示“每个输出两个 half,共四个输出”。两张布局输出同一套 Copy 内部元素编号 e:

S(tS,p) = 8*tS+p
D(tD,v) = 2*tD + v%2 + 64*(v/2)

先从目的 lane 0、值 5 出发:

D(0,5) = 2*0 + 5%2 + 64*(5/2)
= 129

再反查源位置。令 S(tS,p)=129,其中 p 取 0…7:

8*tS+p = 129

tS = 16
p = 1

这就是下面这个转换的含义:

right_inverse(S).compose(D)

Copy 目的 (0,5)
→ 内部元素 e=129
→ Copy 源 (16,1)

要填入 lane 0 的 half 槽位 5,必须由源 lane 16 所提供行内编号 1 的元素送来。 这一结论来自 Copy 指令的固定路由。e=129 在这里用于匹配源和目的位置。

接下来还需要把 A 的坐标赋给这个源位置。不能直接用 129 作为 sA 的元素下标。

第三步:将 Copy 路由接到 MMA 的矩阵分工​

make_tiled_copy_A 将 MMA 的 A 布局作为参数传入:

return make_tiled_copy_impl(
copy_atom,
mma.get_layoutA_TV(),
make_shape(tile_size<0>(mma), tile_size<2>(mma)));

TiledCopy 把这个布局保存为 TiledLayout_TV。该接口的语义是“reference TV 到矩阵位置”,因此可以记为:

F = TiledLayout_TV = mma.get_layoutA_TV()

F(reference_thread, reference_value) → A tile 的矩阵编号 q

当前 R=D,Copy 目的 TV 转成 reference TV 后,线程和值编号保持不变。所以 Copy 目的 (0,5) 的最终矩阵含义是:

目的 (0,5)
→ D:内部编号 129
→ right_inverse(R):reference (0,5)
→ F:矩阵编号 144,即 A(0,9)

这就是前面两套编号的连接点。D 先将目的 TV 转成 Copy 内部编号,再通过 right_inverse(R) 回到 reference TV,最后才交给 MMA 的矩阵映射 F。

将三个布局并列,可以看到“输出不同”发生在哪一层:

布局输入输出
MMA 的 F(0,5)矩阵编号 q=144
Copy Atom 的 D(0,5)内部元素编号 e=129
Copy 最终目的映射 F ∘ right_inverse(R) ∘ D(0,5)矩阵编号 q=144

Copy Atom 的内部编号与矩阵编号含义不同;组合后的 Copy 目的映射与 MMA 的矩阵分工则一致。

从这个对应关系出发,第二步反查到的源位置 (16,1) 就必须读取 A(0,9)。下面看 partition_S 如何落实这个要求。

第四步:partition_S 将源位置变成实际地址​

前面为了便于理解,从目的位置反查源位置。源码生成源视图时,则从源 TV 正向计算矩阵位置。tidfrg_S 传入的转换为:

return tile2thrfrg(
zipped_divide(stensor, Tiler_MN{}),
right_inverse(AtomLayoutRef{}).compose(AtomLayoutSrc{}));

tile2thrfrg 再把这个转换接到 TiledLayout_TV:

auto atom_layout_TV = zipped_divide(
TiledLayout_TV{}, make_shape(AtomNumThr{}, AtomNumVal{}));

auto trg_layout_TV = atom_layout_TV.compose(ref2trg, _);

本例整个 reference TV 和一个 Copy Atom 都是 32 个线程、每线程 8 个值,切分后剩余组大小为 1。省略这些大小为 1 的 mode,源到矩阵的映射就是:

F ∘ right_inverse(R) ∘ S

将源 (16,1) 代入:

S(16,1) = 129

right_inverse(R)(129)
→ reference TV 的展平编号 tv=160
→ (thread,value)=(0,5),因为 160=0+32*5

F(0,5) = 144
→ (m,k)=(0,9)

源位置经过组合后,得到的也是 A(0,9)。更一般地,Copy 路由中相互对应的源、目的位置满足 S(src_tv)=D(dst_tv)=e,所以两端接入同一个 right_inverse(R) 和 F 后,会得到相同的矩阵坐标。

但现在仍只是求出了坐标,还要让源 tensor 真正指向该元素。tile2thrfrg 整理线程和值的 mode 后执行:

auto tv_tensor = tensor.compose(thrval2mn, _);

这一步将刚才的逻辑映射接到 sA 原来的布局上。对 (16,1),结果为:

源 TV=(16,1)
→ 矩阵坐标 (0,9)
→ sA.layout()(0,9)=9
→ sA.data() 中的第 9 个 half
→ 地址 0x1012,里面存放数值 37

partition_S 保留原 tensor 的存储起点,并用组合后的布局构造源视图。因此,tXsA 的元素访问最终能够引用这个真实的数据位置。源码中的 Tiler_MN 是通用名字;在当前 A tensor 上,它的方向实际是 M、K。

指令需要的是行首地址​

ldmatrix 的每个源 lane 提供一行的起始地址。因此还要计算 lane 16 的源值 0:

源 (16,0)
→ S:128
→ reference (0,4)
→ F:128,即 A(0,8)
→ sA 元素偏移 8
→ 行首地址 0x1010

将这行的前两个值放在一起:

源位置对应 A 元素half 偏移示例地址
(16,0)A(0,8)80x1010
(16,1)A(0,9)90x1012

继续计算源值 2…7,得到 A(0,10)…A(0,15)。这些元素在当前存储布局中连续,因此 lane 16 最终向指令提供 &A(0,8),行内编号 1 就是待搬运的 A(0,9)。

到这里,读取哪一份数据已经确定。接下来要让指令输出引用正确的 fragment 槽位。

第五步:将 Copy 输出绑定到 tCrA 的槽位​

retile_D(tCrA) 使用 fragment 原来的数据起点,并从原布局推导 Copy 的目的访问方式:

return make_tensor(
static_cast<DTensor&&>(dtensor).data(),
TiledCopy::retile(dtensor.layout()));

本例打印的布局为:

tCrA:((2,2,2),1,1):((1,2,4),0,0)
tXrA:((8,1), 1,1):((1,0), 0,0)

第 0 个 mode 分别表示“一条 MMA 当前 lane 的 8 个 A 值”和“一次 Copy 的 8 个 half,共 1 组”。后两个 mode 是 M、K 方向的重复,本例都为 1。

对值编号 5,两种布局计算的存储偏移为:

tCrA:5 展开为 (1,0,1),偏移 = 1+2*0+4*1 = 5
tXrA:5 展开为 (5,0), 偏移 = 5+0*0 = 5

两者又保留同一个存储起点,因此:

&tXrA(5,0,0) == &tCrA(5,0,0);

随后,copy_unpack 将传入的目的视图按指令接口重解释:

Tensor rD = recast<RegTypeDst>(dst); // 本例 RegTypeDst=uint32_t

每两个相邻 half 成为一个 32 位输出。detail::explode 按顺序传参,展开后的调用形式为:

CopyOp::copy(rS(0), rD(0), rD(1), rD(2), rD(3));

因此,当前 lane 的输出引用分别绑定为:

指令输出fragment 的 half 槽位后续 MMA 的 A 操作数
dst0,即 rD(0)0、1a0
dst1,即 rD(1)2、3a1
dst2,即 rD(2)4、5a2
dst3,即 rD(3)6、7a3

lane 0 的 dst2 高 16 位已经绑定到 tCrA 的 half 槽位 5。 这是输出参数与 fragment 存储的对应关系,加载指令执行后会直接写入该位置。

第六步:执行 ldmatrix,搬运数值 37​

源行地址和目的输出都准备好后,warp 共同执行:

ldmatrix.sync.aligned.x4.m8n8.shared.b16
{dst0,dst1,dst2,dst3}, [addr];

这条指令一次处理四个 8×8 矩阵,编号为 b=0…3。各组 lane 提供的行地址,经前面的布局推导,指向下面这些 A 区域:

矩阵编号 b提供行地址的 lane对应 A 的范围
00…7A(0…7,0…7)
18…15A(8…15,0…7)
216…23A(0…7,8…15)
324…31A(8…15,8…15)

对非转置的 m8n8.x4,矩阵内 row、col 都取 0…7,硬件分发规则是:

提供这一行地址的 lane = 8*b+row
接收该元素的 lane = 4*row+col/2
目的 32 位输出编号 = b
输出中的 half 编号 = col%2 // 0 为低位,1 为高位

目标元素 A(0,9) 在第 b=2 个矩阵的 row=0、col=1 处。因此:

lane 16 提供行首地址 0x1010
→ 行内编号 1,地址 0x1012,读到数值 37
→ 接收 lane = 4*0+1/2 = 0
→ 输出编号 2,即 dst2
→ half 编号 1,即高 16 位
→ 直接写入 lane 0 的 tCrA 槽位 5

整个 warp 的线程各自提供地址和输出参数,由硬件完成跨 lane 的分发。源 lane 16 提供这一行的地址,目的 lane 0 接收其中相应的值。

copy_sm75.hpp 的封装将源参数的 shared memory 地址传入指令,并将四个输出引用绑定到 PTX 操作数:

uint32_t smem_int_ptr = cast_smem_ptr_to_uint(&smem_src);

asm volatile(
"ldmatrix.sync.aligned.x4.m8n8.shared.b16 {%0, %1, %2, %3}, [%4];\n"
: "=r"(dst0), "=r"(dst1), "=r"(dst2), "=r"(dst3)
: "r"(smem_int_ptr));

对源 lane 16,传入的是行首 0x1010,行内元素 1 由硬件读取。指令参数中没有 129、144,也没有 (m,k);这些逻辑编号已经用于生成地址和访问方式。

后续 gemm 将 tCrA 的片段交给 MMA Atom,mma_unpack 同样通过 recast<uint32_t> 将相邻 half 组成操作数,并按顺序传入 a0…a3。槽位 5 因而成为 a2 的高 16 位,MMA 按硬件约定将该位置作为 A(0,9) 使用。

recast 保留 half 的原始位模式。此时装在那个位置里的数据是数值 37,Copy 输出和 MMA 输入引用同一份 fragment 存储。

把这次加载对应到两端的数据访问​

对于 lane 0 的目标槽位,这次加载的语义可以写成:

q = F(0,5); // 144
m = q % 16; // 0
k = q / 16; // 9

tCrA(5,0,0) = sA(m,k); // 将数值 37 写入槽位 5

这是对结果的描述。实现时,源地址由 partition_S 根据 Copy 路由计算,目的存储由 retile_D 和 copy_unpack 绑定,warp 的 ldmatrix 完成搬运。

可以把本例的两端访问并列起来:

读:sA.data() + sA.layout()(0,9) → shared memory 中的 half 偏移 9
写:tCrA.data() + tCrA.layout()(5,0,0) → 当前 lane fragment 的 half 槽位 5

MMA 布局提供的 (lane,value) → 矩阵坐标 关系,正是连接这两个访问位置的依据。它本身不执行数据搬运;数据读取和输出写入发生在加载指令执行时。

本文中 R=D,两边都是 32 个 lane、每 lane 8 个 half,线程编号对应一致,所以目的 TV 可以直接按 MMA 的值编号解释。更大的 tile 会按 Copy Atom 的大小切分 TV,再在各组内应用相同的转换;逐槽位相等的具体形式也要随配置推导。

打印布局,核对一次​

配套程序:cute_ldmatrix_mma_coordinate_trace.cu。在 CUTLASS 根目录编译运行:

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

/tmp/cute_ldmatrix_mma_coordinate_trace

CUDA 头文件路径按本机安装位置调整。程序在主机上使用布局和带 shared memory 标签的数组视图,模拟上述路由,不执行 GPU 的 ldmatrix 或 MMA。示例地址 0x1000 仅用于打印,不会被解引用。

程序先用 A(m,k)=16*m+k 填入值标签,再将 A(0,9) 改为 37。这样可以直接看到,搬运的数据与矩阵编号、内存偏移是不同的量。

首先打印目的需求和反查结果:

MMA target (0,5): q=144; Copy final destination q=144
Copy route: destination (0,5) -> e=129 -> source (16,1)

再核对源地址的正向推导:

source TV | e | reference TV | tv | q | A(m,k) | half offset | example byte address
(16,0) | 128 | (0,4) | 128 | 128 | A(0,8) | 8 | 0x1010
(16,1) | 129 | (0,5) | 160 | 144 | A(0,9) | 9 | 0x1012

最后,lane 0 的 fragment 应当得到:

dst0 / a0: low=0, high=1
dst1 / a1: low=128, high=129
dst2 / a2: low=8, high=37
dst3 / a3: low=136, high=137

程序枚举全部源行和行内元素,用独立写出的 m8n8.x4 分发公式对照 Copy traits,并核对源矩阵坐标、目的 MMA 坐标、fragment 存储别名和操作数中的 half 位模式。最后应输出:

PASS: 256 hardware routes, 256 fragment slots, 128 packed operands verified (host only).

对照源码​

下面的链接固定到本文使用的提交,便于逐行核对: