MPI教程1

MPI(Message Passing Interface,消息传递编程接口)是由全世界工业、科研和政府部门联合建立的一个消息传递编程标准,其目的是为基于消息传递的并行程序设计提供一个高效的、可扩展的、统一的编程环境。MPI标准定义了一组c和fortran函数接口,用于进程间的消息传递,这组函数的具体实现由各厂商实现。流行的实现有mpich和OpenMPI等,本节代码运行于mpich下,且只介绍c语言接口。

§MPI运行模式

mpi_mode

§安装

流行的Linux版本可以直接从软件仓库安装,如

ubuntu: sudo apt install mpich

centos: sudo yum list mpich*, sudo yum install mpichX

mpich等主流mpi实现都不支持windows,在windows上使用mpi,需要安装ms-mpi,从https://github.com/microsoft/Microsoft-MPI/releases下载文件msmpisdk.msi和msmpisetup.exe安装即可。

如果因为某些原因不得不从源代码安装,可以遵循以下流程进行安装(假设无root权限)。

1、从mpich网站下载源代码并传至服务器

$ wget https://www.mpich.org/static/downloads/3.0.4/mpich-3.0.4.tar.gz
$ scp mpich-3.0.4.tar.gz user@remote_ip:/home/user/mpich-3.0.4.tar.gz -P 22

更多版本可以在https://www.mpich.org/downloads/和https://www.mpich.org/static/downloads/找到。

2、解压缩

$ tar -xf mpich-3.0.4.tar.gz

3、编译

$ mkdir mpich
$ cd mpich-3.0.4
$ ./configure --prefix=/home/user/mpich/ 2>&1 | tee c.log
$ make 2>&1 | tee m.log
$ make install 2>&1 | tee mi.log

4、加入环境变量。编辑~/.bashrc,修改或者添加以下行:

1
2
3
4
5
export PATH="/home/user/mpich/bin:$PATH"
export LD_LIBRARY_PATH="/home/user/mpich/lib:$LD_LIBRARY_PATH"
export C_INCLUDE_PATH="/home/user/mpich/include:$C_INCLUDE_PATH"
export CPLUS_INCLUDE_PATH="/home/user/mpich/include:$CPLUS_INCLUDE_PATH"
export LIBRARY_PATH="/home/user/mpich/lib:$LIBRARY_PATH"

编译过程可以参考:https://www.mpich.org/static/downloads/3.3.2/mpich-3.3.2-installguide.pdf.

§集群配置

§节点情况

假定一个集群中有4个节点,每个节点配置一个内部通信用的静态ip地址,且每个节点至少有一个别名。/etc/hosts文件如下:

127.0.0.1	localhost

21.21.21.1       node1  gpunode1
21.21.21.2       node2  gpunode2
21.21.21.3       node3  gpunode3
21.21.21.4       node4  gpunode4

各个节点之前是否互通可以通过ping来测试:

$ ping node2 -c 4
PING node2 (21.21.21.2) 56(84) bytes of data.
64 bytes from node2 (21.21.21.2): icmp_seq=1 ttl=64 time=0.181 ms
64 bytes from node2 (21.21.21.2): icmp_seq=2 ttl=64 time=0.181 ms
64 bytes from node2 (21.21.21.2): icmp_seq=3 ttl=64 time=0.162 ms
64 bytes from node2 (21.21.21.2): icmp_seq=4 ttl=64 time=0.185 ms

--- node2 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 2999ms
rtt min/avg/max/mdev = 0.162/0.177/0.185/0.013 ms

首先每个节点都需要有自己能访问的用户,此处假定4个节点上的用户名都为user。

§免密登录

切换节点可以用ssh登录:

user@node1:~$ ssh node2
user@node2's password:
Last login: Tue Jul 21 23:31:08 2020 from node1
user@node2:~$

mpi可以通过ssh进行通信,但必须配置免密登录。ssh的免密登录通过公钥/密钥认证实现。以下介绍步骤。

1、在node1上生成公钥和密钥

