高火力(時間課金)でChainerMNを試す

先日はChainerMNによる機械学習の高速化勉強会にご参加いただきありがとうございました!想定していたより多くの方に参加いただけました。
質疑時間や懇親会での討論もとても有意義なもので、とても良い勉強会になったと思います。

今日のブログは、そちらの勉強会でお話した内容の書き起こし(資料公開)に加えて、その後の再実験でより良い結果が出ましたので、その結果もまとめて報告したいと思います。
第一部のPFN鈴木さんによる『分散深層学習とChainerMNについて』の発表資料はPFN&PFIさんのSlideShareに上がっていますので、そちらをご覧ください。

なお、本記事に書いてある事柄は、記事執筆時点(2017年12月5日)の情報であり、今後の高火力・Chainerの発展にともなって変更される箇所が多数あるかと思います。最新の情報については、それぞれのヘルプ等を参照ください。

概要

細かな話に入る前に、結果を先にお見せしたいと思います。

Chainer/ChainerMNの速度比較

ここで、p1,p4,p8,p16と書いてあるのは、それぞれ1,4,8,16プロセスで並列処理した結果です。ご覧の通り、ChainerMNを用いて4,8,16と並列数が増えていくに従って処理速度もスケールさせることができました!

ChainerMNと高火力について

最近、「AIしたい!」(大意)という話をよくお聞きします。
そこまで大げさでなくても、AI関連のお仕事などがとても活発な昨今です。
この「AIしたい」とは何か。
よくよくお話を伺ったりして考えてみると、多くの場合、それは「深層学習(Deep Learning)を使って問題を解決したい」ということ、つまり「深いニューラルネットワークを使って機械学習したい」という意味であったりします。

ではじゃあ早速「深層学習しよう」と考えた時、どうしましょうか。

一つの方法としては、深層学習をするソフトウェアを全部自分で作るというのがあります。しかし、それはオススメしません。
なぜなら、この分野は現在とても研究が盛んで、とっっても進歩が速い分野です。例えば、よくあることなのですが、先週公開された手法について今日には改良された論文がarXivにある、みたいなことがあります。
ですので、自分が深層学習に対する専業の専門家でない限り、これに常に追いつき続けるのはかなり無謀です。
加えて、そもそもの目的は深層学習を使うことではなく、深層学習を使って何かの課題を解決したいというところにあります。
手法や実装を深く掘り下げるには、自分の勉強のためにはいいですが、問題解決をしたいという要望に応えるには少し遠回りです。

そこで、深層学習を使うには、既存のライブラリやフレームワークを使うのが良いです。
これは、行列演算をするのに全部自力で使わずに、BLAS系ライブラリを使うのに似ています。
そうすることで、非専門家であっても最新の手法の恩恵を受けやすくなり、また、速度面についてもライブラリ・フレームワークの開発者側にお任せしてしまうことができます。
これにより、アプリケーション(問題解決法の開発)に集中しやすくなります。

さてでは、深層学習用のフレームワークを使いましょう。しかし世の中には、深層学習用のフレームワークはいっぱいあります。

  • Chainer (Preferred Networks)
  • TensorFlow (Google)
  • MXNet (Amazon)
  • CNTK (Microsoft)
  • Caffe (BAIR/BVLC)
  • などなど

どれがいいでしょうか?分散学習するにあたって同期・非同期であるかなど、色々な評価軸がありますが、今回はChainerを使うことにします。

理由は、年始に公開されたPFN秋葉さんによる記事『ChainerMNによる分散深層学習の性能について』で、「分散並列してもスケールする」「学習制度も劣化しない」という報告があり、時間のかかる学習はChainerでなら高速化できるかもという期待ができそうだからです。

上記『ChainerMNによる分散深層学習の性能について』より

Chainerとは、Preferred Networksさんが開発している深層学習用フレームワークです。
Github.com上でオープンソースとして開発されており、全てPythonで書かれているのが特徴です。
それゆえ、ニューラルネットワークを簡単に作ることができ、また、複数GPUも含むGPUサポートも受けられます。
現時点での最新版は、v3.1.0(2017-11-17)であり、本記事でもこのバージョンを使用します。

