TensorFlow/Kerasでchannels_firstにするとGPUの訓練が少し速くなる話

TensorFlow/Kerasのデフォルトはchannels_lastですが、channels_firstに変更するとGPUの訓練が少し速くなるというのをRTX2080Tiで実際に計測してみました。

## 前回までの記事
[対決！RTX 2080Ti SLI vs Google Colab TPU ～Keras編～](https://qiita.com/koshian2/items/61356d3a3b8e2d4624d2)

## きっかけ
[前回の記事を書いたら](https://qiita.com/koshian2/items/61356d3a3b8e2d4624d2)、Twitter上で「channels_first」にすると速くなるよ！と指摘をいただいたので、確かめてみました。

最初、自分は「TensorFlowのデフォルトはChannels lastだからそんなの逆に遅くなるでしょ」と疑ってしまって、申し訳ないことしてしまったなと反省しています。結果は、**確かに速くなりました**。少しでもTensorFlowの速度を上げたいときに使えるテクニックではないかと思います。指摘をしてくださった長谷川はしびろこうさんありがとうございました。

## 公式ドキュメントでは
cuDNNを使いNVIDIAのGPUで訓練する際は、channels_firstにするのが最適であることが明言されています。

> Within TensorFlow there are two naming conventions representing the two most common data formats:
 
> * ```NCHW``` or channels_first
> * ```NHWC``` or channels_last

> ```NHWC``` is the TensorFlow default and ```NCHW``` is the optimal format to use when training on NVIDIA GPUs using cuDNN.

https://www.tensorflow.org/guide/performance/overview

N, C, H, Wは次の英語に対応します。

* N : the Number of images in batch（ミニバッチの画像数）
* C : Channel（チャンネル）
* H : Height（高さ）
* W : Width（幅）

NHWC, NCHWというのはこれらの順番を表します。つまり、**チャンネルが2番目にくるのがchannels_first、チャンネルが4番目にくるのがchannels_last**というわけです。PyTorch、Chainerなどのフレームワークはchannels_firstです。**TensorFlowや、TensorFlowバックエンドはKerasはデフォルトでchannels_last**です。ただし、TensorFlowバックエンドではないKerasではchannel_firstとして使うこともあります（MXNetバックエンドなど）。TensorFlowバックエンドでも、KerasのAPIを使えばchannels_firstとして簡単に書くことができるので[^1]、今回はそれを試してみます。

[^1]: 歴史的にはKerasがあり、KerasがTensorFlowに吸収されて、TensorFlowのKerasがあります。KerasはもともとTensorFlowやCNTKなど異なるフレームワークを同一のAPIを通じてコーディングすることを目的として作られたもので、吸収前のKerasにとってTensorFlowはバックエンドの1つでしかありませんでした。しかし、吸収後のKerasはTensorFlowのAPIとなったために、TensorFlowとKerasは1つのバックエンドを越えた密接な関係になりました。そのAPIを通じてchannels first/lastを簡単に切り替えることができます。

## TensorFlowでのChannels firstへの切り替え方
全体のコードは末尾にあります。ポイントのみ解説します。

### データはnp.transposeなどで軸を入れ替える
keras.datasetsで読めるデータはchannels_lastで定義されているので、```NHWC```が```NCHW```になるように変更します。

```python
(X_train, y_train), (X_test, y_test) = keras.datasets.cifar10.load_data()
X_train, X_test = X_train/255.0, X_test/255.0
X_train, X_test = np.transpose(X_train, [0, 3, 1, 2]), np.transpose(X_test, [0, 3, 1, 2])
```

### Conv2D, Poolingなどはdata_format="channels_first"を指定
Channelが2番目にあるか4番目にあるかで処理が異なるようなレイヤー（Conv2DやMax/Average/Global Average Pooling）などは**data_format="channels_first"**の引数を指定します。

```python
from tensorflow.keras import layers

x = inputs
x = layers.Conv2D(ch, 3, padding="same", data_format="channels_first")(x)
x = layers.AveragePooling2D(2, data_format="channels_first")(x)
x = layers.GlobalAveragePooling2D(data_format="channels_first")(x)
```

Global Average Poolingは忘れやすいので注意（自分は忘れて調べ直しになりました）。

### Batch Normalizationでは「axis=1」を指定
Batch Normalizationのデフォルトは、Normalizationする軸がchannels_last向けに「axis=-1」（```NHWC```だから）になっています。これを「**axis=1**」と指定します。

```python
x = layers.BatchNormalization(axis=1)(x)
```

想定通りの実装になっているかは、model.summary()で確認するのをおすすめします。

## ハードウェアスペック
* GPU : RTX 2080Ti 11GB Manli製×2 SLI構成
* CPU : Core i9-9900K
* メモリ : DDR4-2666 64GB
* CUDA : 10.0
* cuDNN : 7.5.1
* TensorFlow : 1.13.1

前回と同じです。「[ELSA GPU Monitor](http://www.elsa-jp.co.jp/products/products-top/graphicsboard/geforce/gpu_manager/elsa-gpu-monitor/)」を使って、GPUのロードや消費電力をモニタリングします（5秒ごとCSV出力）。

GPUの場合は、**GPU1枚使う場合**（そのまま訓練させる場合）、**マルチGPUに対応させる場合**（```model = keras.utils.multi_gpu_model(model, gpus=2)```）の両方計測します。

## channels_lastとchannels_firstの比較（結果）
channels_lastとchannels_firsのトータルの訓練時間、精度を比較します。

### 1GPU、10層CNN
L=Channels Last（前回の結果）、F=Channels First（今回の結果）を示します。

| バッチ | L訓練時間 |  L精度 | F訓練時間 |   F精度 | 時間比 |
|:------:|----------:|-------:|----------:|--------:|-------:|
|   128  |   0:19:34 | 0.8960 |   0:17:50 | 0.8929 |  91.2% |
|   256  |   0:18:32 | 0.8940 |   0:17:04 | 0.8919 |  92.1% |
|   512  |   0:17:44 | 0.8849 |   0:16:12 | 0.8850 |  91.3% |
|  1024  |   0:17:20 | 0.8597 |   0:15:52 | 0.8726 |  91.5% |

**時間比で10%弱の高速化**になりました。精度は差はないと言っていいでしょう。

### 1GPU、WRN28-10
| バッチ | L訓練時間 |  L精度 | F訓練時間 | F精度 | 時間比 |
|:------:|----------:|-------:|----------:|------:|-------:|
|   128  |   3:10:21 | 0.8416 |   3:01:00 | 0.815 |  95.1% |

こちらは**5%弱**の高速化となりました。係数が多い高価なモデルでは、畳み込みの計算量が支配的であるため、Channels Firstによる恩恵は小さいと言えるでしょう。

### 2GPU、10層CNN
Kerasの```multi_gpu_model```を使い、データパラレル方式の複数GPU化をします。

| バッチ | L訓練時間 |  L精度 | F訓練時間 |  F精度 | 時間比 |
|:------:|----------:|-------:|----------:|-------:|-------:|
|   128  |   0:23:47 | 0.9003 |   0:21:36 | 0.8948 |  90.8% |
|   256  |   0:18:53 | 0.8926 |   0:18:35 | 0.8908 |  98.4% |
|   512  |   0:17:41 | 0.8853 |   0:16:24 | 0.8869 |  92.7% |
|  1024  |   0:17:17 | 0.8541 |   0:15:23 | 0.8753 |  89.0% |
|  2048  |   0:15:08 | 0.8457 |   0:13:39 | 0.8341 |  90.3% |

**Channels Lastの場合と比較すると1GPUと同様に10%弱速くなっていますが、相変わらず1GPUのときよりは遅いという現象は変わりありません**。バッチサイズ1024のときのみ1GPUよりわずかに速くなっている程度です。1GPUのときのバッチサイズ2048はOOMになりました。

### 2GPU、WRN28-10
| バッチ | L訓練時間 |  L精度 | F訓練時間 |  F精度 | 時間比 |
|:------:|----------:|-------:|----------:|-------:|-------:|
|   128  |   3:44:47 | 0.8257 |   3:37:40 | 0.8334 |  96.8% |
|   256  |   3:36:33 | 0.8165 |   3:25:30 | 0.8314 |  94.9% |

WideResNetでも同様の結果となりました。1GPUのときのバッチサイズ256はOOMになりました。

## TensorFlow/Kerasでchannels_firstにしたときのGPUログ
### 1GPU、10層CNN
![gpu_10 Layers CNN.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/8c1ef624-5571-da9a-0610-fe1b7e7f0366.png)

今回から訓練の終わりに1分のクールダウンを入れたので末端の大きく下がっているところは、Channels Firstによる影響ではありません。またこのクールダウンによる高速化の影響は軽微です（すぐ85℃近くになってしまうため）。

### 1GPU、WRN28-10
![gpu_Wide ResNet 28-10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/1e8e7f10-5bc7-0732-967d-b5ed25d1dec9.png)

ニューラルネットワークの大小にかかわらず、相変わらずMAXまでメモリを確保しに行っているのがちょっと奇妙。

### 2GPU、10層CNN
![multigpu_10 Layers CNN.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/11cc9a09-1a0d-df89-1b40-bda6bc6d1d68.png)

### 2GPU、WRN28-10
![multigpu_Wide ResNet 28-10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/1a1d81e2-bacf-24ce-c408-fd0264be4202.png)

特にChannels Firstにしたからといって、GPUのログが変わるということはなかったです。

## まとめ
* **TensorFlow/Kerasでchannels_firstにするとGPUによる訓練が数%～10%程度高速化される**。
* 複数GPU使ったときに逆に遅くなる現象は解決されなかった（ちなみにPyTorchだと解決されました）

Channels Firstにすると、[TPUの訓練も高速化されるという記事](http://tadaoyamaoka.hatenablog.com/entry/2019/02/13/222943)もあるので、TensorFlowを使ったGPU最適化は不十分かもしれません。ぶっちゃけちゃうと、GPUを本当に高速化させたいんだったらPyTorchを使うべきです。しかし、2019年5月現在ではまだPyTorchはTPU対応していないので、TPUの最適化のときには使える議論ではないかと思います。

PyTorchを使ったGPUの訓練の高速化はまた次回書きたいと思います。

## コード
https://gist.github.com/koshian2/c94ed2c2ec9bc83a14c01c1a38ad987d