Stimulator

機械学習とか好きな技術話とかエンジニア的な話とかを書く

複数ノードDockerでChainerMNを動かすためのTips

- はじめに -

ChainerMNがついに本家Chainerにマージされました。分散深層学習への本気度が伺えます。

github.com


節目という事で、Dockerを利用して複数ノードでChainerMNするために行った事のメモをTips形式で残しておこうという記事です。

私は半年くらい前からこの記事の内容を使っているのでアップデートがあるかもしれません。
加えて、最近はkubernetesを使うのが流暢で、PFNさんも公式ブログ書いてるし多分k8sが良いと思います。
ChainerMN on Kubernetes with GPUs
(私もk8s挑戦してるけどあんまり上手くいってなくて放置中です…)

この記事はk8s使えないけどDocker使えるような分散環境はあるみたいなニッチな需要を満たすかもしれないなあというレベル感の記事です。

 

 

- 前提と参考文献 -

記事前提として以下を想定しています。

  • docker、nvidia-docker、cuda周りがインストールされており、1ノード1GPUでChainerのtrainを回せる環境がある
  • 複数ノード、複数GPU環境がある

また、私が環境構築した際に読んだ参考文献を示します。

ひとまず、上の5つに目を通せば動くような気がします。
加えて、細かなエラーでStackOverflowやOpenMPIの公式リファレンスを読んでいますがそちらは随時記載します。


Deep Learning分散の仕組みについては、秋葉さん、鈴木さんの記事とスライドに任せます。


 

- Docker環境構築 -

cuda周り以外はまっさらのUbuntu 16.04 のDocker想定。

apt-get upgrade
apt-get update
apt-get install python3-pip python3-dev
apt-get install python-pip python-dev
apt-get install wget git

バックエンドにOpenMPI、NCCLを利用する想定でインストールを進める。

OpenMPIのインストール

apt-get install infiniband-diags opensm ibverbs-utils infiniband-diags perftest

wget https://download.open-mpi.org/release/open-mpi/v3.0/openmpi-3.0.1.tar.gz
tar -zxvf openmpi-3.0.1.tar.gz
cd openmpi-3.0.1

./configure --with-cuda --prefix=$HOME/local/openmpi --with-openib
make -j4
make install

touch ~/.bashrc
echo 'export LD_LIBRARY_PATH=$HOME/local/openmpi/lib:${LD_LIBRARY_PATH}' >> ~/.bashrc
echo 'export PATH=$HOME/local/openmpi/bin:${PATH}' >> ~/.bashrc


NCCLなるGPU通信ライブラリのインストールは、以下のリンクからダウンロードする。
NVIDIA Developerの登録を済ませておく必要がある)
https://developer.nvidia.com/nccl/nccl-download

ライセンス読んで「I Agree~」をチェックした後、debファイルを以下からダウンロードしてくる。
f:id:vaaaaaanquish:20180917213514p:plain

Docker内にダウンロードしてきたファイルを設置する。

# コンテナ内に送る
sudo docker cp nccl-repo-ubuntu1604-2.2.12-ga-cuda9.0_1-1_amd64.deb [CONTAINER ID]:/hogehoge

# docker attach後
dpkg -i nccl-repo-ubuntu1604-2.2.12-ga-cuda9.0_1-1_amd64.deb
apt update
apt install libnccl2 libnccl-dev
echo 'export NCCL_ROOT=/usr/local/nccl' >> ~/.bashrc
echo 'export CPATH=$NCCL_ROOT/include:$CPATH' >> ~/.bashrc
echo 'export LD_LIBRARY_PATH=$NCCL_ROOT/lib/:$LD_LIBRARY_PATH' >> ~/.bashrc
echo 'export LIBRARY_PATH=$NCCL_ROOT/lib/:$LIBRARY_PATH' >> ~/.bashrc
source ~/.bashrc

追記:2018/09/19
エゴサしてたらNCCL情報が


 
この後一番陥りやすいのが相互にsshする環境の構築である。
複数ノードの場合「ノード間はssh-keyでパスワードなしでssh可」「dockerに入るにはパスワードなしで」を実現しなければならず、通信できない場合にOpenMPIが無反応だったりしてわからんってなるのでちゃんとやる。

