CS336 · 第 6 课

Kernels 与 Triton 编程

上一讲说明了 FLOPs 和 HBM bytes 为什么共同决定性能;这一讲进一步回答程序员能做什么:怎样测出真实瓶颈,并通过 fusion、tiling 和更合适的 kernel 编程层次减少数据搬运。

授课:Percy Liang 预计用时:80 分钟 难度:中等 → 困难

Spring 2026 官方来源:Lecture 6 trace · 课程日程

本页是中文学习导读;讲次主题、教师与材料版本以 Stanford 官方页面和讲义为准。


这一讲到底解决什么问题?

硬件规格只能给出上限,无法告诉我们当前 workload 卡在 launch overhead、HBM traffic、occupancy 还是计算单元。优化必须从可重复测量开始,否则 fusion 或更复杂的 kernel 可能只是增加代码量。

30 秒知识地图

可靠 benchmark → profiler 定位瓶颈 → fusion 消除中间 HBM 往返 → tiling 建立片上复用 → Triton 表达 block-level 数据流 → PTX / hardware detail 用于解释剩余差距。

本课问题与 prerequisite refresher

数学 expression 不等于 GPU kernel:前者定义结果,后者定义线程怎样分工、数据经过哪些 memory levels。先会同步 GPU 计时、知道异步 launch 与 warm-up,才谈优化。

从表达式到时间

PyTorch ops → kernel graph → launches + HBM intermediates
                           ↓ profile
fusion → fewer launches / fewer HBM round trips
tiling → more on-chip reuse
masking → boundary correctness

correctness baseline → warm-up → synchronized timing → profiler → hypothesis

为什么 fusion 有效

计算 \(z=\mathrm{relu}(x+1)\)。两个 kernels 会读 \(x\)、写中间 \(u\),再读 \(u\)、写 \(z\);fused kernel 每个元素加载一次 \(x\),在 register 做 add 与 relu,只写一次 \(z\)。数学 FLOPs 几乎不变,HBM traffic 与 launch 数减少。若算子中间结果必须跨设备或被别处消费,这个 fusion 就可能不成立。

反例:更少 kernel 不保证更快

过度 fusion 可能增加 registers、产生 spill、降低可并行 blocks,或让一个通用 kernel 失去专用 Tensor Core path。Triton 也不会自动消除错误的 tile、非 coalesced access 或不正确 mask;DSL 降低表达门槛,不替代性能模型。

Benchmarking 与 Profiling

为什么要 benchmark?

Spec sheet 给上限,benchmark 给当前实现。

实际性能取决于 shape、dtype、库版本、硬件和系统负载。每次优化都应先定义基线,再在控制变量的条件下比较。

Benchmark 的正确姿势

import time

def benchmark(func, num_warmups=1, num_trials=5):
    """正确的 benchmark 方法。"""
    # 1. 预热: 让 JIT 编译、缓存预热
    for _ in range(num_warmups):
        func()
    torch.cuda.synchronize()  # 等待 GPU 完成!

    # 2. 正式计时
    times = []
    for _ in range(num_trials):
        start = time.time()
        func()
        torch.cuda.synchronize()  # 关键!GPU 是异步的
        times.append(time.time() - start)

    return sum(times) / len(times)

关键:torch.cuda.synchronize()

GPU kernel launch 是异步的——func() 返回时 kernel 可能还在跑。不 synchronize 的话,你只测了 launch 时间,不是执行时间。

Profiling:看时间花在哪

from torch.profiler import ProfilerActivity

with torch.profiler.profile(
    activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
    with_stack=True,
) as prof:
    loss = model(x).mean()
    loss.backward()

# 打印时间排名
print(prof.key_averages().table(sort_by="cuda_time_total"))

# 导出 flame graph(可视化调用栈)
prof.export_stacks("stacks.txt", "self_cuda_time_total")

Benchmarking 能回答什么?


Kernel Fusion

问题:每个操作都读/写 HBM

# 假设计算 GeLU(A @ B)
# 不 fused 的版本:

# Kernel 1: matmul
tmp = A @ B          # 读 A, B from HBM; 写 tmp to HBM

# Kernel 2: gelu
output = gelu(tmp)   # 读 tmp from HBM; 写 output to HBM

# 问题: tmp 被写入 HBM,然后立即读回——浪费!

