前途科技前途科技
  • 洞察
  • 服务
  • 关于
  • AI 资讯
    • 快讯
    • 产品
    • 技术
    • 商业
    • 政策
    • 初创
  • 洞察
  • 资源中心
    • 深度研究
      • AI 前沿
      • 案例研究
      • AI 知识库
    • 行业报告
      • 白皮书
      • 行业报告
      • 研究报告
      • 技术分享
      • 专题报告
    • 精选案例
      • 金融行业
      • 医疗行业
      • 教育行业
      • 零售行业
      • 制造行业
  • 服务
  • 关于
联系我们

PyTorch 性能分析入门:torch.profiler 完全指南

技术2026年5月28日· 原作者:Hugging Face· 6 分钟阅读0 阅读

本文是 PyTorch 性能分析系列的第一篇。从最简单的矩阵乘法与加法操作出发,手把手教你读取 profiler 表格和 trace,理解 CPU 与 GPU 协作的细节,并探索 torch.compile 带来的变化。适合 PyTorch 初学者快速上手性能分析。

无法分析的东西,就无法优化。

无论你是想榨出大语言模型(LLM)的更多 token 每秒,缩短推理的毫秒级延迟,还是想搞清楚为何训练循环跑得比规格承诺的慢,最终都绕不开性能分析(profiling)。

问题在于性能分析的入门门槛很高。追踪记录(trace)看起来是一堵堵彩色矩形堆成的墙,事件名称也让人望而生畏。大多数教程都假设你已经能读懂这些内容。所以,即使我们知道自己该做性能分析,打开一个 trace 也常常像件苦差事,总想往后拖或交给别人。本文以及它开启的这个系列,就是尝试降低这个门槛。

这是 PyTorch 性能分析(Profiling in PyTorch) 系列的开篇。我们的计划是:

  • 第一部分(本文):从最简单的操作——矩阵乘法加偏置加法开始,学习如何读懂 profiler 返回的信息。
  • 第二部分:扩展到 nn.Linear 和小型 MLP,用 trace 来驱动优化,并窥探底层的 kernel。
  • 第三部分:在 transformers 库的大语言模型上综合运用。

我们从初学者的角度记录整个旅程,只需要基本的 PyTorch 知识。把它当作一篇轻松的阅读,期待一些“啊哈!”时刻。文章结构有意采用“问题驱动”:打开一个 trace,问“等等,为什么是那样?”,然后追查答案直到弄懂。读完本文,你将掌握:

  • 如何设置 torch.profiler 以及它实际返回什么;
  • 如何读懂 profiler 表格和 trace(CPU 跑道、GPU 跑道以及两者之间可疑的间隙);
  • 从 Python 调用到 CUDA 内核的完整事件链;
  • 加上 torch.compile 后,哪些变了,更有趣的是哪些没变。

开始之前,先给出两个定义,能让下文读起来更顺畅:

  1. GPU 内核(kernel) 是一个在 GPU 众多线程上并行运行的程序。
  2. CPU 负责调度和启动这些内核。

你通常不需要自己写 GPU 内核;当你使用 PyTorch 操作时,它会被自动翻译成一个或多个内核来在 GPU 上执行。

记住这两个概念,我们开始提问。

本文所用的完整脚本:01_matmul_add.py。建议在新标签页中打开这个脚本,一步步跟着代码走。我们使用 NVIDIA A100-SXM4-80GB GPU 运行脚本。


矩阵乘法与加法操作

正如 Sara Hooker 博士的精辟比喻,就像我们主要由水构成,深度神经网络主要由矩阵乘法构成。既然它们如此基础,用其他操作开始我们的性能分析之旅就太可惜了。

def fn(x, w, b):
  return torch.add(torch.matmul(x, w), b)

矩阵加法搭配矩阵乘法,模拟了神经元中权重和偏差的交互。这个加法(双关语)将帮助我们理解它如何为 后面部分 的编译铺平道路。

为了进行性能分析,我们将使用 torch.profiler 模块。步骤包括:

  1. 准备好要分析的代码(这里 def fn 打包了矩阵乘法和加法)。
  2. 为算法添加注释。虽然完全可选,但我们推荐这样做。record_function 将我们的函数注释为 matmul_add,方便在 trace 中导航(后面会提到)。
