高性能计算-MPI并行程序开发实战(一)

高性能计算-MPI并行程序开发实战(一)

MPI 简介

MPI是一个库,而不是一门语言。许多人认为MPI是一种并行编程语言,这是错误的。若一定要按照并行语言分类,则可以分为FORTRAN+MPI或C++/C+MPI,看作为在原来串行编程语言基础上扩展后得到的并行语言。MPI库可以被FORTRAN77/Fortran90/C/C++调用。从语法上说,它遵守所有对库函数/过程的调用规则,和一般的函数/过程没有什么区别。

MPI是标准或规范的代表,不特指具体实现。截至目前为止,所有的并行计算机制造商都提供对MPI的支持,可以获得MPI在不同并行计算机上的实现,一个正确的MPI程序,可以不加修改的在所有的并行机上执行。

MPI是一种消息传递编程模型,并成为编程模型的代表和标准。MPI虽然庞大,但是最终的目的是服务于进程间通信这一目标。在MPI上很容易移植其他的并行代码,且编程者不需要掌握更多的概念,就可以编写MPI程序。当然,MPI也有一些缺点。

消息传递方式是广泛应用于多类并行机的一种模式,特别是那些分布存储并行机。尽管
在具体的实现上有许多不同, 但通过消息完成进程通信的基本概念是容易理解的,十多年来这种模式在重要的计算应用中已取得了实质进步。有效和可移植地实现一个消息传递系统是可行的。因此,通过定义核心库程序的语法、语义,这将在大范围计算机上可有效实现将有益于广大用户,这是MPI产生的重要原因。

MPI 目的

总的概括起来,MPI在实际应用中十分重要又相互矛盾的三个方面有:

  • 较高的通信性能
  • 较好的程序可移植性
  • 强大的功能

具体来说,有以下几个方面:

  • 提供应用程序编程接口
  • 提高通信效率。措施包括避免存储器到存储器的多次重复拷贝,允许计算和通信的重叠等。
  • 可在异构环境下提供实现。
  • 提供的接口可以方便C/C++和Fortran77的调用。
  • 提供可靠的通信接口,即用户不必处理通信失败。
  • 定义的接口和现在已有接口(如PVM,NX,Express,p4等)差别不能太大,但是允许扩展以提供更大的灵活性。
  • 定义的接口能在基本的通信和系统软件无重大改变时,在许多并行计算机生产商的平台上实现。接口的语义是独立于语言的。
  • 接口设计应是线程安全的。

MPI提供了与语言和平台无关,可以被广泛使用的编写消息传递程序的标准,用它来编写消息传递程序,不仅实用、可移植、高效和灵活,而且和当前已有的实现没有太大的变化。

第一个MPI程序

学习一门新的语言或函数库,我们写的第一个程序往往都是Hello World,接下来我们就实现一个C++ MPI HelloWorld程序。由于作者不接触Fortran编程,故本系列文章只有C/C++实现。

对于该程序,需要增加C语言实现的头文件mpi.h。定义程序中所需要的与MPI有关的变量。程序中的MPI_MAX_PROCESSOR_NAME是MPI预定义的宏,即某一MPI的具体实现中允许机器名字的最大长度,机器名放在变量processor_name中;整型变量rank和numprocess分别用来记录某一个并行执行进程的标示和所有参加计算的进程的个数。namelen是实际得到的机器名字的长度。

MPI程序的开始和结束必须是MPI_Init和MPIFinalize,分别完成MPI程序的初始化和结束工作。在C语言中,几乎所有的MPI函数都是以MPI开头,后面的第一个字母大写,后面的部分小写。

在MPI程序体中,包括各种MPI过程调用语句和C++语句。MPI_Common_rank函数可以获取当前正在运行的进程的标识号,将结果存放在rank中;MPI_Comm_size的到所有参加运算的进程的个数,放在numprocess中;MPI_Get_processor_name的到本进程运行的机器的名称,结果存放在processor_name中,是一个字符串,字符串的长度存放在namelen中;fprintf语句将本进程的标识号、并行执行的进程的个数、本进程所运行的机器的名字打印出来,和一般的C/C++程序不同的是这些程序题中的执行语句是并行执行的,每一个进程都要执行。

不妨指定本程序启动时共产生4个进程同时运行,而运行本程序的机器名为:DESKTOP-QFMCAFJ,4个进程都在DESKTOP-QFMCAFJ上运行,其标识分别为0,1,2,3,执行结果如下面的图片所示。虽然MPI程序本身只有一条打印语句,但是由于启动了四个进程同时执行,每个进程都执行打印操作,故最终执行结果打印了四条语句。

通过上面的描述,我们可以将MPI程序的框架结构概述为:
MPI_Program_Structure

注:所有MPI的名字都有前缀MPI,不管是常量、变量还是过程或函数调用的名字都是这样。在自己编写的程序中不准说明以前缀MPI开始的任何变量和函数。这样做的主要目的是为了避免与MPI可能的名字混淆。
对于C++的MPI调用,一般为MPI_Aaaa_aaaa的形式。

此处我们使用Intel Parallel Studio 2017编程套件中提供的Intel C++编译器来编译MPI_HelloWorld程序。

在安装完成Intel Parallel Studio 2017后,启动Visual Studio 2015(2017也可)。在Visual Studio中,在模板中选择Visual C++,Win32,在右侧选择Win32控制台应用程序,输入项目名称及解决方案名称,点击确定继续。
VS_New_Project

由于项目创建较为简单,此处不再详细介绍。

在配置完成后,可先确认程序是否可正常执行。设此处可以正常执行,则开始配置Intel MPI并行库的使用。

在解决方案MPIHelloWorld下属项目上右键属性,进入配置。

