Analysez les performances de tf.data avec TF Profiler

Aperçu

Ce guide suppose que vous êtes familier avec TensorFlow Profiler et tf.data . Il vise à fournir des instructions étape par étape avec des exemples pour aider les utilisateurs à diagnostiquer et à résoudre les problèmes de performances du pipeline d'entrée.

Pour commencer, collectez un profil de votre tâche TensorFlow. Des instructions sur la façon de procéder sont disponibles pour les CPU/GPU et les Cloud TPU .

TensorFlow Trace Viewer

Le flux de travail d'analyse détaillé ci-dessous se concentre sur l'outil de visualisation de traces dans le profileur. Cet outil affiche une chronologie qui montre la durée des opérations exécutées par votre programme TensorFlow et vous permet d'identifier les opérations qui prennent le plus de temps à s'exécuter. Pour plus d'informations sur la visionneuse de traces, consultez cette section du guide TF Profiler. En général, les événements tf.data apparaîtront sur la chronologie du processeur hôte.

Flux de travail d'analyse

Veuillez suivre le flux de travail ci-dessous. Si vous avez des commentaires pour nous aider à l'améliorer, veuillez créer un problème github avec le label « comp:data ».

1. Votre pipeline tf.data produit-il des données suffisamment rapidement ?

Commencez par vérifier si le pipeline d'entrée constitue le goulot d'étranglement de votre programme TensorFlow.

Pour ce faire, recherchez les opérations IteratorGetNext::DoCompute dans la visionneuse de trace. En général, vous vous attendez à les voir au début d’une étape. Ces tranches représentent le temps nécessaire à votre pipeline d'entrée pour produire un lot d'éléments lorsqu'il est demandé. Si vous utilisez des keras ou si vous parcourez votre ensemble de données dans un tf.function , ceux-ci devraient être trouvés dans les threads tf_data_iterator_get_next .

Notez que si vous utilisez une stratégie de distribution , vous pouvez voir les événements IteratorGetNextAsOptional::DoCompute au lieu de IteratorGetNext::DoCompute (à partir de TF 2.3).

image

Si les appels reviennent rapidement (<= 50 us), cela signifie que vos données sont disponibles lorsqu'elles sont demandées. Le pipeline d’entrée n’est pas votre goulot d’étranglement ; consultez le guide Profiler pour des conseils plus génériques sur l’analyse des performances.

image

Si les appels reviennent lentement, tf.data n'est pas en mesure de répondre aux demandes du consommateur. Passez à la section suivante.

2. Effectuez-vous une prélecture des données ?

La meilleure pratique pour les performances du pipeline d'entrée consiste à insérer une transformation tf.data.Dataset.prefetch à la fin de votre pipeline tf.data . Cette transformation chevauche le calcul de prétraitement du pipeline d'entrée avec l'étape suivante du calcul du modèle et est requise pour des performances optimales du pipeline d'entrée lors de la formation de votre modèle. Si vous effectuez une prélecture de données, vous devriez voir une tranche Iterator::Prefetch sur le même thread que l'opération IteratorGetNext::DoCompute .

image

Si vous n'avez pas de prefetch à la fin de votre pipeline , vous devez en ajouter une. Pour plus d'informations sur les recommandations de performances tf.data , consultez le guide des performances de tf.data .

Si vous effectuez déjà une prélecture de données et que le pipeline d'entrée constitue toujours votre goulot d'étranglement, passez à la section suivante pour analyser plus en détail les performances.

3. Atteignez-vous une utilisation élevée du processeur ?

tf.data atteint un débit élevé en essayant d'utiliser au mieux les ressources disponibles. En général, même lorsque vous exécutez votre modèle sur un accélérateur tel qu'un GPU ou un TPU, les pipelines tf.data sont exécutés sur le CPU. Vous pouvez vérifier votre utilisation avec des outils tels que sar et htop , ou dans la console de surveillance cloud si vous utilisez GCP.