解决:融合成一个 kernel

Fused 版本: 一个 kernel 完成
读 A, B from HBM
计算 A @ B,直接在寄存器中做 GeLU
写 output to HBM

省掉了 tmp 的 HBM 读写!

仓库 vs 工厂类比:

不 fused:把零件从仓库搬到工厂,搬回仓库,再搬回工厂...

Fused:零件只搬一次,所有加工在工厂内完成。

Benchmark 对比

GeLU 的 benchmark 结果(dim=16384, bf16):
manual_gelu (不 fused): ~2.5 ms ← 多次 HBM 读写
pytorch_gelu (fused): ~0.8 ms ← 一次 HBM 读写

Fused 版本快 3×!

PyTorch 自动 fusion

# torch.compile 可以自动融合
@torch.compile
def fused_operation(x, w):
    return gelu(x @ w)  # 自动融合 matmul + gelu

# 但手动写 kernel 仍然有价值:
# 1. 更激进的融合(跨多个 matmul)
# 2. 自定义操作(FlashAttention)
# 3. 精确控制 shared memory

Implementation · CUDA kernel 与 Python extension

CUDA Kernel 入门

CUDA 执行模型

# Grid: 所有 thread blocks 的集合
# Thread Block: 一组共享 shared memory 的 threads
# Thread: 基本执行单位

# 启动 kernel
kernel_name<<<num_blocks, block_size>>>(args)

# 例如: 1024 个 thread, block_size=256
# → num_blocks = 4
# → 4 个 block, 每个 256 个 thread

第一个 CUDA Kernel:GeLU

// gelu.cu
#include <cuda_fp16.h>

__global__ void gelu_kernel(
    const half* __restrict__ x,
    half* __restrict__ y,
    int num_elements
) {
    // 每个 thread 处理一个元素
    int i = blockIdx.x * blockDim.x + threadIdx.x;

    if (i < num_elements) {
        float xi = __half2float(x[i]);
        // tanh approximation of GELU
        float a = 0.79788456f * (xi + 0.044715f * xi * xi * xi);
        float exp_2a = expf(2.0f * a);
        float tanh_a = (exp_2a - 1.0f) / (exp_2a + 1.0f);
        float result = 0.5f * xi * (1.0f + tanh_a);
        y[i] = __float2half(result);
    }
}

在 Python 中加载

from torch.utils.cpp_extension import load_inline

cuda_source = open("gelu.cu").read()
cpp_source = "torch::Tensor gelu(torch::Tensor x);"

module = load_inline(
    cuda_sources=[cuda_source],
    cpp_sources=[cpp_source],
    functions=["gelu"],
    extra_cflags=["-O2"],
    name="inline_gelu",
    build_directory="var/cuda_gelu",
)

# 使用
y = module.gelu(x)

注意:逐元素操作(如 GeLU)在 CUDA 中相对简单——每个 thread 独立处理一个元素。真正的挑战是需要跨元素聚合的操作:softmax、layer norm、matmul。


Triton 入门

Triton 是什么?

Triton 是 OpenAI 2021 年发布的 GPU 编程语言——用 Python 语法写 GPU kernel。

任务CUDATriton
Memory coalescing手动自动
Shared memory 管理手动自动
SM 内调度手动自动
跨 SM 调度手动手动

Triton 编程模型

import triton
import triton.language as tl

@triton.jit
def gelu_kernel(
    x_ptr, y_ptr,
    num_elements,
    BLOCK_SIZE: tl.constexpr,
):
    # 每个 program (block) 处理 BLOCK_SIZE 个元素
    pid = tl.program_id(0)
    block_start = pid * BLOCK_SIZE

    # 这个 block 处理的元素索引
    offsets = block_start + tl.arange(0, BLOCK_SIZE)
    mask = offsets < num_elements  # 边界保护

    # 读取
    x = tl.load(x_ptr + offsets, mask=mask)

    # 计算 (和标准 GeLU 一样)
    a = 0.79788456 * (x + 0.044715 * x * x * x)
    exp = tl.exp(2 * a)
    tanh = (exp - 1) / (exp + 1)
    y = 0.5 * x * (1 + tanh)

    # 写回
    tl.store(y_ptr + offsets, y, mask=mask)

