跳到主要内容

从 atile 到 upcast:读懂 CuTe 的 A 广播与右逆布局

读 retile_D 的内部实现时,有一行容易让人困惑:

auto frg_layout_mn = upcast<TiledNumThr{} * V>(
right_inverse(TiledLayout_TV{})
.with_shape(shape(Tiler_MN{})));

假设 A tile 是 32×16,只有 512 个矩阵元素,而 TiledNumThr*V 却等于 128×8=1024。为什么能做 upcast<1024>?为什么右逆布局中会出现 stride 512?为什么最后两个矩阵方向的范围都是 1?

这些问题的起点是 get_layoutA_TV() 中的 atile。它把 N 方向的线程广播接入 A 布局,使同一个 A 元素对应多个 TV,但保留完整线程域的编号。下面固定同一组配置,沿着源码把这件事算清楚。

先区分矩阵坐标与 TV 坐标​

本文使用 SM80 的 16×8×16 MMA Atom,并沿 M、N 方向各排列两个 warp:

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

它的整个 MMA tile 是:

(M,N,K) = (16*2, 8*2, 16*1) = (32,16,16)

A 使用 (M,K) 坐标,所以 A tile 是 32×16。本文讨论恰好覆盖这个 tile 的 fragment,每线程第一维有 8 个 half 值。

符号本文含义取值或范围
lanewarp 内的线程编号0…31
wmM 方向的 warp 坐标0、1
wnN 方向的 warp 坐标0、1
t整个 TiledMMA 的线程编号0…127
v线程内一个 half 的值编号0…7
qA tile 的逻辑矩阵编号q=m+32*k,范围 0…511
tv展平后的线程和值编号tv=t+128*v,范围 0…1023

其中:

t = lane + 32*wm + 64*wn

q 编码矩阵位置,tv 编码线程和值的位置。两者有不同的编号范围。本文的 q 也不是共享内存地址偏移;实际存储地址还要由源 tensor 的布局计算。

make_tiled_copy_A 直接采用 MMA 的 A 布局和 A tile 大小:

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

因此这一例子中:

TiledLayout_TV = mma.get_layoutA_TV()
TiledNumThr = 128
V = size<0>(tCrA) = 8
Tiler_MN = (32,16)

Tiler_MN 是 Copy 实现的通用名字;在 A 操作数这里,两维实际是 M、K。V 取输入 fragment 的第一维大小,不是任意大 fragment 的总元素数。

atile 为何让 512 个 A 元素对应 1024 个 TV​

get_layoutA_TV() 先创建参考矩阵布局:

auto ref_A = make_layout(
make_shape(tile_size_mnk<0>(), tile_size_mnk<2>()));

本例中它是:

ref_A = (32,16):(1,32)
ref_A(m,k) = m+32*k

随后通过以下组合返回最终的 TV 布局:

return thrfrg_A(ref_A)
.compose(atile, _)
.compose(thridx_2_thrid, _);

先看 thrfrg_A(ref_A)。它将 MMA Atom 的 A 分工与 M、K 方向的线程排列组合起来,线程输入结构是:

(ThrV,(ThrM,ThrK))

这里 ThrV 是 Atom 内的线程坐标,本例就是 lane;它与线程内的值编号 v 不同。由于 A 只需要 M、K,当前线程域大小是:

32*2*1 = 64

配合每线程 8 个值,共有 64*8=512 个 TV,恰好覆盖 A 的 512 个不同元素。

但是,完整的 MMA 线程组织还包含 N 方向。源码用 atile 将它接入:

auto atile = make_tile(
_,
make_tile(
make_layout(
make_shape(size<1>(thr_layout_vmnk_),
size<2>(thr_layout_vmnk_)),
make_stride(Int<1>{}, Int<0>{})),
_));

本例中间那张布局是:

(ThrM,ThrN):(1,0) = (2,2):(1,0)

(wm,wn) → wm+0*wn → wm

atile 在求值时保留 wm、忽略 wn。将它接到 thrfrg_A(ref_A) 的输入端后,新布局可以接收包含 N 的完整线程坐标,而不同 wn 会落到相同的 A 位置。

阶段线程域大小TV 数量不同 A 元素数量
thrfrg_A(ref_A)32*2=6464*8=512512
接入 atile 后32*2*2=128128*8=1024512

这就是 A 在 N 方向的广播:两个 N warp 分块计算不同的输出列,但使用相同的 A。广播表达重复的元素需求,没有将完整线程域压缩成 64。

最后的 thridx_2_thrid 将线程编号转换成逻辑线程坐标;本例它等价于 128:1。最终 F 的输入仍是全部 128 个线程及其值编号。

把 get_layoutA_TV 写成一个可以计算的公式​

为了看清后面的 stride,先拆开 lane 和值编号:

lane = t0+4*t1 t0=0…3,t1=0…7
v = v0+2*v1+4*v2 v0、v1、v2 均为 0 或 1

