并行与分布式计算:MPI进阶(七)
鱼生苦短,争取更咸!!!
Section 7 MPI Collective Communication
为了方便和读者交流,我在本文及以后的文章中定义没有兴趣为你日理万机(比如你在学罗老师的并分导,现在正在肝作业),对于一些不甚重要的东西(比如罗老师没有讲,考试也不会考的东西)都不上心,只想粗略的了解一个大概的这种心理
没有兴趣是为了忙于学业的P大学子准备的。如果你对相关的内容感兴趣,希望打好一个基础,那你想必对于文中的所有知识都感兴趣
7.1 概述
本部分以矩阵向量乘法为例,介绍以下几部分
7.1.1 四个通信函数
- MPI_Allgatherv:数据全收集函数,不同进程可以提供不同数目的元素
- MPI_Scatterv:分发数据操作,不同进程可以获得不同数目的元素
- MPI_Gatherv:数据收集操作:从不同进程收集来的元素个数可以不同
- MPI_Alltoallv:全交换操作,在所有进程间交换数据元素
7.1.2 五个通信域函数
- MPI_Dims_create:为平衡的笛卡尔进程网格创建新维度
- MPI_Cart_create:创建一个笛卡尔拓扑通信域
- MPI_Cart_coords:返回笛卡尔进程网格中某个进程的坐标
- MPI_Cart_rank:返回笛卡尔进程网格中位于某个坐标的进程的进程号
- MPI_Comm_split:将一个通信域的进程划分为一个或多个小组
7.1.3 Collective Communication
- one-to-all broadcast,all-to-one reduction
- all-to-all broadcast,all-to-all reduction
- gather,scatter
- all-to-all exchange
7.2 一些说明
我们研究的问题是计算 A b = c Ab=c Ab=c,其中 A A A是 m ∗ n m*n m∗n的矩阵, b b b是n维度列向量, c c c是m维列向量,为了方便学习,我们假设m=n。对于b和c,复制他们而增加的存储空间开销并不会影响空间复杂度的数量级,所以我们简单地将其复制到每一个进程中去,故而下面的分解主要考虑对矩阵的分解。
为了避免I/O混乱,我们总是只用一个进程来读取数据,也只用一个进程来输出结果。
7.3 按行分解
从行的角度看,矩阵向量乘法的结果可以视为n个n维向量对的点积,向量乘法的按行分解的动机是非常显然的。
当我们合适的将矩阵按行分解并进行合适的聚集和映射后,实际上要做的事情是要把各个进程的结果收集起来,放到一起去变成总的结果。
按行分解的矩阵的读取与发送是容易的。因为读取的顺序本来就是按行读取的,只需要把连续内存中的数据发送给指定线程即可。
MPI_Allgatherv
一个全收集通信可以连接分布在一组进程中的向量数据块,并把结果复制到所有进程。
如果要从每个进程收集同样数目的函数,比较简单的MPI_Allgather函数就非常合适。但是我们没有办法保证每个进程分配到的任务是相等的,所以我们使用MPI_Allgather。
函数声明如下
int MPI_Allgatherv(
void* send_buffer,//此进程要发送的数据的起始地址
int send_cut,//此进程要发送的数据个数
MPI_Datatype send_type,//发送的数据类型
void* receive_buffer,//用来存放收集到的元素的起始地址
int* receive_cnt,//一个数组,第i个元素为第i个进程接受数据的数据个数
int* receive_disp,//一个数组,第i个元素为从第i个进程接受的数据项的偏移量
MPI_Datatype receive_type,//接收的数据类型
MPI_Comm communicator//本操作所在的通信域
);
也就是说第i个进程的结果转化为了从 r e c e i v e _ b u f f e r [ r e c e i v e _ d i s p [ i ] ] receive\_buffer[receive\_disp[i]] receive_buffer[receive_disp[i]]到 r e c e i v e _ b u f f e r [ r e c e i v e _ d i s p [ i ] + r e c e i v e _ c n t [ i ] − 1 ] receive\_buffer[receive\_disp[i]+receive\_cnt[i]-1] receive_buffer[receive_disp[i]+receive_cnt[i]−1]的数组元素
MPI_Gatherv
该函数的基本功能、参数和Allgatherv类似,只不过是从每个进程收集数据,并将其集中到一个进程中
这里就不再赘述了
7.4 按列分解
从线性空间的角度,我们也可以理解为Ab是按照b中参数对A中向量的线性组合,记 A = ( α 1 , . . . , α n ) , b T = ( b 1 , . . . , b n ) , A b = ∑ i = 1 n b i α i A=(\alpha_1,...,\alpha_n),b^T=(b_1,...,b_n),Ab=\sum_{i=1}^nb_i\alpha_i A=(α1,...,αn),bT=(b1,...,bn),Ab=∑i=1nbiαi
所以,按列分解的动机也是极为显然的。每个task就是将α向量的扩张为原来的b倍,并在最后做一次规约操作。
MPI_Scatterv
按列分解时,发送数据显然就没有刚刚那么简单。对于每一行,我们都要将他们分段并送给不同的进程。
函数声明如下
int MPI_Scatterv(
void* send_buffer,//此进程要发送的数据的起始地址
int* send_cnt,//一个数组,第i个元素为发送给第i个进程的数据个数
int* send_disp,//一个数组,第i个元素为发送给第i个进程的数据在send_buffer中的偏移量
MPI_Datatype send_type,//发送的数据类型
void* recv_buffer,//本进程用来存放接受元素的指针
int recv_cnt,//本进程要接受的数据个数
MPI_Datatype recv_type,//接收的数据类型
int root,//分发数据的进程ID
MPI_Comm communicator//本操作所在的通信域
);
MPI_Scatterv是一个组通信函数,通信域中的所有进程都参与执行。此函数要求所有进程初始化两个数组,一个是指出根进程向每个进程发送数据的个数,一个指出发放数据在缓冲区中的偏移量。分发操作按进程号顺序执行,进程0得到第一块,进程1得到第二块…
MPI_Alltoallv
正如我们一开始所说的,当第j个task计算完n个乘积后,如果它还想要计算出c[j],那么他必须留下一个值,并从其他task中取得剩下所需n-1个值。所有的task都要发放n-1个值出去,并收集所需的n-1个结果,这就叫做全交换(all-to-all exchange)
MPI_Alltoallv可以完成在一个通信域的所有进程之间相互交换数据
函数的声明如下
int MPI_Alltoallv(
void* send_buffer,//待交换数组的起始地址
int* send_count,//第i个元素指定发送给进程i的元素个数
int* send_displacement,//第i个元素为发送给进程i的数据在send_buffer的起始地址
MPI_Datatype send_type,
void* recv_buffer,//接收数据(包括自己发给自己的数据)的缓冲区起始地址
int* recv_count,//第i个元素表示本进程将要从进程i接收的数据个数
int* recv_displacement,//第i个元素表示从进程i接收的数据在recv_buffer的起始地址
MPI_Datatype recv_type,
MPI_Comm communicator
);
7.5 棋盘式分解
从矩阵的分块乘法的角度入手,A可以棋盘式的分解为p个块,并把每块的计算安排给一个进程。
矩阵分解的信息收发值得思考,但并不是我们核心的关注对象。所以我们把这个问题抽象为一个棋盘格状的进程组,其中第一列参与收集数据d的任务(在本例子中,可以假设b被分成了k段,分别交给了第一列的每一个线程——尽管这和我们最开始将b直接复制给每个线程的假设是矛盾的——我这样说只是为了引出问题),第一行参与散发数据d的任务(散发给它的同列进程),每行所有进程进行独立的求和规约,在第一列的进程中产生一个向量,这个向量再被收集到第一列第一行的进程(简称零号线程)中去。
所以实际上我们要做的是这样一个事情:第一步,第一列所有进程将数据集合到零号线程(第一列意义上的All to one reduction);第二步,零号线程将信息分发给第一行的各个线程(第一行意义上的Scatter);第三步,每一列在第一行的进程将信息复制给同列进程(每一列意义上的One to all broadcast)。我们可以发现,在以上操作中,实际上有一个更小的通信域。也就是说,进程被细化的分为了数个通信组。从直觉上我们也能感受到,在更小的组内进行通讯,效率远远比在大的组内进行部分通信要高得多,这就是我们接下来所要谈的问题了。
我们接下来在这一类问题的基础上介绍通信域的概念。至于具体得出结果的计算过程及其之后的规约和传递,与此大同小异,不再赘述。
通讯域
一个通讯域由一个进程组,上下文,以及其他属性构成。
进程拓扑是通信域的一个重要特征。
- 拓扑可以为进程建立新的编址模式,而不仅仅是使用进程编号
- 拓扑是虚拟的,也就是说它不依赖于处理器的实际连接方式
- MPI支持两种拓扑结构:笛卡尔拓扑和图拓扑
MPI_Dims_create
为了使得矩阵向量乘积算法具有最好的可扩展性,所建立的虚拟进程网格最好接近方形(这个原因暂时不必深究,在学习了可扩展性后可以自行验证),所以我们将使用笛卡尔拓扑(网格拓扑)
仅将笛卡尔网格的节点数和维数传给这个函数,它将在size中给出每个维度分别应该有多少个节点。如果我们对这个网格有特殊要求,也可以在size中手动规定
int MPI_Dims_create(
int nodes,//网格中的进程数
int dims,//我们想要的网格维数
int* size//每一维度的大小,如果size[i]为0,那么这个函数将决定这个维度的大小
);
特别地,如果dims=2且size全为0的话,size[0],size[1]将分别展示网格的行数和列数(???这是什么狗屁设定???)
MPI_Cart_create
确定了虚拟网络每一维度的大小后,需要为这种拓扑建立通信域。组函数MPI_Cart_create可以完成此任务,其声明如下:
int MPI_Cart_create(
MPI_Comm old_comm,//旧的通信域。这个通讯域中的所有进程都要调用该函数
int dims,//网格维数
int* size,//长度为dims的数组,size[j]是第j维的进程数
int* periodic,//长度为dims的数组,如果第j维有周期性,那么periodic[j]=1,否则为0(这个我也有疑问)
int reorder,//进程是否能重新被编号,如果为0则进程在新的通信域中仍保留在旧通信域的标号
MPI_Comm* cart_comm//该函数返回后,此变量将指向新的笛卡尔通信域起始地址
//(这是一个通讯域数组,每一个元素代表一个新的通信域)
);
MPI_Cart_rank
该函数的作用是通过进程在网格中的坐标获得它的进程号
MPI_Cart_coords
该函数的作用是确定某个线程在虚拟网格中的坐标
MPI_Comm_split
将某个通信域进一步划分为几组
7.6 Collective Communication
组通信是并行计算中很重要的概念
7.6.1 通信网络的拓扑结构
在谈组通信之前,我们必须说清楚我们是在什么样的进程网络中进行组通信的。
我们所谓的通信网络,关注的都是网络的拓扑结构,也就是哪些处理器和哪些处理器之间在我们的并行程序设计架构中相连,这与实际上他们是否临近无关
概念
下面给出了很多常见的概念,对于没有兴趣的同学来说可以不看,若如此做,下一节的所有分析都不必看(也看不懂)
- 直接拓扑结构:一个开关对应一个处理器节点,一个开关节点都与一个处理器节点及一个或多个别的开关相连
- 间接拓扑结构:一个处理器节点连接多个开关,一些开关可以仅仅与别的开关相连
- 直径:两个开关节点之间的最大距离。直径决定了随机节点对之间通讯的并行算法复杂度的下界,所以直径小更好
- 对分带宽:对分带宽是为了将该网络分为两半而必须删除的边数最小值。对分带宽越大越好,在需要大量数据移动的算法中,并行算法复杂度的下界就是数据集大小除以对分带宽
- 每个开关节点的边数:最好是每个开关节点的边数与网络规模无关,这样更容易扩展
- 固定边长:出于扩展性的原因,最佳情况是网络的节点和边能够布置于三维空间,使得最大边长是一个与网络大小无关的常数
常见拓扑结构
下面给出了很多常见的拓扑结构,没有兴趣的同学仅需了解环形结构(Ring)和超立方体(Hybercube)
在图示中,圆形代表开关,方形代表处理器。开关是控制处理器通讯的手段,保证其通信安全、可靠,具体内容可以看教材,没有兴趣可以不看
-
环形结构
顾名思义,处理器一颗两颗三颗连成线,头尾相接成闭环
-
二维网格形网络(直接拓扑)
好处:扩展性好(开关边为常数)
-
二叉树形网络(间接拓扑)
好处:直径小
坏处:对分带宽小(1)
-
超树形网络(间接拓扑)
好处:直径较小,对分带宽比二叉树更大
4-叉超树网络几乎在各个方面都比二叉树形网络出众,它具有很少的开关节点、小的直径和大的对分带宽
-
蝶形网络
-
超立方体网络
直径:logn,对分带宽n/2
传输方式:相临边仅有一个二进制位差异,依次改变初始点和目标点的差异二进制位就能生成一条路径
-
混洗-交换网络
这是一种折中方案,具有固定的边、较小的直径和较好的对分带宽
小结
7.6.2 Broadcast & Reduction
B&R操作主要分为以下几种
- one-to-all broadcast,all-to-one reduction
- all-to-all broadcast,all-to-all reduction
- gather,scatter
- all-to-all exchange(Personalized Communication)
注意,讨论每一种操作时都要说明当前所依附的拓扑网络
One-to-all broadcast,All-to-one reduction
该通信的基本效果如下
以环形拓扑结构举例如下(虚线上的数字代表其发生的先后顺序,箭头表示由起点发送给终点的数据)
All-to-all broadcast,All-to-all reduction
该通信的效果如下
以环形拓扑结构举例(Broadcast)如下(虚线上的数字代表其发生的先后顺序,箭头表示由起点发送给终点的数据)
(下面是一种最简单朴素的方法,使用p次one-to-all broadcast很容易就实现了这个效果,尽管效率不咋的)
以二维网格结构举例(Broadcast)如下(该广博仅进行了一半,再进行一次列间通信即可完成)
Gather,scatter
Scatter与One-to-all Broadcast有一定相似性(信息来源唯一,发送给所有进程),实际上,如果在实现One-to-all Broadcast的过程中,使得每个节点截取自己所需的消息,并从广播内容中删除,就能实现Scatter
All-to-all exchange(All-to-all Personalized Communication)
从左侧来理解, M i , j M_{i,j} Mi,j表示现存于 i i i号进程,欲发往 j j j号进程的消息(右侧正好相反)