今回、このChainerで分散学習するために、ChainerMNを用います。
ChainerMNは、Chainerの追加パッケージで、基本的には既存のChainerコードにoptimizerをかぶせるだけで分散学習できるようになるというものです。
先述の記事の通り、128GPUまでスケールするとのことです。また、先日公開された“Extremely Large Minibatch SGD: Training ResNet-50 on ImageNet in 15 Minutes”では、ChainerMNを使って、1024GPUを使ってResNet50によるImageNetデータセットの学習が15分で完了できたとのことです。
最新版はv1.0.0(2017-09-01)で、本記事でもこのバージョンを使用します。

ChainerとChainerMNの詳細については、勉強会第一部で鈴木さんが説明されているので、そちらをご覧ください。

さて、これでChainer/ChainerMNを用いれば分散並列して学習時間が短縮できそうなことが分かりました。
でも、そうは言ってもGPUをそんなに何十枚・何百枚と持っているとは限らないと思います。
また、もし仮に持てたとしても、そんなにたくさん管理するのはとても大変です(専業の管理者が必要になります)。
そこで、クラウドを利用すればいい!ということになります。

とは言うものの、またここで「ではどのクラウドを利用すればいいだろうか?」という話になります。例えば以下のようなサービスがあります。

  • AWS (Amazon)
  • Azure (Microsoft)
  • Compute Engine (Google)
  • 高火力コンピューティング (さくらインターネット)
  • などなど

今回は、一番最後の高火力コンピューティングを利用することにしました。

高火力コンピューティングは、さくらインターネットによるGPUクラウドサービスです。

特徴として、まずGPUノードをまるっと時間単位で借りられるというのがあります(月額課金もあります)。以下は現時点での高火力(時間課金モデル)で利用できるGPUになります。

GPU 枚数 料金[円/時]
Maxwell TITAN X x4 288
Pascal TITAN X x4 294
Tesla P40 x1 349
Tesla P100 x1 357

ご覧の通り、時間あたり300円前後で借りることができます。時間課金というと例えばAWSでも借りることができますが、AWSはサービスの選択肢が多く設定も少し難しい部分があります。
一方、高火力であれば、先述の通りノード1つをまるっと渡してもらえるので、普段作業しているワークステーションと遜色ない使い勝手になります。
また、日本語ヘルプも充実しており、例えばNVIDIAドライバのインストール方法なども丁寧に解説されているため、日本で活動する人たちにとってはとてもありがたいです。

ということで、今回は、分散機械学習するためのフレームワークとしてChainerおよびChainerMNを使い、GPU環境では高火力コンピューティング(時間課金)を使うことにしました。

動かし方

というわけで、さっそく動かし方を説明していきます。動かすためには、以下の5ステップが必要です。

  1. 高火力(時間課金)を契約&準備
  2. CUDA等の環境整備
  3. Chainerを入れる
  4. ChainerMNを入れる
  5. ImageNet(ILSVRC2012)で動かす

