本文是 AI Infra 学习系列的第四篇,聚焦 CUDA kernel 性能分析。我们从零实现了三个矩阵乘法版本(Naive → Shared Memory → Register Tiled),在 RTX 5060 Ti 上实测性能差异,并用 Nsight Compute 分析每个版本的瓶颈根因。最终与 cuBLAS 对比,理解工业级优化的天花板在哪。
🔍 写在前面:为什么需要 Profiler?
前三篇文章中,我们实现了三个矩阵乘法 kernel,观察到耗时差异很大。但"快了多少"只是表象------性能优化的核心问题是:为什么快?瓶颈在哪?还能不能更快?
这就需要一个 GPU profiler。NVIDIA Nsight Compute(简称 ncu)是 CUDA kernel 级别的性能分析器,能告诉你每个 kernel 的计算利用率、内存带宽利用率、warp 调度情况。
Nsight Compute vs Nsight Systems
| 工具 | 看什么 | 类比 |
|---|---|---|
| Nsight Compute (ncu) | 单个 kernel 内部的微观性能 | 显微镜:看细胞 |
| Nsight Systems (nsys) | 整个程序的时间线:kernel launch、内存拷贝、CPU 活动 | 望远镜:看全局 |
本文聚焦 ncu,因为它直接回答"这个 kernel 为什么慢"。
📐 实验环境
| 项目 | 配置 |
|---|---|
| GPU | NVIDIA RTX 5060 Ti 8GB(Blackwell 架构) |
| CUDA | 12.8 |
| 系统 | WSL2 Ubuntu |
| 矩阵规模 | 2048 × 2048,FP32 |
| 计算量 | 2N³ = 17.18 GFLOPS |
🛠️ 五个核心指标:你只需要先看这几个
Nsight Compute 能采集数百个指标,但入门只需关注 5 个。
1. SM Throughput(计算利用率)
sm__throughput.avg.pct_of_peak_sustained_elapsed
SM 的计算单元有多忙。值越高说明 GPU 计算核心越饱和。高(>60%)说明 kernel 是 compute-bound,低(<30%)说明可能在等内存数据。
2. Memory Throughput(显存带宽利用率)
dram__throughput.avg.pct_of_peak_sustained_elapsed
Global Memory 的带宽用了多少。高(>60%)说明 kernel 是 memory-bound,低(<30%)说明数据大多命中了 cache。
3. Occupancy(占用率)
sm__warps_active.avg.pct_of_peak_sustained_active
SM 上实际活跃 warp 占理论最大值的百分比。受三个因素限制:每线程寄存器数、每 Block 的 Shared Memory 用量、每 Block 线程数。
4. Warp Stall Reasons(停顿原因)
warp 为什么停下来不计算?常见原因:
| 停顿原因 | 含义 | 优化方向 |
|---|---|---|
| Long Scoreboard | 在等 Global/Local Memory | 增加 occupancy、减少 Global 访问 |
| LG Throttle | Load/Store 单元饱和 | 合并访问、减少 Global 读写 |
| MIO Throttle | Shared Memory / Constant Memory 拥堵 | 解决 bank conflict |
| No Instructions | 没有 ready warp | 增加 occupancy |
5. Roofline 模型(计算强度)
每搬运 1 字节数据做了多少次浮点运算(FLOPS/Byte)。这个值决定了 kernel 是 compute-bound 还是 memory-bound:
┌─ compute-bound 区域
│
┌────────────┼──────────────┐
│ memory- │ compute- │
│ bound │ bound │
└────────────┼──────────────┘
│
Roofline 拐点 = Peak FLOPS / Peak Bandwidth
对于 RTX 5060 Ti(FP32 ~23 TFLOPS,带宽 ~448 GB/s),Roofline 拐点约 51 FLOPS/Byte。低于 51 → memory-bound;高于 51 → compute-bound。
🏆 三个版本的实现回顾
v1: Naive
每个线程计算 C 的一个元素,独立从 Global Memory 读 A 的一行和 B 的一列:
cuda
__global__ void matMulNaive(const float *A, const float *B, float *C, int width) {
int row = threadIdx.y + blockIdx.y * blockDim.y;
int col = threadIdx.x + blockIdx.x * blockDim.x;
if (row < width && col < width) {
float sum = 0.0f;
for (int k = 0; k < width; k++) {
sum += A[row * width + k] * B[k * width + col];
}
C[row * width + col] = sum;
}
}
问题:A 的第 i 行被 N 个线程各读一次,Global Memory 读取次数 = 2N³。
v2: Shared Memory Tiled
把矩阵沿 K 维度分块,Block 协作加载 tile 到 Shared Memory:
cuda
__global__ void matMulShared(const float *A, const float *B, float *C, int width) {
__shared__ float sA[TILE_SIZE][TILE_SIZE];
__shared__ float sB[TILE_SIZE][TILE_SIZE];
int row = threadIdx.y + blockIdx.y * TILE_SIZE;
int col = threadIdx.x + blockIdx.x * TILE_SIZE;
float sum = 0.0f;
for (int t = 0; t < width / TILE_SIZE; t++) {
// 协作加载 tile
sA[threadIdx.y][threadIdx.x] = A[row * width + t * TILE_SIZE + threadIdx.x];
sB[threadIdx.y][threadIdx.x] = B[(t * TILE_SIZE + threadIdx.y) * width + col];
__syncthreads();
for (int k = 0; k < TILE_SIZE; k++)
sum += sA[threadIdx.y][k] * sB[k][threadIdx.x];
__syncthreads();
}
C[row * width + col] = sum;
}
收益:Global Memory 读取次数从 2N³ 降到 2N³/TILE_SIZE(TILE=32 时减少 32 倍)。
v3: Register Tiled
每个线程不只算 1 个元素,而是算 4×4 = 16 个元素,把数据缓存到寄存器:
cuda
for (int k = 0; k < BK; k++) {
// 从 Shared Memory 加载到寄存器(只需 4+4=8 次 Shared Memory 读取)
float regA[TM], regB[TN];
for (int m = 0; m < TM; m++) regA[m] = sA[threadRow + m][k];
for (int n = 0; n < TN; n++) regB[n] = sB[k][threadCol + n];
// 纯寄存器运算:4×4 = 16 次乘加,零内存访问
for (int m = 0; m < TM; m++)
for (int n = 0; n < TN; n++)
threadResults[m][n] += regA[m] * regB[n];
}
收益:Shared Memory 读取量从"每元素 2 次"降到"每元素 0.5 次"(减少 4 倍)。
📊 实测结果
性能对比
| 版本 | 耗时 | GFLOPS | 相对 Naive | 相对 cuBLAS |
|---|---|---|---|---|
| Naive | 12.091 ms | 1,421 | 1x | 9% |
| Shared Memory | 9.076 ms | 1,893 | 1.3x | 12% |
| Register Tiled | 2.183 ms | 7,872 | 5.5x | 51% |
| cuBLAS | 1.107 ms | 15,521 | 10.9x | 100% |
性能可视化
GFLOPS
15521 ┤ ████████████████████████████████████████ cuBLAS (Tensor Core)
7872 ┤ ████████████████████ Register Tiled
1893 ┤ █████ Shared Memory
1421 ┤ ████ Naive
└──────────────────────────────────────────
💡 深度分析
为什么 Shared Memory 只快了 1.3x?
理论上应该快 3-5x,但实测只有 1.33x。原因是 RTX 5060 Ti 的 L2 Cache 有 32-48 MB,而我们的矩阵 A+B ≈ 32 MB,整个都能放进 L2 Cache。Naive 版本虽然直接从 Global Memory 读,但第二次读同一地址时命中了 L2(~200 cycles),没有真的去 HBM(~300+ cycles)。所以 Naive 的实际性能比理论值好很多,削弱了 Shared Memory 的优势。
此外,Shared Memory 版用了 32×32 的 tile,存在 bank conflict(我们没有做 padding 优化),也抵消了一部分收益。
为什么 Register Tiled 能快 5.5x?
这是真正的质变。每线程算 16 个元素,数据缓存在寄存器里(~1 cycle 读取),计算密度远高于前两个版本。而且 256 线程的 Block 让 SM 可以同时驻留更多 Block,occupancy 调度更灵活。
Register Tiled 达到了 7,872 GFLOPS,占 RTX 5060 Ti FP32 理论峰值(~23 TFLOPS)的 34%。
cuBLAS 为什么还能再快 2x?
cuBLAS 达到 15,521 GFLOPS,是 Register Tiled 的 2 倍。差距来自:
-
Tensor Core (最大因素):普通 CUDA Core 一条指令做 1 次 FP32 FMA,Tensor Core 一条指令做 4×4 矩阵乘加(等效 64 次 FMA),计算密度高 16 倍。cuBLAS 自动调用 Tensor Core(内部用 TF32 精度)。
-
汇编级调优:cuBLAS 的 kernel 是 NVIDIA 针对每个 GPU 架构手写的 PTX/SASS 汇编,指令调度、寄存器分配、warp 编排都经过极致优化。
-
更激进的 tiling 参数 :cuBLAS 会根据矩阵大小和 GPU 型号自动选择最优的 BM/BN/BK/TM/TN 组合,还会用 double buffering +
cp.async异步拷贝。
瓶颈转移全景
Naive Shared Memory Register Tiled cuBLAS
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
瓶颈位置 │Memory│ → │混合 │ → │Compute│ → │极限 │
└──────┘ └──────┘ └──────┘ └──────┘
数据复用 无 Block 内共享 线程内寄存器 Tensor Core
每元素读取 Global ×2N Shared ×64 Register ×8 TC 内部
优化过程就是不断把瓶颈从慢的组件推向快的组件:从 Global Memory → Shared Memory → Register → Tensor Core。
⚠️ WSL2 上使用 ncu 的踩坑记录
问题 1:sudo 下找不到 ncu
bash
$ which ncu
/usr/local/cuda/bin/ncu
$ sudo ncu --print-summary per-kernel ./matmul_naive
sudo: ncu: command not found
原因:sudo 会重置 PATH 到安全默认值,不包含 .bashrc 里加的路径。
解决:
bash
# 方法 1:用绝对路径
sudo /usr/local/cuda/bin/ncu --print-summary per-kernel ./matmul_naive
# 方法 2:让 sudo 保留当前 PATH
sudo env PATH=$PATH ncu --print-summary per-kernel ./matmul_naive
问题 2:LibraryNotLoaded 错误
==ERROR== Failed to initialize the profiler: LibraryNotLoaded
原因:WSL2 的 GPU 驱动是 Windows 侧通过 /usr/lib/wsl/lib/ 映射过来的,profiler 库加载有兼容性问题。
尝试修复:
bash
sudo LD_LIBRARY_PATH=/usr/lib/wsl/lib /usr/local/cuda/bin/ncu \
--target-processes all --print-summary per-kernel ./matmul_naive
如果仍然失败,说明当前 WSL2 驱动版本的 ncu 兼容性有问题,可以用程序内 cudaEvent 计时替代(本文的实验数据就是这样获取的)。
🏆 优化 Checklist
当你写完一个 CUDA kernel,按这个顺序检查和优化:
- Coalesced Access :
threadIdx.x相邻的线程是否访问相邻内存地址? - Shared Memory 复用:Block 内是否有数据被多个线程重复读取?如果有,搬到 Shared Memory。
- Bank Conflict :Shared Memory 是否存在列访问模式?用 padding
[TILE][TILE+1]解决。 - Register Tiling:每线程是否可以多算几个元素,把数据从 Shared Memory 搬到寄存器?
- Occupancy :每线程寄存器数是否过高导致 occupancy 下降?用
__launch_bounds__调节。 - Tensor Core:矩阵运算是否可以用 WMMA API 或 cuBLAS 调用 Tensor Core?
📝 总结
| 你会了什么 | 具体内容 |
|---|---|
| 用 ncu 采集 kernel 指标 | SM/Memory throughput、Occupancy、Warp stall |
| 判断 kernel 瓶颈类型 | memory-bound vs compute-bound |
| 从 stall reason 推断优化方向 | Long Scoreboard → 优化内存;MIO Throttle → 优化 Shared Memory |
| Roofline 模型 | 计算强度决定瓶颈类型 |
| 三个版本的性能差异根因 | Naive 内存瓶颈 → Shared 混合 → Register 计算瓶颈 |
GPU 优化的核心范式:把数据从慢的内存搬到快的内存,最大化每一层的复用。这个范式从 CUDA kernel 到 FlashAttention、到 vLLM 的 PagedAttention、到分布式训练的 AllReduce,底层思想都是一致的。
环境:RTX 5060 Ti 8GB + CUDA 12.8 + WSL2 Ubuntu
编译命令:
nvcc -O3 -o matmul_naive 02_matmul_naive.cu && ./matmul_naivecuBLAS 编译:
nvcc -O3 -o matmul_cublas 05_matmul_cublas.cu -lcublas