PyTorch model performance analysis and optimization

Use your little hands to make a fortune and give it a like!

Training deep learning models, especially large models, can be an expensive expense. One of the main methods we can use to manage these costs is performance optimization. Performance optimization is an iterative process where we continually look for opportunities to improve application performance and then exploit those opportunities. In previous articles (such as here ) we have emphasized the importance of having the appropriate tools to perform this analysis. The choice of tool may depend on many factors, including the type of training accelerator (e.g., GPU, HPU, or other) and the training framework.

alt

The focus of this article [1] is on using PyTorch for training on GPU. More specifically, we'll focus on PyTorch's built-in performance profiler, PyTorch Profiler, and one of the ways to view its results, the PyTorch Profiler TensorBoard plugin.

This article is not intended to replace the official PyTorch documentation on PyTorch Profiler or using the TensorBoard plugin to analyze profiler results. Our goal is to show how to use these tools in your daily development process.

For some time now, I've been particularly interested in one section of the TensorBoard plugin tutorial. This tutorial introduces a classification model (based on Resnet architecture) trained on the popular Cifar10 dataset. Next, we demonstrate how to use PyTorch Profiler and TensorBoard plugins to identify and fix bottlenecks in data loaders.

alt

If you look closely, you'll see that the optimized GPU utilization is 40.46%. Now there's no way to sugarcoat this: these results are absolutely terrible and should keep you up at night. As we have expanded in the past, the GPU is the most expensive resource in our training machine and our goal should be to maximize its utilization. A utilization result of 40.46% often represents a significant opportunity to accelerate training and save costs. Of course, we can do better! In this blog post, we’ll try to do better. We will first try to reproduce the results provided in the official tutorial and see if we can use the same tools to further improve training performance.

Toy example

The following code block contains the training loop defined in the TensorBoard plugin tutorial, with two minor modifications:

  1. We use a fake dataset with the same properties and behavior as the CIFAR10 dataset used in this tutorial.
  2. We initialize torch.profiler.schedule, setting the warmup flag to 3 and the repeat flag to 1. We found that a slight increase in the number of preheating steps improved the stability of the analytical results.
import numpy as np
import torch
import torch.nn
import torch.optim
import torch.profiler
import torch.utils.data
import torchvision.datasets
import torchvision.models
import torchvision.transforms as T
from torchvision.datasets.vision import VisionDataset
from PIL import Image

class FakeCIFAR(VisionDataset):
    def __init__(self, transform):
        super().__init__(root=None, transform=transform)
        self.data = np.random.randint(low=0,high=256,size=(10000,32,32,3),dtype=np.uint8)
        self.targets = np.random.randint(low=0,high=10,size=(10000),dtype=np.uint8).tolist()

    def __getitem__(self, index):
        img, target = self.data[index], self.targets[index]
        img = Image.fromarray(img)
        if self.transform is not None:
            img = self.transform(img)
        return img, target

    def __len__(self) -> int:
        return len(self.data)

transform = T.Compose(
    [T.Resize(224),
     T.ToTensor(),
     T.Normalize((0.50.50.5), (0.50.50.5))])

train_set = FakeCIFAR(transform=transform)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=32
                                           shuffle=True)

device = torch.device("cuda:0")
model = torchvision.models.resnet18(weights='IMAGENET1K_V1').cuda(device)
criterion = torch.nn.CrossEntropyLoss().cuda(device)
optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
model.train()

# train step
def train(data):
    inputs, labels = data[0].to(device=device), data[1].to(device=device)
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

