Fusic Tech Blog

Fusion of Society, IT and Culture

TensorFlow dataset API についてのいくつかの実験
2018/10/03

TensorFlow dataset API についてのいくつかの実験

TensorFlow の dataset API は、全データをメモリ上に展開できないような巨大なデータセットを使ってニューラルネットを学習する際に、大きな役割を果たす。\ 今回は、dataset API の仕様例を示すとともに、いくつかの実験を行い、その特性を調査する。\ 本来であれば内部のコードを見に行く必要があるが、今回はコードリーディングは割愛した。

実験概要

実験環境

実験は、以下のマシンにDocker 環境を構築して行った。

OS: Ubuntu 16.04 LTS 
CPU: Intel(R) Core(TM) i7-7700K CPU @ 4.20GHz (4コア8スレッド) 
メモリ: 64GB

実験に使ったデータは、ファッションMnist のデータである。

Dockerコンテナの作成には、 github にある、Dockerfile 及び docker-compose.yml を使用した。\ また、本実験に使用したコードは、\ https://github.com/gorogoroyasu/tensorflow_dataset_speed_check\ にあげてある。

実験条件

条件を設定し、dataset API は、どんな状況に強く、チカラを発揮するのかを調べた。\ 設定した条件は、以下の4つ

  • 並列実行のプロセス数
  • バッチサイズ
  • ニューラルネットの学習にかかる時間
  • ステップ数

計測した値はすべて、3回実行した処理の平均値である。\ ただし、ニューラルネットの学習にかかる時間は、time.sleep を入れることによって模擬した。

実験結果

実験結果を、 gist に示す。\ dataset API を使ったほうが 独自で作った画像読み出しよりも早かったのは、

sleep:  0.1 num_para:  2 batch_size:  256 steps 256
num cycle:  0
num cycle:  1
num cycle:  2
dataset:  30.862543026606243
gen:  32.16954223314921
 
 
sleep:  0.1 num_para:  8 batch_size:  256 steps 256
num cycle:  0
num cycle:  1
num cycle:  2 
dataset:  31.022260506947834
gen:  32.385775566101074

の2条件であった。

この2条件は、並列化するプロセス数が異なる。\ また、2プロセスで実行したときのほうが8プロセスで実行したときよりも早かった。

とはいえ、あまり大きな違いがあるわけではない。\ もし、 num_para が結果に大きく聞いているならば、 2プロセスで実行した場合よりも 8プロセスで実行した場合のほうが実行速度は速いはずである。\ そのため、速度に違いの説明からnum_paraの要素を除外できる。

以降は、num_para: 2 の結果を対象にする。

他の要因とは、\ – バッチサイズ\ – ニューラルネットの学習にかかる時間\ – ステップ数\ である。

バッチサイズの影響

sleep:  0.1 num_para:  2 batch_size:  8 steps 256
num cycle:  0
num cycle:  1
num cycle:  2
dataset:  29.567235867182415
gen:  28.046199957529705

batch_size が 8 のとき、僅差ではあるが dataset API のほうが処理時間が長いことがわかる。

ここで、 gen と記した、 独自で作った画像読み出し の時間に着目する。\ batch_size が 32 のとき gen にかかった時間は、32.39s。\ batch_size が 8 のとき gen にかかった時間は、28.05s。\ batch_size が 32 のときは、 1.15 倍の時間がかかっている。

一方、 dataset の方は、\ batch_size が 32 のとき gen にかかった時間は、30.86s。\ batch_size が 8 のとき gen にかかった時間は、29.57s。\ batch_size が 32 のときは、 1.04 倍しかかかっていない。\ 差は小さいが、batch_size が大きくなればなるほど、 dataset API のほうが有利になる。

ステップ数 の影響

num cycle:  0
num cycle:  1
num cycle:  2
dataset:  3.4478182792663574
gen:  2.287947495778402

ステップ数が8 の時、 dataset の実行時間は、 gen の実行時間の1.51倍であった。\ 一方、ステップ数が32 のときのdataset の実行時間は、 gen の実行時間の0.96倍である。\ このことから、ステップ数が伸びれば伸びるほど、 dataset API のほうが有利になる。

ニューラルネットの学習にかかる時間の影響

sleep:  0.0 num_para:  2 batch_size:  256 steps 256
num cycle:  0
num cycle:  1
num cycle:  2
dataset:  23.061609506607056
gen:  6.0361127853393555

sleep が 0.0 秒のとき、 dataset API を使った場合は独自で作った画像読み出しを使った場合の3.82 倍実行時間がかかっている。\ 一方、 sleep が 0.1 秒のとき、 dataset API を使った場合は独自で作った画像読み出しを使った場合の0.96倍の時間で処理が終わっている。

考察

上記の結果から明らかなように、ニューラルネットの学習に時間がかかる場合に dataset APIの有効性が高まることがわかった。これは、シンプルなニューラルネットを学習する際は、dataset API を使わず独自で画像の読み出し機構を構築したほうが速度が上る可能性を示唆している。

