Análisis y optimización del rendimiento del modelo PyTorch.

¡Usa tus manitas para hacer una fortuna y dale me gusta!

Entrenar modelos de aprendizaje profundo, especialmente modelos grandes, puede suponer un gasto elevado. Uno de los principales métodos que podemos utilizar para gestionar estos costes es la optimización del rendimiento. La optimización del rendimiento es un proceso iterativo en el que buscamos continuamente oportunidades para mejorar el rendimiento de las aplicaciones y luego explotar esas oportunidades. En artículos anteriores (como aquí) hemos enfatizado la importancia de contar con las herramientas adecuadas para realizar este análisis. La elección de la herramienta puede depender de muchos factores, incluido el tipo de acelerador de entrenamiento (por ejemplo, GPU, HPU u otro) y el marco de entrenamiento.

alternativo

El objetivo de este artículo [1] es el uso de PyTorch para el entrenamiento en GPU. Más específicamente, nos centraremos en el generador de perfiles de rendimiento integrado de PyTorch, PyTorch Profiler, y una de las formas de ver sus resultados, el complemento PyTorch Profiler TensorBoard.

Este artículo no pretende reemplazar la documentación oficial de PyTorch sobre PyTorch Profiler ni el uso del complemento TensorBoard para analizar los resultados del generador de perfiles. Nuestro objetivo es mostrar cómo utilizar estas herramientas en su proceso de desarrollo diario.

Desde hace algún tiempo, he estado particularmente interesado en una sección del tutorial del complemento TensorBoard. Este tutorial presenta un modelo de clasificación (basado en la arquitectura Resnet) entrenado en el popular conjunto de datos Cifar10. A continuación, demostramos cómo utilizar los complementos PyTorch Profiler y TensorBoard para identificar y solucionar cuellos de botella en los cargadores de datos.

alternativo

Si miras de cerca, verás que la utilización optimizada de la GPU es del 40,46%. Ahora bien, no hay manera de endulzar esto: estos resultados son absolutamente terribles y deberían mantenerte despierto por la noche. Como hemos ampliado en el pasado, la GPU es el recurso más caro de nuestra máquina de entrenamiento y nuestro objetivo debería ser maximizar su utilización. Un resultado de utilización del 40,46% representa a menudo una oportunidad importante para acelerar la formación y ahorrar costes. ¡Por supuesto que podemos hacerlo mejor! En esta publicación de blog, intentaremos hacerlo mejor. Primero intentaremos reproducir los resultados proporcionados en el tutorial oficial y veremos si podemos usar las mismas herramientas para mejorar aún más el rendimiento del entrenamiento.

Ejemplo de juguete

El siguiente bloque de código contiene el bucle de entrenamiento definido en el tutorial del complemento TensorBoard, con dos modificaciones menores:

  1. Usamos un conjunto de datos falso con las mismas propiedades y comportamiento que el conjunto de datos CIFAR10 utilizado en este tutorial.
  2. Inicializamos torch.profiler.schedule, configurando el indicador de calentamiento en 3 y el indicador de repetición en 1. Descubrimos que un ligero aumento en el número de pasos de precalentamiento mejoró la estabilidad de los resultados analíticos.
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

La GPU utilizada en este tutorial es Tesla V100-DGXS-32GB. En este artículo, intentamos reproducir los resultados de rendimiento de este tutorial y mejorarlos utilizando una instancia Amazon EC2 p3.2xlarge que contiene una GPU Tesla V100-SXM2 de 16 GB. Aunque comparten la misma arquitectura, existen algunas diferencias entre estas dos GPU. Usamos la imagen de Docker de AWS PyTorch 2.0 para ejecutar el script de entrenamiento. Los resultados de rendimiento del script de entrenamiento que se muestran en la página de descripción general del visor de TensorBoard se muestran a continuación:

alternativo

Primero notamos que, a diferencia del tutorial, la página de descripción general de nuestro experimento (torch-tb-profiler versión 0.4.1) combina los tres pasos de análisis en uno. Por lo tanto, el tiempo de paso total promedio es de 80 ms, no los 240 ms informados. Esto se puede ver claramente en la pestaña Seguimiento (que, según nuestra experiencia, casi siempre proporciona informes más precisos), donde cada paso dura aproximadamente 80 milisegundos.

alternativo

Tenga en cuenta que nuestro punto de partida de 31,65 % de utilización de GPU y tiempo de paso de 80 ms es diferente de los puntos de partida de 23,54 % y 132 ms presentados en el tutorial, respectivamente. Esto puede deberse a diferencias en los entornos de entrenamiento, incluido el tipo de GPU y la versión de PyTorch. También notamos que, si bien los resultados de referencia del tutorial diagnosticaron claramente el problema de rendimiento como un cuello de botella en el DataLoader, nuestros resultados no lo hicieron. A menudo encontramos que los cuellos de botella en la carga de datos se disfrazan como un alto porcentaje de "CPU Exec" u "Otro" en la pestaña Descripción general.

Optimización 1: carga de datos multiproceso

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

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

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

alternativo

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

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

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

优化2:内存固定

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

alternativo

为了解决这个问题,我们将应用 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)

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

alternativo

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

优化3:增加批量大小

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

alternativo

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

alternativo

虽然 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 倍,并且红色碍眼的现象几乎消失了:

alternativo

我们现在的 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 利用率的有用资源:

alternativo

该报告中最引人注目的细节之一是未使用 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%。

alternativo

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

alternativo

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

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

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

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

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

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

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

alternativo

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

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

alternativo

总结

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

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

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

Reference

[1]

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

Este artículo está publicado por mdnice multiplataforma.

Supongo que te gusta

Origin blog.csdn.net/swindler_ice/article/details/132397302
Recomendado
Clasificación