# training loop wrapped with profiler object
with torch.profiler.profile(
        schedule=torch.profiler.schedule(wait=1, warmup=4, active=3, repeat=1),
        on_trace_ready=torch.profiler.tensorboard_trace_handler('./log/resnet18'),
        record_shapes=True,
        profile_memory=True,
        with_stack=True
as prof:
    for step, batch_data in enumerate(train_loader):
        if step >= (1 + 4 + 3) * 1:
            break
        train(batch_data)
        prof.step()  # Need to call this at the end of each step

The GPU used in this tutorial is Tesla V100-DGXS-32GB. In this article, we try to reproduce the performance results of this tutorial and improve it using an Amazon EC2 p3.2xlarge instance containing a Tesla V100-SXM2–16GB GPU. Although they share the same architecture, there are some differences between these two GPUs. We use the AWS PyTorch 2.0 Docker image to run the training script. The performance results for the training script shown in the TensorBoard viewer overview page are shown below:

alt

We first notice that, contrary to the tutorial, the overview page in our experiment (torch-tb-profiler version 0.4.1) combines three analysis steps into one. Therefore, the average total step time is 80 ms, not the reported 240 ms. This can be clearly seen in the Tracking tab (which, in our experience, almost always provides more accurate reporting), where each step takes approximately 80 milliseconds.

alt

Note that our starting point of 31.65% GPU utilization and 80 ms step time is different from the 23.54% and 132 ms starting points introduced in the tutorial respectively. This may be due to differences in training environments, including GPU type and PyTorch version. We also noticed that while the tutorial baseline results clearly diagnosed the performance issue as a bottleneck in the DataLoader, our results did not. We often find that data loading bottlenecks disguise themselves as a high percentage of "CPU Exec" or "Other" in the Overview tab.

Optimization 1: Multi-process data loading

让我们首先应用本教程中所述的多进程数据加载。由于 Amazon EC2 p3.2xlarge 实例有 8 个 vCPU,我们将 DataLoader 工作线程的数量设置为 8 以获得最大性能:

train_loader = torch.utils.data.DataLoader(train_set, batch_size=32
                               shuffle=True, num_workers=8)

本次优化的结果如下所示:

alt

对单行代码的更改使 GPU 利用率提高了 200% 以上(从 31.65% 增加到 72.81%),并将训练步骤时间减少了一半以上(从 80 毫秒减少到 37 毫秒)。

本教程中的优化过程到此结束。虽然我们的 GPU 利用率 (72.81%) 比教程中的结果 (40.46%) 高很多,但我毫不怀疑,像我们一样,您会发现这些结果仍然非常不令人满意。

个人评论,您可以随意跳过:想象一下,如果 PyTorch 在 GPU 上训练时默认应用多进程数据加载,可以节省多少全球资金!确实,使用多重处理可能会产生一些不需要的副作用。尽管如此,必须有某种形式的自动检测算法可以运行,以排除识别潜在问题场景的存在,并相应地应用此优化。

优化2:内存固定

如果我们分析上次实验的 Trace 视图,我们可以看到大量时间(37 毫秒中的 10 毫秒)仍然花费在将训练数据加载到 GPU 上。

alt

为了解决这个问题,我们将应用 PyTorch 推荐的另一个优化来简化数据输入流,即内存固定。使用固定内存可以提高主机到 GPU 数据复制的速度,更重要的是,允许我们使它们异步。这意味着我们可以在 GPU 中准备下一个训练批次,同时在当前批次上运行训练步骤。有关更多详细信息以及内存固定的潜在副作用,请参阅 PyTorch 文档。

此优化需要更改两行代码。首先,我们将 DataLoader 的 pin_memory 标志设置为 True。

train_loader = torch.utils.data.DataLoader(train_set, batch_size=32
                          shuffle=True, num_workers=8, pin_memory=True)

然后我们将主机到设备的内存传输(在训练函数中)修改为非阻塞:

inputs, labels = data[0].to(device=device, non_blocking=True), \
                 data[1].to(device=device, non_blocking=True)

内存固定优化的结果如下所示:

alt

我们的 GPU 利用率现在达到了可观的 92.37%,并且我们的步数时间进一步减少。但我们仍然可以做得更好。请注意,尽管进行了这种优化,性能报告仍然表明我们花费了大量时间将数据复制到 GPU 中。我们将在下面的步骤 4 中再次讨论这一点。

优化3:增加批量大小

对于我们的下一个优化,我们将注意力集中在上一个实验的内存视图上:

alt

该图表显示,在 16 GB 的 GPU 内存中,我们的利用率峰值低于 1 GB。这是资源利用不足的一个极端例子,通常(尽管并非总是)表明有提高性能的机会。控制内存利用率的一种方法是增加批处理大小。在下图中,我们显示了将批处理大小增加到 512(内存利用率增加到 11.3 GB)时的性能结果。

alt

虽然 GPU 利用率指标没有太大变化,但我们的训练速度显着提高,从每秒 1200 个样本(批量大小 32 为 46 毫秒)到每秒 1584 个样本(批量大小 512 为 324 毫秒)。

注意:与我们之前的优化相反,增加批量大小可能会对训练应用程序的行为产生影响。不同的模型对批量大小的变化表现出不同程度的敏感度。有些可能只需要对优化器设置进行一些调整即可。对于其他人来说,调整到大批量可能会更困难甚至不可能。请参阅上一篇文章,了解大批量训练中涉及的一些挑战。

优化4:减少主机到设备的复制

您可能注意到了我们之前的结果中饼图中代表主机到设备数据副本的红色大碍眼。解决这种瓶颈最直接的方法就是看看是否可以减少每批的数据量。请注意,在图像输入的情况下,我们将数据类型从 8 位无符号整数转换为 32 位浮点数,并在执行数据复制之前应用归一化。在下面的代码块中,我们建议对输入数据流进行更改,其中我们延迟数据类型转换和规范化,直到数据位于 GPU 上:

# maintain the image input as an 8-bit uint8 tensor
transform = T.Compose(
    [T.Resize(224),
     T.PILToTensor()
     ])
train_set = FakeCIFAR(transform=transform)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=1024, shuffle=True, num_workers=8, pin_memory=True)

