Partecipa al simposio Women in ML il 7 dicembre Registrati ora

Analizza le prestazioni di tf.data con TF Profiler

Mantieni tutto organizzato con le raccolte Salva e classifica i contenuti in base alle tue preferenze.

Panoramica

Questa guida presuppone familiarità con TensorFlow Profiler e tf.data . Ha lo scopo di fornire istruzioni dettagliate 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 farlo sono disponibili per CPU/GPU e Cloud TPU .

TensorFlow Trace Viewer

Il flusso di lavoro di analisi descritto di seguito si concentra sullo strumento visualizzatore di tracce nel Profiler. Questo strumento mostra 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 di TF Profiler. In generale, gli eventi tf.data appariranno sulla timeline della CPU host.

Flusso di lavoro di analisi

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

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

Inizia accertando se la pipeline di input è il collo di bottiglia per il tuo programma TensorFlow.

Per fare ciò, cerca IteratorGetNext::DoCompute ops nel visualizzatore di tracce. 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 usando keras o stai iterando sul tuo set di dati in un tf.function , questi dovrebbero essere trovati nei thread tf_data_iterator_get_next .

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

image

Se le chiamate tornano rapidamente (<= 50 us), significa che i tuoi dati sono disponibili quando vengono richiesti. La pipeline di input non è il tuo collo di bottiglia; consulta la guida di Profiler per suggerimenti più generici sull'analisi delle prestazioni.

image

Se le chiamate tornano 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 best practice 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 pre-elaborazione 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 IteratorGetNext::DoCompute .

image

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

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

3. Stai raggiungendo un elevato utilizzo della CPU?

tf.data raggiunge un throughput elevato cercando di utilizzare al meglio le risorse disponibili. In generale, anche quando si esegue il modello su un acceleratore come una GPU o TPU, le pipeline tf.data vengono eseguite sulla CPU. Puoi controllare il tuo utilizzo con strumenti come sar e htop o nella console di monitoraggio del cloud se stai utilizzando 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 best practice e l'utilizzo e la velocità effettiva rimangono bassi, continua con l' analisi del collo di bottiglia di seguito.

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

È possibile 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 tuoi dati rientrano nella memoria; questo riduce il calcolo a scapito di un maggiore utilizzo della memoria. Inoltre, la disabilitazione del parallelismo intra-operativo 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 tracce per capire 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 a valle.

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 del set di dati potrebbe differire leggermente dall'API Python (ad esempio, FiniteRepeat invece di Repeat), ma dovrebbe essere abbastanza intuitivo da analizzare.

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 vengono visualizzati sullo stesso thread.

Per le trasformazioni asincrone (come Prefetch , ParallelMap , ParallelInterleave e MapAndBatch ) gli eventi delle trasformazioni upstream si troveranno in un thread diverso. In questi casi, il "nome lungo" può aiutarti 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 nei thread tf_data_iterator_get_next . Poiché Prefetch è asincrono, i relativi eventi di input ( BatchV2 ) si troveranno in un thread diverso e possono essere individuati cercando il nome lungo Iterator::Prefetch::BatchV2 . In questo caso, si trovano nel thread tf_data_iterator_resource . Dal suo nome lungo, puoi dedurre che BatchV2 è a monte di Prefetch . Inoltre, il parent_id dell'evento BatchV2 corrisponderà all'ID dell'evento Prefetch .

Identificare il 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. Partendo dalla trasformazione finale nella tua pipeline, ricorri alle trasformazioni upstream finché non trovi una trasformazione lenta o raggiungi un set di dati di origine, ad esempio TFRecord . Nell'esempio sopra, dovresti iniziare da Prefetch , quindi risalire a BatchV2 , FiniteRepeat , Map e infine Range .

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

Si noti 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 tracce conterrà eventi aggiuntivi che corrispondono alla pipeline di input del dispositivo. La trasformazione più esterna della pipeline del dispositivo (annidata in IteratorGetNextOp::DoCompute o IteratorGetNextAsOptionalOp::DoCompute ) sarà un evento Iterator::Prefetch con un evento Iterator::Generator upstream. È possibile trovare la pipeline host corrispondente cercando gli eventi Iterator::Model .

Esempio 1

image

Lo screenshot sopra è 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 relativi eventi di input ( Iterator::FlatMap ) restituiscono rapidamente. Ciò suggerisce che la trasformazione sequenziale della mappa è il collo di bottiglia.

Si noti che nello screenshot, l'evento InstantiatedCapturedFunction::Run corrisponde al tempo necessario per eseguire la funzione map.

Esempio 2

image

Lo screenshot sopra è 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 usa 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 è 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. Per fare ciò, assicurati che i tuoi dati siano ripartiti su più file e usa 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 fare quanto segue:

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

Si noti che i file frammentati dovrebbero essere ragionevolmente grandi per ammortizzare il sovraccarico dell'apertura di un file. Per maggiori dettagli sull'estrazione parallela dei dati, consulta questa sezione della guida alle prestazioni di tf.data .

Set di dati di trasformazione

Se hai identificato una trasformazione tf.data intermedia come collo di bottiglia, puoi risolverla 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 di tf.data mostra come parallelizzarli. Altre trasformazioni, come Filter , Unbatch e Batch sono intrinsecamente sequenziali; puoi parallelizzarli introducendo il "parallelismo esterno". Ad esempio, supponendo che la pipeline di input sia inizialmente 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 frammentati 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