Optimice el rendimiento de la GPU de TensorFlow con TensorFlow Profiler

Visión general

Esta guía le mostrará cómo usar TensorFlow Profiler con TensorBoard para obtener información y obtener el máximo rendimiento de sus GPU, y depurar cuando una o más de sus GPU están infrautilizadas.

Si eres nuevo en el generador de perfiles:

Tenga en cuenta que la descarga de cálculos a la GPU puede no ser siempre beneficiosa, especialmente para modelos pequeños. Puede haber gastos generales debido a:

  • Transferencia de datos entre el host (CPU) y el dispositivo (GPU); y
  • Debido a la latencia involucrada cuando el host inicia núcleos de GPU.

Flujo de trabajo de optimización del rendimiento

Esta guía describe cómo depurar problemas de rendimiento comenzando con una sola GPU y luego pasando a un solo host con varias GPU.

Se recomienda depurar los problemas de rendimiento en el siguiente orden:

  1. Optimice y depure el rendimiento en una GPU:
    1. Compruebe si la canalización de entrada es un cuello de botella.
    2. Depurar el rendimiento de una GPU.
    3. Habilite la precisión mixta (con fp16 (float16)) y, opcionalmente, habilite XLA .
  2. Optimice y depure el rendimiento en el host único multi-GPU.

Por ejemplo, si está utilizando una estrategia de distribución de TensorFlow para entrenar un modelo en un solo host con varias GPU y observa una utilización de GPU subóptima, primero debe optimizar y depurar el rendimiento de una GPU antes de depurar el sistema de varias GPU.

Como punto de referencia para obtener un código de alto rendimiento en las GPU, esta guía asume que ya está utilizando tf.function . Las API Keras Model.compile y Model.fit utilizarán tf.function automáticamente bajo el capó. Al escribir un ciclo de entrenamiento personalizado con tf.GradientTape , consulte Mejor rendimiento con tf.function sobre cómo habilitar tf.function s.

Las siguientes secciones analizan los enfoques sugeridos para cada uno de los escenarios anteriores para ayudar a identificar y corregir los cuellos de botella en el rendimiento.

1. Optimice el rendimiento en una GPU

En un caso ideal, su programa debe tener una alta utilización de GPU, una comunicación mínima de CPU (el host) a GPU (el dispositivo) y sin sobrecarga de la canalización de entrada.

El primer paso para analizar el rendimiento es obtener un perfil para un modelo que se ejecuta con una GPU.

La página de descripción general de Profiler de TensorBoard, que muestra una vista de nivel superior de cómo se desempeñó su modelo durante una ejecución de perfil, puede brindar una idea de qué tan lejos está su programa del escenario ideal.

TensorFlow Profiler Overview Page

Los números clave para prestar atención a la página de resumen son:

  1. ¿Qué parte del tiempo del paso corresponde a la ejecución real del dispositivo?
  2. El porcentaje de operaciones colocadas en el dispositivo frente al host
  3. ¿Cuántos núcleos usan fp16

Lograr un rendimiento óptimo significa maximizar estos números en los tres casos. Para obtener una comprensión profunda de su programa, deberá estar familiarizado con el visor de seguimiento Profiler de TensorBoard. Las siguientes secciones muestran algunos patrones comunes del visor de seguimiento que debe buscar al diagnosticar cuellos de botella en el rendimiento.

A continuación se muestra una imagen de una vista de seguimiento del modelo que se ejecuta en una GPU. Desde las secciones TensorFlow Name Scope y TensorFlow Ops , puede identificar diferentes partes del modelo, como el pase hacia adelante, la función de pérdida, el cálculo del gradiente/paso hacia atrás y la actualización del peso del optimizador. También puede hacer que las operaciones se ejecuten en la GPU junto a cada flujo , que se refieren a flujos CUDA. Cada secuencia se utiliza para tareas específicas. En este seguimiento, Stream#118 se usa para iniciar kernels de cómputo y copias de dispositivo a dispositivo. Stream#119 se utiliza para la copia de host a dispositivo y Stream#120 para la copia de dispositivo a host.

El seguimiento a continuación muestra las características comunes de un modelo de alto rendimiento.

