CuTe 与 CuTe DSL:从 C++ 模板元编程到 Python DSL 的 GPU 编程演进

1. 引言:一个被低估的名字

如果你写过 CUDA 高性能算子,大概率听过 CUTLASS,也大概率在某个深夜被 cute::Layout、cute::Tensor、Swizzle 这些词劝退过。而 CuTe,正是 CUTLASS 3.x 之后被推到台前的核心组件------它不是一个"库",更像是一套用 C++ 模板表达张量布局与线程映射的代数系统。

本文想聊清楚几件事:CuTe 是什么、它为什么会被设计出来、它的核心思想是什么(配可运行代码)、它这些年坚持了什么又放弃了什么、当前的热点方向,以及它和 Triton、TileLang 这类 GPU DSL 的对比------尤其是"它是 C++ 模板库而非 Python DSL、不基于 MLIR/TVM、编译过程不是黑盒"这一点带来的差异。

说明:CuTe 的 API 在 CUTLASS 3.x 各版本间有演进,本文示例基于 CUTLASS 3.x 的 CuTe 接口,实际使用时请以你本地版本的 cute/tensor.hpp 为准。

2. 前世:CuTe 诞生之前的世界

2.1 手写 CUDA 的困境

在 CuTe 之前,写一个高性能 GEMM 大致是这样的流程:

  • 手工推导 shared memory 的分块与 bank conflict 规避方案;
  • 手工计算每个线程负责的 (m, n, k) 坐标;
  • 手工写 ldmatrix / mma.sync 的内联 PTX;
  • 换一个 tile size,上面所有推导重来一遍。

问题在于:布局(layout)和线程映射(thread mapping)是耦合在代码里的,一旦硬件换代(比如从 Ampere 到 Hopper 的 TMA、WGMMA),大量代码需要重写。

2.2 CUTLASS 2.x 的模板化尝试

CUTLASS 2.x 用 C++ 模板把 GEMM 拆成了 ThreadblockShape、WarpShape、InstructionShape 等策略类,通过模板参数组合出不同的 kernel。这解决了"复用"问题,但没解决"表达力"问题:

  • 布局仍然是通过一堆嵌套的 cutlass::layout::RowMajor、PitchLinear 组合出来的,可读性差;
  • 想做非标准的 swizzle、非标准的线程重排,需要写大量特化;
  • 编译错误信息极其冗长,调试成本高。

2.3 行业环境与研究背景

CuTe 的设计动机,和 2020---2022 年前后的几个行业趋势直接相关:

  1. Tensor Core 指令越来越复杂 :从 mma.sync 到 Hopper 的 wgmma,再到 Blackwell 的 tcgen05,指令对操作数布局的要求越来越苛刻(比如 WGMMA 要求 A 在 shared memory 中满足特定的 swizzle 模式)。
  2. 异步与流水线成为标配 :cp.async、TMA、多级 pipeline,让"数据搬运"和"计算"的调度成为性能关键,而调度本质上是对布局的变换。
  3. 算子种类爆炸:Attention、MoE、卷积、归一化......每个算子都需要自定义的 tile 与线程映射,靠"特化"不可持续。
  4. MLIR/TVM 路线的启发与反思 :TVM 用调度原语(schedule primitives)表达变换,MLIR 用 dialect 表达多层抽象。CUTLASS 团队选择了另一条路------不引入编译器基础设施,而是用 C++ 模板在编译期完成布局代数运算。

于是 CuTe(CUDA Tensor)诞生了:把"张量布局"抽象成一个可以做代数运算的数学对象。

3. 核心设计思想:Layout 即函数

3.1 一句话概括

CuTe 的核心思想可以概括为:Layout 是一个从"逻辑坐标"到"物理偏移"的映射函数,而 Tensor 是 Layout 加上一块数据指针。

在 CuTe 中:

  • Layout 由 Shape 和 Stride 组成,写作 (Shape) : (Stride);
  • 一个 Layout 可以理解为 f(coord) = sum(coord[i] * stride[i]);
  • Tensor = Layout + Engine(通常是指针)。

这个抽象的美妙之处在于:布局可以像数字一样做运算 。composition、product、logical_divide、swizzle 这些操作,本质上都是对映射函数的组合与变换。

3.2 一个可运行的代码示例

下面这段代码演示了 CuTe 最核心的三件事:构造 Layout、做布局变换、用 Tensor 访问数据。