device = torch.device("cuda:0")
model = torch.compile(torchvision.models.resnet18(weights='IMAGENET1K_V1').cuda(device), fullgraph=True)
criterion = torch.nn.CrossEntropyLoss().cuda(device)
optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
model.train()

# train step
def train(data):
    inputs, labels = data[0].to(device=device, non_blocking=True), \
                     data[1].to(device=device, non_blocking=True)
    # convert to float32 and normalize
    inputs = (inputs.to(torch.float32) / 255. - 0.5) / 0.5
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

由于这一变化,从 CPU 复制到 GPU 的数据量减少了 4 倍,并且红色碍眼的现象几乎消失了:

alt

我们现在的 GPU 利用率达到新高,达到 97.51%(!!),训练速度达到每秒 1670 个样本!让我们看看我们还能做什么。

优化5:将渐变设置为“无”

在这个阶段,我们似乎充分利用了 GPU,但这并不意味着我们不能更有效地利用它。一种流行的优化据说可以减少 GPU 中的内存操作,即在每个训练步骤中将模型参数梯度设置为 None 而不是零。有关此优化的更多详细信息,请参阅 PyTorch 文档。实现此优化所需要做的就是将optimizer.zero_grad调用的set_to_none设置为True:

optimizer.zero_grad(set_to_none=True)

在我们的例子中,这种优化并没有以任何有意义的方式提高我们的性能。

优化6:自动混合精度

GPU 内核视图显示 GPU 内核处于活动状态的时间量,并且可以成为提高 GPU 利用率的有用资源:

alt

该报告中最引人注目的细节之一是未使用 GPU Tensor Core。 Tensor Core 可在相对较新的 GPU 架构上使用,是用于矩阵乘法的专用处理单元,可以显着提高 AI 应用程序性能。它们的缺乏使用可能代表着优化的主要机会。

