斯坦福大学 CS336 Lecture 06 Kernel Optimization and Application of the Triton Framework
1.Review of GPUs
- 每个 Thread 独享一组 Register
- 每个 Block 占有一块 shared_memory ( shared_memory 位于 SM 内部,但即使一个 SM 上被分配了多个 Block,这些 Block 之间也不能共享 shared_memory,所以一个 SM 内的多个 Block 之间的通信也比较昂贵)
- Block 内部共享 shared_memory,通信代价低;跨 Block 代价高。
所有需要交互的数据都应尽量保持在同一个 Block 或计算单元内部,确保运算速度达到极致。
2. Benchmarking and Profiling
2.1 Benchmarking
做一次 warm_up;torch.cuda.synchronize() 保持 GPU 和 CPU 状态同步( Assignment 1 里写过)
2.1.1 sleep()
mean_time = benchmark("sleep", lambda: time.sleep(50/1000))
print("mean_time = ", mean_time)
结果为(RTX 3070):

2.1.2 矩阵乘法
def run_operation2(dim, operation):
a = torch.randn(dim, dim)
b = torch.randn(dim, dim)
def run():
return operation(a, b)
return run
if torch.cuda.is_available():
dims = (1024, 2048, 4096, 8192, 16384) # @inspect dims
else:
dims = (1024, 2048) # @inspect dims
matmul_results = []
for dim in dims:
# @ inspect dim
result = benchmark(f"matmul(dim={dim})", run_operation2(dim=dim, operation=lambda a, b: a @ b))
matmul_results.append((dim, result)) # @inspect matmul_results
for dim, time_ms in matmul_results:
print(f"{dim:<10} {time_ms:<15.4f}")
# :<10 ———— 左对齐,占用10个字符宽度
# :<15.4f ———— 左对齐,总宽度15格,保留4位小数
结果为:

发现了一个比较有意思的点:对于自己的 3070 小破卡,基本上时间差都是 8 倍:因为对于维度为 n 的方阵来说,矩阵乘法的复杂度为O(n^3)。所以维度为 2 倍,所需时间为 8 倍。但对于 H100 来说,前期不遵循 8 倍关系,只有最后 8192 → 16384 大致遵循。猜测是因为 H100 计算力太强,直到 8192 维,Roofline 还停留在左侧的内存带宽瓶颈,没有完全释放计算性能。
2.1.3 MLP
class MLP(nn.Module):
"""Simple MLP: linear -> GeLU -> linear -> GeLU -> ... -> linear -> GeLU"""
def __init__(self, dim: int, num_layers: int):
super().__init__()
self.layers = nn.ModuleList([nn.Linear(dim, dim) for _ in range(num_layers)])
def forward(self, x: torch.Tensor):
for layer in self.layers:
x = layer(x)
x = torch.nn.functional.gelu(x)
return x
def run_mlp(dim: int, num_layers: int, batch_size: int, num_steps: int) -> Callable:
# Callable 表示返回的是一个可调用的函数
# Define a model (with random weights)
model = MLP(dim, num_layers).to(get_device())
# Define an input (random)
x = torch.randn(batch_size, dim, device=get_device())
def run():
# Run the model `num_steps` times (note: no optimizer updates)
for step in range(num_steps):
# Forward
y = model(x).mean()
# Backward
y.backward()
return run
一个简单的 MLP 模型,甚至没计算损失函数,只是用了 model(x) 的平均值 .mean() 来计算梯度。
Test 1:

Test 2:在 Test 1 的基础上,引入新变量 scale,分别与 run_mlp 的四个参数相乘。发现运行时间与 num_layers 以及 num_steps 呈现线性相关。