cpp 复制代码
// 编译:nvcc -std=c++17 -I<cutlass>/include example.cu -o example
#include <cute/tensor.hpp>
#include <cstdio>

using namespace cute;

int main() {
    // 1) 构造一个 4x8 的行主序 Layout
    //    Shape = (4, 8), Stride = (8, 1)
    //    即 f(i, j) = i * 8 + j
    auto layout_row_major = make_layout(make_shape(Int<4>{}, Int<8>{}),
                                        make_stride(Int<8>{}, Int<1>{}));

    // 2) 构造一个列主序 Layout
    //    Shape = (4, 8), Stride = (1, 4)
    //    即 f(i, j) = i * 1 + j * 4
    auto layout_col_major = make_layout(make_shape(Int<4>{}, Int<8>{}),
                                        make_stride(Int<1>{}, Int<4>{}));

    print("row-major: "); print(layout_row_major); print("\n");
    print("col-major: "); print(layout_col_major); print("\n");

    // 3) 布局代数:把 (4, 8) 拆成 ((2, 2), (4, 2))
    //    logical_divide 是 CuTe 里最常用的变换之一
    auto tiled = logical_divide(layout_row_major,
                                make_shape(Int<2>{}, Int<4>{}));
    print("logical_divide: "); print(tiled); print("\n");

    // 4) 用 Layout 构造 Tensor,并访问元素
    float data[32];
    for (int i = 0; i < 32; ++i) data[i] = static_cast<float>(i);

    auto tensor = make_tensor(make_gmem_ptr(data), layout_row_major);

    // tensor(2, 3) 等价于 data[2 * 8 + 3] = data[19]
    printf("tensor(2, 3) = %.1f\n", tensor(2, 3));

    // 5) 切片:取出第 2 行,得到一个长度为 8 的一维 Tensor
    auto row2 = tensor(Int<2>{}, _);
    print("row2 layout: "); print(row2.layout()); print("\n");
    printf("row2(3) = %.1f\n", row2(3));

    return 0;
}

这段代码里最值得体会的是第 3 步:logical_divide 把一个 (4,8):(8,1) 的布局拆成了"外层 tile 坐标 + 内层 tile 内坐标"的两级结构。这正是所有 GPU 分块算法的数学本质------把大矩阵切成 tile,再在 tile 内做局部计算。CuTe 把这个过程变成了一个可以组合的函数调用,而不是一堆手写的索引运算。

3.3 用 Layout 写一个算子:分块 GEMM 的骨架

上一节的示例只演示了"布局是什么",但 CuTe 真正的价值在于用布局直接驱动一个算子 。下面这段代码用 CuTe 实现一个最简化的分块 GEMM 骨架:把 C = A × B 拆成 threadblock 级 tile,用 local_tile 切块、用 TiledCopy 搬运、用 TiledMMA 计算。它不追求性能极致,但完整展示了"布局 → 分块 → 拷贝 → 计算"的算子写法。

cpp 复制代码
// 编译:nvcc -std=c++17 -arch=sm_80 -I<cutlass>/include gemm_tile.cu -o gemm_tile
#include <cute/tensor.hpp>
#include <cstdio>

using namespace cute;

