第二章:模型分析与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:记录调用栈,方便你找到代码里对应的位置。
我的小技巧: 如果你只想看GPU上的耗时,可以只设置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%。后来我们改成异步传输+流水线,延迟直接降了一半。

注意: 别只看CUDA时间。CPU时间同样重要,尤其是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 定位瓶颈的实战思路

好了,工具会用了,数据也有了。怎么判断瓶颈在哪?

我一般按这个顺序排查:

  1. 先看整体:GPU利用率高不高?如果低于80%,说明有等待或数据传输开销。
  2. 再看热点算子:哪个算子耗时最长?是卷积、全连接还是激活函数?
  3. 分析瓶颈类型:用Nsight看这个算子的计算/访存比。比值高是计算瓶颈,比值低是访存瓶颈。
  4. 检查数据传输:有没有不必要的CPU-GPU拷贝?能不能用pin_memory或异步传输?
  5. 看kernel启动开销:如果有很多小kernel,启动开销可能比计算本身还大。这时候就该考虑算子融合了。
核心原则: 优化永远针对瓶颈。如果瓶颈是访存,你拼命优化计算,那是白费力气。反过来也一样。

2.6 避坑指南

最后,分享几个我踩过的坑:

  • 不要只跑一次Profiling:GPU有温度、频率的动态变化,建议跑3-5次取平均。
  • 注意Profiling本身的开销:开启profile_memorywith_stack会引入额外开销,影响结果。正式分析时,可以先关掉这些选项,只测时间。
  • 别忽略CPU端:我曾经有个模型,GPU利用率100%,但整体还是慢。一查发现,CPU端的DataLoader成了瓶颈,GPU一直在等数据。后来用num_workersprefetch_factor解决了。
  • Nsight的timeline要放大看:有时候一个kernel看起来只占一小段,但放大后发现它内部有大量的同步等待。

嗯,Profiling这部分就讲这么多。记住一句话:没有Profiling的优化,都是耍流氓。下一章,我们会聊算子融合——这是解决小kernel启动开销的利器。