def triton_gelu(x):
    y = torch.empty_like(x)
    num_elements = x.numel()
    block_size = 1024
    num_blocks = triton.cdiv(num_elements, block_size)
    gelu_kernel[(num_blocks,)](x, y, num_elements, BLOCK_SIZE=block_size)
    return y

Triton 的优势


Triton 实战:Fused Softmax

Naive Softmax 的问题

# Naive 实现: 5 次遍历数据
def manual_softmax(x):
    x_max = x.max(dim=1)[0]      # 遍历 1: 求 max
    x = x - x_max[:, None]        # 遍历 2: 减 max
    numerator = torch.exp(x)      # 遍历 3: exp
    denominator = numerator.sum(dim=1)  # 遍历 4: sum
    y = numerator / denominator[:, None]  # 遍历 5: 除法
    return y

# 5MN 次读取 + 3MN 次写入
# 理论上只需要 MN 读 + MN 写(4× 差距!)

Fused Softmax Kernel

@triton.jit
def softmax_kernel(
    x_ptr, y_ptr,
    x_row_stride, y_row_stride,
    num_cols,
    BLOCK_SIZE: tl.constexpr,
):
    # 每个 program 处理一行
    row_idx = tl.program_id(0)
    col_offsets = tl.arange(0, BLOCK_SIZE)

    # 读取一行(从 HBM 到 SRAM)
    x_start_ptr = x_ptr + row_idx * x_row_stride
    x_ptrs = x_start_ptr + col_offsets
    x_row = tl.load(x_ptrs, mask=col_offsets < num_cols, other=float("-inf"))

    # 在 SRAM 中完成所有计算
    x_row = x_row - tl.max(x_row, axis=0)        # 减 max
    numerator = tl.exp(x_row)                     # exp
    denominator = tl.sum(numerator, axis=0)       # sum
    y_row = numerator / denominator                # 除法

    # 写回 HBM
    y_start_ptr = y_ptr + row_idx * y_row_stride
    y_ptrs = y_start_ptr + col_offsets
    tl.store(y_ptrs, y_row, mask=col_offsets < num_cols)

关键:整个 softmax 在 SRAM 中完成,HBM 只读一次、写一次。

Naive: 5MN reads + 3MN writes

Fused: MN reads + MN writes

理论加速:4×

性能对比

dim=16384, bf16, H100
manual_softmax: ~2.5 ms
pytorch_softmax: ~0.7 ms ← PyTorch 已实现 fused
triton_softmax: ~0.7 ms ← 接近 PyTorch
compiled_softmax: ~0.7 ms ← torch.compile 也能 fused


Tiling(分块)与 Shared Memory

为什么需要 Tiling?

矩阵乘法 C = A × B,朴素实现每个元素独立计算:

朴素 matmul: 每个 C[i,j] 独立读 A 的一行和 B 的一列
C 有 MN 个元素,每个需要读 K 个元素
总读取: 2 × M × N × K 次 HBM 访问

问题: A 的同一行被读了 N 次(每列一次)
B 的同一列被读了 M 次(每行一次)

Tiled Matmul

关键思想: 分块加载到 shared memory
每个 block 计算 C 的一个 TILE_SIZE × TILE_SIZE 子矩阵

步骤:
1. 把 A 的一个 TILE × TILE 块加载到 shared memory
2. 把 B 的一个 TILE × TILE 块加载到 shared memory
3. 用这两个 shared memory 块计算部分结果
4. 重复直到覆盖整个 K 维度

效果: A 的每个元素只从 HBM 读一次(被多个 C 元素复用)

Triton 的自动 Tiling

# Triton 的 matmul kernel 自动处理 tiling:
@triton.jit
def matmul_kernel(A, B, C, M, N, K, BLOCK_M: tl.constexpr, ...):
    # Triton 自动:
    # - 选择 tile 大小
    # - 管理 shared memory
    # - 处理边界条件
    # - 优化内存访问模式

# 你只需要指定 block 大小,编译器处理细节

L2 Cache 优化

不同 tile 处理顺序对 L2 cache 命中率影响巨大:

行优先处理: 每个 tile 需要加载新的 A 和 B 块
→ 9 个 A 块 + 81 个 B 块 = 90 次加载

分组处理(swizzled): 多个 tile 复用同一 A 块
→ 27 个 A 块 + 27 个 B 块 = 54 次加载