apt-get install -y build-essential libssl-dev libreadline-dev zlib1g-dev language-pack-ja
apt-get -y install openssh-server ufw curl
mkdir /var/run/sshd

# ユーザを作って、そのユーザでsshできるようにする(vaaaaanquishの所をよしなに)
useradd -m vaaaaanquish && echo "vaaaaanquish:vaaaaanquish" | chpasswd && gpasswd -a vaaaaanquish sudo
mkdir -p /home/vaaaaanquish/.ssh
chmod 700 /home/vaaaaanquish/.ssh
passwd -d root
passwd -d vaaaaanquish

鍵はDockerコンテナ内では生成せず、ノードからマウントしてくる形を取る(結局ノード間自体もsshできないと意味ないので)。

 
/etc/ssh/sshd_configを以下のように変更する(なければ作る)
ここで指定するPortは、Dockerからもアクセスするので今後統一して選べる所にする。

Port 2223
PermitEmptyPasswords yes
PasswordAuthentication no
PubkeyAuthentication yes
UsePAM no

また、rootから~/.ssh/configの最期の方に以下も追記する(なければ作る)

Host *
    Port 2223
    StrictHostKeyChecking no
    UserKnownHostsFile /dev/null

 
最後にcythonとChanierMNをインストールする。
cupyのcudaNNのバージョン等を絶対間違えないよう確認する

pip3 install cython
pip3 install cupy-cuda90
pip3 install chainermn

# Chainer試すためのMNISTパッケージもここで
pip3 install python-mnist


 
dockerコンテナからデタッチする前に、以下のファイルをルート配下辺りに「init.sh」のような名前で置いておく。

#!/bin/bash

NCCL_SOCKET_IFNAME=^docker0
/usr/sbin/service ssh start
/bin/bash

NCCLがDockerの作る仮想ソケットを利用してしまうらしいのでNCCL_SOCKET_IFNAME=^docker0は必須
参考:http://tabisurubiker.hatenadiary.jp/entry/2017/10/02/092952
Dockerコマンドでrunする際に実行するプログラムとして、このinit.shを実行、sshサーバを立ち上げて、bashで待機させるようにする。

待機しているdockerコンテナに対してOpenMPIがプロセス振ってくれるイメージ。

 
これで多分できたのでcommitしてsaveしてDockerコンテナは終了。


 

- ノード間のsshの実現 -

前述したようにノード間はsshできないとダメ。

実現する方法はいくつかあるがが、私はアホなので全ノードでssh-keygenして全ノードに配ればええやんと思ってスクリプト書きました。これで作ったkeyをマウントする形を取っている。ssh_generator.pyなる名前で以下のファイルを作って実行するだけ。自身をfabricで読んで各サーバに送る動作をpexpectで実行するアホみたいなスクリプトです。

# -*- coding: utf-8 -*-
# must : pip install pexpect, fabric3
from fabric.api import *
import pexpect
import sys

HOSTS = ['hogehoge', piyopiyo]    # 利用予定のサーバ
env.user = 'vaaaaanquish'          # localからサーバにアクセスするユーザ
env.key_filename = 'local key'    # localからサーバにアクセスするためのkey
env.password = PASSWORD      # localからサーバにアクセスするためのkey pass


def ssh_gen():
    run('ssh-keygen -t rsa')

def ssh_copy_id(y):
    run('ssh-copy-id -i /home/{}/.ssh/id_rsa.pub {}'.format(USER, y))


