【infra之路】CUDA Kernel 性能分析实战:用 Nsight Compute 看透 GPU 优化本质

本文是 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 倍。差距来自:

  1. Tensor Core (最大因素):普通 CUDA Core 一条指令做 1 次 FP32 FMA,Tensor Core 一条指令做 4×4 矩阵乘加(等效 64 次 FMA),计算密度高 16 倍。cuBLAS 自动调用 Tensor Core(内部用 TF32 精度)。

  2. 汇编级调优:cuBLAS 的 kernel 是 NVIDIA 针对每个 GPU 架构手写的 PTX/SASS 汇编,指令调度、寄存器分配、warp 编排都经过极致优化。

  3. 更激进的 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,按这个顺序检查和优化:

  1. Coalesced AccessthreadIdx.x 相邻的线程是否访问相邻内存地址?
  2. Shared Memory 复用:Block 内是否有数据被多个线程重复读取?如果有,搬到 Shared Memory。
  3. Bank Conflict :Shared Memory 是否存在列访问模式?用 padding [TILE][TILE+1] 解决。
  4. Register Tiling:每线程是否可以多算几个元素,把数据从 Shared Memory 搬到寄存器?
  5. Occupancy :每线程寄存器数是否过高导致 occupancy 下降?用 __launch_bounds__ 调节。
  6. 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_naive

cuBLAS 编译:nvcc -O3 -o matmul_cublas 05_matmul_cublas.cu -lcublas