2.2 Profiling
benchmark 过于粗粒度,只能表示代码所用时长。而 profiling 不仅能精确显示时间消耗在哪些函数,还能追溯到代码与 PyTorch 接口的交互,追踪从高层到底层的调用,看到底层实际执行的命令,可以更直观地理解程序如何在硬件上实际执行。
def profile(description: str, run: Callable, num_warmups: int = 1, with_stack: bool = False):
# Warmup
for _ in range(num_warmups):
run()
if torch.cuda.is_available():
torch.cuda.synchronize() # Wait for CUDA threads to finish (important!)
# Run the code with the profiler
with torch.profiler.profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
# 同时监测 CPU 端和 GPU 端的所有操作
with_stack=with_stack,
# with_stack 如果为 True,会记录每个操作的 Python 调用栈(用于后面生成堆栈跟踪可视化)
experimental_config=torch._C._profiler._ExperimentalConfig(verbose=True)
# 启用更详细的实验性配置,让 profiler 输出更多底层信息
) as prof:
run()
if torch.cuda.is_available():
torch.cuda.synchronize() # Wait for CUDA threads to finish (important!)
# Print out table
table = prof.key_averages().table(sort_by="cuda_time_total",
max_name_column_width=80,
row_limit=10)
# 按 CUDA 总耗时从高到低排序(cuda_time_total 是该操作在所有调用中花费的 GPU 总时间)
# max_name_column_width=80:限制操作名称列的最大宽度,避免过长。
# row_limit=10:只显示耗时最长的前 10 个操作
# Write stack trace visualization
# 用于生成火焰图
if with_stack:
text_path = f"var/stacks_{description}.txt"
svg_path = f"var/stacks_{description}.svg"
prof.export_stacks(text_path, "self_cuda_time_total")
return table
2.2.1 sleep()
sleep_function = lambda: time.sleep(50 / 1000)
sleep_profile = profile("sleep", sleep_function)
print(sleep_profile)

2.2.2 矩阵加法
def run_operation2(dim: int, operation: Callable) -> Callable:
# Setup: create two random dim x dim matrices
x = torch.randn(dim, dim, device=device)
y = torch.randn(dim, dim, device=device)
# Return a function to perform the operation
return lambda: operation(x, y)
add_function = lambda a, b: a + b
add_profile = profile("add", run_operation2(dim=2048, operation=add_function))
print(add_profile)

表头中 Self 指不包括其子调用,Total 指包括子调用
①. aten::add 是 Pytorch 底层 C++ 核心库中的加法操作函数。(aten:A Tensor ENgine)

②. Unrecognized
③. 第三项一大串:被 C++名称修饰(Name Mangling)后的符号名,还原后为:at::native::vectorized_elementwise_kernel<4, at::native::CUDAFunctor_add<float, float>, ...>。 这是一个 CUDA 向量化逐元素内核,负责 float 型加法操作。 外函数 vertorized_elementwise_kernel 进行逐元素向量化,内部第一个参数 4 代表向量化宽度,即每个 CUDA 线程一次处理 4 个元素;第二个参数 CUDAFunctor_add 指明执行的操作为加法。这是执行加法操作的核心部分。
④. cudaLaunchKernel:CPU 接受指令并发送给 GPU 的过程。
注意到这四项的 Self CPU 时间和恰好等于 aten::add 的 CPU total,说明它们为 aten::add 的子函数
⑤. cudaDeviceSynchronize:等待 GPU 完成计算并传回数据
2.2.3矩阵乘法
matmul_function = lambda a, b: a @ b
matmul_profile = profile("matmul", run_operation2(dim=2048, operation=matmul_function))
print(matmul_profile)

分析方法差不多,前六项的 Self CPU 之和为 aten::matmul 的 CPU total。
①. aten:matmul:矩阵乘法入口,只负责调度,实际运算位于子函数;
②. aten:mm:二维矩阵乘法底层函数
③. Unrecognized
④. ampere_sgemm_128x64_nn:NVDIA的线性代数库 cuBLAS 根据矩阵大小以及 GPU 硬件选出的高性能计算方案

⑤. cudaOccupancyMaxActiveBlocksPerMultiprocessor:计算给定内核函数在 GPU 的一个 SM 上最多能同时有多少个线程块
⑥. cudaLaunchKernel:同上
⑦. cudaDeviceSynchronize:同上
将矩阵维度从 2048改为 128,结果如下:

