Otimize o desempenho da GPU TensorFlow com o TensorFlow Profiler

Visão geral

Este guia mostrará como usar o TensorFlow Profiler com TensorBoard para obter informações e obter o máximo desempenho de suas GPUs e depurar quando uma ou mais de suas GPUs forem subutilizadas.

Se você é novo no Profiler:

Tenha em mente que descarregar os cálculos para a GPU nem sempre pode ser benéfico, principalmente 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.

É recomendável 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 habilite opcionalmente 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 de GPU abaixo do ideal, primeiro você deve otimizar e depurar o desempenho de uma GPU antes de depurar o sistema multi-GPU.

Como base para obter código de alto 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 sob o capô. 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 as 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 executado 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 uma execução de perfil — pode fornecer uma ideia de quão distante seu programa está do cenário ideal.

TensorFlow Profiler Overview Page

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

  1. Quanto do tempo da etapa é 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 Escopo do nome do TensorFlow e Operações do TensorFlow, você pode identificar diferentes partes do modelo, como a passagem para frente, a função de perda, a passagem para trás/cálculo de gradiente e a atualização de peso do otimizador. Você também pode ter as operações em execução na GPU ao lado de cada Stream , que se referem a fluxos CUDA. Cada fluxo é usado para tarefas específicas. Nesse 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 poucas lacunas. Há 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 rastreamento. O restante deste guia abrange 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 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 seu pipeline de entrada contribuir significativamente para o tempo da etapa:

  • Você pode usar o guia específico do tf.data para aprender a 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 pré-processamento. Aqui está um exemplo de uso dessa 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á devido à cópia de 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 as soluções potenciais.

1. Analise as lacunas entre as etapas

Uma observação comum quando seu programa não está funcionando de maneira ideal são as lacunas entre as etapas de treinamento. Na imagem da visualização de rastreamento abaixo, há uma grande lacuna entre as etapas 8 e 9, o que significa que a GPU está ociosa durante esse tempo.

image

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

No entanto, mesmo com um pipeline de entrada otimizado, você ainda pode ter lacunas entre o final de uma etapa e o início de outra devido à contenção de thread da CPU. tf.data faz uso de threads em segundo plano para paralelizar o processamento do pipeline. Esses encadeamentos podem interferir na atividade do lado 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 de GPU sejam iniciados a partir de seus próprios threads dedicados e não fiquem enfileirados atrás do trabalho tf.data .

As lacunas entre as etapas também podem ser causadas por cálculos de métrica, retornos de chamada Keras ou operações fora do tf.function que são executados 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 da GPU.