由于 Tensor Core 是专门为混合精度计算而设计的,因此提高其利用率的一种直接方法是修改我们的模型以使用自动混合精度(AMP)。在 AMP 模式下,模型的部分会自动转换为较低精度的 16 位浮点并在 GPU TensorCore 上运行。

重要的是,请注意,AMP 的完整实现可能需要梯度缩放,但我们的演示中并未包含该梯度缩放。在进行调整之前,请务必查看有关混合精度训练的文档。

下面的代码块演示了启用 AMP 所需的训练步骤的修改。

def train(data):
    inputs, labels = data[0].to(device=device, non_blocking=True), \
                     data[1].to(device=device, non_blocking=True)
    inputs = (inputs.to(torch.float32) / 255. - 0.5) / 0.5
    with torch.autocast(device_type='cuda', dtype=torch.float16):
        outputs = model(inputs)
        loss = criterion(outputs, labels)
    # Note - torch.cuda.amp.GradScaler() may be required  
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()

对 Tensor Core 利用率的影响如下图所示。尽管它继续表明有进一步改进的机会,但仅用一行代码,利用率就从 0% 跃升至 26.3%。

alt

除了提高 Tensor Core 利用率之外,使用 AMP 还可以降低 GPU 内存利用率,从而释放更多空间来增加批处理大小。下图捕获了 AMP 优化且批量大小设置为 1024 后的训练性能结果:

alt

尽管 GPU 利用率略有下降,但我们的主要吞吐量指标进一步增加了近 50%,从每秒 1670 个样本增加到 2477 个样本。我们正在发挥作用!

注意:降低模型部分的精度可能对其收敛产生有意义的影响。与增加批量大小(见上文)的情况一样,使用混合精度的影响会因模型而异。在某些情况下,AMP 会毫不费力地工作。其他时候,您可能需要更加努力地调整自动缩放器。还有一些时候,您可能需要显式设置模型不同部分的精度类型(即手动混合精度)。

优化7:在图形模式下训练

我们将应用的最终优化是模型编译。与默认的 PyTorch 急切执行模式相反,其中每个 PyTorch 操作都“急切”运行,编译 API 将模型转换为中间计算图,然后以最适合底层的方式编译为低级计算内核。

以下代码块演示了应用模型编译所需的更改:

model = torchvision.models.resnet18(weights='IMAGENET1K_V1').cuda(device)
model = torch.compile(model)

模型编译优化结果如下所示:

alt

与之前实验中的 2477 个样本相比,模型编译进一步将我们的吞吐量提高到每秒 3268 个样本,性能额外提升了 32% (!!)。

图编译改变训练步骤的方式在 TensorBoard 插件的不同视图中非常明显。例如,内核视图表明使用了新的(融合的)GPU 内核,而跟踪视图(如下所示)显示了与我们之前看到的完全不同的模式。

alt

总结

在这篇文章中,我们展示了玩具分类模型性能优化的巨大潜力。尽管还有其他性能分析器可供您使用,每种分析器都有其优点和缺点,但我们选择了 PyTorch Profiler 和 TensorBoard 插件,因为它们易于集成。

我们应该强调的是,成功优化的路径将根据训练项目的细节(包括模型架构和训练环境)而有很大差异。在实践中,实现您的目标可能比我们在此介绍的示例更困难。我们描述的一些技术可能对您的表现影响不大,甚至可能使情况变得更糟。我们还注意到,我们选择的精确优化以及我们选择应用它们的顺序有些随意。强烈鼓励您根据项目的具体细节开发自己的工具和技术来实现优化目标。

机器学习工作负载的性能优化有时被视为次要的、非关键的和令人讨厌的。我希望我们已经成功地让您相信,节省开发时间和成本的潜力值得在性能分析和优化方面进行有意义的投资。

Reference

[1]

Source: https://towardsdatascience.com/pytorch-model-performance-analysis-and-optimization-10c3c5822869

This article is published by mdnice multi-platform

Guess you like

Origin blog.csdn.net/swindler_ice/article/details/132397302