user@node1:~$ ssh-keygen -t rsa
Generating public/private rsa key pair.
Enter file in which to save the key (/home/user/.ssh/id_rsa): 
Enter passphrase (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /home/user/.ssh/id_rsa.
Your public key has been saved in /home/iser/.ssh/id_rsa.pub.
The key fingerprint is:
dc:da:09:df:08:d5:bf:e4:30:22:c1:00:24:20:f9:24 user@node1
The key's randomart image is:
+--[ RSA 2048]----+
|ooo          |
|E ..   o   .     |
| +      o . .    |
|  .    . +   .   |
|        S o o o  |
|         B = = . |
|        . = . o  |
|                 |
|                 |
+-----------------+

中途询问路径和密码直接回车即可。生成的公钥和密钥分别存放在文件/home/user/.ssh/id_rsa.pub和/home/user/.ssh/id_rsa中。

2、将node1生成的公钥加入node2、node3、node4的authorized_keys中:

user@node2:~$ mkdir ~/.ssh
user@node2:~$ scp node1:/home/user/.ssh/id_rsa.pub ~/.ssh/authorized_keys
...

3、更改权限

user@node2:~$ chmod 700 ~/.ssh
user@node2:~$ chmod 600 ~/.ssh/authorized_keys
...

此时从node1登录node2、node3、node4不再需要输入密码了。如果仍然不能登录,则检查node2、node3、node4的ssh配置文件/etc/ssh/sshd_config中以下几个选项是否正确:

RSAAuthentication yes                    # 启用 RSA 认证
PubkeyAuthentication yes                 # 启用公钥私钥配对认证方式
AuthorizedKeysFile .ssh/authorized_keys  # 公钥文件路径

如果不是上述配置则改成上述配置然后重启ssh服务:service ssh restart

如果要从node2等节点免密登录其它节点,按上述流程操作即可。

2020-12-14 更新:可以直接执行ssh-copy-id user@host来将自己的id_rsa.pub加入到host的user用户的.ssh/authorized_keys中,这样就不需要手动更改authorized_keys了。比如要从node1免密登录node2,则在node1上执行ssh-copy-id node2,然后输入密码即可。

§共享目录

我们只在一个节点上编译了mpich,其它节点上也需要安装mpich并配置环境变量,可以将它拷贝过去:

user@node1:~$ scp -r .bashrc mpich node2:/home/user/mpich

除mpich需要每个节点一份以外,每次编译好的可执行文件也需要每个节点都有一份且路径相同,为简单起见,此处使用共享目录进行同步。

注意:设置共享目录必须有root权限!

假定各个节点已经安装了nfs-utils,需要同步的目录为/home/user/workspace,此目录仅存储于node1的磁盘上,遵循以下步骤操作可将此目录挂载至node2、node3、node4:

1、首先编辑node1的/etc/export文件,加入一行:

/home/user/workspace *(rw,sync,root_squash)

其中,

*():所有主机都可挂载;

rw:read-write,可读写;

sync:同步写入(文件同时写入硬盘和内存),适用在通信比较频繁且实时性比较高的场合;

root_squash:把客户端root身份的UID/GID(0/0)对应到服务端的nobody用户去,即服务端使用nobody用户来操作共享目录。

2、然后在node1上执行:

root@node1:~# exportfs -rav
exporting *:/home/user/workspace

3、然后在node2、node3、node4上分别执行:

root@node2:~# mkdir /home/user/workspace
root@node2:~# mount -t nfs -o nfsvers=3 node1:/home/user/workspace /home/user/workspace

4、如果node2、node3、node4上未见到共享目录的内容,可以重启nfs服务:

root@node2:~# service nfs restart

§进程数量控制

假设编译出来的可执行文件为/home/user/workspace/a.out。在/home/user/workspace下新建一个名为servers的文本文件,内容如下:

node1:2
node2:2
node3:2
node4:2

然后执行:

1
user@node1:~/workspace$ mpiexec -f servers ./a.out

这样就可以使用4个节点共8个进程进行计算。以上操作在任意一个节点上进行都可以,只要此节点可以免密登录其余节点。

注意:此节上述配置仅适用于mpich,如果是openmpi,按照下面的操作进行。

1、在/home/user/workspace/下新建一个文本文件,名为hostfile,内容为:

node1 slots=2
node2 slots=2
node3 slots=2
node4 slots=2

2、执行:

1
user@node1:~/workspace$ mpiexec --hostfile hostfile ./a.out

§进程特点

1、进程与程序相联,程序一旦在操作系统中运行即成为进程。

2、进程拥有独立的执行环境,内存空间彼此不相交。

3、进程间可以相互交换信息。例如数据交换、同步等待,消息是这些交换信息的基本单位,消息传递是实现进程间通信的唯一方式。

§MPI重要概念

1、进程组(process group):MPI程序的全部进程集合的一个有序子集,且进程组中每个进程被赋于一个在该组中唯一的序号(rank),用于在该组中标识该进程。序号的取值范围是[0, 进程数-1]。

2、通信器(communicator):一类进程的集合即一个进程组,且在该进程组,进程间可以相互通信。任何MPI通信函数均必须在某个通信器内发生。MPI系统提供省缺的通信器MPI_COMM_WORLD,所有启动的MPI进程通过调用函数MPI_Init()包含在该通信器内;各进程通过函数MPI_Comm_size()获取通信器包含的(初始启动)的MPI进程个数。

3、进程序号(rank):用来在一个进程组或通信器中标识一个进程。MPI程序中进程在进程组内有唯一序号,不同进程组内的同一进程可以有不同的序号。

4、消息(message):分为数据(data)和包装(envelope)两个部分。包装由接收进程序号/发送进程序号、消息标号和通信器三部分组成,数据是用户将要传递的内容。

5、MPI对象:MPI系统内部定义的数据结构,包括数据类型(如MPI_INT)、通信器(MPI_Comm)、通信请求(MPI_Request)等。

6、MPI联接器(handles):联接MPI对象的具体变量,用户可以通过它访问和参与相应MPI对象的具体操作。例如,MPI系统内部提供的通信器MPI_COMM_WORLD。

§MPI函数形式

MPI的函数名称以MPI_开头,一般形式为:

1
int MPI_Xxxx(parameters_list);

除MPI_Wtick和MPI_Wtime外,其它函数的返回值都是整型错误编码,如全局归约函数声明为:

1
2
int MPI_Reduce(const void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype,
               MPI_Op op, int root, MPI_Comm comm);

返回值可能为:MPI_SUCCESS、MPI_ERR_COMM、MPI_ERR_COUNT、MPI_ERR_TYPE、MPI_ERR_BUFFER。标识了MPI_Reduce函数调用过程中可能发生的错误。

§MPI数据类型

MPI的许多函数调用时,需要指定传递的数据类型,MPI的数据类型定义为结构体MPI_Datatype,内置类型见下表:

MPI_Datatype 对应c语言类型
MPI_CHAR char
MPI_SHORT short
MPI_INT int
MPI_LONG long
MPI_FLOAT float
MPI_DOUBLE double
MPI_UNSIGNED_CHAR unsigned char
MPI_UNSIGNED_SHORT unsigned short
MPI_UNSIGNED unsigned int
MPI_UNSIGNED_LONG unsigned long
MPI_LONG_LONG_INT long long
MPI_LONG_DOUBLE long double

需要注意的是MPI_DOUBLE这样的类型不是真正的类型,而是对象,目的是用来告知MPI操作数的类型,声明变量仍然只能使用对应的c语言类型。

除了这些内置类型,MPI还支持自定义类型,这个功能在后面介绍。

§MPI程序结构

下面是MPI程序的典型结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <mpi.h>
// ...
int main(int argc, char *argv[]) {
    // 定义变量
    int rank = 0;
    int nprocs = 0;
    
    // 并行区域
    MPI_Init(&argc, &argv);
    MPI_Comm_size(MPI_COMM_WORLD, &nprocs);
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);
    
    // ...
    
    // 结束并行
    MPI_Finalize();
    
    return 0;
}

