评价此页

性能调优指南#

创建日期:2020年9月21日 | 最近更新:2025年7月9日 | 最近验证:2024年11月5日

作者Szymon Migacz

性能调优指南是一系列优化方法和最佳实践,可以加速 PyTorch 中深度学习模型的训练和推理。所介绍的技术通常只需修改几行代码即可实现,并可应用于所有领域的各种深度学习模型。

您将学到什么
  • 针对 PyTorch 模型的通用优化技术

  • 特定于 CPU 的性能优化

  • GPU 加速策略

  • 分布式训练优化

先决条件
  • PyTorch 2.0 或更高版本

  • Python 3.8 或更高版本

  • 具备 CUDA 能力的 GPU(推荐用于 GPU 优化)

  • Linux、macOS 或 Windows 操作系统

概述#

性能优化对于高效的深度学习模型训练和推理至关重要。本教程涵盖了一套全面的技术,用于在不同的硬件配置和用例中加速 PyTorch 工作负载。

通用优化#

import torch
import torchvision

启用异步数据加载和增强#

torch.utils.data.DataLoader 支持在单独的工作子进程中进行异步数据加载和数据增强。DataLoader 的默认设置是 num_workers=0,这意味着数据加载是同步的,并且在主进程中完成。结果主训练进程必须等待数据可用才能继续执行。

设置 num_workers > 0 可以启用异步数据加载,并使训练和数据加载重叠。num_workers 应该根据工作负载、CPU、GPU 和训练数据的位置进行调整。

DataLoader 接受 pin_memory 参数,默认为 False。使用 GPU 时,最好设置 pin_memory=True,这会指示 DataLoader 使用锁页内存(pinned memory),从而实现从主机到 GPU 更快且异步的内存复制。

为验证或推理禁用梯度计算#

PyTorch 会保存所有涉及需要梯度的张量操作的中间缓冲区。通常验证或推理不需要梯度。torch.no_grad() 上下文管理器可用于在指定的代码块内禁用梯度计算,这会加速执行并减少所需的内存量。torch.no_grad() 也可以用作函数装饰器。

为直接跟随 Batch Norm 的卷积禁用偏置#

torch.nn.Conv2d() 具有 bias 参数,默认为 True(对于 Conv1dConv3d 也是如此)。

如果 nn.Conv2d 层后面直接跟着 nn.BatchNorm2d 层,则卷积中的偏置是不需要的,应改用 nn.Conv2d(..., bias=False, ....)。偏置是不需要的,因为在第一步中,BatchNorm 会减去均值,这实际上抵消了偏置的影响。

这也适用于一维和三维卷积,只要 BatchNorm(或其他归一化层)在与卷积偏置相同的维度上进行归一化。

torchvision 中提供的模型已经实现了这种优化。

使用 parameter.grad = None 代替 model.zero_grad() 或 optimizer.zero_grad()#

与其调用

model.zero_grad()
# or
optimizer.zero_grad()

来清零梯度,不如使用以下方法

for param in model.parameters():
    param.grad = None

第二个代码片段不会对每个单独参数的内存进行清零,此外随后的反向传播使用赋值而不是加法来存储梯度,这减少了内存操作的数量。

将梯度设置为 None 的数值行为与将其设置为零略有不同,更多详情请参阅 文档

或者,调用 modeloptimizer.zero_grad(set_to_none=True)

融合操作#

逐点操作(Pointwise operations),如元素加法、乘法,以及 sin()cos()sigmoid() 等数学函数,可以合并到单个内核中。这种融合有助于减少内存访问和内核启动次数。通常,逐点操作受内存带宽限制;PyTorch 急切模式(eager-mode)会为每个操作启动一个单独的内核,这涉及从内存加载数据、执行操作(通常不是最耗时的步骤)以及将结果写回内存。

通过使用融合算子,多个逐点操作只需启动一个内核,且数据仅加载和存储一次。这种效率对于激活函数、优化器和自定义 RNN 单元等特别有益。

PyTorch 2 引入了由 TorchInductor 促进的编译模式(compile-mode),TorchInductor 是一个底层编译器,可以自动融合内核。TorchInductor 的能力超越了简单的元素级操作,它能够对符合条件的逐点操作和归约(reduction)操作进行高级融合,以实现优化性能。

在最简单的情况下,可以通过在函数定义上应用 torch.compile 装饰器来启用融合,例如

@torch.compile
def gelu(x):
    return x * 0.5 * (1.0 + torch.erf(x / 1.41421))

有关更高级的用例,请参考 torch.compile 入门

为计算机视觉模型启用 channels_last 内存格式#