def step():
  with torch.profiler.record_function("matmul_add"):
    return fn(x, w, b)
  1. 用 torch.profiler.profile 上下文管理器 包裹代码。
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()
  1. 导出分析结果。
# profiler 表格
prof.key_averages().table(sort_by="cuda_time_total", row_limit=15)

# profiler trace
prof.export_chrome_trace(trace_path)

profiler 输出两种不同的产物:

  1. Profiler 表格:提供算法的统计摘要。回答“什么占用了最多时间”。这对发现热点非常有用。热点可能是耗时最多的事件、管线瓶颈,或者被频繁触发的事件。
  2. Profiler trace:提供时间维度的执行视图。回答“操作在何时以及为什么发生”,描绘 CPU 和 GPU 上的活动。当你想调查启动了哪些内核、启动是否有延迟、CPU 和 GPU 活动是否有重叠时,trace 非常有用。

让我们看看第一次执行时的两者表现。(这里是完整的 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
图1: 64 大小矩阵 matmul add 的 profiler 表格
图1: 64 大小矩阵 matmul add 的 profiler 表格

.txt 文件保存了 profiler 表格。打开文件,如图1所示,你会看到一张大表格,第一列是在 profile 作用域内触发的事件。其他列与事件在 CPU、GPU 或其他在 activities 中指定的设备上花费的时间有关。观察哪些事件耗时最多,并直觉地判断这个事件是否应该花这么多时间。还要关注“调用次数”列,它显示事件被触发了多少次。

我们还应该谈谈“Self CPU/CUDA”与“CPU/CUDA total”的区别。“Self”列只测量事件本身内部的时间,不包括子事件。“Total”列则包含事件及其所有子事件的时间。所以如果你看 matmul_add 的“CPU total”,它包括自身时间加上它触发的子事件时间。这是需要注意的重要细节。

如果你观察表格的最后两行,会发现 profiler 告诉我们:

Self CPU time total: 2.314ms
Self CUDA time total: 23.104us

CPU 时间以毫秒计,GPU 时间以微秒计。对比来看,GPU 上(内核 ampere_bf16_s16816gemm...)花费的时间不到 CPU(matmul_add 操作)的 1%。GPU 大部分时间空闲,这是一个明显的危险信号。原因是 GPU 可以非常快地计算小规模 matmul,所以我们的代码大部分时间花在准备内核、启动它们、发送要相乘的数据以及收集结果上。这被称为开销受限(overhead-bound) 算法。摆脱这种状态最简单的方法是使用更大的矩阵乘法。

uv run 01_matmul_add.py --size 4096
图2: 4096 大小矩阵 matmul add 的 profiler 表格
图2: 4096 大小矩阵 matmul add 的 profiler 表格

图2 的最后两行是:

Self CPU time total: 4.908ms
Self CUDA time total: 4.495ms

现在两个时间都以毫秒计,这意味着我们通过增大矩阵乘法规模,实实在在地增加了 GPU 时间。如果你看图2,还会发现 CUDA 时间现在主要由 GPU 内核(ampere_bf16_s16816gemm_..)占据,而不是启动它的 CPU 操作(matmul_add)。这意味着我们确实从开销受限变成了计算受限。

现在我们进入可视化调度链,它存在于 .json 产物中。你可以将其上传到 Perfetto UI 查看 trace,或者使用 uvx trace-util traces -b traces 直接生成 Perfetto 链接。


64×64 trace

图3: 64×64 bf16 matmul 后接 add 的 PyTorch profiler trace
图3: 矩阵乘法和加法在 64 大小矩阵上的 profiler trace

在图3中,我们看到矩阵乘法和加法的 profiler trace。条形宽度表示事件持续时间,垂直嵌套表示调用层级,CPU 跑道显示 CPU 上的事件,而 GPU 跑道显示实际的内核执行。你可能还会注意到空白区域,那是等待或空闲时间。

脚本使用的是默认配置:

  • size 64: 输入、权重和偏置大小均为 (64, 64)
  • dtype bf16: 数据类型为 bfloat16
  • no compile: 未编译 torch 操作
  • no warmup: 性能分析前未预热 GPU

在 Perfetto 中,建议用键盘更快地浏览 trace。可以使用“W A S D”导航。

图4: PyTorch profiler trace 中标记的 CPU 跑道和 GPU 跑道
图4: PyTorch profiler trace 的 CPU 和 GPU 跑道

图4 中有两个跑道,一个给 CPU 活动,一个给 GPU 活动。在 CPU 跑道中,你会注意到三个 profile step(从 ProfilerStep#2 开始)。这来自 schedule。

schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)

wait 跳过有噪声的初始化(ProfilerStep#0),warmup 执行但不记录(ProfilerStep#1),active 才是 trace 中显示的内容。可以在 这里的脚本 中找到使用的 schedule。

让我们戴上侦探帽子,调查 trace 并提出一些问题。

为什么 ProfilerStep#2 花费这么长时间?

图5: ProfilerStep#2 看起来比 ProfilerStep#3 和 ProfilerStep#4 更宽
图5: ProfilerStep#2 明显比后面的步骤更宽

在图5中,我们注意到 ProfileStep#2 花费的时间比其他步骤更多。仔细观察会发现 matmul_add 注释也有类似模式。根源在注释内部,而不是注释本身:

步骤matmul_add 开始aten::matmul 开始间隙
#2138.736366.493227.757 µs
#3517.926523.4475.521 µs
#4610.039614.5274.488 µs
图6: record_function("matmul_add") 与 aten::matmul 调度之间约 228 µs 的死窗口
图6: 进入 record_function("matmul_add") 和 PyTorch 实际调度 aten::matmul 之间约 228 µs 的间隙

这个约 228 µs 的“死窗口”有多种可能的原因,包括工作空间分配、cuBLAS(NVIDIA 专有的 GPU 加速线性代数库)的启发式策略,或惰性模块加载。我们可以忽略它,或者在性能分析前 多跑几个预热步骤(这是标准做法)。

在性能分析中,预热就是在实际记录之前多跑几次事件。GPU 的预工作(包括上述几点)是一次性工作,我们不想分析它们。在我们的例子中,有两级预热:一是在进入 profiler 之前实际循环函数,二是在 profiler 内部通过 warmup 参数实现。在这一节,我们启用了实际迭代以及 schedule。

uv run 01_matmul_add.py --warmup

64×64 带预热版的 Perfetto Trace

图7: 预热步骤后,ProfileStep#2 不再显示冷启动开销
图7: 预热后,每个 profile step 花费相似的时间

图7 显示每个 profile step 花费相似的时间,但这并不意味着我们优化了那些一次性开销。我们只是预热了运行,使得那些开销不被记录。如果我们不给出解决这个问题的提示就草草结束这一节,那就对不起读者了。这里有一个 链接 可以进一步了解优化启动开销。

为什么 CPU 和 GPU 跑道之间有约 2.5 ms 的偏移?

图8: PyTorch profiler trace 中 CPU 跑道和 GPU 跑道之间约 2.5 ms 的偏移
图8: CPU 和 GPU 跑道之间约 2.5 ms 的偏移

在图8中,我们看到 CPU 和 GPU 跑道之间有约 2.5 ms 的偏移:这是 CPU 提交 CUDA 内核到内核实际开始执行之间的延迟。你可能会认为,预热阶段加上 schedule 的 wait 和 warmup 应该能让 GPU 保持忙碌,从而消除偏移。

为了揭示真正发生了什么,我们稍微修改一下 schedule:

- schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)
+ schedule = torch.profiler.schedule(wait=0, warmup=0, active=3, repeat=1)
图9: wait=0, warmup=0 时,trace 显示步骤间有 Activity Buffer Request
图9: wait=0 和 warmup=0 时,trace 在 GPU 跑道上显示了 Activity Buffer Request

图9 显示,在任何操作之前,GPU 跑道上有一个 Activity Buffer Request。我们来放大一点看看。

图10: profiler buffer 请求导致 matmul 和 add 内核之间出现间隙
图10: 在 profile step 1,matmul 和 add 内核之间出现间隙

放大 GPU trace 后,我们注意到 ProfileStep#0(其 CPU trace 未在图中显示)的 matmul 和 add 内核一个接一个执行,而 ProfileStep#1 的内核之间有一个间隔。最好的解释是缓冲区溢出,在内核执行期间又发出了另一个缓冲区请求(在 GPU VRAM 上分配内存的请求)。

排除其他可能性的最好方法是分析更多迭代,看看 trace 的其他部分是否也出现类似的间隔。为此,我们设置 active=20 运行。

图11: 20 个 active 迭代中,buffer-request 间隙只出现一次
图11: 20 个 active 步骤后,间隙只出现一次,证实是 buffer 请求

如图11所示,我们在 ProfileStep#1 中看到了类似的趋势。这与我们之前的发现一致,可以安全地得出结论:这确实是另一个缓冲区请求。

事件链

图12: PyTorch profiler 中嵌套的 CPU 调度链:ProfileStep, matmul_add, aten::matmul, aten::mm
图12: 调度链

在图12中,我们看到嵌套的 CPU 调用。这是一个重要的可视化,让你了解到调度链究竟是什么样子。

我们从 ProfileStep#<id> 开始,它封装了整个 profile 步骤。由于我们注释了步骤,可以看到 matmul_add 行。matmul_add 包含两个 aten 调用,一个用于矩阵乘法,一个用于矩阵加法。

aten::matmul 是 ATen 级别 的调度,用户层面的 PyTorch matmul 调用最终落在这里。aten::mm 是二维矩阵乘法的后端。

有趣的是,如果给矩阵加上批处理维度,PyTorch 会调用 aten::bmm(批处理的矩阵乘法)。让我们绕个小弯,看看 aten::bmm 的实际表现。

- x = torch.randn(args.size, args.size, device=device, dtype=dtype)
- w = torch.randn( args.size, args.size, device=device, dtype=dtype)
- b = torch.randn(args.size, args.size, device=device, dtype=dtype)

+ # 添加批处理大小为 8
+ x = torch.randn(8, args.size, args.size, device=device, dtype=dtype)
+ w = torch.randn(8, args.size, args.size, device=device, dtype=dtype)
+ b = torch.randn(8, args.size, args.size, device=device, dtype=dtype)
图13: 批处理的矩阵乘法 trace 显示 aten::matmul 调度了 aten::bmm
图13: 批处理矩阵乘法

在图13中,添加批处理维度后,aten::matmul 现在包含了一些预置的 CUDA 运行时调用以及 aten::bmm(而不是 aten::mm)。这也暗示了 cuBLAS 为了为程序调度最合适的内核,需要做更多的启发式工作。

在本文的剩余部分,除非特别说明,我们将使用简单的二维矩阵。

为什么 matmul 多了一个 CUDA 运行时调用?

图14: CPU 跑道上,matmul 内核启动前出现了 cudaOccupancyMaxActiveBlocksPerMultiprocessor
图14: matmul 内核启动前调用了 CUDA 占用率查询

我们注意到,aten::mm 有两个 CUDA 运行时调用,即 cudaOccupancyMaxActiveBlocksPerMultiprocessor(图14中方框标出)和 cudaLaunchKernel,而 aten::add 只有 cudaLaunchKernel。

cudaOccupancyMaxActiveBlocksPerMultiprocessor 是一个规划调用,纯粹在 CPU 侧执行。它询问:“给定一个内核函数、一个选择的块大小和一个选择的动态共享内存大小,这个内核有多少个块可以同时驻留在一个 SM(流式多处理器)上?”

这就引出了一个问题:为什么 matmul 需要规划,而 add 不需要?

要理解这一点,我们需要查看内核的资源占用情况。点击 GPU 内核,你可以检查相应内核的资源占用。

图15: cuBLAS matmul 内核资源占用:寄存器、共享内存和块大小图16: 逐元素加法 CUDA 内核资源占用:32 个寄存器,零共享内存
图15: matmul 资源占用图16: add 资源占用

在图15中,我们注意到矩阵乘法的 per thread registers 和 shared memory 是动态的(取决于矩阵大小)。cuBLAS 提供了数百种内核变体,每种都有启发式驱动的启动路径,需要关于硬件能力的运行时信息。占用率查询是这种启发式的一部分。概念上,我们可以把 GPU 加速的矩阵乘法看作 在独立的块(tile)上工作:使用多少块、每块多大,取决于矩阵和硬件。现代算法远比这复杂,但这仍然是一个很好的参考框架。

从图16我们看到,加法的资源占用显示 32 个寄存器和零共享内存。这是微不足道的。没有什么可查询的,因为没有硬件资源会限制占用率。这个内核设计上就是资源轻量的。

你可以把它作为阅读任何 trace 时的快速诊断工具:扫描 CPU 跑道上的 cudaOccupancyMaxActiveBlocksPerMultiprocessor。每次出现都标记了一个“重量级、自适应启动的”内核,通常是 GEMM、卷积或类似操作。没有先发生占用率查询的内核通常是 PyTorch 机械式启动的逐元素/归约类内核。

为什么 cudaDeviceSynchronize 这么大(约 1.78 ms)?

cudaDeviceSynchronize 阻塞 CPU 直到设备上所有 GPU 工作完成。profiler 在 active 窗口结束时发出这个同步,以刷新事件。没有它,内核计时会缺失。

一个 1.78 ms 的同步覆盖了 26 µs 的实际 GPU 工作,告诉你这次运行时 98% 是空闲的。这是典型的开销受限症状。


4096×4096 trace

我们已经从上面 profiler 表格的分析中知道,为算法提供更大的矩阵可以使其从开销受限变为计算受限。

让我们运行命令,深入 trace。

uv run 01_matmul_add.py --size 4096 --warmup

为什么同一个内核花费的时间比其他更长?

图17: 在同一个 GPU 上,4096×4096 bf16 matmul 内核在不同 profile 步骤中的耗时不同
图17: 一个 matmul 内核运行时间比其他的更长,尽管输入完全相同

在图17中,我们注意到 ProfileStep#3 的 matmul 内核在 GPU 上花费的时间比其他步骤长。这特别值得注意,因为其他启动的内核完全相同,这意味着没有 cuBLAS 启发式卷入。没有调度间隙,CPU 启动正常,也不是 profiler 的产物。

图17 中的这个 trace 提出了一个在理想化例子中容易忽略的有用观点:即使在相同的硬件环境中运行相同的代码和相同的数据,内核运行时间也不是常数。

让我们通过稍微修改脚本来让这一点更加明确。我们迭代 20 次,捕获每一步。

- schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)
+ schedule = torch.profiler.schedule(wait=0, warmup=0, active=20, repeat=1)