// 一个极简的分块 GEMM:C[M,N] = A[M,K] * B[K,N]
// 这里只演示 CuTe 的布局驱动写法,省略了 pipeline / swizzle 等优化
template <class ProblemShape, class TiledMMA, class TiledCopyA, class TiledCopyB>
__global__ void tile_gemm_kernel(ProblemShape problem,
                                 TiledMMA tiled_mma,
                                 TiledCopyA copy_a,
                                 TiledCopyB copy_b,
                                 float const* A, float const* B, float* C) {
    // 1) 用 problem shape 构造全局张量:布局由 stride 决定
    auto M = get<0>(problem);
    auto N = get<1>(problem);
    auto K = get<2>(problem);

    auto gA = make_tensor(make_gmem_ptr(A), make_layout(make_shape(M, K), GenRowMajor{}));
    auto gB = make_tensor(make_gmem_ptr(B), make_layout(make_shape(K, N), GenRowMajor{}));
    auto gC = make_tensor(make_gmem_ptr(C), make_layout(make_shape(M, N), GenRowMajor{}));

    // 2) 取出当前 threadblock 负责的 tile
    //    local_tile 用布局代数把全局张量切成 (BLK_M, BLK_K) 的块
    auto blk_m = Int<128>{};
    auto blk_n = Int<128>{};
    auto blk_k = Int<8>{};

    auto blk_coord = make_coord(blockIdx.x, blockIdx.y, _);
    auto gA_blk = local_tile(gA, make_shape(blk_m, blk_k), make_coord(_, _, blockIdx.z));
    auto gB_blk = local_tile(gB, make_shape(blk_k, blk_n), make_coord(_, _, blockIdx.z));
    auto gC_blk = local_tile(gC, make_shape(blk_m, blk_n), blk_coord);

    // 3) 为 shared memory 构造布局,并用 TiledCopy 建立拷贝关系
    //    smem layout 由 TiledCopy 的 dst 布局推导,保证与 MMA 的 fragment 对齐
    __shared__ float smem_a[cosize_v<decltype(tiled_mma.get_slice(0).partition_A(
        make_layout(make_shape(blk_m, blk_k))))>];
    __shared__ float smem_b[cosize_v<decltype(tiled_mma.get_slice(0).partition_B(
        make_layout(make_shape(blk_k, blk_n))))>];

    auto sA = make_tensor(make_smem_ptr(smem_a), tiled_mma.get_slice(0).partition_A(
        make_layout(make_shape(blk_m, blk_k))));
    auto sB = make_tensor(make_smem_ptr(smem_b), tiled_mma.get_slice(0).partition_B(
        make_layout(make_shape(blk_k, blk_n))));

    // 4) 建立累加器 fragment,并清零
    auto acc = partition_fragment_C(tiled_mma, make_shape(blk_m, blk_n));
    clear(acc);

    // 5) 主循环:拷贝 tile 到 smem,再交给 MMA 计算
    for (int k = 0; k < K / blk_k; ++k) {
        // 用 TiledCopy 把全局 tile 搬到 shared memory
        copy(copy_a, gA_blk(_, _, k), sA);
        copy(copy_b, gB_blk(_, _, k), sB);

        // 用 TiledMMA 做分块矩阵乘,累加到 acc
        gemm(tiled_mma, sA, sB, acc);
    }

    // 6) 把累加器写回全局内存
    auto gC_tile = local_tile(gC, make_shape(blk_m, blk_n), blk_coord);
    copy(acc, gC_tile);
}

int main() {
    // 问题规模与 tile 配置
    auto problem = make_shape(Int<1024>{}, Int<1024>{}, Int<1024>{});
    auto tiled_mma = make_tiled_mma(SM80_16x8x8_F32F16F16F32_TN{});
    auto copy_a = make_tiled_copy_A(SM75_16x8x8_F32F16F16F32_TN{}, tiled_mma);
    auto copy_b = make_tiled_copy_B(SM75_16x8x8_F32F16F16F32_TN{}, tiled_mma);

    // 这里只打印布局,实际运行需要分配 device 内存并 launch kernel
    print("tiled_mma: "); print(tiled_mma); print("\n");
    print("copy_a: "); print(copy_a); print("\n");
    return 0;
}

这段代码里,每一处"分块"和"搬运"都是布局运算的结果,而不是手写的索引:

  • local_tile 用 make_shape(blk_m, blk_k) 把全局张量切成 tile,返回的仍是一个 Tensor,布局自动变成"tile 坐标 + tile 内坐标"的两级结构;
  • tiled_mma.get_slice(0).partition_A(...) 根据 MMA 指令对操作数的布局要求,自动推导出 shared memory 中 A 的 fragment 布局------你不需要手算 swizzle,CuTe 会保证它满足指令约束;
  • copy(copy_a, gA_blk, sA) 中的 copy_a 是一个 TiledCopy,它把"哪个线程搬哪个元素"编码成布局,copy 只是按布局执行;
  • gemm(tiled_mma, sA, sB, acc) 同理,tiled_mma 把"哪个线程算哪个输出"编码成布局,gemm 按布局展开成具体的 mma.sync 指令。

换句话说,写算子的过程就是不断构造和变换布局的过程:全局布局 → tile 布局 → fragment 布局 → 指令布局。这也是为什么 CuTe 的代码看起来"全是类型推导"------因为布局本身就是类型的一部分,编译器在编译期就把所有索引算完了。

提示:上面这段代码为了突出布局驱动的主线,省略了 cp.async 流水线、Swizzle 防 bank conflict、边界处理等工程细节。真实的高性能 kernel 会在这些地方继续用布局代数做优化,但骨架是一致的。

3.4 为什么这个抽象重要