PyTorch 支持卷积网络的 channels_last 内存格式。此格式旨在与 AMP 结合使用,以利用 Tensor Core 进一步加速卷积神经网络。

channels_last 的支持是实验性的,但预计可用于标准计算机视觉模型(例如 ResNet-50、SSD)。要将模型转换为 channels_last 格式,请按照 Channels Last 内存格式教程 进行操作。该教程包含有关 转换现有模型 的章节。

检查点中间缓冲区#

缓冲区检查点(Checkpointing)是一种减轻模型训练内存容量负担的技术。它不是存储所有层的输入以在反向传播中计算上游梯度,而是存储少数层的输入,其他输入在反向传播过程中重新计算。减少的内存需求使得可以增加批量大小,从而提高利用率。

应仔细选择检查点目标。最好不要存储重计算成本较低的大型层输出。示例目标层包括激活函数(如 ReLUSigmoidTanh)、上/下采样以及累加深度较小的矩阵-向量运算。

PyTorch 支持原生的 torch.utils.checkpoint API,可自动执行检查点和重计算。

禁用调试 API#

许多 PyTorch API 旨在用于调试,在常规训练运行中应将其禁用

特定于 CPU 的优化#

利用非统一内存访问 (NUMA) 控制#

NUMA 或非统一内存访问是一种用于数据中心机器的内存布局设计,旨在利用具有多个内存控制器和内存块的多插槽(multi-socket)机器中的内存局部性。一般来说,所有深度学习工作负载(训练或推理)在不跨 NUMA 节点访问硬件资源的情况下都会获得更好的性能。因此,推理可以运行多个实例,每个实例运行在一个插槽上,以提高吞吐量。对于单节点上的训练任务,推荐使用分布式训练,使每个训练进程运行在一个插槽上。

在一般情况下,以下命令仅在第 N 个节点的内核上执行 PyTorch 脚本,并避免跨插槽内存访问,以减少内存访问开销。

numactl --cpunodebind=N --membind=N python <pytorch_script>

更详细的描述可以在 这里 找到。

利用 OpenMP#

OpenMP 被用来为并行计算任务带来更好的性能。OMP_NUM_THREADS 是最简单的开关,可用于加速计算。它决定了用于 OpenMP 计算的线程数。CPU 亲和性(affinity)设置控制工作负载如何分布在多个核心上。它会影响通信开销、缓存行失效开销或页面抖动,因此正确的 CPU 亲和性设置会带来性能提升。GOMP_CPU_AFFINITYKMP_AFFINITY 决定了如何将 OpenMP 线程绑定到物理处理单元。详细信息可以在 这里 找到。

通过以下命令,PyTorch 在 N 个 OpenMP 线程上运行任务。

export OMP_NUM_THREADS=N

通常,以下环境变量用于设置使用 GNU OpenMP 实现的 CPU 亲和性。OMP_PROC_BIND 指定线程是否可以在处理器之间移动。将其设置为 CLOSE 可使 OpenMP 线程保持在连续位置分区中的主线程附近。OMP_SCHEDULE 决定 OpenMP 线程如何调度。GOMP_CPU_AFFINITY 将线程绑定到特定的 CPU。一个重要的调优参数是核心钉扎(core pinning),它可以防止线程在多个 CPU 之间迁移,增强数据局部性并最小化核心间通信。

export OMP_SCHEDULE=STATIC
export OMP_PROC_BIND=CLOSE
export GOMP_CPU_AFFINITY="N-M"

Intel OpenMP 运行时库 (libiomp)#

默认情况下,PyTorch 使用 GNU OpenMP (GNU libgomp) 进行并行计算。在 Intel 平台上,Intel OpenMP 运行时库 (libiomp) 提供了 OpenMP API 规范支持。与 libgomp 相比,它有时会带来更多的性能收益。利用环境变量 LD_PRELOAD 可以将 OpenMP 库切换为 libiomp

export LD_PRELOAD=<path>/libiomp5.so:$LD_PRELOAD

与 GNU OpenMP 中的 CPU 亲和性设置类似,libiomp 中提供了环境变量来控制 CPU 亲和性设置。KMP_AFFINITY 将 OpenMP 线程绑定到物理处理单元。KMP_BLOCKTIME 设置线程在完成并行区域执行后,在进入睡眠前应等待的时间(以毫秒为单位)。在大多数情况下,将 KMP_BLOCKTIME 设置为 1 或 0 会产生良好的性能。以下命令显示了 Intel OpenMP 运行时库的常用设置。

export KMP_AFFINITY=granularity=fine,compact,1,0
export KMP_BLOCKTIME=1

切换内存分配器#