可以观察到第四项执行了不同的指令,调用了不同的计算内核。在高抽象层,矩阵乘法被视为一个整体操作。但在底层实现时,根据矩阵维度以及硬件配置的差异,系统实际调用的矩阵乘法运算内核可能完全不同,这会导致相当大的性能差异。
Torch Compile 工具:内置了一个能够对硬件上的矩阵乘法性能进行微基准测试(micro benchmark),然后为模型选择性能最高的矩阵乘法子程序(subroutines)——得到 10% 左右的效率优化。(具体见下 3.4)
2.2.4 torch.cdist()
无论是加法还是乘法, CPU 和 GPU 之间为一对一的关系:一个 CPU 操作对应一个 GPU 操作。
torch.cdist 计算的是两组矩阵之间的欧式距离,即两组词向量的逐对距离度量。
cdist_function = lambda a, b: torch.cdist(a,b)
cdist_profile = profile("matmul", run_operation2(dim=2048, operation=cdist_function))
print(cdist_profile)

2.2.5 gelu()
gelu_function = lambda a, b: torch.nn.functional.gelu(a+b)
gelu_profile = profile("matmul", run_operation2(dim=2048, operation=gelu_function))
print(gelu_profile)

2.2.6 softmax()
softmax_function = lambda a, b: torch.nn.functional.softmax(a+b, dim=-1)
softmax_profile = profile("matmul", run_operation2(dim=2048, operation=softmax_function))
print(softmax_profile)

2.2.7 MLP
if torch.cuda.is_available():
mlp_profile = profile("mlp", run_mlp(dim=2048, num_layers=64, batch_size=1024, num_steps=2), with_stack=True)
else:
mlp_profile = profile("mlp", run_mlp(dim=128, num_layers=16, batch_size=128, num_steps=2), with_stack=True)
print(mlp_profile)
2.3 NVIDIA —— Nsight System
粗略看了一下这东西不简单,工程实践上的东西,先略过
没自己用过听老哥讲根本听不懂
在代码运行过程中, CPU 进度要远远快于 GPU 。例如在迭代过程中打印损失值 loss,会影响 CPU 与 GPU 的运行状态。由于打印操作是发生在 CPU 上的,所以 CPU 必须等待 GPU 计算成损失结果,才能继续往下进行。故这种情况下,两者进度同步, CPU 有大量的空转时间。
3. CUDA Kernels
3.1 pytorch & manual(gelu)
Pytorch 内部的 GeLu 实现方式如下:
def pytorch_gelu(x:torch.Tensor):
return torch.nn.functional.gelu(x,approximate="tanh")
x = torch.tensor([1.])
y1 = pytorch_gelu(x)
利用 tanh 来近似计算 GeLu 以加快计算速度,没有使用 GeLu 的精确定义用标准高斯分布的累计分布函数 CDF 来计算。
原始方法计算:
def manual_gelu(x: torch.Tensor):
return 0.5 * x * (1 + torch.tanh(0.79788456 * (x + 0.044715 * x * x * x)))
y2 = manual_gelu(x)
计算两者结果,并分别进行 benchmark 以及 profiling 。
def run_operation1(dim:int,operation:Callable)->Callable:
x = torch.randn(dim, dim, device=device)
return lambda:operation(x)
# benchmark
pytorch_time = benchmark("pytorch_gelu", run_operation1(dim=16384, operation=pytorch_gelu))
manual_time = benchmark("manual_gelu",run_operation1(dim=16384, operation=manual_gelu))
# profiling
pytorch_table = profile("pytorch_gelu",run_operation1(dim=16384, operation=pytorch_gelu))
manual_table = profile("manual_gelu",run_operation1(dim=16384, operation=manual_gelu))

pytorch_table:

manual_table:

