TensorFlow プロファイラによる tf.data パフォーマンスの分析

概要

このガイドは、TensorFlow プロファイラtf.data に精通していることを前提にしています。また、ユーザーが入力パイプラインの問題を診断して解決するのに役立つ段階的な手順と例を提供することを目的としています。

まずは TensorFlow ジョブのプロファイルを収集することから始めましょう。CPU/GPUCloud TPU それぞれの手順を確認できます。

TensorFlow Trace Viewer

以下で詳しく説明する分析ワークフローは、プロファイラのトレースビューアツールに焦点を当てています。このツールは、TensorFlow プログラムによって実行される演算の時間を示すタイムラインを表示し、実行に最も時間がかかる演算を特定できるようにします。トレース ビューアの詳細については、TensorFlow プロファイラガイド内のこちらのセクションをご覧ください。通常、tf.data イベントはホストの CPU タイムラインに表示されます。

分析ワークフロー

以下のワークフローに従ってください。改善に役立つ意見があれば、“comp:data” のラベルを付けて GitHub の課題を作成してください。

1. tf.data パイプラインが十分な速度でデータを生成しているか?

入力パイプラインが TensorFlow プログラムのボトルネックになっているかどうかを確かめるところから始めます。

そのためには、トレースビューアで IteratorGetNext::DoCompute 演算を探します。通常はステップの最初で見つけることができます。これらのスライスは、入力パイプラインが要求されたときに要素のバッチを生成するのに要する時間を表しています。Keras を使用しているか、tf.function でデータセットをイテレートしている場合、これらは tf_data_iterator_get_next スレッドで見つかります。

分散戦略を使用している場合、IteratorGetNext::DoCompute ではなく IteratorGetNextAsOptional::DoCompute イベントが表示されるかもしれません(TensorFlow 2.3 以降)。

image

呼び出しの応答が速い場合(<= 50 us)、データは要求された時点で使用できます。入力パイプラインはボトルネックではありません。より一般的なパフォーマンス分析のヒントについては、プロファイラガイドを参照してください。

image

呼び出しの応答が遅い場合tf.data は消費者の要求に対応できません。次のセクションに進んでください。

2. データをプリフェッチしているか?

入力パイプラインのベストプラクティスは、tf.data.Dataset.prefetch
変換を tf.data パイプラインの最後に挿入することです。この変換は、入力パイプラインの前処理計算をモデル計算の次のステップと一部重複させ、モデルをトレーニングする際に入力パイプラインのパフォーマンスを最適化するために必要なものです。データをプリフェッチしている場合は、IteratorGetNext::DoCompute 演算と同じスレッドに Iterator::Prefetch スライスが表示されているはずです。

image

prefetch がパイプラインの最後に存在しない場合、1 つ追加してください。tf.data のパフォーマンスに関する推奨事項の詳細は、tf.data パフォーマンスガイドをご覧してください。

すでにデータをプリフェッチしており、入力パイプラインが引き続きボトルネックになっている場合は、次のセクションに進み、さらにパフォーマンス分析を行ってください。

3. CPU 使用率が高くなっているか?

tf.data は利用可能なリソースを最大限に活用し、高いスループットを達成します。一般的にはモデルを GPU や TPU などのアクセラレータ上で実行している場合でも、tf.data パイプラインは CPU 上で実行されています。使用率は sarhtop のようなツールで、または GCP を利用している場合はクラウド監視コンソールで確認できます。

使用率が低い場合、入力パイプラインがホストの CPU を最大限に活用していない可能性があります。tf.data パフォーマンスガイドでベストプラクティスを調べることをお勧めします。ベストプラクティスを適用しても使用率とスループットが低い場合は、以下のボトルネック分析に進んでください。

使用率がリソースの許容限度に近づいている場合、パフォーマンスをさらに向上させるには、(不要な計算を回避するなどの対策で)入力パイプラインの効率を上げるか、計算をオフロードする必要があります。

入力パイプラインの効率は、tf.data 内の不要な演算を回避することで改善できます。その方法の 1 つは、データがメモリに収まる場合に tf.data.Dataset.cache 変換を計算負荷の高い作業の後に挿入することです。これによってメモリの使用量は増えてしまいますが、計算量は減少します。また、tf.data で演算内部の並列処理を無効化することで、効率を 10% 以上高められる可能性があります。入力パイプラインで次のオプションを設定すると無効化できます。

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

4. ボトルネック分析

次のセクションでは、トレースビューアで tf.data イベントを読み取り、ボトルネックの場所と考えられる緩和策を理解する方法について説明します。

プロファイラの tf.data イベントを理解する

プロファイラ内の各 tf.data イベントには Iterator::<Dataset> という名前が付いており、<Dataset> はデータセットのソースまたは変換の名前です。各イベントには Iterator::<Dataset_1>::...::<Dataset_n> という長い名前も付いており、これは tf.data イベントをクリックすると確認できます。長い名前の中にある <Dataset_n> は(短い)名前の <Dataset> と一致しており、それ以外のデータセットは下流の変換を表しています。

image

たとえば、上のスクリーンショットは次のコードから生成されたものです。

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