if __name__ == '__main__':
    for x in HOSTS:
        # 作るやつ
        print('\n[Making key : {}]'.format(x))
        cmd= "fab -f ssh_generator.py -H {} ssh_gen".format(x)
        child = pexpect.spawn(cmd, encoding='utf-8')
        child.logfile = sys.stdout
        while 1:
            i=child.expect([
                r'^.*(Enter file in which to save the key).*',
                r'.*(Enter passphrase).*',
                r'.*(Enter same passphrase again).*',
                r'.*(Overwrite).*\?',
                pexpect.EOF, pexpect.TIMEOUT], timeout=5)
            if i in [0,1,2]:
                child.sendline('')
            if i == 3:
                child.sendline('y')
                child.sendline('')
            if i in [4, 5]:
                break

        # 配るやつ
        print('\n[Sending key : {}]'.format(x))
        for y in HOSTS:
            cmd= "fab -f ssh_generator.py -H {} ssh_copy_id:{}".format(x,y)
            child = pexpect.spawn(cmd, encoding='utf-8')
            child.logfile = sys.stdout
            while 1:
                i = child.expect([
                    r".*(password).*",
                    pexpect.EOF, pexpect.TIMEOUT], timeout=5)
                if i == 0:
                    child.sendline('{}'.format(PASSWORD))
                    child.sendline('')
                if i in [1,2]:
                    break

これをローカルPCで実行し、鍵配り作業とする。各サーバの~/.ssh/配下に鍵がそれぞれ登録されます。
(基本的に使い終わったらこの鍵を削除しておかないと危ういのでちゃんとやりましょう)

踏み台等を利用する環境でも「env.gateway = [GATEWAY_SERVER]」のようにする事で解決可能です。


 

- mpiexecを用いたChainerMNの実行 -

以下ChinerMNのチュートリアルpython-mnistで試していく。
Step 1: Communicators and Optimizers — ChainerMN 1.3.1 documentation

 

Dockerのrun

nvidia-docker runするがこの際には以下のようにオプションを付ける。

sudo nvidia-docker run -it -d \
    --net=host -p 2223:2223 \
    -v /tmp:/tmp \
    -v /etc/hosts:/etc/hosts:ro \
    -v /home/vaaaaanquish/.ssh:/home/vaaaaanquish/.ssh \
    -e LD_LIBRARY_PATH=/usr/local/nccl/lib/:/root/local/openmpi/lib:/usr/local/nvidia/lib:/usr/local/nvidia/lib64:/usr/local/cuda/lib64 \
    -e PATH=/root/local/openmpi/bin:/usr/local/nvidia/bin:/usr/local/cuda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
    -e NCCL_ROOT=/usr/local/nccl \
    -e CPATH=/usr/local/nccl/include:/usr/local/cuda/targets/x86_64-linux/include \
    -e LIBRARY_PATH=/usr/local/nccl/lib/:/usr/local/cuda/lib64/stubs \
    -e PYTHONPATH=/usr/bin/python3\
    [docker_image_name] sh /init.sh

dockerのポートはssh_configに書いたものを指定する。
/etc/hostsはマウントしないとdockerから各ノード間で通信できないので必須。
また前述の通り、配布したsshもノード上の物をマウントして利用する。
環境変数はdocker内でbashrcに書いても良いし、個々で-eで指定しても良いですが今回はinit.shを最後に動かすようにしてるのでここで。

あとコレはHorovodなるpackageを使った時に起こりがちな症状ですが/tmpにファイル作る場合もあるので、/tmpも大人しくマウントしておくと良いです。

 
何か雑にこんなんで全ノードでdocker実行すればええんちゃうんですかね

from fabric.api import *

env.hosts = [HOSTS]
env.user = USER
env.key_filename = KEY_PATH
env.password = PASSWORD

DOCKER_ENV = '-e LD_LIBRARY_PATH=/usr/local/nccl/lib/:/root/local/openmpi/lib:/usr/local/nvidia/lib:/usr/local/nvidia/lib64:/usr/local/cuda/lib64 -e PATH=/root/local/openmpi/bin:/usr/local/nvidia/bin:/usr/local/cuda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -e NCCL_ROOT=/usr/local/nccl -e CPATH=/usr/local/nccl/include:/usr/local/cuda/targets/x86_64-linux/include -e LIBRARY_PATH=/usr/local/nccl/lib/:/usr/local/cuda/lib64/stubs -e PYTHONPATH=/usr/bin/python3'

@parallel
def run_docker():
        run("sudo nvidia-docker run -it -d --name {} --net=host -p 2223:2223 -v {}:{} -v /etc/hosts:/etc/hosts:ro -v /home/{}/.ssh:/home/{}/.ssh {} {} sh {}".format(P_NAME, LUSTERFS_MAUNT, LUSTERFS_HOME, USER, USER,DOCKER_ENV, DOCKER, INIT_SH))


 

