本当は怖いHPC

HPC屋の趣味&実益ブログ

UCXを試す日記(9):allreduceを実装する上での予備調査

とりあえず、UCXで実用(?)コードを書いてみようということで、Allreduceを書いてみようと思っています。Allreduceは、ディープラーニングにおいては重要な通信パターンで、業務においても研究したことがあるので経験があります(これについては、近々会社のブログの方で発表できると思います)。

Allreduceを実装するにはいくつかのポイントがあることがわかっていますので、これを先に調査しておきます。

send/recv通信として, bcopyzcopy のどちらを使うか

いわゆる普通のsend/recvを実現する手段としては、bcopyzcopyがあります。bcopyzcopyの違いはわかりにくいですが、このページによると、

  • bcopy:転送を直ちに開始するか、失敗かの2通りの結果しかない。失敗とは、sendするローカル側のリソースが不足している場合で、UCS_ERR_WOULD_BLOCKが返される。この場合、Endpointの作成時にUCT_FLAG_PENDINGとコールバックを設定しておくと、リソースが利用可能になった時点でこのコールバックが呼ばれる
  • zcopybcopyの動作に加えて、 UCS_INPROGRESS を返すことがある。この場合、通信は非同期に行われ、完了時に uct_ep_am_zcopyの引数に指定したコールバックが呼ばれる。

ということで、非同期通信モデルである zcopy が良さそうです。気になるのは、bcopyのメモリコピーに関する動作がはっきり書いていないところです(bが何を意味するのかよくわからない)。zcopyzはZeroだろうと思われるので、逆に考えるとbcopyはゼロコピー性は保証されていないのかな…?ソースを読む必要がありそう

通信の順序が保証されるか?

MPIおよびInfinibandでは、通信の到着順は保証されています。これが入れ替わってしまうと、Ring-Allreduceが成り立たないので注意が必要です。下のレイヤがInfinibandであれば自動的に(事実上)保証されることになるでしょうが、APIとしての保証がない場合、例えば下のレイヤをTCPに変えたら動作が変わって動かなくなる、みたいなことになりそうなので注意が必要ですね。

UCTとUCPのどちらを使うか?

UCXには、通信を実現するAPIのレイヤとして UCPUCTがあります。UCTは、固有のAPIに近い、薄いレイヤを提供するので、特定の機能がサポートされているかどうか、などのフラグによる分岐をユーザーが書く必要があります。一方でUCPは上位の機能を実現するレイヤでタグマッチングなどが実装されています。インターフェースがサポートしない機能はエミュレーションするように実装されているようなので、便利そうではあります。

その場合、 * UCPのオーバーヘッドはどれくらいか? * UCPのエミュレーション機能は、現段階でどれくらい実装されているのか * UCPのみで実現されている機能が必要か?

ということだけど…

以上の点を調査する必要がありそうですが、とりあえずInfinibandを前提にした高速な実装を目指そうと思うので、これらの調査は先送りしようと思います。

Travis CI上で、g++とclang++の複数バージョンでビルドする

C++でライブラリを作っていて、それをTravisCI上でテストします。そのとき、GCCとClangの両方でテストしたっかったのですが、いろいろ面倒だったのでメモです。

基本的には、Travis CIのmatrixという機能を使って様々なバージョンのコンパイラを使ったテストを独立・並行に立ち上げることができます(GCCとClangの複数バージョンをテストしたいので、単にcompiler を設定するだけでは不足です)。

以下が面倒なポイントです

  • GCC, Clangのそれぞれについて、バージョンに応じたリポジトリを追加・パッケージを入れる必要がある
    • GCC: apt / source に、 ubuntu-toolchain-r-test を加える
    • Clang: それに加え、3.8 までと 3.9 以降で追加リポジトリが違う。Clangのバージョンに応じて llvm-toolchain-precise-X.Yもしくは llvm-toolchain-trusty-X.Yを加える
  • Clangの注意:
    • C++11/14以降を使うためにはlibc++が必要なので、 libc++-dev, libc++abi-dev をインストールする
    • C++で書かれた依存ライブラリが必要な場合は、それも libc++ でビルドする必要がある。(C++ のABIが違うので)。aptパッケージなどのビルド済みバイナリは使えない
    • -stdlib=libc++CMAKE_CXX_FLAGS に加えた状態でビルド

