Analice el rendimiento de tf.data con TF Profiler

Descripción general

Esta guía asume familiaridad con TensorFlow Profiler y tf.data . Su objetivo es proporcionar instrucciones paso a paso con ejemplos para ayudar a los usuarios a diagnosticar y solucionar problemas de rendimiento de la canalización de entrada.

Para comenzar, recopile un perfil de su trabajo de TensorFlow. Las instrucciones sobre cómo hacerlo están disponibles para CPU/GPU y Cloud TPU .

TensorFlow Trace Viewer

El flujo de trabajo de análisis que se detalla a continuación se centra en la herramienta de visualización de seguimiento en Profiler. Esta herramienta muestra una línea de tiempo que muestra la duración de las operaciones ejecutadas por su programa TensorFlow y le permite identificar qué operaciones tardan más en ejecutarse. Para obtener más información sobre el visor de seguimiento, consulte esta sección de la guía TF Profiler. En general, los eventos tf.data aparecerán en la línea de tiempo de la CPU del host.

Flujo de trabajo de análisis

Siga el flujo de trabajo a continuación. Si tiene comentarios que nos ayuden a mejorarlo, cree un problema de github con la etiqueta "comp:data".

1. ¿Su canal tf.data está produciendo datos lo suficientemente rápido?

Comience por determinar si la canalización de entrada es el cuello de botella de su programa TensorFlow.

Para hacerlo, busque las operaciones IteratorGetNext::DoCompute en el visor de seguimiento. En general, espera verlos al comienzo de un paso. Estos sectores representan el tiempo que le toma a su canal de entrada generar un lote de elementos cuando se solicita. Si está utilizando keras o iterando sobre su conjunto de datos en un tf.function , estos deben encontrarse en los subprocesos tf_data_iterator_get_next .

Tenga en cuenta que si está utilizando una estrategia de distribución , es posible que vea eventos IteratorGetNextAsOptional::DoCompute en lugar de IteratorGetNext::DoCompute (a partir de TF 2.3).

image

Si las llamadas regresan rápidamente (<= 50 us), esto significa que tus datos están disponibles cuando se solicitan. El canal de entrada no es su cuello de botella; consulte la guía Profiler para obtener consejos de análisis de rendimiento más genéricos.

image

Si las llamadas regresan lentamente, tf.data no puede seguir el ritmo de las solicitudes del consumidor. Continúe con la siguiente sección.

2. ¿Estás buscando datos previamente?

La mejor práctica para el rendimiento de la canalización de entrada es insertar una transformación tf.data.Dataset.prefetch al final de la canalización tf.data . Esta transformación superpone el cálculo de preprocesamiento de la canalización de entrada con el siguiente paso del cálculo del modelo y es necesaria para un rendimiento óptimo de la canalización de entrada al entrenar su modelo. Si está precargando datos, debería ver un segmento Iterator::Prefetch en el mismo hilo que la operación IteratorGetNext::DoCompute .

image

Si no tiene una prefetch al final de su canalización , debe agregar una. Para obtener más información sobre las recomendaciones de rendimiento tf.data , consulte la guía de rendimiento de tf.data .

Si ya está precargando datos y la canalización de entrada sigue siendo su cuello de botella, continúe con la siguiente sección para analizar más a fondo el rendimiento.

3. ¿Está alcanzando un uso elevado de la CPU?

tf.data logra un alto rendimiento al intentar hacer el mejor uso posible de los recursos disponibles. En general, incluso cuando ejecuta su modelo en un acelerador como GPU o TPU, las canalizaciones tf.data se ejecutan en la CPU. Puede verificar su utilización con herramientas como sar y htop , o en la consola de monitoreo de la nube si está ejecutando GCP.

Si su utilización es baja, esto sugiere que es posible que su canal de entrada no esté aprovechando al máximo la CPU del host. Debe consultar la guía de rendimiento de tf.data para conocer las mejores prácticas. Si ha aplicado las mejores prácticas y la utilización y el rendimiento siguen siendo bajos, continúe con el análisis de cuellos de botella a continuación.

Si su utilización se acerca al límite de recursos , para mejorar aún más el rendimiento, debe mejorar la eficiencia de su canal de entrada (por ejemplo, evitando cálculos innecesarios) o descargar el cálculo.

