2、计算瓶颈剖析:GPU利用率低的原因分析、算子瓶颈识别、CUDA Kernel优化基础

好,咱们直接切入正题。很多同学跑模型,一看GPU利用率才30%,心里就发毛。我刚开始做训练优化那会儿,也经常对着nvidia-smi发呆——明明显存占满了,算力就是上不去。这到底是谁在拖后腿?

说白了,GPU利用率低,通常就三个原因:数据加载太慢、算子效率太低、Kernel启动开销太大。咱们一个一个拆。

2.1 GPU利用率低:三大典型原因

先看数据加载。你想想看,GPU算得飞快,但CPU来不及喂数据,那GPU就只能干等。这种现象叫数据饥饿。我在项目中遇到过,用PyTorch的DataLoader默认设置,单进程加载图片,GPU利用率直接掉到20%。后来加了num_workers=8,配合prefetch_factor=2,才把利用率拉回90%以上。

核心指标:GPU利用率低于80%,优先排查数据加载管线。

再看算子效率。有些算子天生就慢,比如逐元素操作(element-wise)。你写个y = x + 1,看起来简单,但每个元素都要启动一个Kernel。我见过一个项目,把LayerNorm里的逐元素操作合并成融合Kernel,速度直接快了3倍。

最后是Kernel启动开销。每次调用CUDA Kernel,都有固定的启动延迟。如果你的模型里全是小算子,那大部分时间都花在启动上了,而不是真正在算。嗯,这里要注意:小算子多,不如大算子少。

瓶颈类型 典型表现 排查工具
数据加载 GPU利用率波动大,显存占用高但算力低 nsys profile、DataLoader耗时统计
算子效率 某个算子耗时占比异常高 PyTorch Profiler、NVIDIA Nsight
Kernel启动 小算子密集,GPU空闲时间片多 nsys timeline、Kernel launch分析

2.2 算子瓶颈识别:从Profiler到火焰图

怎么找到瓶颈算子?我个人习惯先用PyTorch Profiler跑一遍。它会告诉你每个算子的耗时、调用次数、CUDA Kernel名称。我建议重点关注两个指标:CUDA time占比和Kernel launch次数。

举个例子,你看到某个算子耗时占了40%,但它的计算量其实很小。那说明什么?说明这个算子可能没有充分利用GPU的并行能力。我曾经排查过一个模型,发现一个reshape操作耗时异常高。后来发现,reshape触发了内存拷贝,而不是简单的视图变换。改成view之后,耗时直接降了80%。

小技巧:用torch.cuda.profiler.profile()配合chrome://tracing查看火焰图。火焰图越宽的函数,越值得优化。

还有一个工具叫NVIDIA Nsight Systems,它能展示GPU上的Kernel执行时间线。你一眼就能看出哪些Kernel是串行的,哪些是并行的。我习惯先看GPU busy rate,如果低于80%,说明有大量空闲时间。

2.3 CUDA Kernel优化基础:从理论到实践

好了,找到瓶颈算子之后,怎么优化?这里我讲三个最基础、也最实用的方向:内存访问模式、线程块配置、算子融合。

2.3.1 内存访问:合并访问是关键

GPU的显存带宽很高,但前提是你的访问模式要合并。什么意思?就是相邻的线程要访问相邻的内存地址。如果你让线程0访问地址0,线程1访问地址100,那带宽利用率直接掉到10%。

我在优化一个矩阵转置Kernel时,就踩过这个坑。一开始用朴素实现,带宽利用率只有15%。后来改成共享内存分块,先让线程块内合并加载到共享内存,再转置写回,带宽利用率提升到了70%。

// 不合并访问(慢)
__global__ void bad_transpose(float* in, float* out, int n) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    out[col * n + row] = in[row * n + col];  // 写操作不连续
}

// 合并访问(快)
__global__ void good_transpose(float* in, float* out, int n) {
    __shared__ float tile[TILE_SIZE][TILE_SIZE];
    int row = blockIdx.y * TILE_SIZE + threadIdx.y;
    int col = blockIdx.x * TILE_SIZE + threadIdx.x;
    tile[threadIdx.y][threadIdx.x] = in[row * n + col];
    __syncthreads();
    out[col * n + row] = tile[threadIdx.x][threadIdx.y];  // 写操作连续
}

注意:共享内存大小有限,一般每个SM只有几十KB。分块大小要算好,别超了。

2.3.2 线程块配置:找到最佳网格大小

线程块的大小和数量,直接影响GPU的占用率。我建议遵循一个原则:让每个SM至少跑4个Warp,这样才能隐藏内存访问延迟。

怎么算?假设你的GPU有80个SM,每个SM最多2048个线程。那你至少需要80 * 4 * 32 = 10240个线程。线程块大小一般选128或256,这样每个SM能同时跑8-16个Warp。

我曾经优化过一个逐元素Kernel,一开始线程块设成32,占用率只有25%。改成256之后,占用率到了75%,速度翻了3倍。你想想看,只是改了个数字,效果就这么明显。

线程块大小 每个SM的Warp数 占用率 性能表现
32 2 25% 差
128 8 50% 中等
256 16 75% 好

2.3.3 算子融合:减少Kernel启动

最后一个技巧,也是我最常用的——算子融合。把多个小算子合并成一个Kernel,减少启动开销和中间显存读写。

举个例子,y = (x + 1) * 2,如果分开写,要启动两个Kernel,中间结果还要写回显存。融合之后,一个Kernel搞定,中间结果留在寄存器里。

// 分开写(慢)
z = x + 1;
y = z * 2;

// 融合写(快)
__global__ void fused_kernel(float* x, float* y, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        y[idx] = (x[idx] + 1.0f) * 2.0f;  // 一次计算,无中间显存
    }
}

嗯,这里要注意:不是所有算子都能融合。比如涉及跨线程通信的(像Softmax),融合起来就比较复杂。但像逐元素操作、激活函数、简单的数学运算,几乎都能融合。

一句话总结:GPU利用率低,先查数据加载,再找瓶颈算子,最后用Kernel优化三板斧——合并访问、调线程块、算子融合。

计算瓶颈剖析知识体系 GPU利用率低 数据加载慢 算子效率低 Kernel启动开销 nsys profile / DataLoader PyTorch Profiler / Nsight nsys timeline / Kernel launch 合并内存访问 调线程块配置 算子融合 共享内存分块 Warp数 ≥ 4/SM 逐元素+激活融合 目标:GPU利用率 ≥ 90%

好了,这一章的内容就到这里。记住,优化GPU利用率不是一蹴而就的事,需要你反复profile、分析、改代码。但只要你掌握了上面这些方法,大部分瓶颈都能找到并解决。