manual_gelu 执行了大量运算,触发了多个 CUDA kernel(没有实现 Computation Fesion,数据被搬运太多次)。 而 pytorch_gelu 只采用了一个 kernel 就完成了计算,所以出现了 8 倍的 benchmark 时间差异。
3.2 Write a Kernel (gelu)
CUDA is an extension of C/C++ with APIs for managing GPUs and a programming model for expressing parallelism.
语言层面: C/C++ 扩展
语言层面:提供操作 GPU 的 API
编程模型:实现并行
编写 CUDA kernel 函数并启动它时,它会自动在 GPU 的数千个线程上并行执行,对向量或矩阵的所有元素同时进行计算。在 CUDA 的编程模型中,Grid 是顶层容器,包含若干个 Block;每个 Block 包含若干个 Thread。例如在二维 Grid 中,每个 Block 通过 (blockIdx.x, blockIdx.y) 来定位;二维 Block 中,每个 Thread 通过 (threadIdx.x, threadIdx.y) 定位。(这些参数不是通过参数传给 Kernel 的,Kernel 可以直接访问 CUDA 提供的内置变量 threadIdx、blockIdx、blockDim、gridDim,通过这些变量,每个线程可以计算出自己在全局数据中的唯一索引,从而处理对应的数据元素)
调试 CUDA 时,需要将环境变量设置为 os.environ["CUDA_LAUNCH_BLOCKING"] = "1"
只有这样才能正确调试 CUDA,不过这牺牲了运行时性能,系统能够返回详细的错误信息。
#include <math.h>
#include <torch/extension.h>
#include <c10/cuda/CUDAException.h>
__global__ void gelu_kernel(float* in, float* out, int num_elements) {
// Get the index into the tensor
int i = blockIdx.x * blockDim.x + threadIdx.x;
// 算出当前线程要处理张量中的哪个位置
if (i < num_elements) { // 防止超出张量范围(因为总线程数 ≥ 元素数)
// Do the actual computation
out[i] = 0.5 * in[i] * (1.0 + tanh(0.79788456 * (in[i] + 0.044715 * in[i] * in[i] * in[i])));
}
}
// __global__ 不能有返回值,用 out 指针写回结果
inline unsigned int cdiv(unsigned int a, unsigned int b) {
// inline: 建议编译器消除函数调用开销,直接展开代码
// Compute ceil(a / b)
return (a + b - 1) / b; // -1 防止原本就能整除 (a = 9, b = 3)
}
// Host函数:发生在CPU侧
torch::Tensor gelu(torch::Tensor x) {
// 检查张量在 GPU 上且连续
TORCH_CHECK(x.device().is_cuda());
TORCH_CHECK(x.is_contiguous());
// 在 GPU 上分配一块和 x 同样大小(形状)的内存,但不初始化
torch::Tensor y = torch::empty_like(x);
// Determine grid (elements divided into blocks)
int num_elements = x.numel(); // 获取元素总数 例如 [3, 4] 的张量 → num_elements = 12
int block_size = 1024; // Number of threads in a block
int num_blocks = cdiv(num_elements, block_size); //用向上取整除法算出需要启动多少个 Block 才能覆盖所有元素
// Launch the kernel
gelu_kernel<<<num_blocks, block_size>>>(x.data_ptr<float>(), y.data_ptr<float>(), num_elements);
// 在 GPU 上启动 num_blocks 个 Block,每个 Block 有 1024 个线程,总共 num_blocks × 1024 个线程并行执行
// 由于 gelu_kernel 由核心修饰符 __global__ 修饰,说明 gelu_kernel 由 CPU 调用, GPU 个线程并行执行
// gelu_kernel 需要 <<<>>> 传参
// 所有被 __global__ 修饰的核函数都需要传这两个参数
C10_CUDA_KERNEL_LAUNCH_CHECK();
//检查内核启动是否成功。如果内核里有 bug ,这里会立即捕获并报错
return y;
}

