Analysez les performances de tf.data avec le TF Profiler

Restez organisé à l'aide des collections Enregistrez et classez les contenus selon vos préférences.

Aperçu

Ce guide suppose que vous êtes familiarisé 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 manière 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 visionneuse de traces dans le profileur. Cet outil affiche une chronologie indiquant 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 libellé "comp: data".

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

Commencez par déterminer si le pipeline d'entrée est le goulot d'étranglement de votre programme TensorFlow.

Pour ce faire, recherchez IteratorGetNext::DoCompute ops 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 keras ou itérez sur votre ensemble de données dans un tf.function , ceux-ci doivent être trouvés dans les threads tf_data_iterator_get_next .

Notez que si vous utilisez une stratégie de distribution , vous pouvez voir des é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 du profileur pour obtenir des conseils d'analyse des performances plus génériques.

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. Préchargez-vous les 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 nécessaire pour optimiser les performances du pipeline d'entrée lors de la formation de votre modèle. Si vous préchargez des données, vous devriez voir une tranche Iterator::Prefetch sur le même thread que l' IteratorGetNext::DoCompute .

image

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

Si vous prélevez déjà des 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 de tirer le meilleur parti possible des ressources disponibles. En général, même lorsque vous exécutez votre modèle sur un accélérateur comme 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 davantage 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 le calcul.

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 intensif en calcul si vos données tiennent dans la 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 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 traces 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> à partir du nom (court), et les autres jeux 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 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 synchrones tf.data (telles que Batch et Map ), vous verrez les événements des transformations en amont sur le même thread. Dans l'exemple ci-dessus, toutes les transformations utilisées étant 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 seront sur un thread différent. Dans de tels cas, le "nom long" peut vous aider à identifier à quelle transformation d'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 sont sur les threads tf_data_iterator_get_next . Étant donné que Prefetch est asynchrone, ses événements d'entrée ( BatchV2 ) seront sur un thread différent et peuvent ê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 .

Identification du 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 de 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 jeu de données source, tel que TFRecord . Dans l'exemple ci-dessus, vous commenceriez par Prefetch , puis marcheriez en amont 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 hôtes est l'événement Iterator::Model . La transformation de modèle est introduite automatiquement par le runtime tf.data et est utilisée pour instrumenter et ajuster automatiquement les performances du pipeline d'entrée.

Si votre travail utilise une stratégie de distribution , la visionneuse de trace contiendra des événements supplémentaires qui correspondent au pipeline d'entrée de l'appareil. La transformation la plus externe du pipeline de périphérique (imbriquée 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 Map séquentielle est le goulot d'étranglement.

Notez que dans la capture d'écran, l'événement InstantiatedCapturedFunction::Run correspond au temps d'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 au précédent, 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 est le goulot d'étranglement.

S'attaquer au 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 à partir 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 réparties 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 encore améliorer les performances en définissant l'indicateur deterministic=False sur tf.data.Dataset.interleave à partir de TF 2.2. Par exemple, si vous lisez à partir de 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 partitionné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 en parallèle, consultez cette section du guide des performances de 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 dans la mémoire et que cela est approprié. Certaines transformations telles que Map ont des contreparties 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 un « parallélisme externe » en exécutant plusieurs copies du pipeline d'entrée sur des entrées partitionné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