对一并行程序实验的简单理解
对一并行程序实验的简单理解
写在前面
使用一维数组
在写并行程序时,会遇到很多针对矩阵、张量的计算,尽管它们都是以多维数组的方式组合在一起,但一般情况下,我更加倾向于使用一维数组来表示这些多维数组的数据。我认为,这样做的好处有:
- 方便函数参数类型的统一。如果程序中既声明了二维数组(这意味着它是一个 pointer to pointer 的类型),又存在一维数组,那么在调用函数时(比如需要生成随机数据,或者检查运行结果是否正确),会出现类型不一致,就需要实现两个功能类似的函数,增加了代码冗余。当然了,这里我们只讨论 C 语言的情况 :sweat:。
- 方便索引的计算。计算偏移量是并行编程中常做的事情,然而在多维数组中,指针偏移是很容易出错且吃力的事情(很多时候单看
p+1
你甚至不知道指针到底偏移了多少!)。而如果全部使用一维数组,偏移的量就很容易理解,你再也不会被多维指针搞得晕头转向:smile:。 - 更接近机器底层。对计算机系统稍做了解,就会发现所谓的“多维数组”其实是程序员抽象出来的东西,计算机真正认识的只有一维数组。使用一维数组写并行程序,不仅对 MPI 更加友好(大部分 MPI 函数只接受一维指针),还会更有助于我们理解机器行为,挖掘并行潜力。
使用一维数组虽然有如上好处,但也有明显的弊端:索引表达不方便。当然也有办法可以解决这些问题:
-
使用宏函数。注意一定要给每个运算部分加上括号。
1
#define INDEX(i, j) (((i)*(N))+(j))
-
如果你对宏函数嗤之以鼻,那么可以使用
inline
函数代替宏函数,性能不会降低且更加安全。
使用堆空间并注意内存泄露
并行编程往往涉及非常大的数据量,因此推荐将数据放入堆空间中。不过,这会带来一个非常棘手的问题:内存泄露。C 语言中没有自动的内存回收工具,内存能不能被回收利用全靠程序员自觉,但良好的编程习惯和适当的技巧可以尽可能地避免这类问题:
1 |
|
用循环 do ... while(0)
将程序主体包裹起来,这样一旦分配内存出错就可以直接 break
跳出来,然后将其释放掉。还避免使用了 goto
语句。另外需要注意的是,释放内存前一定要检查指针是否已经指向 NULL
,这可避免二次释放造成的运行时错误。
使用串行程序进行验算
使用串行程序检验最终计算结果对并行编程的重要性不言而喻。但在调试时,串行程序也可以贡献出自己的一份力量。若遇到复杂的程序设计,可能需要分步完成程序,每一步都用串行程序验证正确性。稳扎稳打,步步为营才是王道。
题目描述
矩阵 和 均为 的双精度矩阵,现有 个处理器,针对以下程序片段,分别采用按行块连续划分和棋盘划分的方式,给出相应的 MPI 程序实现。
1 |
|
按行连续划分
方法概要
按行连续划分,即将矩阵以行为单位分割,并交给不同的处理器进行处理。下图就是我们对矩阵 行连续划分的示意图。除主处理器外,每个处理器轮流地取出连续的三行。在程序一开始,假设只有0号处理器拥有所有的数据。
我们的程序需要完成以下步骤:
- 在某个处理器中生成随机数据。为了计算的方便,我假设编号最大的处理器中握有数据,并且还负责最后结果的汇总。
- 将生成的随机数据分发给不同的处理器。从上面的程序片段中看出,计算一个矩阵 元素需要三行的矩阵 元素,因此至少需要给一个处理器三行数据。
- 各个处理器完成自己的任务。
- 将计算好的结果从各个处理器发出,由主处理器收集整理。
细节处理
细节决定成败,这对并行编程来说尤为如此。特别地,需要注意
- 从处理器会接到多个来自主处理器的数据,必须要用明确的标签区分这些数据。
- 处理好余数情况,某几个从处理器收到的数据可能会比其他从处理器多一个,需要一个私有变量记录处理器需要计算多少行矩阵 。当然,也可以采用对矩阵 补 0 对齐的方法
- 收集计算结果时,要将起点定在矩阵 的(1,1)中,还要弄清楚到底哪一行计算结果存放在哪一个处理器中。
- 时刻注意索引的计算
部分代码解析
先是程序的初始化和随机生成数据,并串行地执行计算。
1 |
|
随后,开始真正的并行计算:先让拥有数据的线程将数据广播到相应的线程中。注意,根据图上的示例,需要发送连续的三层数据。需要注意到,N 不一定能整除 (num_procs-1),需要考虑留下的余数。ctn
变量记录收到数据的份数,表明了后面的计算需要循环的次数。
1 |
|
分发数据完成!接下来就是每个线程的计算部分。这里的实现和串行程序十分类似。注意,ctn
变量记录了该线程受到了几组数据,因此外层循环需要循环 ctn
次:
1 |
|
把计算得到的矩阵全都发给 Proc#num-1。
1 |
|
棋盘划分
方法概要
先以块棋盘划分来实现并行。在程序片段中,矩阵 的有效结果范围为 [1~N-2, 1~N-2] 。若将矩阵 B 按如下示意图划分为棋盘状,每个小格子由一个处理器单独完成(以下就用“格子”数据类型称呼它们)。
此时,程序需要完成以下步骤:
- 在某个处理器中生成随机数据。为方便,这次选择编号0的处理器生成随机数据。
- 将生成的随机数据分发给不同的处理器。按照总共有的处理器数量,合理分配每行处理器的数量以及总行数,分配完成后,将随机数据发给不同的处理器。遇到余数的问题,用补 0 对齐的方法解决(虚线表示)。
- 各个处理器计算自己“格子”内的数据
- 将计算好的结果从各个处理器发出,由编号0的处理器收集整理。
细节处理
除了之前在行连续划分中提到的外,还要特别注意到:棋盘划分出的“格子”在一维数组中是不连续的。把一个格子发送给某个处理器,如果不采用一些小技巧,是很难做到的(你需要一个循环,并计算格子中每一行的索引),但如果使用 MPI 提供的创建数据类型功能,就很容易办到。通过下面的三条语句,就可以轻松地创建出类型为“格子”的数据类型。
1 |
|
对于矩阵 来说,一个处理器所需要的数据,即所谓的棋盘格子,事实上就是一个有 a+2
个数据段,每个段长度为 b+2
,段与段之间相隔 N
个单位的“结构体”,使用 MPI_Type_vector
函数就可以轻松描述它,如果你想了解更多,请参考 MPICH 官网。
部分代码解析
1 |
|
创建新的数据类型,用于描述前面提到的“格子”。
1 |
|
有了这个数据类型,就不需要纠结于繁琐的边界问题,按照索引计算规则,直接将矩阵 切割好发送出去。当然索引计算什么的仍然很不友好 :sweat:,不过接收方的代码确实简单不少。
1 |
|
开始计算,这边的计算完全和串行程序一致,因为我们只是将矩阵 和 变小了而已。
1 |
|
收集计算好的数据。注意到,此时数据类型变化了,因为矩阵 的格子比的要大一圈,用一个数据类型就无法描述了。
1 |
|
当然,还有很多不同的做法:可以使用 MPI_Pack
函数对“格子”类型的数据进行打包;也可以用面向对象的思想,直接封装一个 class ;当然也可以用循环这种最笨拙的办法传递“格子”
我的代码实现见 github 仓库。