因为一旦布局是"值"而不是"代码结构",你就可以:

  • 在编译期对布局做静态检查(static_assert 布局是否满足 WGMMA 要求);
  • 把布局作为模板参数传递,让编译器为每种布局生成最优代码;
  • 用同一套代码适配不同的 tile size、不同的 swizzle 模式、不同的硬件指令。

这就是 CuTe 相比 CUTLASS 2.x 的本质跃迁:从"策略类组合"到"布局代数"。

4. 今生:CuTe 的演进

4.1 坚持了什么

  • 零运行时开销的编译期抽象:所有布局运算都在编译期完成,生成的代码和手写 CUDA 等价。
  • C++ 模板作为唯一载体 :不引入新的语言、不依赖外部编译器,#include 即可用。
  • 布局与算法解耦 :copy、gemm、mma 这些算法对布局是泛型的,布局由调用方指定。
  • 与 CUTLASS 生态深度绑定:CuTe 是 CUTLASS 3.x 的底座,所有 CUTLASS 3.x kernel 都建立在 CuTe 之上。

4.2 增加了什么

  • 更完整的原子操作集 :copy / copy_if / gemm / mma / axpby 等通用算法,配合 TiledCopy、TiledMMA 表达"分块后的拷贝与矩阵乘"。
  • 对 Hopper / Blackwell 新特性的支持 :TMA(SM90_TMA_LOAD)、WGMMA、tcgen05 等指令的 CuTe 封装。
  • Swizzle 的一等公民化 :Swizzle<B, M, S> 成为布局变换的标准工具,用于规避 shared memory bank conflict。
  • CuTe DSL(Python):这是近年最重要的新增方向,见 4.4。

4.3 放弃了什么

  • 放弃了"一套模板打天下"的幻想:不同架构(Ampere / Hopper / Blackwell)的指令差异太大,CuTe 选择了"按架构提供不同原子"而非强行统一。
  • 放弃了 friendly error message 的追求:模板元编程的报错依然冗长,这是 C++ 模板路线的固有代价。
  • 部分旧接口的弃用:CUTLASS 3.x 逐步弃用了 2.x 的一些 layout 类型,迁移成本由用户承担。

4.4 CuTe DSL:从 C++ 模板到 Python

CuTe 的 C++ 模板虽然强大,但门槛极高:编译慢、报错难读、调试困难。于是出现了 CuTe DSL------用 Python 提供 CuTe 的布局代数能力,底层仍然生成高效的 GPU 代码。

它的意义在于:

  • 降低入门门槛:Python 的交互式体验让布局调试变得可行;
  • 保留 CuTe 的语义 :Layout、Tensor、TiledCopy 这些概念在 DSL 中一一对应;
  • 编译期与运行期分离:Python 侧负责描述与特化,编译产物仍然是原生的 CUDA kernel。

这实际上是对"CuTe 是 C++ 模板库"这一身份的一次重要补充:模板库负责极致性能与底层控制,DSL 负责易用性与快速迭代。

5. 当前热点与未来方向

5.1 当前热点

  • Blackwell 架构适配 :tcgen05 指令族、新的 Tensor Memory 层次,CuTe 正在跟进封装。
  • CuTe DSL 的成熟:如何让 Python DSL 生成的代码性能逼近手写 C++,是当前的核心工程问题。
  • 与 PyTorch 生态的衔接:自定义算子、编译后端集成。
  • Attention / MoE 等新算子的 CuTe 化:FlashAttention 系列的很多实现已经深度使用 CuTe 的布局抽象。

5.2 想增加的方向

  • 更友好的编译期诊断(至少在 DSL 层面);
  • 更通用的自动布局推导(给定指令约束,自动求解满足条件的 swizzle);
  • 跨架构的可移植抽象,减少"换卡重写"的成本。

5.3 未来方向

CuTe 的长期定位大概率是:成为 NVIDIA GPU 上"布局与指令映射"的事实标准中间层。上层是各种 DSL(CuTe DSL、Triton 等),下层是具体的 PTX 指令,CuTe 负责把"逻辑上的张量运算"翻译成"物理上的指令序列"。

6. 横向对比:CuTe vs Triton vs TileLang

6.1 共同点

  • 目标一致:都是为了让开发者用更高层的抽象写出接近硬件极限的 GPU 算子。
  • 核心抽象相似:都围绕"tile / block"、"layout"、"thread mapping"这几个概念展开。
  • 都强调编译期特化:通过模板或 JIT,把 tile size、布局等参数在编译期确定,避免运行时开销。