这条 MMA Atom 的 A 布局给出:

Atom 内的 m = t1+8*v1
Atom 内的 k = 2*t0+v0+8*v2

M 方向的 warp 排列再增加 16*wm;N 方向经过 atile 广播,不增加 A 的矩阵坐标。因此:

m = t1+8*v1+16*wm
k = 2*t0+v0+8*v2

代入 q=m+32*k:

F(t,v) = q
= 64*t0+t1+16*wm+0*wn+32*v0+8*v1+256*v2

省略大小为 1 的 mode,合并后的布局正是:

子轴: t0 t1 wm wn v0 v1 v2
shape: 4 8 2 2 2 2 2
q stride: 64 1 16 0 32 8 256

wn 的 stride 为 0,但 shape 仍为 2。这一个子轴同时说明了两件事:输入 TV 数量翻倍,输出的不同矩阵元素数量不变。

例如,两个 N warp 中的 lane 0、值 4 都需要 A(0,8):

F(t=0, v=4) = 256
F(t=64,v=4) = 256

它们的原 TV 编号分别是:

0 +128*4 = 512
64+128*4 = 576

right_inverse 选一个代表,但保留原来的 TV 编号​

令:

auto F = mma.get_layoutA_TV();
auto G = right_inverse(F);

G 需要满足:

F(G(q)) = q

也就是每个矩阵元素反查出一个可用的 TV。它不必还原所有重复的 TV,因此不要求 G(F(tv))=tv。

源码先对 F 的完整 shape 计算前缀积,作为原输入展平编号的权重:

子轴: t0 t1 wm wn v0 v1 v2
shape: 4 8 2 2 2 2 2
TV 权重: 1 4 32 64 128 256 512

这些权重也可以直接从 TV 编号公式中看出:

tv = t+128*v
= t0+4*t1+32*wm+64*wn+128*v0+256*v1+512*v2

接着,right_inverse 按 q stride 排序,构造连续覆盖。wn 的零 stride 无法扩展连续覆盖,因此被跳过,本例相当于选 wn=0 的代表。

跳过广播维度不会重新计算原 TV 编号的权重。 v2 的权重仍包含完整线程域中的 ThrN=2:

v2 的 TV 权重 = 4*8*2*2*2*2 = 512

所以:

G(256) = 512

F(512) = 256
G(F(576)) = 512,而不是 576

这里 F 接收单个整数时,将它按自身 shape 解读为展平的 TV 坐标。G 选中了 TV 512,不再返回同一元素的另一个代表 TV 576。

实际得到的右逆布局为:

G = (8,2,2,2,4,2):(4,256,32,128,1,512)
size(G) = 512

size(G)=512 限制的是输入数量,输出仍然是原来 0…1023 空间中的 TV 编号。例如一个简单布局 2:512 也只有两个输入,却分别输出 0 和 512;shape 不会限制 stride 必须小于元素数量。

with_shape 把右逆的输入组织成 M、K​

现在看:

auto H = right_inverse(F)
.with_shape(make_shape(_32{}, _16{}));

with_shape 的实现是:

return composition(*this, make_layout(shape));

因此 H 的含义为:

H(m,k) = G(m+32*k)

它将矩阵的二维坐标接到 G 的输入端,输出仍是 G 选中的 TV 编号。实际布局为:

H = ((8,2,2),(2,4,2)):((4,256,32),(128,1,512))

把它的两个顶层轴拆开:

m = m0+8*m1+16*m2 shape=(8,2,2)
k = k0+2*k1+8*k2 shape=(2,4,2)

与前面的坐标对应:

(m0,m1,m2) = (t1,v1,wm)
(k0,k1,k2) = (v0,t0,v2)

因此输出 TV 编号是:

H(m,k) = 4*m0+256*m1+32*m2+128*k0+k1+512*k2

现在 stride 512 的意义就很具体了:k2 从 0 变成 1,矩阵 K 增加 8,对应线程内 v 增加 4,原 TV 编号增加 128*4=512。

输入矩阵坐标q选中的 (t,v)H 输出的 tv
(0,0)0(0,0)0
(0,8)256(0,4)512
(0,9)288(0,5)640

H 虽然只有 512 个输入,输出却没有压紧为 0…511。 选中 wn=0 后,每个 v 对应的 TV 编号是 128*v…128*v+63,后面的 64 个位置仍然留给未被右逆选中的 N 广播副本。

with_shape 没有凭空产生 stride 512。它将右逆已有的 TV 权重组织到了 K 的对应子轴上。

upcast 为什么使用 1024,而不是 512​

回到目标表达式:

auto frg_layout_mn = upcast<128*8>(H);

128*8 来自完整线程数和输入 fragment 的第一维值数。它表示全部线程各自前 8 个值所对应的 TV 范围。