Puede mejorar la eficiencia de su canal de entrada evitando cálculos innecesarios en tf.data . Una forma de hacerlo es insertar una transformación tf.data.Dataset.cache después de un trabajo computacional intensivo si sus datos caben en la memoria; esto reduce el cálculo a costa de un mayor uso de memoria. Además, deshabilitar el paralelismo intraoperatorio en tf.data tiene el potencial de aumentar la eficiencia en > 10 % y se puede lograr configurando la siguiente opción en su canal de entrada:

dataset = ...
options = tf.data.Options()
options.experimental_threading.max_intra_op_parallelism = 1
dataset = dataset.with_options(options)

4. Análisis de cuellos de botella

La siguiente sección explica cómo leer eventos tf.data en el visor de seguimiento para comprender dónde está el cuello de botella y las posibles estrategias de mitigación.

Comprender los eventos tf.data en Profiler

Cada evento tf.data en Profiler tiene el nombre Iterator::<Dataset> , donde <Dataset> es el nombre de la fuente o transformación del conjunto de datos. Cada evento también tiene el nombre largo Iterator::<Dataset_1>::...::<Dataset_n> , que puede ver al hacer clic en el evento tf.data . En el nombre largo, <Dataset_n> coincide con <Dataset> del nombre (corto) y los otros conjuntos de datos en el nombre largo representan transformaciones posteriores.

image

Por ejemplo, la captura de pantalla anterior se generó a partir del siguiente código:

dataset = tf.data.Dataset.range(10)
dataset = dataset.map(lambda x: x)
dataset = dataset.repeat(2)
dataset = dataset.batch(5)

Aquí, el evento Iterator::Map tiene el nombre largo Iterator::BatchV2::FiniteRepeat::Map . Tenga en cuenta que el nombre del conjunto de datos puede diferir ligeramente del de la API de Python (por ejemplo, FiniteRepeat en lugar de Repetir), pero debe ser lo suficientemente intuitivo para analizarlo.

Transformaciones sincrónicas y asincrónicas.

Para transformaciones tf.data sincrónicas (como Batch y Map ), verá eventos de transformaciones ascendentes en el mismo hilo. En el ejemplo anterior, dado que todas las transformaciones utilizadas son sincrónicas, todos los eventos aparecen en el mismo hilo.

Para transformaciones asincrónicas (como Prefetch , ParallelMap , ParallelInterleave y MapAndBatch ), los eventos de transformaciones ascendentes estarán en un subproceso diferente. En tales casos, el "nombre largo" puede ayudarle a identificar a qué transformación en una canalización corresponde un evento.

image

Por ejemplo, la captura de pantalla anterior se generó a partir del siguiente código:

dataset = tf.data.Dataset.range(10)
dataset = dataset.map(lambda x: x)
dataset = dataset.repeat(2)
dataset = dataset.batch(5)
dataset = dataset.prefetch(1)

Aquí, los eventos Iterator::Prefetch están en los subprocesos tf_data_iterator_get_next . Dado que Prefetch es asincrónico, sus eventos de entrada ( BatchV2 ) estarán en un subproceso diferente y se pueden ubicar buscando el nombre largo Iterator::Prefetch::BatchV2 . En este caso, están en el hilo tf_data_iterator_resource . Por su nombre largo, se puede deducir que BatchV2 está en sentido ascendente de Prefetch . Además, el parent_id del evento BatchV2 coincidirá con el ID del evento Prefetch .

Identificando el cuello de botella

En general, para identificar el cuello de botella en su canalización de entrada, recorra la canalización de entrada desde la transformación más externa hasta el origen. A partir de la transformación final en su canalización, recurra a transformaciones ascendentes hasta que encuentre una transformación lenta o llegue a un conjunto de datos de origen, como TFRecord . En el ejemplo anterior, comenzaría desde Prefetch , luego avanzaría hacia BatchV2 , FiniteRepeat , Map y finalmente Range .

En general, una transformación lenta corresponde a aquella cuyos eventos son largos, pero cuyos eventos de entrada son cortos. A continuación se muestran algunos ejemplos.

Tenga en cuenta que la transformación final (la más externa) en la mayoría de las canalizaciones de entrada del host es el evento Iterator::Model . La transformación del modelo la introduce automáticamente el tiempo de ejecución tf.data y se utiliza para instrumentar y ajustar automáticamente el rendimiento de la canalización de entrada.

