- 引言 — 把同一个核函数写两遍会暴露什么
- 三个层次各自替我们做了什么决定
- CUDA C++ — 直接向硬件下达指令的层
- Triton — 按瓦片编写,把线程交给编译器
- 把同一个核函数写两遍来做比较
- CUTLASS 与 CuTe — 用模板拼装 GEMM
- 该选哪一层
- 结语 — 抽象层次的选择依据是修改速度,而不是性能
- 参考资料
引言 — 把同一个核函数写两遍会暴露什么
用 CUDA C++ 手写一个按行 softmax 核函数,算上 warp shuffle reduction、共享内存部分和,以及两次 block 同步,大概要写到 80 行。同一个核函数用 Triton 写只要 12 行。可是性能却相差无几,甚至 Triton 还略快一点。
第一次确认这个事实时,很容易会想"那还有什么理由用 CUDA C++呢?"。可是几个月后,又会遇到一个 Triton 怎么也表达不出来的核函数,只好重新打开 CUDA C++。实务中的判断标准,就藏在这两个点之间的某处。
本文用同一把尺子衡量编写核函数的三个层次。核实所用的版本是 Triton 3.7.1(2026 年 6 月发布)、CUTLASS 4.6.1(2026 年 7 月)、CUDA Toolkit 13.3 Update 1。这三个层次都在活跃变化中,具体 API 请以你所用版本的文档为准。
三个层次各自替我们做了什么决定
编写核函数说到底就是做出六项决定。三个层次的区别,在于这些决定里有多少由我们自己做,又有多少交给工具去做。
| 需要决定的事项 | CUDA C++ | Triton | CUTLASS / CuTe |
|---|---|---|---|
| grid 与 block 的形状 | 人 | 人(仅 program grid) | 人(通过瓦片策略) |
| block 内的线程布局 | 人 | 编译器 | 由模板决定 |
| 共享内存分配与 swing buffering | 人 | 编译器 | 由模板决定 |
| 避免 bank 冲突(swizzle) | 人 | 编译器 | 由布局表达 |
| 异步拷贝与流水线化 | 人 | 编译器 | 由模板决定 |
| Tensor Core 指令选择 | 人(或库) | 编译器 | 通过 atom 明示 |
具体来看是这样的。CUDA C++ 把六项全部留给我们自己决定。所以它最自由,也最耗时间。Triton 只决定前两项,其余全部交给编译器。所以代码短,但代价是在编译器处理不好的模式上,我们能插手的余地也变少了。CUTLASS 同样要决定全部六项,但不是自己动手写,而是挑选已经验证过的部件来拼装。所以它在 GEMM 这一类问题上能做到最高性能,只是学习成本更高。
CUDA C++ — 直接向硬件下达指令的层
先建立基准线。我们手写一个按行 softmax。一个 block 负责一行,最大值和总和分别在整个 block 范围内做 reduction。
// softmax_cuda.cu
// 构建: nvcc -O3 -arch=sm_80 softmax_cuda.cu -o softmax_cuda
#include <cstdio>
#include <cfloat>
#include <cuda_runtime.h>
static const int WARP = 32;
static const int BLOCK = 256; // 每个 block 有 8 个 warp
__inline__ __device__ float warpMax(float v) {
for (int off = WARP / 2; off > 0; off >>= 1)
v = fmaxf(v, __shfl_xor_sync(0xffffffffu, v, off));
return v;
}
__inline__ __device__ float warpSum(float v) {
for (int off = WARP / 2; off > 0; off >>= 1)
v += __shfl_xor_sync(0xffffffffu, v, off);
return v;
}
__global__ void softmaxKernel(float *out, const float *in, int nCols) {
__shared__ float part[BLOCK / WARP];
__shared__ float bcast; // 专门用于广播的槽位
const long row = blockIdx.x;
const float *src = in + row * nCols;
float *dst = out + row * nCols;
const int tid = threadIdx.x;
const int lane = tid % WARP, wid = tid / WARP;
const int nWarps = BLOCK / WARP;
// 第 1 遍: 该行的最大值
float m = -FLT_MAX;
for (int i = tid; i < nCols; i += BLOCK) m = fmaxf(m, src[i]);
m = warpMax(m);
if (lane == 0) part[wid] = m;
__syncthreads();
if (tid == 0) {
float t = part[0];
for (int w = 1; w < nWarps; w++) t = fmaxf(t, part[w]);
bcast = t;
}
__syncthreads();
m = bcast;
__syncthreads(); // 在重用 part[] 之前必不可少
// 第 2 遍: exp 的总和
float s = 0.f;
for (int i = tid; i < nCols; i += BLOCK) s += __expf(src[i] - m);
s = warpSum(s);
if (lane == 0) part[wid] = s;
__syncthreads();
if (tid == 0) {
float t = 0.f;
for (int w = 0; w < nWarps; w++) t += part[w];
bcast = t;
}
__syncthreads();
const float inv = 1.f / bcast;
// 第 3 遍: 归一化并写出
for (int i = tid; i < nCols; i += BLOCK) dst[i] = __expf(src[i] - m) * inv;
}
int main() {
const int R = 8192, C = 4096;
size_t bytes = (size_t)R * C * sizeof(float);
float *h = (float *)malloc(bytes);
for (long i = 0; i < (long)R * C; i++) h[i] = (float)((i * 37) % 100) * 0.01f;
float *d_in, *d_out;
cudaMalloc(&d_in, bytes); cudaMalloc(&d_out, bytes);
cudaMemcpy(d_in, h, bytes, cudaMemcpyHostToDevice);
for (int i = 0; i < 5; i++) softmaxKernel<<<R, BLOCK>>>(d_out, d_in, C);
cudaDeviceSynchronize();
cudaEvent_t a, b; cudaEventCreate(&a); cudaEventCreate(&b);
cudaEventRecord(a);
for (int i = 0; i < 50; i++) softmaxKernel<<<R, BLOCK>>>(d_out, d_in, C);
cudaEventRecord(b); cudaEventSynchronize(b);
float ms; cudaEventElapsedTime(&ms, a, b);
double per = ms / 50.0;
// 最小流量: 读一次、写一次。(虽然分 3 遍,但缓存会吸收掉)
printf("cuda %7.3f ms %7.1f GB/s\n", per,
2.0 * bytes / (per * 1e-3) / 1e9);
return 0;
}
这 80 行里,真正的数学运算只有三行。剩下的全是"在 block 内把值收集起来、再重新分发出去"的管道代码。而这段管道里,藏着一个不动声色的陷阱。
陷阱就是上面代码里 m = bcast; 之后的那个 __syncthreads()。如果没有它,跑得快的 warp 可能会先把第 2 遍的 part[wid] 覆盖掉,导致跑得慢的 warp 还没读到的第 1 遍结果就这样被抹掉了。结果在大多数运行里是对的,偶尔会错。在小规模输入上几乎复现不出来。这是手写核函数里代价最高的一类 bug,而且管道越长,出错概率越高。
Triton — 按瓦片编写,把线程交给编译器
下面用 Triton 写同一个运算。
# softmax_triton.py
# 运行: python softmax_triton.py (基于 triton 3.7.x、torch 2.13)
import torch
import triton
import triton.language as tl
@triton.jit
def softmax_kernel(out_ptr, in_ptr, in_stride, out_stride, n_cols,
BLOCK_SIZE: tl.constexpr):
row = tl.program_id(0)
cols = tl.arange(0, BLOCK_SIZE)
mask = cols < n_cols
# 把一整行一次性载入 SRAM。填充位置是 -inf,不影响 max/sum。
x = tl.load(in_ptr + row * in_stride + cols, mask=mask, other=-float("inf"))
x = x - tl.max(x, axis=0)
num = tl.exp(x)
y = num / tl.sum(num, axis=0)
tl.store(out_ptr + row * out_stride + cols, y, mask=mask)
def softmax(x: torch.Tensor) -> torch.Tensor:
n_rows, n_cols = x.shape
BLOCK_SIZE = triton.next_power_of_2(n_cols)
num_warps = 4 if BLOCK_SIZE < 2048 else (8 if BLOCK_SIZE < 8192 else 16)
out = torch.empty_like(x)
softmax_kernel[(n_rows,)](
out, x, x.stride(0), out.stride(0), n_cols,
BLOCK_SIZE=BLOCK_SIZE, num_warps=num_warps,
)
return out
if __name__ == "__main__":
torch.manual_seed(0)
x = torch.randn(8192, 4096, device="cuda", dtype=torch.float32)
ours, ref = softmax(x), torch.softmax(x, axis=1)
assert torch.allclose(ours, ref, atol=1e-5), "正确性验证失败"
gb = 2 * x.numel() * x.element_size() / 1e9
for name, fn in [
("triton", lambda: softmax(x)),
("torch ", lambda: torch.softmax(x, axis=1)),
]:
ms = triton.testing.do_bench(fn, warmup=25, rep=100)
print(f"{name} {ms:7.3f} ms {gb / (ms * 1e-3):7.1f} GB/s")
核心差异就是 tl.max(x, axis=0) 这一行。在 CUDA 里需要 40 行才能完成的整个 block 的 reduction,这里只是一次数组运算。要不要用 warp shuffle、共享内存分配多少字节、同步该放在哪——全部由编译器决定。前面说的那种漏掉同步导致的 bug,在这里从结构上就不可能发生,因为我们根本不需要自己写同步。
编译流水线 — 编译器到底做了什么
Triton 靠的不是魔法,而是一步步的阶段。从代码仓库的结构可以确认出这样一条路径。
Python 函数(@triton.jit)
│ 遍历 Python AST 生成 IR
▼
Triton IR(TTIR) 瓦片运算,尚无硬件概念
│ 布局分配、合并访存(coalescing)、流水线化 pass
▼
TritonGPU IR(TTGIR) warp/线程布局与共享内存已确定
│
▼
LLVM IR
│ NVPTX 后端 │ AMDGPU 后端
▼ ▼
PTX → (ptxas) → SASS AMDGCN → 目标文件
由此可以得出两个在实务上有意义的事实。
第一,后端分叉的位置在 LLVM IR 之下。Triton 代码仓库的 third_party 目录里,nvidia 和 amd 两个后端并排放在一起。这意味着同一个 @triton.jit 核函数会同时为两家厂商编译。接下来两节的内容,都建立在这个性质之上。
第二,从 TTIR 下降到 TTGIR 这一步,决定了性能的大部分。布局分配一旦出错,共享内存访问就会触发 bank 冲突;流水线化 pass 没接上,load 延迟就会完全暴露出来。我们真正能碰的旋钮,也就是 BLOCK_SIZE、num_warps、num_stages 这几个,所以 Triton 调优基本上就是在拧这三个旋钮。
Triton 把这种调节工作封装成了一个装饰器。
@triton.autotune(
configs=[
triton.Config({"BLOCK_M": 64, "BLOCK_N": 64}, num_warps=4, num_stages=3),
triton.Config({"BLOCK_M": 128, "BLOCK_N": 64}, num_warps=8, num_stages=3),
triton.Config({"BLOCK_M": 128, "BLOCK_N": 128}, num_warps=8, num_stages=4),
triton.Config({"BLOCK_M": 64, "BLOCK_N": 128}, num_warps=4, num_stages=4),
],
key=["M", "N", "K"], # 这些值一变,就会重新调优
)
@triton.jit
def matmul_kernel(...):
...
每当 key 里列出的参数发生变化,它就会把所有候选配置全部编译、实测一遍,然后把最快的那个缓存下来。要在 CUDA C++ 里做同样的事,就得自己动手写模板实例化和基准测试框架。
想看看编译器生成了什么的时候
有办法不把 Triton 当成一个黑盒。中间产物是可以直接取出来看的。
import triton, torch
# 编译一次核函数,拿到句柄
k = softmax_kernel.warmup(
torch.empty(1, 8, device="cuda"), torch.empty(1, 8, device="cuda"),
8, 8, 8, BLOCK_SIZE=8, grid=(1,),
)
k._init_handles()
print(k.asm.keys()) # ttir, ttgir, llir, ptx, cubin
print(k.asm["ttgir"][:800]) # 用肉眼确认布局分配的结果
print(k.n_regs, k.n_spills) # 一旦发生寄存器溢出,这里就不会是 0
如果 n_spills 不是 0,就说明寄存器不够用,溢出到了本地内存,通常是 BLOCK_SIZE 定得太大。这是 Triton 核函数莫名其妙变慢时,第一个该检查的值。
Triton 不擅长的地方
这部分需要老实交代。确实有一些场合不该指望 Triton。
- 需要精细 warp 级控制的核函数。依赖特定 lane 之间 shuffle、warp specialization(把 producer warp 和 consumer warp 分开的结构)、或
__ballot一类投票操作的算法,装不进瓦片抽象里。为了填补这个空白,Triton 最近在实验一种更底层的语言triton.experimental.gluon,但截至 3.7.1,它仍处于实验阶段,还不足以作为生产环境决策的依据。 - 不规则索引与依赖数据的控制流。像图遍历、排序、动态大小的稀疏运算这类访问模式要到运行时才能确定的情况,瓦片模型并不适合。
- 追求极致性能的 GEMM。稠密矩阵乘法是 CUTLASS 和 cuBLAS 积累了多年调优经验的领域,Triton 很难追上最后那 10 到 20 个百分点。实务中也没有必要这么做。
- 编译时间会计入响应时间的场合。JIT 编译的开销加在第一次调用上。打开自动调优后,这个开销还会按候选数量成倍增加。在 serving 路径上,缓存预热是一个前提条件。
- 要求精确数值可复现性的场合。reduction 的顺序是编译器决定的,版本一变,最后一位的比特就可能不一样。
把同一个核函数写两遍来做比较
下面是在相同条件下测量两种实现的方法,以及由此得到的结果大致会是什么样子。
测量规则和前一篇一样: 丢弃 warm-up,多次运行取平均,先验证正确性,再换算成有效带宽而不是单纯的时间。Triton 这边 triton.testing.do_bench 已经处理好了 warm-up、重复次数,甚至连 L2 缓存刷新都包了,不用自己再写一遍。CUDA 这边则用 cudaEvent 对齐相同的条件。
softmax 是算术强度很低的 memory-bound 核函数,两种实现最终都会撞上 HBM 带宽的上限。所以结果的形态大致是这样的。
| 实现 | 代码行数(核函数主体) | 有效带宽 | 备注 |
|---|---|---|---|
| CUDA C++(上面的代码) | 约 45 行 | 上限的 80% 区间 | 3 遍。大部分是 reduction 管道代码 |
| Triton(上面的代码) | 12 行 | 上限的 85% 区间 | 一旦一行能装进 SRAM 就只需 1 遍 |
torch.softmax | 0 行 | 上限的 85% 区间 | 已经是库中融合好的核函数 |
绝对数值会随 GPU 型号和行长度大幅变化,请在自己的硬件上跑一遍上面的测试框架,得出属于你自己的数字。这张表真正该读出来的,不是排名,而是两个观察。
第一,代码量相差将近 4 倍,性能却相差无几。原因是在 memory-bound 核函数里,两种实现最终撞上的是同一堵墙。既然墙决定了性能,那么到墙为止的代码自然是越短越好。
第二,torch.softmax 本身已经一样快了。这才是这次对比真正的教训: 不要自己手写标准算子。自己动手写核函数的价值,来自库里没有的运算——也就是融合组合,或者特定领域的 masking。举例来说,如果 softmax 前后挂着 masking、scaling 和 dropout,而它们各自作为独立的核函数运行,那么合并成一个的瞬间,HBM 往返次数就会从四次降到一次。这时候的收益能有 3 倍以上,而这正是使用 Triton 的理由。
为什么 Triton 成了自定义注意力核函数事实上的标准
现在只要有新的注意力变体核函数出现,大多数都会先用 Triton 实现。原因不在性能,而在结构。
- 注意力和瓦片模型天然契合。取一个 query block,按 block 逐块扫过 key 和 value,同时用 online softmax 累加——这个结构本身就是 Triton 的编程模型。
- 变体很多,寿命却很短。滑动窗口、ALiBi、soft-capping,以及各种稀疏模式不断冒出来,其中只有一部分能存活下来。没人能为了一次实验花两周时间去写 CUDA C++。
- 写一次,两家厂商都能跑。正如前面看到的,后端在 LLVM IR 之下才分叉,所以同一个核函数会同时为 NVIDIA 和 AMD 编译。如果用 CUDA C++ 写,AMD 那一侧就得另外做一遍。
- 整个生态已经默认了 Triton 的存在。PyTorch 的 TorchInductor 会把面向 GPU 的代码生成为 Triton 核函数。vLLM 在注意力后端列表里保留了
TRITON_ATTN,并把它放进了优先级表(vLLM 注意力后端文档)。一个工具已经装好、已经在跑,这件事本身就是巨大的优势。
CUTLASS 与 CuTe — 用模板拼装 GEMM
这是第三层。CUTLASS 是一个从 2017 年延续至今的 CUDA C++ 模板库,把矩阵乘法及其周边运算按层级分解,做成了可以重新拼装的部件。
核心思路是不再把 GEMM 看成一整块。把整个问题切成 thread block 瓦片,再把它切成 warp 瓦片,再切到单条 Tensor Core 指令能处理的大小。每一层都是独立的模板参数,我们要做的就是挑选组合。
// CUTLASS 3.x/4.x 风格 GEMM 配置的骨架
// 完整、可实际构建的示例请参考代码仓库中的 examples 目录。
using ElementA = cutlass::half_t;
using ElementB = cutlass::half_t;
using ElementC = float;
// 用多大的瓦片、分几个阶段的流水线来处理
using TileShape = cute::Shape<cute::_128, cute::_128, cute::_64>;
using ClusterShape = cute::Shape<cute::_1, cute::_1, cute::_1>;
// 把主循环(数据搬运 + Tensor Core 累加)
// 和 epilogue(附加在结果上的后处理)分开组装
using CollectiveMainloop = /* CollectiveBuilder<...> */;
using CollectiveEpilogue = /* CollectiveBuilder<...> */;
using GemmKernel = cutlass::gemm::kernel::GemmUniversal<
cute::Shape<int, int, int, int>, CollectiveMainloop, CollectiveEpilogue>;
using Gemm = cutlass::gemm::device::GemmUniversalAdapter<GemmKernel>;
这里实务上最有价值的概念是 epilogue。趁 GEMM 结果还留在累加寄存器里的时候,就直接把偏置加法和激活函数应用上去,写入 HBM 再读回来的那一趟往返就整个消失了。如果调用 cuBLAS,再用另一个核函数施加激活,这趟往返就会原样保留下来。直接使用 CUTLASS 的理由,很大一部分就在这里。
CuTe 与布局代数
从 CUTLASS 3.x 开始的基础是 CuTe。它的思路是把张量的布局——也就是从逻辑坐标到物理地址的映射——变成一等公民的值,再对它做代数组合。
在前一篇里,我们靠给数组填充一格来解决共享内存的 bank 冲突。在 CuTe 里,这类操作被表达成一种叫"swizzle"的布局变换,可以对瓦片做分解与合成,并且在编译期就会被检验。那种手写下标运算、一不小心就写错的 bug,在类型层面就会被拦下来。
老实说,学习成本很高。模板报错信息又长,概念又分好几层。所以 CUTLASS 4 加入了 Python 接口 CuTe DSL。CUTLASS 4.6.1 的 README 把它描述为"一个无需 C++、就能暴露 CuTe 核心概念(布局、张量、硬件 atom,以及对线程与数据层级的完全控制)的低级编程模型",并明确写明它目前处于公开测试版(beta)阶段。可以从 PyPI 用 nvidia-cutlass-dsl 安装,4.6.1 是最新版本。
测试版这一点,不妨就直接照单全收。拿来做原型验证是够用的,但要作为生产核函数的唯一实现,还为时过早。
该选哪一层
把判断压缩成一张表,大致是这样。
| 情况 | 推荐 | 理由 |
|---|---|---|
| 标准 GEMM、卷积 | cuBLAS / cuDNN | 没有理由自己写 |
| 想给 GEMM 加后处理,省掉一趟往返 | CUTLASS 的 epilogue | 就是为这个目的设计的层 |
| 注意力变体、融合的逐元素链 | Triton | 契合瓦片模型,迭代快 |
| MoE 路由、自定义归一化 | Triton | 同上 |
| 算法本质上依赖 warp specialization、lane 级 shuffle | CUDA C++ | 瓦片抽象表达不了 |
| 不规则索引、依赖数据的控制流 | CUDA C++ | 同上 |
| 必须第一时间用上新的硬件指令 | CUDA C++ 或 CUTLASS | 编译器支持来得晚 |
| 必须用一份代码同时支持 NVIDIA 和 AMD | Triton | 后端在更底层分叉 |
换成实务上的操作顺序,就是这样:先看库能不能搞定,不行就用 Triton 写;如果 Triton 表达不了或者性能上不去,再把 profiler 指出的那一个核函数下放到 CUDA C++ 去写。一上来就用 CUDA C++,只有在你已经确信这个核函数本质上就需要 warp 级控制时才说得通。
再补充一点,混用多个层次才是常态。打开一个真实的推理技术栈就会看到: GEMM 交给 cuBLAS 或 CUTLASS,注意力用手写的 CUDA C++ 或者 Triton,归一化和激活用 Triton,中间的粘合剂是 PyTorch。那种"必须选一个层次、把所有东西都统一起来"的压力,并没有什么依据。
结语 — 抽象层次的选择依据是修改速度,而不是性能
虽然比较了三个层次,但结论并不是"Triton 赢了"。CUDA C++ 和 Triton 在 memory-bound 核函数上性能相近,是因为两者撞上的是同一堵物理墙;CUTLASS 在 GEMM 上领先,是因为那堵墙立在别的地方。层次本身并不决定性能。
层次真正决定的是另一件事: 改一个核函数、重新测量一遍要花多久,漏掉同步导致非确定性出错的概率,新 GPU 一发布又要返工多少代码。如果性能相近,这些才应该是选择的依据——而在大多数实务场景里,性能确实相近。
所以把判断标准压缩成一句话就是: 这个核函数今后大概还要改多少次。如果是那种写一次、两年都不会再碰的核函数,下放到 CUDA C++、榨出最后 10% 的性能是合理的。如果是下个月实验还会继续碰的核函数,用 12 行写完,把省下来的时间拿去多跑一次实验,几乎永远是更好的选择。
参考资料
- Triton 代码仓库与教程: https://github.com/triton-lang/triton
- Triton 官方文档: https://triton-lang.org/main/index.html
- Tillet et al., Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations (MAPL 2019): https://dl.acm.org/doi/10.1145/3315508.3329973
- CUTLASS 代码仓库: https://github.com/NVIDIA/cutlass
- CuTe 文档: https://docs.nvidia.com/cutlass/media/docs/cpp/cute/00_quickstart.html
- CUDA C++ Programming Guide: https://docs.nvidia.com/cuda/cuda-c-programming-guide/
- vLLM 注意力后端文档: https://docs.vllm.ai/en/latest/design/attention_backends.html
현재 단락 (1/233)
用 CUDA C++ 手写一个按行 softmax 核函数,算上 warp shuffle reduction、共享内存部分和,以及两次 block 同步,大概要写到 80 行。同一个核函数用 Tri...