Neste tutorial, aprenderemos a usar nn.parallel.DistributedDataParallel
Para treinar nossos modelos em várias GPUs. Vamos dar um exemplo mínimo de treinamento de um classificador de imagem e ver como podemos acelerar o treinamento.
Vamos começar com algumas importações.
import torch
import torchvision
import torchvision.transforms as transforms
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import time
Usaremos o CIFAR10 em todos os nossos experimentos com um tamanho de lote de 256.
def create_data_loader_cifar10():
transform = transforms.Compose(
(
transforms.RandomCrop(32),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))))
batch_size = 256
trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size,
shuffle=True, num_workers=10, pin_memory=True)
testset = torchvision.datasets.CIFAR10(root='./data', train=False,
download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=batch_size,
shuffle=False, num_workers=10)
return trainloader, testloader
Primeiro treinaremos o modelo em uma única GPU da NVIDIA A100 para 1 época. Material de pytorch padrão aqui, nada de novo. O tutorial é baseado no Tutorial oficial dos documentos de Pytorch.
def train(net, trainloader):
print("Start training...")
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)
epochs = 1
num_of_batches = len(trainloader)
for epoch in range(epochs):
running_loss = 0.0
for i, data in enumerate(trainloader, 0):
inputs, labels = data
images, labels = inputs.cuda(), labels.cuda()
optimizer.zero_grad()
outputs = net(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
print(f'(Epoch {epoch + 1}/{epochs}) loss: {running_loss / num_of_batches:.3f}')
print('Finished Training')
O test
A função é definida da mesma forma. O script principal apenas montará tudo:
if __name__ == '__main__':
start = time.time()
PATH = './cifar_net.pth'
trainloader, testloader = create_data_loader_cifar10()
net = torchvision.models.resnet50(False).cuda()
start_train = time.time()
train(net, trainloader)
end_train = time.time()
torch.save(net.state_dict(), PATH)
test(net, PATH, testloader)
end = time.time()
seconds = (end - start)
seconds_train = (end_train - start_train)
print(f"Total elapsed time: {seconds:.2f} seconds, \
Train 1 epoch {seconds_train:.2f} seconds")
Usamos um resnet50 para medir o desempenho de uma rede de tamanho decente.
Agora vamos treinar o modelo:
$ python -m train_1gpu
Accuracy of the network on the 10000 test images: 27 %
Total elapsed time: 69.03 seconds, Train 1 epoch 13.08 seconds
Ok, hora de chegar ao trabalho de otimização.
O código está disponível em Girub. Se você planeja solidificar seu conhecimento pytorch, há dois livros incríveis que recomendamos: Aprendizado profundo com pytorch das publicações de Manning e Aprendizado de máquina com Pytorch e Scikit-Learn Por Sebastian Raschka. Você sempre pode usar o código de desconto de 35% Blaisummer21 Para todos os produtos de Manning.
Torch.nn.dataparallelal: sem dor, sem ganho
O DataParallelel é um processo único, multi-thread e só funciona em uma única máquina. Para cada GPU, usamos o mesmo modelo para fazer o passe para a frente. Espalhamos os dados por toda a GPUs e realizamos passes para a frente em cada um deles. Essencialmente, o que acontece é que o tamanho do lote é dividido em todo o número de trabalhadores.
Neste caso de uso, essa funcionalidade não forneceu ganho. Isso ocorre porque o sistema que estou usando possui um gargalo de CPU e disco rígido. Outras máquinas que possuem disco muito rápido e CPU, mas lutam com a velocidade da GPU (gargalo da GPU) podem se beneficiar dessa funcionalidade.
Na prática, a única mudança que você precisa fazer no código é o seguinte:
net = torchvision.models.resnet50(False)
if torch.cuda.device_count() > 1:
print("Let's use", torch.cuda.device_count(), "GPUs!")
net = nn.DataParallel(net)
Ao usar nn.DataParallel
o tamanho do lote deve ser divisível pelo número de GPUs.
nn.DataParallel
divide o lote e o processa independentemente em todos os GPUs disponíveis. Em cada passagem para a frente, o módulo é replicado em cada GPU, que é uma sobrecarga significativa. Cada réplica lida com uma parte do lote (Batch_size / GPUs). Durante o passe para trás, os gradientes de cada réplica são somados no módulo original.
Mais informações sobre nosso anterior artigo no paralelismo de dados versus modelo.
Uma boa prática ao usar várias GPUs é definir com antecedência as GPUs que seu script vai usar:
import os
os.environ('CUDA_VISIBLE_DEVICES') = "0,1"
Isso deve ser feito antes Qualquer outra importação relacionada a Cuda.
Mesmo do pytorch documentação É óbvio que esta é uma estratégia muito ruim:
É recomendado usar
nn.DistributedDataParallel
em vez dessa classe, para fazer treinamento com várias GPU, mesmo que haja apenas um nó único.
O motivo é que o distributedDataparall usa um processo por trabalhador (GPU) enquanto o Dataparallelel encapsula toda a comunicação de dados em um único processo.
De acordo com os documentos, os dados podem estar em qualquer dispositivo antes de serem passados para o modelo.
No meu experimento, Dataparallel foi Mais devagar do que treinar em uma única GPU. Mesmo com 4 GPUs. Depois de aumentar o número de trabalhadores, reduzi o tempo, mas ainda pior do que uma única GPU. Eu medro e relato o tempo necessário para treinar o modelo para uma época, ou seja, imagens de 50k 32×32.
Nota final: para comparar o desempenho com uma única GPU, multipliquei o tamanho do lote pelo número de trabalhadores, ou seja, 4 por 4 GPUs. Caso contrário, é mais de 2x mais lento.
Isso nos leva ao tópico hardcore de dados de dados distribuídos.
O código está disponível em Girub. Você sempre pode apoiar nosso trabalho por compartilhamento de mídia social, fazendo um doaçãoe comprando nosso livro e E-Course.
Pytorch distribuiu paralelo de dados
Os dados distribuídos paralelos são multiprocessos e trabalham para treinamento único e de várias máquinas. Em Pytorch, nn.parallel.DistributedDataParallel
Paralelize o módulo dividindo a entrada nos dispositivos especificados. Este módulo também é adequado para treinamento em vários nó e multi-GPU. Aqui, experimentei apenas um único nó (1 máquina com 4 GPUs).
A principal diferença aqui é que cada GPU é tratada por um processo. Os parâmetros nunca são transmitidos entre processos, apenas gradientes.
O módulo é replicado em cada máquina e em cada dispositivo. Durante o passe para a frente, cada trabalhador (GPU) processa os dados e calcula seu próprio gradiente localmente. Durante o passe para trás, são calculados gradientes de cada nó. Por fim, cada trabalhador executa uma atualização de parâmetro e envia para todos os outros nós a atualização de parâmetros computados.
O módulo executa um All-Reduce Entende os gradientes e assume que eles serão modificados pelo otimizador em todos os processos da mesma maneira.
Abaixo estão as diretrizes para converter seu script de GPU único em treinamento multi-GPU.
Etapa 1: Inicialize os processos de aprendizado distribuídos
def init_distributed():
dist_url = "env://"
rank = int(os.environ("RANK"))
world_size = int(os.environ('WORLD_SIZE'))
local_rank = int(os.environ('LOCAL_RANK'))
dist.init_process_group(
backend="nccl",
init_method=dist_url,
world_size=world_size,
rank=rank)
torch.cuda.set_device(local_rank)
dist.barrier()
Esta inicialização funciona quando lançamos nosso script com torch.distributed.launch
(Pytorch 1.7 e 1.8) ou torch.run
(Pytorch 1.9+) de cada nó (aqui 1).
Etapa 2: Enrole o modelo usando DDP
net = torchvision.models.resnet50(False).cuda()
net = nn.SyncBatchNorm.convert_sync_batchnorm(net)
local_rank = int(os.environ('LOCAL_RANK'))
net = nn.parallel.DistributedDataParallel(net, device_ids=(local_rank))
Se cada processo tiver a classificação local correta, tensor.cuda()
ou model.cuda()
pode ser chamado corretamente em todo o script.
Etapa 3: use um amostrador distribuído no seu Dataloader
import torch
from torch.utils.data.distributed import DistributedSampler
from torch.utils.data import DataLoader
import torch.nn as nn
def create_data_loader_cifar10():
transform = transforms.Compose(
(
transforms.RandomCrop(32),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))))
batch_size = 256
trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
download=True, transform=transform)
train_sampler = DistributedSampler(dataset=trainset, shuffle=True)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size,
sampler=train_sampler, num_workers=10, pin_memory=True)
testset = torchvision.datasets.CIFAR10(root='./data', train=False,
download=True, transform=transform)
test_sampler =DistributedSampler(dataset=testset, shuffle=True)
testloader = torch.utils.data.DataLoader(testset, batch_size=batch_size,
shuffle=False, sampler=test_sampler, num_workers=10)
return trainloader, testloader
No modo distribuído, chamando o data_loader.sampler.set_epoch()
método no início de cada época antes criando o DataLoader
O iterador é necessário para fazer o rebaixamento funcionar corretamente em várias épocas. Caso contrário, a mesma ordem será sempre usada.
def train(net, trainloader):
print("Start training...")
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)
epochs = 1
num_of_batches = len(trainloader)
for epoch in range(epochs):
trainloader.sampler.set_epoch(epoch)
De uma forma mais geral:
for epoch in range(epochs):
data_loader.sampler.set_epoch(epoch)
train_one_epoch(...)
Boas práticas para DDP
Quaisquer métodos que baixem os dados devem ser isolados no processo mestre. Quaisquer métodos que executem E/S de arquivo devem ser isolados no processo mestre.
import torch.distributed as dist
import torch
def is_dist_avail_and_initialized():
if not dist.is_available():
return False
if not dist.is_initialized():
return False
return True
def save_on_master(*args, **kwargs):
if is_main_process():
torch.save(*args, **kwargs)
def get_rank():
if not is_dist_avail_and_initialized():
return 0
return dist.get_rank()
def is_main_process():
return get_rank() == 0
Com base nesta função, você pode ter certeza de que alguns comandos são executados apenas no processo principal:
if is_main_process():
Inicie o script usando torch.distributed.launch
ou torch.run
$ python -m torch.distributed.launch --nproc_per_node=4 main_script.py
Erros ocorrerão. Certifique -se de matar qualquer processo de treinamento distribuído indesejado por:
$ kill $(ps aux | grep main_script.py | grep -v grep | awk '{print $2}')
Substituir main_script.py
com o seu nome de script. Outra opção mais simples é $ kill -9 PID
. Caso contrário, você pode ir a coisas mais avançadas, como matar todos os processos relacionados à GPU da CUDA quando não são mostrados em nvidia-smi
lsof /dev/nvidia* | awk '{print $2}' | xargs -I {} kill {}
Isso é apenas para o caso em que você não consegue encontrar o PID do processo em execução na GPU.
Um livro muito bom sobre treinamento distribuído é Aprendizado de máquina distribuído com Python: acelerando o treinamento e servir modelo com sistemas distribuídos por Guanhua Wang.
Treinamento de precisão mista em Pytorch
A precisão mista combina o ponto flutuante (FP) 16 e FP 32 em diferentes etapas do treinamento. O treinamento FP16 também é conhecido como treinamento de meia precisão, que vem com desempenho inferior. A precisão mista automática é literalmente a melhor dos dois mundos: tempo de treinamento reduzido com desempenho comparável ao FP32.
Em Treinamento de precisão mistatodas as operações computacionais (passagem para a frente, passe para trás, gradientes de peso) veja a versão fundida do FP16. Para fazer isso, é necessária uma cópia do FP32 do peso, além de calcular a perda no FP32 após o passe para a frente no FP16 para evitar o fluxo acima e abaixo. Os gradientes de peso são lançados de volta ao FP32 para atualizar os pesos do modelo. Além disso, a perda no FP32 é ampliada para evitar o subfluxo de gradiente antes de ser lançado para o FP16 para executar o passe para trás. Como compensação, os pesos FP32 serão reduzidos pelo mesmo escalar antes da atualização do peso.
https://www.youtube.com/watch?v=i1fibtdhjig
Aqui estão as mudanças na função do trem:
fp16_scaler = torch.cuda.amp.GradScaler(enabled=True)
for epoch in range(epochs):
trainloader.sampler.set_epoch(epoch)
running_loss = 0.0
for i, data in enumerate(trainloader, 0):
inputs, labels = data
images, labels = inputs.cuda(), labels.cuda()
optimizer.zero_grad()
with torch.cuda.amp.autocast():
outputs = net(images)
loss = criterion(outputs, labels)
fp16_scaler.scale(loss).backward()
fp16_scaler.step(optimizer)
fp16_scaler.update()
Resultados e resumir
Em um mundo paralelo utópico, N trabalhadores dariam uma aceleração de N. Aqui você vê que precisa de 4 GPUs no modo distribuídoDataparallel para obter uma aceleração de 2x. O treinamento misto de precisão normalmente fornece uma aceleração substancial, mas a GPU A100 e outras arquiteturas de GPU baseadas em amperes têm ganhos limitados (até onde eu li on-line).
Resultados abaixo relatam o tempo em segundos para 1 Epoch no CIFAR10 com um resnet50 (tamanho do lote 256, NVIDIA A100 40GB GPU Memory):
Tempo em segundos | |
GPU único (linha de base) | 13.2 |
DataParallelel 4 GPUs | 19.1 |
DistributedDataParallel 2 GPUS | 9.8 |
DistributedDataParallel 4 GPUS | 6.1 |
DistributedDataParallel 4 GPUS + Precisão mista | 6.5 |
Uma nota muito importante aqui é que DistribuídoDataparallelal usa um tamanho eficaz em lote de 4*256 = 1024, então faz Menos atualizações de modelo. É por isso que acredito que ele obtém uma precisão de validação muito menor (14% em comparação com 27% na linha de base).
O código está disponível em Girub Se você quiser brincar. Os resultados variam com base no seu hardware. Sempre há o caso de perdi algo em meus experimentos. Se você encontrar uma falha, por favor me avise em nosso Discord Server.
Essas descobertas proporcionariam a você um sólido começo para treinar seus modelos. Espero que você os ache úteis. Nos apoia por compartilhamento de mídia social, fazendo um doaçãocomprando nosso livro ou E-Course. Sua ajuda nos ajudaria a produzir mais conteúdo gratuito e conteúdo de IA acessível. Como sempre, obrigado pelo seu interesse em nosso blog.
* Divulgação: Observe que alguns dos links acima podem ser links de afiliados e, sem nenhum custo adicional, ganharemos uma comissão se você decidir fazer uma compra depois de clicar.

Luis es un experto en Ciberseguridad, Computación en la Nube, Criptomonedas e Inteligencia Artificial. Con amplia experiencia en tecnología, su objetivo es compartir conocimientos prácticos para ayudar a los lectores a entender y aprovechar estas áreas digitales clave.