鲲鹏社区首页
中文
注册
高性能计算之MPI——10分钟超越99%的程序员

高性能计算之MPI——10分钟超越99%的程序员

HPC

发表于 2025/09/22

0

前段时间写了一篇关于高性能计算(HPC)的文章,在知乎上点赞量400+,收藏量破千了;在我们稼先也有超过2K的浏览量!
单就收藏量比去年写的关于杨振宁先生的三篇文章加起来的阅读量都要高。
这个事实证明,当年老夫从物理转行搞计算机是多么明智的选择(大雾)
https://jx.huawei.com/community/comgroup/postsDetails?postId=b5ef08e5681a4cf481dbbed425aa3d81&noTop=true&type=freePost

所以,我决定开个高性能计算(HPC)专栏。
促使我写专栏的一个原因是当前中文互联网上对HPC的系统介绍太少了。
另一个原因是现有的材料把HPC描述得太枯燥。
HPC本身很有趣,把HPC写成一件枯燥乏味的事情实在有些暴殄天物。
这让我产生了一种舍我其谁的错觉(手动狗头)

当然能不能坚持下来,还得看各位支持。各位老官,有钱的碰个钱场没钱的捧个人场~【疯狂暗示点赞、收藏和关注】
那我们就从MPI开始。

论起名字的重要性

MPI全称:Message Passing Interface.
我一直觉得这个英文名字很ugly,无法表达MPI的精神内核。
当然,如果你知道中文名后,可能会选择原谅它的英文名。
因为它的中文名叫"消息传递接口"。
对。message-消息;passing-传递;interface-接口,连起来就是消息传递接口。
妥妥的"译制腔" 让我仿佛回到了看国产配音的国外电影的巨大尴尬中。


消息传递接口就像这些译制腔一样 让人难受
算了,还是叫MPI吧。

MPI究竟是神马?

言而简之,简而言之,一言以蔽之:
MPI指的是,多进程并行时不同进程之间通信的协议或标准。
为啥要用并行计算?前人之述备矣。
通常情况下,我们把一个进程运行到某一个或几个CPU核心上,然后让多个进程并行工作。
绝大部分情况下,不同进程之间不是完全相互独立和互不干扰的,它们之间需要通信。
MPI就是诸多进程彼此之间通信的规范。



《高性能计算:从入门到放弃》中提到那个例子:假设你是个包工头,招募了10个工人一起搬砖。
大部分情况,这10名工人只要默默搬好自己的砖,不需要交流。
但某些情况下,他们之间需要互相沟通(通信)。
试想以下:你招募的这10个工人来自天南海北,他们各自说方言,这就变成了一场灾难。
山东话、河南话、吴语、粤语等等这些方言要不是因为当年秦始皇一声"车同轨、书同文"令下,恐怕现如今早就演变成不同语言了。
所以,作为包工头的你必须站出来:咱们统一口径,都说普通话。
这样沟通的问题才能被解决。

在HPC领域,MPI充当了"普通话"的角色。
在多个进程(工人)工作过程中,统一了不同进程之间交换数据(通信)的标准。
顺便说一句,在计算机领域,如果进程A无法理解进程B发来的消息,产生的恶果可远远不止影响效率那么简单,整个程序会因此而崩掉。
由此可见,建立不同进程之间的通信标准(协议)有多么重要。

当然,这个标准并不是一成不变的。
从1994年5月,MPI 1.0标准诞生以来,MPI已经走过了整整30年。
最新发布的MPI标准是2023年11月发布的4.1版本。
由于标准需要向前兼容,所以标准书像滚雪球似地越滚越大。
MPI 1.0标准只有27页,MPI 4.1足足有1166页。
https://www.mpi-forum.org/docs/mpir-specification-10-11-2010.pdf
https://www.mpi-forum.org/docs/mpi-4.1/mpi41-report.pdf

我在读博期间接触到MPI编程,那是MPI 3.0的时代,标准书已经膨胀到852页了。
当然,就像没人会对着辞典去学汉字一样,也不会有人对照标准书学编程。


