C++
MPI

MPI-IOを使ってみる

これは多分MPI Advent Calendar 2017の13日目の記事です。なんとなく乗っ取った感じになっていますが、最後まで乗っ取る気は(いまのところ)ありません。

はじめに

MPIプログラムでファイルIOを真面目にやるのはいろいろ面倒です。特に、複数プロセスからファイルを吐きに行く時、性能を出すためには、裏の並列ファイルシステムの特性を理解しなければなりません。また、ステージングとかしているとさらに話がややこしくなります。ここでは、性能を全く気にせずにファイル出力を行ったらどうなるかだけ試してみます。

複数プロセスからのファイル出力

複数プロセスからファイルを吐く時、一番簡単なのはそれぞれのプロセスから別ファイルに吐き出すことです。しかし、シミュレーションとかで時間発展とかしている時に、ステップごとに全てのプロセスがファイルを吐いているとファイル数が膨大になります。そこで、各ステップごとに、各プロセスが持っているデータを一つのファイルにまとめて吐くことを考えます。

一番単純には、rank 0がファイルを作成し、プロセス数だけループを回して毎回バリアし、自分の番になったらファイルに自分のデータを追記していく方法でしょう。ナイーブに実装するならこんな感じでしょうか。

void
write_seq(int rank, int np) {
  char filename[256];
  static int index = 0;
  sprintf(filename, "data%02d.seq", index);
  index++;
  if (rank == 0) {
    FILE *fp = fopen(filename, "w");
    fclose(fp);
  }
  for (int r = 0; r < np; r++) {
    MPI_Barrier(MPI_COMM_WORLD);
    if (rank != r)continue;
    FILE *fp = fopen(filename, "ab");
    fwrite((void *)buffer, sizeof(double), N, fp);
    fclose(fp);
  }
}

各プロセスがbufferの内容を同じファイルに順番に追記していくコードです。static int indexがあり、呼ばれるたびにインクリメントされるので、ステップごとに異なるファイルを吐きます。

MPI-IO

さて、先程の例では、どのプロセスがファイルのどの位置からどの位置まで書き込むか予めわかっていました。こういう時にはMPI-IOが使えます。MPI-IOは、並列ファイルシステムをMPIの枠組みで抽象化したものです。高速化をよしなにやってくれるわけではなく、あくまでも並列ファイルシステムをうまく使うための仕組みをMPIの枠組みで提供するだけのものですので、高速化関連のチューニングは自分でやる必要があります。

とにかく使ってみましょう。詳細な説明は省略しますが、こんな感じです。

  • ファイルハンドルはMPI_Fileで宣言
  • MPI_File_open ファイルを開く
  • MPI_File_set_view これが何をやってるか僕は知りません
  • MPI_File_write_at 指定されたオフセットに内容を書き込む
  • MPI_File_close ファイルを閉じる

先程の、各プロセスが持っているbufferをプロセス順に並べて書き込むコードはこんな感じになるでしょう。

int
write_mpi(int rank) {
  char filename[256];
  static int index = 0;
  sprintf(filename, "data%02d.mpi", index);
  index++;
  MPI_File fh;
  MPI_File_open(MPI_COMM_WORLD, filename, MPI_MODE_WRONLY | MPI_MODE_CREATE, MPI_INFO_NULL, &fh);
  MPI_File_set_view(fh, 0, MPI_INT, MPI_INT, (char *)"native", MPI_INFO_NULL);
  MPI_Status st;
  MPI_File_write_at(fh, rank * 2 * N, buffer, N, MPI_DOUBLE, &st);
  MPI_File_close(&fh);
  return MPI_SUCCESS;
}

ベンチマーク

コード

ベンチマークを取ってみます。各プロセスが10万個の倍精度実数の乱数を一つのファイルに保存する、という処理を100回繰り返すコードです。