Si votre utilisation est faible, cela suggère que votre pipeline d'entrée ne tire peut-être pas pleinement parti du processeur hôte. Vous devriez consulter le guide des performances tf.data pour connaître les meilleures pratiques. Si vous avez appliqué les meilleures pratiques et que l'utilisation et le débit restent faibles, passez à l'analyse des goulots d'étranglement ci-dessous.

Si votre utilisation approche de la limite de ressources , afin d'améliorer encore les performances, vous devez soit améliorer l'efficacité de votre pipeline d'entrée (par exemple, en évitant les calculs inutiles), soit décharger les calculs.

Vous pouvez améliorer l'efficacité de votre pipeline d'entrée en évitant les calculs inutiles dans tf.data . Une façon de procéder consiste à insérer une transformation tf.data.Dataset.cache après un travail de calcul intensif si vos données tiennent en mémoire ; cela réduit le calcul au prix d'une utilisation accrue de la mémoire. De plus, la désactivation du parallélisme intra-opérationnel dans tf.data a le potentiel d'augmenter l'efficacité de > 10 % et peut être effectuée en définissant l'option suivante sur votre pipeline d'entrée :

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

4. Analyse des goulots d'étranglement

La section suivante explique comment lire les événements tf.data dans la visionneuse de trace pour comprendre où se trouve le goulot d'étranglement et les stratégies d'atténuation possibles.

Comprendre les événements tf.data dans le profileur

Chaque événement tf.data dans le profileur porte le nom Iterator::<Dataset> , où <Dataset> est le nom de la source ou de la transformation de l'ensemble de données. Chaque événement porte également le nom long Iterator::<Dataset_1>::...::<Dataset_n> , que vous pouvez voir en cliquant sur l'événement tf.data . Dans le nom long, <Dataset_n> correspond à <Dataset> du nom (court), et les autres ensembles de données dans le nom long représentent les transformations en aval.

image

Par exemple, la capture d'écran ci-dessus a été générée à partir du code suivant :

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

Ici, l'événement Iterator::Map porte le nom long Iterator::BatchV2::FiniteRepeat::Map . Notez que le nom des ensembles de données peut différer légèrement de celui de l'API Python (par exemple, FiniteRepeat au lieu de Repeat), mais doit être suffisamment intuitif pour être analysé.

Transformations synchrones et asynchrones

Pour les transformations tf.data synchrones (telles que Batch et Map ), vous verrez les événements des transformations en amont sur le même thread. Dans l'exemple ci-dessus, puisque toutes les transformations utilisées sont synchrones, tous les événements apparaissent sur le même thread.

Pour les transformations asynchrones (telles que Prefetch , ParallelMap , ParallelInterleave et MapAndBatch ), les événements des transformations en amont se dérouleront sur un thread différent. Dans de tels cas, le « nom long » peut vous aider à identifier à quelle transformation dans un pipeline correspond un événement.

image

Par exemple, la capture d'écran ci-dessus a été générée à partir du code suivant :

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

Ici, les événements Iterator::Prefetch se trouvent sur les threads tf_data_iterator_get_next . Étant donné que Prefetch est asynchrone, ses événements d'entrée ( BatchV2 ) se trouveront sur un thread différent et pourront être localisés en recherchant le nom long Iterator::Prefetch::BatchV2 . Dans ce cas, ils se trouvent sur le thread tf_data_iterator_resource . De son nom long, vous pouvez déduire que BatchV2 est en amont de Prefetch . De plus, le parent_id de l'événement BatchV2 correspondra à l'ID de l'événement Prefetch .

Identifier le goulot d'étranglement

En général, pour identifier le goulot d'étranglement dans votre pipeline d'entrée, parcourez le pipeline d'entrée depuis la transformation la plus externe jusqu'à la source. À partir de la transformation finale de votre pipeline, effectuez une récurrence dans les transformations en amont jusqu'à ce que vous trouviez une transformation lente ou atteigniez un ensemble de données source, tel que TFRecord . Dans l'exemple ci-dessus, vous commenceriez par Prefetch , puis remonteriez vers BatchV2 , FiniteRepeat , Map et enfin Range .

