跳到主要内容
读懂 CuTe

Layout 与布局代数 / 第 1 章

读懂 CuTe 的 Layout:从 tuple 到坐标映射

访问一张按行存放的 2×3 矩阵,通常会写:

data[m * 3 + n];

这里其实做了两件事:先将坐标 (m,n) 换算成整数下标,再用下标读取数据。

CuTe 把前一件事交给 Layout:

using namespace cute;

auto layout = make_layout(make_shape(_2{}, _3{}),
make_stride(_3{}, _1{}));

layout(1,2); // 返回 5

Layout 保存坐标的形状,以及从坐标计算下标的规则。它没有接收 data,这一步也没有读取矩阵。

如果一直是普通二维矩阵,两个大小、两个步长就够用了。但在 CuTe 中,一个维度还可以拆成“组内位置、组编号”,拆出来的维度也可能继续分组。那些看起来复杂的括号,正是用 tuple 保存的层次结构。

先把括号读懂,再按 Shape 分解坐标,用 Stride 算出索引。这篇先把这些基础讲完整,Product 和 Divide 留给后续两篇。

tuple:先理解括号里装了什么​

tuple 是一组有顺序的对象。它与数组的一个区别是,每个位置可以放不同类型的对象:

auto t = make_tuple(2, _3{});

第一个位置是普通 int,第二个位置是 CuTe 的编译期整数。CuTe 的 tuple 用法与 std::tuple 相近,并提供可以在 CPU 和 GPU 上使用的实现。

访问位置用 get,编号从 0 开始:

get<0>(t); // 2
get<1>(t); // 编译期整数 3

这里的 <0>、<1> 是模板参数;tuple 各位置的类型可以不同,所以不能把它当作普通数组来写 t[i]。

2 与 _2{},数值一样,类型不同​

CuTe 经常出现这种写法:

int n = 3; // 普通运行时整数
auto s = _3{}; // CuTe 编译期整数

_3 是 Int<3> 的简写,数值 3 保存在类型中。_3{} 则是创建这个类型的对象,和 Int<3>{} 等价。

这让 CuTe 在编译期知道某个维度的大小或步长,进而简化布局运算。普通 int 也可以用于 Layout,只是类型本身不携带固定数值。

例如下面两种布局,在 n=3 时都可以计算 3*m+n_coord:

auto fixed = make_layout(make_shape(_2{}, _3{}),
make_stride(_3{}, _1{}));

int n = 3;
auto mixed = make_layout(make_shape(_2{}, n),
make_stride(n, _1{}));

前者全部是静态整数;后者混用了静态和动态整数。

打印时,CuTe 用下划线区别静态数值:

fixed = (_2,_3):(_3,_1)
mixed = (_2,3):(3,_1)

下文为便于阅读,通常省略这些打印用的下划线,将前者写成 (2,3):(3,1)。

tuple 可以继续嵌套​

再看这个对象:

auto t = make_tuple(_2{}, make_tuple(_3{}, _4{}));
// (2,(3,4))

它的最外层只有两个位置:第一个放 2,第二个放子 tuple (3,4)。

用文本树展开,可以看清顶层位置与叶子的区别:

t = (2,(3,4))
├─ get<0>(t) → 2
└─ get<1>(t) → (3,4)
├─ get<1,0>(t) → 3
└─ get<1,1>(t) → 4

顶层只有两个位置,但整棵树有三个整数叶子:2、3、4。

因此这两句的含义不同:

get<1>(t); // (3,4)
get<1,0>(t); // 3,等价于 get<0>(get<1>(t))

括号不能随意删。(2,(3,4)) 与 (2,3,4) 的叶子数字相同,顶层结构却不同;后面给 Layout 传坐标时,这种区别就会表现出来。

IntTuple:用同一套结构表达 Shape、Stride 和坐标​

不是所有 tuple 都只装整数。CuTe 把“一个整数,或者由这类对象递归组成的 tuple”称为 IntTuple。

所以这些都属于 IntTuple:

2
(2,3)
(2,(3,4))

第一个是单个整数;后两个是有不同层次结构的整数 tuple。

对 IntTuple,有三个常用操作:

操作回答的问题对 (2,(3,4)) 的结果
rank最外层有几个位置?2
depth最深有几层 tuple?2
size所有叶子数字相乘是多少?2×3×4=24

这里的 size 是 IntTuple 的数值乘积,不是 tuple 的元素数量,也不是 C++ 对象占用的字节数。

