.. _sec_auto_para:
Paralelismo Automático
======================
.. raw:: html
.. raw:: html
O MXNet constrói automaticamente gráficos computacionais no *back-end*.
Usando um gráfico computacional, o sistema está ciente de todas as
dependências e pode executar seletivamente várias tarefas não
interdependentes em paralelo para melhorar a velocidade. Por exemplo,
:numref:fig_asyncgraph em :numref:sec_async inicializa duas variáveis
independentemente. Consequentemente, o sistema pode optar por
executá-los em paralelo.
.. raw:: html
.. raw:: html
O PyTorch constrói automaticamente gráficos computacionais no
*back-end*. Usando um gráfico computacional, o sistema está ciente de
todas as dependências e pode executar seletivamente várias tarefas não
interdependentes em paralelo para melhorar a velocidade. Por exemplo,
:numref:`fig_asyncgraph` em :numref:`sec_async` inicializa duas
variáveis independentemente. Consequentemente, o sistema pode optar por
executá-las em paralelo.
.. raw:: html
.. raw:: html
Normalmente, um único operador usará todos os recursos computacionais em
todas as CPUs ou em uma única GPU. Por exemplo, o operador ``dot`` usará
todos os núcleos (e threads) em todas as CPUs, mesmo se houver vários
processadores de CPU em uma única máquina. O mesmo se aplica a uma única
GPU. Consequentemente, a paralelização não é tão útil em computadores de
dispositivo único. Com vários dispositivos, as coisas são mais
importantes. Embora a paralelização seja normalmente mais relevante
entre várias GPUs, adicionar a CPU local aumentará um pouco o
desempenho. Veja, por exemplo, :cite:`Hadjis.Zhang.Mitliagkas.ea.2016`
para um artigo que se concentra no treinamento de modelos de visão
computacional combinando uma GPU e uma CPU. Com a conveniência de uma
estrutura de paralelização automática, podemos atingir o mesmo objetivo
em algumas linhas de código Python. De forma mais ampla, nossa discussão
sobre computação paralela automática concentra-se na computação paralela
usando CPUs e GPUs, bem como a paralelização de computação e
comunicação. Começamos importando os pacotes e módulos necessários.
Observe que precisamos de pelo menos duas GPUs para executar os
experimentos nesta seção.
.. raw:: html
.. raw:: html
.. code:: python
from mxnet import np, npx
from d2l import mxnet as d2l
npx.set_np()
.. raw:: html
.. raw:: html
.. code:: python
import torch
from d2l import torch as d2l
.. raw:: html
.. raw:: html
Computação Paralela em GPUs
---------------------------
Vamos começar definindo uma carga de trabalho de referência para testar
- a função ``run`` abaixo realiza 10 multiplicações matriz-matriz no
dispositivo de nossa escolha usando dados alocados em duas
variáveis,\ ``x_gpu1`` e ``x_gpu2``.
.. raw:: html
.. raw:: html
.. code:: python
devices = d2l.try_all_gpus()
def run(x):
return [x.dot(x) for _ in range(50)]
x_gpu1 = np.random.uniform(size=(4000, 4000), ctx=devices[0])
x_gpu2 = np.random.uniform(size=(4000, 4000), ctx=devices[1])
Agora aplicamos a função aos dados. Para garantir que o cache não
desempenhe um papel nos resultados, aquecemos os dispositivos realizando
uma única passagem em cada um deles antes da medição.
.. code:: python
run(x_gpu1) # Warm-up both devices
run(x_gpu2)
npx.waitall()
with d2l.Benchmark('GPU1 time'):
run(x_gpu1)
npx.waitall()
with d2l.Benchmark('GPU2 time'):
run(x_gpu2)
npx.waitall()
.. parsed-literal::
:class: output
GPU1 time: 0.5087 sec
GPU2 time: 0.4951 sec
Se removermos o ``waitall ()`` entre as duas tarefas, o sistema fica
livre para paralelizar a computação em ambos os dispositivos
automaticamente.
.. code:: python
with d2l.Benchmark('GPU1 & GPU2'):
run(x_gpu1)
run(x_gpu2)
npx.waitall()
.. parsed-literal::
:class: output
GPU1 & GPU2: 0.5090 sec
No caso acima, o tempo total de execução é menor que a soma de suas
partes, uma vez que o MXNet programa automaticamente a computação em
ambos os dispositivos GPU sem a necessidade de um código sofisticado em
nome do usuário.
.. raw:: html
.. raw:: html
.. code:: python
devices = d2l.try_all_gpus()
def run(x):
return [x.mm(x) for _ in range(50)]
x_gpu1 = torch.rand(size=(4000, 4000), device=devices[0])
x_gpu2 = torch.rand(size=(4000, 4000), device=devices[1])
Agora aplicamos a função aos dados. Para garantir que o cache não
desempenhe um papel nos resultados, aquecemos os dispositivos realizando
uma única passagem em cada um deles antes da medição.
``torch.cuda.synchronize ()`` espera que todos os kernels em todos os
streams em um dispositivo CUDA sejam concluídos. Ele recebe um argumento
``device``, o dispositivo para o qual precisamos sincronizar. Ele usa o
dispositivo atual, fornecido por ``current_device ()``, se o argumento
do dispositivo for ``None`` (padrão).
.. code:: python
run(x_gpu1)
run(x_gpu2) # Warm-up all devices
torch.cuda.synchronize(devices[0])
torch.cuda.synchronize(devices[1])
with d2l.Benchmark('GPU 1 time'):
run(x_gpu1)
torch.cuda.synchronize(devices[0])
with d2l.Benchmark('GPU 2 time'):
run(x_gpu2)
torch.cuda.synchronize(devices[1])
.. parsed-literal::
:class: output
GPU 1 time: 0.4915 sec
GPU 2 time: 0.4926 sec
Se removermos ``torch.cuda.synchronize ()`` entre as duas tarefas, o
sistema fica livre para paralelizar a computação em ambos os
dispositivos automaticamente.
.. code:: python
with d2l.Benchmark('GPU1 & GPU2'):
run(x_gpu1)
run(x_gpu2)
torch.cuda.synchronize()
.. parsed-literal::
:class: output
GPU1 & GPU2: 0.4913 sec
No caso acima, o tempo total de execução é menor que a soma de suas
partes, uma vez que o PyTorch programa automaticamente a computação em
ambos os dispositivos GPU sem a necessidade de um código sofisticado em
nome do usuário.
.. raw:: html
.. raw:: html
Computação Paralela e Comunicação
---------------------------------
Em muitos casos, precisamos mover dados entre diferentes dispositivos,
digamos, entre CPU e GPU, ou entre diferentes GPUs. Isso ocorre, por
exemplo, quando queremos realizar a otimização distribuída onde
precisamos agregar os gradientes em vários cartões aceleradores. Vamos
simular isso computando na GPU e copiando os resultados de volta para a
CPU.
.. raw:: html
.. raw:: html
.. code:: python
def copy_to_cpu(x):
return [y.copyto(npx.cpu()) for y in x]
with d2l.Benchmark('Run on GPU1'):
y = run(x_gpu1)
npx.waitall()
with d2l.Benchmark('Copy to CPU'):
y_cpu = copy_to_cpu(y)
npx.waitall()
.. parsed-literal::
:class: output
Run on GPU1: 0.5484 sec
Copy to CPU: 2.3873 sec
Isso é um tanto ineficiente. Observe que já podemos começar a copiar
partes de ``y`` para a CPU enquanto o restante da lista ainda está sendo
calculado. Essa situação ocorre, por exemplo, quando calculamos o
gradiente (*backprop*) em um minibatch. Os gradientes de alguns dos
parâmetros estarão disponíveis antes dos outros. Portanto, é vantajoso
começar a usar a largura de banda do barramento PCI-Express enquanto a
GPU ainda está em execução. Remover ``waitall`` entre as duas partes nos
permite simular este cenário.
.. code:: python
with d2l.Benchmark('Run on GPU1 and copy to CPU'):
y = run(x_gpu1)
y_cpu = copy_to_cpu(y)
npx.waitall()
.. parsed-literal::
:class: output
Run on GPU1 and copy to CPU: 2.5573 sec
.. raw:: html
.. raw:: html
.. code:: python
def copy_to_cpu(x, non_blocking=False):
return [y.to('cpu', non_blocking=non_blocking) for y in x]
with d2l.Benchmark('Run on GPU1'):
y = run(x_gpu1)
torch.cuda.synchronize()
with d2l.Benchmark('Copy to CPU'):
y_cpu = copy_to_cpu(y)
torch.cuda.synchronize()
.. parsed-literal::
:class: output
Run on GPU1: 0.4916 sec
Copy to CPU: 2.3453 sec
Isso é um tanto ineficiente. Observe que já podemos começar a copiar
partes de ``y`` para a CPU enquanto o restante da lista ainda está sendo
calculado. Essa situação ocorre, por exemplo, quando calculamos o
gradiente (*backprop*) em um minibatch. Os gradientes de alguns dos
parâmetros estarão disponíveis antes dos outros. Portanto, é vantajoso
começar a usar a largura de banda do barramento PCI-Express enquanto a
GPU ainda está em execução. No PyTorch, várias funções como ``to()`` e
``copy_()`` admitem um argumento ``non_blocking`` explícito, que permite
ao chamador ignorar a sincronização quando ela é desnecessária. Definir
``non_blocking = True`` nos permite simular este cenário.
.. code:: python
with d2l.Benchmark('Run on GPU1 and copy to CPU'):
y = run(x_gpu1)
y_cpu = copy_to_cpu(y, True)
torch.cuda.synchronize()
.. parsed-literal::
:class: output
Run on GPU1 and copy to CPU: 1.6498 sec
.. raw:: html
.. raw:: html
O tempo total necessário para ambas as operações é (conforme esperado)
significativamente menor do que a soma de suas partes. Observe que essa
tarefa é diferente da computação paralela, pois usa um recurso
diferente: o barramento entre a CPU e as GPUs. Na verdade, poderíamos
computar em ambos os dispositivos e nos comunicar, tudo ao mesmo tempo.
Como observado acima, há uma dependência entre computação e comunicação:
``y[i]`` deve ser calculado antes que possa ser copiado para a CPU.
Felizmente, o sistema pode copiar ``y[i-1]`` enquanto calcula ``y[i]``
para reduzir o tempo total de execução.
Concluímos com uma ilustração do gráfico computacional e suas
dependências para um MLP simples de duas camadas ao treinar em uma CPU e
duas GPUs, conforme descrito em :numref:`fig_twogpu`. Seria muito
doloroso agendar o programa paralelo resultante disso manualmente. É
aqui que é vantajoso ter um *back-end* de computação baseado em gráfico
para otimização.
.. _fig_twogpu:
.. figure:: ../img/twogpu.svg
MLP de duas camadas em uma CPU e 2 GPUs.
Resumo
------
- Os sistemas modernos têm uma variedade de dispositivos, como várias
GPUs e CPUs. Eles podem ser usados em paralelo, de forma assíncrona.
- Os sistemas modernos também possuem uma variedade de recursos para
comunicação, como PCI Express, armazenamento (normalmente SSD ou via
rede) e largura de banda da rede. Eles podem ser usados em paralelo
para eficiência máxima.
- O *back-end* pode melhorar o desempenho por meio de comunicação e
computação paralela automática.
Exercícios
----------
1. 10 operações foram realizadas na função ``run`` definida nesta seção.
Não há dependências entre eles. Projete um experimento para ver se o
MXNet irá executá-los automaticamente em paralelo.
2. Quando a carga de trabalho de um operador individual é
suficientemente pequena, a paralelização pode ajudar até mesmo em uma
única CPU ou GPU. Projete um experimento para verificar isso.
3. Projete um experimento que use computação paralela na CPU, GPU e
comunicação entre os dois dispositivos.
4. Use um depurador como o Nsight da NVIDIA para verificar se o seu
código é eficiente.
5. Projetar tarefas de computação que incluem dependências de dados mais
complexas e executar experimentos para ver se você pode obter os
resultados corretos enquanto melhora o desempenho.
.. raw:: html
.. raw:: html
`Discussions `__
.. raw:: html
.. raw:: html
`Discussions `__
.. raw:: html
.. raw:: html
.. raw:: html