Triton 的 L2 swizzle 自动优化这个

Deep Dive · Blackwell、TMEM、clusters 与 occupancy

Spring 2026:Blackwell、TMEM 与 thread block clusters

官方 Lecture 6 将 A100、H100 与 B200 放在同一张硬件表里,并强调 kernel 优化需要跟随硬件数据路径变化,而不能只复用 2025 年的 Hopper 直觉。

变化它解决什么写 kernel 时的含义
B200 / sm100148 SM、192 GB HBM、约 8 TB/s HBM 带宽重新测 ridge point、tile 数和 wave quantization;不要照搬 H100 参数
TMEMBlackwell Tensor Core 专用的 tensor memory,位于 register/shared-memory 数据路径之间通常由编译器或库管理;它改变矩阵 kernel 的内部搬运与累加策略
Thread block clustersH100/B200 上多个 block 可协作并访问 distributed shared memory跨 block 复用成为可能,但同步、布局和资源占用更复杂

Occupancy 不是单一百分比

测量闭环:torch.cuda.synchronize() 保证计时边界正确;PyTorch Profiler 用于高层算子归因;Nsight Systems/Compute 用于观察 kernel 时间线、通信重叠、访存与 occupancy。优化前后必须在目标 GPU(尤其 B200)重测。


Reference · 从 PTX 检查编译器生成的访存与计算指令

PTX:GPU 的汇编语言

什么是 PTX?

PTX (Parallel Thread Execution) 是 NVIDIA GPU 的底层指令集——类似于 CPU 的汇编。

# Triton 生成的 PTX 示例(GeLU kernel):
ld.global.v4.f32  {%f0, %f1, %f2, %f3}, [%rd0];  # 读 4 个 float
fma.rn.f32        %f4, %f0, %f5, %f6;            # 融合乘加
ex2.approx.f32    %f7, %f8;                      # 近似 exp
st.global.v4.f32  [%rd1], {%f0, %f1, %f2, %f3};  # 写 4 个 float

从 PTX 读出的信息


本讲知识地图:5 种实现层次

方式示例性能易用性
手写 Pythonmanual_gelu最慢最易
PyTorch 原生F.gelu中-高最易
torch.compile@torch.compile高易
手写 CUDAcuda_gelu高难
手写 Tritontriton_gelu高中

核心原则:

趋势:自动编译器(Triton、torch.compile)在快速进步。手写 kernel 的价值在于:(1) 自定义操作;(2) 极端性能优化;(3) 理解底层。


知识检查

问题 1:benchmark GPU 代码时为什么需要 torch.cuda.synchronize()?

问题 2:Kernel fusion 为什么能加速 memory-bound 操作?

问题 3:Triton 的 fused softmax 为什么比 naive softmax 快?

问题 4:Triton 相比 CUDA 的主要优势是什么?

问题 5:矩阵乘法 tiling 的主要目的是什么?


Concept Check · fusion 改的是哪本账

u=x+1; z=relu(u) 融合后,数学 FLOPs 与 HBM traffic 分别怎样变化?

答案:FLOPs 基本不变;中间 \(u\) 不再写回/重读 HBM,launch 也少一次。

若答“FLOPs 减半”,说明你把执行图的数据移动与数学表达式混在了一起。

你现在应该能够解释

通往第 7 课

单卡 kernel 已经在优化片上与 HBM traffic;当单卡容量或速度不足,数据必须跨 GPU,下一课把“搬 bytes”扩展到 collective 与 topology。

如果只能记住 5 件事

  1. 优化从同步正确的 benchmark 和 profiler 时间线开始;没有基线就无法判断改动是否有效。
  2. Kernel fusion 的主要收益是消除中间张量的 HBM 写回与再次读取,而不是减少数学 FLOPs。
  3. Tiling 把可复用数据搬到 registers / shared memory,让一次 HBM load 服务更多计算。
  4. Triton 提供 block-level 编程模型,但 tile、layout、num warps 等选择仍需针对目标 shape 和 GPU 测量。
  5. Occupancy、coalescing、bank conflict 与 wave quantization 是解释结果的机制,不应脱离实际 kernel 单独追求某个数字。

延伸阅读

Reference · Triton、FlashAttention 与 profiling 材料