再对照几个例子:

写法rankdepthsize
6106
(6)116
(2,3,4)3124
(2,(3,4))2224

CuTe 将单个整数的 rank 约定为 1。因此单个整数 6 与单元素 tuple (6) 的 rank 相同,但 depth 不同。代码中后者要用 make_tuple(_6{}) 创建。

Shape、Stride、Coord 是不同用途的名字​

它们都沿用 IntTuple 的结构:

auto shape = make_shape(_2{}, _3{});
auto stride = make_stride(_3{}, _1{});
auto coord = make_coord(1, 2);
  • Shape 说明每个坐标分量有多大。
  • Stride 说明该分量增加 1 时,输出增加多少。
  • Coord 是这次访问实际使用的坐标。

这些名字用于说明角色,并不是三种互不兼容的数据容器。在源码中,Shape、Stride、Coord 都是 cute::tuple 的别名;如果叶子的类型一样,构造出来的 C++ 类型也可以相同。

Shape 和 Stride 需要有相同的树形结构。例如:

Shape = (2,(2,3))
Stride = (3,(6,1))

每个 Shape 叶子都能找到对应的 Stride 叶子。这叫结构一致,CuTe 用 congruent(shape,stride) 检查。

把上面的 Stride 写成 (3,6,1) 就不一致了:它的顶层有三个位置,而 Shape 的顶层只有两个。数值个数相同,不代表可以丢掉层次。

Layout:把 Shape 和 Stride 放到一起​

先回到普通的二维例子:

auto row = make_layout(make_shape(_2{}, _3{}),
make_stride(_3{}, _1{}));
// (2,3):(3,1)

本文约定第一个坐标 m 是行,第二个坐标 n 是列,都从 0 开始。合法坐标满足 0≤m<2、0≤n<3,计算方式为:

row(m,n) = 3*m + n

沿行方向移动一步,跳过 3 个元素;沿列方向移动一步,前进 1 个元素。这就是行主序。

如果 Shape 不变,把 Stride 改成 (1,2):

auto column = make_layout(make_shape(_2{}, _3{}),
make_stride(_1{}, _2{}));
// column(m,n) = m + 2*n

就得到列主序:

把两种布局的输出直接填进矩阵。在相同的 (1,1) 位置,行主序返回 4,列主序返回 3:

m/n012
0012
1345
行主序 (2,3):(3,1):offset = 3m + n
m/n012
0024
1135
列主序 (2,3):(1,2):offset = m + 2n

例如同一个坐标 (1,1):

row(1,1); // 4
column(1,1); // 3

图中格内的数字是 Layout 的输出索引。若用它访问普通数组,它就是从数组起点开始的元素偏移;不是矩阵中保存的数据值,也不是字节偏移。

Layout 也可以输出其他编号,例如线程编号。这里先以数组偏移为例,便于把映射算清楚。

不写 Stride,会生成什么​

auto layout = make_layout(make_shape(_2{},_3{}));
// (2,3):(1,2)

默认使用 LayoutLeft,左边的 mode 连续,按从左向右的前缀乘积生成步长。对这个普通二维 Shape,它得到列主序。

也可以显式指定:

auto left = make_layout(make_shape(_2{},_3{}), LayoutLeft{});
auto right = make_layout(make_shape(_2{},_3{}), LayoutRight{});
// left = (2,3):(1,2)
// right = (2,3):(3,1)

因此,Shape 并不决定行主序或列主序,不能只看 (2,3) 就判断元素如何存放。

嵌套 Layout:一个 mode 里面还可以有子坐标​

现在将第二个 mode 拆成两部分:

auto h = make_layout(
make_shape(_2{}, make_shape(_2{},_3{})),
make_stride(_3{}, make_stride(_6{},_1{})));

// (2,(2,3)):(3,(6,1))

将完整坐标写成 (m,(a,b))。Shape 和 Stride 对应起来后,公式就是:

0≤m<2,0≤a<2,0≤b<3

h(m,(a,b)) = 3*m + 6*a + b

例如:

h(make_coord(1,make_coord(0,2))); // 3*1+6*0+2 = 5

这里有三个叶子坐标,不能把它直接当作普通的二维行列。可以先固定 a,分别画出两张以 m 为行、b 为列的矩阵:

m/b012
0012
1345
固定 a=0:h(m,(0,b)) = 3m + b
m/b012
0678
191011
固定 a=1:h(m,(1,b)) = 3m + 6 + b