Se, depois de otimizar seu pipeline de entrada, você ainda notar lacunas entre as etapas no visualizador de rastreamento, verifique 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 (do lado do dispositivo e do 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 todas as etapas. 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 de inicialização do kernel do 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 enfileira 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 para este mesmo programa mostra pequenos intervalos entre kernels onde o host está ocupado iniciando kernels na GPU.

image

Ao iniciar muitas operações pequenas na GPU (como um add 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 em 2,77 segundos. Assim, cada kernel tem cerca de 21,9 μs, o que é muito pequeno (aproximadamente o mesmo tempo que a latência de inicialização) e pode resultar em atrasos na inicialização do kernel do host.

image

Se o 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 trabalhe mais, o que manterá a GPU ocupada por mais tempo.
  • Certifique-se de estar usando tf.function para criar gráficos do TensorFlow, para que você não esteja executando operações em um modo ansioso puro. 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ê.
  • Fundir kernels usando XLA com tf.function(jit_compile=True) ou auto-clustering. Para obter mais detalhes, acesse a seção Habilitar precisão mista e XLA abaixo para saber como habilitar o XLA para obter maior desempenho. Esse recurso pode levar à 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 em relação ao dispositivo (você também pode verificar o posicionamento de operações específicas observando o visualizador de rastreamento . Como na imagem abaixo, você deseja que 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 deve ser colocada 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 um op a ser colocado em um determinado dispositivo, sua implementação pode 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 de memória excessivas 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 pode ser fácil associar um memCopy a uma operação. Nesses casos, é útil observar as operações próximas para verificar se a cópia de memória ocorre no mesmo local em todas as etapas.

3. Kernels mais eficientes em GPUs

Quando 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 de kernels qualificados.

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 (veja a seção Habilitando Precisão Mista abaixo) é uma maneira de fazer com que os kernels General Matrix Multiply (GEMM) do seu programa (matmul ops) utilizem o Tensor Core. Os kernels de GPU usam os Tensor Cores com eficiência 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 aprendizado profundo 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 de desempenho significativos. Para saber mais, consulte o guia XLA .

3. Habilite precisão mista e XLA

Depois de seguir as etapas acima, habilitar precisão mista e 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. Ative a precisão mista

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

Certifique-se de que as dimensões de matriz/tensor atendam aos requisitos para chamar kernels que usam Tensor Cores. Os kernels de GPU usam os Tensor Cores com eficiência 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 o 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 ativado, os kernels de multiplicação de matrizes (GEMM) do seu programa devem usar a versão correspondente do fp16 que utiliza os Tensor Cores. No entanto, em alguns casos, isso não acontece e você não experimenta a aceleração esperada ao habilitar fp16 , pois seu programa volta para a implementação ineficiente.

image

A página de estatísticas do kernel da GPU mostra quais operações são elegíveis ao Tensor Core e quais kernels estão realmente usando o eficiente Tensor Core. 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 de usar fp16 também serão mostrados em kernels que anteriormente eram vinculados à memória, pois agora as operações levarão metade do tempo.

2. Escala de perda dinâmica vs. estática

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

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

2. Habilite XLA com tf.function(jit_compile=True) ou auto-clustering

Como etapa final para obter o melhor desempenho com uma única GPU, você pode experimentar habilitar 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 XLA em seu programa com tf.function(jit_compile=True) ou auto-clustering, consulte o guia XLA .

Você pode definir o nível JIT global para -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ável, 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 seja idealmente escalável, à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 é experimentar a escala de desempenho apenas com a sobrecarga adicional de comunicação de gradiente e maior utilização de thread de host. Devido a essa 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 de comunicação extra ao treinar em várias GPUs. Há alguma sobrecarga para concatenar os gradientes, comunicá-los entre as 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 um melhor desempenho ao otimizar o desempenho no cenário de várias GPUs:

  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. O uso do criador de perfil de memória ajuda a ter uma noção de quão próximo 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 é superado 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 garantir 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 em 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 sobrecarregada porque o host erroneamente acaba colocando mais trabalho nela.
  6. Por fim, verifique a etapa de treinamento em todas as GPUs em sua 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 nessa 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.

Após computar as passagens para frente e para trás pelo modelo, os gradientes calculados em cada dispositivo precisam ser agregados e reduzidos. Esse gradiente AllReduce acontece após o cálculo do gradiente em cada dispositivo e antes que o otimizador atualize 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 ocorrer 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)

Esse 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 o fp16 habilitado, o NCCL AllReduce utiliza os parâmetros fp32 .

Para obter os benefícios do dimensionamento, o tempo de etapa 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 de host de 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, iniciar kernels em outra GPU em uma ordem não determinística . Isso pode causar uma inclinação ou dimensionamento negativo, o que pode afetar negativamente o desempenho.

O visualizador de rastreamento abaixo mostra a sobrecarga quando a CPU cambaleia o kernel da GPU é iniciado de forma ineficiente, pois GPU1 está ociosa e começa a executar operações após GPU2 .

image

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

image

Se você experimentar 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 TF_GPU_THREAD_MODE como gpu_private . Essa variável de ambiente informará ao host para manter os threads de uma GPU privados.
  • Por padrão, TF_GPU_THREAD_MODE=gpu_private configura o número de encadeamentos para 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 para o número desejado de threads.