6.2 关键区别:CuTe 是 C++ 模板库

这是最本质的差异,值得展开:

维度 CuTe Triton TileLang
载体 C++ 模板库 Python DSL Python DSL
编译基础设施 无(纯 C++ 模板 + nvcc) 自研编译器(基于 MLIR) 基于 TVM/MLIR
编译过程 白盒,可读生成的 PTX/SASS 相对黑盒 相对黑盒
抽象层级 低(贴近指令) 中(贴近算法) 中(贴近算法)
上手难度 高 低 中
性能上限 极高(可精确控制每条指令) 高 高
调试体验 差(模板报错) 好 较好

具体来说:

  • 不基于 MLIR/TVM,编译不是黑盒 :CuTe 没有自己的编译器,它就是 C++ 模板 + nvcc。你写的每一行 CuTe 代码,最终展开成什么,是可以通过 -E、--ptx 直接看到的。这意味着当性能不达预期时,你可以精确地定位到是哪条指令、哪个布局出了问题,而不是去猜编译器做了什么优化。
  • 更底层,因此编译时更灵活:CuTe 允许你在编译期做任意复杂的布局运算,甚至可以根据硬件特性动态选择指令。这种灵活性是高层 DSL 难以提供的------DSL 的抽象一旦固定,就只能在框架内做优化。
  • 上限更高:因为贴近指令,CuTe 可以榨干硬件的每一分性能,包括手工调度 pipeline、精确控制寄存器分配。

6.3 CuTe 的不足

  • 学习曲线陡峭:模板元编程 + GPU 体系结构,双重门槛。
  • 编译时间长:大量模板实例化,大型 kernel 编译动辄数分钟。
  • 报错信息不友好:一个布局不匹配,可能产生几百行模板错误。
  • 可移植性差:深度绑定 NVIDIA 硬件,换 AMD/其他加速器基本要重写。
  • 开发效率低:相比 Triton 几十行写一个 kernel,CuTe 往往需要几百行。

6.4 如何选择

  • 追求极致性能 、需要精确控制指令与布局、团队有 CUDA 专家 → CuTe;
  • 追求开发效率、算子相对标准、希望快速迭代 → Triton;
  • 介于两者之间、需要较好的性能与可接受的开发成本 → TileLang;
  • 想要 CuTe 的能力但不想写 C++ 模板 → CuTe DSL。

7. 总结

CuTe 的价值不在于它"好用",而在于它把 GPU 编程中最难表达的部分------布局与线程映射------变成了可以做代数运算的数学对象。它坚持 C++ 模板路线,拒绝引入编译器黑盒,换来了极致的可控性与性能上限,代价是陡峭的学习曲线与糟糕的报错体验。

CuTe DSL 的出现,说明这个团队也意识到了"曲高和寡"的问题,正在尝试用 Python 的易用性包裹 C++ 模板的强大。未来 CuTe 大概率会继续扮演"底层布局中间层"的角色,而 DSL 层则负责把这份能力交付给更多开发者。

对于学习者,我的建议是:先用 Triton 建立对 GPU 算子优化的直觉,再回头读 CuTe 的 Layout 代数 ------当你真正理解 logical_divide 在做什么的时候,很多之前看不懂的 kernel 代码会突然变得清晰。

相关推荐
·present·16 分钟前
CSAPP(lecture 13_02 Linking)
c语言·c++
10年前端老司机44 分钟前
Milvus 8种检索能力实践
数据库·python·agent
FRing031 小时前
vue2干货总结8(路由模块封装、router-link、声明式导航、路由重定向、编程式导航)
开发语言·前端·vue.js·vue2
旖旎夜光1 小时前
【C++ 基础入门】C++ 多态(上):虚函数、重写、override/final 与抽象类
开发语言·c++·笔记·学习
starzy19901 小时前
深入理解Scala嵌套函数:作用域封装之道
开发语言·scala
Sakura_j_h_1 小时前
从零写一个 MCP stdio Server:用 Python 校验博客文章格式
开发语言·python·自动化·ai工具·mcp·json-rpc
大草原的小灰灰2 小时前
LLM应用的标准开发框架LangChain(八)- 中间件
python·中间件·langchain
卷无止境2 小时前
百万 token 时代的真相:主流大模型上下文窗口与最大输出的横向对比分析
人工智能·python
用户298698530142 小时前
Python 实战:将 HTML 批量转换为 Markdown
python·html·markdown