まず最初に、高火力(時間課金)を契約し、使えるようにします。今回は、複数GPUで分散処理させるため、Pascal TITAN Xが4枚載っているQuadモデルを利用します。手順は以下のとおりです

  1. 公式ヘルプのとおりに進めて契約する
  2. 後でCUDA 9を使うため、Ubuntu 16.04 LTSに入れ替える(公式ヘルプ
  3. ログインする(公式ヘルプ

これが終われば、後は普通にCUDA環境をインストールします。NVIDIA公式にある通りです。今回はCUDA 9.0.176-1を使用します。

x86_64のUbuntu 16.04をダウンロードし、下のコマンドの通りに実行すれば、インストール完了します。

次に、cuDNNを入れます。cuDNNはNVIDIA謹製の深層学習用ライブラリであり、Chainerは内部でこのライブラリを利用し高速処理しています。
こちらも、基本的にはNVIDIA公式にある通りに進めていけば問題ありません。

  1. NVIDIA Developer Programに登録する(既に登録済みの人は不要)
  2. cuDNNのラインタイムと開発キットの.debをダウンロード(今回はv7.0.4 for CUDA 9.0)
    • libcudnn7_7.0.3.11-1+cuda9.0_amd64.deb
    • libcudnn7-dev_7.0.3.11-1+cuda9.0_amd64.deb
  3. インストール

そして、NCCL2もインストールします。NCCL2も、cuDNNと同じくNVIDIA謹製のライブラリであり、GPU間の集団通信をするためにChainerMNが利用します。
手順はこれまでと同様NVIDIA公式にしたがって操作すれば入れることができます。

  1. NVIDIA Developer Programに登録する(既に登録済みの人は不要)
  2. NCCL2をダウンロード(今回はv2.1 for CUDA 9.0)
    • nccl-repo-ubuntu1604-2.0.5-ga-cuda9.0_3-1_amd64.deb
  3. インストール

注意として、NCCLには、NCCL1と呼ばれるGithub.comでオープンソースとして開発されていたバージョンがありますが、こちらは既に開発が停止されており、また別物になっていますので、間違えないように注意してください。

その後、Python3をインストールします。Ubuntu 16.04はデフォルトではPython2が入っていますが、後でMultiprocessIteratorを使ったImageNetサンプルを動かすためにPython3が必要です。
ここで、システムとの競合を避けるためにpyenv(とvirtualenv)を使うのが良い望ましいです。インストールには以下のコマンドを実行します

そして以下を~/.bashrc先頭に追加します。
通常このような設定は末尾につけますが、今回の環境では、bashrc等の先頭に「非対話型シェルの場合は設定しない」という処理が入っているため、後でMPIを使った時にも設定されるように必ず先頭に追記してください。

そして、ここで再ログインするか、source ~/.bashrcします。その後、以下のコマンドでPython3を使うことができます。

ここまでくると、あとはChainerを入れます。Chainerはpipで簡単に入ります。

以前はCuPyはChainerの一部でしたが、最近になって分離されたため、必ずCuPyも別途インストールしてください。そうしないと、計算にGPUを使えなくなります。

ここまでで、Chainerが動くようになったはずなので確認しましょう。ここでは、MNISTのサンプルを動かしてみて、以下のように出ればOKです。

さて、ここからChainerMNの導入に移っていきます。まず最初に、OpenMPIをCUDA Aware APIありでインストールするところからです。
これは、後でChainerMNをGPUありで動かすために必要になってきます。

注意点として、apt等で入るOpenMPIではCUDA Aware APIが使えないため、必ず--with-cudaオプションを付けてconfigureして、自力ビルドしてください。
もし既にapt等で入れてしまっている場合は、綺麗に消してから実行してください。今回の環境では、最初は空っぽの状態で借りているはずですので、aptで触る前にビルドしてインストールすれば良いです。

OpenMPIが入ったら、ちゃんとMPIが動作するか確認しましょう。以下のように動けばOKです。

0と1の順番は前後するかもしれません。

そしてようやく、ChainerMNをインストールします。これもpipで簡単に入れられます。

これでChainerMNを使う設定ができました。ノード並列する前に、1ノードでChainerMNが動くか確認します。

ここでエラーになる場合は、OpenMPIをaptで入れてしまってないか、pyenv等の設定を.bashrcの先頭でなく末尾に入れてしまっていないか確認してください。

ここまできたら、あとは複数ノードでも動かしてみましょう。
まずはじめに、高火力コンピューティングの上で、ローカルネットワークを構築します。
これをやらないと、ノード間通信が全てグローバル経由になってしまい、ネットワーク帯域がとても遅くなり、分散してもまったく速度が上がらなくなります。
設定手順は基本的には、公式ヘルプに従って作業すれば完了します。

まず、サーバーコンソールにログインして、ネットワークを作成します。その後、ローカルネットワークを構成したいサーバー全てに同じネットワークを割り当てます。

ローカルネットワークの設定例

次に、各ノードにログインして、ローカルネットワークを使えるように設定します。/etc/network/interfacesを適当なファイルで開き、末尾の

となっている部分のコメントアウトを外し、xxxになっている部分を変更し保存します。

(1から254までの好きな番号)の部分については、各ノードに一意に割り当てます。例えば4ノード借りている場合は、それぞれのノードで192.168.0.1, 192.168.0.2, 192.168.0.3, 192.168.0.4とすると分かりやすいでしょう。編集が終わったら

を忘れずに。これでローカルネットワークが開通しましたので、実際にnetperf等で動かしてみると以下のように10Gbイーサネットの恩恵が受けられていることが確認できます。

設定が何をやっているかなどの詳細については別のヘルプの各OSのセットアップ仕様>Ubuntu 16.04にある「7.2 ネットワーク冗長構成でのローカル接続側NICの設定」を参照してください。

無事にネットワークも作れましたので、MPI通信の準備のために、hostfileというのを書きます。hostfileというのは、使用するノードのIPアドレス等を羅列したファイルです。
例えば、先のローカルネットワークで構築した4ノードで、各ノードが4GPUある場合、作業ディレクトリに以下のようなhostfileを作ってください。

そして、MPIが複数ノードでも動くか確認します。先のhostfileと同じディレクトリで以下を実行してみます

数の順番は前後するかもしれませんが、0から15の数字が表示されればOKです。オプションの--map-by ppr:4:nodeは、1ノードあたり4プロセス起動するという意味です。
今回の環境では、hostfileにソケット数を指定しないと1プロセスしか起動できず、指定しても4プロセスにならないことがありましたので、必ず指定してください。
また、ここで、応答が帰ってこなかったりエラーになっている場合は、例えばsshのログインに失敗している可能性があります。
SSHでパスワードやサーバー証明書の確認なしにログインできるかどうかを確認してください。

最後に、複数ノードでChainerMNを動かすことができます。同じくMNISTのサンプルを動かしてみます。

これで、ChainerMNを動かすところまでできました。

以上はMNISTサンプルでしたが、これではデータセットもモデルも小さすぎるので、本番のためにImageNet(ILSVRC2012)データセットを用意しましょう。
ChainerおよびChainermnのサンプルにあるtrain_imagenet.pyを使うためには、以下の手順が必要です。

  1. ILSVRC2012のデータセット(ILSVRC2012_img_train.tar, ILSVRC2012_img_val.tar)をダウンロードする(かなり巨大なファイルをアメリカから取ってくるので、かなり時間がかかります)
  2. ダウンロードしたtarballを解凍
  3. すべての画像を、中心で正方形にくり抜く
  4. 256ピクセル四方に縮小
  5. RGB画像に変換
  6. ラベルと画像パスを1対1に書いたファイルを生成

以上をすべて手作業でやっていると日が暮れても終わらないので、変換スクリプト”ImagenetConverterForChainer”を用意しました。使用方法はREADMEをご覧ください。

動かした結果

以上までで準備が整ったので、実際に動かしてみます。

実行環境は、先述の通り、さくら高火力(時間課金)Pascal TITANが4枚載ったQuadモデルを用います。今回は4ノードまで借りて、以下のコマンドで計測しました

chainer_p1(Chainer 1GPU)
chainer_p4(Chainer 4GPU)
chainermn_p4(ChainerMN 1 node, total:4 GPUs)
chainermn_p8(ChainerMN 2 nodes, total:8 GPUs)
chainermn_p16(ChainerMN 4 nodes, total:16 GPUs)

基本的には、exampleにあるtrain_imagenet.pyをそのまま使います。ただし、val_intervallog_interval(1, 'epoch')に固定しました。

実験結果は以下のようになりました(これは冒頭の概要部分で載せたものと同じものです)。ご覧の通り、ノード並列して分散学習することで、処理速度(単位時間あたりの処理画像枚数)を大幅に向上させることができました!

Chainer/ChainerMNの速度比較

特に、ChainerMNを用いて4→8→16ノードと2倍ずつノード数を増やしていくことで、処理速度は1.5倍強ずつ高速化されています。これは最初の秋葉さんによる記事である通り「スケールする」ことを示しています。

ただし、理想状態ではノード数が2倍になった時には2倍高速化されてほしいところです。少し足りないので、内訳をちゃんと計測してみます。
計測にはプロファイラー等を使う手もありますが、ここでは素朴に、該当する部分にタイマーを仕込んでみます。ChainerMNを使った時の1反復は、chainermn/optimizers.pyの_MultiNodeOptimizer.updateメソッドにあり、以下のようになっています。

ここで、以下のようにタイマーを仕込んで、標準出力に出すことで、時間を計測してみました。

これで、反復毎に、反復全体の時間、compute(forward+backward)、allreduce、optimizeのそれぞれの時間が計測できます。その結果、以下の通りになりました

ChainerMN処理時間の内訳

ご覧の通り、16ノードの時にはallreduceにかかる時間が半分以上を占めてしまっているのが分かります。

そこで、このallreduceの時間について追ってみます。
まず、ローカル回線は10Gbイーサネットとのことなので、そもそもMPI通信でその速度が出ているかを確認しました。
確認には、簡単なMPI P2P通信の計測スクリプトを使いました。結果は以下のとおりです。

MPI P2P通信による測定結果

ご覧の通り、8-9[Gibps]と、ほぼ10Gbイーサネットを使い切っていることが確認できました。

そこで次に、実際のallreduceではどれぐらいのネットワーク帯域が出ているか確認しました。
まず、MPIのメッセージ長ですが、先のupdateメソッドのbackwardが計算し終わった後辺りに

というコードを仕込んで、実測してみます。今回の測定に用いたRetNet50での結果は102228384[byte]と出ました。これをLとします。
allreduceの場合、reduceとbcastで2回通信します。通信量Nは、通信アルゴリズムの実装にも寄りますが、概ね、プロセス数pに対して、$N = \frac{2L(p-1)}{p}$と近似できます。
今回ChainerMNのサンプルそのままであるhierarchical communicatorを用いたので、実際にMPI通信しているプロセスは各ノードにつき1プロセスだけです。
よって、4プロセス、8プロセス、16プロセスの時は、それぞれp=1,2,4となり、つまり、通信量は$N=0, L, \frac{3}{2}L$になります。当然ですが、1ノードしか使っていない場合は通信しません。
一方、allreduceにかかった時間は、先の計測結果から、2ノードの時0.15[s]、4ノードの時0.22[s]であることが分かっています。これらを全て代入すると、以下の結果を得えられました

  • 2ノード:102228384*2*1/2 / 0.15 [byte/s] = 4.9[Gibps]
  • 4ノード:102228384*2*3/4 / 0.22 [byte/s] = 5.1[Gibps]

allreduceの処理の中には純粋なMPI通信だけでなく、NCCLを用いたreduce/bcast処理やCPU-GPUデータ転送も入っていることを考えると、実効帯域8[Gibps]に対して5[Gibps]程度は十分に帯域を使い切っていると考えて良さそうです。

まとめ

以上が今回の結果になり、まとめると以下のようになります。

成果
  • ChainerMNを高火力コンピューティング(時間課金)で動かした
  • ImageNet(ILSVRC2012)の学習時間を計測した
  • 高火力(時間課金)でも、ノード数を増やしていくと処理速度はスケールしそうなことが分かった
  • 処理時間の半分程度を通信処理であるallreduceが占めているが、詳細に検討すると、10Gbイーサネットのハードウェア制限を十分に使いこなせていると考えられる
知見
  • 学習データのダウンロード・変換時間なども課金時間に含まれるので注意する必要がある
  • 高火力(時間課金)を用いてもスケールするので、一時的にたくさんのGPUを使って深層学習を高速化したい場合、今回のChainerMN+高火力という組み合わせは有用であると言える
  • ただし、まだ高速化できる余地はありそう。例えば、ノード間をもっと高速なネットワークでつなぐか、通信時間を計算時間と重ねて隠蔽するなどの方法が考えられる。いずれも、今後のChainerMNや高火力コンピューティングの発展に伴い、改善していくものと期待できる

将来の課題

今回はあくまでResNet50とILSVRC2012データセットを用いた結果であることに留意してください。
特に、精度については、冒頭で紹介した『ChainerMN による分散深層学習の性能について』の「分散深層学習の難しさと課題について」節で触れられている通り、並列数を増やすとバッチサイズが増える影響で、精度劣化の原因となります。
今回は、1000分類問題に対してバッチサイズが最大で32×16プロセス=512程度であったので、経験的に極端な精度劣化がないと期待できる範囲での実験結果です。さらなる並列数でも精度を担保するには、バッチサイズを小さくしたりその他の工夫が必要です。
工夫の詳細については、PFN鈴木さんの発表資料aのp.47あたりからに詳しいですので、そちらもご覧ください。
今後、また機会があれば、更なる大規模ノードでそれらの改良手法を取り入れての実験もしてみたいと思います。

また、スループットの図の通り、シングルノードであれば、Chainerのtrain_imagenet_data_parallel.pyを使ったほうが、ChainerMNを使うより高速であるという結果も得られています。こちらについては、原因はまだ調査中で、ChainerMNのGithubにもIssueとして報告済みです。進展ありましたら本記事にでも追記しようと思います。

謝辞

本成果は、国立研究開発法人新エネルギー・産業技術総合開発機構(NEDO)の委託事業「IoT推進のための横断技術開発プロジェクト」の先導調査研究テーマ「実社会ビッグデータ処理基盤を実現する大規模データセンター構築・運用技術の研究開発」(代表提案者:さくらインターネット株式会社)によって得られたものです。

また、本成果を出すにあたり、特にネットワーク周りの設定について、さくらインターネット株式会社の須藤さんに多大なご助言をいただきました。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です