Si su trabajo utiliza una estrategia de distribución , el visor de seguimiento contendrá eventos adicionales que corresponden a la canalización de entrada del dispositivo. La transformación más externa de la canalización del dispositivo (anidada en IteratorGetNextOp::DoCompute o IteratorGetNextAsOptionalOp::DoCompute ) será un evento Iterator::Prefetch con un evento Iterator::Generator ascendente. Puede encontrar la canalización de host correspondiente buscando eventos Iterator::Model .

Ejemplo 1

image

La captura de pantalla anterior se genera a partir del siguiente canal de entrada:

dataset = tf.data.TFRecordDataset(filename)
dataset = dataset.map(parse_record)
dataset = dataset.batch(32)
dataset = dataset.repeat()

En la captura de pantalla, observe que (1) los eventos Iterator::Map son largos, pero (2) sus eventos de entrada ( Iterator::FlatMap ) regresan rápidamente. Esto sugiere que la transformación secuencial del mapa es el cuello de botella.

Tenga en cuenta que en la captura de pantalla, el evento InstantiatedCapturedFunction::Run corresponde al tiempo que lleva ejecutar la función de mapa.

Ejemplo 2

image

La captura de pantalla anterior se genera a partir del siguiente canal de entrada:

dataset = tf.data.TFRecordDataset(filename)
dataset = dataset.map(parse_record, num_parallel_calls=2)
dataset = dataset.batch(32)
dataset = dataset.repeat()

Este ejemplo es similar al anterior, pero utiliza ParallelMap en lugar de Map. Aquí notamos que (1) los eventos Iterator::ParallelMap son largos, pero (2) sus eventos de entrada Iterator::FlatMap (que están en un subproceso diferente, ya que ParallelMap es asíncrono) son cortos. Esto sugiere que la transformación ParallelMap es el cuello de botella.

Abordar el cuello de botella

Conjuntos de datos de origen

Si ha identificado una fuente de conjunto de datos como cuello de botella, como la lectura de archivos TFRecord, puede mejorar el rendimiento al paralelizar la extracción de datos. Para hacerlo, asegúrese de que sus datos estén divididos en varios archivos y use tf.data.Dataset.interleave con el parámetro num_parallel_calls establecido en tf.data.AUTOTUNE . Si el determinismo no es importante para su programa, puede mejorar aún más el rendimiento configurando el indicador deterministic=False en tf.data.Dataset.interleave a partir de TF 2.2. Por ejemplo, si estás leyendo de TFRecords, puedes hacer lo siguiente:

dataset = tf.data.Dataset.from_tensor_slices(filenames)
dataset = dataset.interleave(tf.data.TFRecordDataset,
  num_parallel_calls=tf.data.AUTOTUNE,
  deterministic=False)

Tenga en cuenta que los archivos fragmentados deben ser razonablemente grandes para amortizar la sobrecarga de abrir un archivo. Para obtener más detalles sobre la extracción de datos en paralelo, consulte esta sección de la guía de rendimiento tf.data .

Conjuntos de datos de transformación

Si ha identificado una transformación tf.data intermedia como el cuello de botella, puede solucionarlo paralelizando la transformación o almacenando en caché el cálculo si sus datos caben en la memoria y es apropiado. Algunas transformaciones como Map tienen contrapartes paralelas; la guía de rendimiento tf.data demuestra cómo paralelizarlos. Otras transformaciones, como Filter , Unbatch y Batch son inherentemente secuenciales; puedes paralelizarlos introduciendo “paralelismo externo”. Por ejemplo, supongamos que su canal de entrada inicialmente se parece al siguiente, con Batch como cuello de botella:

filenames = tf.data.Dataset.list_files(file_path, shuffle=is_training)
dataset = filenames_to_dataset(filenames)
dataset = dataset.batch(batch_size)

Puede introducir un "paralelismo externo" ejecutando múltiples copias de la canalización de entrada sobre entradas fragmentadas y combinando los resultados:

filenames = tf.data.Dataset.list_files(file_path, shuffle=is_training)

def make_dataset(shard_index):
  filenames = filenames.shard(NUM_SHARDS, shard_index)
  dataset = filenames_to_dataset(filenames)
  Return dataset.batch(batch_size)

indices = tf.data.Dataset.range(NUM_SHARDS)
dataset = indices.interleave(make_dataset,
                             num_parallel_calls=tf.data.AUTOTUNE)
dataset = dataset.prefetch(tf.data.AUTOTUNE)

Recursos adicionales