mpiio.cpp
#include <cstdio>
#include <random>
#include <algorithm>
#include <time.h>
#include <mpi.h>
//----------------------------------------------------------------------
const int N = 100000;
const int LOOP = 100;
double buffer[N];
std::mt19937 mt;
//----------------------------------------------------------------------
double
myrand(void) {
  std::uniform_real_distribution<double> ud(0.0, 1.0);
  return ud(mt);
}
//----------------------------------------------------------------------
int
write_mpi(int rank) {
  char filename[256];
  static int index = 0;
  sprintf(filename, "data%02d.mpi", index);
  index++;
  MPI_File fh;
  MPI_File_open(MPI_COMM_WORLD, filename, MPI_MODE_WRONLY | MPI_MODE_CREATE, MPI_INFO_NULL, &fh);
  MPI_File_set_view(fh, 0, MPI_INT, MPI_INT, (char *)"native", MPI_INFO_NULL);
  MPI_Status st;
  MPI_File_write_at(fh, rank * 2 * N, buffer, N, MPI_DOUBLE, &st);
  MPI_File_close(&fh);
  return MPI_SUCCESS;
}
//----------------------------------------------------------------------
void
write_seq(int rank, int np) {
  char filename[256];
  static int index = 0;
  sprintf(filename, "data%02d.seq", index);
  index++;
  if (rank == 0) {
    FILE *fp = fopen(filename, "w");
    fclose(fp);
  }
  for (int r = 0; r < np; r++) {
    MPI_Barrier(MPI_COMM_WORLD);
    if (rank != r)continue;
    FILE *fp = fopen(filename, "ab");
    fwrite((void *)buffer, sizeof(double), N, fp);
    fclose(fp);
  }
}
//----------------------------------------------------------------------
void
makebuf(void) {
  for (int i = 0; i < N; i++) {
    buffer[i] = myrand();
  }
}
//----------------------------------------------------------------------
int
main(int argc, char **argv) {
  int rank, np;
  double t1, t2;
  MPI_Init(&argc, &argv);
  MPI_Comm_rank(MPI_COMM_WORLD, &rank);
  MPI_Comm_size(MPI_COMM_WORLD, &np);
  mt.seed(rank);
  MPI_Barrier(MPI_COMM_WORLD);
  t1 = MPI_Wtime();
  for (int i = 0; i < LOOP; i++) {
    makebuf();
    write_mpi(rank);
  }
  MPI_Barrier(MPI_COMM_WORLD);
  t2 = MPI_Wtime();
  double time_mpi = t2 - t1;

  mt.seed(rank);
  MPI_Barrier(MPI_COMM_WORLD);
  t1 = MPI_Wtime();
  for (int i = 0; i < LOOP; i++) {
    makebuf();
    write_seq(rank, np);
  }
  MPI_Barrier(MPI_COMM_WORLD);
  t2 = MPI_Wtime();
  double time_seq = t2 - t1;
  if (0 == rank) {
    printf("%f #MPI\n", time_mpi);
    printf("%f #SEQ\n", time_seq);
  }
  MPI_Finalize();
}
//----------------------------------------------------------------------

実行環境

  • Intel(R) Xeon(R) CPU E5-2680 v3 @ 2.50GHz
  • 1ソケット12コア、1ノード2ソケット、合計24コア/ノード
  • 1ノード24プロセス実行
  • ファイルシステムはLustre
  • SGI MPT 2.14
  • Intel MPI 2018.1.163

実行結果

  • SGI MPT
$ mpirun -np 24 ./a.out
4.810011 #MPI
2.725134 #SEQ

$ for file in *.mpi; do diff $file ${file/mpi/seq};done
  • Intel MPI
$ mpirun -np 24 ./a.out
4.748924 #MPI
2.741026 #SEQ

$ for file in *.mpi; do diff $file ${file/mpi/seq};done

それぞれdata00.mpi, data00.seq...data99.mpi, data99.seqというファイルができて、拡張子がmpiなのはMPI-IOを使ったもの、seqはナイーブに実装したものです。diffをとって同じステップのファイルがMPI-IOとシーケンシャル番で同じ内容であることを確認しています。

で、1ノードから出力しているのでMPI-IOは遅いだろうなと思っていましたが、やっぱり遅いですね。

まとめ

MPI-IOを使ってみました。1ノードで、複数プロセスから同じファイルにシーケンシャルにファイルを出力した場合と、MPI-IOで一度に書き込みにいった場合を比較して、MPI-IOを使ったほうが倍近く遅いことがわかりました。高並列時にちゃんと使えば早くなるのでしょうが1、様々な資料を見る限り、かなりのノウハウが必要そうな気がします。なによりサイト依存が激しそうです。

とりあえず頭を使わずナイーブに吐いちゃって、それで遅くて仕方なかったら、吐くデータ量や吐く頻度を削減できないか考えた方がいろいろ良い気がします。

参考


  1. どうすれば早くなるの?>教えてMPIのえらいひと 

  2. 間違ってたら教えてください>MPIのえらいひと