高亮位置都满足 m=1、b=2。改变 a 从 0 到 1,会让输出从 5 增加到 11;增加的 6 正是 a 对应的 Stride。

第二个 mode 虽然有两个子坐标,在外层仍算一个 mode:

rank(h) = 2
depth(h) = 2
size(h) = 2×2×3 = 12

shape<1>(h) = (2,3)
size<1>(h) = 6

shape<1> 返回结构,size<1> 返回这部分所有大小的乘积。它们回答的不是同一个问题。

还可以继续向下取:

shape<1,1>(h); // 3
layout<1>(h); // (2,3):(6,1),第二个 mode 自己也是一个子布局

这样的层次结构可以保留维度的分组含义。比如一组 6 个位置,可以按两个子坐标组织,后续代码便可以整体处理这一组,也可以继续访问里面的部分。

嵌套 Shape 的默认步长也按叶子生成​

如果只给出相同的 Shape:

auto shape = make_shape(_2{},make_shape(_2{},_3{}));

auto left = make_layout(shape);
auto right = make_layout(shape,LayoutRight{});

结果为:

left = (2,(2,3)):(1,(2,4))
right = (2,(2,3)):(6,(3,1))

它们分别按所有叶子从左向右、从右向左生成紧凑步长,再放回原来的树形结构。本文手动指定的 (3,(6,1)) 是第三种规则,不能因为 Shape 相同,就把它与其中任一种混为一谈。

输入一个整数时,CuTe 怎样拆坐标​

Layout 可以接收完整的 tuple 坐标,也可以接收一个整数编号。

先看普通的 row=(2,3):(3,1):

row(3);

这不是直接返回偏移 3。整数 3 是输入坐标,还要先按 Shape (2,3) 分解:

i = m + 2*n

m = i % 2
n = i / 2 // 整数除法

i=3 → (m,n)=(1,1) → row(1,1)=4

分解时,第一个 mode 优先递增。这个顺序由 Shape 的坐标编号规则决定,与实际 Stride 是否行主序无关。

将整数输入编号 i 放回同一张逻辑矩阵,会得到下面的排列。注意,这次格内写的是输入编号,不是 Layout 的输出偏移:

m/n012
0024
1135
Shape 的输入编号:i = m + 2n;高亮 i=3

高亮格的坐标是 (1,1)。它在这张输入编号图中写着 3,在前面的行主序输出图中写着 4,所以 row(3)=4。

整数输入 i按 Shape 拆出的 (m,n)row(i)
0(0,0)0
1(1,0)3
2(0,1)1
3(1,1)4
4(0,2)2
5(1,2)5

CuTe 也允许只将某个嵌套 mode 写成整数。对前面的 h,第二个 mode 的 Shape 是 (2,3),因此:

j = a + 2*b

a = j % 2
b = j / 2

若整个 Layout 都用一个整数 i 表示,再在外面加一层分解:

i = m + 2*j

所以以下三种写法会得到同一个偏移 5:

h(make_coord(1,make_coord(0,2))); // 完整坐标
h(1,4); // 第二个 mode 用整数 4 表示
h(9); // 整个坐标用整数 9 表示

把 j=0…5 依次排在横轴上,也能直接查到同一个结果:

m/j012345
0061728
139410511
h(m,j) 的输出偏移:j = a + 2b

横轴的 j 是第二个 mode 的整数坐标,不是原来完整坐标中的 b。例如 j=4 会拆成 (a,b)=(0,2),所以高亮格是 h(1,4)=5;整个输入再合成 i=1+2×4=9。

把整数输入 9 的计算过程逐步展开如下,表中的除法均为整数除法:

步骤计算结果
按外层 Shape 拆分m = 9 % 2 = 1,j = 9 / 2 = 4(m,j) = (1,4)
按子 Shape 拆分a = 4 % 2 = 0,b = 4 / 2 = 2完整坐标 (1,(0,2))
应用 Stride3*m + 6*a + b = 3*1 + 6*0 + 2输出偏移 5

可以把整个过程分成两步:Shape 决定整数输入怎样拆成坐标,Stride 决定这些坐标怎样加成输出。

不要将整数坐标直接当成内存偏移。后面阅读布局复合、线程和值的映射时,这个区别会反复出现。

size 与 cosize:逻辑位置和覆盖范围​

size 数的是逻辑坐标有多少种组合,它只依赖 Shape。