这一范围大小为 1024。虽然右逆只选了其中 512 个代表,它们的编号仍散布在这个范围中,例如:

v=0: 0…63
v=1:128…191
v=2:256…319
...
v=7:896…959

upcast 改变布局的编号粒度,并同步缩小输入 shape。它不要求输入坐标数量大于或等于参数 N;这里也没有 Tensor 元素类型的转换。

对本例正的静态 stride,源码逐个子轴计算:

new_shape = ceil_div(old_shape, ceil_div(N, old_stride));
new_stride = ceil_div(old_stride, N);

本例 N 与各 stride 都满足源码的整除条件,所以下面可以用“每组覆盖多少个原坐标位置”来理解内层除法。

先看 K 的最后一个子轴 (shape,stride)=(2,512)。它有两个坐标,对输出 TV 编号的贡献分别为 0 和 512。以 1024 为一组,这两个位置在同一组中:

每组覆盖的原坐标数量 = ceil_div(1024,512) = 2
所需组数 = ceil_div(2,2) = 1

其他子轴也一样:

子轴原 shape原 stride每组覆盖的原坐标数新 shape
M₀84256ceil_div(8,256)=1
M₁22564ceil_div(2,4)=1
M₂23232ceil_div(2,32)=1
K₀21288ceil_div(2,8)=1
K₁411024ceil_div(4,1024)=1
K₂25122ceil_div(2,2)=1

ceil_div 是向上取整的除法。例如 8/256=0.03125,向上取整就是 1:这一子轴只有 8 个位置,虽然不足一整组,仍需要保留一组。

所有原 stride 都大于 0、小于 1024,所以 ceil_div(old_stride,1024) 也都等于 1。实际结果是:

frg_layout_mn
= ((1,1,1),(1,1,1)):((1,1,1),(1,1,1))

product_each(shape(frg_layout_mn)) = (1,1)

作为核对,H 的最大输出是:

7*4 + 1*256 + 1*32 + 1*128 + 3*1 + 1*512 = 959

全部选定 TV 都在第一个 1024 范围内,与上面的布局计算一致。返回的新布局已经缩小了输入域,不能把它当成保留全部原 (m,k) 输入、仅将每个输出做整数除法。

如果对同一个 H 改用 upcast<128*4>,K 最后一个子轴就会留下:

new_shape = ceil_div(2, ceil_div(512,512)) = 2

此时 product_each(shape(...))=(1,2),对应 K 的前后两段。这说明压缩粒度取决于所考虑的每线程值范围,而不是简单取矩阵的不同元素数量。

放回 retile_D,结果为 1 表示什么​

retile 随后会使用这份布局计算 fragment 收集范围:

auto frg_layout_v = zipped_divide(
logical_product(make_layout(V), right_inverse(frg_layout_mn)),
make_layout(AtomNumVal{}));

auto t_tensor = zipped_divide(
tensor, prepend(product_each(shape(frg_layout_mn)), V));

本例 product_each(...)=(1,1),因此后一个 tiler 是 (8,1,1):第一维保留每线程的 8 个值,M、K 方向各取一个 fragment 位置。

这 8 个 half 没有消失。后面的 logical_product(make_layout(V), ...) 继续保留它们,再按 Copy Atom 每次需要的值数分组。例如 A 使用 ldmatrix.x2 时,每次写入 4 个 half,最终仍会将 8 个值组织成 (4,2)。

右逆选择 wn=0 的代表,也不会使其他 N warp 停止加载。这里计算的是分组布局;ThrCopy::retile_D 最终为每个线程使用该线程自身 fragment 的 data() 建立视图,各 warp 仍然准备自己需要的 A 寄存器。

这条调用链中,atile 建立 A 的 N 广播,right_inverse 选择保留原编号的 TV 代表,with_shape 接入 M、K 坐标,upcast 则提取按当前 V 范围划分的 fragment 收集布局。

对照源码​

本文依据 CUTLASS 提交 0b55a2f691d6。布局数值已用主机端程序核对,包括全部 1024 个 TV 的组合结果,以及全部 512 个矩阵编号的 F(G(q))=q;无需启动 GPU kernel 即可验证这些布局关系。

内容源码
MMA Atom 的 A 布局mma_traits_sm80.hpp
thrfrg_A 的 M、K 线程划分mma_atom.hpp
get_layoutA_TV 与 atilemma_atom.hpp
make_tiled_copy_A 选择布局和 tilecopy_atom.hpp
right_inverse 的前缀积和连续覆盖layout.hpp
with_shape 的组合layout.hpp
upcast 的 shape、stride 公式layout.hpp
内部 retile 与外层 retile_Dcopy_atom.hpp、copy_atom.hpp

若要继续对照实际加载的源地址、寄存器槽位和 A、B 的不同分组,可以阅读从共享内存到 MMA:读懂 A、B 的 ldmatrix 与 retile_D。