- for _ in range(5):
+ for _ in range(20):
图18: 20 次 matmul 迭代中,相同内核的运行速度不同
图18: 20 次迭代中,相同的 matmul 内核以不同速度运行

图18 揭示了类似的结果。虽然每个内核完全相同,但计时不同。不同的计算时间可以归因于多种原因:

  • GPU 时钟在空闲和提升时变化
  • GPU 发热
  • GPU 电源管理
  • 驱动侧的后台任务

只看平均值的读者会得出 matmul 大约需要 1 ms(5 次的均值 = 1084 µs)的结论;而查看 trace 的读者会看到 matmul 大约需要 580 µs,除非 GPU 不正常。这是两种截然不同的心智模型,只有一种是对的。


来看看 torch.compile 的效果

使用 torch.compile 总是让我们感到惊喜。你只需编写正常的 eager 模式 PyTorch 代码,但 PyTorch 会尝试捕获张量密集的区域,将其转化为计算图,进行优化,然后运行生成的代码。默认后端通常是 TorchInductor,大致的管线是:

  1. TorchDynamo 将 Python 执行捕获为 FX 图
  2. AOTAutograd 在涉及梯度时准备前向/反向图
  3. Inductor 将图转换为优化的 CPU 或 GPU 代码

在这一节,我们讨论编译并查看 profiler trace。