但真实的索引空间可能有空隙。比如一张 2×3 的矩阵,每行起点相隔 5 个元素:

auto padded = make_layout(make_shape(_2{},_3{}),
make_stride(_5{},_1{}));
// (2,3):(5,1)

它的输出表是:

m/n012
0012
1567
带行间空隙的布局 (2,3):(5,1):offset = 5m + n

共有 6 个逻辑位置,但若从偏移 0 开始覆盖到最后一个输出 7,需要 8 个位置:

size(padded) = 2×3 = 6
cosize(padded) = (2-1)×5 + (3-1)×1 + 1 = 8

cosize 包含偏移 3、4 这样的空隙。它不是“实际访问到的不同下标有几个”;这里访问到的不同下标只有 6 个。

将物理偏移从 0 到 7 排成一行,就能看清这些空隙。下面横轴表示物理偏移,不再是逻辑列坐标;✓ 表示被访问,— 表示未被访问:

偏移01234567
访问✓✓✓——✓✓✓
物理偏移 0…7:黄色的 3、4 未被访问

反过来,stride 为 0 时,可以有多个逻辑坐标访问同一个位置:

auto broadcast = make_layout(make_shape(_2{},_3{}),
make_stride(_0{},_1{}));

它的两行都会输出 0、1、2,因此 size=6、cosize=3。

m/n012
0012
1012
广播布局 (2,3):(0,1):offset = n

黄色区域中的 (0,1) 和 (1,1) 是两个逻辑位置,却都对应偏移 1。矩阵有 6 个格子,不代表访问了 6 个不同的存储位置。

这里都采用非负整数 stride。对这些例子,cosize 就是最大输出偏移加 1;带负 stride 或输出更复杂索引的布局,需要结合具体定义另看。

flatten 和 coalesce 简化的是哪一部分​

知道 tuple 的层次后,可以顺便区分这两个常见操作。

flatten(h) 去掉 Shape、Stride 的嵌套层次:

(2,(2,3)):(3,(6,1))
↓ flatten
(2,2,3):(3,6,1)

它保留整数输入 i 对应的输出,但坐标接口从 (m,(a,b)) 变成了 (m,a,b)。这一步没有保证内存连续。

coalesce 则尝试合并能连续接起来的相邻 mode,例如:

(2,3):(1,2) → 6:1

因为第二个 stride 2 等于第一个大小 2 乘以第一个 stride 1,这两个 mode 对整数输入的映射可以合成一条连续规则。

同样,整数输入的映射保留了,但原来的二维坐标接口不会自动保留。是否能删除括号,要看后续代码还需不需要它们表达的分组。

从这里继续读 Product 和 Divide​

到这里,可以独立读懂一张 Layout:先看 tuple 的分组,再按 Shape 分解坐标,最后用 Stride 算出索引。

后续两篇按这个顺序展开即可:

篇章从什么问题开始逐步展开的内容
Product已有一份布局,怎样重复成更大的结构?从 logical_product 的小例子开始,再比较 zipped、tiled、flat,以及 blocked、raked 的坐标分组与用途
Divide已有一份大布局,怎样拆成块内位置与块编号?先算一个一维分块,再扩展二维;比较各类 divide,最后接到 tensor 切片与线程分区

两篇都沿用这里的记号和坐标映射基础,不再同时引入 tuple、嵌套 mode 和整数坐标分解。Layout Product 已有完整示例;想继续看 Tensor 切片、线程和值的布局,以及 MMA 的 ALayout,可以阅读从矩阵坐标到线程分工。

配套程序与参考源码​

配套程序核对了 tuple 层次、各类坐标写法、默认步长,以及 size/cosize、flatten 和 coalesce 的行为,共 106 项检查。程序在 CPU 上运行,不需要 GPU:

# 在 CUTLASS 仓库根目录执行。
nvcc -std=c++17 -I include cute_layout_basics_trace.cu \
-o /tmp/cute_layout_basics_trace
/tmp/cute_layout_basics_trace

完整打印见运行结果。文中矩阵表的偏移已与同一程序输出逐项核对;输入编号图按文中的 Shape 分解公式排列,物理偏移图则标出了 padded 布局实际访问的位置。

相关源码与文档:

本文依据本地 CUTLASS 提交 0b55a2f691d69981583568fd9eb69687b1f0de8a 的 C++ 实现。文中的精确 Shape、Stride 以该版本实际编译打印的结果为准。