Analizza le prestazioni di tf.data con TF Profiler

Panoramica

Questa guida presuppone la familiarità con TensorFlow Profiler e tf.data . Ha lo scopo di fornire istruzioni passo passo con esempi per aiutare gli utenti a diagnosticare e risolvere i problemi di prestazioni della pipeline di input.

Per iniziare, raccogli un profilo del tuo lavoro TensorFlow. Le istruzioni su come eseguire questa operazione sono disponibili per CPU/GPU e Cloud TPU .

TensorFlow Trace Viewer

Il flusso di lavoro di analisi dettagliato di seguito si concentra sullo strumento di visualizzazione delle tracce nel Profiler. Questo strumento visualizza una sequenza temporale che mostra la durata delle operazioni eseguite dal tuo programma TensorFlow e ti consente di identificare quali operazioni richiedono più tempo per essere eseguite. Per ulteriori informazioni sul visualizzatore di tracce, consulta questa sezione della guida TF Profiler. In generale, gli eventi tf.data verranno visualizzati sulla sequenza temporale della CPU host.

Flusso di lavoro di analisi

Si prega di seguire il flusso di lavoro riportato di seguito. Se hai feedback per aiutarci a migliorarlo, crea un problema su GitHub con l'etichetta "comp:data".

1. La tua pipeline tf.data produce dati abbastanza velocemente?

Inizia verificando se la pipeline di input costituisce il collo di bottiglia per il tuo programma TensorFlow.

A tale scopo, cercare le operazioni IteratorGetNext::DoCompute nel visualizzatore di traccia. In generale, ti aspetti di vederli all'inizio di un passaggio. Queste sezioni rappresentano il tempo impiegato dalla pipeline di input per produrre un batch di elementi quando viene richiesto. Se stai utilizzando keras o stai eseguendo l'iterazione del tuo set di dati in un tf.function , questi dovrebbero essere trovati nei thread tf_data_iterator_get_next .

Tieni presente che se stai utilizzando una strategia di distribuzione , potresti visualizzare gli eventi IteratorGetNextAsOptional::DoCompute invece di IteratorGetNext::DoCompute (a partire da TF 2.3).

image

Se le chiamate ritornano velocemente (<= 50 us), significa che i tuoi dati sono disponibili nel momento in cui vengono richiesti. La pipeline di input non è il collo di bottiglia; consultare la guida Profiler per suggerimenti più generici sull'analisi delle prestazioni.

image

Se le chiamate ritornano lentamente, tf.data non è in grado di tenere il passo con le richieste del consumatore. Continua alla sezione successiva.

2. Stai precaricando i dati?

La procedura consigliata per le prestazioni della pipeline di input consiste nell'inserire una trasformazione tf.data.Dataset.prefetch alla fine della pipeline tf.data . Questa trasformazione si sovrappone al calcolo di preelaborazione della pipeline di input con il passaggio successivo del calcolo del modello ed è necessaria per prestazioni ottimali della pipeline di input durante il training del modello. Se stai precaricando i dati, dovresti vedere una sezione Iterator::Prefetch sullo stesso thread dell'operazione IteratorGetNext::DoCompute .

image

Se non disponi di un prefetch alla fine della pipeline , dovresti aggiungerne uno. Per ulteriori informazioni sui consigli sulle prestazioni tf.data , consulta la guida alle prestazioni di tf.data .

Se stai già precaricando i dati e la pipeline di input rappresenta ancora il collo di bottiglia, vai alla sezione successiva per analizzare ulteriormente le prestazioni.

3. Stai raggiungendo un elevato utilizzo della CPU?

tf.data raggiunge un rendimento elevato cercando di sfruttare al meglio le risorse disponibili. In generale, anche quando si esegue il modello su un acceleratore come una GPU o un TPU, le pipeline tf.data vengono eseguite sulla CPU. Puoi verificare il tuo utilizzo con strumenti come sar e htop o nella console di monitoraggio del cloud se utilizzi GCP.