uv run 01_matmul_add.py --size 4096 --warmup --compile

args.compile 标志会触发以下代码:

def fn(x, w, b):
  return torch.add(torch.matmul(x, w), b)

fn = torch.compile(fn) if args.compile else fn
图19: trace 中高亮的 torch.compile 区域,显示 TorchDynamo 和 Inductor 帧
图19: 编译后的区域在 trace 中显示为 TorchDynamo 和 Inductor 帧

在图19中,我们看到了名为 Torch-Compiled Region: 0/0 的新 CPU 行,这指向正在使用的编译函数。

我们是否将 matmul 和 add 内核融合为一个?

图20: 编译后的 trace 显示 aten::addmm 替代了 eager 模式下的 aten::add 和 aten::mm 对
图20: 编译运行调度了单个 aten::addmm

看图20,我们问:我们真的把乘法和加法操作融合成一个了吗?

这是图级别的算子融合。Inductor 将我们的 torch.add(torch.matmul(x, w), b) 改写为单个 aten::addmm(b, x, w) 调用。需要注意的重要一点是,它并没有产生一个新的融合 CUDA 内核。实际的 GPU 工作仍然是 ampere_bf16_s16816gemm_bf16_128x256_ldg8_f2f_stages_64x3_nn,和 eager 模式使用的 cuBLAS 内核一样。所以这里的“融合”是在调度器层面,而不是内核层面。

