短距離ハイブリッド並列分子動力学コードの設計思想と説明のようなもの...
DESCRIPTION
短距離古典MDコード、主に通信まわりの設計で苦労したところ、悩んだところなど。TRANSCRIPT
1/22
短距離ハイブリッド並列分子動力学コードの 設計思想と説明のようなもの〜並列編〜
東大物性研 渡辺宙志
2014年8月5日
2/22 概要
本資料の目的
・並列プログラム特有の「設計の難しさ」を共有したい ・っていうか単に「MPIの気持ち悪さ」を共有したい
設計思想
・クラスが肥大化しすぎないようにしたい ・不必要なクラスを作り過ぎないようにしたい ・なるべくややこしいこと(通信の隠蔽とか)をしない
開発の歴史
まずflat-‐MPI版を作成(Ver. 1) その後、ハイブリッド並列版をスクラッチから作成 (Ver. 2) Ver. 1からVer. 2で設計思想が変化
3/22 コードの概観
h6p://mdacp.sourceforge.net/ ファイルの置き場所 言語:C++ ライセンス: 修正BSD ファイル数:50ファイル (*.ccと*.hがほぼ半数ずつ) ファイル行数: 5000 lines (ぎりぎり読める程度?)
計算の概要 ・短距離古典分子動力学法 (カットオフ付きLJポテンシャル) ・相互作用、カットオフ距離は全粒子で固定 ・MPI+OpenMPによるハイブリッド並列化 プロセス/スレッドの両方で領域分割(pseudo-‐flat-‐MPI) ・アルゴリズムの解説
Prog. Theor. Phys. 126 203-‐235 (2011) arXiv:1012.2677
Comput. Phys. Commun. 184 2775-‐2784 (2013) arXiv:1210.3450
4/22 MPIラッパークラス (1/2)
とりあえずMPIのラッパークラスは作って置きたくなる
Communicatorクラス (communicator.cc/.h) 静的メソッドのみ含む、事実上の名前空間
void Communicator::SendInteger(int &number, int dest_rank){ MPI_Send(&number, 1, MPI_INT, dest_rank, 0, MPI_COMM_WORLD); }
例:MPI_Sendのラッパー
例:std::vectorをやりとりするためのラッパー
void Communicator::SendRecvIntegerVector( std::vector<int> &send_buffer, int send_number, int dest_rank, std::vector<int> &recv_buffer, int recv_number, int src_rank);
ラッパークラスの役割: 型の明示、std::vectorの扱い、コミュニケータの隠蔽
5/22 MPIラッパークラス (2/2)
MPI_InitとMPI_Finalizeの隠蔽もすぐに思いつく ・main関数とライフタイムを共有する適当なクラス(ここではMDManager)を用意する ・そのコンストラクタでMPI_Initを、デストラクタでMPI_Finalizeを呼び出す
int main(int argc, char **argv) { MDManager mdm(argc, argv); if (mdm.IsValid()) { ProjectManager::GetInstance().ExecuteProject(&mdm); } else { mout << "Program is aborted." << std::endl; } }
← ここでMPI_Initが呼ばれている
← 関数を抜けるときにMPI_Finalizeが呼ばれる
main.cc
※ このコードは異常終了処理を考慮していない。正しく異常終了させる(=ユーザの都合で異常終了する際にMPI_Finalizeが呼ばれることを保証する) ためには例外処理をするのが自然だが、手抜きにより実装していない。
6/22 通信をどう設計するか? (1/3)
とりあえず単純領域分割、flat-‐MPIのみ考える すると、領域更新を担当するクラスを作るのが自然 → ここではMDUnitと名付ける
MDUnit MDUnit MDUnit MDUnit
実空間
分割された領域それぞれをMDUnitのインスタンスが管理 → 通信まわりをどう設計すべきか?
7/22 通信をどう設計するか? (2/3)
案1: MDUnit同士が行う
MDUnit MDUnit
・「隣の領域に誰がいるか」をMDUnitが自分で知っている必要がある ・「領域更新」という局所的な役割と、「全体把握」という大局的な 役割の同居がとても気持ち悪い → flat-‐MPI版では案1を採用
MDUnit MDUnit
8/22 通信をどう設計するか? (3/3)
案2: MDUnitを管理するMDManagerクラスを作る
MDUnit MDUnit MDUnit MDUnit
MDManager
・MDUnitは自分が全体のどこに位置するか知らない ・通信は全てMDManagerを通して行う ・局所的役割と大局的役割の分離 → ハイブリッド版では案2を採用
9/22 MPIの気持ち悪さ (1/3)
ユーザ
こういう動作を期待
こいつらだけが 並列動作する
MDManager
MDUnit
MDUnit
MDUnit
MDUnit
こいつが管理
すくなくともこういうイメージでMDManagerを作った
10/22 MPIの気持ち悪さ (2/3)
実際にはこうなってる
MDManager MDUnit
MDManager MDUnit
MDManager MDUnit
MDManager MDUnit
こいつらみんな 並列動作する
並列動作するインスタンスを管理する「ただひとつの管理インスタンス」が存在しない →このようにクラスを分ける意味はあったのだろうか?
ユーザ
プロセス数に関係なく「ユーザから見てただひとつのインスタンスに 見える」オブジェクトがあれば、少なくとも設計はスッキリする?
※ ハイブリッド版では、一つのMDManager(プロセス)が複数のMDUnit(スレッド)を管理するという意味もあるが・・・
11/22 MPIの気持ち悪さ (3/3)
MDManager
通信はMDManagerを通してのみ行いたい
MDUnit MDUnit
MDManager
しかし実際には、ソースのどこからでもどこへでもMPI通信できる
→ MPIには本質的に「スコープ」が存在しない
MDUnitに隣接する領域のランクを教えないことで 擬似的に「スコープ」を導入
12/22 どの情報を誰が管理すべきか (1/2)
MPIでは、ノードをまたぐ通信量をなるべく減らすように プロセスを配置する
0 1 4 5
2 3 6 7
8 9 11 12
10 11 13 14
ハイブリッドだとさらにややこしくなる。 → どの領域に誰がいるかの「地図」の管理が必要
1ノード4プロセス、4ノード計算のプロセス配置例
13/22 どの情報を誰が管理すべきか (2/2)
案1: MPIInfoクラスを作って、そこで地図を管理 通信するクラスがMPIInfoクラスのインスタンスを持つ
案2: MDManagerクラスが地図を直接管理してしまう
flat-‐MPIコードの開発では案1を採用したが、 ハイブリッドコードの開発では案2を採用
ハイブリッドコードでは、MDManagerのコンストラクタ、デストラクタでMPI_Init/Finalizeを呼び出しており、MPI関連の情報を分離できていないこと、及び分離することのメリットがあまりないことによる
14/22 通信まわりの実装 (1/4)
アルゴリズム
・相互作用距離よりも遠い粒子をペアリストに登録し、しばらくリストを使いまわす(Bookkeeping法) ・端にある粒子の座標のみ通信(短距離相互作用) ・もらった粒子をさらに転送することで、斜め方向の通信を省く(詳細は論文参照)。
考えるべきこと
・自分の粒子と他から借りている粒子をどうやって区別するか ・送られてくる粒子情報が「どこから来た」か保存すべきか
15/22 通信まわりの実装 (2/4)
自分の粒子と他から借りている粒子の区別 → 配列を共有、粒子数を2つ用意した
データ配列
自分が管理する粒子 送られて来た粒子
ParocleNumber (PN)
TotalParocleNumber (TPN)
※この名前は良くなかった 実空間
16/22 通信まわりの実装 (3/4)
送られてくる粒子情報が「どこから来た」か保存すべきか →「どこへ何を送るか」を覚えることで不要に
一番最初に送るときに「誰にどの粒子を送るか」をテーブルに保存。 また、誰から何粒子もらうかも記憶しておく(MPI_Sendrecvの引数で必要だから)。 あとは同じ順番で送れば、同じ場所に同じ粒子の座標が送られてくるはず
PN
TPN
1. 通信前にTPNをPNに合わせる
2. 右から粒子をもらい、その数だけTPNをずらす PN
TPN 3. 以上の手続きを左、前後、上下で繰り返す。
17/22 通信まわりの実装 (4/4)
自分の粒子と他から借りている粒子の区別 →二体関数以上の計算で必要
ポテンシャルエネルギーや圧力など、二体の関数について、そのまま計算すると、重複する分だけダブルカウントしてしまう。
→「自分が管理する粒子」と「借りた粒子」の寄与は半分にする。 →「借りた粒子同士の寄与」は無視する
粒子番号のチェックだけでできる(原始的?)
三体以上の相互作用がある場合はどうするんだろう?
18/22 main関数の引数を誰が受け取るか(1/4)
・MPI情報管理クラス: MPI_Initはargc, argvを要求 ・パラメータクラス: ファイル名の取得にargvが必要
main関数の引数を要求するクラスが、少なくとも2つある
誰がどうやって受け取るべきか?
19/22 main関数の引数を誰が受け取るか(2/4)
案1: argc, argvをMPI管理クラス(MPIInfo)とパラメータ管理クラス(Parameterクラス)それぞれに渡し、それらのポインタを管理クラスに渡す。
int main(int argc, char *argv[]){ MPIInfo minfo(argc, argv); Parameter param(argc, argv); MDUnit mdu(&minfo, ¶m); //なにか処理 }
flat-‐MPI版コードではこちらを採用 設計的にはこれがまっとうな気もする。
20/22 main関数の引数を誰が受け取るか(3/4)
案2: MDManagerにのargc, argvを渡し、コンストラクタでMPI_Initの処理やParameterのインスタンスを作る
int main(int argc, char *argv[]){ MDManager mdm(argc, argv); }
MDManager::MDManager(int &argc, char ** &argv) { MPI_Init(&argc, &argv); MPI_Comm_size(MPI_COMM_WORLD, &num_procs); MPI_Comm_rank(MPI_COMM_WORLD, &rank); std::string inpurile; if (argc > 1) { inpurile = argv[1]; } else { mout << "# Input file is not specified. input.cfg is used." << std::endl; inpurile = "input.cfg"; } param.LoadFromFile(inpurile.c_str()); }
ハイブリッド版コードではこちらを採用
21/22 main関数の引数を誰が受け取るか(4/4)
Q. なぜ全てMDManagerに詰め込んだのですか? 分けたほうが設計がきれいだと思いますが?
A. 分けるご利益があまりないと考えたから
その他雑多な感想 ・MPIInfo、Parameterクラスのインスタンスは、どちらもMDManagerのメンバになっており、ライフタイムを共有している。MDManagerとライフタイムを共有するクラスのインスタンスを外で作って渡す、というのがどうにも気持ち悪かった。 ・プロセスの化身であるMDManagerが、自分のランクを自分で知らない、というのが気持ち悪い気がした。「プロセスの化身」は誰か?MDManagerか?MPIInfoか? ・main関数はなるべく簡素化したい(これは単に趣味)。
22/22 まとめのようなもの
「相互作用が全て同一」という条件を最大限に利用した設計 ◯粒子番号しかチェックしなくて済むのでシンプル。 ☓粒子番号に意味を付与するのは拡張性に欠ける。 いずれ追加情報の管理が必要になりそう。 → 通信まわりを最適化してしまうと、相互作用の詳細に強く依存し、毎回作りなおしに近くなる? 通信をもう少し抽象的に扱いたい。
通信の隠蔽を考慮していない ◯ 原則としてMPI_Sendrecvしか使わないのでシンプル。デバッグが楽。 ☓ 計算が比較的重いからできたこと。強スケーリングを追求すると破綻。
MPI、というかSPMDという設計思想に慣れるのに時間がかかった。 SPMDは「通信に関わる全体的な視点」と「送受信に関わるプロセスの局所的な視点」の両方同時に要求する。「慣れろ」と言われればそれまでだが・・・
C++の言語仕様そのものに起因する問題で、設計にわりと苦しんだ っていうかC++はダメだと思う。GCのない言語で参照渡しの多用はいろいろ問題がある。