函数MPI_Init用于初始化MPI系统,在调用其它MPI函数(除MPI_Initialized外)之前必须先调用譔函数。在许多MPI系统中,第一个进程通过MPI_Init来启动其它进程。注意要将命令行参数的地址传递给MPI_Init,因为MPI程序启动时一些初始化参数是通过命令行传递给进程的,这些参数被添加在命令行参数列表中,MPI_Init通过它可以得到MPI程序运行的相关信息。

函数MPI_Comm_size和MPI_Comm_rank分别返回指定通信器中进程的数目和本进程的编号。

函数MPI_Finalize用于退出MPI系统,调用MPI_Finalize之后不能再调用其它MPI函数。

要注意即使是MPI_Init之前的代码,也会被所有进程执行,只是无法使用MPI进行通信,因此要注意资源的分配和数据的一致性。

§例1、计算$\pi$的值--全局归约

通过近似计算定积分$\int_0^1\cfrac{4}{1 + x^2}dx$来计算$\pi$。根据定积分的定义,有: $$ \int_0^1\cfrac{4}{1 + x^2}dx = \lim_{n \rightarrow +\infty} h\sum_{i=0}^{n-1} f(x_i) $$ 其中$h=1/n$为积分步长;$x_i=(i+0.5)h$为区间中点;$f(x)=\cfrac{4}{1 + x^2}$为被积函数。

