第二章:模型分析与Profiling——用数据说话,别靠猜
做部署优化,最怕什么?
最怕你凭感觉改代码,改完发现没效果,甚至更慢了。
我见过太多人,一上来就调算子、换后端,忙活半天,结果瓶颈根本不在那儿。所以,我的第一个建议是:先Profiling,再动手。没有数据,你就是在黑暗中乱撞。
2.1 为什么要做Profiling?
说白了,Profiling就是给你的模型做一次「体检」。你得知道:
- 哪个算子最耗时?
- 是计算瓶颈还是访存瓶颈?
- GPU利用率高不高?
- 有没有不必要的CPU-GPU数据传输?
我刚开始做部署时,有个模型推理特别慢。我直觉以为是卷积太慢,花了两天优化卷积。结果一Profiling发现,真正慢的是后面的一个Reshape操作——它触发了内存拷贝。嗯,那两天白干了。从那以后,我养成了先Profiling的习惯。
2.2 PyTorch Profiler:最趁手的工具
如果你用PyTorch,那PyTorch Profiler就是你的首选。它集成在PyTorch里,用起来非常方便。
基本用法很简单:
import torch
from torch.profiler import profile, record_function, ProfilerActivity
model = YourModel().cuda()
input_data = torch.randn(1, 3, 224, 224).cuda()
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
with_stack=True
) as prof:
with record_function("model_inference"):
output = model(input_data)
# 打印耗时排序
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))
这里我重点说几个参数:
record_shapes=True:记录张量形状,方便你定位是哪个尺寸的算子慢。profile_memory=True:看内存占用,有时候显存爆了就是因为某个中间结果太大。with_stack=True:记录调用栈,方便你找到代码里对应的位置。
ProfilerActivity.CUDA。但建议CPU也开着,因为有时候CPU预处理会成为瓶颈。
2.3 读懂Profiling结果:找热点
跑完Profiling,你会看到一张表。我拿一个实际案例来说:
| Name | Self CPU % | Self CUDA % | CPU Mem (MB) | CUDA Mem (MB) |
|---|---|---|---|---|
| aten::conv2d | 12.3% | 45.2% | 0.1 | 128.5 |
| aten::batch_norm | 5.1% | 18.7% | 0.0 | 32.1 |
| aten::relu | 2.0% | 8.3% | 0.0 | 0.0 |
| aten::addmm | 8.7% | 22.1% | 0.2 | 64.3 |
| cudaMemcpy | 35.6% | 0.0% | 256.0 | 0.0 |
你看这张表,最扎眼的是什么?
是cudaMemcpy!它占了35.6%的CPU时间,但CUDA时间是0。这说明什么?说明大量的时间花在了CPU和GPU之间的数据传输上。这往往是性能杀手。
我遇到过的一个真实案例:有个模型在GPU上推理很快,但整体延迟很高。一Profiling发现,每次推理前都要把输入数据从CPU拷贝到GPU,推理完再拷回来。这个拷贝操作占了总时间的40%。后来我们改成异步传输+流水线,延迟直接降了一半。
cudaMemcpy这种同步操作,它会阻塞GPU的执行。
2.4 Nsight Systems:更底层的视角
PyTorch Profiler适合快速定位,但如果你想看更底层的东西——比如GPU的kernel执行细节、内存带宽利用率、SM利用率——那就得上Nsight Systems了。
Nsight Systems的用法:
# 命令行启动
nsys profile -o output_profile -t cuda,nvtx,osrt python your_script.py
# 然后打开生成的 .nsys-rep 文件
# 在GUI里看timeline
我个人习惯用Nsight Systems看两个东西:
- GPU Timeline:看kernel是不是串行执行的?有没有空闲间隙?
- Memory Bandwidth:看每个kernel的内存带宽利用率。如果带宽利用率低,说明是计算瓶颈;如果带宽打满了,那就是访存瓶颈。
举个例子,你看到一个卷积kernel的带宽利用率只有20%,但计算单元利用率很高。这说明什么?说明这个卷积是计算密集型的,优化方向应该是减少计算量(比如用TensorCore、量化)。反过来,如果带宽利用率接近100%,但计算单元空闲,那就是访存瓶颈,优化方向应该是减少内存访问(比如算子融合、减少中间结果)。
2.5 定位瓶颈的实战思路
好了,工具会用了,数据也有了。怎么判断瓶颈在哪?
我一般按这个顺序排查:
- 先看整体:GPU利用率高不高?如果低于80%,说明有等待或数据传输开销。
- 再看热点算子:哪个算子耗时最长?是卷积、全连接还是激活函数?
- 分析瓶颈类型:用Nsight看这个算子的计算/访存比。比值高是计算瓶颈,比值低是访存瓶颈。
- 检查数据传输:有没有不必要的CPU-GPU拷贝?能不能用
pin_memory或异步传输? - 看kernel启动开销:如果有很多小kernel,启动开销可能比计算本身还大。这时候就该考虑算子融合了。
2.6 避坑指南
最后,分享几个我踩过的坑:
- 不要只跑一次Profiling:GPU有温度、频率的动态变化,建议跑3-5次取平均。
- 注意Profiling本身的开销:开启
profile_memory和with_stack会引入额外开销,影响结果。正式分析时,可以先关掉这些选项,只测时间。 - 别忽略CPU端:我曾经有个模型,GPU利用率100%,但整体还是慢。一查发现,CPU端的DataLoader成了瓶颈,GPU一直在等数据。后来用
num_workers和prefetch_factor解决了。 - Nsight的timeline要放大看:有时候一个kernel看起来只占一小段,但放大后发现它内部有大量的同步等待。
嗯,Profiling这部分就讲这么多。记住一句话:没有Profiling的优化,都是耍流氓。下一章,我们会聊算子融合——这是解决小kernel启动开销的利器。