PyTorch 提供了 torch.addmm 函数,它正是做了我们两步骤做的事:乘法和加法。我们鼓励读者查看这个函数的 trace,并在下方评论你的发现!

torch.compile 的运行时架构

虽然理论上我们知道编译函数时发生了什么,但看到实际发生的过程同样重要。让我们看看反映 torch.compile 运行时架构的 CPU 侧层级。

TorchDynamo Cache Lookup 是 Dynamo 检查当前调用是否仍然匹配已编译的输入形状、数据类型、设备和张量元数据的地方。如果任何不匹配,Dynamo 会重新编译。即使编译完成后,每次调用都要付出这个代价。

Torch-Compiled Region 是“进入”编译版本的包装器。

AOTDispatcher Runtime Wrapper Prologue 是 AOT Autograd 的运行时包装器。即使我们这里不需要梯度,AOTDispatcher 始终在堆栈中处理张量元数据、视图追踪,如果 requires_grad 为真,还会设置反向传播。

## Call CompiledFxGraph 是实际生成的代码运行的地方。“CompiledFxGraph”后面的字符串是 FX 图的内容哈希。三个 active 步骤中哈希相同,确认了缓存命中。

你可以在磁盘上 /tmp/torchinductor_<user>/fxgraph 下根据这个哈希找到生成的代码,当你想要阅读 Inductor 实际产生的 Triton/C++ 代码时,这非常有用。