首先,在项目配置属性中配置Intel Performance Library,在Intel Math Kernel Library选项列表中,确认Use MPI Library选项为Intel(R) MPI.如图所示:
VS_PRJSET_MPI_ENABLE

接下来到配置属性中的调试部分设置中的命令和命令参数设置为:

Command: $(I_MPI_ROOT)\intel64\bin\mpiexec.exe
Command arguments: -n <processes_number> "$(TargetPath)"

可替换为处理器数量,推荐设置为8.
在配置属性中选择C/C++,配置Additional Include Directories为:

Additional Include Directories: $(I_MPI_ROOT)\intel64\include

在配置属性中选择Linker,配置Additional Library Directories为:

Additional Library Directories: $(I_MPI_ROOT)\intel64\lib\<configuration>

可替换为以下列表中的值:

debug: 单线程调试库
release: 单线程优化库
debug_mt: 多线程调试库
release_mt: 多线程优化库

在配置属性中选择Linker,Input项目并设置Additional Dependencies为:

Additional Dependencies: impi.lib

在上面配置完成后,即可在MPIHelloWorld.cpp中添入程序代码,MPI的HelloWorld程序代码如下:

// MPIHelloWorld.cpp : 定义控制台应用程序的入口点。
//

#include "stdafx.h"
#include "mpi.h"

#pragma comment (lib, "impi.lib")

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

    int rank, numprocess;
    int namelen;
    char processor_name[MPI_MAX_PROCESSOR_NAME];

    MPI_Init(&argc, &argv);
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);
    MPI_Comm_size(MPI_COMM_WORLD, &numprocess);

    MPI_Get_processor_name(processor_name, &namelen);
    fprintf(stderr, "Hello World! Process %d of %d on %s\n", rank, numprocess, processor_name);
    MPI_Finalize();
    return EXIT_SUCCESS;

}

代码编写完成后,使用本地Windows调试器运行程序,可得到如下结果:
MPI_HelloWorld_Run

MPI函数详解

MPI_Init:告知MPI系统进行所有必要的初始化设置。它是写在启动MPI并行计算的最前面的。具体的语法结构为:

MPI_Init(int* argc_p, char*** argv_p);

参数argc_p和argv_p分别指向main函数中的指针参数,C/C++中规定main函数的参数有两个,习惯上这两个参数写为argc和argv。因此,main函数的函数头可写为:main (argc, argv)。C语言还规定argc(第一个形参)必须是整型变量,argv(第二个形参)必须是指向字符串的指针数组。其中argc参数表示了命令行中参数的个数(注意:文件名本身也算作参数),argc的值是在输入命令行时由系统按实际参数的个数自动赋予的,argc的值不可修改。

然而在MPI_Init函数中,并不一定都需要设置argc_p和argv_p这两个参数的,不需要的时候,将它们设置为NULL即可。

通讯子(communicator):MPI_COMM_WORLD表示一组可以互相发送消息的进程集合。
MPI_Comm_rank:用来获取正在调用进程的通信子中的进程号的函数。
MPI_Comm_size:用来得到通信子的进程数的函数。
这两个函数的具体结构如下:

int MPIAPI MPI_Comm_rank(
    __in MPI_Comm comm,
    __out int* rank
    );

int MPIAPI MPI_Comm_size(
    __in MPI_Comm comm,
    __out int* size
    );

它们的第一个参数都传入通信子作为参数,第二参数都用传出参数分别把正在调用通信子的进程号和通信的个数。

MPI_Finalize:告知MPI系统MPI已经使用完毕。它总是放到做并行计算的功能块的最后面,在此函数之后就不能再出现任何有关MPI相关的东西了。

以上只是表达了作为一个MPI并行计算的基本结构,并没有真正涉及进程之间的通信,为了更好的进行并行,必然需要进程间的通信,下面介绍两个进程间通信的函数,它们就是MPI_Send和MPI_Recv,分别用于消息的发送和接收。

MPI_Send:阻塞型消息发送。其结构为:

int MPI_Send (void *buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm)

参数buf为发送缓冲区;count为发送的数据个数;datatype为发送的数据类型;dest为消息的目的地址(进程号),其取值范围为0到np-1间的整数(np代表通信器comm中的进程数) 或MPI_PROC_NULL;tag为消息标签,其取值范围为0到MPI_TAG_UB间的整数;comm为通信器。
MPI_Recv:阻塞型消息接收。

int MPI_Recv (void *buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status *status)

参数buf为接收缓冲区;count为数据个数,它是接收数据长度的上限,具体接收到的数据长度可通过调用MPI_Get_count函数得到;datatype为接收的数据类型;source为消息源地址(进程号),其取值范围为0到np-1间的整数(np代表通信器comm 中的进程数),或MPI_ANY_SOURCE,或MPI_PROC_NULL;tag为消息标签,其取值范围为0到MPI_TAG_UB间的整数或MPI_ANY_TAG;comm为通信器;status返回接收状态。
MPI_Status:返回消息传递的完成情况。数据结构的相关变量的意义就比较多了,具体可以参考Intel MPI函数库使用手册。

typedef struct {
... ...
int MPI_SOURCE;             /*消息源地址*/
int MPI_TAG;                /*消息标签*/
int MPI_ERROR;              /*错误码*/
... ...
} MPI_Status;

总结

从上面的C++ MPI HelloWorld程序可以得知,MPI并行程序是在原来串行程序基础上的扩展,在许多地方和串行程序是相同的。但是在设计MPI程序时,头脑中必须有并行执行的概念,而不是原有的顺序执行,这才是串行和并行最主要的区别。上面的程序不涉及任何通信部分,但是它给出了MPI程序与串行程序的主要区别,简单来说,它同时打印了多条“Hello World”语句,而不是一个。这就已经包含了SPMD(Single Program Multiple Data)程序的精髓。