Hugging Face 这篇 PyTorch Profiling 入门,例子小到有点“反常”:一个 torch.matmul(x, w),后面接一个 torch.add(..., b),再用 record_function("matmul_add") 做标注。
但这个小例子正好把一个常见误判拆开了:GPU 慢,不一定是 GPU 在忙;CPU 时间高,也不等于 PyTorch 本身差。很多性能问题卡在 CPU 发起调用、CUDA kernel 启动,以及 GPU 真正执行之间的空白里。
我更在意的是这一点:这篇教程不是在教人记 torch.profiler 的参数,而是在训练一种读 trace 的顺序。先看事实在哪里发生,再判断该优化哪里。
最小例子先把噪声压下去
教程没有一上来讲 LLM,也没有把训练循环、数据加载、通信和编译全塞进来。它只看一个接近线性层核心形态的函数:矩阵乘法加 bias。
这个选择很朴素,也很有效。例子越小,trace 里的干扰越少。开发者更容易看清一条链路:Python/ATen 调用如何落到 CUDA kernel,GPU 又在什么时候真正开始算。
torch.profiler 主要给两类产物。一个看统计,一个看时间线。
| 产物 | 主要回答的问题 | 读的时候最容易错在哪 |
|---|---|---|
| profiler table | 哪些 op 耗时高、调用多少次、self/total 时间怎么分布 | 只盯 total time,忽略子调用和调用次数 |
| Chrome trace / Perfetto | CPU lane、GPU lane、ProfilerStep 和 kernel 启动关系 | 把 kernel 前后的空白误当成 GPU 计算 |
| schedule | wait、warmup、active 各阶段如何采样 | 把首个 active step 当成程序第一次运行 |
这里有个细节很容易被跳过。教程使用了类似 schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1) 的机制。
wait 阶段跳过,warmup 阶段预热,active 阶段才记录。因此 trace 里从 ProfilerStep#2 开始,不代表程序第一次执行。刚接触 profiling 的人如果忽略这一点,很容易把编号和耗时对错位。
对开发者来说,第一步不该急着改代码。更稳的做法是先跑一个最小可复现例子,确认 profiler table 和 trace 能互相对上。表里看到热点,trace 里要能找到它出现在哪条 lane、前后有没有空白。
这一步做不扎实,后面谈优化就是缘木求鱼。
小矩阵看开销,大矩阵看计算
教程里最有用的对比,是 64×64 和 4096×4096 两种规模。
在 64×64、bf16 的示例中,GPU kernel 时间远小于 CPU 侧开销。原文结果显示,Self CPU time total 是毫秒级,Self CUDA time total 只有几十微秒级。
这说明程序更像 overhead-bound。GPU 不是算不动,而是很多时间花在调度、启动和 profiling 记录这类开销上。小矩阵计算太短,固定成本反而变得显眼。
矩阵扩大到 4096×4096 后,CUDA 时间上升到毫秒级,主要耗时转向 GPU GEMM kernel。此时程序更接近 compute-bound,瓶颈才真正落到 GPU 计算上。
| 场景 | trace 里更该看什么 | 更可能的瓶颈 | 优化方向的含义 |
|---|---|---|---|
| 64×64 bf16 | CPU lane、kernel 启动前后空白、调用次数 | 调度和启动开销 | 减少碎片化调用,观察能否融合或批量化 |
| 4096×4096 | GPU lane、GEMM kernel 持续时间 | GPU 计算 | 再讨论 kernel 性能、算子实现和编译优化 |
这个对比不能外推成“某块 GPU 一定怎样”。教程示例来自特定硬件和特定输入形状,数字只说明这个例子里的关系。
但判断方法可以迁移。小 batch、小矩阵、碎片化 op 多的推理链路,常常先输在启动开销上。大矩阵、较高算力利用率的训练或推理场景,才更适合把注意力放到 GEMM kernel 和 GPU 利用率上。
这对两类人最直接。
做 PyTorch 推理优化的工程师,可以先暂停“换更强 GPU 就会快”的直觉。先看 trace 里的空白是不是太多。如果空白占比高,采购或迁移硬件未必是第一优先级。
刚开始做训练优化的开发者,也不必一上来打开 Nsight Compute 看硬件计数器。先用 PyTorch profiler 把 Python、ATen 和 CUDA kernel 串起来。等确认瓶颈真的在 GPU kernel,再下钻到底层工具。
真正要学的是排查顺序
我不太买账的一种做法,是拿 profiler table 截图后直接下结论:哪个 op 排第一,就优化哪个 op。
table 很有用,但它只回答“谁耗时多”。它不能单独解释“为什么耗时多”。调用次数高、子调用长、CPU 等 GPU、GPU 等 CPU,都会在表里表现成耗时。
更稳的排查顺序可以很短:
- 先看 profiler table,找 self time、total time 和调用次数异常的 op。
- 再打开 Perfetto,看对应 op 在 CPU lane 和 GPU lane 的位置。
- 盯住
ProfilerStep,确认 active 阶段没有被误读。 - 看 kernel 启动前后有没有空白,判断是 overhead-bound 还是 compute-bound。
- 如果是 overhead-bound,优先减少小 op、碎片化调用和不必要同步;如果是 compute-bound,再看 kernel、编译和算子实现。
这篇教程的边界也要说清。它目前只覆盖 matmul/add 和 torch.profiler 入门,没有证明 torch.compile 一定能带来收益,也没有展开 LLM 推理优化。
更现实的观察点不是“后面会不会更高级”,而是两个具体问题:后续例子进入 nn.Linear、小型 MLP 后,CPU-GPU 空白是否还能被清楚定位;如果引入 torch.compile,trace 里的空白有没有真的缩短。
表格里的名字变漂亮,不算优化。时间线里的空白变短,才算有落点。
这也是 profiling 的价值:它不替你优化,但能阻止你太早押错方向。先看清 CPU 调度、GPU kernel 和中间空隙,再决定要动代码、动编译器,还是动硬件。