mpiexecでChainerMNの実行

どこか1つのノードに対して「ssh root@hogehoge -p 2223」等としてdockerに入る。

docker内でmpiexecを実行する時、オプションで別のノードを指定することもできるが、 hostfile が あると便利なのでこれを用意する。
host.txtみたいな雑な名前で良い。書き方は以下の通り。

hogehoge_server_001 port=2223 cpu=2
hogehoge_server_002 port=2223 cpu=2
hogehoge_server_003 port=2223 cpu=2
hogehoge_server_004 port=2223 cpu=2

cpu=2って書いてるけど、これ実際はgpuの数っぽい。(プロセス数という意なのかも)
各サーバでport=2223とport=2224をそれぞれGPU割り当てて、Dockerマウントして使いたい!みたいなのはhostfileでは書けない。


いざ実行。前述example内のtrain.pyの画像path等をちょっと変更して動かしてみる。

mpiexec --allow-run-as-root --mca btl_tcp_if_include ib0 -x PATH=$PATH -x PYTHONPATH=$PYTHONOATH -x LD_LIBRARY_PATH=$LD_LIBRARY_PATH -x CPATH=$CPATH -x LIBRARY_PATH=$LIBRARY_PATH -x NCCL_ROOT=$NCCL_ROOT --hostfile host.txt -np 8 python3 train.py

dockerのrootで走ってもらう必要があるので--allow-run-as-rootが必須。
また、Infinibandを使っている時は--mca btl_tcp_if_include ib0して指定しないと掴んでくれなくて遅いので注意。
npの後ろにGPUと同じ数を書いて実行コマンドを叩くだけ。


学習が走ってそうだったら終わりです。


 

- エラー対応とか -

結構つまり所なエラーについて書いておきます。
OpenMPI、クソデコレーションされたエラー文吐く時もあれば、うんともすんとも言わないときもあるのでイライラせず対応していきましょう。

mpiexec実行しても全く何も表示されない!

→ 相互sshが上手くいってないので普通にsshで別ノードの指定ポートにパスワードなしで飛んでdockerコンテナ内に入れるかチェック

orterun was unable to launch the specified application as it could not access or execute an executable ~~~

→ 相互sshが上手くいってないので普通にsshで別ノードの指定ポートにパスワードなしで飛んでdockerコンテナ内に入れるかチェック

mpiexecが動いてそうだけど学習前に止まる!

→ chainerのサンプルでいうとGPU掴むところで止まってるんだと思います。
hostfileの書き方が怪しい場合と環境変数が上手く渡せてない場合が多いです。
この場合エラーも出ず止まったままになるのでサッサとctrl+cで抜けて確認。

ORTE does not know how to route a message to the specified daemon located on the indicated node ~~~

sshsshdのconfig間違えてデーモン起動できてないんだと思います

クソ遅い!received unexpected process identifier!

→ infiniband掴めてないのでmpiexec時にこれ忘れてる --mca btl_tcp_if_include ib0
もしくは環境とオプションが合ってない

It appears as if there is not enough space for (the shared-memory backing

→ /tmpとかファイルシステムに書き込む物を実行した時にそこマウントしてないと起こりがち
エラー文を読めばなんとか。

Failed, NCCL error nvidia-sample.cu:88 'unhandled system error

→ NCCLがDockerの作る仮想ソケットを利用してしまうので、上記init.shもしくはそれと同等のものが動いてるか確認


 

- おわりに -

最初にも書きましたが多分k8sとか色んなツールが出てきてるので、こんなコツコツやる必要もなくなって行くと思います。

ただこれ貯めといても仕方ないし書いときました。何かしらの参考になれば幸いです。


最近uberが出しているTF, Keras, PyTorchのOpenMPIトレーニングのwrapperとして扱えるhorovodも、以上の方法で使えます。
uber/horovod: Distributed training framework for TensorFlow, Keras, and PyTorch.
github.com

horovodとかdistTF、pytorch.distributedの知見も徐々にアウトプットしていこうと思っています。がんばります。