GTC 2015の関連記事 |
---|
・【レポート】GTC 2015 - ヘテロジニアスHPCとNVLINK ・【レポート】GTC 2015 - Deep Learning用のCUDAライブラリ「cuDNN」 ・【レポート】GTC 2015 - NVIDIA、7TFlopsの演算性能を実現したハイエンドGPU「Titan X」を発表 ・→ GTC 2015の記事一覧はコチラ |
GTC 2015において、MPIを使うマルチGPUプログラミングというチュートリアルセッションが行われた。これからマルチGPUのシステムのプログラムを作ろうという人には役立ちそうな内容であるので紹介する。
次の図のように、CPUにメモリが付き、さらにPCIe経由でGPUとネットワークカードが付いているというのが計算ノードで、複数の計算ノードがネットワークでつながっているというのが、一般的なマルチGPU環境である。これらのノード間の通信には、MPI(Message Passing Interface)というライブラリが使われることが多い。MPIはSPMD(Single Program Multiple Data)実行モデルであり、すべてのノード(後述のように、正確にはランク)で同じプログラムが走る。
MPIを動かすと、ネットワークに繋がっているすべてのノードを見つけ出し、それぞれのノードに実行すべきプロセスのコピーを作って一連のランク番号を付ける。ここでは各ノードに1ランクとして説明をしているが、MPIの起動時の指定で1つのノードに複数のランクを作ることもできる。1個のCPUに複数台のGPUを接続する場合は、複数のランクが1個のCPUで走り、それぞれのランクが1つのGPUに対応するという造りにするのが一般的である。
MPIによる通信は、メッセージの送信と受信のペアの動作で行われ、次の図のように、rank 0がMPI_Sendを行ってs_buf_dに入っているデータをRank n-1に送り。rank n-1がMPI_Recvを行ってRank 0からのデータを受け取るという手順で行われる。
最小のMPIプログラムは次のようなものである。最初にMPIのヘッダファイル
各ランクでは、まず、MPI_Comm_rankとMPI_Comm_size関数を呼ぶ。MPI_Comm_rankは自分のランク番号を返し、MPI_Comm_sizeは全部でいくつのランクがあるかを返す。各ランクはsizeで自分がどれだけの処理を分担するか、自分のランク番号からどのデータ処理を行うのかなどを知ることができる。
そして、相手のランク番号を指定してメッセージの送受信を行い、各種の計算処理などを実行して行く。処理が終わるとMPI_Finalizeを呼び、後始末をしてMPI処理を終了する。
#include <mpi.h>
int main(int argc, char *argv[]) {
int rank,size;
/* Initialize the MPI library */
MPI_Init(&argc,&argv);
/* Determine the calling process rank and total number of ranks */
MPI_Comm_rank(MPI_COMM_WORLD,&rank);
MPI_Comm_size(MPI_COMM_WORLD,&size);
/* Call MPI routines like MPI_Send, MPI_Recv, ... */
...
/* Shutdown MPI library */
MPI_Finalize();
return 0;
}
MPIプログラムを作るには、MPIをサポートしたmpiccなどのコンパイラを使ってソースプログラムをコンパイルして実行形式を作る。そしてmpirunコマンドで実行を開始する。ここで-np 4の指定で4つのランクを起動することを指定している。ここではノードが4個あるので、各ノードに1ランクが割り当てられる。
ヤコビ法は隣接する4点の値から中心の点の次の値を計算するというもので、分散処理の説明に良く使われる。n×kランクで処理を分担すると右下の図のようになる。
1個のGPUで処理する場合は、単純に周囲の4点の平均で次の値を計算して行けば良い。
しかし、1つ前の図のように複数のランクで処理を分割すると、端の部分では一部の隣接点は他のランクが分担しており、自分のところにはデータがないということが起こる。このため、端の部分(Haloと呼ぶ、日本語では袖領域ともいう)のデータは隣接するランクと情報交換する必要がある。
上下の隣接したランクとの情報交換は次の図のようになる。この図の上半分はOpenAcc、下半分はCUDAでの記述である。なお、ここでは送信と受信を同時に行うMPI_Sendrecv関数が使われており、u_new+offset_first_rowからランクt_nbにデータを送り、ランクb_nbからのデータをu_new+offset_bottom_bondaryに受け取る。これが右の図で1と書かれた通信である。
そして青字で書かれたMPI_Sendrecvで2の通信を行う。こうすると、上下の隣接行の内容が白○のHalo領域にコピーされ、端の赤○の部分でも上下の隣接データが存在することになる。
図の下半分のCUDAのコードでもほぼ同じ記述で上下の隣接領域のデータのコピーを行うことができる。なお、これはCUDA対応のMPIを使った場合で、一般のMPIを使う場合は、送信データをcudaMemcpyで明示的にホストCPUのメモリにコピーしてから送信し、受信はホストCPUのメモリに確保したバッファに入れる。そして、また、cudaMemcpyで受信データをGPUのメモリにコピーするという手順が必要となる。
横の隣接データはアドレスが連続していないので、いったんto_leftという配列にコピーしてから送り、from_left配列に受け取ったデータを縦方向に並び替えているが、Halo部分を埋めるという考え方は縦の隣接と同じである。
このようなヤコビ法のプログラムを、プロセスあたり4K×4Kを分担させて1000ステップ実行した場合の実行時間の例を次の図に示す。黄色がK20X GPUでの実行で青はXeon E5-2690 v2の3GHzクロックでの実行結果である。E5-2690 v2は10コアの各コアで1スレッドを実行させ、K20Xは1GPU/ランクでの実行であり、K20Xの方が3~4倍速いという結果になっている。また、各ランクが分担する仕事量は一定であるが、ランク数が多くなると若干処理時間が長くなっている。