第十五報:分散コンピューティングに挑戦! (2)
さて今回も、前回に引き続き MPI について話していきたいと思います。ようやくまともな通信を行えるようになると思います。
私は通信関係のプログラムに関しては疎いので、いろいろ間違ったことを言うことがあるかもしれません。変なところがあったらごめんなさい。
ようやく通信です。通信の方法にはいくつか種類があるようです。大きく分けると1対1のプロセス間で通信する1対1通信と、複数のプロセス間で通信する集団通信に分かれます。この節では1対1通信に的を絞って話したいと思います。
一番単純な通信です。ランク(前回やった MPI_Comm_rank で取得できる数値)を直接指定して、1対1で通信する手法です。ブロッキング通信というのは、送信では送信バッファのデータを処理し終えた後に終了する送信、受信では受信バッファに全てのデータを受信し終えた後に終了する受信のことです。
最も基本的な送信関数です。
この具体的な動作ははっきりとは決められていないようです。バッファリングするかもしれませんし、しないかもしれません。バッファリングするのであれば受信が発行されていなくても関数を終了できますし、しないのであれば受信が発行されなければ関数を終了できません。
そういうわけで、この関数が終了するかどうかは一般的には受信側の影響を受けます。つまり、送信作業はその意味でノンローカルです。送信側だけで操作が完結しないということです。
ただ、対応する受信が発行されていなくても実行できるようにはなっているようです。
送信をバッファリングします。バッファは MPI_Buffer_attach/MPI::Attach_buffer 関数で与えてやる必要があります。
この関数は対応する受信が発行されていなくても実行できます。そして、受信が発行されていてもいなくても、その状況に影響されることなく処理は終了します。その意味で、この送信作業はローカルです。
バッファリングする必要性のないときはバッファリングしないように実装することが許されているようですが、一般にそう実装されていることを期待することはできません。
この関数は受信側と同期をとります。つまり、対応する受信が発行される前に関数を呼ぶことができますが、実際に受信が発行されるまで処理を返しません。従って、ノンローカルです。
この関数は対応する受信が発行されていなければエラーになります。
...と書いてあるのですが、実際に LAM で使ってみると実行できました。どうなってるのかはちょっと良く分かりません。
資料によれば、既に対応する受信が始まっていることを明示することによってパフォーマンスを上げられると書いてあります。でも、これがどれだけ真面目に実装されているかは疑問ですね...。ただ、正しく実装されていることを期待してプログラムを組まないと正しく実装されているライブラリでエラーが出るので、正しく実装されていると想定してプログラムを組むのが正道でしょう。
あと、もちろんノンローカルです。
受信関数です。どの送信モードにも対応しています。
ブロッキング受信では、きちんとバッファにデータが受信され終わった後に関数が終了します。
どうも、受信するデータよりもバッファサイズの方が小さい場合はエラーになるようです。
まぁ、あーだこーだ言うより、とりあえず使ってみましょう。
さて、前回と異なり最初に C の場合の説明を行います。理由は後々分かると思います。
先ず、最初の NUMOF マクロと STRLEN マクロは、それぞれ配列のサイズと、文字列の文字数を取得するためのものです。但し、ポインタに対しては使えません。ポインタに使うとそれぞれ「ポインタのサイズ / 1要素のサイズ」と、それマイナス1が得られるだけです。生の配列に対してのみ使いましょう。
main ではランクを取得して、ランク 0 のプロセスでは Send を、ランク 1 のプロセスでは Recv を呼んでいます。そして、最後に自分のランクを表示します。ここは大して問題ありませんね。
で、次はランク 0 の時に呼ばれる Send 関数の中身を見て見ましょう。Send 関数では、MPI_Send を呼んで "Hello, host1!" という文字列を送信しています。
MPI_Send の引数は次の通りです。
int MPI_Send( void* buffer, int count, MPI_Datatype dataType, int rankDest, int tag, MPI_Comm comm);
buffer
送信するデータです。先頭アドレスを渡します。count
データの要素数です。データの種類は次の datatype で指定します。dataType
データの種類(データ型)です。送信する時と受信する時で一致させる必要があります。rankDest
送信先のランクを指定します。tag
送るメッセージに対してつけるユニークな番号です(メッセージタグ)。送信する時と受信する時で一致させる必要があります。comm
コミュニケータです。通常は MPI_COMM_WORLD を指定することになると思います。
例えば今回の例では、
char hello[] = "Hello, host1!"; MPI_Send(hello, STRLEN(hello), MPI_CHAR, RANK_RECVER, TAG_HELLO, MPI_COMM_WORLD);
となっています。送るデータは文字列なので、データ型は MPI_CHAR(文字)とします。そして、文字列 hello を buffer に、その文字数 STRLEN(hello) を count に指定します。
次に送信先のランクを指定します。ここでは RANK_RECVER と、列挙子を使って指定しました。同じく、メッセージタグも TAG_HELLO という列挙子を使って指定しました。列挙子を使うことによって、生の値を直接書いたときと異なり、
といった利点があります。逆に言えば、生の値は
という欠点があることになります。こういう列挙子やマクロを使わずに書かれた生の値のことをマジックナンバーと呼び、バグの温床として忌み嫌われています。マジックナンバーが書かれたソースを見ると一気に読む気が失せてしまうどころか、プログラミングスキルが信用できなくなります。
で、マジックナンバーを使うなと言うと、次のように書く人が出てきます。
enum TAG_KIND { TAG_0, TAG_1, TAG_2, TAG_3, };
こういうのはむしろプログラミングセンスを疑われます。注意しましょう。
次は受信関数 Recv を見てみましょう。ここでは先ず MPI_Recv を使って文字列を受信し、MPI_Get_count を使って受信したサイズを取得し、受信したサイズと文字列を出力しています。
では、順に見ていきましょう。受信関数 MPI_Recv ですが、これは殆ど MPI_Send と同じです。違うところはもちろん送信ではなく受信することで、もう1つが MPI_Status 構造体変数を引数の最後に渡してやる必要があります。
MPI_Status status; MPI_Recv(hello, STRLEN(hello), MPI_CHAR, RANK_SENDER, TAG_HELLO, MPI_COMM_WORLD, &status);
この MPI_Status 構造体はどこから受信したか、どのメッセージタグを持っているか、どんなエラーが発生したか、というものを受け取ります。しかしこの構造体の使い道はそれだけではなく、MPI_Get_count 関数を使って受信したデータの要素数を取得することもできます。
int recvCount; MPI_Get_count(&status, MPI_CHAR, &recvCount);
第1引数には構造体のアドレスを、第2引数にはデータの種類を、第3引数には要素数を受け取る変数のアドレスを渡します。
送信の時と受信の時とで異なることは、場合によっては受信するべきサイズが決まっていないことです。しかし、このブロッキング受信ではどうやら一括でしか受信できないようです。今回は受信バッファのサイズを大きめにとることで回避しています。実際には、
という3つの場合が考えられます。MPI_Probe/MPI_Iprobe に関してはあとで話すと思います。
で、最後に、表示する前に文字列のお尻にヌルキャラクタ '\0' をつけます。別に '\0' を一緒に送信してもいいのですが、一応通信量を抑えるためにあとからつけることにしました。
さて、FORTRAN77 の場合ですが、今回は特に大きな問題はありません。しかし、いくつか C と異なる点はあります。それは、
という点です。
1. の構造体が使えないというのは、特に問題はありません。構造体の中身を直接いじることもないでしょうし、一応直接いじるためにそれぞれ MPI_SOURCE, MPI_TAG, MPI_ERROR というパラメータが用意されており、それ番目の要素が構造体の同名のメンバに対応しています。そして、あとの関数の呼び出しに関しては C と全く変わりません。
2. はただ単に C と FORTRAN77 とで型名が違うので、それにあわせているだけという感じです。
3. は列挙子が使えないのでパラメータにしました。ただそれだけです。C と共用したければマクロにするという手があります。大抵のコンパイラでは拡張子を .F か .for にすれば C プリプロセッサが使えるようになると思います。
しかし、FORTRAN77 でマクロを使う際は注意が必要です。それは、FORTRAN77 の横幅制限のせいです。FORTRAN77 では横幅が 72 列を超えてはならないという制限があります。マクロはパラメータと違いソース中のテキストを置き換えるだけのものです。なので、うっかりすると置き換える前は横幅制限に引っかかってないように見えていても、置き換えた後には 72 列を超えてしまうことがあるわけです。
コンパイルエラーになればまだいいですが、ちょうどコンパイルエラーにならないところで切れると悲惨です。そこで、必ずコンパイルエラーになるように、カッコで囲めるならカッコで囲んでおくのがいいと思います。切れるとカッコの整合性が保てなくなるので、必ずコンパイルエラーになってくれます。
#define PI 3.141592653589793D0 x = sin(PI * th) * cos(PI * ph) * r ↓ 置き換える x = sin(3.141592653589793D0 * th) * cos(3.141592653589793D0 * ph) * r |---+-|--1----+----2----+----3----+----4----+----5----+----6----+----7-|~~~ はみ出るけど、エラーにはならない ↑ x = (sin(PI * th) * cos(PI * ph) * r) ↓ 置き換える x = (sin(3.141592653589793D0 * th) * cos(3.141592653589793D0 * ph) * r) |---+-|--1----+----2----+----3----+----4----+----5----+----6----+----7-| ~~~~ カッコが閉じられないのでエラーになる ↑
しかし、これを忘れるとコンパイルエラーにならないわけで、できるだけ FORTRAN77 ではマクロは使わない方がいいのかもしれません。ただ、それを上回る利点があるのであれば、マクロを使うのも仕方がないのかもしれません。
で、今回は関係なかったという問題点ですが、それは MPI_Send と MPI_Recv の第1引数に関してです。ここには様々なデータが渡されることになります。今回の様に Character 型だったり、また別の時には Integer 型だったり、Double Precision 型だったり、はたまた Logical 型だったりもします。
1つのプログラム中でこれらの呼び出しを混在させた場合、FORTRAN77 コンパイラは第1引数の型が揃っていないと文句をつけてきます。これはあくまで警告(重大な文法違反によってコンパイルができないというほど致命的ではないが、場合によっては深刻な場合があるエラー)であってコンパイルは一応できますし、プログラムに何か問題があるわけでもありません。だから無視しても構わないのではありますが、プログラムが大きくなると鬱陶しくなると思われます。
その場合に一番重大なのは、重要なエラーや警告がその無意味な警告に埋もれてしまうことです。かといって、この警告を表示しないようなオプションを仮に立てることができたとしても、今度はその警告がなければ発見できないバグをとりこぼすことが考えられます。
これを回避する方法は基本的にはありません。エラーの中から MPI_Send や MPI_Recv に関するこの警告を除外するフィルタを作って、
% make |& filter
とするか、我慢するしかないでしょう。filter の具体的な実装は、コンパイラによって変える必要もあります。
さて、最後に C++ です。
MPI_Send と MPI_Recv については、MPI::Comm::Send と MPI::Comm::Recv に変わっただけなので特に問題はないでしょう。
あとは MPI_Status が MPI::Status クラスに変わっていて、MPI_Get_count はそのメンバ関数 MPI::Status::Get_count になっています。Recv に MPI::Status オブジェクトを渡す時は、参照型になっているのでいちいち & をつける必要はありません。また、別に MPI_CHAR も使えますが、統一感もありますし、const 定数ですし、名前空間があった方が安心できることもあり、MPI::CHAR を使っています。
これは C とそう変わりはないので問題ないですね。
さて、次はノンブロッキング通信です。名前から分かるとおり、ノンブロッキング通信というのは送信では送信バッファの内容を全て処理し終える前に関数を終了する送信のことで、受信ではバッファに全て受信し終わる前に関数を終了する受信のことです。
それでは危ないじゃないか! と思うかもしれませんが、要は終わるまでバッファを触らなければいいわけです。終わるまで待つ関数があるので、バッファを操作したい時にはその関数を呼べばいいのです。
このようにノンブロッキング通信は、送受信(これはネットワークの速度に律速される)のように遅いものが終了するのを待つより先に、できる処理は並列して処理してやろうというものなわけです。「とりあえずブロッキング通信を使っとけばいいや」では得られないパフォーマンスが得られる可能性があると思われます。
しかし、受信してすぐにデータを使わなくてはならないという場合にはブロッキング通信で十分だと思います。「とりあえずノンブロッキング通信を使っとけばいいや」でもダメなのでしょう。
さて、ノンブロッキング通信に使う関数の名前ですが、ただ単に名前の先頭に I (Immediate:即座 の I)をつけるだけです。MPI_Isend, MPI_Ibsend, MPI_Irecv, MPI::Comm::Isend などです。
そして、これらの関数にはブロッキング通信の場合に加え、新しい引数が必要になります。それは MPI_Request ハンドラです。FORTRAN77 では Integer 型変数で、C++ では MPI::Request クラスのオブジェクトになります。ノンブロッキング通信が終わったかの確認や中止はこの MPI_Request ハンドラを介して行います。
では、詳細は実際にプログラムを見てから話すことにしましょう。
先ずはノンブロッキング受信です。プログラムは C で書きました。その動作をよく見るために、Send では送信を1秒遅らせておきます。sleep という関数は unistd.h 内で宣言される関数で、引数秒だけ処理をストップします。名前から分かるように、unistd.h は UNIX の標準ヘッダです。GNU でも使えます。しかし、別の OS では別の形で sleep または Sleep 関数が提供されていると思います。
で、ノンブロッキング受信を行っているところは、ここになります。
MPI_Request req; MPI_Irecv(hello, STRLEN(hello), MPI_CHAR, RANK_SENDER, TAG_HELLO, MPI_COMM_WORLD, &req);
先頭に I の付いた関数 MPI_Irecv 、これがノンブロッキング受信関数です。
MPI_Recv と異なる点は、MPI_Status 構造体変数ではなく、代わりに MPI_Request ハンドラを引数にとるという点です。MPI_Status 構造体変数を引数にとらない理由は、受信が完了していないまま関数を抜けるので、送受信量の取得などがまだ行えないためです。
で、代わりに増えた MPI_Request ハンドラですが、これは上で書いたとおりのものです。これを使って受信が終了するのを待つには、
MPI_Status status; MPI_Wait(&req, &status);
とします。MPI_Status 構造体変数はここで使います。受信が終了すれば Wait 関数を終了し、その情報が MPI_Status 構造体変数に格納されます。
ここでは、MPI_Wait を呼ぶ前に文字列の表示を行っています。MPI_Irecv の後に呼びながらも、受信が終わる前に表示されます。実際に実行してみるとよく分かると思います。
次はノンブロッキング送信です。今度は FORTRAN77 を使います。
ノンブロッキング送信でも、ノンブロッキング受信の場合と同じく MPI_Request ハンドラが必要になります。しかし、FORTRAN77 では Integer 型の変数で代用します。従って、ノンブロッキング送信は
Integer req Call MPI_Issend(hello, Len(hello), MPI_CHARACTER, RANK_RECVER, TAG_HELLO, MPI_COMM_WORLD, req, err)
となります。C ならば
MPI_Request req; MPI_Issend(hello, STRLEN(hello), MPI_CHAR, RANK_RECVER, TAG_HELLO, MPI_COMM_WORLD, &req);
となります。
そして、送信が終了するのを待つ関数も、やはり Wait です。Wait は MPI_Status 構造体変数を引数にとりますが、必要なければ MPI_STATUS_IGNORE を指定すれば無視できます。例えば FORTRAN77 では、
Call MPI_Wait(req, MPI_STATUS_IGNORE, err)
となります。
今回は同期モードで送信しているので、Wait は受信が発行されるまで終わりません。ここで注意するのは、受信が終わるまでではないということです。これはブロッキング送信の MPI_Ssend でも同じです。
受信の発行を Sleep で遅らせているので、Wait はその Sleep が終わって MPI_Recv が実行されるまで待ちます。で、その MPI_Issend と Wait の間で文字列を表示しています。これがノンブロッキング送信です。
さて、MPI_Wait は通信が終わるまで処理をそこでストップしました。これはブロッキング終了確認になります。
それに対応して、ノンブロッキング終了確認もあります。つまり、通信が終わってるかどうかを確認して、すぐに関数を終了するのです。その関数は終わっていたかどうかを返し、もし終わっていれば MPI_Status にその情報も返しますが、終わっていなければ返しません。
その関数が MPI_Test です。C++ では MPI_Request ハンドラに対応する MPI::Request クラスのメンバの MPI::Request::Test になります。
MPI_Test の呼び出し方は次のようになります。
FORTRAN77 Logical complete Call MPI_Test(req, complete, MPI_STATUS_IGNORE, err) // MPI::Status がいらない時 Call MPI_Test(req, complete, status, err) // MPI::Status が欲しい時 C int complete; MPI_Test(&req, &complete, MPI_STATUS_IGNORE); // MPI::Status がいらない時 MPI_Test(&req, &complete, &status); // MPI::Status が欲しい時 C++ MPI2CPP_BOOL_T complete; complete = req.Test(); // MPI::Status がいらない時 complete = req.Test(status); // MPI::Status が欲しい時
つまり、FORTRAN77 と C では MPI_Request と MPI_Status の間に終了したかどうかを格納するための変数を参照渡しすればいいわけです。C++ では終了したかどうかは戻り値で与えられます。戻り値の型は MPI2CPP_BOOL_T となっていますが、これは bool 型に対応していない古いコンパイラとの互換性を保つためのトリックです。対応していれば bool になりますが、未対応の場合は列挙子になります。ただ、条件文に直接入れてやる場合は型を気にすることはないでしょう。
では、プログラムを見てみましょう。今度は C++ で書いてあります。基本的にはノンブロッキング送信の時のプログラムと同じですが、今度は受信を5秒遅らせます。そして、1秒ごとに送信が終了しているかどうか確かめることにしました。そのコードがこれです。
while(!req.Test()) { cout << " Waiting..." << endl; sleep(1); }
MPI::Request::Test が偽を返す(まだ送信が終了していない)時は、文字列を表示して1秒待ちます。真を返す(送信が終了する)とループを抜けます。
C で書くとしたら
while(MPI_Test(&req, &complete, &status), complete) { puts(" Waiting..."); sleep(1); }
FORTRAN77 なら
10 Call MPI_Test(req, complete, status, err) If(complete) Goto 20 Write(*, *) ' Waiting...' Sleep(1) Goto 10 20 Continue
となりますね。
終了確認のための関数はもう少しあって、複数の通信のうちどれか1つが終わったかどうかを確認する MPI_Waitany/MPI::Request::Waitany, MPI_Testany/MPI::Request::Testany と、複数の通信のうち少なくとも1つが終わったかどうかを確認する MPI_Waitsome/MPI::Request::Waitsome, MPI_Testsome/MPI::Request::Testsome と、複数の通信の全てが終わったかどうかを確認する MPI_Waitall/MPI::Request::Waitall, MPI_Testall/MPI::Request::Testall があります。
〜any 関数では MPI_Request の配列を渡し、その何番目が終了したかとその MPI_Status を返します。〜some 関数では MPI_Request の配列を渡し、その何番目が終了したかの配列とその MPI_Status の配列、そして終わった通信の数を返します。〜all 関数では MPI_Request の配列を渡し、MPI_Status の配列を受け取ります。
また、終了の確認が特に必要ない場合、その終了を待たずに MPI_Request ハンドルを開放することもできます。それが MPI_Request_free/MPI::Request::Free 関数です。終了の確認が必要ない場合というのは、例えばずっと値を変えるつもりのないものを送る時です。受信の時にはいつ終わったかが分からないと困るので、使うことはないでしょう。しかし送信の場合でも、同期モードの送信に対して実行すると受信の確認ができなくなってその通信が永遠に終わらなくなるようなので、同期モードの送信に対しては使わないようにしてましょう。
さて、今まで通信で使ってきたデータ型は MPI_CHAR/MPI_CHARACTER/MPI::CHAR だけでした。もちろん他にもいろいろあって、それは次のようになります。
全言語共通 | |
---|---|
データ型 | 説明 |
他のライブラリにおけるパック/アンパック処理と互換性を持たせるためのデータ型。詳細は割愛。 | |
後に説明 | |
後に説明 | |
FORTRAN | |
データ型 | 説明 |
Character 型です。 | |
1バイト1バイトそのまま送ります。Character 型に対しては使わない方が賢明です。 | |
Integer 型です。 | |
Integer*1 型です。使えないこともあります。 | |
Integer*2 型です。使えないこともあります。 | |
Integer*4 型です。使えないこともあります。 | |
Real 型です。 | |
Double Precision 型(倍精度)です。 | |
Real*4 型(単精度)です。使えないこともあります。 | |
Real*8 型(倍精度)です。使えないこともあります。 | |
Complex 型(単精度複素数)です。 | |
Complex*16 型(倍精度複素数)です。 | |
Logical 型です。 | |
C | |
データ型 | 説明 |
char 型です。 | |
unsigned char 型です。 | |
short int 型です。 | |
int 型です。 | |
long int 型です。 | |
float 型です。 | |
double 型です。 | |
unsigned char 型です。 | |
unsigned short int 型です。 | |
unsigned int 型です。 | |
unsigned long 型です。 | |
long double 型です。使えないこともあるようです。long double に対応していないコンパイラもあるみたいですし。 |
C++ では、C の MPI_ を MPI:: に変えたものになります。基本的に名前そのままなんで、覚えやすいと思います。違うのは MPI_DOUBLE_COMPLEX と MPI_BYTE だけですか。
なぜ全部 MPI_BYTE で送らないかですが、これはバイトオーダ(int のように複数のバイトをまとめて1つのデータとするときの、バイトを並べる順番)などの変換が必要な状況であっても動作するようにとのことです。ただ、そのような変換が起こらないコンピュータ同士で動かすことを前提にできるなら、全部 MPI_BYTE で問題ないと思います。ビジネスソフトになるときつい要請かもしれませんが、科学計算であれば問題ないと思います。変換判定が入らない分、おそらく速くなるでしょう。
ちなみに、C と FORTRAN の間での通信におけるデータ型の扱いは規格として定義されていないようです。つまり、基本的にそういうのはしないように、ということです。ただ、ライブラリによっては対応していることを銘記してあるかもしれません。その場合は問題ないでしょう。
もし MPI_BYTE で何でも送れるわけでなければ、構造体などを送る時に問題になります。また、行列の一部を送る場合、連続領域を送ることになるとは限りません。
そこで出てくるのが汎用データ型です。汎用データ型というのは、上の基本データ型とデータの間隔のデータを含むデータ型の並びからなるデータ型のことです。
百聞は一見に如かずということで、実際に見てみましょう。ベーシックな例として、int 型 2 個の配列を1単位として考えるとします。
ここをクリックすると別ウィンドウに C のソースが表示されます
3つとも説明するのはしんどいので、説明は C のソースを使って行います。
先ず、int 型 2 個の配列を NEWTYPE_T という名前で再定義しておきます。別にこの作業は必須のものでは有りませんが、2 というマジックナンバーを消したいのでそうしておきます。
typedef int NEWTYPE_T[2];
この typedef というのはある型に別名をつけるためのキーワードです。従って、NEWTYPE_T a; と int a[2]; は同等です。ポインタに別名をつけた時だけは、後から参照先を const にすることができないという違いが生まれます。
typedef int* PINT; typedef const int* PCINT; const int* p1; /* p1 の参照先が定数 */ int* const p2; /* p2 自身が定数 */ const PINT p3; /* p3 自身が定数 */ PINT const p4; /* p4 自身が定数 */ PCINT p5; /* p5 の参照先が定数 */
次に、この型に対応する新しいデータ型を作ります。CreateNewType という関数を見てみましょう。
/* MPI の新しいデータ型を作成、登録します */ void CreateNewType(MPI_Datatype* pNewType) { /* int 型 2 個からなる新しいデータ型を作成します */ MPI_Type_contiguous(NUMOFT(NEWTYPE_T), MPI_INT, pNewType); /* その新しいデータ型を MPI が使用できるように登録します */ MPI_Type_commit(pNewType); }
この、MPI_Type_contiguous/MPI::Datatype::Create_contiguous という関数は、あるデータ型を第1引数の数だけ羅列した、新しいデータ型を作る関数です。その新しいデータ型のハンドルは参照渡しされた第3引数に代入されます。
本題から外れますが、この NUMOFT というのは maindefs.h で定義しました。配列型に対して typedef を使って別名をつけた時、その配列型の要素数を取得するというマクロです。
#define NUMOFT(type) NUMOF(*(type*)0)
NUMOFT(NEWTYPE_T) は先ず NUMOF(*(NEWTYPE_T*)0) と展開されます。NEWTYPE_T 型へのポインタ型で 0 (ヌルポインタ)をキャストし、そのデータを参照する形に書いています。実際ヌルポインタへのアクセスは禁止されていますが、この場合サイズを(静的に、つまりコンパイル時に)取得するためだけに参照しているので問題ありません。こうしてダミーの配列データを作って、それを使って NUMOF を行っているのです。
何でこんなに回りくどいことをしているかというと、配列型でキャストすることができないからです。まぁ、このあたりのテクニックは今のところ理解できなくても問題ないでしょう。とにかく上の例は
MPI_Type_contiguous(2, MPI_INT, pNewType);
の 2 というマジックナンバーを回避したものだ、ということだけ分かってもらえれば十分です。もちろん、typedef の部分を
#define NEWTYPE_SIZE 2 typedef int NEWTYPE_T[NEWTYPE_SIZE];
として NEWTYPE_SIZE を使ったのでも構いません。
で、本題に戻りますが、実のところ新しいデータ型を作っただけでは何も起こりません。この型を MPI が認識できるようにするには、さらに MPI_Type_commit/MPI::Datatype::Commit という関数で MPI に登録しなくてはなりません。
MPI_Type_commit(pNewType);
ここで、引数は参照渡しします。これでこのデータ型を MPI が認識できるようになります。
ちなみに、C++ では
void CreateNewType(MPI::Datatype& newType) { newType = MPI::INT.Create_contiguous(NUMOFT(NEWTYPE_T)); newType.Commit(); }
となります。
このデータ型が不要になれば、MPI_Type_free/MPI::Datatype::Free という関数で開放します。
MPI_Type_free(pNewType);
やはりこれも参照渡しします。
なお、MPI に登録したデータ型から新しいデータ型を作ることも可能です。しかし、登録していないデータ型を使うことはできません。また、データ型を開放すると、そのデータ型だけでなく、それから作った新しいデータ型も使えなくなります。注意しましょう。
データ型作成関数の一覧は次の通りです(MPI_Type_ は省略します)。
関数 | 機能 |
---|---|
type を count だけ羅列します。 | |
type を blocklen だけ羅列したものを1つのブロックとし、そのブロックの先頭同士の間隔を stride 要素だけ離して count 個羅列します。 | |
vector とほぼ同じですが、stride が要素数ではなくバイト数になります。 | |
ブロックの長さを配列を使って aBlocklen で与えます。つまり、i 番目のブロックは type が aBlocklen[i] だけ羅列されます。各ブロックの先頭は aHead で与えられ、これは先頭からの要素数を使って指定します。aBlocklen と aHead のサイズは count で指定します。 | |
indexed とほぼ同じですが、aHead は先頭からのバイト数を使って指定します。 | |
hindexed とほぼ同じですが、各ブロックごとに別の型 aType[i] を適用できます。 |
データ型の作成ではさらに下限/上限マーカというものを使うことができます。これは、データ型のサイズを本当のサイズより大きく見せるために使います。例えば、
int aBlocklen[] = { 1, 1, 1 }; int aHead[] = { -2, 0, 3 }; MPI_Datatype aType[] = { MPI_LB, MPI_DOUBLE, MPI_UB }; MPI_Type_struct(3, aBlocklen, aHead, aType, pNew);
という風にします。MPI_LB/MPI_UB というのが、それぞれ下限(lower bound)マーカと上限(upper bound)マーカです。これらはダミーのデータ型で、これ自身は何のデータにも対応しません。しかし、その分だけデータ型のサイズは膨らみます。その膨らますサイズを aHead の対応する要素で指定します。MPI_DOUBLE が変位 0 (aHead[1])にいるわけですが、下に 2 個(aHead[1] - aHead[0])、上に MPI_DOUBLE 自身を含む 3 個(aHead[1] + aHead[2])の要素分の領域を占めることになります。
図にすると、こんな感じです。
-2 | -1 | 0 | 1 | 2 |
ダミー | 本物 | ダミー | ||
---|---|---|---|---|
2 個 | 3 個 |
こういう型を作っておけば、例えばこれを MPI_Type_contiguous で 2 つ繋げると
-2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
ダミー | 本物 | ダミー | ダミー | 本物 | ダミー | ||||
---|---|---|---|---|---|---|---|---|---|
2 個 | 3 個 | 2 個 | 3 個 |
というように、MPI_Type_vector と似たような形になります。これだけなら MPI_Type_vector を使えばいいのですが、こういった型と別の型を組み合わせると、ちょっと変わった穴の開いたデータ型を作ることができるわけです。今のところどういう場合に使うのかが今ひとつ分からないのですが、いろいろやってると必要になってくるのかもしれません。
MPI_Get_count と似た関数に、MPI_Get_elements/MPI::Status::Get_elements というのがあります。これは、受信した基本データ型の個数を返します。例えば、上の例で作った新しいデータ型は int 型 2 個の配列だったので、それを n 個送ると MPI_Get_elements で得られる値は 2n です。一方 MPI_Get_count では n になります。
また、中途半端な数だけ送信された場合、例えば、上の例で奇数個の MPI_INT のデータを送信し、受信側では newType を使った場合、一応この受信は成功します。ただ、もちろん最後のデータは中途半端にしか詰められていません。MPI_Get_elements であればこのような場合もきちんとした値を返せますが、中途半端にしか詰められていないため MPI_Get_count はきちんとした値を返せません。そこで、その場合 MPI_Get_count は MPI_UNDEFINED を返します。
MPI_Type_size 関数は、データ型の合計サイズをバイト単位で返します。
FORTRAN77 Integer size Call MPI_Type_size(type, size) C int size; MPI_Type_size(type, &size); C++ int size; size = type.Get_size();
並列計算を行う場合など、同じ引数を使って通信を行うことがあると思います。そのような場合に、予め引数を指定しておき、後から何回もその引数を使ってノンブロッキング通信を行うことができるようになっています。
そのための関数は、名前の最後に _init が付いています。例えば MPI_Send_init や MPI_Ssend_init 、 MPI::Comm::Recv_init などです。そして、その通信を行いたい場合には、MPI_Start/MPI::Request::Start を実行します。この通信はノンブロッキング通信なので、通信の終了は MPI_Wait や MPI_Test で確認する必要があります。
MPI_Start で開始できて、何回も同じ引数を使いまわすことができるということ以外は普通のノンブロッキング通信と変わりないので、例は示すまでもないでしょう。
データを送信し、さらにそのまま受信を行うということは、MPI_Sendrecv/MPI::Comm::Sendrecv という1つの関数で行えます。送信バッファと受信バッファに同じものを使いたい場合は、MPI_Sendrecv_replace/MPI::Comm::Sendrecv_replace を使うことができます。これらはブロッキング通信です。
FORTRAN77 Call MPI_Sendrecv( * sendBuffer, sendCount, sendType, sendRank, sendTag, * recvBuffer, recvCount, recvType, recvRank, recvTag, * comm, status, err) Call MPI_Sendrecv_replace( * buffer, count, type, * sendRank, sendTag, recvRank, recvTag, * comm, status, err) C MPI_Sendrecv( sendBuffer, sendCount, sendType, sendRank, sendTag, recvBuffer, recvCount, recvType, recvRank, recvTag, comm, &status); MPI_Sendrecv_replace( buffer, count, type, sendRank, sendTag, recvRank, recvTag, comm, &status); C++ comm.Sendrecv( sendBuffer, sendCount, sendType, sendRank, sendTag, recvBuffer, recvCount, recvType, recvRank, recvTag, &status); comm.Sendrecv_replace( buffer, count, type, sendRank, sendTag, recvRank, recvTag, &status);
各引数の意味は、Send や Recv のそれと同じなので、特に問題はないでしょう。
あるコミュニケータに関連付けられているプロセスの総数を取得するには、MPI_Comm_size/MPI::Comm::Get_size 関数を使います。つまり mpirun で -np オプションを使って指定した数を取得するには、
FORTRAN77 Integer size Call MPI_Comm_size(MPI_COMM_WORLD, size, err) C int size; MPI_Comm_size(MPI_COMM_WORLD, &size); C++ int size; size = MPI::COMM_WORLD.Get_size();
とすれば良いことになります。
受信サイズをデータを受信する前に知りたい時は、MPI_Probe/MPI::Comm::Probe 関数で知ることができます。この関数のノンブロッキング版が MPI_Iprobe/MPI::Comm::Iprobe です。
FORTRAN77 Logical flag Call MPI_Iprobe(rank, tag, comm, flag, status, err) C int flag; MPI_Iprobe(rank, tag, comm, &flag, &status); C++ MPI2CPP_BOOL_T flag; flag = comm.Iprobe(rank, tag, &status);
ブロッキング版の方は、受信が終了したかのフラグ flag を引数から除外すればいいだけです。
MPI_Probe を使えば、受信サイズが一定でなく、しかも巨大になりうるときに、その受信領域を動的に確保することができます。領域を確保した後に受信を行えばいいのです。
ノンブロッキング通信関数を呼んだものの、それを取り消したくなった時は、MPI_Cancel/MPI::Request::Cancel 関数を使います。ただ、まだ処理が保留されている場合にしか取り消すことができません。
キャンセルが実行されると、実際にキャンセルされる前に関数を終了します。従って、ノンブロッキングです。キャンセル作業を待つには、MPI_Wait や MPI_Test を使います。そして、キャンセルされたのか、それともキャンセルされるまえに通信が終了したのかを取得するには MPI_Test_cancelled/MPI::Is_cancelled を使って判定します。
ただこの関数、LAM では受信の場合にしか実装されていないようです。受信待ちをしていて、いつまで経っても受信できない場合、キャンセルすることになると思います。
例えばこんな感じです。
ヌルプロセス MPI_PROC_NULL/MPI::PROC_NULL に対しては、どんな送信も受信も成功し、直ちに関数を終了します。その際、受信バッファには何の操作も加えられません。ランクに対するヌルデータ(意味を持ったあらゆる値と異なることが保証されている値。多分負の値。私ならそう定義します)で、変数の初期化や番人(操作を単純にするために使うダミーデータ)として使えそうですね。
並列化のパフォーマンスを見るために、時間を計る必要があります。そのために、MPI は MPI_Wtime という関数を用意してくれています。この関数は、「ある時刻」からの経過時間を秒単位で返します。しかし、時間の差を求めるのであれば、その「ある時刻」がいつかを知る必要はありません。
これの関数はエラーを返しません。従って、その時間は戻り値として得られます。戻り値の型は double/Double Precision です。
FORTRAN77 Double Precision before, after before = MPI_Wtime() Call SomethingToDo after = MPI_Wtime() Write(*, *) after - before C double before, after; before = MPI_Wtime(); SomethingToDo(); after = MPI_Wtime(); printf("%lf\n", after - before); C double before, after; before = MPI::Wtime(); SomethingToDo(); after = MPI::Wtime(); cout << (after - before) < endl;
また、この MPI_Wtime の精度は MPI_Wtick で得られます。
具体的な例として、数値積分を行いたいと思います。
最初ベクトルの内積を求めようとしていたのですが、計算量(掛けて足すだけ)に比べ通信量(ベクトルを送らなくてはならない!)が多いので、却って並列化した方が遅いという悲惨な結果になってしまいました。ベクトルの生成そのものが並列化できる場合にはいいのですが、そうでない場合には並列化は不可能なようです。多分、前回の行列の積も同じ結果になると思います。
数値積分の方法にはいろいろあるみたいですが、今回は Romberg 積分法を用いました。台形公式を使って数値積分を間隔を変えて行い、その結果の推移から Richardson 補外を使って真の値を推測するというものです。
補外する部分は並列化できない(する必要もない)ので、並列化する部分は台形公式で和を求める部分のみです。
プログラムはこちらです。C++ にするメリットがあまりなかったので、ここでは C++ にはしていません。
この例では、
f(x) = x5 sin(x4) * cos(x3) * exp(-x2) * log(x + 1)
という関数を 0 からあるところまで積分します。
1000 まで積分し、初期分割数 1(分割しない)で C のプログラムを実行した結果は次の通りです。数値の後についているカッコつきの値は、最終桁の誤差を表します。実効並列化率は通信時間を考慮していません。スペックは諸般の事情で秘密です。
ノード数 | 計算結果 | 3回の平均時間(秒) | 並列化効率 | 実効並列化率 |
---|---|---|---|---|
1 | 0.09024978425958483 | 3.764(1)0 | 1.0000(0) | - |
2 | 0.09024978425958483 | 1.933(3)0 | 0.5135(9) | 0.973(2)0 |
3 | 0.09024978425958483 | 1.324(2)0 | 0.3518(6) | 0.9723(9) |
4 | 0.09024978425958483 | 1.0183(2) | 0.2705(1) | 0.9727(1) |
5 | 0.09024978425958483 | 0.836(1)0 | 0.2221(3) | 0.9724(4) |
6 | 0.09024978425958483 | 0.713(1)0 | 0.1894(3) | 0.9727(4) |
7 | 0.09024978425958483 | 0.601(2)0 | 0.1597(6) | 0.9804(7) |
8 | 0.09024978425958483 | 0.517(2)0 | 0.1374(6) | 0.9858(7) |
並列化率は大体 97〜98% になりました。7, 8 台のときに増えてる理由はよく分かりません。
今回の場合、関数が特にパラメータを持っていなかったので、通信量は殆ど効いてこないと思います(関数のパラメータを通信する必要がなかった、という意味です)。よく並列計算できていることが分かると思います。
さて、これで終わってもいいのですが、プログラミングテクニックの観点からいくつか解説していきましょう。
では C の方から見ていきましょう。
先ず、基本的なことですが、ヘッダファイルは次のような構造にしておきます。
#ifndef ユニークなマクロ名 #define ユニークなマクロ名 ...ここに何か書く... #endif
こうしておけば、このヘッダファイルを何度インクルードしても最初の 1 回以外は無視されます。
#ifndef (if not defined)というのは次に書いたマクロが定義されていなければ #endif まで(#else や #elseif があればそこまで)をコンパイルせよ、という命令です。最初はマクロが定義されていないのでコンパイルされますが、そのマクロは入ってすぐの #define で定義されるので、2度目からはコンパイルされなくなるというわけです。
またそこから分かるように、この仕掛けに使うマクロはこのファイル以外で定義してはいけません。ユニークな(唯一の)マクロ名、と書いたのはそういうことです。ユニークであることを完全に保証することは難しいのですが、ファイル名、日時、適当な文字列を全て入れておくと比較的安全です。
#define や #ifndef 〜 #endif のようにコンパイルの前にソースを整形したりコンパイルの動作を制御したりする命令を、プリプロセッサディレクティブ(プリプロセッサ指令)と呼びます。if は実行中に分岐しますが、#ifndef はコンパイルの前に分岐するのです。この違いは非常に重要なので、ぜひ頭に入れておいてください。
次は maindefs.h にあるこれです。
/* デバッグ用出力 */ #ifdef NDEBUG #define TRACE(params) #else #define TRACE(params) fprintf params #endif
次は #ifndef ではなく #ifdef (if defined)が出てきました。これは次に書いたマクロが定義されている時にコンパイルせよ、というものです。#else はそうでない場合にコンパイルせよ、というものです。#ifdef と #ifndef はそれぞれ #if defined(マクロ名) と #if !defined(マクロ名)と書くこともできます。また、else if に対応するのは #elseif です。#elseifdef とかはないので、#elseif defined(マクロ名) とする必要があります。
そして、ここでは TRACE というマクロを定義しています。これは NDEBUG が定義されている場合は消えてなくなり、定義されていない場合は fprintf に置き換えます。これを使う場合は
TRACE((stderr, "デバッグ時のみ表示!\n"));
とします。カッコが2重になってるのは、マクロは引数の数が固定なので、引数をまとめてカッコで囲んで 1 引数にしないといけないからです。
このコードは、NDEBUG が定義されている場合は
;
と白文になります。定義されていない場合は
fprintf (stderr, "デバッグ時のみ表示!\n");
となり、fprintf としてコンパイルされることになります。ここでマクロの定義にはなかったカッコがありますが、これは TRACE で引数をまとめる時に使ったカッコです。
従って、fprintf ではなく TRACE を使うと、NDEBUG を定義するかどうかで出力するかしないかを制御できるわけです。そして、if で分岐したのとは異なり、NDEBUG が定義されている時はコンパイルすらされません。
次は maindefs.h 内のこれです。
typedef unsigned long long UINT64; #define FORMAT_UINT64 "%Lu"
「あるバイト数を持った整数を表す型」というのは、C/C++ には char しかありません。C の型の指定は随分とアバウトで、随分と困らされることもあります。
int は 32 ビット機では普通 32 ビット(4 バイト)に、16 ビット機では 16 ビット(2 バイト)になっています(1 バイトが 8 ビットでない CPU は無視します)。short int は int のサイズ以下としか、long int は int のサイズ以上としか規格で定められていません。未満と超過ではなく、以下と以上です。
従って、例えば「64 ビット(8 バイト)のサイズを持った型」というのはコンパイラによって異なります。この問題を解消するためには、上でも使った typedef を使います。「符号なし 64 ビット整数」の型として UINT64 という型を定義してやれば、あとは環境によってこの定義を変えてやればいいのです。
ここでは「書き換えて下さい」という風にしていますが、大抵は各コンパイラ毎に固有なマクロが自動的に定義されるので、それで分岐することも可能です。
また、printf などで使う書式指定もあわせて変える必要があります。ここでは FORMAT_UINT64 というマクロを使うようにしました。これも環境によって定義を変えればいいだけですね。
次は main.c にあるクライアントの処理です。
for(; ;) { int command; MPI_Recv(&command, 1, MPI_INT, RANK_MASTER, TAG_COMMAND, MPI_COMM_WORLD, MPI_STATUS_IGNORE); switch(command) { case CMD_NULL: : : :
最初の for(; ;) は無限ループです。そして、次に MPI_Recv で受け取った値を使って分岐処理を行っています。これを延々と続け、値が CMD_END の場合に終了します。
このようにしておくと、ある動作をクライアントに行わせたいときは、それに応じた値を送ればいいことになります。その動作内でまたいくつかの通信を行うことになるでしょうが、こう大雑把に分岐しておけばプログラムの見通しも良くなるでしょう。
次は main.c の Func 関数です。
const double x2 = x * x; const double x3 = x2 * x; const double x4 = x2 * x2; const double x5 = x2 * x3;
2 乗、3 乗といった簡単に求められるものに、遅い処理である pow 関数を使うのは無駄です。そういうものは掛け算で済ませましょう。ただ、あまりに複雑にしなければ求められないものは、pow の方が速くなるでしょう。
次は romberg.h のこれです。
double (*fpInt)(double)
これは関数ポインタです。関数を関数に渡したい時など、場合ごとに効率よく処理を変えたい時に使います。戻り値の型から引数の型まで全て一致しないと代入できません。
次は romberg.c のこれです。
/* Richardson 補外用のバッファのサイズ */ #define RICHARDSON_BUFSIZE 32
上でも話したことですが、こういう風にマクロでサイズを指定しておけば、バッファサイズを後で変えたくなったときに楽になります。極めて重要なテクニックなので、ここでも触れておきます。
次は romberg.c のこれです。
typedef struct ___dummy_SUM_T { double xI; /* 始点 */ double span; /* サンプリング幅 */ UINT64 points; /* サンプリング点の数 */ } SUM_T;
C では構造体を使うときには
struct 構造体名 変数名;
とします。この struct の部分をサボりたい時には、上の様に typedef を使います。enum や union でも同じようにしてサボることができます。typedef 大活躍ですね。
次は romberg.c のこれです。
subSpan = mainSpan points = divide; weight = 2; for(i = 1; i < RICHARDSON_BUFSIZE; ++i) { : : : subSpan /= 2; points *= 2; weight *= 2; }
これも pow を避けるための工夫です。特に整数の場合は整数→小数→整数という変換が必要になり、キャストしただけでは数値誤差で正確な指数が得られないことがあります。どうしても pow を使う必要がある場合は、正の時は 0.5 を足し、負の時は 0.5 を引いてからキャストしましょう。
あとは、関数ポインタを通信することはできないのでちょっと工夫したり、Richardson 補外のアルゴリズムを工夫したり、というところも見ておくといいでしょう。
次は FORTRAN77 の方を見てみましょう。
先ずは debug.h の TRACE です。
/* デバッグ用出力 */ #ifdef NDEBUG #define TRACE ! #define TNEXT ! #else #define TRACE Write #define TNEXT * #endif
FORTRAN77 ではコメントにすることで無視するようにしました。
ただ、これだけでは TRACE の後が複数行になると破綻します。そこで、TRACE で複数行にわたる場合には、* の代わりに上の TNEXT を使うようにします。これも NDEBUG が定義されている時にはコメントに変わります。
! を使った行中からのコメントに対応していない FORTRAN77 ではこれに変わる工夫はなかなか難しいです。なぜコメントアウトにしたかというのは、Write 文に渡す値はカッコで囲まないからです。C では fprintf という普通の関数に渡すようにしていたので問題なかったのですが、FORTRAN77 では問題になるのです。
で、! が使えない場合は要するに C に変えればいいのですが、1 文字のマクロというのも訳が分からないので、例えば DBG というのを C に変えるようにします。
#ifdef NDEBUG #define DBG C #else #define DBG #endif
例えば、これはこう使います。
DBG Write(*,*) result, after - before C 123456
これなら Write 文以外でもコメントアウトできるので便利なように見えます。しかし、これをこう書いてしまうと
DBG Write(*,*) result, after - before C 12345
だめになるのです。上下の違いは空白の個数なのですが、下の場合は DBG を取り除くと
Write(*,*) result, after - before C2345
となり、6 カラム目に W がめり込んでしまいます。こういう危険なことがあるので厄介なのです。
あと、TRACE でもマクロを展開した際に前後に空白が入ることがあるので、あまり 72 カラムギリギリまで書くのは避けるのが賢明でしょう。この空白のおかげで上の DBG で助けられることもありますが、あまり期待しない方がいいでしょう。
次は、ヘッダファイルの構成です。FORTRAN77 ではあるファイル内全てに有効な定義というものを行うことができません。そこで、ヘッダファイルで定義しておき、それを全てのサブルーチンや関数でインクルードするという手法をとることでなんとかします。それがここでは _in.h で終わるファイルになります。
それとは別に、別のファイルでも使うことのある関数の戻り値の型を指定したヘッダファイルも用意しました。これが上記以外の .h で終わるファイルになります。といっても、ここでは romberg.h しかありませんが。
この時、外部で使う予定の無いものを romberg.h に書かないようにします。例えば、Richardson 関数は外部では使わないので、romberg.h ではなく romberg_in.h に書いています。このように差別化することで、せめて関数だけは別のサブルーチンや関数の中で使われるのをある程度防ぐことができるのです。もちろん型を指定してやれば使えますが、わざわざそうしない限り使えない、というところが重要なのです。
ただ、サブルーチンはどうにもならないので、これは注意するほかありません。
あとは 1.0D0 のように D0 をつけないと倍精度と扱われないとか、72 カラム目がどこか一目で分かるようなコメント行を用意しておくとか、マジックナンバーは使わないとか、その程度です。
次回は集団通信に挑戦してみたいと思います。それでは。
Last update was done on 2002.11.30