CS336 · 第 6 课
上一讲说明了 FLOPs 和 HBM bytes 为什么共同决定性能;这一讲进一步回答程序员能做什么:怎样测出真实瓶颈,并通过 fusion、tiling 和更合适的 kernel 编程层次减少数据搬运。
Spring 2026 官方来源:Lecture 6 trace · 课程日程
本页是中文学习导读;讲次主题、教师与材料版本以 Stanford 官方页面和讲义为准。
硬件规格只能给出上限,无法告诉我们当前 workload 卡在 launch overhead、HBM traffic、occupancy 还是计算单元。优化必须从可重复测量开始,否则 fusion 或更复杂的 kernel 可能只是增加代码量。
可靠 benchmark → profiler 定位瓶颈 → fusion 消除中间 HBM 往返 → tiling 建立片上复用 → Triton 表达 block-level 数据流 → PTX / hardware detail 用于解释剩余差距。
数学 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计算 \(z=\mathrm{relu}(x+1)\)。两个 kernels 会读 \(x\)、写中间 \(u\),再读 \(u\)、写 \(z\);fused kernel 每个元素加载一次 \(x\),在 register 做 add 与 relu,只写一次 \(z\)。数学 FLOPs 几乎不变,HBM traffic 与 launch 数减少。若算子中间结果必须跨设备或被别处消费,这个 fusion 就可能不成立。
过度 fusion 可能增加 registers、产生 spill、降低可并行 blocks,或让一个通用 kernel 失去专用 Tensor Core path。Triton 也不会自动消除错误的 tile、非 coalesced access 或不正确 mask;DSL 降低表达门槛,不替代性能模型。
Spec sheet 给上限,benchmark 给当前实现。
实际性能取决于 shape、dtype、库版本、硬件和系统负载。每次优化都应先定义基线,再在控制变量的条件下比较。
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 时间,不是执行时间。
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")
# 假设计算 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,然后立即读回——浪费!
Fused 版本: 一个 kernel 完成
读 A, B from HBM
计算 A @ B,直接在寄存器中做 GeLU
写 output to HBM
省掉了 tmp 的 HBM 读写!
仓库 vs 工厂类比:
不 fused:把零件从仓库搬到工厂,搬回仓库,再搬回工厂...
Fused:零件只搬一次,所有加工在工厂内完成。
GeLU 的 benchmark 结果(dim=16384, bf16):
manual_gelu (不 fused): ~2.5 ms ← 多次 HBM 读写
pytorch_gelu (fused): ~0.8 ms ← 一次 HBM 读写
Fused 版本快 3×!
# torch.compile 可以自动融合
@torch.compile
def fused_operation(x, w):
return gelu(x @ w) # 自动融合 matmul + gelu
# 但手动写 kernel 仍然有价值:
# 1. 更激进的融合(跨多个 matmul)
# 2. 自定义操作(FlashAttention)
# 3. 精确控制 shared memory
# 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
// 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);
}
}
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 是 OpenAI 2021 年发布的 GPU 编程语言——用 Python 语法写 GPU kernel。
| 任务 | CUDA | Triton |
|---|---|---|
| Memory coalescing | 手动 | 自动 |
| Shared memory 管理 | 手动 | 自动 |
| SM 内调度 | 手动 | 自动 |
| 跨 SM 调度 | 手动 | 手动 |
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
# 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× 差距!)
@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
矩阵乘法 C = A × B,朴素实现每个元素独立计算:
朴素 matmul: 每个 C[i,j] 独立读 A 的一行和 B 的一列
C 有 MN 个元素,每个需要读 K 个元素
总读取: 2 × M × N × K 次 HBM 访问
问题: A 的同一行被读了 N 次(每列一次)
B 的同一列被读了 M 次(每行一次)
关键思想: 分块加载到 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 的 matmul kernel 自动处理 tiling:
@triton.jit
def matmul_kernel(A, B, C, M, N, K, BLOCK_M: tl.constexpr, ...):
# Triton 自动:
# - 选择 tile 大小
# - 管理 shared memory
# - 处理边界条件
# - 优化内存访问模式
# 你只需要指定 block 大小,编译器处理细节
不同 tile 处理顺序对 L2 cache 命中率影响巨大:
行优先处理: 每个 tile 需要加载新的 A 和 B 块
→ 9 个 A 块 + 81 个 B 块 = 90 次加载
分组处理(swizzled): 多个 tile 复用同一 A 块
→ 27 个 A 块 + 27 个 B 块 = 54 次加载
Triton 的 L2 swizzle 自动优化这个
官方 Lecture 6 将 A100、H100 与 B200 放在同一张硬件表里,并强调 kernel 优化需要跟随硬件数据路径变化,而不能只复用 2025 年的 Hopper 直觉。
| 变化 | 它解决什么 | 写 kernel 时的含义 |
|---|---|---|
B200 / sm100 | 148 SM、192 GB HBM、约 8 TB/s HBM 带宽 | 重新测 ridge point、tile 数和 wave quantization;不要照搬 H100 参数 |
| TMEM | Blackwell Tensor Core 专用的 tensor memory,位于 register/shared-memory 数据路径之间 | 通常由编译器或库管理;它改变矩阵 kernel 的内部搬运与累加策略 |
| Thread block clusters | H100/B200 上多个 block 可协作并访问 distributed shared memory | 跨 block 复用成为可能,但同步、布局和资源占用更复杂 |
测量闭环:torch.cuda.synchronize() 保证计时边界正确;PyTorch Profiler 用于高层算子归因;Nsight Systems/Compute 用于观察 kernel 时间线、通信重叠、访存与 occupancy。优化前后必须在目标 GPU(尤其 B200)重测。
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
ld.global.v4:每个 thread 一次读 4 个 float(coalesced access)fma.rn.f32:融合乘加(FMA),GPU 最基本的计算指令ex2.approx:近似 exp2 指令(快但精度低)thread coarsening:一个 thread 处理 8 个元素(编译器优化)| 方式 | 示例 | 性能 | 易用性 |
|---|---|---|---|
| 手写 Python | manual_gelu | 最慢 | 最易 |
| PyTorch 原生 | F.gelu | 中-高 | 最易 |
| torch.compile | @torch.compile | 高 | 易 |
| 手写 CUDA | cuda_gelu | 高 | 难 |
| 手写 Triton | triton_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 的主要目的是什么?
u=x+1; z=relu(u) 融合后,数学 FLOPs 与 HBM traffic 分别怎样变化?
答案:FLOPs 基本不变;中间 \(u\) 不再写回/重读 HBM,launch 也少一次。
若答“FLOPs 减半”,说明你把执行图的数据移动与数学表达式混在了一起。
单卡 kernel 已经在优化片上与 HBM traffic;当单卡容量或速度不足,数据必须跨 GPU,下一课把“搬 bytes”扩展到 collective 与 topology。