image

Por ejemplo, la línea de tiempo de procesamiento de la GPU ( Stream#118 ) parece "ocupada" con muy pocos espacios. Hay copias mínimas del host al dispositivo ( Transmisión n.º 119 ) y del dispositivo al host ( Transmisión n.º 120 ), así como espacios mínimos entre los pasos. Cuando ejecuta Profiler para su programa, es posible que no pueda identificar estas características ideales en su vista de seguimiento. El resto de esta guía cubre escenarios comunes y cómo solucionarlos.

1. Depurar la canalización de entrada

El primer paso en la depuración del rendimiento de la GPU es determinar si su programa está vinculado a la entrada. La forma más fácil de resolver esto es usar el analizador de canalización de entrada de Profiler, en TensorBoard, que proporciona una descripción general del tiempo empleado en la canalización de entrada.

image

Puede tomar las siguientes acciones potenciales si su canal de entrada contribuye significativamente al tiempo de paso:

  • Puede usar la guía específica de tf.data para aprender a depurar su canalización de entrada.
  • Otra forma rápida de verificar si la tubería de entrada es el cuello de botella es usar datos de entrada generados aleatoriamente que no necesitan ningún procesamiento previo. Aquí hay un ejemplo del uso de esta técnica para un modelo ResNet. Si la canalización de entrada es óptima, debería experimentar un rendimiento similar con datos reales y con datos aleatorios/sintéticos generados. La única sobrecarga en el caso de los datos sintéticos se deberá a la copia de los datos de entrada que, de nuevo, se pueden precargar y optimizar.

Además, consulte las prácticas recomendadas para optimizar la canalización de datos de entrada .

2. Depurar el rendimiento de una GPU

Hay varios factores que pueden contribuir a la baja utilización de la GPU. A continuación, se muestran algunos escenarios comúnmente observados al mirar el visor de seguimiento y las posibles soluciones.

1. Analiza las brechas entre los pasos

Una observación común cuando su programa no funciona de manera óptima son las brechas entre los pasos de entrenamiento. En la imagen de la vista de seguimiento a continuación, hay una gran brecha entre los pasos 8 y 9, lo que significa que la GPU está inactiva durante ese tiempo.

image

Si su visor de seguimiento muestra grandes espacios entre los pasos, esto podría ser una indicación de que su programa está vinculado a la entrada. En ese caso, debe consultar la sección anterior sobre la depuración de su tubería de entrada si aún no lo ha hecho.

Sin embargo, incluso con una canalización de entrada optimizada, aún puede haber espacios entre el final de un paso y el comienzo de otro debido a la contención de subprocesos de la CPU. tf.data utiliza subprocesos en segundo plano para paralelizar el procesamiento de canalización. Estos subprocesos pueden interferir con la actividad del lado del host de GPU que ocurre al comienzo de cada paso, como la copia de datos o la programación de operaciones de GPU.

Si observa grandes brechas en el lado del host, que programa estas operaciones en la GPU, puede configurar la variable de entorno TF_GPU_THREAD_MODE=gpu_private . Esto garantiza que los núcleos de GPU se inicien desde sus propios subprocesos dedicados y no se queden en cola detrás del trabajo de tf.data .

Las brechas entre los pasos también pueden deberse a cálculos de métricas, devoluciones de llamadas de Keras u operaciones fuera de tf.function que se ejecutan en el host. Estas operaciones no tienen un rendimiento tan bueno como las operaciones dentro de un gráfico de TensorFlow. Además, algunas de estas operaciones se ejecutan en la CPU y copian tensores de un lado a otro de la GPU.

Si después de optimizar su canalización de entrada aún nota brechas entre los pasos en el visor de seguimiento, debe mirar el código del modelo entre los pasos y verificar si deshabilitar las devoluciones de llamadas/métricas mejora el rendimiento. Algunos detalles de estas operaciones también se encuentran en el visor de seguimiento (tanto del lado del dispositivo como del host). La recomendación en este escenario es amortizar la sobrecarga de estas operaciones ejecutándolas después de un número fijo de pasos en lugar de cada paso. Al usar el método Model.compile en la API de tf.keras , configurar el indicador steps_per_execution lo hace automáticamente. Para bucles de entrenamiento personalizados, use tf.while_loop .

2. Lograr una mayor utilización del dispositivo

1. Retrasos en el lanzamiento de kernels de GPU pequeños y kernel host

El host pone en cola los núcleos para que se ejecuten en la GPU, pero hay una latencia (alrededor de 20-40 μs) antes de que los núcleos se ejecuten realmente en la GPU. En un caso ideal, el host pone en cola suficientes kernels en la GPU de modo que la GPU pase la mayor parte de su tiempo ejecutando, en lugar de esperar a que el host ponga en cola más kernels.

La página de descripción general de Profiler en TensorBoard muestra cuánto tiempo estuvo inactiva la GPU debido a la espera de que el host iniciara los kernels. En la imagen a continuación, la GPU está inactiva durante aproximadamente el 10 % del tiempo de paso a la espera de que se inicien los núcleos.

image

El visor de seguimiento de este mismo programa muestra pequeños espacios entre los núcleos donde el host está ocupado lanzando núcleos en la GPU.

image

Al lanzar muchas operaciones pequeñas en la GPU (como una adición escalar, por ejemplo), es posible que el host no se mantenga al día con la GPU. La herramienta TensorFlow Stats en TensorBoard para el mismo perfil muestra 126 224 operaciones Mul en 2,77 segundos. Por lo tanto, cada kernel tiene aproximadamente 21,9 μs, que es muy pequeño (casi el mismo tiempo que la latencia de lanzamiento) y puede provocar retrasos en el lanzamiento del kernel del host.

image

Si su visor de seguimiento muestra muchos espacios pequeños entre operaciones en la GPU como en la imagen de arriba, puede:

  • Concatene tensores pequeños y use operaciones vectorizadas o use un tamaño de lote más grande para hacer que cada kernel lanzado haga más trabajo, lo que mantendrá la GPU ocupada por más tiempo.
  • Asegúrese de estar usando tf.function para crear gráficos de TensorFlow, de modo que no esté ejecutando operaciones en un modo puro y ansioso. Si está utilizando Model.fit (a diferencia de un ciclo de entrenamiento personalizado con tf.GradientTape ), entonces tf.keras.Model.compile lo hará automáticamente por usted.
  • Fusionar núcleos usando XLA con tf.function(jit_compile=True) o agrupación automática. Para obtener más detalles, vaya a la sección Habilitar precisión mixta y XLA a continuación para obtener información sobre cómo habilitar XLA para obtener un mayor rendimiento. Esta característica puede conducir a una alta utilización del dispositivo.
2. Colocación de operaciones de TensorFlow

La página de descripción general de Profiler le muestra el porcentaje de operaciones colocadas en el host frente al dispositivo (también puede verificar la ubicación de operaciones específicas mirando el visor de seguimiento . Como en la imagen a continuación, desea que el porcentaje de operaciones en el host ser muy pequeño en comparación con el dispositivo.

image

Idealmente, la mayoría de las operaciones de cómputo intensivo deberían colocarse en la GPU.

Para averiguar a qué dispositivos están asignadas las operaciones y los tensores de su modelo, configure tf.debugging.set_log_device_placement(True) como la primera declaración de su programa.

Tenga en cuenta que, en algunos casos, incluso si especifica que se coloque una operación en un dispositivo en particular, su implementación podría anular esta condición (ejemplo: tf.unique ). Incluso para el entrenamiento de una sola GPU, especificar una estrategia de distribución, como tf.distribute.OneDeviceStrategy , puede generar una ubicación más determinista de las operaciones en su dispositivo.

Una razón para colocar la mayoría de las operaciones en la GPU es evitar copias de memoria excesivas entre el host y el dispositivo (se esperan copias de memoria para los datos de entrada/salida del modelo entre el host y el dispositivo). Un ejemplo de copia excesiva se muestra en la vista de seguimiento a continuación en los flujos de GPU #167 , #168 y #169 .

image

Estas copias a veces pueden dañar el rendimiento si bloquean la ejecución de los kernels de GPU. Las operaciones de copia de memoria en el visor de seguimiento tienen más información sobre las operaciones que son el origen de estos tensores copiados, pero puede que no siempre sea fácil asociar una memCopy con una operación. En estos casos, es útil mirar las operaciones cercanas para verificar si la copia de la memoria se realiza en la misma ubicación en cada paso.

3. Kernels más eficientes en GPU

Una vez que la utilización de GPU de su programa sea aceptable, el siguiente paso es buscar aumentar la eficiencia de los núcleos de GPU mediante el uso de Tensor Cores o fusionando operaciones.

1. Utilizar núcleos tensoriales

Las GPU NVIDIA® modernas tienen núcleos Tensor especializados que pueden mejorar significativamente el rendimiento de los kernels elegibles.

Puede usar las estadísticas del kernel de GPU de TensorBoard para visualizar qué kernels de GPU son elegibles para Tensor Core y qué kernels usan Tensor Cores. Habilitar fp16 (consulte la sección Habilitar precisión mixta a continuación) es una forma de hacer que los núcleos (operaciones matmul) de Multiplicación de matriz general (GEMM) de su programa utilicen Tensor Core. Los kernels de GPU usan los Tensor Cores de manera eficiente cuando la precisión es fp16 y las dimensiones del tensor de entrada/salida son divisibles por 8 o 16 (para int8 ).

Para obtener otras recomendaciones detalladas sobre cómo hacer que los núcleos sean eficientes para las GPU, consulte la guía de rendimiento de aprendizaje profundo de NVIDIA® .

2. Operaciones con fusibles

Use tf.function(jit_compile=True) para fusionar operaciones más pequeñas para formar núcleos más grandes que generen mejoras significativas en el rendimiento. Para obtener más información, consulte la guía XLA .

3. Habilite la precisión mixta y XLA

Después de seguir los pasos anteriores, habilitar la precisión mixta y XLA son dos pasos opcionales que puede seguir para mejorar aún más el rendimiento. El enfoque sugerido es habilitarlos uno por uno y verificar que los beneficios de rendimiento sean los esperados.

1. Habilite la precisión mixta

La guía de precisión mixta de TensorFlow muestra cómo habilitar la precisión fp16 en las GPU. Habilite AMP en las GPU NVIDIA® para usar Tensor Cores y obtenga hasta 3 veces más de velocidad general en comparación con usar solo fp32 (float32) en Volta y arquitecturas de GPU más nuevas.

Asegúrese de que las dimensiones de matriz/tensor satisfagan los requisitos para llamar a los kernels que usan Tensor Cores. Los núcleos de GPU usan los Tensor Cores de manera eficiente cuando la precisión es fp16 y las dimensiones de entrada/salida son divisibles por 8 o 16 (para int8).

Tenga en cuenta que con cuDNN v7.6.3 y versiones posteriores, las dimensiones de convolución se rellenarán automáticamente cuando sea necesario para aprovechar Tensor Cores.

Siga las mejores prácticas a continuación para maximizar los beneficios de rendimiento de la precisión fp16 .

1. Utilice kernels fp16 óptimos

Con fp16 habilitado, los núcleos de multiplicación de matrices (GEMM) de su programa deben usar la versión correspondiente fp16 que utiliza los Tensor Cores. Sin embargo, en algunos casos, esto no sucede y no experimenta la aceleración esperada al habilitar fp16 , ya que su programa recurre a la implementación ineficiente.

image

La página de estadísticas del kernel de la GPU muestra qué operaciones son elegibles para Tensor Core y qué kernels realmente usan el eficiente Tensor Core. La guía de NVIDIA® sobre el rendimiento del aprendizaje profundo contiene sugerencias adicionales sobre cómo aprovechar los Tensor Cores. Además, los beneficios de usar fp16 también se mostrarán en kernels que anteriormente estaban limitados a la memoria, ya que ahora las operaciones llevarán la mitad del tiempo.

2. Escalado de pérdidas dinámico frente a estático

La escala de pérdida es necesaria cuando se usa fp16 para evitar el subdesbordamiento debido a la baja precisión. Hay dos tipos de escalado de pérdida, dinámico y estático, los cuales se explican con mayor detalle en la guía Precisión mixta . Puede usar la política mixed_float16 para habilitar automáticamente el escalado de pérdidas dentro del optimizador de Keras.

Al intentar optimizar el rendimiento, es importante recordar que el escalado dinámico de pérdidas puede introducir operaciones condicionales adicionales que se ejecutan en el host y generar brechas que serán visibles entre los pasos en el visor de seguimiento. Por otro lado, el escalado de pérdida estática no tiene tales gastos generales y puede ser una mejor opción en términos de rendimiento con el inconveniente de que debe especificar el valor de escala de pérdida estática correcto.

2. Habilite XLA con tf.function(jit_compile=True) o agrupamiento automático

Como paso final para obtener el mejor rendimiento con una sola GPU, puede experimentar con la habilitación de XLA, lo que fusionará las operaciones y conducirá a una mejor utilización del dispositivo y una menor huella de memoria. Para obtener detalles sobre cómo habilitar XLA en su programa con tf.function(jit_compile=True) o agrupación automática, consulte la guía XLA .

Puede establecer el nivel JIT global en -1 (desactivado), 1 o 2 . Un nivel más alto es más agresivo y puede reducir el paralelismo y usar más memoria. Establezca el valor en 1 si tiene restricciones de memoria. Tenga en cuenta que XLA no funciona bien para modelos con formas de tensor de entrada variable, ya que el compilador XLA tendría que seguir compilando kernels cada vez que encuentre nuevas formas.

2. Optimice el rendimiento en el host único multi-GPU

La API tf.distribute.MirroredStrategy se puede usar para escalar el entrenamiento de modelos de una GPU a varias GPU en un solo host. (Para obtener más información sobre cómo realizar un entrenamiento distribuido con TensorFlow, consulte las guías Entrenamiento distribuido con TensorFlow , Uso de una GPU y Uso de TPU y el tutorial Entrenamiento distribuido con Keras ).

Aunque la transición de una GPU a varias GPU idealmente debería ser escalable desde el primer momento, a veces puede encontrar problemas de rendimiento.

Cuando se pasa del entrenamiento con una sola GPU a varias GPU en el mismo host, idealmente debería experimentar la escala de rendimiento con solo la sobrecarga adicional de la comunicación de gradiente y una mayor utilización del subproceso del host. Debido a esta sobrecarga, no tendrá una aceleración exacta de 2x si pasa de 1 a 2 GPU, por ejemplo.

La siguiente vista de seguimiento muestra un ejemplo de la sobrecarga de comunicación adicional cuando se entrena en varias GPU. Hay algunos gastos generales para concatenar los gradientes, comunicarlos entre réplicas y dividirlos antes de realizar la actualización del peso.

image

La siguiente lista de verificación lo ayudará a lograr un mejor rendimiento al optimizar el rendimiento en el escenario de múltiples GPU:

  1. Intente maximizar el tamaño del lote, lo que conducirá a una mayor utilización del dispositivo y amortizará los costos de comunicación entre varias GPU. El uso del generador de perfiles de memoria ayuda a tener una idea de qué tan cerca está su programa de la utilización máxima de la memoria. Tenga en cuenta que si bien un tamaño de lote más alto puede afectar la convergencia, esto generalmente se ve compensado por los beneficios de rendimiento.
  2. Al pasar de una sola GPU a varias GPU, el mismo host ahora tiene que procesar muchos más datos de entrada. Entonces, después de (1), se recomienda volver a verificar el rendimiento de la canalización de entrada y asegurarse de que no sea un cuello de botella.
  3. Verifique la línea de tiempo de GPU en la vista de seguimiento de su programa para ver si hay llamadas de AllReduce innecesarias, ya que esto da como resultado una sincronización en todos los dispositivos. En la vista de seguimiento que se muestra arriba, AllReduce se realiza a través del kernel NCCL y solo hay una llamada NCCL en cada GPU para los gradientes en cada paso.
  4. Busque operaciones de copia D2H, H2D y D2D innecesarias que se puedan minimizar.
  5. Verifique el tiempo de paso para asegurarse de que cada réplica esté haciendo el mismo trabajo. Por ejemplo, puede suceder que una GPU (generalmente, GPU0 ) esté suscrita en exceso porque el host, por error, termina poniendo más trabajo en ella.
  6. Por último, verifique el paso de entrenamiento en todas las GPU en su vista de seguimiento para ver si hay operaciones que se ejecutan secuencialmente. Esto suele suceder cuando su programa incluye dependencias de control de una GPU a otra. En el pasado, la depuración del rendimiento en esta situación se resolvía caso por caso. Si observa este comportamiento en su programa, registre un problema de GitHub con imágenes de su vista de seguimiento.

1. Optimizar gradiente AllReduce

Cuando se entrena con una estrategia síncrona, cada dispositivo recibe una parte de los datos de entrada.

Después de calcular los pases hacia adelante y hacia atrás a través del modelo, los gradientes calculados en cada dispositivo deben agregarse y reducirse. Este gradiente AllReduce ocurre después del cálculo del gradiente en cada dispositivo y antes de que el optimizador actualice los pesos del modelo.

Cada GPU primero concatena los degradados a través de las capas del modelo, los comunica a través de las GPU mediante tf.distribute.CrossDeviceOps ( tf.distribute.NcclAllReduce es el predeterminado) y luego devuelve los degradados después de la reducción por capa.

El optimizador utilizará estos gradientes reducidos para actualizar los pesos de su modelo. Idealmente, este proceso debería ocurrir al mismo tiempo en todas las GPU para evitar gastos generales.

El tiempo para AllReduce debe ser aproximadamente el mismo que:

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

Este cálculo es útil como una verificación rápida para comprender si el rendimiento que tiene cuando ejecuta un trabajo de entrenamiento distribuido es el esperado o si necesita realizar una depuración adicional del rendimiento. Puede obtener la cantidad de parámetros en su modelo de Model.summary .

Tenga en cuenta que cada parámetro del modelo tiene un tamaño de 4 bytes, ya que TensorFlow usa fp32 (float32) para comunicar gradientes. Incluso cuando tiene habilitado fp16 , NCCL AllReduce utiliza parámetros fp32 .

Para obtener los beneficios de escalar, el tiempo de paso debe ser mucho más alto en comparación con estos gastos generales. Una forma de lograr esto es usar un tamaño de lote mayor, ya que el tamaño del lote afecta el tiempo del paso, pero no afecta la sobrecarga de comunicación.

2. Contención de subprocesos de host de GPU

Cuando se ejecutan varias GPU, el trabajo de la CPU es mantener todos los dispositivos ocupados mediante el lanzamiento eficiente de núcleos de GPU en todos los dispositivos.

Sin embargo, cuando hay muchas operaciones independientes que la CPU puede programar en una GPU, la CPU puede decidir usar muchos de sus subprocesos de host para mantener ocupada una GPU y luego lanzar kernels en otra GPU en un orden no determinista. . Esto puede provocar un sesgo o una escala negativa, lo que puede afectar negativamente al rendimiento.

El visor de seguimiento a continuación muestra la sobrecarga cuando la CPU escalona el kernel de la GPU y se inicia de manera ineficiente, ya que la GPU1 está inactiva y luego comienza a ejecutar operaciones después de que se haya iniciado GPU2 .

image

La vista de seguimiento del host muestra que el host está iniciando núcleos en GPU2 antes de iniciarlos en GPU1 (tenga en cuenta que las siguientes operaciones tf_Compute* no son indicativas de subprocesos de CPU).

image

Si experimenta este tipo de escalonamiento de los núcleos de GPU en la vista de seguimiento de su programa, la acción recomendada es:

  • Establezca la variable de entorno TensorFlow TF_GPU_THREAD_MODE en gpu_private . Esta variable de entorno le indicará al host que mantenga privados los subprocesos para una GPU.
  • De forma predeterminada, TF_GPU_THREAD_MODE=gpu_private establece la cantidad de subprocesos en 2, lo que es suficiente en la mayoría de los casos. Sin embargo, ese número se puede cambiar configurando la variable de entorno de TensorFlow TF_GPU_THREAD_COUNT en el número deseado de subprocesos.