
1. 为什么我要专门写一篇CUTLASS源码评测如果你长期在搞AI推理相关的工作CUTLASS这个名字一定绕不开。它是NVIDIA开源的CUDA C模板库专门用来生成高性能的矩阵乘、卷积、注意力等算子也是很多推理引擎和训练框架底层算子的重要参考实现。我第一次翻它源码的时候感觉像打开了一座模板元编程的矿山到处都是CUTLASS_DEVICE_ATTRIBUTE、Layout、Shape、Tile初学者很容易直接看懵。但这套东西一旦理解价值非常大它不止是GEMM它是理解现代GPU计算方式的一把钥匙。这篇文章会以CUTLASS 3.x为主线从整体架构分层、CollectiveBuilder的工作方式、CuTe DSL的原理到AI推理落地时的工程选择和常见坑尽量完整地分享我自己读源码和落地算子时的笔记。适合的人群是已经在做推理引擎、开始接触算子优化、或者准备啃CUTLASS源码的开发者。如果你只是调API跑模型这篇文章可能偏深但也能帮你在遇到算子性能瓶颈时知道该往哪里查。我始终觉得算子优化的核心问题不是“会调一个核函数”而是“知道一个GEMM在GPU上应该被拆成多少层、谁负责搬数据、谁负责计算、中间为什么要有这么多层分块”。CUTLASS最厉害的地方就是它把这些问题全部变成了可组合的C模板抽象。1.1 矩阵乘不是套公式是一场数据搬运的战争先聊一个很基础的问题为什么NVIDIA要专门搞一套CUTLASS来写GEMM直接用cuBLAS不香吗香但不够。很多时候生产环境里需要融合bias、激活、量化、剪枝后的稀疏操作这些如果全部拆成独立kernel调用显存带宽会变成绝对瓶颈。LLM推理里的decode阶段就是典型场景batch size很小一个GEMV慢得让人怀疑人生这时候通用库往往打得不够准必须做算子融合。GPU跟CPU的一个根本区别是计算单元非常密集存控层级非常深。一个现代数据中心GPU光计算峰值可以是几十甚至上百TFLOPs但显存带宽通常只有几个TB/s。矩阵乘如果拆成单个元素去算数据搬一次的时间比计算时间高几个数量级。所以CUTLASS的第一原则就是“分块”。把一个大的矩阵乘拆成Block Tile、Warp Tile、Thread Tile三层每一层都想办法让数据待在寄存器、共享内存这些离计算单元更近的地方尽量减少对全局显存的访问。这就像食堂打饭如果所有人每次只盛一口菜就回座位食堂窗口会被挤爆。正确做法是把大批菜先放进保温桶共享内存再一小碟一小碟端到餐桌上寄存器计算单元像人吃饭一样只从手边拿。CUTLASS做的事情就是为你分别设计“保温桶怎么摆”“碟子怎么端”的整套方案。1.2 CUTLASS 3.x的架构分层比我预想的更要命我最早读的是CUTLASS 2.x那会还是相对经典的“线程层级Global-Shared-Register”搬运模型每个线程算哪几个元素靠手写迭代逻辑。到了3.xNVIDIA引入了CuTe这套张量代数原语把“布局”和“数据搬运”抽象成可组合的表达式整个代码结构一下子立体了太多。从架构上看一个CUTLASS 3.x的GEMM Kernel大体可以拆成三块Collective Mainloop、Epilogue和贯穿全局的Tile抽象。Mainloop负责把数据从全局内存搬进共享内存和寄存器执行Tensor Core的矩阵乘累加并处理流水线阶段切换Epilogue负责把计算结果从寄存器搬回全局内存同时顺带做bias、激活、缩放这类融合操作CuTe则提供了一整套语言让这两块之间可以用统一的Layout语言沟通。这种分层最直接的收益是“可替换性”。同一个Mainloop可以替换不同的Epilogue实现不同融合逻辑同一个Epilogue也可以换到不同的硬件架构上。只要CuTe表达式能正确描述布局模板就能生成对应代码。在工程上这比维护十几个复制粘贴变体要靠谱一万倍。1.3 为什么说CUTLASS是一个“模板算子工厂”很多人把CUTLASS理解成一个库这不够准确。它更像一个“算子工厂”喂给它矩阵形状、数据类型、Layout、目标架构、Tile大小、流水线阶段数、Epilogue类型它就在编译期通过模板元编程生成一份高度特化的GPU Kernel。这种设计带来的代价是编译速度慢、编译期逻辑晦涩但好处也很直接生成的代码几乎不含动态分支循环次数全部在编译期确定寄存器分配可以完美贴合Tensor Core指令的需求。到了推理部署阶段你会特别爱这种“静态生成”的方式因为它可以做全程序优化把很多运行时的判断提前到编译期。我看源码时有个很直观的感受CUTLASS其实不太是“给你一个黑盒算子”而是“给你一堆积木和一张图纸”。如果你只是要一个快速GEMM还是cuBLAS/CUTLASS里的Device级别API最方便。但如果你想在某一层调整数据流或者针对某个特殊形状做极致优化那才是CUTLASS模板组合真正发力的时候。2. 源码级架构解析CollectiveBuilder到Kernel的生成链路读CUTLASS源码时最先让人迷糊的是一长串模板参数。我建议不要一开始就硬啃每一个类型而是先抓住“从CollectiveBuilder到最终Kernel”的生成链路。这条链路就是整个库的龙骨。2.1 CollectiveBuilder一台编译期自动选优的机器我先说结论CollectiveBuilder做的事情是接收你给的计算描述然后通过模板特化和if constexpr类似的编译期分发选择合适的CollectiveMainloop和EpilogueCollective再把它们拼装成Kernel。为什么需要这个东西因为你给的描述和硬件实际支持的实现之间存在巨大的鸿沟。比如你说“我要在Hopper上算一个half精度的RowMajor GEMM”但底层实际可能是用TMA搬运全局数据、用wgmma做矩阵乘、用warp specialization做领导中间会有无数种组合。如果让写算子的人自己把所有组合都配一遍人会疯。CollectiveBuilder就是把“可用组合”自动匹配出来的那层胶水。实际使用中你会看到类似这样的拼装方式示意代码细节以CUTLASS官方example为准using CollectiveOp cutlass::gemm::collective::CollectiveBuilder cutlass::arch::Sm90, cutlass::arch::OpClassTensorOp, cutlass::half_t, cutlass::layout::RowMajor, // A矩阵 cutlass::half_t, cutlass::layout::RowMajor, // B矩阵 cutlass::half_t, cutlass::layout::RowMajor, // C矩阵 cutlass::gemm::GemmShape128, 256, 64, // CTA Tile cutlass::gemm::GemmShape64, 128, 64, // Cluster Shape cutlass::epilogue::thread::LinearCombinationfloat, 8, float, float, float, cutlass::gemm::collective::StageCountAuto, cutlass::gemm::collective::KernelScheduleAuto ::CollectiveOp;这段代码背后的模板匹配会决定使用哪个原子指令、是否使用TMA、共享内存怎么排布、流水线是否warp-specialized。你可能会问为什么不直接写死一个kernel因为不同GPU架构的硬件特性差异很大Ampere的cp.async和Hopper的cp.async.bulk.tensor指令行为完全不同Tensor Core的MMA指令形状也一代一个样。CUTLASS用编译期抽象把这种差异折叠起来换来的是跨架构的复用。这里有一个非常关键的工程观点如果你在源码里看到一个复杂的模板类不要觉得是作者在炫技。它大概率是为了把“硬件特性的差异”限制在很小的范围内不污染上层的算法逻辑。我在改CUTLASS源码做定制算子的时候对此体会极深。很多问题改Mainloop一层就够完全不需要动Epilogue。2.2 MainloopTMA、Warp Specialization与流水线Mainloop是CUTLASS 3.x最核心的一层也是性能最敏感的地方。以Hopper SM90为例Mainloop里会大量出现两个关键字TMA (Tensor Memory Accelerator) 和 warp specialization。TMA是Hopper引入的异步拷贝引擎。以前从Global Memory搬数据到Shared Memory需要每个线程自己发cp.async指令数据大小和地址转换要靠软件控制非常繁琐。TMA则把一堆操作打包成一个“硬件异步拷贝任务”一行指令就能描述一个多维Tensor Tile的搬运包括cache hint、swizzle模式都可以设置。CUTLASS在SM90的Mainloop里会提前预取多块Tile到Shared Memory靠多级流水线隐藏搬运延迟。Warp Specialization指的是把不同Warp的角色错开。一组Warp专门负责“生产者”持续用TMA把A、B矩阵的Tile搬到Shared Memory另一组Warp专门负责“消费者”从Shared Memory取数执行wgmma累加。两组Warp通过barrier异步协作。你可能会觉得这有什么特别的普通GEMM里所有Warp都是同步“搬一块、算一块”但GPU一旦等待数据计算单元就空转。Warp Specialization本质是让搬运和计算同时进行用“空间独立”换取“时间交叠”。我读源码时最关注的变量通常是这几个流水线Stage数量。Stage越高共享内存占用越大但延迟隐藏效果越好。CUTLASS里的StageCountAuto会根据Shared Memory容量自动算一个最大安全值。Cluster Dimension。Hopper引入了Thread Block Cluster多个CTA可以共享一块分布式共享内存这能明显提升小Tile的利用率。CollectiveBuilder会根据Tile Shape推导Cluster。Swizzle模式。Shared Memory地址做位异或重排避免bank conflict。CuTe里通常是一个Swizzle...类型参数。读到这你会发现CUTLASS根本不是“一行代码调用”的层面它的每个参数都对应一个硬件机制。这也是为什么源码评测有意义你只有读了Mainloop才知道为什么某些参数组合能让性能翻倍。2.3 Epilogue一次融合的终点很多人读CUTLASS时忽略Epilogue但它恰恰是推理落地最常用的部分。Epilogue负责把Mainloop算出来的寄存器里的累加结果经过一系列变换写回Global Memory。常见的融合操作有线性缩放和Bias相加。激活函数比如ReLU、GELU。量化反量化比如FP32转FP16或INT8。特殊裁剪比如钳位到某个范围。CUTLASS 3.x的Epilogue也被重构为Collective它知道当前输出Tile如何分布在线程和寄存器之间可以直接在写回前完成各种逐元素操作。优点是不用把中间结果先写回显存再读出来省一次带宽。不过这里有个很容易被忽略的坑Epilogue的融合能力取决于数据在寄存器里的布局。如果你的输出Tile布局和后续的Layout转换不匹配编译器会额外生成shuffle或者共享内存中转性能立刻掉下去。所以落地上很多算子融合并不是“想融就融”而是要看数据能不能自然地走完这一条路径。3. CuTe DSL原理换一种方式描述张量CUTLASS 3.x对源码阅读者最大的门槛其实是CuTe。CuTe全称是“CUDA Templates for Linear Algebra?”但更准确地说它是一套用于创建高性能GPU Kernel的C张量布局DSL。它不是为了数值计算设计的而是为了描述“数据在GPU硬件中如何摆放和搬运”设计的。3.1 Layout就是一切Shape、Stride和MappingCuTe的起点是Layout。一个Layout由Shape和Stride两个组成。Shape描述每个维度的大小Stride描述每个维度相邻元素在内存中的间隔。比如一个行优先的4x4矩阵Shape是(4, 4)Stride是(4, 1)意思是第一维行移动1个单位线性索引跳4个元素第二维列移动1个单位线性索引跳1个元素。列优先矩阵的Stride则是(1, 4)。代码看起来是这个样子#include cute/layout.hpp using namespace cute; auto row_major_layout make_layout(make_shape(Int4{}, Int4{}), make_stride(Int4{}, Int1{})); // 也可以直接用 LayoutLeft / LayoutRight 快捷方式 auto col_major_layout make_layout(make_shape(Int4{}, Int4{}), LayoutLeft{});这里有个很微妙的点Shape和Stride里的数字通常不是普通的int而是Int...这种编译期常量。为什么因为CuTe要把Layout变成类型的一部分并在编译期计算各种坐标映射关系这样在生成代码时所有索引都能被内联展开成常量循环也更容易被编译器向量化。如果你还没意识到Layout的意义可以想一个问题一个Tensor在Global Memory里的布局、一个Tile在Shared Memory里的布局、一个Warp里32个线程各自该处理的数据排列本质上都是Layout。CuTe用同一套语言把它们描述出来再用Layout的复合、求逆、补集等操作进行推导。这是CUTLASS 3.x最优雅的地方。3.2 组合、补集与乘积在编译期做线性代数CuTe真正的威力不是“定义一个二维数组”而是Layout之间的运算。例如make_layout(shape_a, stride_a)和make_layout(shape_b, stride_b)可以做连接Append、拼接Concat、左右因子分解。最常见的操作是在GEMM里要把一个大Layout拆成多个子Layout比如一个128x128的Tensor Tile先切成4个128x32的横条再切成更小的寄存器块。每个切分都是对Layout做Complement或者Composition运算。我举个简单例子假设有一个Shape(M, N)的布局要按行切成上下两半就可以把Shape替换为ShapeShapeM/2, 2, N然后调整Stride。CuTe里这类操作被称为“对Layout的秩扩展”它本质上是在类型系统里做一个“基变换”GPU的Index计算全部在编译期完成。再比如TMA里需要描述一个多维Tensor块如何从Global映射到Shared。CuTe的Tensor类型由Layout和Engine组成Engine提供实际数据指针Layout提供下标到线性地址的映射。你可以直接写Tensor tensor make_tensor(ptr, layout); // ptr是设备指针 Tensor tile local_tile(tensor, make_shape(Int64{}, Int64{}), make_coord(block_row, block_col));这行代码的意思是从一个大Tensor里按坐标取出一块64x64的子块。如果Layout是位异或Swizzle过的CuTe会把这个Swizzle代入坐标计算你不需要手工把地址算出来。这就是为什么CUTLASS 3.x的Shared Memory存取能有效避免Bank Conflict因为Swizzle被抽象成了Layout的一部分。3.3 TiledCopy与MMA Atom把指令当作计算单元CuTe里还有一个核心概念叫Atom。Atom是最小的硬件操作单元比如一个cp.async指令、一个wgmma指令。而TiledMMA就是把Atom和线程布局组合起来形成“整个CTA怎么分配Warp、每个Warp怎么分配线程、每个线程处理哪个MMA片段”的描述。看源码时会发现很多类似MMA_AtomSM80_16x8x8_F32F16F16F32_TN这样的类型后面跟着TiledMMA。这些类型名包含了指令形状、数据类型、操作数顺序。它们不是抽象出来的“数学操作”而是真实硬件指令的镜像。有一个非常容易踩的坑你可能觉得TiledMMA就是把一个MMA指令复制多份。实际上它还要负责“线程映射”。比如一个m16n8k8的mma指令需要4个线程协作每个线程持有4个寄存器。那一个128x128的Tile需要多少个Warp每个Warp负责哪一块这些都由TiledMMA中的线程Layout决定。我之前调试过一个自定义FP8 GEMM最初以为只要替换MMA Atom里的数据类型即可结果产生一堆bank conflict后来发现是线程布局没有跟着换导致寄存器索引与共享内存地址的映射乱套。CuTe的模式是如果你修改了Atom的形状一定要同步修改TiledMMA的Tile和线程布局否则C模板不会报错但性能会一落千丈。3.4 一个可以立刻运行的CuTe最小示例读这么多不如跑一遍。CuTe可以脱离CUTLASS单独使用你只需要一个包含CUTLASS头文件的CUDA C工程。最简单的示例就是定义Layout并打印索引#include cute/layout.hpp #include cute/tensor.hpp #include cute/print.hpp #include iostream using namespace cute; int main() { auto layout make_layout(make_shape(Int4{}, Int8{}), LayoutRight{}); print(layout); // 验证几个关键坐标 static_assert(layout(0, 0) 0); static_assert(layout(3, 0) 3); static_assert(layout(0, 7) 28); std::cout layouts are compile-time check ok\n; return 0; }这个例子最直白地展示了Layout的坐标映射。当你把它跑通再去看CUTLASS的examples/cute时心里会踏实很多。我强烈建议想入门CuTe的人先不要碰GEMM先把Layout、Tensor、LocalTile这几个概念玩熟。CUTLASS源码的注释里有一个很经典的提醒“Think about layouts, not loops.”知道每个数据在哪个坐标比纠结“我用几层for循环”重要得多。4. 工程能力与AI推理落地指南前面聊了很多源码和原理这部分我想聚焦到实际工程。因为我见过太多人拿着官方Example跑出一个GEMM后就以为能直接部署了结果被驱动、CMake、精度、性能折磨得痛不欲生。4.1 工程能力测试、Example、Profiler一样不少先说结论CUTLASS的工程成熟度在NVIDIA开源项目里属于第一梯队。它有完整的单元测试、大量的Examples、一个功能很丰富的Profiler以及专门用于验证算子正确性的验证工具。我读源码时常常会把examples目录当“教材”看examples/cute里是CuTe基础用法。examples/36_gemm_basic一类是完整的GEMM调用链路。examples/50_hopper_*专门展示Hopper的TMA和Warp Specialization。如果你不想从零拼CollectiveBuilder可以直接从这些Example改。实际工程里很多人也是这么干的找一个最接近需求的Example不断替换模板参数直到性能满足预期。Profiler功能也值得一提。CUTLASS的Profiler可以自动跑不同配置输出TFLOPs、带宽、耗时等指标。我通常会在调参时用它做“配置扫描”把几十种Tile大小、Stage数量、Swizzle组合全部跑一遍再挑出最优的前几个进行验证。不过它的CMake构建也有一点学习成本。第一次构建时CUDA架构参数一定要选对否则编译出来的东西可能根本没有针对你的GPU实例化对应的内核。我一般会用-DCUTLASS_ENABLE_CUBLASON和-DCUTLASS_ENABLE_SM90ON这类开关确保编译使用的架构与运行环境一致。4.2 推理落地三层次直接调用、定制Epilogue、深入MainloopAI推理落地时你会发现CUTLASS不是“要么全用、要么不用”的关系而是有三个层次的参与深度。第一层直接用官方Device级Gemm。当你需要比较标准的高性能矩阵乘且没有特殊融合需求时直接用cutlass::gemm::device::Gemm即可。这比cuBLAS好在可控、可复现而且不依赖闭源库的调度策略。很多量化推理框架里的INT8、FP16 GEMM就是直接用CUTLASS设备端API包一层。第二层定制Epilogue实现算子融合。这是推理引擎最常用的方式。LLM里的LayerNorm、Residual Add、Activation、RMSNorm等都可以挂在GEMM的Epilogue里。这样整个推理链路里一个GEMM带出的数据可以在寄存器里直接被Transformer的下一个子层消费避免写回显存再读一次。我实测过在部分场景下Epilogue融合能把端到端算子延迟降低20%到40%尤其是小Batch时效果更明显。第三层深入Mainloop对数据流做定制。这种情况通常出现在模型极其特殊时比如极稀疏的MoE专家路由、超长序列的Attention、或者KVCache相关的高效读取。此时你会修改Mainloop里的搬运策略甚至用CuTe重新描述输入输出的Layout。要注意的是第二层和第三层的“跳崖式”难度差异很大。定制Epilogue通常只要理解输出Tile分布而修改Mainloop往往需要同时驾驭TMA、流水线、Thread Block Cluster和Shared Memory布局。如果你只是要在1-2周内上线一个模型我建议尽量停留在第一层和第二层第三层业务收益不确定的时候不要轻易碰。4.3 性能调优要点Tile Size、Stage、Swizzle与BenchmarkCUTLASS调优本质上是“一次参数搜索”。最影响GEMM性能的几个参数是CTA Tile Shape决定一个Block负责多大的输出块。一般选128x128、128x256等。太大容易让线程块数不足太小共享内存复用率低带宽利用率上不去。Warp Tile Shape决定一个Warp在CTA内负责多少输出。它与Tensor Core指令的累加形状强相关。流水线Stage数一般2到5。Stage太少扛不住Global Memory延迟Stage太多Shared Memory放不下导致占用率下降。Swizzle模式影响Shared Memory的Bank冲突。一个错误的Swizzle可能带来30%以上性能损失。Cluster SizeHopper上用多个CTA组成Cluster可以共享数据但也会增加同步开销。我不建议人工挨个调而是建议用CUTLASS Profiler或者写一个简单的自动化脚本在预先定义的参数空间里跑Grid Search。以128x128为例可以扫CTA_Tile、Warp_Tile、Stage的组合然后比较TFLOPs。Benchmark时务必注意不要只跑一次取平均值要在同一分配好的显存上做多轮warmup后取稳定值要记录kernel启动之间是否有设备端同步开销要用Nsight Compute看Memory Throughput和Compute Throughput而不仅仅是看kernel时间。很多时候一个kernel看起来很快但GRID大小太小导致整个GPU没有喂满这时候更该关注SM 占用率和L2命中率。4.4 常见问题排查与踩坑记录这部分是我自己实操中最值钱的部分整理成一张速查表方便大家直接对照。现象可能原因解决思路编译时模板报错日志几千行模板参数不匹配常见于CollectiveBuilder里的Shape和类型先编译官方Example再逐步替换参数锁定第一个被拒绝的模板参数Kernel编译成功但结果不对Layout的RowMajor/ColumnMajor选错或者坐标顺序与预期不一致先用小Shape和全0/全1数据做正确性测试再对比cuBLAS结果Shared Memory使用量超出设备限制Tile或Stage数过大把StageCountAuto改为固定值或调小CTA Tile性能远低于cuBLASTile太小、Stage太少、Swizzle不合适或未启用对应架构开关用Profiler扫描参数空间检查实际运行的SASS中是否出现TMA指令CUDA error: no kernel image编译时没有为当前GPU架构实例化模板在CMake中开启对应架构选项比如CUTLASS_ENABLE_SM90nvidia-smi显示驱动异常驱动版本与CUDA runtime不匹配或内核模块未加载确认驱动与CUDA Toolkit版本关系重装驱动后重启再做CUTLASS编译前的环境检查运行时出现misaligned address输入指针内存对齐不满足16字节或32字节要求用cudaMalloc或显式对齐分配避免从普通malloc指针传入Epilogue融合后速度反而变慢融合的逻辑太重或数据在寄存器内的布局被打乱检查是否引发了额外的layout转换或spill必要时只融合轻量逐元素操作这些坑里我想特别强调“编译环境”问题。CUTLASS对CUDA版本和GCC版本有要求尤其新版面向Hopper的代码往往需要较新的CUDA Toolkit。如果你在Ubuntu上装的是旧驱动nvcc可能能编译出来但运行时nvidia-smi就报一堆错。遇到环境问题先不要怀疑CUTLASS代码先用最简单的deviceQuery确认GPU驱动、CUDA Runtime、nvcc三者版本匹配再回来跑Example。另外有一个很隐蔽的坑CUTLASS默认使用C17而很多老推理项目还停留在C14。混编时模板库的if constexpr、std::variant这类特性会在老标准下直接编译失败。这时候要么整体升级项目编译标准要么单独把CUTLASS相关文件编成一个CUDA动态库对外只暴露C接口。第二种方案我在实际部署中用得最多能有效隔离编译期复杂度也顺便解决了调用方不需要安装全套CUDA Toolkit的问题。关于精度问题我再多说一句。CUTLASS默认很多累加用的是FP32但如果你要用TF32或者FP16做累加必须在模板里显式指定。做了量化之后精度验证不能只看最大误差最好在一个真实模型上跑一遍用余弦相似度评估中间层输出。我遇到过一种情况单个GEMM误差在合理范围内但层叠起来之后误差被放大最终导致生成文本质量大幅下降。这种问题在算子库测评里很容易漏掉。最后再分享一个我个人的工作习惯拿到一个新架构的GPU我不会直接开始调优而是先跑一遍CUTLASS官方Profiler的GEMM benchmark看看这个GPU在当前驱动和CUTLASS版本下能跑到多少TFLOPs。这个数据就是后续所有调优的“天花板”。之后每改一个参数就用它和这个天花板对比而不是和某个网上所谓“最优配置”对比。因为硬件环境、驱动版本、编译参数都会影响结果只有自己实测出来的基线最可靠。在AI推理这条路上CUTLASS不会是唯一工具但它提供的那套“把硬件数据流抽象成可组合原语”的思路确实能帮你走得很远。模板元编程再怎么折磨人也比在几千个手写kernel里加功能要幸福得多。希望这篇笔记能让正在准备啃源码的你少走一点弯路。