マルチGPU(分散深層学習)でニューラルネットワークの学習をしてみた

#0.はじめに
この記事は[Using GPU(s) in Chainer](https://docs.chainer.org/en/stable/guides/gpu.html "Using GPU(s) in Chainer")を参考に書いたものです至らない点はリンク先で補ってください。
フレームワークにはChainerを使用しますが、基本的な事は[Chainer v4 ビギナー向けチュートリアル](https://qiita.com/mitmul/items/1e35fba085eb07a92560 "Chainer v4 ビギナー向けチュートリアル")がわかりやすいです。
Chainerには分散深層学習用のAPIである[ChainerMN](https://docs.chainer.org/en/stable/chainermn/index.html "Distributed Deep Learning with ChainerMN")がありますが今回は使用しません。
また分散深層学習はまだまだマイナーな分野なので、参考文献がすくないため
もし間違っている箇所があったらコメントで知らせてください。
#1.分散深層学習とは
分散深層学習とはその名の通り、ディープラーニングを分散して学習する手法のことをいいます。
深層とついているので主にディープラーニング向けのもので、
実際1024GPU上でImageNetによるResNet-50の学習を15分で行うなどの実績をあげたりしています。
分散の方法としては複数台のPCをネットワークで繋いで分散させるなど色々と考えられますが、
今回はMulti-GPUを使った分散深層学習方法です。

アプリ開発者やある程度のプログラマーならわかると思いますが、何事もマルチ化というのは難しいもので、
一つの物事(処理)をマルチ化しようとすると、大量の例外処理に追われたり、
思ったほど速度がでなかったり(リソース競合など)、逆に遅くなったり、そもそも動かなかったりするわけで、
分散深層学習も同様で、2-GPUだから速度が2倍になるわけではなく(いいとこ1.5倍付近)、
考えなしにやっても別段早くなったりするわけではないということを念頭に置いてください。
つまるところ、分散深層学習はコストが高かったりします。
#2.Model-ParallelとData-Parallel
学習方法は主に２つあり、それがModel-ParallelとData-Parallelです。
のちのち詳しく説明します。
#3.変数の確保
まず、変数の収得の話から始めます。
基本的にCPUが使う変数(メモリ)とGPUが使う変数は違います。
またGPUついてもDevice(GPU0,GPU1)によって変数を分けなければいけないことに留意してください。

```python
import chainer
import numpy as np
import chainer.functions as F

device_gpu_0, device_gpu_1 = 0, 1

x_cpu = np.ones((1, 4, 3), dtype='float32')
print('x_cpu = ', x_cpu)
x_gpu_0 = chainer.backends.cuda.to_gpu(x_cpu, device=device_gpu_0)
with chainer.backends.cuda.get_device_from_id(device_gpu_1):
    x_gpu_1 = chainer.backends.cuda.cupy.array(x_cpu * 2, dtype='float32')
print('x_gpu_0 + x_gpu_1 = ', \
    x_gpu_0 + chainer.backends.cuda.copy(x_gpu_1, out_device=device_gpu_0))
```

```console
x_cpu =  [[[1. 1. 1.]
  [1. 1. 1.]
  [1. 1. 1.]
  [1. 1. 1.]]]
x_gpu_0 + x_gpu_1 =  [[[3. 3. 3.]
  [3. 3. 3.]
  [3. 3. 3.]
  [3. 3. 3.]]]
```

その他にも様々な書き方があるので[Using GPU(s) in Chainer](https://docs.chainer.org/en/stable/guides/gpu.html "Using GPU(s) in Chainer")を参照してください。
#4.基本モデル
基本となるモデルを作ります。
![MLP50.png](https://qiita-image-store.s3.amazonaws.com/0/334847/239870b2-d539-ffda-66ca-0491d1bf84f6.png)


```python
import chainer
import chainer.functions as F
import chainer.links as L
from chainer import training
from chainer.training import extensions

class MLP(chainer.Chain):

    def __init__(self, in_size, n_units, n_out):
        super(MLP, self).__init__()
        with self.init_scope():
            self.l1 = L.Linear(in_size, n_units)
            self.l2 = L.Linear(n_units, n_units)
            self.l3 = L.Linear(n_units, n_out)
    
    def forward(self, x):
        h1 = F.relu(self.l1(x))
        h2 = F.relu(self.l2(h1))
        h3 = self.l3(h2)
        return h3
```

このMLPを繋げたモデルが基本のモデルになります。
ちなみにMLPは多層パーセプトロン(Multilayer perceptron)の略です。
![baseModel50.png](https://qiita-image-store.s3.amazonaws.com/0/334847/4839af2c-6305-ae46-cb9b-d6395d4d27da.png)

```python
class SMLPs(chainer.Chain):
    def __init__(self, in_size, n_unit, out_size):
        super(SMLPs, self).__init__()
        with self.init_scope():
            self.mlp1 = MLP(in_size, n_unit, n_unit)
            self.mlp2 = MLP(n_unit, n_unit, out_size)
    
    def forward(self, x):
        h1 = F.relu(self.mlp1(x))
        h2 = self.mlp2(h1)
        return h2
```

これが基本となるモデルです、今回はDatasetにCifar-10を用いてるので
Inputのサイズは32x32x3=3072、unit(パーセプトロン)の数は5000、outputのサイズは10です。
unitの数はGPUのメモリに乗りきらない場合、outofmemoryになるので、各自の環境に合わせてください。
私のGTX970の場合、batchsizeを512でunitを6000以上にするとoutofmemoryになりました。
メモリに乗るギリギリまでに設定すると分散深層学習との差がわかりやすいと思います。
#5.Model-Parallel
Model-Parallelの基本的な考え方はモデルの分割です。
![model-parallel.png](https://qiita-image-store.s3.amazonaws.com/0/334847/67dd8840-0ea3-803c-1b71-32e07759b317.png)
全結合(Linear)の場合、結合の半分をGPU0で、もう半分をGPU1で処理するという考え方で、
速度向上は勿論なんですが、例えば層が深すぎたり(ResNetなど)
Modelが一つではなかったり(GANなど)した場合にパラメーター(重みやバイアス)が
単体のGPUに乗り切らない場合があります。

私の場合、GANの話になってしまいますが
generatorとdiscriminatorのModelがSRResnetで構成されていた場合、
高解像度の学習はできず、またバッチサイズも矮小化せざるを得ないことがありました<sup>*GTX970 3.5GBの場合</sup>
つまり今後、更に層が深くなっていけばパラメーターだけでGPUのメモリが埋まることもありえるわけです。
(*GANについては[今さら聞けないGAN](https://qiita.com/triwave33/items/1890ccc71fab6cbca87e "今さら聞けないGAN")がおススメです。)

そんなときに用いる手法がModel-Parallelで、
先ほども言った通りsingleGPUでこのモデルのunitの数を6000以上にすると
outofmemoryですが、Model-Parallelなら10000unitでも実行可能です。
コードを記載します。

```python
class ParallelMLP(chainer.Chain):
    def __init__(self, in_size, n_units, out_size):
        super(ParallelMLP, self).__init__()
        with self.init_scope():
            self.mlp1_gpu0 = MLP(in_size, n_units//2, n_units).to_gpu(gpu_0)
            self.mlp1_gpu1 = MLP(in_size, n_units//2, n_units).to_gpu(gpu_1)

            self.mlp2_gpu0 = MLP(n_units, n_units//2, out_size).to_gpu(gpu_0)
            self.mlp2_gpu1 = MLP(n_units, n_units//2, out_size).to_gpu(gpu_1)

    def forward(self, x):
        #xはgpu_0に存在
        z0 = self.mlp1_gpu0(x)
        z1 = self.mlp1_gpu1(F.copy(x, gpu_1))

        h0 = F.relu(z0 + F.copy(z1, gpu_0))
        h1 = F.relu(z1 + F.copy(z0, gpu_1))

        y0 = self.mlp2_gpu0(h0)
        y1 = self.mlp2_gpu1(h1)

        y = y0 + F.copy(y1, gpu_0)
        return y
```

gpu_0には0がgpu_1には1が入ったグローバルな変数です。
気を付けてほしいのはコメントにも書いてありますが、テンソル(Variable)が
最初に存在している場所はGPU0です。
singleのモデルと比較してみるとunitの数を半分に割っているのがわかるかと思います。
そして肝心のGPU間の同期ですが、見てわかる通りテンソル同士の足し算になっているので、結局のところ
この(chainerのチュートリアルにおいての)Model-Parallelは完全にベースモデルを分割しているというわけではなく
文字通り計算量の多い処理を分散しているということになります。
つまりModel-ParallelとsingleGPUの精度を比較した場合、結果が異なってくるのは容易に想像できるかと思います。
![modelParallelGraph.png](https://qiita-image-store.s3.amazonaws.com/0/334847/40e93e3d-47dc-e68b-5439-1307c6dd5cb4.png)
<sup>*ハイパーパラメーターの調整をしていないので学習の安定性と精度は少し劣るかもしれませんがご容赦ください。</sup>

ただ今回は層が浅いこともあってかどちらが優れているということまでは
わかりませんでしたが(ぱっと見Model-Parallelの方がよく見えるが)
基本的にはModel-Parallelの方が精度は劣るものと思います、
しかしsingleGPUがこれ以上大きくunitや層を増やせないことを考えると
Model-Parallelのアドバンテージもあるでしょう。
速度は、7.速度の章でまとめて記載します。
#6.Data-Parallel
Data-Parallelの考え方は、ミニバッチの分割です。
![data-parallel.png](https://qiita-image-store.s3.amazonaws.com/0/334847/e131f59b-eac6-5e68-3120-40183afa9932.png)
例えばバッチサイズが512だった場合、半分の256バッチをGPU0で、半分をGPU1で計算し集計します。
つまりそれぞれのGPUが(全く同じパラメーターの)Modelを持ち同時に学習するわけです。
モデルはベースモデルと同じです。

```python
def main():
    model_0 = SMLPs(input_size, unit, o_unit)
    model_1 = model_0.copy()

    model_0.to_gpu(gpu_0)
    model_1.to_gpu(gpu_1)

    optimizer = chainer.optimizers.SGD(lr=lr/2)
    optimizer.setup(model_0)
```

ただ、いくら損失関数の値がバッチサイズに対する平均なのだとしても、ただ単に損失関数の値を
model_0とmodel_1で計算した損失関数値に対してさらに平均化したものにすればいいというわけではなく、
バックプロパゲーション(誤差逆伝播法)の問題もあるので、つまるところ勾配も集計しなければいけません。
ではどうするのかといいますとaddgrads関数をつかってmodel_0にmodel_0で計算した勾配にさらにmodel_1で計算した勾配を
足し、(model_0のみを)更新するという手法を取ります。
ゆえに上のコードの学習率が半分で、かつoptimizersの適応はmodel_0のみとなっています。
そしてcopyparams関数で更新したmodel_0のパラメーターをすべてmodel_1にコピーするというのが一連の流れで、
このGPU間の同期の仕方がのちのち速度的な問題に繋がっていきます。
それではtrainの箇所のコードを載せます。

```python
for e in range(epochsize):
        print('epoch %d' % e)

        #学習
        per_epoch_train_start = time.time()
        indexes = np.random.permutation(dataset_size)
        for i in range(0, dataset_size, batchsize):
            x_batch = x_train[indexes[i : i + batchsize]]
            y_batch = y_train[indexes[i : i + batchsize]]

            x0 = chainer.Variable(chainer.backends.cuda.to_gpu(x_batch[:batchsize//2], gpu_0))
            y0 = chainer.Variable(chainer.backends.cuda.to_gpu(y_batch[:batchsize//2], gpu_0))
            x1 = chainer.Variable(chainer.backends.cuda.to_gpu(x_batch[batchsize//2:], gpu_1))
            y1 = chainer.Variable(chainer.backends.cuda.to_gpu(y_batch[batchsize//2:], gpu_1))

            predict_0 = model_0.forward(x0)
            predict_1 = model_1.forward(x1)

            loss_0 = F.softmax_cross_entropy(predict_0, y0)
            loss_1 = F.softmax_cross_entropy(predict_1, y1)
            
            model_0.cleargrads()
            model_1.cleargrads()

            loss_0.backward()
            loss_1.backward()

            model_0.addgrads(model_1)
            optimizer.update()

            model_1.copyparams(model_0)
```

Data-Parallelのアドバンテージはなんといってもバッチサイズの拡大です。
バッチサイズの拡大は、速度向上はもちろんのこと精度の問題にも繋がっていきます。
例えばパラメーターがGPUのメモリを圧迫し実行できない場合、簡単な解決法として
バッチサイズの縮小化がありますが、バッチサイズを縮小するということはDatasetの一枚一枚に
過敏に反応することになり、仮にノイズ混じりのDatasetなのだとしたら大きく精度が変化してしまうため
GANなどModelが二つありかつ層が深い場合この問題は顕著に表れ、画像生成が思うようにいかない場合があります。
Data-Parallelはそんな問題を解決するのに役立ちます。
続いて精度グラフです。
![dataParallelGraph.png](https://qiita-image-store.s3.amazonaws.com/0/334847/d1e1970c-0eca-9206-2772-06c7650fb7f1.png)
完全な一致とまではいきませんが、Model-Parallelと比べてほぼ一致することがわかります。
今まで説明してきませんでしたが、バッチサイズが500と中途半端だったのはDatasetの数が500で割り切れ
(cifar-10の場合、trainが50000枚なので100回で1epoch)
かつバッチサイズが2で割り切れる為(全ての処理を完全に分割できる)、精度がsingle-GPUに近づくからです。
ただ理論上は精度が一致するはずなのですが、浮動小数点の精度の限界や活性化関数(relu)を挟んでいるので
小さな誤差が初期値鋭敏性的な大きな誤差になっている可能性があります(憶測です)
#7.速度
肝心の速度です。

| Model      | elapsed[sec]     | timeaverage[sec] |
|:-----------|------------:|------------:|
| Model-Parallel       | 804.30(13min)       | 6.07        |
| single-GPU     | 1,346.28(22min)      | 9.98      |
| Data-Parallel       | 2,214.90(37min)        | 18.34         |
|CPU                  |34,517.45(10h)|324.45|
<sup>*dataset=cifar-10,epoch=100,batchsize=500,unit=5000,GPU=GTX970 SLI,CPU=AMD 2600x</sup>
elapsedは(精度の計測も含めた)全ての終了時間、timeaverageは学習の平均時間、単位はsecです。
当たり前ですがCPUは桁違いに遅いです。
問題は、Model-Parallelがsingle-GPUに対して約1.6倍早くなっているのに対して
Data-Parallelは約0.5倍になってしまっています。
これはGPU間の同期速度に限界がある為です、どういうことかといいますと先ほども言った通り
勾配を足すのにaddgrads、パラメーターをコピーするのにcopyparamsを使用しますが
この回数が1epochを回す時間に対して大きすぎるとsingle-GPUとの差が大きくなります(パラメーターの数にもよるが)
今回は層が浅く、学習時間が短いためこのような結果になってしまっているということです。
仮にもし、GPU間の同期がない場合、つまるところaddgradsとcopyparamsを抜いた場合の速度は以下のようになります。

| Model      | elapsed[sec]     | timeaverage[sec] |
|:-----------|------------:|------------:|
| Data-Parallel       | 785.36(13min)        | 5.54        |

ほぼModel-Parallelと一致しますが、
ただこれはDatasetをランダムに半分に割ってバッチサイズを250で学習しているにすぎませんので
特に意味はありません。
しかし、今後GPU間の同期速度が上がればこの値に近づくという指標として見てもらえればと思います。
#8.github
整理次第記載します。