Otimize o desempenho da GPU do TensorFlow com o TensorFlow Profiler

Visão geral

Este guia mostrará como usar o TensorFlow Profiler com TensorBoard para obter insights e obter o desempenho máximo de suas GPUs e depurar quando uma ou mais de suas GPUs estão subutilizadas.

Se você é novo no Profiler:

Tenha em mente que transferir cálculos para a GPU nem sempre pode ser benéfico, especialmente para modelos pequenos. Pode haver sobrecarga devido a:

  • Transferência de dados entre o host (CPU) e o dispositivo (GPU); e
  • Devido à latência envolvida quando o host inicia os kernels da GPU.

Fluxo de trabalho de otimização de desempenho

Este guia descreve como depurar problemas de desempenho começando com uma única GPU e depois passando para um único host com várias GPUs.

Recomenda-se depurar problemas de desempenho na seguinte ordem:

  1. Otimize e depure o desempenho em uma GPU:
    1. Verifique se o pipeline de entrada é um gargalo.
    2. Depure o desempenho de uma GPU.
    3. Habilite a precisão mista (com fp16 (float16)) e, opcionalmente, habilite XLA .
  2. Otimize e depure o desempenho no host único multi-GPU.

Por exemplo, se você estiver usando uma estratégia de distribuição do TensorFlow para treinar um modelo em um único host com várias GPUs e notar uma utilização abaixo do ideal da GPU, primeiro otimize e depure o desempenho de uma GPU antes de depurar o sistema multi-GPU.

Como base para obter código de desempenho em GPUs, este guia pressupõe que você já esteja usando tf.function . As APIs Keras Model.compile e Model.fit utilizarão tf.function automaticamente nos bastidores. Ao escrever um loop de treinamento personalizado com tf.GradientTape , consulte Melhor desempenho com tf.function sobre como habilitar tf.function s.

As próximas seções discutem abordagens sugeridas para cada um dos cenários acima para ajudar a identificar e corrigir gargalos de desempenho.

1. Otimize o desempenho em uma GPU

Em um caso ideal, seu programa deve ter alta utilização de GPU, comunicação mínima de CPU (o host) para GPU (o dispositivo) e nenhuma sobrecarga do pipeline de entrada.

A primeira etapa na análise do desempenho é obter um perfil para um modelo rodando com uma GPU.

A página de visão geral do Profiler do TensorBoard - que mostra uma visão de nível superior do desempenho do seu modelo durante a execução de um perfil - pode fornecer uma ideia de quão longe seu programa está do cenário ideal.

TensorFlow Profiler Overview Page

Os principais números para prestar atenção na página de visão geral são:

  1. Quanto do tempo da etapa é proveniente da execução real do dispositivo
  2. A porcentagem de operações colocadas no dispositivo versus host
  3. Quantos kernels usam fp16

Alcançar o desempenho ideal significa maximizar esses números nos três casos. Para obter uma compreensão aprofundada do seu programa, você precisará estar familiarizado com o visualizador de rastreamento Profiler do TensorBoard. As seções abaixo mostram alguns padrões comuns do visualizador de rastreamento que você deve procurar ao diagnosticar gargalos de desempenho.

Abaixo está uma imagem de uma visualização de rastreamento de modelo em execução em uma GPU. Nas seções TensorFlow Name Scope e TensorFlow Ops , você pode identificar diferentes partes do modelo, como a passagem direta, a função de perda, o cálculo da passagem retroativa/gradiente e a atualização do peso do otimizador. Você também pode ter as operações em execução na GPU ao lado de cada Stream , que se referem aos streams CUDA. Cada fluxo é usado para tarefas específicas. Neste rastreamento, o Stream#118 é usado para iniciar kernels de computação e cópias de dispositivo para dispositivo. Stream#119 é usado para cópia de host para dispositivo e Stream#120 para cópia de dispositivo para host.

O traço abaixo mostra características comuns de um modelo de desempenho.

image