Se l'utilizzo è basso, ciò suggerisce che la pipeline di input potrebbe non sfruttare appieno la CPU host. Dovresti consultare la guida alle prestazioni di tf.data per le migliori pratiche. Se hai applicato le migliori pratiche e l'utilizzo e la produttività rimangono bassi, continua con l'analisi dei colli di bottiglia di seguito.

Se il tuo utilizzo si sta avvicinando al limite delle risorse , per migliorare ulteriormente le prestazioni, devi migliorare l'efficienza della pipeline di input (ad esempio, evitando calcoli non necessari) o scaricare i calcoli.

Puoi migliorare l'efficienza della pipeline di input evitando calcoli non necessari in tf.data . Un modo per farlo è inserire una trasformazione tf.data.Dataset.cache dopo un lavoro ad alta intensità di calcolo se i dati rientrano nella memoria; ciò riduce il calcolo al costo di un maggiore utilizzo della memoria. Inoltre, la disabilitazione del parallelismo intra-op in tf.data ha il potenziale per aumentare l'efficienza di > 10% e può essere eseguita impostando la seguente opzione sulla pipeline di input:

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

4. Analisi dei colli di bottiglia

La sezione seguente illustra come leggere gli eventi tf.data nel visualizzatore di traccia per comprendere dove si trova il collo di bottiglia e le possibili strategie di mitigazione.

Comprensione degli eventi tf.data nel Profiler

Ogni evento tf.data nel Profiler ha il nome Iterator::<Dataset> , dove <Dataset> è il nome dell'origine o della trasformazione del set di dati. Ogni evento ha anche il nome lungo Iterator::<Dataset_1>::...::<Dataset_n> , che puoi vedere facendo clic sull'evento tf.data . Nel nome lungo, <Dataset_n> corrisponde a <Dataset> dal nome (breve) e gli altri set di dati nel nome lungo rappresentano trasformazioni downstream.

image

Ad esempio, lo screenshot sopra è stato generato dal seguente codice:

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

Qui, l'evento Iterator::Map ha il nome lungo Iterator::BatchV2::FiniteRepeat::Map . Tieni presente che il nome dei set di dati potrebbe differire leggermente dall'API Python (ad esempio, FiniteRepeat invece di Repeat), ma dovrebbe essere sufficientemente intuitivo da poter essere analizzato.

Trasformazioni sincrone e asincrone

Per le trasformazioni tf.data sincrone (come Batch e Map ), vedrai gli eventi delle trasformazioni upstream sullo stesso thread. Nell'esempio precedente, poiché tutte le trasformazioni utilizzate sono sincrone, tutti gli eventi appaiono sullo stesso thread.

Per le trasformazioni asincrone (come Prefetch , ParallelMap , ParallelInterleave e MapAndBatch ) gli eventi delle trasformazioni upstream si troveranno su un thread diverso. In questi casi, il "nome lungo" può aiutare a identificare a quale trasformazione in una pipeline corrisponde un evento.

image

Ad esempio, lo screenshot sopra è stato generato dal seguente codice:

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

Qui, gli eventi Iterator::Prefetch si trovano sui thread tf_data_iterator_get_next . Poiché Prefetch è asincrono, i suoi eventi di input ( BatchV2 ) si troveranno su un thread diverso e potranno essere individuati cercando il nome lungo Iterator::Prefetch::BatchV2 . In questo caso, si trovano nel thread tf_data_iterator_resource . Dal suo nome lungo si può dedurre che BatchV2 sia a monte di Prefetch . Inoltre, il parent_id dell'evento BatchV2 corrisponderà all'ID dell'evento Prefetch .

Identificazione del collo di bottiglia

In generale, per identificare il collo di bottiglia nella pipeline di input, percorrere la pipeline di input dalla trasformazione più esterna fino all'origine. A partire dalla trasformazione finale nella pipeline, ricorsi alle trasformazioni upstream finché non trovi una trasformazione lenta o raggiungi un set di dati di origine, come TFRecord . Nell'esempio sopra, inizierai da Prefetch , quindi camminerai a monte fino a BatchV2 , FiniteRepeat , Map e infine Range .

