.. _sec_async: Computação Assíncrona ===================== Os computadores de hoje são sistemas altamente paralelos, consistindo em vários núcleos de CPU (geralmente várias *threads* por núcleo), vários elementos de processamento por GPU e, muitas vezes, várias GPUs por dispositivo. Resumindo, podemos processar muitas coisas diferentes ao mesmo tempo, geralmente em dispositivos diferentes. Infelizmente, Python não é uma ótima maneira de escrever código paralelo e assíncrono, pelo menos não com alguma ajuda extra. Afinal, o Python é de thread único e é improvável que isso mude no futuro. Estruturas de aprendizado profundo, como MXNet e TensorFlow, utilizam um modelo de programação assíncrona para melhorar o desempenho (o PyTorch usa o próprio programador do Python, levando a uma compensação de desempenho diferente). Para PyTorch, por padrão, as operações de GPU são assíncronas. Quando você chama uma função que usa a GPU, as operações são enfileiradas no dispositivo específico, mas não necessariamente executadas até mais tarde. Isso nos permite executar mais cálculos em paralelo, incluindo operações na CPU ou outras GPUs. Portanto, entender como a programação assíncrona funciona nos ajuda a desenvolver programas mais eficientes, reduzindo proativamente os requisitos computacionais e as dependências mútuas. Isso nos permite reduzir a sobrecarga de memória e aumentar a utilização do processador. Começamos importando as bibliotecas necessárias. .. raw:: html
mxnetpytorch
.. raw:: html
.. code:: python import os import subprocess import numpy from mxnet import autograd, gluon, np, npx from mxnet.gluon import nn from d2l import mxnet as d2l npx.set_np() .. raw:: html
.. raw:: html
.. code:: python import os import subprocess import numpy import torch from torch import nn from d2l import torch as d2l .. raw:: html
.. raw:: html
Assincronismo via *Back-end* ---------------------------- .. raw:: html
mxnet
.. raw:: html
Para um aquecimento, considere o seguinte problema brinquedo - queremos gerar uma matriz aleatória e multiplicá-la. Vamos fazer isso no NumPy e no MXNet NP para ver a diferença. .. raw:: html
.. raw:: html
Para um aquecimento, considere o seguinte problema brinquedo - queremos gerar uma matriz aleatória e multiplicá-la. Vamos fazer isso tanto no NumPy quanto no tensor PyTorch para ver a diferença. Observe que o ``tensor`` do PyTorch é definido em uma gpu. .. raw:: html
mxnetpytorch
.. raw:: html
.. code:: python with d2l.Benchmark('numpy'): for _ in range(10): a = numpy.random.normal(size=(1000, 1000)) b = numpy.dot(a, a) with d2l.Benchmark('mxnet.np'): for _ in range(10): a = np.random.normal(size=(1000, 1000)) b = np.dot(a, a) .. parsed-literal:: :class: output numpy: 0.9818 sec mxnet.np: 0.0048 sec Isso é ordens de magnitude mais rápido. Pelo menos parece que sim. Uma vez que ambos são executados no mesmo processador, algo mais deve estar acontecendo. Forçar o MXNet a terminar toda a computação antes de retornar mostra o que aconteceu anteriormente: a computação está sendo executada pelo *back-end* enquanto o *front-end* retorna o controle ao Python. .. code:: python with d2l.Benchmark(): for _ in range(10): a = np.random.normal(size=(1000, 1000)) b = np.dot(a, a) npx.waitall() .. parsed-literal:: :class: output Done: 0.9211 sec De um modo geral, o MXNet possui um front-end para interação direta com os usuários, por exemplo, via Python, bem como um *back-end* usado pelo sistema para realizar a computação. Conforme mostrado em: numref: ``fig_frontends``, os usuários podem escrever programas MXNet em várias linguagens de front-end, como Python, R, Scala e C ++. Independentemente da linguagem de programação de front-end usada, a execução de programas MXNet ocorre principalmente no *back-end* de implementações C ++. As operações emitidas pela linguagem do front-end são passadas para o back-end para execução. O back-end gerencia seus próprios threads que continuamente coletam e executam tarefas enfileiradas. Observe que, para que isso funcione, o *back-end* deve ser capaz de controlar as dependências entre as várias etapas do gráfico computacional. Portanto, não é possível paralelizar operações que dependem umas das outras. .. raw:: html
.. raw:: html
.. code:: python # warmup for gpu computation device = d2l.try_gpu() a = torch.randn(size=(1000, 1000), device=device) b = torch.mm(a, a) with d2l.Benchmark('numpy'): for _ in range(10): a = numpy.random.normal(size=(1000, 1000)) b = numpy.dot(a, a) with d2l.Benchmark('torch'): for _ in range(10): a = torch.randn(size=(1000, 1000), device=device) b = torch.mm(a, a) .. parsed-literal:: :class: output numpy: 0.8409 sec torch: 0.0011 sec Isso é ordens de magnitude mais rápido. Pelo menos parece que sim. O produto de ponto Numpy é executado no processador cpu enquanto A multiplicação da matriz de Pytorch é executada no gpu e, portanto, o último espera-se que seja muito mais rápida. Mas a enorme diferença de tempo sugere que algo mais deve estar acontecendo. Por padrão, as operações da GPU são assíncronas no PyTorch. Forçando PyTorch a terminar todos os cálculos antes de retornar os programas, o que aconteceu anteriormente: o cálculo está sendo executado pelo backend enquanto o front-end retorna o controle para Python. .. code:: python with d2l.Benchmark(): for _ in range(10): a = torch.randn(size=(1000, 1000), device=device) b = torch.mm(a, a) torch.cuda.synchronize(device) .. parsed-literal:: :class: output Done: 0.0023 sec Em termos gerais, o PyTorch tem um *front-end* para interação direta com os usuários, por exemplo, via Python, bem como um *back-end* usado pelo sistema para realizar a computação. Conforme mostrado em: numref: ``fig_frontends``, os usuários podem escrever programas PyTorch em várias linguagens de *front-end*, como Python e C ++. Independentemente da linguagem de programação de frontend usada, a execução de programas PyTorch ocorre principalmente no backend de implementações C ++. As operações emitidas pela linguagem do *front-end* são passadas para o *back-end* para execução. O *back-end* gerencia suas próprias threads que continuamente coletam e executam tarefas enfileiradas. Observe que para que isso funcione, o *back-end* deve ser capaz de rastrear as dependências entre várias etapas no gráfico computacional. Portanto, não é possível paralelizar operações que dependem umas das outras. .. raw:: html
.. raw:: html
.. _fig_frontends: .. figure:: ../img/frontends.png :width: 300px Programação *Frontend*. Vejamos outro exemplo brinquedo para entender um pouco melhor o grafo de dependência. .. raw:: html
mxnetpytorch
.. raw:: html
.. code:: python x = np.ones((1, 2)) y = np.ones((1, 2)) z = x * y + 2 z .. parsed-literal:: :class: output array([[3., 3.]]) .. raw:: html
.. raw:: html
.. code:: python x = torch.ones((1, 2), device=device) y = torch.ones((1, 2), device=device) z = x * y + 2 z .. parsed-literal:: :class: output tensor([[3., 3.]], device='cuda:0') .. raw:: html
.. raw:: html
.. _fig_asyncgraph: .. figure:: ../img/asyncgraph.svg Dependências. O trecho de código acima também é ilustrado em :numref:`fig_asyncgraph`. Sempre que a *thread* de *front-end* do Python executa uma das três primeiras instruções, ela simplesmente retorna a tarefa para a fila de *back-end*. Quando os resultados da última instrução precisam ser impressos, a *thread* de *front-end* do Python irá esperar que a\ *thread* de *back-end* do C ++ termine de calcular o resultado da variável ``z``. Um benefício desse *design* é que a *thread* de *front-end* do Python não precisa realizar cálculos reais. Portanto, há pouco impacto no desempenho geral do programa, independentemente do desempenho do Python. :numref:`fig_threading` ilustra como *front-end* e *back-end* interagem. .. _fig_threading: .. figure:: ../img/threading.svg Frontend and Backend. Barreiras e Bloqueadores ------------------------ Existem várias operações que forçam o Python a aguardar a conclusão: \* Obviamente, ``npx.waitall ()`` espera até que todo o cálculo seja concluído, independentemente de quando as instruções de cálculo foram emitidas. Na prática, é uma má ideia usar este operador, a menos que seja absolutamente necessário, pois pode levar a um desempenho insatisfatório. \* Se quisermos apenas esperar até que uma variável específica esteja disponível, podemos chamar ``z.wait_to_read ()``. Nesse caso, os blocos MXNet retornam ao Python até que a variável ``z`` seja calculada. Outros cálculos podem continuar depois. Vamos ver como isso funciona na prática: .. raw:: html
mxnet
.. raw:: html
.. code:: python with d2l.Benchmark('waitall'): b = np.dot(a, a) npx.waitall() with d2l.Benchmark('wait_to_read'): b = np.dot(a, a) b.wait_to_read() .. parsed-literal:: :class: output waitall: 0.0366 sec wait_to_read: 0.0066 sec .. raw:: html
.. raw:: html
Ambas as operações levam aproximadamente o mesmo tempo para serem concluídas. Além das operações de bloqueio óbvias, recomendamos que o leitor esteja ciente dos bloqueadores *implícitos*. Imprimir uma variável requer claramente que a variável esteja disponível e, portanto, é um bloqueador. Por último, as conversões para NumPy via ``z.asnumpy ()`` e conversões para escalares via ``z.item ()`` estão bloqueando, uma vez que NumPy não tem noção de assincronismo. Ele precisa acessar os valores assim como a função ``print``. Copiar pequenas quantidades de dados frequentemente do escopo do MXNet para NumPy e vice-versa pode destruir o desempenho de um código eficiente, uma vez que cada operação requer o gráfico computacional para avaliar todos os resultados intermediários necessários para obter o termo relevante *antes* que qualquer outra coisa possa ser feita. .. raw:: html
mxnet
.. raw:: html
.. code:: python with d2l.Benchmark('numpy conversion'): b = np.dot(a, a) b.asnumpy() with d2l.Benchmark('scalar conversion'): b = np.dot(a, a) b.sum().item() .. parsed-literal:: :class: output numpy conversion: 0.0216 sec scalar conversion: 0.0323 sec .. raw:: html
.. raw:: html
Melhorando a Computação ----------------------- Em um sistema altamente *multithread* (mesmo laptops regulares têm 4 threads ou mais e em servidores multithread esse número pode exceder 256), a sobrecarga das operações de agendamento pode se tornar significativa. É por isso que é altamente desejável que a computação e a programação ocorram de forma assíncrona e em paralelo. Para ilustrar o benefício de fazer isso, vamos ver o que acontece se incrementarmos uma variável em 1 várias vezes, tanto em sequência quanto de forma assíncrona. Simulamos a execução síncrona inserindo uma barreira ``wait_to_read ()`` entre cada adição. .. raw:: html
mxnet
.. raw:: html
.. code:: python with d2l.Benchmark('synchronous'): for _ in range(1000): y = x + 1 y.wait_to_read() with d2l.Benchmark('asynchronous'): for _ in range(1000): y = x + 1 y.wait_to_read() .. parsed-literal:: :class: output synchronous: 0.1225 sec asynchronous: 0.0829 sec .. raw:: html
.. raw:: html
Uma interação ligeiramente simplificada entre a *thread* de *front-end* Python e a *thread* de *back-end* C ++ pode ser resumida da seguinte maneira: 1. O *front-end* ordena que o *back-end* insira a tarefa de cálculo ``y = x + 1`` na fila. 2. O *back-end* então recebe as tarefas de computação da fila e executa os cálculos reais. 3. O *back-end* então retorna os resultados do cálculo para o *front-end*. Suponha que as durações desses três estágios sejam :math:`t_1, t_2` e :math:`t_3`, respectivamente. Se não usarmos a programação assíncrona, o tempo total necessário para realizar 1000 cálculos é de aproximadamente :math:`1000 (t_1+ t_2 + t_3)`. Se a programação assíncrona for usada, o tempo total gasto para realizar 1000 cálculos pode ser reduzido para :math:`t_1 + 1000 t_2 + t_3` (assumindo :math:`1000 t_2> 999 t_1`), uma vez que o *front-end* não precisa esperar que o *back-end- retorne os resultados dos cálculos para cada*\ loop*. Melhorando o *Footprint* de Memória ----------------------------------- Imagine uma situação em que continuamos inserindo operações no *back-end*, executando o código Python no *front-end*. Por exemplo, o *front-end* pode inserir um grande número de tarefas de minibatch em um tempo muito curto. Afinal, se nenhum cálculo significativo acontecer no Python, isso pode ser feito rapidamente. Se cada uma dessas tarefas puder ser iniciada rapidamente ao mesmo tempo, isso pode causar um aumento no uso de memória. Dada uma quantidade finita de memória disponível nas GPUs (e mesmo nas CPUs), isso pode levar à contenção de recursos ou até mesmo travamentos do programa. Alguns leitores devem ter notado que as rotinas de treinamento anteriores faziam uso de métodos de sincronização como ``item`` ou mesmo ``asnumpy``. Recomendamos usar essas operações com cuidado, por exemplo, para cada minibatch, para equilibrar a eficiência computacional e a pegada de memória. Para ilustrar o que acontece, vamos implementar um *loop* de treinamento simples para uma rede profunda e medir seu consumo de memória e tempo. Abaixo está o gerador de dados simulado e a rede profunda. .. raw:: html
mxnet
.. raw:: html
.. code:: python def data_iter(): timer = d2l.Timer() num_batches, batch_size = 150, 1024 for i in range(num_batches): X = np.random.normal(size=(batch_size, 512)) y = np.ones((batch_size,)) yield X, y if (i + 1) % 50 == 0: print(f'batch {i + 1}, time {timer.stop():.4f} sec') net = nn.Sequential() net.add(nn.Dense(2048, activation='relu'), nn.Dense(512, activation='relu'), nn.Dense(1)) net.initialize() trainer = gluon.Trainer(net.collect_params(), 'sgd') loss = gluon.loss.L2Loss() .. raw:: html
.. raw:: html
Em seguida, precisamos de uma ferramenta para medir a pegada de memória de nosso código. Usamos uma chamada ``ps`` relativamente primitiva para fazer isso (observe que a última só funciona no Linux e MacOS). Para uma análise muito mais detalhada do que está acontecendo aqui, use, por exemplo, o `Nsight `__ da Nvidia ou o `vTune `__ da Intel. .. raw:: html
.. raw:: html
.. code:: python def get_mem(): res = subprocess.check_output(['ps', 'u', '-p', str(os.getpid())]) return int(str(res).split()[15]) / 1e3 .. raw:: html
.. raw:: html
Antes de começarmos o teste, precisamos inicializar os parâmetros da rede e processar um lote. Caso contrário, seria complicado ver qual é o consumo de memória adicional. Veja :numref:`sec_deferred_init` para mais detalhes relacionados à inicialização. .. raw:: html
.. raw:: html
.. code:: python for X, y in data_iter(): break loss(y, net(X)).wait_to_read() .. raw:: html
.. raw:: html
Para garantir que não estouremos o *buffer* de tarefa no *back-end*, inserimos uma chamada ``wait_to_read`` para a função de perda no final de cada *loop*. Isso força a propagação direta a ser concluída antes que uma nova propagação direta seja iniciada. Observe que uma alternativa (possivelmente mais elegante) seria rastrear a perda em uma variável escalar e forçar uma barreira por meio da chamada de ``item``. .. raw:: html
.. raw:: html
.. code:: python mem = get_mem() with d2l.Benchmark('time per epoch'): for X, y in data_iter(): with autograd.record(): l = loss(y, net(X)) l.backward() trainer.step(X.shape[0]) l.wait_to_read() # Barrier before a new batch npx.waitall() print(f'increased memory: {get_mem() - mem:f} MB') .. parsed-literal:: :class: output [04:02:35] src/base.cc:49: GPU context requested, but no GPUs found. batch 50, time 4.2066 sec batch 100, time 8.2570 sec batch 150, time 12.1179 sec time per epoch: 12.1193 sec increased memory: 9.424000 MB .. raw:: html
.. raw:: html
Como vemos, o tempo dos minibatches se alinha muito bem com o tempo de execução geral do código de otimização. Além disso, o consumo de memória aumenta apenas ligeiramente. Agora vamos ver o que acontece se derrubarmos a barreira no final de cada minibatch. .. raw:: html
.. raw:: html
.. code:: python mem = get_mem() with d2l.Benchmark('time per epoch'): for X, y in data_iter(): with autograd.record(): l = loss(y, net(X)) l.backward() trainer.step(X.shape[0]) npx.waitall() print(f'increased memory: {get_mem() - mem:f} MB') .. parsed-literal:: :class: output batch 50, time 0.1447 sec batch 100, time 0.2817 sec batch 150, time 0.3972 sec time per epoch: 12.0468 sec increased memory: 6.156000 MB .. raw:: html
.. raw:: html
Mesmo que o tempo para emitir instruções para o *back-end* seja uma ordem de magnitude menor, ainda precisamos realizar o cálculo. Consequentemente, uma grande quantidade de resultados intermediários não pode ser liberada e pode se acumular na memória. Embora isso não tenha causado nenhum problema no exemplo acima, pode muito bem ter resultado em situações de falta de memória quando não verificado em cenários do mundo real. Resumo ------ - MXNet desacopla o *front-end* Python de um *back-end* de execução. Isso permite a rápida inserção assíncrona de comandos no *back-end* e o paralelismo associado. - O assincronismo leva a uma interface bastante responsiva. No entanto, tenha cuidado para não sobrecarregar a fila de tarefas, pois isso pode levar ao consumo excessivo de memória. - Recomenda-se sincronizar para cada minibatch para manter o *front-end* e o *back-end* aproximadamente sincronizados. - Esteja ciente do fato de que as conversões do gerenciamento de memória do MXNet para Python forçarão o\* back-end\* a esperar até que a variável específica esteja pronta. ``print``, ``asnumpy`` e ``item`` têm este efeito. Isso pode ser desejável, mas o uso sem carro da sincronização pode prejudicar o desempenho. - Os fornecedores de chips oferecem ferramentas sofisticadas de análise de desempenho para obter uma visão muito mais detalhada da eficiência do *deep learning*. Exercícios ---------- 1. Mencionamos acima que o uso de computação assíncrona pode reduzir a quantidade total de tempo necessária para realizar :math:`1000` computações para :math:`t_1 + 1000 t_2 + t_3`. Por que temos que assumir :math:`1000 t_2 > 999 t_1` aqui? 2. Como você precisaria modificar o *loop* de treinamento se quisesse ter uma sobreposição de um minibatch cada? Ou seja, se você quiser garantir que o lote :math:`b_t` termine antes que o lote :math:`b_{t+2}` comece? 3. O que pode acontecer se quisermos executar código em CPUs e GPUs simultaneamente? Você ainda deve insistir em sincronizar após cada minibatch ter sido emitido? 4. Meça a diferença entre ``waitall`` e\ ``wait_to_read``. Dica: execute uma série de instruções e sincronize para um resultado intermediário. .. raw:: html
.. raw:: html
`Discussions `__ .. raw:: html
.. raw:: html
.. raw:: html