Skip to content

Triton 手写内核实战:从向量加法到 FlashAttention 核心 ​

摘要: torch.compile 的默认后端 TorchInductor 把计算图翻译成 Triton kernel——想真正理解 PyTorch 为什么快,或想为自定义算子手写高性能内核,Triton 是绕不开的一站。本文从零手写三个内核:向量加法 → 分块 GEMM → FlashAttention 核心,每个都给完整代码、性能对比和调优思路。

0. 实验目标 ​

  • 掌握 Triton 编程模型:块级(block-level)编程 + 自动并行
  • 手写向量加法与分块 GEMM,理解 tile(分块) 与 循环展开
  • 手写 FlashAttention 核心(online softmax 分块),对比 PyTorch 原生
  • 学会 kernel 调优:块大小、tl.constexpr、tl.static_range

1. 为什么框架工程师要学 Triton ​

原理讲解:

  • CUDA 内核手写成本高:线程/共享内存/同步全要手动管。Triton 把编程模型提到 "块"级别——你描述"每个块做什么",编译器负责映射到线程、调度、共享内存。
  • TorchInductor 生成的 GPU kernel 就是 Triton(见本知识库 PyTorch 编译栈 篇)——学 Triton = 看懂 torch.compile 的产物。
  • 自定义算子(融合 kernel、量化、长尾性能热点)用 Triton 写,几行到几十行搞定,性能可逼近手写 CUDA。

2. 内核一:向量加法(最小可运行) ​

python
import torch, triton, triton.language as tl

@triton.jit
def add_kernel(x_ptr, y_ptr, out_ptr, n, BLOCK: tl.constexpr):
    pid = tl.program_id(0)                          # 当前块的编号
    offs = pid * BLOCK + tl.arange(0, BLOCK)        # 本块覆盖的元素下标
    mask = offs < n                                 # 边界掩码(n 不整除 BLOCK)
    x = tl.load(x_ptr + offs, mask=mask)
    y = tl.load(y_ptr + offs, mask=mask)
    tl.store(out_ptr + offs, x + y, mask=mask)

def add(x, y):
    n = x.numel()
    out = torch.empty_like(x)
    grid = (triton.cdiv(n, 1024),)                  # 块数 = ceil(n / BLOCK)
    add_kernel[grid](x, y, out, n, BLOCK=1024)
    return out

x = torch.randn(1_000_000, device="cuda")
y = torch.randn(1_000_000, device="cuda")
print("正确性:", torch.allclose(add(x, y), x + y))

逐段拆解:

  • @triton.jit:把 Python 函数编译成 GPU kernel。函数体是"每块执行的逻辑",不是串行循环。
  • tl.program_id(0):当前块 ID;tl.arange(0, BLOCK):生成 [0, BLOCK) 的索引向量——Triton 是向量化(块内 SIMD)编程。
  • BLOCK: tl.constexpr:编译期常量。改块大小触发重新编译,所以调优是"编译期参数"而非运行期参数。
  • mask:边界处理。n=1_000_000 不整除 1024,最后一块要掩码防越界。
  • grid:启动的块数。Triton 自动把块调度到所有 SM——无需手动管线程。

3. 内核二:分块 GEMM(tile 化矩阵乘) ​

python
@triton.jit
def matmul_kernel(a_ptr, b_ptr, c_ptr, M, N, K,
                  stride_am, stride_ak, stride_bk, stride_bn, stride_cm, stride_cn,
                  BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr):
    pid_m = tl.program_id(0)
    pid_n = tl.program_id(1)
    rm = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)    # 本块的行
    rn = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)    # 本块的列
    acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
    for k in range(0, K, BLOCK_K):                  # K 维分块循环
        rk = k + tl.arange(0, BLOCK_K)
        a = tl.load(a_ptr + rm[:, None] * stride_am + rk[None, :] * stride_ak)
        b = tl.load(b_ptr + rk[:, None] * stride_bk + rn[None, :] * stride_bn)
        acc += tl.dot(a, b)                          # 块级矩阵乘(编译为 MMA)
    c = acc.to(tl.float16)
    tl.store(c_ptr + rm[:, None] * stride_cm + rn[None, :] * stride_cn, c)

def matmul(a, b):
    M, K = a.shape
    K2, N = b.shape
    c = torch.empty((M, N), device="cuda", dtype=torch.float16)
    grid = (triton.cdiv(M, 64), triton.cdiv(N, 64))
    matmul_kernel[grid](a, b, c, M, N, K,
                        a.stride(0), a.stride(1), b.stride(0), b.stride(1),
                        c.stride(0), c.stride(1), BLOCK_M=64, BLOCK_N=64, BLOCK_K=32)
    return c

a = torch.randn(1024, 1024, device="cuda", dtype=torch.float16)
b = torch.randn(1024, 1024, device="cuda", dtype=torch.float16)
out = matmul(a, b)
ref = a @ b
print("正确性:", torch.allclose(out.float(), ref.float(), atol=0.5))

原理讲解:

  • tile 化:输出矩阵切成 BLOCK_M × BLOCK_N 的块,每块一个 kernel 实例。K 维再切成 BLOCK_K 循环——这是 GEMM 高性能的核心:数据局部性 + 可展开循环。
  • tl.dot(a, b):编译成 GPU 的 MMA(矩阵乘加速指令)——性能与 cuBLAS 同级别的关键。
  • 为什么快:每个 tile 的 A/B 块在寄存器/共享内存中复用 K 次,主内存带宽消耗降为 O(MN(K/BLOCK_K)) 而非 O(MNK)。
  • 对比 cuBLAS:Triton 手写 GEMM 通常能达到 cuBLAS 的 70-95%——对于融合场景(如 attention 里内联乘加)反而能超过库。