(搞了一整天没编译成功 .cu 和 .cpp 文件,先搁置吧。心累,这种实践上的东西 AI 也是张口就来,解决不了)贴一个完整代码在下面,benchmark 和 profiling 也只有略过。
import torch
import os
from torch.utils.cpp_extension import load_inline
# 1. 确保目录存在
def ensure_directory_exists(path):
os.makedirs(path, exist_ok=True)
# 2. CUDA 源代码(核函数实现)
cuda_gelu_src = """
#include <math.h>
__global__ void gelu_kernel(float* in, float* out, int num_elements) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < num_elements) {
float x = in[i];
out[i] = 0.5f * x * (1.0f + tanhf(0.79788456f * (x + 0.044715f * x * x * x)));
}
}
// 这个函数被 C++ 封装代码调用
void launch_gelu_kernel(float* in, float* out, int num_elements) {
int block_size = 1024;
int num_blocks = (num_elements + block_size - 1) / block_size;
gelu_kernel<<<num_blocks, block_size>>>(in, out, num_elements);
}
"""
# 3. C++ 封装代码(PyTorch 绑定)
cpp_gelu_src = """
#include <torch/extension.h>
#include <c10/cuda/CUDAException.h>
// 声明 CUDA 函数(在 cuda_sources 中实现)
void launch_gelu_kernel(float* in, float* out, int num_elements);
// 被 Python 调用的函数
torch::Tensor gelu(torch::Tensor x) {
TORCH_CHECK(x.is_cuda(), "x must be on GPU");
TORCH_CHECK(x.dtype() == torch::kFloat32, "x must be float32");
auto y = torch::empty_like(x);
int num_elements = x.numel();
launch_gelu_kernel(x.data_ptr<float>(), y.data_ptr<float>(), num_elements);
C10_CUDA_KERNEL_LAUNCH_CHECK();
return y;
}
"""
# 4. 安全检查
if not torch.cuda.is_available():
print("CUDA 不可用,跳过编译")
exit(1)
# 5. 确保编译目录存在
ensure_directory_exists("var/cuda_gelu")
# 6. 使用 load_inline 编译
try:
module = load_inline(
name="inline_gelu",
cuda_sources=[cuda_gelu_src],
cpp_sources=[cpp_gelu_src],
functions=["gelu"], # 暴露给 Python 的函数名
extra_cflags=["-O2"],
extra_cuda_cflags=["-O2"],
verbose=True,
build_directory="var/cuda_gelu",
)
print("✅ 编译成功!")
# 7. 测试
x = torch.randn(10, device='cuda', dtype=torch.float32)
y = module.gelu(x) # 调用编译好的函数
print(f"输入: {x}")
print(f"输出: {y}")
except Exception as e:
print(f"❌ 编译失败: {e}")
1. pip install ninja;
2.解决报错:subprocess.CalledProcessError: Command '['where', 'cl']' returned non-zero exit status 1 Pytorch 找不到 Visual Studio 的 C++ 编译器 cl.exe 解决方法链接
3.解决报错:subprocess.CalledProcessError: Command '['ninja', '-v']' returned non-zero exit status 2. 将 torch.utils.pp_extension 中的
command = ['ninja', '-v'] -----> command = ['ninja', '--version']
4.:新问题:搞不定了,摆烂了