每个进程计算一部分和,然后进行归约。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 文件名:pi.cpp
#include <iostream>
#include <mpi.h>

double f(double x) {
    return 4.0 / (1.0 + x * x);
}

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

    int np = 0, id = 0;
    MPI_Comm_size(MPI_COMM_WORLD, &np);  // 获取进程数量
    MPI_Comm_rank(MPI_COMM_WORLD, &id);  // 获取进程id

    const int n = 100000;    // 分成100000段来计算
    const double h = 1.0 / n;
    double sum = 0;

    // 0号进程统计时间
    double start_time = 0, end_time = 0;
    if (0 == id)
        start_time = MPI_Wtime();

    for (int i = id; i < n; i += np)
        sum += f(h * (i + 0.5));
    sum *= h;

    double pi = 0;
    MPI_Reduce(&sum, &pi, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD);

    // 0号进程输出结果
    if (0 == id) {
        end_time = MPI_Wtime();
        std::cout.precision(10);
        std::cout << "pi is " << pi << "\n";
        std::cout << "the time cost is " << end_time - start_time << "\n";
    }

    MPI_Finalize();
    return 0;
}

编译:mpicxx pi.cpp -o pi.out

一个进程执行:

1
2
3
$ ./pi.out
pi is 3.141592654
the time cost is 0.001555681229

多个进程执行:

1
2
3
$ mpiexec -n 4 ./pi.out
pi is 3.141592654
the time cost is 0.0006926059723

此段代码主要用到的函数为MPI_Reduce,签名为:

1
2
int MPI_Reduce(const void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype,
               MPI_Op op, int root, MPI_Comm comm);

此函数的功能可由下图描述:

MPI_Reduce

MPI_Op为归约操作的二元运算,此处需要将各个进程的结果求和,因此指定为MPI_SUM。可用的操作见下表:

MPI_Op 描述
MPI_MAX 两个操作数中较大的一个
MPI_MIN 两个操作数中较小的一个
MPI_SUM 两个操作数之和
MPI_PROD 两个操作数之积
MPI_LAND 两个操作数的逻辑与(logical and)
MPI_BAND 两个操作数的按位与(bitwise and)
MPI_LOR 两个操作数的逻辑或(logical or)
MPI_BOR 两个操作数的按位或(bitwise or)
MPI_LXOR 两个操作数的逻辑异或(logical xor)
MPI_BXOR 两个操作数的按位异或(bitwise xor)
MPI_MAXLOC 两对操作数的最大值和最大值的位置(u,i)op(v,j)=(max(u,v),k)
MPI_MINLOC 两对操作数的最小值和最小值的位置(u,i)op(v,j)=(min(u,v),k)

如果需要的操作没有预定义的MPI_Op,可以通过MPI_Op_create函数定义合适的函数。

count指定数组长度,datatype指定数组的元素类型,root和comm分别指示归约到哪个进程和在哪个进程组内归约。

§文档

[1] https://www.open-mpi.org/doc/current/.

[2] https://www.mpich.org/static/docs/latest/.

[3] https://www.mpi-forum.org/docs/mpi-3.1/mpi31-report.pdf.

加载评论