Triton 是一种面向 GPU Kernel 的编程语言和编译器。与 CUDA 直接围绕线程、线程块编程不同,Triton 更强调 一个 Program Instance 处理一个数据块(Tile) :程序员描述块内数据的索引、加载、计算和写回,编译器负责将这些块级操作映射到 GPU 线程上。
一个完整、可维护的 Triton 算子通常由以下部分组成:
使用 @triton.jit 定义设备端 Kernel;
根据 Program ID 构造当前数据块的索引;
使用 Mask 保护不完整的边界块;
编写 Python Wrapper,检查输入、分配输出并配置启动网格;
与 PyTorch 参考实现比较,验证正确性;
预热并重复测量,统计可靠的性能数据。
本文以一维向量加法为例,提炼这种代码的一般形式。
Triton 的执行模型·
假设向量包含 N N N 个元素,每个 Triton Program 处理 BLOCK_SIZE 个元素,那么需要启动的 Program 数量为
N p r o g r a m s = ⌈ N B L O C K _ S I Z E ⌉ N_{programs}=\left\lceil\frac{N}{BLOCK\_SIZE}\right\rceil
N p r o g r am s = ⌈ B L O C K _ S I Z E N ⌉
Program 与数据的关系如下:
1 2 3 4 Program 0 -> [0, BLOCK_SIZE) Program 1 -> [BLOCK_SIZE, 2 * BLOCK_SIZE) Program 2 -> [2 * BLOCK_SIZE, 3 * BLOCK_SIZE) ...
这里的 Program 不等价于 CUDA 中的单个线程,更接近一个负责 Tile 的逻辑执行实例。tl.program_id(axis=0) 返回当前 Program 在启动网格第 0 维上的编号,tl.arange 则一次产生整个块的局部索引。
当 N N N 不是 BLOCK_SIZE 的整数倍时,最后一个 Program 会覆盖超出张量范围的地址。因此,几乎所有非整齐分块的 Kernel 都需要边界 Mask。
下面的向量加法包含 Kernel、启动函数和用户接口三层。
分出 _launch_add 的原因是正常调用需要创建输出,而性能测试通常需要复用预分配的输出,以免把内存分配时间混入 Kernel 时间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 import torchimport tritonimport triton.language as tl@triton.jit def add_kernel ( x_ptr, y_ptr, output_ptr, n_elements, BLOCK_SIZE: tl.constexpr, ): block_start = tl.program_id(axis=0 ) * BLOCK_SIZE offsets = block_start + tl.arange(0 , BLOCK_SIZE) mask = offsets < n_elements x = tl.load(x_ptr + offsets, mask=mask) y = tl.load(y_ptr + offsets, mask=mask) output = x + y tl.store(output_ptr + offsets, output, mask=mask) def _launch_add ( x: torch.Tensor, y: torch.Tensor, output: torch.Tensor, ) -> None : n_elements = output.numel() if n_elements == 0 : return grid = lambda meta: ( triton.cdiv(n_elements, meta["BLOCK_SIZE" ]), ) add_kernel[grid]( x, y, output, n_elements, BLOCK_SIZE=256 , ) def triton_add (x: torch.Tensor, y: torch.Tensor ) -> torch.Tensor: if not (x.is_cuda and y.is_cuda): raise ValueError("x and y must be CUDA tensors" ) if x.shape != y.shape: raise ValueError("x and y must have the same shape" ) if x.device != y.device: raise ValueError("x and y must be on the same device" ) if x.dtype != y.dtype: raise ValueError("x and y must have the same dtype" ) if not (x.is_contiguous() and y.is_contiguous()): raise ValueError("x and y must be contiguous" ) output = torch.empty_like(x) _launch_add(x, y, output) return output
Kernel 签名·
1 2 3 4 5 6 7 8 @triton.jit def add_kernel ( x_ptr, y_ptr, output_ptr, n_elements, BLOCK_SIZE: tl.constexpr, ):
@triton.jit 表示该函数由 Triton 即时编译并在 GPU 上执行。传入的 PyTorch CUDA Tensor 会根据其 data_ptr() 和 dtype 转换为设备指针。
Kernel 参数可以分成两类:
运行时参数 :输入输出指针、张量尺寸和 Stride 等;
编译期元参数 :块大小、流水级数或某个算法开关等,通常标记为 tl.constexpr。
BLOCK_SIZE 参与 tl.arange(0, BLOCK_SIZE) 的形状构造,所以必须在编译期确定。相比之下,n_elements 只参与运行时边界判断,不应该随意标为 tl.constexpr,否则不同长度可能生成不同的编译版本,增加编译和缓存开销。
块内索引与指针运算·
一维 Kernel 最常见的索引模板是:
1 2 pid = tl.program_id(axis=0 ) offsets = pid * BLOCK_SIZE + tl.arange(0 , BLOCK_SIZE)
pid * BLOCK_SIZE 是当前块的起始位置,tl.arange 产生块内向量索引。于是 offsets 不是单个整数,而是包含 BLOCK_SIZE 个元素的 Triton Tensor。
Triton 使用显式指针运算描述访存位置:
1 2 x = tl.load(x_ptr + offsets, mask=mask) tl.store(output_ptr + offsets, output, mask=mask)
这与 C/CUDA 中的指针偏移类似,但一次构造和访问的是一组地址。连续的 offsets 通常可以形成合并访存(Coalesced Access)。如果输入不是连续布局,就需要把维度索引与 Stride 组合到地址表达式中,而不能简单地把张量当作扁平连续数组。
边界 Mask·
边界 Mask 的典型写法是:
1 2 3 mask = offsets < n_elements x = tl.load(x_ptr + offsets, mask=mask, other=0.0 ) tl.store(output_ptr + offsets, output, mask=mask)
只计算 mask 而不把它传给 tl.load 和 tl.store 没有任何效果。越界读可能得到非法数据甚至触发运行错误,越界写则可能破坏其他张量的内容。
对于加法,越界位置不会被存储,因此 Load 的 other 值并不参与有效输出,可以省略。对于归约、矩阵乘法等运算,越界填充值会参与中间计算,此时必须根据运算选择单位元,例如:
求和或矩阵乘法使用 other=0.0;
求最大值通常使用负无穷;
求最小值通常使用正无穷。
启动网格与元参数·
Triton Kernel 通过以下语法启动:
1 2 3 4 5 6 7 add_kernel[grid]( x, y, output, n_elements, BLOCK_SIZE=256 , )
grid 描述需要创建多少个 Program Instance。它既可以是一个元组,也可以是一个根据元参数计算网格形状的函数:
1 grid = (triton.cdiv(n_elements, 256 ),)
或者:
1 2 3 grid = lambda meta: ( triton.cdiv(n_elements, meta["BLOCK_SIZE" ]), )
第二种形式使网格与 BLOCK_SIZE 保持关联。当块大小以后改为自动调优参数时,不需要同步修改网格计算。
Triton Kernel 的启动是异步的。Wrapper 返回 Tensor 时,GPU 计算可能仍在进行;后续有数据依赖的 GPU 操作会在同一 Stream 上保持正确顺序。只有在 CPU 必须读取结果或进行手工计时时,才需要显式同步。
Python Wrapper·
Kernel 本身应专注于 GPU 上的数据处理,而 Python Wrapper 负责建立一个安全、易用的算子接口,通常包括:
检查设备、形状、Dtype 和内存布局;
计算元素数量、维度与 Stride;
分配输出 Tensor;
处理空 Tensor 等无需启动 Kernel 的情况;
计算启动网格并传入元参数。
例如,Kernel 按连续一维布局生成地址,如果 Wrapper 接受非连续 Tensor,程序即使没有报错,也会读取错误的位置。另一种处理方式是在 Kernel 中显式接收 Stride,从而支持更一般的布局。
正确性验证·
自定义 Kernel 首先要证明结果正确,再讨论性能。最直接的方式是与 PyTorch 参考实现比较:
1 2 3 4 5 6 7 8 9 10 11 def test_correctness () -> None : torch.manual_seed(0 ) for size in (0 , 1 , 257 , 100_003 ): x = torch.randn(size, device="cuda" , dtype=torch.float32) y = torch.randn_like(x) actual = triton_add(x, y) expected = x + y torch.testing.assert_close(actual, expected)
测试尺寸不应只有 BLOCK_SIZE 的整数倍。建议至少覆盖:
空输入和单元素输入;
小于一个块的输入;
比一个块多一个元素的输入;
较大的非整齐尺寸;
算子支持的不同 Dtype;
极大值、极小值、零、无穷和 NaN 等特殊数据。
浮点运算还要根据算法选择合理的 rtol 和 atol。包含归约的 Kernel 可能改变加法顺序,因此不一定与参考实现逐位相等。
性能测试·
GPU 基准不能只执行一次,也不应把首次 JIT 编译、输出分配和异步调度混在一起。对于向量加法,可以预分配输出,并对 PyTorch 和 Triton 使用相同的测试条件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 def benchmark (size: int = 1 << 24 ) -> None : x = torch.randn(size, device="cuda" , dtype=torch.float32) y = torch.randn_like(x) torch_output = torch.empty_like(x) triton_output = torch.empty_like(x) options = { "warmup" : 100 , "rep" : 500 , "quantiles" : [0.2 , 0.5 , 0.8 ], "fast_flush" : True , } torch_times = triton.testing.do_bench( lambda : torch.add(x, y, out=torch_output), **options, ) triton_times = triton.testing.do_bench( lambda : _launch_add(x, y, triton_output), **options, ) bytes_moved = 3 * size * x.element_size() def gbps (median_ms: float ) -> float : return bytes_moved / (median_ms * 1e-3 ) / 1e9 for name, times in ( ("PyTorch" , torch_times), ("Triton" , triton_times), ): p20_ms, median_ms, p80_ms = times spread = (p80_ms - p20_ms) / median_ms * 100 print ( f"{name} : median={median_ms:.3 f} ms, " f"p20={p20_ms:.3 f} ms, p80={p80_ms:.3 f} ms, " f"spread={spread:.1 f} %, {gbps(median_ms):.1 f} GB/s" )
这段基准遵循几个原则:
排除内存分配 :两种实现都复用输出 Tensor,只测算子执行;
先预热再采样 :排除 JIT 编译、缓存初始化和 GPU 升频阶段;
使用分位数 :中位数比均值更不容易被系统抢占等长尾样本干扰,p20 和 p80 用于观察波动;
明确缓存条件 :fast_flush=True 冲刷 L2,更接近 DRAM 带宽测试;
统计实际数据流量 :向量加法读取 x、读取 y、写入 output,因此是三个 Tensor 的字节数;
统一单位 :GPU 规格通常使用十进制 GB/s,即 10 9 10^9 1 0 9 Byte/s,而不是 GiB/s。
单个尺寸不足以描述全部性能。小张量通常受 Launch Latency 限制,大张量才更容易进入带宽饱和区。正式性能报告应扫描一组对数递增的尺寸,并使用 triton.testing.perf_report 绘制性能曲线。
二维模板·
二维问题仍遵循相同骨架,只是启动网格和地址计算扩展到两个维度:
1 2 3 4 5 6 7 8 9 pid_m = tl.program_id(axis=0 ) pid_n = tl.program_id(axis=1 ) offsets_m = pid_m * BLOCK_SIZE_M + tl.arange(0 , BLOCK_SIZE_M) offsets_n = pid_n * BLOCK_SIZE_N + tl.arange(0 , BLOCK_SIZE_N) mask = (offsets_m[:, None ] < M) & (offsets_n[None , :] < N) ptrs = base_ptr + offsets_m[:, None ] * stride_m \ + offsets_n[None , :] * stride_n
二维网格可以写成:
1 2 3 4 grid = ( triton.cdiv(M, BLOCK_SIZE_M), triton.cdiv(N, BLOCK_SIZE_N), )
矩阵乘法、Softmax 和归约的复杂性主要来自块内算法、访存重用及数值稳定性,但其外层仍然是“Program ID → Tile 索引 → Load → Compute → Store”的结构。
自动调优·
固定的 BLOCK_SIZE 适合教学和简单算子。当性能明显依赖输入形状、Warp 数或流水级数时,可以使用 @triton.autotune:
1 2 3 4 5 6 7 8 9 10 11 @triton.autotune( configs=[ triton.Config({"BLOCK_SIZE" : 128 }, num_warps=4 ), triton.Config({"BLOCK_SIZE" : 256 }, num_warps=4 ), triton.Config({"BLOCK_SIZE" : 512 }, num_warps=8 ), ], key=["n_elements" ], )@triton.jit def kernel (..., n_elements, BLOCK_SIZE: tl.constexpr ): ...
自动调优器会针对给定 Key 尝试候选配置并缓存较优结果。它不是所有 Kernel 的必需部分:候选配置过多会增加首次运行时间,Key 选择过细也可能导致频繁重新调优。此外,调优期间 Kernel 会执行多次;如果 Kernel 会原地累加或修改状态,需要使用 reset_to_zero、restore_value 或 Hook 保证各候选配置在相同初始条件下测量。
Triton Kernel 的通用模板可以压缩成下面这条主线:
1 2 3 4 5 6 7 8 9 10 11 12 13 定义 JIT Kernel ↓ Program ID 映射到数据 Tile ↓ 构造指针与边界 Mask ↓ Load → Compute → Store ↓ Python Wrapper 校验、分配并启动 ↓ 参考实现验证正确性 ↓ 预热、重复采样并报告稳健统计量
参考资料·
讨论
评论