编程需要循序渐进,要从简单的开始。
既然编程要从简单的开始,那就不妨从最简单的Hello World开始吧。
(如果您对编程不感兴趣,可直接跳到最后一小节)

Hello, World

先从一段最原始的串行Hello World入手,看看我们是如何一步步将其改造成MPI并行的。
在这个改造过程中,你会发现:那些晦涩难懂的MPI概念是如何被引入到MPI标准中的,而且这个引入的过程很丝滑。

/* File Name: hello_world.c 

 the simplest serial vesion: Hello World */

#include <stdio.h> 

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

  printf("Hello World! \n");

  return 0;

}


这段代码经过编译运行后,得到就是令无数程序员热血沸腾的结果:

Hello World!


现在我们尝试着把这个程序改成MPI程序,也就是多进程版本程序。
换句话说,我们希望有多个进程(比如4个进程)都打印出"Hello World!".

/* File Name: mpi_hello_world.c
the simplest MPI parallel vesion */

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

int main(int argc, char **argv) {
    MPI_Init(NULL, NULL);
    printf("Hello World! \n");
    MPI_Finalize();
    return 0;
}


相比串行版本的程序而言,多进程程序仅仅多了一个头文件<mpi.h>和两行代码:MPI_Init和MPI_Finalize.
这两个函数非常容易理解:
MPI_Init,就是Initialize. (初始化)
MPI_Finalize,就是Finalize. (结束)
这相当于告诉计算机:MPI_Init到MPI_Finalize这段代码是MPI并行程序,所有MPI调用必须落在这个区间内。

这样的程序需要MPI编译器进行编译。
以上面的C代码为例,其编译方式如下:
mpicc mpi_hello_world.c -o mpi_hw.exe

看到了没?
除了编译器换成了mpicc之外,编译方式没有发生任何的变化!
编译成功后,使用mpirun命令执行:
mpirun -np 4 ./mpi_hw.exe


这里先不解释-np参数的含义。
执行上述命令,得到如下输出:
Hello World!

Hello World!

Hello World!

Hello World!

从输出就不难看出:np后面的"4"指的是MPI进程数。
np实际上是Numer of Process(进程数)的缩写。
就这样,我们成功地把一脸懵逼变成了四脸懵逼:


到目前为止,恭喜学会并行计算的你,已经超过90%的程序员!
在正式超越99%程序员之前,咱们再用Hello World搞些花样。

Hello, World の花样

大家肯定注意到了:上面例子中虽然使用了4个进程,但4个进程都在干同样一件事。
有没有一种可能:就是四个进程,有两个进程要Hello World, 而有两个进程要Hi World呢?

大家想想,如果要做到上述中不同进程做不同的事情,还缺少那些信息?
没错,缺少的是对每个进程的标识。
换句话说,计算机程序需要有效区分出不同进程,才能让不同进程做不同的事情。

如何做到这一点呢?
程序员的第一反应使用PID(Process ID). 进程ID号确实是进程的唯一标识。
这里最大问题在于,只有程序跑起来后才能知道PID。
但不同进程做的事情在编程阶段就要被确定下来。
专业术语表述是:PID是运行态概念,而MPI进程则是个静态概念。

好在MPI规范中已经帮助程序员做好了这层抽象,而且这层抽象非常简单。
这层抽象就是把数字从0一直排到np-1作为MPI进程的编号。

以四个进程为例,MPI进程序号依次为:0,1,2,3.
把0/1/2/3这样的MPI进程编号叫做rank(进程序号)。
把{0,1,2,3}这样的通信集合叫做communicator(通信域).
那么程序就使用MPI rank来标识进程即可。

/* File Name: mpi_hello_world_v2.c
the MPI parallel */ #include <stdio.h>
#include <mpi.h> int main(int argc, char **argv) {
    MPI_Init(NULL, NULL);
    int rank;
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);
    if (rank % 2 == 0) {
        printf("I'm rank #%d: Hello World! \n", rank);
     } else {
        printf("I'm rank #%d, Hi, World!\n", rank);
    }
    MPI_Finalize();
    return 0;
}


运行上述代码,在4线程时得到如下输出:
I'm rank #0: Hello World! 

I'm rank #2: Hello World! 