对于深度学习工作负载,JemallocTCMalloc 通过尽可能多地重用内存,可以比默认的 malloc 函数获得更好的性能。Jemalloc 是一个通用的 malloc 实现,强调避免碎片和可扩展的并发支持。TCMalloc 也具有多项优化以加速程序执行。其中之一是在缓存中保留内存,以加速常用对象的访问。即使在释放后也保留此类缓存,如果此类内存在稍后被重新分配,也有助于避免昂贵的系统调用。使用环境变量 LD_PRELOAD 来利用其中之一。

export LD_PRELOAD=<jemalloc.so/tcmalloc.so>:$LD_PRELOAD

使用 PyTorch ``DistributedDataParallel``(DDP) 功能在 CPU 上训练模型#

对于小规模模型或受内存限制的模型(如 DLRM),在 CPU 上训练也是一个不错的选择。在具有多个插槽的机器上,分布式训练带来了高效的硬件资源利用,从而加速训练过程。Torch-ccl 使用 Intel(R) oneCCL (集合通信库) 进行了优化,以实现高效的分布式深度学习训练,实现了 allreduceallgatheralltoall 等集合通信,它实现了 PyTorch C10D ProcessGroup API,并可作为外部 ProcessGroup 动态加载。基于 PyTorch DDP 模块中实现的优化,torch-ccl 加速了通信操作。除了对通信内核进行的优化外,torch-ccl 还具有同步计算-通信功能。

特定于 GPU 的优化#

启用 Tensor Core#

Tensor Core 是专为计算矩阵-矩阵乘法操作而设计的硬件,主要用于深度学习和 AI 工作负载。Tensor Core 有特定的精度要求,可以手动调整或通过自动混合精度 (AMP) API 进行调整。

特别地,张量运算可以利用低精度工作负载带来的优势。这可以通过 torch.set_float32_matmul_precision 进行控制。默认格式设置为“highest”,它利用张量数据类型。然而,PyTorch 提供了替代的精度设置:“high”和“medium”。这些选项优先考虑计算速度而非数值精度。

使用 CUDA Graph#

在使用 GPU 时,必须首先从 CPU 启动工作,在某些情况下,CPU 和 GPU 之间的上下文切换会导致资源利用率降低。CUDA Graph 是一种将计算保留在 GPU 内部的方法,而无需支付额外的内核启动和主机同步成本。

# It can be enabled using
torch.compile(m, "reduce-overhead")
# or
torch.compile(m, "max-autotune")

对 CUDA Graph 的支持正在开发中,其使用可能会导致设备内存消耗增加,并且某些模型可能无法编译。

启用 cuDNN 自动调优器#

NVIDIA cuDNN 支持许多算法来计算卷积。自动调优器会运行一个简短的基准测试,并在给定硬件上针对给定输入大小选择性能最佳的内核。

对于卷积网络(目前不支持其他类型),在启动训练循环之前,通过设置以下代码启用 cuDNN 自动调优器

  • 自动调优器的决定可能是非确定性的;不同的运行可能会选择不同的算法。有关更多详细信息,请参阅 PyTorch:可重复性

  • 在极少数情况下,例如输入大小变化很大,最好在禁用自动调优器的情况下运行卷积网络,以避免与为每个输入大小选择算法相关的开销。

避免不必要的 CPU-GPU 同步#

避免不必要的同步,让 CPU 尽可能跑在加速器前面,以确保加速器工作队列包含许多操作。

尽可能避免需要同步的操作,例如

  • print(cuda_tensor)

  • cuda_tensor.item()

  • 内存复制:tensor.cuda(), cuda_tensor.cpu() 及等效的 tensor.to(device) 调用

  • cuda_tensor.nonzero()

  • 依赖于 CUDA 张量操作结果的 Python 控制流,例如 if (cuda_tensor != 0).all()

直接在目标设备上创建张量#

与其调用 torch.rand(size).cuda() 来生成随机张量,不如直接在目标设备上生成输出:torch.rand(size, device='cuda')

这适用于所有创建新张量并接受 device 参数的函数:torch.rand(), torch.zeros(), torch.full() 等。

使用混合精度和 AMP#

混合精度利用 Tensor Core,在 Volta 及更新的 GPU 架构上提供高达 3 倍的整体加速。要使用 Tensor Core,应启用 AMP,且矩阵/张量维度应满足调用使用 Tensor Core 的内核的要求。

要使用 Tensor Core

  • 将大小设置为 8 的倍数(以映射到 Tensor Core 的维度)

    • 更多详情和针对特定层类型的指南,请参阅 深度学习性能文档

    • 如果层大小是根据其他参数导出而不是固定的,仍然可以显式填充(pad),例如 NLP 模型中的词汇表大小

  • 启用 AMP

