10.2 并行编程模型
并行处理系统上如何编程是个难题,目前并没有很好地解决。并行编程模型的目标是方便编程人员开发出能在并行处理系统上高效运行的并行程序。并行编程模型(Parallel Programming Model)是一种程序抽象的集合,它给程序员提供了一幅计算机硬件/软件系统的抽象简图,程序员利用这些模型就可以为多核处理器、多处理器、机群等并行计算系统设计并行程序[26]。
10.2.1 单任务数据并行模型
数据并行(Data Parallel)模型是指对集合或者数组中的元素同时(即并行)执行相同操作。数据并行编程模型可以在SIMD计算机上实现,为单任务数据并行;也可以在SPMD计算机上实现,为多任务数据并行。SIMD着重开发指令级细粒度的并行性,SPMD着重开发子程序级中粒度的并行性。单任务数据并行编程模型具有以下特点:
1)单线程(Single Threading)。从程序员的角度,一个数据并行程序只由一个线程执行,具有单一控制线;就控制流而言,一个数据并行程序就像一个顺序程序一样。
2)同构并行(Identical Parallel)。数据并行程序的一条语句,同时作用在不同数组元素或者其他聚合数据结构,在数据并行程序的每条语句之后,均有一个隐式同步。
3)全局命名空间(Global Naming Space)。数据并行程序中的所有变量均在单一地址空间内,所有语句可访问任何变量而只要满足通常的变量作用域规则即可。
4)隐式相互作用(Implicit Interaction)。因为数据并行程序的每条语句结束时存在一个隐含的栅障(Barrier),所以不需要显式同步;通信可以由变量指派而隐含地完成。
5)隐式数据分配(Implicit Data Allocation)。程序员没必要明确指定如何分配数据,可将改进数据局部性和减少通信的数据分配方法提示给编译器。
10.2.2 多任务共享存储编程模型
在共享存储编程模型中,运行在各处理器上的进程(或者线程)可以通过读/写共享存储器中的共享变量来相互通信。它与单任务数据并行模型的相似之处在于有一个单一的全局名字空间。由于数据是在一个单一的共享地址空间中,因此不需要显式地分配数据,而工作负载则可以显式地分配也可以隐式地分配。通信通过共享的读/写变量隐式地完成,而同步必须显式地完成,以保持进程执行的正确顺序。共享存储编程模型如Pthreads和OpenMP等。
10.2.3 多任务消息传递编程模型
在消息传递并行编程模型中,在不同处理器节点上运行的进程均有独立的地址空间,可以通过网络传递消息而相互通信。在消息传递并行程序中,用户必须明确为进程分配数据和负载,消息传递并行编程模型比较适合开发大粒度的并行性,这些程序是多进程的和异步的,要求显式同步(如栅障等)以确保正确的执行顺序。
消息传递编程模型具有以下特点:
1)多进程(Multiprocess)。消息传递并行程序由多个进程组成,每个进程都有自己的控制流且可执行不同代码;多程序多数据(Multiple Program Multiple Data,简称MPMD)并行和单程序多数据(SPMD)并行均可支持。
2)异步并行性(Asynchronous Parallelism)。消息传递并行程序的各进程彼此异步执行,使用诸如栅障和阻塞通信等方式来同步各个进程。
3)独立的地址空间(Separate Address Space)。消息传递并行程序的进程具有各自独立的地址空间,一个进程的数据变量对其他进程是不可见的,进程的相互作用通过执行特殊的消息传递操作来实现。
4)显式相互作用(Explicit Interaction)。程序员必须解决包括数据映射、通信、同步和聚合等相互作用问题;计算任务分配通过拥有者-计算(Owner-Compute)规则来完成,即进程只能在其拥有的数据上进行计算。
5)显式分配(Explicit Allocation)。计算任务和数据均由用户显式地分配给进程,为了减少设计和编程的复杂性,用户通常采用单一代码方法来编写SPMD程序。
典型的消息传递编程模型包括MPI和PVM。
10.2.4 共享存储与消息传递编程模型的编程复杂度
采用共享存储与消息传递编程模型编写的并行程序是在多处理器并行处理系统上运行的。先了解一下多处理器的结构特点,可以更好地理解并行编程模型。从结构的角度看,多处理器系统可分为共享存储系统和消息传递系统两类。在共享存储系统中,所有处理器共享主存储器,每个处理器都可以把信息存入主存储器,或从中取出信息,处理器之间的通信通过访问共享存储器来实现。而在消息传递系统中,每个处理器都有一个只有它自己才能访问的局部存储器,处理器之间的通信必须通过显式的消息传递来进行。消息传递和共享存储系统的结构如图10.3所示。从图中可以看出,在消息传递系统中,每个处理器的存储器是单独编址的;而在共享存储系统中,所有存储器统一编址。典型的共享存储多处理器结构包括对称多处理器机(Symmetric Multi-Processor,简称SMP)结构、高速缓存一致非均匀存储器访问(Cache Coherent Non Uniform Memory Access,简称CC-NUMA)结构等。
图10.3 消息传递(左)和共享存储系统(右)
在消息传递编程模型中,程序员需要对计算任务和数据进行划分,并安排并行程序执行过程中进程间的所有通信。在共享存储编程模型中,由于程序的多进程(或者线程)之间存在一个统一编址的共享存储空间,程序员只需进行计算任务划分,不必进行数据划分,也不用确切地知道并行程序执行过程中进程间的通信。MPP(Massive Parallel Processing)系统和机群系统是消息传递系统。消息传递系统的可伸缩性通常比共享存储系统要好,可支持更多处理器。
从进程(或者线程)间通信的角度看,消息传递并行程序比共享存储并行程序复杂一些,体现在时间管理和空间管理两方面。在空间管理方面,发送数据的进程需要关心自己产生的数据被谁用到,而接收数据的进程需要关心它用到了谁产生的数据;在时间管理方面,发送数据的进程通常需要在数据被接收后才能继续,而接收数据的进程通常需要等到接收数据后才能继续。在共享存储并行程序中,各进程间的通信通过访问共享存储器完成,程序员只需考虑进程间同步,不用考虑进程间通信。尤其是比较复杂的数据结构的通信,如struct{int*pa;int* pb;int*pc;},消息传递并行程序比共享存储并行程序复杂得多。此外,对于一些在编程时难以确切知道进程间通信的程序,用消息传递的方法很难进行并行化,如{for(i,j){ x=…;y=…;a[i][j]=b[x][y];}}。这段代码中,通信内容在程序运行时才能确定,编写代码时难以确定,改写成消息传递程序就比较困难。
从数据划分的角度看,消息传递并行程序必须考虑诸如数组名称以及下标变换等因素,在将一个串行程序改写成并行程序的过程中,需要修改大量的程序代码。而在共享存储编程模型中进行串行程序的并行化改写时,不用进行数组名称以及下标变换,对代码的修改量少。虽说共享存储程序无须考虑数据划分,但是在实际应用中,为了获得更高的系统性能,有时也需要考虑数据分布,使得数据尽量分布在对其进行计算的处理器上,例如OpenMP中就有进行数据分布的扩展制导。不过,相对于消息传递程序中的数据划分,考虑数据分布还是要简单得多。
总的来说,共享存储编程像BBS应用,一个人向BBS上发帖子,其他人都看得见;消息传递编程则像电子邮件(E-mail),你得想好给谁发邮件,发什么内容。
下面举两个共享存储和消息传递程序的例子。第一个例子是通过积分求圆周率。积分求圆周率的公式如下:
在上式中,N值越大,误差越小。如果N值很大,计算时间就很长。可以通过并行处理,让每个进程计算其中的一部分,最后把每个进程计算的值加在一起来减少运算时间。图10.4给出了计算圆周率的共享存储(基于中科院计算所开发的JIAJIA虚拟共享存储系统)和消息传递并行程序核心片段的算法示意。该并行程序采用SPMD(Single Program Multiple Data)的模式,即每个进程都运行同一个程序,但处理不同的数据。在该程序中,numprocs是参与运算的进程个数,所有参与运算的进程都有相同的numprocs值;myid是参与运算的进程的编号,每个进程都有自己的编号(一般并行编程系统都会提供接口函数让进程知道自己的编号)。例如,如果有4个进程参与运算,则每个进程的numprocs都是4,而每个进程的myid号分别为0、1、2、3。在共享存储并行程序中,由jia_alloc()分配空间的变量pi是所有参与运算的进程共享的,所有进程只有一份,其他变量都是每个进程局部的,每个进程都有一份,每个进程根据numprocs和myid号分别计算部分圆周率值,最后通过一个临界区的机制把所有进程的计算结果加在一起。jia_lock()和jia_unlock()是一种临界区的锁机制,保证每次只有一个进程进入这个临界区,这样才能把所有进程的结果依次加在一起,不会互相冲掉。在消息传递并行程序中,由malloc()分配空间的变量每个进程都有独立的一份,互相看不见。每个进程算完部分结果后,通过归约操作reduce()把所有进程的mypi加到0号进程的pi中。
图10.4 积分求圆周率算法示意
第二个例子是矩阵乘法。矩阵乘法的算法大家都很熟悉,这里就不介绍了。图10.5给出了共享存储和消息传递并行程序。同样,由jia_alloc()分配的变量所有进程共享一份,而由malloc()分配的变量每个进程单独一份,因此在这个程序中消息传递并行程序需要更多的内存。在共享存储并行程序中,先由0号进程对A、B、C三个矩阵进行初始化,而其他进程通过jia_barrier()语句等待。barrier是并行程序中常用的同步方式,它要求所有进程都等齐后再前进。然后每个进程分别完成部分运算,再通过jia_barrier()等齐后由0号进程统一打印结果。消息传递并行程序与共享存储并行程序的最大区别是需要通过显式的发送语句send和接收语句recv进行多个进程之间的通信。先由0号进程进行初始化后发送给其他进程,每个进程分别算完后再发送给0号进程进行打印。在消息传递并行程序中要详细列出每次发送的数据大小和起始地址等信息,0号进程接收的时候还要把从其他进程收到的数据拼接在一个矩阵中,比共享存储并行程序麻烦不少。
图10.5 矩阵乘法算法示意