文件也成功生成了
3.3 Triton Kernels (gelu)
Triton 的优点:可以用纯 Python 写 GPU Kernel,无需关心线程管理,只需专注于线程块的设计;能自动处理许多底层细节,比如自动调整内存访问模式。代码是以 Tile 为中心写的,编译器负责将这些 Tile 调度到 SM 上。跨 SM 的数据共享需要手动处理,跨 SM 的并行 Triton 能够自动实现。
首先需要安装 triton(由于自己是 Windows 环境,需要进行一些设置)
import torch
import triton
import triton.language as tl
# 原本用 C++ 实现的 CPU 侧的 Host 函数
# torch::Tensor gelu(torch::Tensor x)
def triton_gelu(x: torch.Tensor):
assert x.is_cuda
assert x.is_contiguous()
# Allocate output tensor
y = torch.empty_like(x)
# Determine grid (elements divided into blocks)
num_elements = x.numel()
block_size = 1024 # Number of threads
num_blocks = triton.cdiv(num_elements, block_size)
# 这一步的实现与 CUDA 稍有区别
# gelu_kernel<<<num_blocks, block_size>>>(x.data_ptr<float>(), y.data_ptr<float>(), num_elements);
triton_gelu_kernel[(num_blocks,)](x, y, num_elements, BLOCK_SIZE=block_size)
# Triton 要求 grid 参数是一个元组,用来支持多维网格
# (M,) 一维:启动 M 个 block,索引为 0, 1, 2, ..., M-1
# (M,N) 二维:启动 M*N 个 block,索引为 (0,0), (0,1), ..., (M-1, N-1)
# (M,N,K) 三维
return y
@triton.jit
def triton_gelu_kernel(x_ptr, y_ptr, num_elements, BLOCK_SIZE: tl.constexpr):
# BLOCK_SIZE: tl.constexpr:编译时常量(在编译时确定,不能运行时改变)
pid = tl.program_id(axis=0) # 可以理解成 blockId
block_start = pid * BLOCK_SIZE
offsets = block_start + tl.arange(0, BLOCK_SIZE)
# 计算索引
# CUDA 需要计算出每个 thread 的索引
# Triton kernel 计算的offsets 是一个向量,长度为 block_size
# Handle boundary
mask = offsets < num_elements
# Read
x = tl.load(x_ptr + offsets, mask=mask)
# Approx gelu is 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))
# Compute (tl.tanh doesn't exist, use tanh(a) = (exp(2a) - 1) / (exp(2a) + 1)
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)
# Store
tl.store(y_ptr + offsets, y, mask=mask)
# 类似于 SIMT,许多 threads 同时拿到这个命令,然后计算不同的数据
Triton 编译后得到 PTX,再经过一步变成 GPU 最终执行的机器码。
代码还是跑不通,没招了
3.4 Torch Compile(gelu)
能将未优化的 PyTorch 代码自动转换为更高效的版本,它自动尝试进行内核融合等优化操作。
compiled_gelu = torch.compile(manual_gelu)
compiled_time = benchmark("compiled_gelu", run_operation1(dim=16384, operation=compiled_gelu))
compiled_table = profile("compiled_gelu", run_operation1(dim=16384, operation=compiled_gelu))
(还是跑不了,这章听得我难受,配环境变量搞了一整天,啥也没成,还不知道原因)
3.5 Triton Kernel (softmax)
def triton_softmax(x: torch.Tensor):
# Allocate output tensor
y = torch.empty_like(x)
# Determine grid
M, N = x.shape # Number of rows x number of columns
block_size = triton.next_power_of_2(N) # 将 N 向上取整到 2 的幂
num_blocks = M # Each block is a row 每行独立计算 Softmax,一行对应一个 block
# Launch kernel
triton_softmax_kernel[(M,)](
x_ptr=x, y_ptr=y,
x_row_stride=x.stride(0), y_row_stride=y.stride(0),
num_cols=N, BLOCK_SIZE=block_size
)
# 传递了行跨度 (stride) 以便在内存中正确跳转到不同行
return y
@triton.jit
def triton_softmax_kernel(x_ptr, y_ptr, x_row_stride, y_row_stride, num_cols, BLOCK_SIZE: tl.constexpr):
assert num_cols <= BLOCK_SIZE
# Process each row independently
row_idx = tl.program_id(0)
col_offsets = tl.arange(0, BLOCK_SIZE)
# Read from global memory
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"))
# other=float("-inf"):被 mask 掉的元素用负无穷填充,这样在后续 max 操作中不会影响结果
# Compute
x_row = x_row - tl.max(x_row, axis=0)
numerator = tl.exp(x_row)
denominator = tl.sum(numerator, axis=0)
y_row = numerator / denominator
# Block 内部会有一个比较 max 以及求和的操作
# 这两步 triton 会自行处理
# 虽然单个线程看起来需要等待其他线程的结果,但 Triton 在 Block 层面实现了同步
# Write back to global memory
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)
更多推荐


所有评论(0)