4. 内核三:FlashAttention 核心(online softmax) ​

python
@triton.jit
def flashattn_kernel(q_ptr, k_ptr, v_ptr, o_ptr, N, d,
                     BLOCK: tl.constexpr, d_BLOCK: tl.constexpr):
    pid = tl.program_id(0)                            # 处理一个 query 块
    start = pid * BLOCK
    rq = start + tl.arange(0, BLOCK)                  # query 行
    rd = tl.arange(0, d_BLOCK)                        # head 维度

    q = tl.load(q_ptr + rq[:, None] * d + rd[None, :])
    m_i = tl.full((BLOCK,), float("-inf"), tl.float32)   # 运行最大值
    l_i = tl.zeros((BLOCK,), tl.float32)                 # 运行 exp 和
    acc = tl.zeros((BLOCK, d_BLOCK), tl.float32)

    for start_k in range(0, N, BLOCK):                # 分块扫描 K/V
        rk = start_k + tl.arange(0, BLOCK)
        k = tl.load(k_ptr + rk[:, None] * d + rd[None, :])
        # 注意力分数(无 scale,简化)
        s = tl.dot(q, tl.trans(k))                    # (BLOCK, BLOCK)
        m_ij = tl.max(s, axis=1)
        m_new = tl.maximum(m_i, m_ij)
        p = tl.exp(s - m_new[:, None])                # 数值稳定的 softmax
        alpha = tl.exp(m_i - m_new)
        l_i = l_i * alpha + tl.sum(p, axis=1)
        v = tl.load(v_ptr + rk[:, None] * d + rd[None, :])
        acc = acc * alpha[:, None] + tl.dot(p.to(tl.float16), v)
        m_i = m_new
    acc /= l_i[:, None]
    tl.store(o_ptr + rq[:, None] * d + rd[None, :], acc.to(tl.float16))

# 调用(简化:单头、d=64、N 对齐 BLOCK)
N, d = 512, 64
q = torch.randn(N, d, device="cuda", dtype=torch.float16)
k = torch.randn(N, d, device="cuda", dtype=torch.float16)
v = torch.randn(N, d, device="cuda", dtype=torch.float16)
o = torch.empty_like(q)
flashattn_kernel[(triton.cdiv(N, 64),)](q, k, v, o, N, d, BLOCK=64, d_BLOCK=64)
print("输出形状:", o.shape)

原理讲解(online softmax 三件套):

  • 问题:标准 attention 要先把整个 N×N 分数矩阵算出来再 softmax,显存 O(N²)——长序列直接爆炸。
  • 解决:分块扫描 K/V(每次只载 BLOCK×d 的块),维护三个运行量:m_i(运行最大值)、l_i(运行 exp 和)、acc(加权和)。每来一个块:更新最大值 → 重缩放 alpha=exp(m_i-m_new) → 更新 l_i 和 acc。全程显存 O(N),这就是 FlashAttention 的核心思想。
  • 代码里 tl.dot 复用 GEMM 加速;tl.trans 转置 K 块。
  • 工程注意:真实实现还需 scale(1/sqrt(d))、mask(因果)、多头循环——本文是最小可运行核心。

5. 调优三板斧 ​

python
# 1) 块大小:更大的 BLOCK 减少块数/循环,但受寄存器与占用率限制
#    GEMM 常见:BLOCK_M=64/128, BLOCK_N=64/128, BLOCK_K=16/32/64
# 2) 编译期常量(tl.constexpr):改值即重编译——调优参数不要写死成 Python 变量
# 3) tl.static_range:K 循环已知时用静态展开,让编译器展开循环

实验结果(示例量级 · A100 · FP16):

内核相对性能
手写 Triton GEMM(BLOCK=64/64/32)cuBLAS 的 ~85%
FlashAttention 核心(BLOCK=64)PyTorch 原生 attention 的 ~2-3×(长序列显存省 O(N²))

数值为相对量级参考。要点:Triton 手写内核达到库级性能(GEMM 80-95%),且融合场景(attention)可以超越原生实现——因为它把 softmax+乘加+输出写回融合进一个 kernel,省掉了中间 N×N 张量的读写。

6. 工程启示汇总 ​

  1. Triton 是"块级"编程模型:描述块做什么,编译器管线程/共享内存/调度。
  2. torch.compile 的后端就是 Triton:学它 = 看懂编译产物、能定制融合。
  3. GEMM 高性能 = tile 化 + tl.dot(MMA):K 循环展开、数据局部性。
  4. FlashAttention 核心 = online softmax 三件套(m_i/l_i/acc),显存 O(N) 的关键。
  5. 调优参数用 tl.constexpr:编译期常量,改值即重编译。
  6. tl.static_range 静态展开已知循环,给编译器展开机会。
  7. 自定义算子用 Triton 写:性能逼近手写 CUDA,接入 PyTorch 生态(dispatch,见 内部架构 篇)零改动。

7. 延伸阅读 ​

AI-HPC 智算超算专家 · Contact: openaihpc@gmail.com