En général, une transformation lente correspond à une transformation dont les événements sont longs, mais dont les événements d'entrée sont courts. Quelques exemples suivent ci-dessous.

Notez que la transformation finale (la plus externe) dans la plupart des pipelines d'entrée de l'hôte est l'événement Iterator::Model . La transformation Modèle est introduite automatiquement par le runtime tf.data et est utilisée pour instrumenter et régler automatiquement les performances du pipeline d'entrée.

Si votre tâche utilise une stratégie de distribution , la visionneuse de trace contiendra des événements supplémentaires qui correspondent au pipeline d'entrée du périphérique. La transformation la plus externe du pipeline de périphériques (imbriqué sous IteratorGetNextOp::DoCompute ou IteratorGetNextAsOptionalOp::DoCompute ) sera un événement Iterator::Prefetch avec un événement Iterator::Generator en amont. Vous pouvez trouver le pipeline hôte correspondant en recherchant les événements Iterator::Model .

Exemple 1

image

La capture d'écran ci-dessus est générée à partir du pipeline d'entrée suivant :

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

Dans la capture d'écran, observez que (1) les événements Iterator::Map sont longs, mais (2) ses événements d'entrée ( Iterator::FlatMap ) reviennent rapidement. Cela suggère que la transformation séquentielle Map est le goulot d'étranglement.

Notez que dans la capture d'écran, l'événement InstantiatedCapturedFunction::Run correspond au temps nécessaire à l'exécution de la fonction map.

Exemple 2

image

La capture d'écran ci-dessus est générée à partir du pipeline d'entrée suivant :

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

Cet exemple est similaire à celui ci-dessus, mais utilise ParallelMap au lieu de Map. Nous remarquons ici que (1) les événements Iterator::ParallelMap sont longs, mais (2) ses événements d'entrée Iterator::FlatMap (qui sont sur un thread différent, puisque ParallelMap est asynchrone) sont courts. Cela suggère que la transformation ParallelMap constitue le goulot d'étranglement.

Résoudre le goulot d'étranglement

Ensembles de données sources

Si vous avez identifié une source d'ensemble de données comme goulot d'étranglement, comme la lecture de fichiers TFRecord, vous pouvez améliorer les performances en parallélisant l'extraction de données. Pour ce faire, assurez-vous que vos données sont partagées sur plusieurs fichiers et utilisez tf.data.Dataset.interleave avec le paramètre num_parallel_calls défini sur tf.data.AUTOTUNE . Si le déterminisme n'est pas important pour votre programme, vous pouvez améliorer encore les performances en définissant l'indicateur deterministic=False sur tf.data.Dataset.interleave à partir de TF 2.2. Par exemple, si vous lisez depuis TFRecords, vous pouvez effectuer les opérations suivantes :

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

Notez que les fichiers fragmentés doivent être raisonnablement volumineux pour amortir la surcharge liée à l'ouverture d'un fichier. Pour plus de détails sur l'extraction de données parallèle, consultez cette section du guide des performances tf.data .

Ensembles de données de transformation

Si vous avez identifié une transformation tf.data intermédiaire comme goulot d'étranglement, vous pouvez y remédier en parallélisant la transformation ou en mettant en cache le calcul si vos données tiennent en mémoire et si cela est approprié. Certaines transformations telles que Map ont des équivalents parallèles ; le guide des performances tf.data montre comment les paralléliser. D'autres transformations, telles que Filter , Unbatch et Batch sont intrinsèquement séquentielles ; vous pouvez les paralléliser en introduisant le « parallélisme externe ». Par exemple, supposons que votre pipeline d'entrée ressemble initialement à ce qui suit, avec Batch comme goulot d'étranglement :

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

Vous pouvez introduire le « parallélisme externe » en exécutant plusieurs copies du pipeline d'entrée sur des entrées fragmentées et en combinant les résultats :

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)

Ressources additionnelles