学习CUTLASS,很多人一上来就冲进GEMM kernel的源码里,结果被层层模板参数和坐标变换砸得晕头转向。我的建议是先看 03_visualize_layout 这个示例,它不写kernel、不碰shared memory同步,只做一件事——把数据布局(Layout)的逻辑坐标到物理内存偏移的映射关系,用矩阵的形式打印出来。就是这个看起来"没什么技术含量"的小工具,把CUTLASS(以及它底层的CuTe模板库)里最核心、也最影响性能的Layout抽象讲透了。
这个示例适合两类人。一类是准备深入读CUTLASS源码、却总在Shape、Stride、Fragment坐标里迷路的读者;另一类是只想用CUTLASS写高效算子、但想搞清楚"为什么同样一个矩阵,RowMajor和ColumnMajor的性能能差出几十倍"的工程师。我自己的体会是,花一个下午把这份示例的每一行输出看懂,之后再去读 mma、cp.async、ldmatrix 这些真正跑在GPU上的代码,会有一种"地图在手里"的踏实感。
下面我把整个示例按我读源码时的思路拆开讲,包括Layout的本质、主流程拆解、几类典型布局的可视化对照,以及如何把它改造成自己的布局调试工具。
1. 为什么先啃"打印布局"这个示例:它是整个CUTLASS的地基
1.1 它在整套示例里的位置
CUTLASS的examples目录从01排到50多,越往后越复杂。01、02通常是矩阵乘法入门,到了03突然插入一个"可视化布局"的示例,很多人会觉得突兀,甚至直接跳过。实际上这个排序是故意的——03之后的每个示例都要用Layout来表达"数据怎么摆、线程怎么取",如果这一步没建立直觉,后面全是空中楼阁。
这个示例的特殊之处在于:它是纯host端代码,不启动任何kernel。你在跑完编译后执行它,看到的只是一堆数字排列成网格状。但这些数字恰恰是GPU高性能计算里最底层的"地质结构"。
1.2 弄懂它之后你能获得什么
读完这个示例,我认为你至少能收获三样东西:
- 看懂Layout表达式。以后在任何CUTLASS代码里看到
Layout<Shape<_4,_2>, Stride<_2,_1>>,你能立刻在脑子里浮现出一个4行2列的矩阵,以及它在内存里的一维偏移序列。 - 理解坐标变换的方向。逻辑坐标
(row, col)和物理地址之间的映射不是"想当然的",它由Stride精确控制。这个映射决定了访存是否合并、是否发生bank conflict。 - 为读kernel源码做铺垫。CUTLASS 3.x里的GEMM kernel大量使用
cute::make_layout、composition、LogicalToPhysical这些操作,本质上都是在操作03示例里打印出来的那种映射关系。
我自己读这套源码的路径是:先跑03,再跑05(基于CuTe的GEMM),最后才回头看07、08那些带tile scheduler的复杂版本。03这一步省不得。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Layout的内存映射本质:Shape、Stride与offset公式
2.1 从二维坐标到一维地址
GPU显存本质上是一维的,地址从0递增到某个大数。但我们写矩阵运算时习惯用 (row, col) 这种二维坐标描述元素。Layout就是连接这两套坐标系的桥梁。
一个Layout由两部分组成:
- Shape:逻辑空间的形状,描述"这个布局有多少行、多少列"。
- Stride:步长,描述"逻辑坐标每个维度增加1时,内存偏移增加多少"。
计算某个逻辑坐标对应的内存偏移,公式极其简单:
code复制offset(row, col) = row * stride_row + col * stride_col
以4行2列的矩阵为例,RowMajor布局写作 Layout<Shape<_4,_2>, Stride<_2,_1>>,意思是行方向步长为2、列方向步长为1。于是:
(0,0)-> 0(0,1)-> 1(1,0)-> 2(3,1)-> 7
把整个矩阵打印出来,就是:
text复制0 1
2 3
4 5
6 7
这就是"行优先"的本质——同一行的元素在内存里相邻,换行时要跳过2个元素(其中1个是上一行最后一个、1个是下一行第一个)。
2.2 为什么用模板常量而不是普通整数
CUTLASS/CuTe里到处是 _4、_2、_1 这种带下划线的类型,初见很劝退。它们其实是编译期整数常量,类型是 Int<4>。把它写成模板参数的原因只有一个:让编译器在编译期就完成所有offset计算。
举个例子,Layout<Shape<_4,_2>, Stride<_2,_1>> 的 layout(row, col) 调用在编译期就能被展开成常量算术,kernel里访问某个元素不会被额外的运行时乘法拖慢。相比之下,如果Shape和Stride都是运行时变量,每次偏移计算都要做乘法,性能损失在高频循环里会被放大。
理解这一点后,你再看代码里的 _ 前缀就不会觉得奇怪了,它是一个纯粹的性能设计。
2.3 print_layout打印出来的数字到底什么意思
示例的核心工具是一个 print_layout 之类的辅助函数。它做的事情非常朴素:双重循环遍历Shape定义的每个逻辑坐标,调用Layout的 operator() 拿到该坐标对应的内存偏移,然后以矩阵形式打印。
换句话说,它打印的不是数据值,而是"如果我把一个矩阵按这个Layout存进内存,每个格子会落到第几个字节偏移上"。这个视角非常关键——你在优化访存时,关注的恰恰是偏移的分布,而不是数据本身。
3. 03_visualize_layout主流程拆解:从Layout定义到矩阵打印
3.1 代码骨架
示例的完整源码在NVIDIA/cutlass仓库的 examples/03_visualize_layout/03_visualize_layout.cu,核心流程拆出来大概是这样的(我做了简化,突出主线):
cpp复制#include <cute/layout.hpp>
#include <cute/print.hpp>
using namespace cute;
// 辅助函数:把layout的每个逻辑坐标对应的物理偏移打印成矩阵
template <class Layout>
void print_layout_matrix(Layout const& layout, char const* name) {
std::cout << name << ": " << layout << "\n";
for (int row = 0; row < size<0>(layout); ++row) {
for (int col = 0; col < size<1>(layout); ++col) {
std::cout << layout(row, col) << "\t";
}
std::cout << "\n";
}
std::cout << "\n";
}
int main() {
{
// 4x2 RowMajor
Layout layout = Layout<Shape<_4,_2>, Stride<_2,_1>>{};
print_layout_matrix(layout, "RowMajor 4x2");
}
{
// 4x2 ColumnMajor
Layout layout = Layout<Shape<_4,_2>, Stride<_1,_4>>{};
print_layout_matrix(layout, "ColumnMajor 4x2");
}
// ... 更多Layout:Interleaved、Swizzled等
return 0;
}
size<0>(layout) 取的是第0维(行)的大小,layout(row, col) 是把它当成一个可调用对象,传入逻辑坐标返回物理偏移。CuTe的Layout operator() 重载了多个版本,支持整数下标、Coord对象、甚至 Tensor 对象,这里用的是最直白的形式。
3.2 输出格式的第一行怎么读
运行时你会看到类似这样的第一行:
text复制RowMajor 4x2: (4,2):(_2,_1)
这个 (4,2):(_2,_1) 是Layout的紧凑表达式,左边括号是Shape,右边括号是Stride。单个Layout可以有很多"模式"(mode),每个模式对应一个维度。对这个例子来说:
- 模式0:Shape=4,Stride=2,也就是行;
- 模式1:Shape=2,Stride=1,也就是列。
如果你看到 (4,2,2):(_8,_2,_1) 这种三个维度的Layout,就用同样的方式逐维解读:第0维步长8、第1维步长2、第2维步长1。多维Layout在高阶算子(比如Conv的NHWC)里非常常见,读法是一样的。
3.3 打印矩阵时要注意的坐标方向
print_layout_matrix 里,外层循环是第0维、内层循环是第1维。也就是说,输出矩阵的每一行对应Layout的第0维坐标,每一列对应第1维坐标。理解这个约定很重要,否则你会把RowMajor的输出误读成ColumnMajor。
以 Shape<_4,_2> 为例,输出共有4行2列。第0行第1列的数字,就是逻辑坐标 (0,1) 的物理偏移。这个约定在CUTLASS社区里是通行的,读其他示例的打印结果时也适用。
4. 核心布局可视化对照:RowMajor、ColumnMajor与Interleaved
4.1 RowMajor和ColumnMajor的直观差异
这是示例里最经典的一组对照。4x2矩阵,RowMajor输出:
text复制0 1
2 3
4 5
6 7
ColumnMajor输出:
text复制0 4
1 5
2 6
3 7
差异一目了然:RowMajor里"行方向连续",同一行相邻元素内存相差1;ColumnMajor里"列方向连续",同一列相邻元素内存相差1,而同一行相邻元素内存相差4(等于列数)。
这两个布局本身没有优劣之分,完全取决于你的访存模式。举个典型场景:如果kernel里让 threadIdx.x 映射到列坐标,那么:
- RowMajor下,相邻线程访问相邻地址,GPU可以合并访存(coalescing),一次事务搞定;
- ColumnMajor下,相邻线程访问的地址相隔4个float,每次访存都被打散,带宽利用率惨不忍睹。
CUTLASS里GEMM kernel对A矩阵和B矩阵常常采用不同的布局策略,本质原因就在这里——A的行遍历和B的列遍历对数据排列有不同需求。
4.2 Interleaved布局:两个逻辑矩阵交错存放
示例里另一个值得研究的布局是 ColumnMajorInterleaved<2>,它服务于一个非常具体的场景:FlashAttention里两个注意力头的数据需要交错存放。用Layout表达,它相当于把两个逻辑矩阵A和B合并成一个三模式Layout,第三模式的大小为2,步长使得"同一位置的两个头的数据在内存里紧挨着"。
假设矩阵是4行2列,Interleaved=2,那么逻辑坐标 (row, col, head) 的偏移规律可以示意为:
head=0的元素落在偶数偏移:(0,0)->0,(1,0)->2,(2,0)->4...head=1的元素落在奇数偏移:(0,0)->1,(1,0)->3,(2,0)->5...
打印出来的矩阵(每个格子同时包含两个头的偏移)大致长这样:
text复制(0,1) (2,3)
(4,5) (6,7)
(8,9) (10,11)
这么做的好处在哪?当你只需要加载一个Attention头的时候,Interleaved似乎反而"浪费"了间隔;但FlashAttention的底层实现往往同时计算多个头,交错布局让一次向量化加载(比如 ld.global.v4)能够同时取到两个头的数据,在高带宽利用率和寄存器复用上都有收益。这就是典型的"用Layout表达业务数据结构"的例子——不直接把数据复制成连续块,而是通过映射关系让不同的逻辑视图共享同一块物理内存。
4.3 Swizzle布局:从视觉上理解bank conflict的解法
示例后半段通常还会展示带Swizzle的Layout。Swizzle的中文意思是"交错/打乱",它通过把偏移的低若干位与高若干位做XOR(异或),改变数据在共享内存bank上的分布。
我用一个简化的例子说明。假设一个8x8的逻辑块,base是RowMajor(Row stride=8, Col stride=1),原始偏移是:
text复制0 1 2 3 4 5 6 7
8 9 10 11 12 13 14 15
16 17 18 19 20 21 22 23
...
如果给它套一层简化的Swizzle规则"列的低3位与行的低3位异或",偏移变成 row * 8 + (col ^ row),第1行会变成:
text复制8 9 10 11 12 13 14 15 <- (row=1, col=0..7)
而第2行(row=2)会变成:
text复制16 19 18 17 20 23 22 21 <- col^2 后列序被打乱
注意第2行第0列是 2*8 + (0^2) = 18,第2行第1列是 2*8 + (1^2) = 19,列的顺序不再是单调递增,出现了"跳变"。这个跳变就是XOR带来的。
为什么需要这种跳变?共享内存被划分成32个bank,每个bank的带宽是独立的。如果线程束(warp)里的32个线程同时访问的地址落在同一个bank上,硬件必须把这些访问串行化,这就是bank conflict。原始RowMajor布局下,如果32个线程分别访问同一列的32行,它们的偏移是 row * 8 + col,对32取模后恰好都等于 col,全部命中同一个bank,性能直接退化到1/32。Swizzle通过XOR把同一列的数据分散到不同bank上,让"本会冲突的访问"变成"无冲突访问"。
CUTLASS模板里的 Swizzle<B, M, S> 三个参数,控制的就是具体对哪几位做XOR、中间跳过几位。你可以改参数后重新打印布局,观察偏移分布的变化,这是理解Striding和Swizzle最好的方法。
5. 从"看布局"到"设计布局":可视化背后的性能动机
5.1 合并访存:从RowMajor到threadIdx映射
上一节提到合并访存,这里展开讲。现代GPU内存系统的做法是:当一个线程束内多个线程访问同一缓存行(通常128字节)时,硬件把这些访问合并成尽可能少的访存事务。
要达到这个效果,核心原则是:相邻的线程ID,应该映射到相邻的内存地址。
在CUTLASS的GEMM kernel里,一个tile的线程布局(ThreadLayout)就是精心设计的Layout。你可以把"线程ID"想象成Layout的第0维,"数据坐标"是另一个Layout,两者通过 composition(复合)操作串起来。03示例里那些 Layout<Shape<_32,_8>, Stride<...>> 的打印结果,本质上就是在回答"第0号线程取哪个偏移、第1号线程取哪个偏移"。
如果在设计kernel时发现自己写的映射打印出来"相邻线程偏移跳跃很大",基本可以判定访存不会合并,性能一定上不去。这就是可视化工具的价值——它在你写代码之前就告诉你答案。
5.2 共享内存bank conflict:为什么需要高级Layout
共享内存的bank conflict是另一个让新手头痛的问题。一个线程束访问共享内存时,硬件按4字节粒度把地址分配到32个bank上,一个时钟周期内最多访问32个不同的bank。如果两个线程落在同一个bank的不同地址上,就发生冲突,访问次数翻倍。
可以说,共享内存上的Layout设计,核心目标之一就是"让一个线程束的访问模式在bank维度上均匀散开"。这不仅包括RowMajor/ColumnMajor的选择,还包括Padding(在行末加一个空列)和Swizzle。Padding的思路是:把行步长从N改成N+1,这样同一列的相邻行元素在bank上的编号也会偏移,从而避开冲突。Swizzle的思路更彻底:直接用XOR打散。
03示例里俯瞰这些输出时,我的建议是重点关注"同一列的数字对32取模后是否集中"。如果集中,说明有冲突风险,你就知道该上Padding或Swizzle了。
5.3 Tensor Core对数据排列的硬约束
CUTLASS 3.x大量使用Tensor Core MMA指令,而MMA指令对寄存器里数据的排列有硬性要求。比如 wgmma 或者 mma.sync 的A矩阵片段,通常要求每个线程的寄存器里持有特定 (row, col) 组合的数据。这些组合在CUTLASS源码里就是一个个Layout对象:Layout<Shape<_...>, Stride<_...>> 描述"第几号线程、第几个寄存器,对应数据矩阵的哪个逻辑坐标"。
为什么03这个纯打印示例对读Tensor Core代码有帮助?因为你迟早会在kernel里看到类似这样的代码:
cpp复制auto sA = make_tensor(make_smem_ptr(...), tile_layout_A); // 共享内存视图
auto tA = local_partition(sA, thread_layout, thread_idx); // 每个线程的寄存器片段
这里 tile_layout_A、thread_layout 都是Layout。你看不懂它们,就无法理解"数据是怎么从全局内存经过共享内存最终进入寄存器,再变成Tensor Core需要的形状"的。而03示例教你的事情,就是把这些Layout的映射关系在纸上画出来、打印出来。
我自己做算子移植时,最常用的一个动作是:写一个小测试,把目标Layout打印成矩阵,贴在代码注释旁边,对着它写循环。这个习惯就是从03示例养成的。
6. 把这个示例改造成自己的布局可视化调试工具
6.1 编译运行示例
CUTLASS现在推荐用CMake构建,跑这个示例的完整流程:
bash复制git clone https://github.com/NVIDIA/cutlass.git
cd cutlass
cmake -S . -B build -DCUTLASS_ENABLE_EXAMPLES=ON
cmake --build build --target 03_visualize_layout -j8
./build/examples/03_visualize_layout/03_visualize_layout
需要注意几点:
- CUTLASS 3.x要求CUDA 11.8或更高版本,建议用CUDA 12.x;
- 编译器建议gcc 11以上,老版本gcc对C++17模板元编程的编译速度和报错可读性都很差;
- 如果只编译这一个示例,可以用
--target 03_visualize_layout加快速度,十几秒就能编完。
6.2 修改示例的几种玩法
跑通之后,我强烈建议你做几个小实验,把示例变成自己的调试工具:
实验一:改Shape和Stride,观察偏移变化。 把 Layout<Shape<_4,_2>, Stride<_2,_1>> 改成 Layout<Shape<_8,_4>, Stride<_4,_1>>,观察8行4列的RowMajor输出,确认自己理解了"行步长=列数×列步长"这个关系。
实验二:用运行时Layout。 写一个 make_layout(8, 4),创建Shape和Stride都是运行时值的Layout,打印结果和编译期版本一致,但你可以把行列数包装成命令行参数,一次性看多种规模。
实验三:叠加Swizzle观察bank分布。 对输出的偏移列做手算 % 32,验证Swizzle前后冲突次数。这一步能让你把Swizzle的参数含义彻底吃透。
实验四:打印组合Layout。 用 composition 把线程布局和数据布局复合起来,打印"每个线程负责的数据坐标"。这是最接近真实GEMM kernel的一个实验,也是从示例到实战的跳跃。
6.3 常见问题与排错经验
这一节分享几个我实际踩过的坑:
- 模板参数类型报错。写
Stride<2>{}会编译失败,必须写Stride<_2>{}。2是运行时int,_2是编译期常量类型,两者不能混用。如果你确实需要运行时步长,要用Layout<int, int>或者make_layout的运行时版本。 layout(row, col)和layout({row, col})的区别。前者依赖操作符重载接受两个整数参数,后者接受一个Coord。两者大多数情况下都能用,但当你把Layout传给模板函数时,签名要匹配,否则会有隐式转换,性能上虽无差别,读代码时容易绕晕。- 打印大Layout时终端对齐乱。Shape超过10的布局,数字位数不一致,
\t也不够对齐。建议自己加一个std::setw(3)之类固定宽度,或者干脆导出到文件里看。 - 不要直接复制
print_layout的实现去打印Tensor。Tensor是"Layout+数据指针"的组合,打印Tensor用cute::print_tensor,打印纯Layout用cute::print_layout,两者都定义在cute/print.hpp里,混用会导致一堆模板匹配报错。
6.4 一条实战建议
如果你正在写自己的自定义算子(比如某个新结构的Attention、某种稀疏格式的GEMM),在设计共享内存tile时,先花十分钟把候选Layout打印出来,手工检查两件事:第一,相邻线程的访存偏移是否连续;第二,一个线程束内所有偏移对32取模后是否均匀。这两条过关了,kernel上线后的访存性能至少不会"死得很难看"。
我在实际做性能调优时发现,很多问题在NVIDIA Nsight Compute里表现为 Memory Throughput 低或者 Bank Conflict 计数高,最终定位下来,都是Layout层的一个小参数没选对。与其在profiler里绕圈子,不如回到布局可视化这一步,把映射关系直接摆出来看。这也是为什么CUTLASS官方愿意花一个示例的篇幅,只做"打印"这一件事。
最后再分享一个我在读这套源码时养成的小习惯:在CUTLASS的源码里,凡是出现 Layout、Stride、Shape 的地方,我都会在边上用注释写一个极小的具体例子(比如"4x2, stride(2,1) -> offsets 0,1,2,3...")。这个习惯让我在啃那些动辄上百行的模板元编程代码时,始终没有丢掉"这到底在描述什么数据"的直觉。希望你读完这篇也能建立这个直觉,后面再接触tile scheduler、epilogue这些更复杂的模块时,就不会再被Layout劝退了。
