@ariG23498: 这个人真会写!将这种理解与性能分析(我系列的无耻推广)结合,你就无敌了! http://hf.…
摘要
一份面向初学者的指南,介绍如何使用 PyTorch 中的 torch.profiler 对深度学习工作负载进行性能分析和优化,涵盖追踪读取、CUDA 分析和 torch.compile 集成。
查看缓存全文
缓存时间: 2026/07/15 15:56
此人写作功力深厚!将这种理解与性能剖析(此处无耻地推销我的系列文章)结合,你将如虎添翼!http://hf.co/blog/torch-profiler…
PyTorch 性能剖析(第一部分):torch.profiler 新手入门指南
来源:https://huggingface.co/blog/torch-profiler
返回文章列表 (https://huggingface.co/blog)
博客文章缩略图 (https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-profiler/thumbnail.png)
你无法优化的东西,就无法剖析。
无论你是想从大语言模型(LLM)中压榨出更多每秒 token 数,还是想削减推理的毫秒级延迟,抑或只是想知道为什么你的训练循环跑得比规格表承诺的慢,最终都绕不开性能剖析。但问题在于,性能剖析的入门门槛极高。轨迹图是一堵密集的彩色矩形墙,事件名称听起来吓人,大多数教程又假设你已经能读懂它们。所以,即使我们知道应该做剖析,打开轨迹图也常常让人觉得还是以后再说(或者交给别人)比较好。
本文以及它所开启的系列文章,就是我们降低门槛的尝试。我们从初学者的角度记录学习过程,除了基础的 PyTorch 知识外没有其他先决条件。把它当作一次轻松的阅读,期待一些“啊哈!”的顿悟时刻。文章结构刻意采用“提问驱动”的方式:我们打开一个轨迹图,问“等等,为什么这样?”,然后追查答案,直到恍然大悟。
读完本文,你应该能掌握:
- 如何设置
torch.profiler,以及它实际返回什么; - 如何阅读性能剖析表格和轨迹图(CPU 轨道、GPU 轨道,以及两者之间可疑的空白区域);
- 从 Python 调用到 CUDA 内核的完整事件链;
- 当你加上
torch.compile之后,什么会发生变化(更有趣的是,什么没有变化)。
在开始之前,先明确两个定义,这会让后续内容更容易理解:
- GPU 内核 是一个在 GPU 的多个线程上并行运行的程序。
- CPU 负责调度和启动这些内核。
你通常不需要自己编写 GPU 内核;当你在 PyTorch 中使用一个操作时,它会自动翻译成一个或多个内核来完成 GPU 上的工作。
带着这两个概念,我们开始提问。
以下是本文使用的完整脚本:
01_matmul_add.py(https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/01_matmul_add.py) 。建议在新标签页中打开此脚本,并逐步阅读代码。 我们使用NVIDIA A100-SXM4-80GBGPU 运行脚本。在 Hugging Face 基础设施上设置 GPU 非常容易,可以通过 Spaces 的开发者模式 (https://huggingface.co/docs/hub/spaces-dev-mode) 来试验脚本。也可以使用 Hugging Face Jobs 管道 (https://huggingface.co/docs/huggingface_hub/en/guides/jobs) 运行脚本。
矩阵乘法与加法操作
正如 Dr. Sara Hooker (https://youtu.be/7knwihgj0fU?si=uvzGH-J9bsCHP4Nn&t=2199) 精辟指出的那样:就像我们主要由水组成一样,深度神经网络主要由矩阵乘法组成。既然矩阵乘法如此基础,用其他操作开始我们的剖析之旅未免可惜。
def fn(x, w, b):
return torch.add(torch.matmul(x, w), b)
在矩阵乘法中加入矩阵加法,模拟了神经元中权重和偏置的交互。这个加法(双关语:addition 亦有“补充”之意)有助于我们理解它如何为后文的编译铺平道路。
要进行性能剖析,我们将使用 torch.profiler 模块。步骤如下:
- 准备好要剖析的代码 (https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/01_matmul_add.py#L26-L27) (此处是
def fn,它封装了矩阵乘法和矩阵加法)。 - 对算法进行注解 (https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/01_matmul_add.py#L32) 。虽然这是完全可选的,但我们仍然推荐这样做。
record_function将我们的函数注解为matmul_add,这将在轨迹图中便于导航(我们后面会提到)。
def step():
with torch.profiler.record_function("matmul_add"):
return fn(x, w, b)
- 用
torch.profiler.profile上下文管理器包裹代码 (https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/01_matmul_add.py#L53-L62) 。
with torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU, # CPU 活动
torch.profiler.ProfilerActivity.CUDA, # GPU 活动
],
) as prof:
# 建议多次运行事件以预热 GPU
for _ in range(5):
step()
prof.step()
- 导出分析结果 (https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/01_matmul_add.py#L70) 。
# 性能剖析表格
prof.key_averages().table(sort_by="cuda_time_total", row_limit=15)
# 性能剖析轨迹
prof.export_chrome_trace(trace_path)
剖析器会导出两种不同的产物:
- 性能剖析表格:提供算法的统计摘要。它回答“什么最耗时”。这对于找出热点非常有用。热点是指耗时最多的事件,可能是管线的瓶颈,也可能是被触发了多次的事件。
- 性能剖析轨迹:提供时间轴上的执行视图。回答“操作何时及为何发生”,描绘 CPU 和 GPU 上的活动。当我们想检查启动了什么内核、是否有延迟、CPU 和 GPU 活动是否有重叠等情况时特别有用。
让我们用第一次执行来看看这两者的实际效果。(这里是完整的 01_matmul_add.py 脚本 (https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/01_matmul_add.py) )
建议在带有 GPU 的机器上运行此脚本。
uv run 01_matmul_add.py --size 64
如果你在 GPU 机器上运行上述脚本,会发现一个名为 traces/01_matmul_add 的文件夹,里面包含两个产物:
64_bf16_cold_eager.json
64_bf16_cold_eager.txt
64 大小矩阵的 matmul add 剖析表格 (https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-profiler/profile-table-64.png)
图 1:64 大小矩阵的 matmul add 剖析表格
.txt 文件保存了剖析表格。打开文件后,如图 1 所示,你会看到一个大型表格,第一列是剖析作用域内被触发的事件。其他列与事件在 CPU、GPU 或 torch.profiler.profile 的 activities 中指定的任何其他设备上消耗的时间有关。看看哪些事件耗时最多,并试着凭直觉判断该事件是否确实应该消耗那么多时间。同样重要的是查看“# of Calls”列,它指示事件被触发的次数。
顺便提一下,“Self CPU/CUDA”与“CPU/CUDA total”的区别。“Self”列仅测量事件本身内部花费的时间,不包括其子事件。“total”列则包括事件及其所有子事件的总时间。所以,如果你看 matmul_add 的“CPU total”,它包含了它自身的时间加上触发的子事件的时间。这是一个需要特别注意的细微差别。
如果你查看表格的最后两行,会注意到剖析器告诉我们:
Self CPU time total: 2.314ms
Self CUDA time total: 23.104us
CPU 时间以 ms 计,而 GPU 时间以 us 计。换句话说,GPU(内核 ampere_bf16_s16816gemm...)花费的时间不到 CPU(matmul_add 操作)时间的 1%。GPU 大部分时间都在闲置,这是一个明显的红旗。出现这种情况的原因是 GPU 可以非常快速地计算小规模矩阵乘法,因此我们的代码大部分时间都花在了准备内核、启动内核、发送数据相乘以及收集结果上。这个概念被称为开销受限算法。摆脱这种状态的最简单方法是使用更大的矩阵乘法。
uv run 01_matmul_add.py --size 4096
4096 大小矩阵的 matmul add 算法剖析表格 (https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-profiler/profiler-table-4096.png)
图 2:4096 大小矩阵的 matmul add 剖析表格
图 2 中的最后两行是:
Self CPU time total: 4.908ms
Self CUDA time total: 4.495ms
两个时间都以 ms 计,这意味着我们仅通过增加矩阵乘法的大小就实现了更多的 GPU 时间。如果在图 2 中仔细看,你还会注意到,现在 CUDA 时间最多的是 GPU 内核(ampere_bf16_s16816gemm_...),而不是启动它的 CPU 操作(matmul_add)。这意味着我们确实从开销受限转向了计算受限。
现在我们转向可视化调度链,它存在于 .json 产物中。你可以将其上传到 Perfetto UI (https://ui.perfetto.dev/) 查看轨迹,也可以使用 uvx trace-util -f traces -b /traces 直接生成 Perfetto 链接。
64x64 轨迹
PyTorch 剖析器轨迹图:64×64 bf16 矩阵乘法后接加法,在 CUDA GPU 上运行 (https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-profiler/64-matmul-add.png)
图 3:64 大小矩阵的 matmul 和 add 剖析轨迹
在图 3 中,我们看到矩阵乘法和加法的剖析轨迹。这里,条形的宽度表示事件的持续时间,垂直嵌套表示调用层次结构,CPU 轨道表示 CPU 上发生的事件,而 GPU 轨道则显示实际的内核执行。你可能还会注意到空白区域,这是等待或空闲时间。
脚本使用默认配置运行,即:
- size 64:输入、权重和偏置的大小均为 (64, 64)
- dtype bf16:数据类型为 bfloat16
- no compile:我们未编译 torch 操作
- no warmup:我们在剖析之前没有预热 GPU
关于 Perfetto,我们建议使用键盘快捷键来更快地浏览轨迹。可以使用 “W A S D” 键进行导航。
PyTorch 剖析器轨迹图,CPU 轨道和 GPU 轨道在 Perfetto 中并排标注 (https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-profiler/gpu-cpu-trace.png)
图 4:PyTorch 剖析器轨迹的 CPU 和 GPU 轨道
图 4 中有两个轨道,一个用于 CPU 活动,一个用于 GPU 活动。在 CPU 轨道中,你会注意到三个剖析步骤(从 ProfilerStep#2 开始)。这源于 schedule 配置。
schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)
wait 跳过噪声较大的初始化阶段(ProfilerStep#0),warmup 在不记录的情况下运行剖析器(ProfilerStep#1),而 active 就是出现在轨迹中的部分。你可以在脚本中找到正在使用的调度配置 (https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/01_matmul_add.py#L58) 。
现在,让我们戴上侦探帽,调查轨迹图并提出一些问题。
为什么 ProfilerStep#2 耗时那么长?
PyTorch 剖析器轨迹中 ProfileStep#2 比 ProfileStep#3 和 ProfileStep#4 看起来更宽 (https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-profiler/why-is-step-2-big.png)
图 5:ProfileStep#2 明显比后续步骤更宽
在图 5 中,我们注意到 ProfileStep#2 比其他步骤花费了更多时间,仔细观察还会发现 matmul_add 注解也有类似模式。问题出在注解内部,而不是注解本身:
| Step | 起始 | 间隙开始 | 间隙结束 | 间隙时长 |
|---|---|---|---|---|
matmul_add start | aten::matmul start | 间隙 | ||
| #2 | 138.736 μs | 366.493 μs | 227.757 μs | |
| #3 | 517.926 μs | 523.447 μs | 5.521 μs | |
| #4 | 610.039 μs | 614.527 μs | 4.488 μs |
profile 步骤 2 中 record_function matmul_add 与 aten::matmul 调度之间的 228 微秒间隙 (https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-profiler/gap-227.png)
图 6:record_function("matmul_add") 入口与实际派发 aten::matmul 之间约 228 μs 的死窗口
图 6 中显示的约 228 μs 是进入 record_function("matmul_add") 和 PyTorch 实际派发 aten::matmul 之间的“死窗口”。这可能是由多种原因造成的,包括工作空间分配、cuBLAS (https://developer.nvidia.com/cublas)(NVIDIA 专有的 GPU 加速线性代数库)的启发式决策,或惰性模块加载。我们既可以忽略它,也可以在剖析之前运行更多的预热步骤(这是标准做法)。
在剖析中,预热是指在实际剖析之前将事件运行几次。GPU 完成的预工作(包括上述几点)是一次性工作,我们不想剖析它们。在我们的示例中,有两个预热阶段:一个是在进入剖析器之前对函数进行循环的预热,另一个是在剖析器内部通过 warmup 参数实现的预热。
在本节中,我们
相似文章
PyTorch 中的性能分析(第一部分):torch.profiler 初学者指南
这是一份初学者友好的指南,介绍如何使用 PyTorch 的 torch.profiler 对神经网络操作进行性能分析和优化,从矩阵乘法和偏置加法开始。它解释了如何读取分析器跟踪并理解 CPU/GPU 交互。
@ariG23498: 有两篇官方PyTorch教程链接到了"Profiling in PyTorch"系列!https://docs.pytorch.org/tutoria…
强调了两篇官方PyTorch教程,它们链接到了'Profiling in PyTorch'系列,提供了使用PyTorch性能分析器API进行性能调试的指导。
@RisingSayak: 我意识到,无法分析的东西就无法优化。这就是为什么我在Diffusers中开始了一个小项目,来……
Sayak Paul 描述了一个使用 torch.compile 分析和优化 Diffusers 流水线的项目,并宣布由 Ari G. 教授的相关教程系列。
@ariG23498: 现在是性能分析时间!在第2部分中,我们涵盖:> 追踪线性层 > 讨论 mul + add 与 linear 的对比 > gemm epilogues (我最…
宣布性能分析教程的第2部分,涵盖线性层追踪、gemm epilogues、MLP追踪以及torch compile与Liger内核的对比,并附有完整内容的链接。
@ManningBooks: PyTorch 能带你走得很远,但当性能成为问题时,了解 GPU 层面的情况就至关重要…
为 Elliot Arledge 所著的《CUDA for Deep Learning》一书做的推广帖子,提供第一章总结视频,讲解 GPU 性能、CUDA 编程模型,以及何时需要编写自定义 CUDA 内核。