Por exemplo, a linha do tempo de computação da GPU ( Stream#118 ) parece "ocupada" com muito poucas lacunas. Existem cópias mínimas de host para dispositivo ( Stream #119 ) e de dispositivo para host ( Stream #120 ), bem como intervalos mínimos entre as etapas. Ao executar o Profiler para seu programa, talvez você não consiga identificar essas características ideais em sua visualização de rastreio. O restante deste guia aborda cenários comuns e como corrigi-los.

1. Depure o pipeline de entrada

A primeira etapa na depuração de desempenho da GPU é determinar se o seu programa está vinculado à entrada. A maneira mais fácil de descobrir isso é usar o analisador de pipeline de entrada do Profiler, no TensorBoard, que fornece uma visão geral do tempo gasto no pipeline de entrada.

image

Você pode realizar as seguintes ações potenciais se o seu pipeline de entrada contribuir significativamente para o tempo da etapa:

  • Você pode usar o guia específico do tf.data para aprender como depurar seu pipeline de entrada.
  • Outra maneira rápida de verificar se o pipeline de entrada é o gargalo é usar dados de entrada gerados aleatoriamente que não precisam de nenhum pré-processamento. Aqui está um exemplo de uso desta técnica para um modelo ResNet. Se o pipeline de entrada for ideal, você deverá experimentar um desempenho semelhante com dados reais e com dados aleatórios/sintéticos gerados. A única sobrecarga no caso de dados sintéticos será devida à cópia dos dados de entrada, que novamente pode ser pré-buscada e otimizada.

Além disso, consulte as práticas recomendadas para otimizar o pipeline de dados de entrada .

2. Depure o desempenho de uma GPU

Existem vários fatores que podem contribuir para a baixa utilização da GPU. Abaixo estão alguns cenários comumente observados ao observar o visualizador de rastreamento e possíveis soluções.

1. Analise as lacunas entre as etapas

Uma observação comum quando o seu programa não está funcionando de maneira ideal são os intervalos entre as etapas do treinamento. Na imagem da visualização de rastreamento abaixo, há um grande intervalo entre as etapas 8 e 9, o que significa que a GPU fica ociosa durante esse período.

image

Se o seu visualizador de rastreamento mostrar grandes lacunas entre as etapas, isso pode ser uma indicação de que o seu programa está vinculado à entrada. Nesse caso, você deve consultar a seção anterior sobre depuração de seu pipeline de entrada, caso ainda não tenha feito isso.

No entanto, mesmo com um pipeline de entrada otimizado, ainda é possível haver lacunas entre o final de uma etapa e o início de outra devido à contenção de threads da CPU. tf.data usa threads em segundo plano para paralelizar o processamento do pipeline. Esses threads podem interferir na atividade do host da GPU que ocorre no início de cada etapa, como copiar dados ou agendar operações da GPU.

Se você notar grandes lacunas no lado do host, que agenda essas operações na GPU, você pode definir a variável de ambiente TF_GPU_THREAD_MODE=gpu_private . Isso garante que os kernels da GPU sejam iniciados a partir de seus próprios threads dedicados e não fiquem na fila atrás do trabalho tf.data .

As lacunas entre as etapas também podem ser causadas por cálculos de métricas, retornos de chamada de Keras ou operações fora de tf.function executadas no host. Essas operações não têm um desempenho tão bom quanto as operações dentro de um gráfico do TensorFlow. Além disso, algumas dessas operações são executadas na CPU e copiam tensores para frente e para trás da GPU.

Se depois de otimizar seu pipeline de entrada você ainda notar lacunas entre as etapas no visualizador de rastreamento, observe o código do modelo entre as etapas e verifique se a desativação de retornos de chamada/métricas melhora o desempenho. Alguns detalhes dessas operações também estão no visualizador de rastreamento (tanto no lado do dispositivo quanto no host). A recomendação neste cenário é amortizar a sobrecarga dessas operações executando-as após um número fixo de etapas, em vez de cada etapa. Ao usar o método Model.compile na API tf.keras , definir o sinalizador steps_per_execution faz isso automaticamente. Para loops de treinamento personalizados, use tf.while_loop .

2. Obtenha maior utilização do dispositivo

1. Pequenos kernels de GPU e atrasos no lançamento do kernel host

O host enfileira os kernels para serem executados na GPU, mas há uma latência (cerca de 20-40 μs) envolvida antes que os kernels sejam realmente executados na GPU. Em um caso ideal, o host enfileira kernels suficientes na GPU de modo que a GPU passe a maior parte do tempo executando, em vez de esperar que o host enfileire mais kernels.

A página de visão geral do Profiler no TensorBoard mostra quanto tempo a GPU ficou ociosa devido à espera do host para iniciar os kernels. Na imagem abaixo, a GPU fica ociosa por cerca de 10% do tempo da etapa aguardando o lançamento dos kernels.

image

O visualizador de rastreamento deste mesmo programa mostra pequenas lacunas entre os kernels onde o host está ocupado lançando kernels na GPU.

image

Ao iniciar muitas operações pequenas na GPU (como uma adição escalar, por exemplo), o host pode não acompanhar a GPU. A ferramenta TensorFlow Stats no TensorBoard para o mesmo perfil mostra 126.224 operações Mul levando 2,77 segundos. Assim, cada kernel tem cerca de 21,9 μs, o que é muito pequeno (quase o mesmo tempo que a latência de inicialização) e pode potencialmente resultar em atrasos na inicialização do kernel host.

image

Se o seu visualizador de rastreamento mostrar muitas pequenas lacunas entre as operações na GPU, como na imagem acima, você poderá:

  • Concatene pequenos tensores e use operações vetorizadas ou use um tamanho de lote maior para fazer com que cada kernel iniciado faça mais trabalho, o que manterá a GPU ocupada por mais tempo.
  • Certifique-se de usar tf.function para criar gráficos do TensorFlow, para que você não execute operações em um modo puro e ansioso. Se você estiver usando Model.fit (em oposição a um loop de treinamento personalizado com tf.GradientTape ), então tf.keras.Model.compile fará isso automaticamente para você.
  • Funda kernels usando XLA com tf.function(jit_compile=True) ou clustering automático. Para obter mais detalhes, vá para a seção Habilitar precisão mista e XLA abaixo para saber como habilitar o XLA para obter maior desempenho. Esse recurso pode levar a uma alta utilização do dispositivo.
2. Posicionamento operacional do TensorFlow

A página de visão geral do Profiler mostra a porcentagem de operações colocadas no host versus o dispositivo (você também pode verificar a colocação de operações específicas olhando para o visualizador de rastreamento . Como na imagem abaixo, você deseja a porcentagem de operações no host ser muito pequeno em comparação com o dispositivo.

image

Idealmente, a maioria das operações de computação intensiva devem ser colocadas na GPU.

Para descobrir a quais dispositivos as operações e tensores em seu modelo são atribuídos, defina tf.debugging.set_log_device_placement(True) como a primeira instrução do seu programa.

Observe que em alguns casos, mesmo se você especificar uma operação a ser colocada em um dispositivo específico, sua implementação poderá substituir essa condição (exemplo: tf.unique ). Mesmo para treinamento de GPU única, especificar uma estratégia de distribuição, como tf.distribute.OneDeviceStrategy , pode resultar em um posicionamento mais determinístico de operações em seu dispositivo.

Uma razão para ter a maioria das operações colocadas na GPU é evitar cópias excessivas de memória entre o host e o dispositivo (são esperadas cópias de memória para dados de entrada/saída do modelo entre o host e o dispositivo). Um exemplo de cópia excessiva é demonstrado na visualização de rastreamento abaixo nos fluxos de GPU #167 , #168 e #169 .

image

Essas cópias às vezes podem prejudicar o desempenho se bloquearem a execução dos kernels da GPU. As operações de cópia de memória no visualizador de rastreamento têm mais informações sobre as operações que são a origem desses tensores copiados, mas nem sempre é fácil associar um memCopy a uma operação. Nesses casos, é útil observar as operações próximas para verificar se a cópia da memória ocorre no mesmo local em todas as etapas.

3. Kernels mais eficientes em GPUs

Assim que a utilização da GPU do seu programa for aceitável, a próxima etapa é aumentar a eficiência dos kernels da GPU utilizando Tensor Cores ou operações de fusão.

1. Utilize núcleos tensores

As GPUs NVIDIA® modernas possuem Tensor Cores especializados que podem melhorar significativamente o desempenho dos kernels elegíveis.

Você pode usar as estatísticas do kernel de GPU do TensorBoard para visualizar quais kernels de GPU são elegíveis para Tensor Core e quais kernels estão usando Tensor Cores. Habilitar fp16 (consulte a seção Habilitar Precisão Mista abaixo) é uma maneira de fazer com que os kernels General Matrix Multiply (GEMM) (matmul ops) do seu programa utilizem o Tensor Core. Os kernels de GPU usam os Tensor Cores de forma eficiente quando a precisão é fp16 e as dimensões do tensor de entrada/saída são divisíveis por 8 ou 16 (para int8 ).

Para obter outras recomendações detalhadas sobre como tornar os kernels eficientes para GPUs, consulte o guia de desempenho de aprendizagem profunda da NVIDIA® .

2. Operações de fusíveis

Use tf.function(jit_compile=True) para fundir operações menores para formar kernels maiores, levando a ganhos significativos de desempenho. Para saber mais, consulte o guia XLA .

3. Habilite precisão mista e XLA

Depois de seguir as etapas acima, ativar a precisão mista e o XLA são duas etapas opcionais que você pode seguir para melhorar ainda mais o desempenho. A abordagem sugerida é habilitá-los um por um e verificar se os benefícios de desempenho são os esperados.

1. Habilite precisão mista

O guia de precisão mista do TensorFlow mostra como ativar a precisão fp16 em GPUs. Habilite o AMP em GPUs NVIDIA® para usar Tensor Cores e obtenha velocidades gerais de até 3x quando comparado ao uso de apenas precisão fp32 (float32) em Volta e arquiteturas de GPU mais recentes.

Certifique-se de que as dimensões da matriz/tensor atendam aos requisitos para chamar kernels que usam Tensor Cores. Os kernels de GPU usam os Tensor Cores de forma eficiente quando a precisão é fp16 e as dimensões de entrada/saída são divisíveis por 8 ou 16 (para int8).

Observe que com cuDNN v7.6.3 e posterior, as dimensões de convolução serão preenchidas automaticamente quando necessário para aproveitar os Tensor Cores.

Siga as práticas recomendadas abaixo para maximizar os benefícios de desempenho da precisão fp16 .

1. Use kernels FP16 ideais

Com fp16 habilitado, os kernels de multiplicações de matrizes (GEMM) do seu programa devem usar a versão fp16 correspondente que utiliza os Tensor Cores. No entanto, em alguns casos, isso não acontece e você não experimenta a aceleração esperada ao ativar fp16 , pois seu programa recorre à implementação ineficiente.

image

A página de estatísticas do kernel da GPU mostra quais operações são elegíveis para o Tensor Core e quais kernels estão realmente usando o Tensor Core eficiente. O guia NVIDIA® sobre desempenho de aprendizado profundo contém sugestões adicionais sobre como aproveitar os Tensor Cores. Além disso, os benefícios do uso fp16 também aparecerão em kernels que anteriormente estavam vinculados à memória, já que agora as operações levarão metade do tempo.

2. Dimensionamento de perda dinâmica versus estática

A escala de perda é necessária ao usar fp16 para evitar underflow devido à baixa precisão. Existem dois tipos de escalonamento de perda, dinâmico e estático, ambos explicados com mais detalhes no guia Mixed Precision . Você pode usar a política mixed_float16 para ativar automaticamente o dimensionamento de perdas no otimizador Keras.

Ao tentar otimizar o desempenho, é importante lembrar que o escalonamento dinâmico de perdas pode introduzir operações condicionais adicionais executadas no host e levar a lacunas que serão visíveis entre as etapas no visualizador de rastreamento. Por outro lado, a escala de perda estática não tem tais sobrecargas e pode ser uma opção melhor em termos de desempenho com a condição de que você precisa especificar o valor correto da escala de perda estática.

2. Habilite XLA com tf.function(jit_compile=True) ou clustering automático

Como etapa final para obter o melhor desempenho com uma única GPU, você pode experimentar ativar o XLA, que fundirá as operações e levará a uma melhor utilização do dispositivo e a um menor consumo de memória. Para obter detalhes sobre como habilitar o XLA em seu programa com tf.function(jit_compile=True) ou clustering automático, consulte o guia XLA .

Você pode definir o nível JIT global como -1 (desativado), 1 ou 2 . Um nível mais alto é mais agressivo e pode reduzir o paralelismo e usar mais memória. Defina o valor como 1 se você tiver restrições de memória. Observe que o XLA não funciona bem para modelos com formas de tensor de entrada variáveis, pois o compilador XLA teria que continuar compilando kernels sempre que encontrasse novas formas.

2. Otimize o desempenho no host único multi-GPU

A API tf.distribute.MirroredStrategy pode ser usada para dimensionar o treinamento de modelo de uma GPU para várias GPUs em um único host. (Para saber mais sobre como fazer treinamento distribuído com o TensorFlow, consulte os guias Treinamento distribuído com TensorFlow , Usar uma GPU e Usar TPUs e o tutorial Treinamento distribuído com Keras .)

Embora a transição de uma GPU para várias GPUs deva ser idealmente escalonável imediatamente, às vezes você pode encontrar problemas de desempenho.

Ao passar do treinamento com uma única GPU para várias GPUs no mesmo host, o ideal é que você experimente o dimensionamento de desempenho apenas com a sobrecarga adicional da comunicação gradiente e maior utilização do thread do host. Por causa dessa sobrecarga, você não terá uma aceleração exata de 2x se passar de 1 para 2 GPUs, por exemplo.

A visualização de rastreamento abaixo mostra um exemplo da sobrecarga extra de comunicação ao treinar em várias GPUs. Há alguma sobrecarga para concatenar os gradientes, comunicá-los entre réplicas e dividi-los antes de fazer a atualização de peso.

image

A lista de verificação a seguir ajudará você a obter melhor desempenho ao otimizar o desempenho no cenário multi-GPU:

  1. Tente maximizar o tamanho do lote, o que levará a uma maior utilização do dispositivo e amortizará os custos de comunicação entre várias GPUs. Usar o criador de perfil de memória ajuda a ter uma noção de quão perto seu programa está do pico de utilização de memória. Observe que, embora um tamanho de lote maior possa afetar a convergência, isso geralmente é compensado pelos benefícios de desempenho.
  2. Ao passar de uma única GPU para várias GPUs, o mesmo host agora precisa processar muito mais dados de entrada. Portanto, após (1), é recomendável verificar novamente o desempenho do pipeline de entrada e certificar-se de que não seja um gargalo.
  3. Verifique a linha do tempo da GPU na visualização de rastreamento do seu programa para quaisquer chamadas AllReduce desnecessárias, pois isso resulta em uma sincronização entre todos os dispositivos. Na visualização de rastreamento mostrada acima, o AllReduce é feito por meio do kernel NCCL e há apenas uma chamada NCCL em cada GPU para os gradientes em cada etapa.
  4. Verifique se há operações de cópia D2H, H2D e D2D desnecessárias que podem ser minimizadas.
  5. Verifique o tempo da etapa para garantir que cada réplica esteja fazendo o mesmo trabalho. Por exemplo, pode acontecer que uma GPU (normalmente, GPU0 ) esteja com excesso de assinaturas porque o host por engano acaba colocando mais trabalho nela.
  6. Por fim, verifique a etapa de treinamento em todas as GPUs na visualização de rastreamento para quaisquer operações que estejam sendo executadas sequencialmente. Isso geralmente acontece quando seu programa inclui dependências de controle de uma GPU para outra. No passado, a depuração do desempenho nesta situação era resolvida caso a caso. Se você observar esse comportamento em seu programa, registre um problema no GitHub com imagens de sua visualização de rastreamento.

1. Otimize o gradiente AllReduce

Ao treinar com uma estratégia síncrona, cada dispositivo recebe uma parte dos dados de entrada.

Depois de calcular as passagens para frente e para trás no modelo, os gradientes calculados em cada dispositivo precisam ser agregados e reduzidos. Este gradiente AllReduce acontece após o cálculo do gradiente em cada dispositivo e antes do otimizador atualizar os pesos do modelo.

Cada GPU primeiro concatena os gradientes nas camadas do modelo, comunica-os entre as GPUs usando tf.distribute.CrossDeviceOps ( tf.distribute.NcclAllReduce é o padrão) e, em seguida, retorna os gradientes após a redução por camada.

O otimizador usará esses gradientes reduzidos para atualizar os pesos do seu modelo. Idealmente, esse processo deve acontecer ao mesmo tempo em todas as GPUs para evitar sobrecargas.

O tempo para AllReduce deve ser aproximadamente o mesmo que:

(number of parameters * 4bytes)/ (communication bandwidth)

Este cálculo é útil como uma verificação rápida para entender se o desempenho que você tem ao executar um trabalho de treinamento distribuído é o esperado ou se você precisa fazer mais depuração de desempenho. Você pode obter o número de parâmetros em seu modelo em Model.summary .

Observe que cada parâmetro do modelo tem 4 bytes de tamanho, pois o TensorFlow usa fp32 (float32) para comunicar gradientes. Mesmo quando você tem fp16 habilitado, o NCCL AllReduce utiliza parâmetros fp32 .

Para obter os benefícios do escalonamento, o passo-tempo precisa ser muito maior em comparação com essas despesas gerais. Uma maneira de conseguir isso é usar um tamanho de lote maior, pois o tamanho do lote afeta o tempo da etapa, mas não afeta a sobrecarga de comunicação.

2. Contenção de thread do host da GPU

Ao executar várias GPUs, o trabalho da CPU é manter todos os dispositivos ocupados, iniciando com eficiência os kernels da GPU nos dispositivos.

No entanto, quando há muitas operações independentes que a CPU pode agendar em uma GPU, a CPU pode decidir usar muitos de seus threads de host para manter uma GPU ocupada e, em seguida, lançar kernels em outra GPU em uma ordem não determinística. . Isso pode causar distorção ou dimensionamento negativo, o que pode afetar negativamente o desempenho.

O visualizador de rastreamento abaixo mostra a sobrecarga quando a CPU escalona a inicialização do kernel da GPU de forma ineficiente, pois GPU1 está ociosa e começa a executar operações após o início GPU2 .

image

A visualização de rastreamento do host mostra que o host está iniciando kernels na GPU2 antes de lançá-los na GPU1 (observe que as operações tf_Compute* abaixo não são indicativas de threads de CPU).

image

Se você enfrentar esse tipo de escalonamento de kernels de GPU na visualização de rastreamento do seu programa, a ação recomendada é:

  • Defina a variável de ambiente do TensorFlow TF_GPU_THREAD_MODE como gpu_private . Esta variável de ambiente dirá ao host para manter os threads de uma GPU privados.
  • Por padrão, TF_GPU_THREAD_MODE=gpu_private define o número de threads como 2, o que é suficiente na maioria dos casos. No entanto, esse número pode ser alterado definindo a variável de ambiente TF_GPU_THREAD_COUNT do TensorFlow para o número desejado de threads.