今回の実験において、sleep が 0.1 秒の場合、 約23秒間は何もしていない時間である。\ sleep が 0.1 秒の場合におけるgenの実行速度は、概ね 30 秒程度である。\ つまり、 画像の読み出し時間にそのまま sleep の時間が加わっている。

一方、 dataset API は、 sleepが 0.1 秒の時に 23.06 秒実行時間がかかっているので、単純に sleep の時間を加えると、 約46 秒かかることになる。 しかし、実際には30.86秒しかかかっていない。これは、メインのプロセスがsleep している間に別プロセス(たぶん)が動いてデータを準備しているからだと考えられる。

どのようなコードでそれが実現されているかについてはまだ調査していないので、今後調査しようと思う。

結論

dataset API を使ったほうがいいのは、処理が重いニューラルネットワークを学習させる場合ということがわかった。\ また、回すステップ数が多い場合やバッチサイズが大きい場合にもdataset APIを使用したほうがより早くデータを読み出せることがわかった。\ この結果は、ニューラルネットの学習を行っている間にデータを読み込むという実装が大きく寄与していると考えられる。

補足

dataset API の使い方

def _parse_function(filename, labels):
    image_string = tf.read_file(filename)
    image_decoded = tf.cast(tf.image.decode_png(image_string, 1), tf.float32)
    image_decoded = tf.div(image_decoded, 255.)
    image_decoded = tf.reshape(image_decoded, [28, 28, 1])
    onehot = tf.one_hot(labels, 10)
    return image_decoded, onehot
 
dataset = tf.data.Dataset.from_tensor_slices((tf_imgs, tf_labels))
dataset = dataset.map(_parse_function,num_parallel_calls=num_para).shuffle(buffer_size=1000).batch(batch_size).prefetch(1).repeat()
iterator = dataset.make_one_shot_iterator()
return iterator.get_next()

from_tensor_slices メソッド

データセットを作る時に叩くメソッド。\ 今回は、tf.constant を引数として渡している。

dataset.map(fn, num_parallel_calls)

マップしてくれるメソッド。\ それぞれのデータに対して同じ処理をする場合に、for 文ではなく、この map 処理を使う。\ なお、 num_parallel_calls は、いくつのプロセスを使用するかを決める変数。

dataset.shuffle(buffer_size)

どれぐらいの幅でシャッフルするかを決めるメソッド。\ buffer_size に関しては、下手な説明を書くより https://stackoverflow.com/questions/46444018/meaning-of-buffer-size-in-dataset-map-dataset-prefetch-and-dataset-shuffle の議論を読んだほうが速いと思う。

dataset.batch(batch_size)

後ほどでてくる make_one_shot_iterator した iteratorget_next メソッドを叩いた時にかえしてくれるデータのバッチサイズ

dataset.prefetch()

(よくわかっていない)\ おそらく、どれだけ先のデータを読み出すかというパラメータだと思う。\ 基本は1で良いと思っているのだが、どんな時にどんな値にすればよいのかは不明。\ 後日調べます。

dataset.repeat()

make_one_shot_iterator でデータセットを作成した場合、1epoch でデータが枯渇してしまう。\ しかし、 repeat メソッドを呼ぶと、データをもう一度シャッフルして生成してくれる。

dataset.make_one_shot_iterator()

上でゴニョゴニョしたデータセットを、イテレータにしてくれる。\ いくつか種類があるが、自分はこれしか触ったことがない。

iterator.get_next()

次のバッチを呼び出す処理。

sess.run(iterator.get_next()) を呼ぶと、warning が出るので、

next_elm = iterator.get_next()
 with tf.Session() as sess:
 # 初期化など
 ...
 # 初期化など
 sess.run(next_elm)

のように書く必要がある。

おわりに

今回は簡単な処理しか行っていないので並列化が逆に速度の低下を引き起こしていました。\ もっと複雑な前処理を加えたら結果は違ったと思います。

また、なんかそれっぽく書きましたが、自分が実装したのは、

def next_batch(self):
    if len(self.order) < self.batch_size:
    self.randomalize()
    batch = self.order[:self.batch_size]
    self.order = self.order[self.batch_size:]
 
    imgs = []
    labels = []
    for i in batch:
        img = cv2.imread(self.img_pathes[i], 0).astype(np.float32)
        img_dev = (img / 255.).astype(np.float32)
        imgs.append(img_dev)
        labels.append(list(np.eye(10, dtype=np.uint8)[int(self.labels[i])]))
    return np.array(imgs).reshape((-1, 28, 28, 1)), np.array(labels)

というかなりひどい感じなので、今回の検証が妥当かどうかは正直あまり自信がありません。\ 普通に、 step x batch_size が大きくなれば、 for を回す回数が増えるので。

とはいえ、sleep を入れた調査の方は、割とインパクトがあるんじゃないかと思います。\ 興味が湧いてきたので、少し dataset 周りを調べてみようと思います。

hamano

hamano

I'm a software engineer in Fukuoka, Japan. Recently, I am developing machine learning models using TensorFlow, and also developing Web services by using PHP.