I'm rank #1, Hi, World!

I'm rank #3, Hi, World!

不难看出,MPI rank的信息是通过MPI_Comm_rank获取的。
代码中MPI_COMM_WORLD表示默认通信域。
通信域有很多新奇玩法,这里就不展开了。
这里面还个API可以获知通信域中包含多少个进程。
这个API的用法和MPI_Comm_rank非常相近,
MPI_Comm_size(MPI_Comm comm, int *size)
显然,在上述例子中,返回的size值等于4.

通信,开始!

如前文所述,MPI既然是通信协议,必然涉及到两个或者两个进程之间的数据交换。
这种数据交换语义,拆解下来无非是一个发送(Send),另一个接收(Receive).
(其实,再复杂的通信都是"发"和"收"两个动作组成的) 我们思绪暂时离开MPI,思考一个生活上的问题:
如果你给另外一个人写信,需要提供哪些信息?
1、信件内容
2、收件者

对计算机来说,信件的内容无非是一段存储空间(buffer).
对于buffer而言需要知道它的起始位置和大小。
因此,在MPI规范中,发送(send)的API是这样规定的:
int MPI_Send(const void *buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm)

buf表示发送数据buffer的初始地址
count表示发送数据的数目
datatype表示发送数据的数据类型
通过count和datatype就能算出来buffer的大小:sizeof(datatype) * count.
这三个参数描述的是"信件内容".
dest:destination. 目的地.

这个参数描述的是"收件人".
comm: communicator,即前文讲到的通信域


那么,这里tag表示的是什么呢?
事实上,由于进程之间的数据交换需要校验机制防止发送和接受的数据发生紊乱,因此必须存在一个校验的标志位,这个标志在MPI里面就是以整数tag出现的,这样的校验机制也被称为tag-matching.

那么,tag-matching是如何完成校验的呢?
官方关于tag-matching的说法相当复杂,如下图所示:


我们还是打个比方,这样更容易理解。
你从同一家网店先后购买了了一件T恤和一件短裤,网店发了两个包裹给你。
店家发货顺序是:T恤、短裤。
但货物真正运输到你的手上时,很有可能先到的是短裤,后到的是T恤。
换句话说,店家发送的顺序和你接受的顺序不能保证是完全一致的(非保序性)。
为了很好地区分收到的包裹,店家在发货的同时会配上一个发货码(tag)。
作为接收端,你可以通过这个发货码清晰地知道自己收到的包裹是T恤还是短裤。
这就是tag-matching机制。

因此对于接受端来讲,tag肯定是存在的,其API形式:
int MPI_Recv(void *buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status *status)

buf/count/datatype的含义和在MPI_Send中是一致的.
source表示发送端的进程序号
tag:tag-matching的校验标识
comm: 通信域
status: 状态

举个两进程的例子:进程0(发送端)把数字100发送给进程1(接收端),通过打印方式可以清晰看到进程1(接收端)前后buffer内容的变化。

/* File Name: mpi_send_recv.c
the MPI parallel send and recv example */

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

int main(int argc, char **argv) {
    MPI_Init(NULL, NULL);
    /* tag */
    int tag = 10086;
    int count = 1; 
    MPI_Status status;
    int rank;

    MPI_Comm_rank(MPI_COMM_WORLD, &rank);


    if (rank == 0) {
        /* sendbuff: sendbuff[0] = 100 */
        int *sendbuff = (int *)malloc(sizeof(int) * count);
        sendbufff[0] = 100;
        /* rank0 -> rank 1: sendbuff */
        MPI_Send(sendbuff, 1, MPI_INT, 1, tag, MPI_COMM_WORLD);
        printf("I'm rank #d, sendbuff[0] = %d\n", rank, sendbuff[0]);
    } else if (rank == 1) {
        int *recvbuff = (int *)malloc(sizeof(int) * count);
        recvbuff[0] = 200;
        /* Before receive, print vaule of recvbuff */
        printf("I'm rank #%d, before recv, recvbuff[0] = %d\n", rank,recvbuff[0]);
        /* rank1 <- rank 0: recvbuff */
        MPI_Recv(recvbuff, 1, MPI_INT, 0, tag, MPI_COMM_WORLD, status);
        /* After receive, print vaule of recvbuff */
        printf("I'm rank #%d, after recv, recvbuff[0] = %d\n", rank,recvbuff[0]);
    }
    MPI_Finalize();
    return 0;
}