在输入长度可变的情况下预分配内存#

语音识别或 NLP 模型通常在具有可变序列长度的输入张量上进行训练。可变长度对于 PyTorch 的缓存分配器来说可能会有问题,并可能导致性能降低或意外的内存溢出(OOM)错误。如果一个具有短序列长度的批次后面跟着另一个具有较长序列长度的批次,则 PyTorch 被迫释放先前迭代的中间缓冲区并重新分配新缓冲区。这个过程非常耗时,并会导致缓存分配器碎片化,从而可能导致内存溢出错误。

典型的解决方案是实现预分配。它包含以下步骤

  1. 生成一批具有最大序列长度的输入(通常是随机的,对应于训练数据集中的最大长度或某个预定义阈值)

  2. 使用生成的批次执行前向和反向传播,不要执行优化器或学习率调度器。此步骤预分配了最大尺寸的缓冲区,可在随后的训练迭代中重用

  3. 清零梯度

  4. 继续进行常规训练

分布式优化#

使用高效的数据并行后端#

PyTorch 有两种实现数据并行训练的方法

DistributedDataParallel 提供了更好的性能和到多 GPU 的扩展性。有关更多信息,请参阅 PyTorch 文档中 CUDA 最佳实践的相关章节

如果使用 DistributedDataParallel 和梯度累加进行训练,则跳过不必要的 all-reduce#

默认情况下,torch.nn.parallel.DistributedDataParallel 在每次反向传播后都会执行梯度 all-reduce,以计算参与训练的所有 worker 的平均梯度。如果训练在 N 个步骤上使用梯度累加,那么不需要在每个训练步骤后都进行 all-reduce,只需在最后一次调用 backward 后、执行优化器之前进行 all-reduce 即可。

DistributedDataParallel 提供了 no_sync() 上下文管理器,它可以禁用特定迭代的梯度 all-reduce。no_sync() 应应用于梯度累加的前 N-1 次迭代,最后一次迭代应遵循默认执行并执行所需的梯度 all-reduce。

如果使用 DistributedDataParallel(find_unused_parameters=True),请匹配构造函数中和执行期间的层顺序#

带有 find_unused_parameters=Truetorch.nn.parallel.DistributedDataParallel 会使用模型构造函数中的层和参数顺序来构建用于 DistributedDataParallel 梯度 all-reduce 的存储桶(bucket)。DistributedDataParallel 将 all-reduce 与反向传播重叠。只有当给定存储桶中所有参数的梯度都可用时,才会异步触发特定存储桶的 all-reduce。

为了使重叠量最大化,模型构造函数中的顺序应大致与执行期间的顺序相匹配。如果顺序不匹配,则整个存储桶的 all-reduce 将等待最后到达的梯度,这可能会减少反向传播与 all-reduce 之间的重叠,all-reduce 最终可能会暴露出来,从而减慢训练速度。

带有 find_unused_parameters=False(默认设置)的 DistributedDataParallel 依赖于基于反向传播期间遇到的操作顺序自动形成的存储桶。使用 find_unused_parameters=False 时,无需重新排列层或参数即可获得最佳性能。

在分布式设置中负载均衡工作负载#

负载不均衡通常可能发生在处理序列数据(语音识别、翻译、语言模型等)的模型中。如果一个设备收到的数据批次的序列长度比其余设备的序列长度长,那么所有设备都要等待最后一个完成的 worker。在具有 DistributedDataParallel 后端的分布式设置中,反向传播充当了一个隐式的同步点。

有多种方法可以解决负载均衡问题。其核心思想是在每个全局批次中尽可能均匀地将工作负载分配给所有 worker。例如,Transformer 通过形成具有大致恒定数量的 token(以及批次中可变数量的序列)的批次来解决不均衡;其他模型通过将具有相似序列长度的样本放入存储桶,甚至按序列长度对数据集进行排序来解决不均衡。

结论#

本教程涵盖了一套全面的 PyTorch 模型性能优化技术。主要总结包括

  • 通用优化:启用异步数据加载、为推理禁用梯度、通过 torch.compile 融合操作,以及使用高效的内存格式

  • CPU 优化:利用 NUMA 控制、优化 OpenMP 设置,以及使用高效的内存分配器

  • GPU 优化:启用 Tensor Core、使用 CUDA Graph、启用 cuDNN 自动调优器,并实现混合精度训练

  • 分布式优化:使用 DistributedDataParallel、优化梯度同步,以及平衡各设备间的工作负载

这些优化中有许多只需很少的代码更改即可应用,并能在各种深度学习模型中提供显著的性能提升。

进一步阅读#