ここでは、Iterator::Map イベントに Iterator::BatchV2::FiniteRepeat::Map とう長い名前が付いています。データセット名は Python API と若干の違いはあるかもしれませんが(Repeat の代わりに FiniteRepeat になっているなど)、問題なく直感的に解析できるはずです。

同期変換と非同期変換

同期的な tf.data 変換(BatchMap など)の場合、上流の変換からのイベントは同じスレッドに表示されます。上記の例では、使用されているすべての変換が同期しているため、すべてのイベントが同じスレッドに表示されています。

非同期変換(PrefetchParallelMapParallelInterleaveMapAndBatch)の場合、上流の変換からのイベントは異なるスレッドに表示されます。このような場合、「長い名前」がイベントに対応するパイプラインの変換を識別するのに役立ちます。

image

たとえば、上のスクリーンショットは次のコードから生成されたものです。

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

ここでは Iterator::Prefetch イベントが tf_data_iterator_get_next スレッドにあります。Prefetch は非同期であるため、その入力イベント(BatchV2)は別のスレッドに表示され、Iterator::Prefetch::BatchV2 という長い名前を検索することで特定できます。この場合、イベントは tf_data_iterator_resource スレッドにあります。その長い名前から、BatchV2Prefetch の上流であると推察できます。また、BatchV2 イベントの parent_idPrefetch イベントの ID に一致します。

ボトルネックの特定

一般的に入力パイプラインのボトルネックを特定するには、入力パイプラインを最も外側の変換からソースまで調査します。パイプラインの最後の変換から始めて、遅い変換が見つかるか、TFRecord などのソースデータセットに到達するまで、上流の変換に向かって再帰的に調査します。上記の例では、 Prefetch から開始し、BatchV2FiniteRepeatMap のように上流に向かい、最後に Range を調査します。

一般的に、遅い変換ではそのイベントの長さに対し、入力イベントが短くなっています。以下にいくつかの例を示します。

ほとんどのホスト側の入力パイプラインで最後の(最も外側の)変換が Iterator::Model イベントになっていることに注意してください。モデルの変換は tf.data ランタイムによって自動的に導入されており、入力パイプラインのインストルメント化とパフォーマンス調整に使用されています。

ジョブが分散戦略を使用している場合、トレースビューアにはデバイス側の入力パイプラインに対応する追加のイベントが含まれます。デバイス側パイプラインの最も外側の変換(IteratorGetNextOp::DoComputeIteratorGetNextAsOptionalOp::DoCompute の下でネストしている)は、上流の Iterator::Generator イベントを持つ Iterator::Prefetch イベントになります。対応するホスト側のパイプラインは Iterator::Model イベントを探すと見つかります。

例1

image

上のスクリーンショットは次の入力パイプラインから生成されたものです。

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

このスクリーンショットでは、(1) Iterator::Map イベントが長いものの、(2) その入力イベント(Iterator::FlatMap)がすぐに応答を返していることが分かります。これは、連続する Map 変換がボトルネックであることを示唆しています。

このスクリーンショットでは、InstantiatedCapturedFunction::Run イベントは map 関数を実行するのに要する時間に対応しています。

例2

image

上のスクリーンショットは次の入力パイプラインから生成されたものです。

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

この例は上記のものと似ていますが、Map ではなく ParallelMap を使用しています。ここでは (1) Iterator::ParallelMap イベントが長いものの、(2) その入力イベント Iterator::FlatMap (ParallelMap が非同期であるため、別スレッド上に存在します)は短くなっています。これは、ParallelMap 変換がボトルネックであることを示唆しています。

ボトルネックへの対処

ソース データセット

TFRecord ファイルからの読み込みなど、データセットソースがボトルネックであることを確認したら、データ抽出を並列化することでパフォーマンスを改善できます。そのためには、データが複数のファイル間でシャーディングされ、num_parallel_calls パラメータを tf.data.experimental.AUTOTUNE に設定して tf.data.Dataset.interleave を使用するようにしてください。決定論がプログラムにとって重要でない場合、tf.data.Dataset.interleavedeterministic=False フラグを設定することで、さらにパフォーマンスを改善できます(TensorFlow 2.2 以降)。たとえば、TFRecord を読み込む場合は次のようにします。

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

ファイルを開く際のオーバーヘッドを埋め合わせるため、シャーディングされたファイルは適度な大きさになっていなければなりません。並列データ抽出の詳細については、tf.data パフォーマンスガイドのこのセクションをご覧ください。

変換データセット

中間の tf.data 変換がボトルネックであることを確認したら、変換を並列化するか、(データがメモリに収まり、かつ適切な場合に)計算をキャッシュすることで対処できます。Map などの一部の変換には対応する並列変換があります。tf.data パフォーマンスガイドでは、これらの並列化方法が説明されています。FilterUnbatchBatch などのその他の変換は本質的に順次実行されますが、これらは「外部並列処理」を導入することで並列ができます。たとえば、入力パイプラインが初期状態で次のようになっており、Batch がボトルネックになっているとします。

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

シャーディングされた入力に対して入力パイプラインの複数のコピーを実行し、それらの結果を組み合わせることで、「外部並列処理」を導入できます。

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.experimental.AUTOTUNE)
dataset = dataset.prefetch(tf.data.experimental.AUTOTUNE)

その他のリソース