打印结果:
I'm rank #0: sendbuff[0] = 100        

I'm rank #1, before recv, recvbuff[0] = 200

I'm rank #1, after recv, recvbuff[0] = 100 

可以看到,对于进程1来说,
在调用MPI_Recv前,recvbuff的数值是200;
在调用MPI_Recv后,recvbuff的数值变成了100.
这说明100这个数字已经成功地从进程0发送给进程1了。

小结:六大金刚

通过上述例子,我们已经了解到MPI里最基本的两大概念:
rank: 进程序号
communicator: 通信域
以及MPI的六个最基本、最常用的函数,简称"六大金刚"。

为了便于查阅,把这6个函数API总结如下:
/* Initialize and Finalize */                                            
int MPI_Init(int *argc, char ***argv)                                        
int MPI_Finalize()                                                  

/* MPI comm size and rank */                                             
int MPI_Comm_size(MPI_Comm comm, int *size)                                     
int MPI_Comm_rank(MPI_Comm comm, int *rank)                                        

/* MPI send and recv */                                               
int MPI_Send(const void *buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm)          
int MPI_Recv(void *buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status *status)  

看到这里,我必须恭喜你,因为你已经超过99%的程序员了!    


后记:MPI,山外有山

2017年秋,天河超算天津中心(简称"天河I号")的两位工程师来北大进行集群使用的培训,有一章节专门提到MPI。
我清晰得记得,那页PPT写着的就是上面提到的六大金刚。
两位工程师自信满满地说:只要学会了这六个函数,就等于学会了MPI.
我当时心想,要是这么算的话,那我算得上是MPI方面的高手了。毕竟除了上述6个函数之外,我还用过MPI_Barrier,虽然我对这个函数知之甚少。
直到有一天,我参加MPI开发,才发现当时的自己真的是too naive.
回过头来看,天河I号工程师的观点其实没有错:
对于一个程序员来说,掌握了上述技能已经可以吊打99%的人了。
但是知识总是无止境的,不是么?


其实,对于想把MPI程序性能推向极致的人来说,上述的知识仅仅是个开端。
举个最简单的例子。
假设在一种场景下,MPI的0号进程(rank 0, 也叫root进程)需要把数据发送给其余所有进程。
这样的场景怎么办?
诚然,可以通过MPI_Send和MPI_Recv来实现上述功能。
这样的实现方式无疑是非常低效的。
原因很简单,root进程需要一个进程一个进程地去发送。
进程一旦多进来,root进程恐怕要冒烟了。

事实上,MPI标准中用适配上述场景的接口。
这个集合通信接口叫做MPI_Bcast。
Bcast这个词来源于Broadcast,意为"广播"。
对,就是那个拿着大喇叭广播的广播。


平心而论,这个名词起得还是非常优秀的。
在MPI_Bcast内部有有很多优秀算法可以让上述过程变得非常快、非常快!
每次使用MPI的时候,我都有一种强烈的感觉:
MPI的牛X之处在于,如果只是想把MPI用起来,只需要很短的时间,比如看完这篇文章后就会了。
如果想利用MPI已有的设计去优化好一款MPI程序或者要去优化MPI本身,则需要非常高的水准了。
至于如何达到较高的水平,且听下回分解。

如果大家有任何问题,欢迎评论区来玩儿~
这个专栏能不能做下去,还看各位的点赞收藏量了~
(本文原载于知乎,知乎作者也是本人,原文链接:https://zhuanlan.zhihu.com/p/715049734

参考文献
[1]. A Comprehensive MPI Tutorial Resource 这是国外一个叫做Wes Kendall的人写的在线MPI教程.
[2]. Message Passing Interface (MPI) 康奈尔大学关于MPI的介绍.
[3]. 中科大超级计算中心《MPI并行编程入门》
[4]. https://www.mpi-forum.org/docs/ MPI社区官网

本页内容