といったあたりがややこしいポイントです。

自分が現在開発しているucx-playgroundというライブラリでは、以上の試行錯誤を全部詰め込んだ結果、無事に動きました(ライブラリというよりは実験コード置き場ですが)

GitHub - keisukefukuda/ucx-playground

Travis CI - Test and Deploy Your Code with Confidence

そのときに使った .travis.yml は、

ucx-playground/.travis.yml at d4102355079e3998e16977369076166afa5526cf · keisukefukuda/ucx-playground · GitHub

これです。リポジトリ内にある travis/gen_yaml.py というPythonスクリプトで自動生成しています。

MPI Communicatorのattributesを利用して、MPI_Finalize()時にコールバック関数を起動する

MPIのややマニアックな機能の1つに、attriburtesがあります。これは、コミュニケータに、整数をキーとした辞書のようなデータ構造で値(属性)を登録できるというものです。関係する関数としては、 MPI_Comm_create_keyvalMPI_Comm_set_attrなどがあります。

一方、定義済みコミュニケーターの1つに、 MPI_COMM_SELF というものがあります。これは、そのプロセス自分自身のみが属する大きさ1の特殊なコミュニケータです。このコミュニケーターは、普通はあまり役に立たないのですが、MPIの終了時に最初に破壊されることが保証されているという特殊な性質を持っており、これを利用して MPI_Finalize() 時のコールバック関数を登録することができます。

MPI 3.1仕様書の当該箇所を引用します。

8.7.1 Allowing User Functions at Process Termination

There are times in which it would be convenient to have actions happen when an MPI process finishes. This can be accomplished in MPI by attaching an attribute to MPI_COMM_SELF with a callback function. When MPI_FINALIZE is called, it will first execute the equivalent of an MPI_COMM_FREE on MPI_COMM_SELF. This will cause the delete callback function to be executed on all keys associated with MPI_COMM_SELF

日本語訳

8.71. プロセス終了時に関数を呼び出す

MPIプロセスの終了時に動作を起こすことができると便利な場合があります。(中略)これは、MPI_COMM_SELF にコールバック付きのattributeを設定することで、MPIに実行させることができます。MPI_FINALIZE が呼ばれるとき、最初に MPI_COMM_FREE 相当の処理を MPI_COMM_SELF に対して行います。これにより、attributesの削除コールバックが起動されます。(以下略)

というわけで、この仕組を使うと、MPIの終了時にフックした関数を実行できます。以下は、具体的なコード例です。

#include <stdio.h>
#include <mpi.h>

// MPI_Comm_delete_attr_function
int my_comm_attr_delete_fun(MPI_Comm comm, int keyval, void* attr_val, void* extra_state) {
    fprintf(stderr, "my_comm_attr_delete_fun is called()\n");
    MPI_Comm *newcomm = (MPI_Comm*) attr_val;
    MPI_Comm_free(newcomm);
    return MPI_SUCCESS;
}

int main(int argc, char **argv) {
    MPI_Init(&argc, &argv);

    /* 独自のCommunicatorを作成。サンプルとして、終了時にこのCommunicatorを削除してみる */
    MPI_Comm newcomm;
    MPI_Comm_dup(MPI_COMM_WORLD, &newcomm);

    /* 新しく作る属性のKey */
    int keyval;

    /* 属性を作成 */
    MPI_Comm_create_keyval(MPI_COMM_NULL_COPY_FN, my_comm_attr_delete_fun, &keyval, NULL);

    /* COMM_SELFに属性を設定 */
    MPI_Comm_set_attr(MPI_COMM_SELF, keyval, &newcomm);

    /* 通常通り MPI_Finalize() を呼ぶ */
    MPI_Finalize();

    return 0;
}
# 実行例
$ mpicc test.c
$ mpiexec -n 2 ./a.out
my_comm_attr_delete_fun is called()
my_comm_attr_delete_fun is called()

コールバックが実行されていることがわかります。

【広告】