MPIを用いた並列プログラミングの概要
ここでは並列計算機の種類とデータ通信の方法から、 MPIを用いた並列プログラミングの概要について説明します。
並列計算機の種類とMPI
並列計算機には共有メモリ型と分散メモリ型の2つの型があります。 前者の共有メモリ型は、すべてのプロセッサが同じメモリを参照する構成の計算機です。 後者の分散メモリ型は、各CPUがそれぞれメモリを持っており、 各CPUがお互いのメモリを直接参照できない構成の計算機です。
今回製作したRaspberry Piスパコンは分散メモリ型の並列計算機です。 分散メモリ型の並列計算機が各CPUのメモリの中身を参照するには、 メモリの中身をメッセージという形で交換しあう必要があります。 このメッセージを交換することをメッセージ・パッシングと呼び、 これを行う規格の1つがMPIです。
MPIはあくまで規格であり、その実装は多々あります。 その実装されたライブラリの一つが、PHASE0やLinpackで使用していたMPICHです。
MPICHの最新はMPICH3ですが、 Raspbianに提供されているバージョンはMPICH2(v1.4.1)なので、 今回はMPICH2を用います。
MPI関数の概要
MPIには数百種の関数がありますが、そのすべてを覚えるのは大変です。 しかし、大抵の並列プログラムは数種類の関数を知っていれば実装できるので、 ここからはその最低限の関数について説明します。
すべての関数の詳細が知りたい方は、MPICHのドキュメントを参照してください。
http://www.mpich.org/static/docs/v3.1.3/
システム関数
MPIを利用するための関数です。 初期化を行うMPI_Init関数や、 並列処理を行うプロセス全体の数を調べるMPI_Comm_size関数、 プロセス全体のうち、自分のプロセスに付けられた番号を調べるMPI_Comm_rank関数、 終了処理を行うMPI_Finalizeなどがあります。
1対1通信関数
MPIでの通信はプロセス単位で行われます。 プロセスとはOSよりメモリが与えられて実行されているプログラムのことです。 今回使用するRaspberry Piクラスタでは、1ノードにつき1プロセス立ち上がっています。 そのため、プロセス間の通信を行う説明をした場合は、 異なるRaspberry Pi間での通信が行われていると考えてください。
このプロセス間を1対1で通信する関数を1対1通信関数と呼びます。 大別すると以下の2種があります。
ブロッキング型関数
データの送受信が完了するまで、1対1通信関数を呼び出した箇所で処理を停止する関数です。 送信はMPI_Send、受信はMPI_Recvなどが該当します。
ノンブロッキング型関数
データの送受信が完了するのを待たずに、すぐに1対1通信関数を呼び出した箇所に戻る関数です。 データの送受信が完了したかはMPI_Wait関数を用いて確認します。 送信はMPI_Isend、受信はMPI_Irecvなどが該当します。
1対全通信関数
1つのプロセスからのメッセージを、並列処理全体を構成する全プロセスへ放送する関数です。 1対全通信関数は放送関数とも呼ばれます。 MPI_Bcast関数が該当します。
MPIを用いた並列HelloWorld
具体的なMPI関数の使い方を説明するため MPI版並列HelloWorldを作りました。 以下にプログラムを示します。
#include#include "mpi.h" int main(int argc, char* argv[]) { int myid, numprocs; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD, &myid); MPI_Comm_size(MPI_COMM_WORLD, &numprocs); printf("Hello world! Myid: %2d / %2d \n", myid, numprocs); MPI_Finalize(); }
ソースはこちらからもダウンロードできます。
mpi_hello.c
MPIの初期化と終了
MPI関数は、 初期化を行うMPI_Init関数と、 終了処理を行うMPI_Finalize関数の間でのみ使用できます。
int main(int argc, char* argv[]){ MPI_Init(&argc, &argv); // ここにコードを書く MPI_Finalize(); }
MPI_Init関数の引数には、main関数の引数argcとargvのアドレスを入れます。 これによってMPI_Init関数は、起動時のコマンドラインオプションを読み込み、 いくつかの設定を行うことができます。 しかし今回は特に必要ないので、気にしないで大丈夫です。
MPI_Finalize関数はMPIの終了処理を行い、MPIを停止します。
プロセス番号の取得
mpirunコマンドでMPIのプログラムが実行されると、 並列計算を行うすべてのノード上で「同じプログラム」が一斉に起動します。 例えば、MPI版並列HelloWorldを16並列で実行した場合、 16台すべてのマシンでMPI版並列HelloWorldが起動します。
起動した各プロセスにはそれぞれ「ランク」という値を割り当てられ、 これを取得するMPI関数がMPI_Comm_rank関数です。 ランク値は自身に割り当てられた仕事範囲の確認や、他のプロセスを指定しての通信を行うために使われます。
先ほどのHelloWorldでは、MPI_Comm_rank関数は次のように用いられています。
MPI_Comm_rank(MPI_COMM_WORLD, &myid);
第1引数にはコミュニケーターという通信集団を指定します。 コミュニケーターを分けることで高度な通信が可能となりますが、 小規模な計算では必要ありません。 MPI_COMM_WORLDを設定して、すべてのプロセスの所属する通信集団を指定します。
第2引数にはランク値を保存する変数のアドレスを指定します。 今回の場合、変数myidに自プロセスのランク値が設定されます。
総プロセス数の取得
MPI_Comm_size関数はコミュニケーターに所属するプロセスの数を取得します。 先ほどのHelloWorldでは、次のように用いられています。
MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
第1引数はMPI_Comm_rank関数と同様にコミュニケーターの指定を行います。
第2引数にはプロセス数を保存する変数のアドレスを指定します。 今回の場合、変数numprocsに全プロセス数が設定されます。
printfの出力先
mpirunで起動したプロセス内でprintfによる出力を行うと、 その出力はプロセスを実行している各ノード上ではなく、 mpirunを実行したノードの標準出力へ出力されます。
よってMPI版HelloWorldの次のprintf文は、 各プロセス上で取得された自身のランク値を出力し、 その出力はmpirunを実行したノードの標準出力へ出力されます。
printf("Hello world! Myid: %2d / %2d \n", myid, numprocs);
コンパイルと実行方法
先ほどのMPI版並列HelloWorldコードをRaspberry Pi上の ~/mpi_hello/mpi_hello.c に保存し、ディレクトリを移動してください。
$ cd ~/mpi_hello/
MPICH2でMPIを用いたプログラムのコンパイルは、以下のコマンドで行います。
$ mpicc -o mpi_hello mpi_hello.c
この作業を並列計算を行うマシンで行った後、 以下のコマンドを実行すると、MPI版HelloWorldが16ノードで動作します。
※第2回で作成したmpdhostsファイルが ~/mpdhosts に保存されているとします
$ mpirun -f ~/mpdhosts -np=16 ~/mpi_hello/mpi_hello
出力結果は以下のようになり、 ランク値の順番が一部バラバラになっていることからもプログラムが並列に動作したことが感じられます。
Hello world! Myid: 0 / 16 Hello world! Myid: 1 / 16 Hello world! Myid: 2 / 16 Hello world! Myid: 3 / 16 Hello world! Myid: 5 / 16 Hello world! Myid: 4 / 16 Hello world! Myid: 6 / 16 Hello world! Myid: 7 / 16 Hello world! Myid: 9 / 16 Hello world! Myid: 8 / 16 Hello world! Myid: 11 / 16 Hello world! Myid: 12 / 16 Hello world! Myid: 10 / 16 Hello world! Myid: 14 / 16 Hello world! Myid: 13 / 16 Hello world! Myid: 15 / 16
MPI関数を用いたデータ通信サンプルプログラム
通信関数を用いたサンプルを作って、 通信関数の使い方を学びます。
1対1通信関数の説明
MPI_SendとMPI_Recvは、プロセス間を1対1で通信する関数です。
最初にMPI_Sendの関数定義を示します。
int MPI_Send(const void *buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm)
引数は以下になります。
第1引数: 送信するデータのアドレス
第2引数: 送信するデータの個数
第3引数: データタイプ
第4引数: 送信先のランク値
第5引数: メッセージタグ(今回は説明を省略。0を設定する)
第6引数: コミュニケータ
戻り値: エラーコード
第3引数のデータタイプには、MPI_CHAR, MPI_INT, MPI_FLOAT, MPI_DOUBLEなどを指定します。 これらは基本的に、C言語のchar, int, float, doubleに対応しています。
次にMPI_Recvの関数定義を示します。
int MPI_Recv(void *buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status *status)
引数は以下になります。
第1引数: 受信先のアドレス
第2引数: 受信するデータの個数
第3引数: データタイプ
第4引数: 送信元のランク値
第5引数: メッセージタグ(今回は説明を省略。0を設定する)
第6引数: コミュニケータ
第7引数: 受信状況に関する情報
戻り値: エラーコード
基本はMPI_Sendと同様です。ひとつ違うのは第7引数で、ここで指定したMPI_Status型の変数に受信状況に関する情報が入ります。詳しくは説明を省略します。
放送関数の説明
MPI_Bcast関数は、特定のプロセスからその他すべてのプロセスに、データを送信する関数です。
以下にMPI_MPI_Bcastの関数定義を示します。
int MPI_Bcast( void *buffer, int count, MPI_Datatype datatype, int root, MPI_Comm comm )
引数は以下になります。
第1引数: 送信または受信するデータの先頭アドレス
第2引数: (送信または受信する)データの個数
第3引数: データのタイプ
第4引数: 送信元プロセスのランク値
第5引数: コミュニケーター
MPI_Bcast関数は、送信も受信も同じ関数で行います。 送信受信の役割を分けているのは、第4引数の値です。 ここで指定したランク値のプロセスから送信が行われ、 他のプロセスは受信を行います。
第1引数には、 送信する側(第4引数で指定するランク値のプロセス)は送信したいデータの開始アドレスを、 受信する側は受信先の開始アドレスを設定します。
サンプルプログラム
以上に説明した通信関数を用いて、変数aの値をやりとりするサンプルを作りました。
#include#include "mpi.h" int main(int argc, char* argv[]) { int myid, numprocs; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD, &myid); MPI_Comm_size(MPI_COMM_WORLD, &numprocs); int a = 10; if(myid == 0){ // プロセス0だけaに0を代入 a = 0; } // プロセス0からaの値を放送 MPI_Bcast( &a, 1, MPI_INT, 0, MPI_COMM_WORLD ); // ここで放送を受けた全プロセスのaの値は0になる printf("a = %d, Myid: %2d / %2d \n", a, myid, numprocs); // aにランク値を追加 a += myid; // 1対1通信関数でプロセス1のaの値をプロセス0に送信する if(myid == 0){ MPI_Status istatus; MPI_Recv(&a, 1, MPI_INT, 1, 0, MPI_COMM_WORLD, &istatus); // プロセス1から受け取ったaの値を表示 printf("Receive from proc 1\n"); printf("a = %d\n",a); } if(myid == 1){ MPI_Send(&a, 1, MPI_INT, 0, 0, MPI_COMM_WORLD); } MPI_Finalize(); }
以下、コードの説明をします。
最初に変数aを定義します。 このaの値は各プロセスにより異なり、プロセス0はa=0、それ以外はa=10となります。
次に放送関数を用いて、プロセス0のaの値をその他すべてのプロセスに放送します。 放送を受けたプロセスは、aの値が書き換えられ、プロセス0と同じa=0となります。
次に変数aにプロセスのランク値を加算した後、1対1通信関数を用いて、変数aの値をプロセス1からプロセス0に送ります。 これにより、プロセス0の変数aの値は0から1に書き換えられます。
このプログラムを3ノードで実行すると、次のような結果が得られます。
$ mpiexec -np 3 -f ../mpdhosts ./mpi_communication a = 0, Myid: 2 / 3 a = 0, Myid: 0 / 3 a = 0, Myid: 1 / 3 Receive from proc 1 a = 1
以上でMPIとMPIを用いた並列プログラミングについての、 簡単な解説を終わります。