CUDA 启动次数是否减少了一半?

图21: 编译后的 matmul trace 显示每个步骤启动两个 GPU 内核:Device-to-Device memcpy 和 GEMM
图21: 编译后每个步骤仍然启动两个 GPU 内核:一个 Device-to-Device memcpy 和一个 GEMM

查看图21中的 trace,我们很高兴地注意到每个步骤只有一个 cudaLaunchKernel。但这个观察与我们在 GPU trace 中看到的直接矛盾。每个步骤仍然启动了两种内核,即 Memcpy DtoD(设备到设备)》和 GEMM。回过头看 CPU trace,我们完全忽略了 cudaMemcpyAsync` 调度。

addmm 计算 out = α·A·B + β·C,而 cuBLAS 的带偏置加法的 GEMM epilogue 写入到目标缓冲区,该缓冲区中必须已经包含偏置。Epilogue 可以看作是 GEMM 之后发生的所有操作。在深度学习领域,我们经常遇到 GEMM-Epilogue 组合,如激活函数、偏置加法、归一化等等。这就是为什么存在多种 cuBLAS GEMM-with- 内核变体。

如果你使用不同的 torch.compile 的 mode,你会注意到不同的内核变体被启动。你可以自己尝试一下,并在下方评论你的观察!

所以 Inductor 生成的代码是:

  • out = copy(C) ← 这就是那个 DtoD memcpy(32 MB,耗时约 33 µs)
  • out = α·(A·B) + β·out ← 带有 α=β=1 的 GEMM,将偏置加法融合到回写中

数学上结果仍然相同。偏置加法并不是免费的,我们预先付出了 memcpy 的代价,再加上稍微更昂贵的 GEMM epilogue。

人们可能希望出现“真正的”融合——x·w + b(这里 out = α·A·B + β·C)塌缩成一个无需额外内存传输的内核——但这并没有发生。Inductor 保留了两种内存触及操作,只是将偏置复制重标记为 memcpy,将加法重标记为 GEMM epilogue。

真正的融合实现会跳过 memcpy。这正是 FlashAttention 风格的手写内核所做的,也是 Inductor 通过 Triton 代码生成能做到的,但对于 4096×4096 bf16 矩阵乘法,Inductor 显然认为“使用 cuBLAS,通过 epilogue 设置处理偏置”是最佳路径。

CPU 开销反而增加了

在比较 eager 和编译运行的时候,这是最容易忽略的一点:

步骤eager 耗时 (ms)compile 耗时 (ms)
#20.10.2
#30.070.1
#40.070.1

编译版本每步的 CPU 开销大约是 eager 的两倍。这是因为每次调用都要走完整的 Dynamo > AOTAutograd > Inductor 栈,再加上本来就已经有的 aten::addmm 调度。编译管线是为包含几十个操作的机器学习模型设计的,这些模型中每次调用的开销可以被摊薄(对于单个操作来说,这是一种税收)。

torch.compile 有一个 mode 参数。留给读者作为课后作业:阅读文档,想出一个能降低 CPU 开销的 mode。🤗


Trace 阅读速查表

这里是我们刚才走过的模式的快速参考。思路是:如果在 trace 中看到这个,通常意味着什么。

Profiler 表格

你看到的通常意味着什么
Self CPU time total ≫ Self CUDA time total(CPU 以毫秒计,GPU 以微秒计)开销受限。CPU 花费在调度的上的时间比 GPU 花费在计算上的多。让工作更大(更大的矩阵、批处理操作)或融合调用。
Self CPU time total ≈ Self CUDA time total,两者都以毫秒计计算受限。GPU 是瓶颈,这通常是想要的状态。
某个事件在 CUDA total 上占主导这就是你的热点。从这里开始优化。
某个事件有巨大的 # of Calls即使每次调用很便宜,也可能是潜在瓶颈。检查是否可以融合或批处理。
某行的 CPU total ≫ Self CPU大部分成本在子事件中。深入嵌套事件,而不是父事件。

CPU 跑道

你看到的通常意味着什么
第一个 ProfileStep 比其他宽得多冷启动开销:工作空间分配、cuBLAS 启发式、惰性模块加载。添加预热迭代和/或 schedule 的 warmup 参数。
record_function("...") 开始和内部第一个 aten::* 之间有很大间隙同样的冷启动税,只是放大了。注释已经进入,但调度尚未发生。
cudaLaunchKernel 前有 cudaOccupancyMaxActiveBlocksPerMultiprocessor一个重量级、自适应启动的内核(GEMM、卷积等)。cuBLAS 询问驱动程序在一个 SM 上能放多少块,以选择内核变体。
没有先前占用率查询的 cudaLaunchKernel一个逐元素或归约内核,具有固定的、资源轻量的足迹。无需规划。
active 窗口末尾有很长的 cudaDeviceSynchronizeprofiler 正在刷新事件。其持续时间主要是 GPU 完成待处理工作,不是真正的 CPU 成本。一个覆盖极少量 GPU 工作的同步是经典的开销受限症状。
一个你并非自己编写的 cudaMemcpyAsync通常是隐藏的设备到设备复制。当 addmm 在 GEMM epilogue 之前用偏置填充其目标缓冲区时很常见。

GPU 跑道

你看到的通常意味着什么
GPU 跑道上的 Activity Buffer Requestprofiler 正在分配/重新填充自己的事件缓冲区。第一个通常解释了最初的 CPU↔GPU 跑道偏移。
一个步骤中两个内核之间的间隙可能是执行中间另一个缓冲区请求。通过运行更多迭代来确认:如果只出现一次,那就是 profiler 的行为,不是你的代码。
同一个内核在不同步骤中的计时不同GPU 时钟、热管理、电源管理、驱动侧后台任务。读 trace,而不仅仅是平均值。
一个像 ampere_bf16_s16816gemm_... 这样的内核名称matmul 的实际 cuBLAS GPU 工作。对于相同的形状和数据类型,eager 和编译模式下的内核名称通常相同。
GEMM 之前的 Memcpy DtoDaddmm epilogue 的偏置复制。“融合”在调度器层面,不在内核中。

调度链

你看到的通常意味着什么
ProfileStep#N → <record_function name> → aten::* → aten::mm / aten::bmm / aten::add规范的嵌套调用层级。Self 时间排除子事件;Total 时间包括它们。
aten::matmul 解析为 aten::mm2D × 2D 矩阵乘法。
aten::matmul 解析为 aten::bmm(带额外的 CUDA 运行时调用)3D+ 张量上的批处理 matmul。cuBLAS 做了更多的启发式工作来选择变体。
aten::addmm(b, x, w) 取代了单独的 aten::add + aten::mm 对调度器层级的算子融合。GPU 内核仍然是相同的 GEMM,偏置加法被折叠到 epilogue 中。

torch.compile

你看到的通常意味着什么
CPU 跑道上有 Torch-Compiled Region: K/M 行你正处在一个编译函数内部。
每个步骤都有 TorchDynamo Cache LookupDynamo 正在验证形状/数据类型/设备与缓存的编译是否匹配。每次调用都要付出这个代价,即使编译完成后。
即使没有梯度也有 AOTDispatcher Runtime Wrapper PrologueAOTAutograd 的运行时包装器始终在堆栈中,处理张量元数据和视图追踪。
## Call CompiledFxGraph <hash> 且不同步骤中 hash 相同生成的代码缓存命中。生成的源代码位于 /tmp/torchinductor_<user>/fxgraph/<hash>。
对于微小操作,torch.compile 下的每步 CPU 时间高于 eager这是预期的。Dynamo → AOTAutograd → Inductor 栈是一种税收,只有通过许多操作才能摊薄。

结论

我们从一个小型的 matmul + add 开始,以此为契机学习如何读取 PyTorch profiler。在这个过程中,我们学到了一些能很好地迁移到更大工作负载上的心智模型。这是 PyTorch 性能分析(Profiling PyTorch) 系列的第一站。在后续的文章中,我们将逐步离开这个双操作玩具,沿着复杂度阶梯向上走,看看更大的构建块,最终分析真正的模型。

感谢 Noe Flandre、Suvaditya Mukherjee 和 Vidit Ostwal 对本文早期草稿的审阅!


原文链接:Hugging Face
本文由前途科技编辑整理

标签:PyTorchCUDA

想了解 AI 如何助力您的企业?

免费获取企业 AI 成熟度诊断报告,发现转型机会

//

24小时热榜

鸽子靠肝脏中的磁感细胞导航
TOP1

鸽子靠肝脏中的磁感细胞导航

代码不珍贵,AI才值得
TOP2

代码不珍贵,AI才值得

3

我把手机相册改造成了自主AI代理

3小时前
4

技术浪潮如何重塑企业战略

3小时前
5

流利不等于得体:AI社交语用失败本质

3小时前
流利不等于得体:AI社交语用失败本质
6

共情之战:AI时代,我们真的赢了吗?

3小时前
7

游戏AI拼的不是智商,是演技

3小时前
8

每月100美元AI,如何花出800美元的效果

3小时前
热门标签
大模型AgentRAG微调私有化部署Prompt EngineeringChatGPTClaudeDeepSeek智能客服知识管理内容生成代码辅助数据分析金融零售制造医疗教育AI 战略数字化转型ROI 分析OpenAIAnthropicGoogle

关注公众号

前途科技微信公众号

扫码关注,获取最新 AI 资讯

免费获取 AI 落地指南

3 步完成企业诊断,获取专属转型建议

已有 200+ 企业完成诊断

前途科技前途科技
服务关于快讯技术商业报告
前途科技微信公众号

微信公众号

扫码关注

Copyright © 2026 AccessPath.com, 前途国际科技咨询(北京)有限公司,版权所有。|京ICP备17045010号-1|京公网安备 11010502033860号|隐私政策|服务条款