In generale, una trasformazione lenta corrisponde a una trasformazione i cui eventi sono lunghi, ma i cui eventi di input sono brevi. Alcuni esempi seguono di seguito.

Tieni presente che la trasformazione finale (più esterna) nella maggior parte delle pipeline di input dell'host è l'evento Iterator::Model . La trasformazione del modello viene introdotta automaticamente dal runtime tf.data e viene utilizzata per la strumentazione e l'ottimizzazione automatica delle prestazioni della pipeline di input.

Se il tuo lavoro utilizza una strategia di distribuzione , il visualizzatore di traccia conterrà eventi aggiuntivi che corrispondono alla pipeline di input del dispositivo. La trasformazione più esterna della pipeline del dispositivo (nidificata sotto IteratorGetNextOp::DoCompute o IteratorGetNextAsOptionalOp::DoCompute ) sarà un evento Iterator::Prefetch con un evento Iterator::Generator upstream. È possibile trovare la pipeline host corrispondente cercando Iterator::Model events.

Esempio 1

image

Lo screenshot precedente è generato dalla seguente pipeline di input:

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

Nello screenshot, osserva che (1) gli eventi Iterator::Map sono lunghi, ma (2) i suoi eventi di input ( Iterator::FlatMap ) ritornano rapidamente. Ciò suggerisce che la trasformazione sequenziale della mappa rappresenta il collo di bottiglia.

Tieni presente che nello screenshot l'evento InstantiatedCapturedFunction::Run corrisponde al tempo necessario per eseguire la funzione map.

Esempio 2

image

Lo screenshot precedente è generato dalla seguente pipeline di input:

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

Questo esempio è simile al precedente, ma utilizza ParallelMap invece di Map. Notiamo qui che (1) gli eventi Iterator::ParallelMap sono lunghi, ma (2) i suoi eventi di input Iterator::FlatMap (che si trovano su un thread diverso, poiché ParallelMap è asincrono) sono brevi. Ciò suggerisce che la trasformazione ParallelMap sia il collo di bottiglia.

Affrontare il collo di bottiglia

Set di dati di origine

Se hai identificato un'origine del set di dati come collo di bottiglia, ad esempio la lettura da file TFRecord, puoi migliorare le prestazioni parallelizzando l'estrazione dei dati. A tale scopo, assicurati che i dati siano suddivisi in più file e utilizza tf.data.Dataset.interleave con il parametro num_parallel_calls impostato su tf.data.AUTOTUNE . Se il determinismo non è importante per il tuo programma, puoi migliorare ulteriormente le prestazioni impostando il flag deterministic=False su tf.data.Dataset.interleave a partire da TF 2.2. Ad esempio, se stai leggendo da TFRecords, puoi effettuare le seguenti operazioni:

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

Tieni presente che i file partizionati dovrebbero essere ragionevolmente grandi per ammortizzare il sovraccarico derivante dall'apertura di un file. Per ulteriori dettagli sull'estrazione parallela dei dati, consultare questa sezione della guida alle prestazioni tf.data .

Set di dati di trasformazione

Se hai identificato una trasformazione tf.data intermedia come collo di bottiglia, puoi risolverlo parallelizzando la trasformazione o memorizzando nella cache il calcolo se i tuoi dati rientrano nella memoria ed è appropriato. Alcune trasformazioni come Map hanno controparti parallele; la guida alle prestazioni tf.data mostra come parallelizzarli. Altre trasformazioni, come Filter , Unbatch e Batch sono intrinsecamente sequenziali; puoi parallelizzarli introducendo il "parallelismo esterno". Ad esempio, supponiamo che la pipeline di input inizialmente sia simile alla seguente, con Batch come collo di bottiglia:

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

Puoi introdurre il "parallelismo esterno" eseguendo più copie della pipeline di input su input partizionati e combinando i risultati:

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)

Risorse addizionali