并行计算入门

正在学习并行计算的基础知识,因为不确定下一次使用相关知识是什么时候,所以打算写一份长篇笔记来保证当我正在使用这方面知识的时候不会忘掉。这篇文章基于《并行计算导论》。只关注并行计算的关键知识点。


目录

文章目录

    • @[toc]
  • 1、并行编程平台
    • 1.1、隐式并行
      • 1.1.1、流水线与超标量执行
      • 1.1.2、超长指令字处理器
    • 1.2、内存性能局限
      • 1.2.1、使用高速缓存将降低内存延迟
      • 1.2.2、内存带宽的影响
      • 1.2.3、躲避内存延迟的方法
    • 1.3、并行计算平台剖析
      • 1.3.1、逻辑组织
      • 1.3.2、硬件组织(通信模型)
    • 1.4、并行平台的物理组织
      • 1.4.1、理想的并行计算机体系结构
      • 1.4.2、并行计算机互联网络
      • 1.4.3、网络拓扑结构
      • 1.4.4、高速缓存一致性
    • 1.5、并行计算机的通信成本
    • 1.6、互联网络的路由选择机制
  • 2、并行算法设计原则
    • 2.1、预备知识
      • 2.1.1、任务依赖图
      • 2.1.2、粒度、并发性与任务交互
      • 2.1.3、进程与映射
    • 2.2、分解技术
      • 2.2.1、递归分解
      • 2.2.2、数据分解
      • 2.2.3、探测性分解
      • 2.2.4、推测性分解
      • 2.2.5、混合分解
    • 2.3、任务和交互的特点
    • 2.4、负载均衡
      • 2.4.1、静态映射
      • 2.4.2、动态映射方案
    • 2.5、减少交互开销的办法
    • 2.6、并行算法模型
  • 3、基本的通信操作
    • 3.1、广播与归约
      • 3.1.1、在换或者线性阵列上的广播与规约操作
      • 3.1.2、格状网络
      • 3.1.3、超立方体网络
    • 3.2、多对多的广播规约
      • 3.2.1、线性阵列与环
      • 3.2.2、格网与超立方体
      • 3.2.3、全归约操作
      • 3.2.4、前缀和操作
    • 3.4、散发操作
    • 3.5、多对多私自通信
      • 3.5.1、在环状网络中使用多对多私自通信
      • 3.5.2、在格网中使用多对多私自通信
      • 3.5.3、在三维超立方体中使用多对多私自通信
    • 3.6、循环位移
  • 4、并行程序的解析建模
    • 4.1、并行程序的开销来源
    • 4.2、并行系统的性能度量
      • 4.2.1、执行时间
      • 4.2.2、总并行开销
      • 4.2.3、加速比
      • 4.2.4、效率
      • 4.2.5、成本
    • 4.3、粒度对性能的影响
    • 4.4、并行系统的可扩展性
    • 4.5、最小执行时间和最小成本最优执行时间
  • 5、使用消息传递的方式编程
    • 5.1、基本概念
    • 5.2、发送和接收操作
      • 5.2.1、阻塞式消息的发送
      • 5.2.2、非阻塞式消息发送
    • 5.3、消息传递接口-MPI
      • 5.3.1、MPI阻塞式通信的使用
      • 5.3.2、MPI非阻塞式通信的使用
  • 6、共享地址空间的编程
    • 6.1、pThread线程编程
      • 6.1.1、线程的创建与终止
    • 6.2、openMP的编程

1、并行编程平台

1.1、隐式并行

1.1.1、流水线与超标量执行

因为处理器在执行指令的时候实际上包含了不同的互不干涉的子任务。这些子任务因为互不干涉,所以这就使得处理器可以在同一时间执行不同指令的不同子任务,从而达到并行的效果,这就是流水线,处理器的流水线可以不只有一条。而处理器在一个周期发送多条指令的能力被称作超标量执行。比如下面这个例子。

并行计算入门_第1张图片

并行计算入门_第2张图片

这是一个两个流水线、一个周期发送两个指令的处理器执行的流程。能够超标量执行的主要原则就是相关性。
两个指令如果有相关性,比如一个指令基于另一个指令的结果,那么就不能一起发送。我们可以通过指令的重排来改善并行性,指令重排需要处理器具有“乱序发射”的能力。这种“一个指令基于另一个指令的结果”的相关性被称作真实数据相关性。还有一种叫做资源相关性,会在两个流水线共享一个资源(元器件)的时候产生。除了这两个之外还有分支相关性也就是因为存在分支语句,而不知道下一步应该执行什么导致的。

超标量执行的效率与当前指令的并行潜力相关。而对于一个元器件一个周期的部分未利用被称作水平浪费,完全未利用叫做垂直浪费。下面的例子我们可以看到加法器的利用:

并行计算入门_第3张图片

1.1.2、超长指令字处理器

如果我们在编译的时候就分析出可以并行的指令,并且将这些指令组合在一起作为一个“超长指令字”一口气发给处理器,那么我们也可以进行指令级并行。

1.2、内存性能局限

内存有两个非常重要的性能,一个是“内存延迟”,一个是“内存带宽”。

1.2.1、使用高速缓存将降低内存延迟

高速缓存是低延迟的处理器,一般集成在处理器上。如果我们可以将需要的数据先一口气放入高速缓存,然后然后用处理器从高速缓存中读取并计算,那么这个延迟就远比每次从内存中取要低。相当于把延迟合并在一开始内存-高速缓存这一次IO中了。

1.2.2、内存带宽的影响

内存带宽变大相当于单位时间可以取更多的数据,也是减少了处理器的等待,从而提高的性能。

为了充分利用内存带宽,我们需要让内存中连续的数据字被连续的指令使用。这种空间本地性利用了内存顺序读取更块的特点。

这里就涉及一个经典的跨距访问的问题,即矩阵的按列访问和按行访问的问题。按行可以充分利用数据的内存上的空间连续性。利用连续访问来保证内存带宽。

1.2.3、躲避内存延迟的方法

躲避内存延迟有两种种方法,一种是预取,一种是多线程。这两种方式虽然会降低访存的延迟,但是实际上这两种方式会显著增加内存带宽的压力。对于多线程来说分配给每一个线程的缓存块变少了,那么很多数据需要从存储中读取,压力就很大,而预取相当于将数据提前进行读取,和实际的请求一起访问内存,那么这也需要更多的内存带宽。

总而言之这两种躲避内存延迟的方式都是相比串行的方式提前进行访存,从而躲避延迟。

1.3、并行计算平台剖析

并且系统在设计上是有共性的,一般分为两个部分,一个是逻辑组织(控制结构),一个是物理组织(通信模型)。前者是程序员严重的平台,后者是实际物理的平台。

1.3.1、逻辑组织

这是程序员眼中的结构。一种经典的并行被称作单指令多数据流结构(SIMD)。同样的指令会被所有的部件同时执行。

并行计算入门_第4张图片

所有的PE都会在同时执行同一个指令。当然我们可以使用“操作屏蔽码”来让某些数据行不执行操作,来支持if-else语句。

与SIMD的结构形成对比的是MIMD结构。也就是多指令多数据流。这种结构有一种变体叫做SPMD,即单程序多数据。每个处理单元运行同一个程序的不同实例。

并行计算入门_第5张图片

SIMD结构相对更加简单,只需要一个全局控制器就好了,MIMD需要在每个处理器上放一个程序副本和操作系统。

SIMD最大的缺点是在执行分支语句的时候,不是当前分支的处理器会暂停运行。

并行计算入门_第6张图片

我们可以看到在“B=0”这个分支的时候只有B=0的处理器会运行,不在这个分支的处理器会停止。而else分支也是同样的道理。这种分支会导致系统的利用率不足。

1.3.2、硬件组织(通信模型)

在并行系统中,并行任务之间需要通信。主要通过共享数据空间和交换消息。

共享数据空间分为非常多种情况。根据访问本地地址空间和非本地地址空间的异同,分为一致内存访问和非一致内存访问两类。具体如下图所示,p可以理解为是一个计算节点,m是内存,c是缓存。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BiLsFrpD-1610969602160)(https://ws2.sinaimg.cn/large/006tKfTcgy1frhdsxuiq6j30r40ggacq.jpg)]

对于共享地址的方案来说,对于同一个地址的操作有互斥锁的问题,对于有计算节点有自己cache的方案来说,我们保证“高速缓存一致性”的问题。也就是对同一个内存字的多个缓存副本的并发操作的一致性问题。

除了共享地址空间之外,还有一种任务之间的交流方式就是消息传递。

消息传递应用在每个节点都有自己的地址空间的场景中,这在现代的分布式系统中非常常见。每个节点都有一个地址,然后不同的节点使用这个地址来发消息通信。

1.4、并行平台的物理组织

1.4.1、理想的并行计算机体系结构

最理想的事完全随机访问计算机,也就是每一个处理器都可以访问内存的任何一个位置。只有在访问相同位置的时候我们才考虑互斥问题。

但是这种方式结构非常复杂,需要将处理器通过一系列开关连在内存上,针对每一个内存子都需要一个开关,这样子开关网络就会非常复杂。

1.4.2、并行计算机互联网络

这是一种多个节点之间的数据传输机制。

网络也分为静态网络和动态网络,静态网络由点对点之间的连接构成,动态网络在网络上增加一系列开关。

并行计算入门_第7张图片

1.4.3、网络拓扑结构

第一种是基于总线的网络,包含一个共享存储介质。

并行计算入门_第8张图片

这种方式会让所有的计算设备都访问同一个内存,无法应对大负载的情况。

还有一种叫做交叉开关网络,有多个存储区。这样子就在一定程度上避免了阻塞。

并行计算入门_第9张图片

但是这种交叉开关网络开关的复杂度是很高的,从成本的角度来说,扩展性不高。如果元器件数量过多,那么也很难实现比较高的传输速度。

在这两种网络之间有一种叫做多级网络。比较常用的是一种叫做omega网络的网络。这种网络是分层的。

并行计算入门_第10张图片

那么中间这多层网络需要做到的一点就是,可以让每一个处理器和存储器互相之间可以通信。在《并行计算导论》中,这一部分的描述并不太清楚。我们举一个例子,我们查找了另外一个链接:omega网络;

并行计算入门_第11张图片

这张图展现了010这个输入端的是如何将信息传送到任何一个输出端的。我们发现消息在每一个节点上都可以分发到两个不一样的节点上。这样子到达输出端的时候就可以完全分发到每一个接口上了。那么这就需要一个对应,那就是相邻的两个输出的输出一定不能相邻。这被称作“完全混洗互联”。

并行计算入门_第12张图片

比如说这种对应就是一种“完全混洗互联”。000与001的输出分别是000与010,这样子就可以保证将一个消息分散到两个节点上。omega网络的每两层之间都有一个“完全混洗互联”。假设总的输入端个数与输出端个数都是 p p p,那么每一层需要的开关就是:

p / 2 p/2 p/2

需要的网络层数是:

l o g 2 p log_{2}^{p} log2p

需要的开关个数是远小于交叉开关网络的,在这个例子中一共需要12个开关,相比之下交叉开关网络需要64个开关。但是实际上omega网络也有阻塞的可能性的,比如说在下面这个例子中,两个计算节点和存储区的通信都是用了AB这一段网络。

并行计算入门_第13张图片

有一种理想化哇网络叫做全连接网络,这种网络的在两两节点之间都建立了连接。不会出现任何被阻塞的情况。

并行计算入门_第14张图片

星型网络依赖于中间的节点。

并行计算入门_第15张图片

线性阵列网络

并行计算入门_第16张图片

多维网格

并行计算入门_第17张图片

k-d格网是一种拓扑结构,有d维,每一维有k个节点。实际上线性阵列就是一种极端的k-d格网。此外还有一种称为超立方体结构的网络是k-d格网的另一个极端。

并行计算入门_第18张图片

最后是基于树的网络。

并行计算入门_第19张图片

并行计算入门_第20张图片

这种网络在较高层的通信有较大的瓶颈,所以增加靠近根部的链路的数量,构成“胖树”就可以解决这个问题。

1.4.4、高速缓存一致性

在带缓存的多处理器系统中,在共享内存中的数据实际上在每个缓存中都可能存在一个副本。那么实际上这就存在一个一致性问题。

主流的方法有两种,某一个节点对于某一个变量的更新会使其他的缓存失效,或者对于一个缓存更新也会激活对于其他变量的更新。前者被称作“失效”策略,后者被称作“更新策略”。

并行计算入门_第21张图片

实际上这个问题还是更加复杂一点。实际上高速缓存的基本单位是高速缓存行。前文说的“变量”,实际上就是“高速缓存行”。一个高速缓存行包含了多个变量。对于一个变量的修改,会导致整个缓存行失效,这其实是很不值当的。在更新协议中这种情况会好一些。

在高速缓存一致性计算机中通常依靠无效协议。我们需要在无效协议中维护一致性。这种维护一致性状态的方法很多,我们举一个例子。比如我们可以维护三种状态。一种是脏、一种共享,一种是无效。干净的数据在共享状态。一旦有数据被修改,那么在被修改的节点上这个数据会被标记为“脏”,提醒节点不要从共享内存读取数据,而是从缓存中读取数据。在其他节点上这个数据会被标记为“无效”,来直接抛弃这些数据。

下面我们将介绍几个保证失效一致性的硬件实现。

高速缓存监听系统

这是一种基于总线网络的监听系统。

并行计算入门_第22张图片

这个网络会监听处理器对于缓存块中数据的操作。并且修改高速缓存的标记。这种方式简单好用,但是当所有的处理器都对同一位缓存进行读写操作就会导致处理器间的一致性协调,从而有较大的开销。

基于目录维护的方案

前文所讲的是一种基于广播的方式,这是在总线网络中非常好用的,但是广播实际上并不是一种性能较优的选择。那么基于目录的方式我们就可以得到地址空间所有数据的位图,从而可以通过点对点的通信来进行沟通。比如写入操作不用再进行广播,而是找也缓存这个数据块的计算节点进行沟通。目录可以在全局维护一个大的目录,也可以在每个计算节点维护自己的目录。这种更精确的元数据管理和沟通方式可以降低通信开销。

并行计算入门_第23张图片

1.5、并行计算机的通信成本

并行计算的通信成本主要来自于三个方面,首先是起点的节点对于消息的处理时间,然后是在路由节点的延迟,叫做每站时间。最后是消息在链路上的传送速度,叫做每字传送时间。

将数据分包传送可以有效地增加通信的效率。每一个路由节点不需要接收到完整的数据再进行转发,这样子就可以提高通信的效率。但是将数据分包之后需要为每个数据包增加包头,所以说这也是个权衡的过程。

并行计算入门_第24张图片

在信息分包之后,我们既可以将数据通过多个路由路径,也可以强制让数据按照固定的顺序和同一个路径进行传输。前者叫做包路由选择,后者叫做直通路由选择。

包路由选择比较适合高动态和高错误率的网络。将数据用不同的链路传输,不把“鸡蛋放在一个篮子里”。在并行计算机网络中,更适合使用直通路由选择,因为并行计算机网络中,错误率比较低并且因为直通路由选择在路由选择和顺序上都是强制固定的,所以可以减少很多路由选择的开销和数据包的大小(直通路由使用的是比数据包小得多的数据片)。

我们可以通过一些方式来降低消息传送的成本。

  • 大块通信:减少准备消息和建立连接的时间
  • 减少数据大小
  • 减小数据传送的距离

1.6、互联网络的路由选择机制

路由选择机制可以分为两种,一种是单纯只是基于最短路径的静态路由选择,还有一种是会考虑到当前网络阻塞状态的动态路由选择。

2、并行算法设计原则

2.1、预备知识

2.1.1、任务依赖图

并行算法的主要依靠将一个大任务进行分解,并且并行执行这个大任务的多个不相关的小任务来提高性能。但是任务有时候是不能随意拆分和并行处理的。因为任务之间存在依赖关系,一些任务可能需要依赖其他任务的结果才可以进行。

这就有了任务依赖图。比如对于一个数据库的查询:

并行计算入门_第25张图片

下面的任务要依赖上面的任务的结果,那就不能完全做到并行。

2.1.2、粒度、并发性与任务交互

任务可以进行大块的分割,也可以进行小块的分割,并以此来并行,这就是分割粒度。并发度是一个和粒度相关的概念,最大可同时进行的任务数量被称作“最大并发度”。我们将之前的那个例子抽象出来。

并行计算入门_第26张图片

圆圈代表任务,圆圈中的数字代表这个任务的工作量。这里就有一个“关键路径”的概念,所谓关键路径就是在这个有向无环图中权重最大的路径。而这里有一个评价并发度非常重要的指标,叫做“平均并发度”。所谓平均并发度就是:

总 工 作 量 / 关 键 路 径 工 作 量 总工作量/关键路径工作量 /

任务之间是有交互的,这种交互不仅仅是“一个任务依赖与另外一个任务的结果”,这种交互是一种更大的范围,可能是结果,也可能是某一个过程量。

有这种交互也就有“任务交互图”。

并行计算入门_第27张图片

2.1.3、进程与映射

在并行计算中的进程和操作系统中所讲的进程的概念是不一样的。在并行计算中,进程是一种抽象的实体,用来执行任务的抽象实体。而将任务映射到进程上的机制被称作映射。

一个好的映射算法除了要让不同的任务充分并行,并且我们需要将有较多交互的任务分配给同一个进程。必须防止任务之间的通信引起进程之间的通信。一个进程可以理解为一个CPU核,也可以是一个CPU,也可以是一个计算节点。而任务之间的交互,反映到最后就是这些物理硬件的通信,这种开销是需要被避免的。

2.2、分解技术

分解技术主要分为这么几种,分别是递归分解、数据分解、探测性分解、推测性分解。

2.2.1、递归分解

实际上可以递归的算法都有很大概率可以进行递归分解。只要我们可以画出递归树。就可以又递归树自下而上进行分解计算。越分解任务越小,也就可以获得越来越大的并发性。

比如说递归版的快速排序。

并行计算入门_第28张图片

当然有些使用迭代的算法我们可以改造成递归程序来活得递归分解。

2.2.2、数据分解

递归分解更像是一种基于任务的分解,而数据分解就是基于要处理的数据的分解。基于数据的分解有有下面这几种形式。

基于输出的分解

如果存在多个输出,并且每个输出都是一个与输入相关的函数,那么我们就可以使用基于输出的分解方式。比如矩阵的乘法,实际上输出矩阵的格式是可以提前知道的,而输出矩阵的每一位都是根据输入矩阵的特定某行某列的信息得到的。那么我们就可以使用基于输出的分割方式。

基于输入的分解

如果输出只有一个或者输出捉摸不定,那么我们就可以使用基于输入的分解,我们将输出数据分割成几个部分,分别计算,然后在根据计算的结果进行进一步的归并。比如寻找一个数列最小值的程序,这是一个没有办法进行输出分解的算法。但是我们可以将数组分段,然后并发地寻找每一段的最小值,然后再将结果进行归并,即在这些每段的最小值中选出一个最最小值。这就是基于输出分解的分别计算和归并过程。

同时基于输入和输出的分解

前两种分解方式的结合。

根据中间结果的分解方式

有时候输入和输出的分解方式并不能将并发的效能最大化,所以我们根据中间结果来进行分解。

拥有者-计算规则

基于输入和输出的分解方式也被称作“拥有者-计算规则”。也就是说划分的每一个部分都能执行这一部分所拥有数据的所有计算。

2.2.3、探测性分解

针对“解空间搜索问题”,我们可以使用探测性分解。比如经典的迷宫问题,我们一开始并不知道从起点怎么可以走到终点,也并不知道到底能不能从起点走到终点,我们只能暴力地遍历可以走的各个方向走一步,然后再根据往各个方向走一步的中间结果再进一步暴力遍历。虽然每一步都不能使我们知道结果,但是每一步都使我们往结果更进一步了。这个过程我们可以构成一个搜索树,树的节点就是一个问题的起始状态,而连线的两个点代表了一个状态到另一个状态的变化。随着搜索的过程不断进行,整个搜索树会越来越大,我们可以根据我们需要的并行规模,在搜索树扩大到某一层的时候,以某一层的中间结果作为并行任务的起点进行解空间搜索。那么我们就一定可以在一个子树也就是并行任务中找到结果的。

并行计算入门_第29张图片

2.2.4、推测性分解

当计算可能会出现多个分支,并且这些分支的选择依赖于之前其他计算的结果,那么我们就可以使用推测性分解。我们根据之前计算结果的各种可能,提前计算各个分支,然后等到之前计算的结果出来之后再选择一个分支的结果。比如,我们有一个switch语句,我们可以根据已有的case,提前并行计算出所有的可能性,然后等待程序可以计算出switch的条件时候,我们根据条件选择一个已经计算完的结果就好了。

2.2.5、混合分解

我们可以将各种分解方式混合起来使用。

2.3、任务和交互的特点

静态与动态

这是交互的一个最核心的概念,所谓静态交互,就是任务的依赖关系是在程序运行之前就可以知道的,动态交互就是程序运行之前不知道依赖关系。

只读与读写

表示任务之间的共享资源是只有读还是既有读又有写。

单向与双向

表示一对任务之间的交互会不会影响到其他的交互。如果没有影响就是单向的,有影响就是双向的。

2.4、负载均衡

并行算法的效率的关键来自于负载均衡。因为在并行算法中,所有任务都要尽可能地同步运行,如果任务分配不均,那么在一定阶段内,进行快的进程就要等待运行慢的进程。这无疑是一种浪费。并行算法的开销主要来自于两个方面,一个是进程之间的通信,一个是进程空闲等待的时间。这两个方面其实有时候是矛盾的,如果我们为了减少进程间的通信而把需要通信的任务放到同一个进程里,那么实际上就会加剧这种负载的不均衡,从而扩大了运行较快的进程的等待时间。

为了良好的负载均衡,我们有两类“任务-进程”映射方式。

静态映射

如果我们在任务执行之前就能知道任务的依赖关系,那么我们就可以提前进行固定的静态映射,这种映射方式比较方便程序设计与编程。

动态映射

如果在程序运行之前我们不能知道任务的依赖关系,那么我们就可以进行动态映射,这种映射需要在程序的运行过程中动态调整任务和进程之间的映射关系,其中不乏在进程间进行数据的迁移,这种更大的通信代价可能会抵消动态映射的好处。

2.4.1、静态映射

以数据划分为基础的映射

对于矩阵和数组来说,以数据划分为基础的最简单好用的映射就是块分配,也就是将空间上连续的数据分给不同的任务。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qlkO4PK0-1610969602174)(https://i.loli.net/2018/05/20/5b0152eae09d0.jpg)]

当然,单纯的将数据空间进行均匀分配未必总是有好结果,因为每一个块对应的任务量可能是不一样的。所以我们也需要一种更为复杂的分配模式。比如下面的这么一个例子。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DvPB5BrN-1610969602174)(https://i.loli.net/2018/05/20/5b015b104b298.jpeg)]

我们可以看到,随着数据越接近右下角,任务量就越大。如果我们还是使用之前较为平均的块分配,那么效果未必很好,右下角的任务会承担过多的计算量,那么这也就不是一个好的负载均衡。所以我们使用一种叫做块循环分配的方式。这种分配的方式就是将数据集分为远比进程数量更多的部分,然后向轮训一样分配给各个进程。最后的效果就好像下图所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ucUDXXi6-1610969602175)(https://i.loli.net/2018/05/20/5b019a3133f5a.jpeg)]

我们可以看到,虽然只有4个进程,但是整个矩阵被分为16个部分,并且4个进程均匀地负责矩阵的各个部分。在任务量最大的右下角上,四个进程共同负责可以部分的计算,可以说这种负载均衡就是更加优化的。

随机块分配是一种比块循环分配更为彻底的一种分配方式。这种分配方式的精髓依旧在于将任务划分为远多于进程数量的个数。然后将任务随机分配到新的进程中。当我们不知道每一个任务的任务量的时候这是一种很好的方法。

任务依赖映射

除了我们可以通过划分数据来进行映射之外,我们还可以通过任务来进行这种映射。基于任务的映射方式在可以画出任务依赖图的静态映射中非常常见。这种任务分配方式的主要矛盾也是在进程空闲和进程之间的通信。我们可以根据任务之间的依赖关系将有需要交互的任务放到同一个进程里。

并行计算入门_第30张图片

上面的这种二叉树任务依赖图是一种分配的经典模式,在叶节点每个进程负责一个任务,而在非叶节点上也用相关的进程来负责,从而减少了进程之间的交互。

分级映射

有时候基于任务和基于数据分割的映射方式可以都是不尽理想的。对于上文的任务映射来说,可能树的根部和树的叶子结点对应的任务量都是不一样的。那么如果都使用单一进程来处理树中的所有任务,那么这就会遇到负载不平衡问题。并且在任务依赖图中并不是每一层都适合任务依赖或者数据分配。可能在每一层上要使用不一样的算法。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nPjt5rsM-1610969602176)(https://i.loli.net/2018/05/21/5b029c4e8484c.jpeg)]

我们可以看到,上面这个例子就是在每一层都分配不一样的进程,越接近根部任务越大,所以使用分级映射就可以将所有的进程再次全部利用上。

2.4.2、动态映射方案

当我们并不知道任务依赖图或者使用静态映射方案难以进行负载均衡的时候,那么我们就可以选用动态映射方案。

集中式映射管理

在这种映射方案中,我们需要以一个进程专程来进行任务和进程的管理。这个进程会检测其他所有工作进程的工作状态,每当有进程完成了当前的任务,就会给其分配新的任务。这看起来是一个非常合理的方式,但是实际上新任务的分配往往意味着需要的数据的传输,所以实际上任务的分配是需要比较大开销的,有时候这就是一种性能瓶颈。解决这种问题的方式有很多,比如说一口气给进程分配一组任务。

分布式管理

在分布式的场景中,每一个进程在一开始都是会分配一定的任务集,然后通过进程之间的任务交换来达到负载均衡的效果。

2.5、减少交互开销的办法

最大化数据本地性

对于一个进程来说,如果一个进程需要的资源都尽可能在本地,那么就可以尽可能减少交互过程。我们可以通过尽可能减少交互的数据量和交互的频率来减少交互的开销。

最小化争用与热点

在共享地址空间的架构中,对于同一个存储块的访问是难以进行并行的,最小化争用与热点需要我们重新设计并行程序,减少在同一时刻对于同一个存储块的争用。

使计算与交互重叠

我们可以提前进行交互操作。让计算运行到要交互的部分的时候,需要交互的数据已经交互完毕了,从而隐藏交互时间。

复制数据

对于很多只读数据,如果可以提前复制到计算节点的私有存储中,那么就可以减少共享存储空间的资源竞争。

使用最优的聚合操作

对于中间结果的归并操作往往会带来很大的交互开销,所以我们需要调整聚合操作,让交互尽可能少。

使一些操作与另一些操作重叠

道理与交互和计算重叠时一样的。

2.6、并行算法模型

数据并行模型

与静态映射的基于数据划分的映射相对应。是数据并行的结果。将数据分成多个部分,然后并行进行计算。

任务图模型

根据任务依赖图产生的模型。与静态映射的任务映射很像。

工作池模型

基于动态映射的计算模型。整个的映射方式不是固定的,只要有工作进程完成了自己的任务,就从工作池中取下一个要完成的任务。

主从模型

在主从模型中有管理者和工作者两个部分。管理者进程负责不断产生任务,而工作者进程负责不断处理管理者进程产生的任务。

流水线模型

在流水线模型中,数据流会通过一系列的任务流水线。在一个数据流中通过执行流水线中的不同任务就是流并行。

3、基本的通信操作

3.1、广播与归约

这是两个反向的操作,广播就是将一个计算节点的数据复制到所有其他节点上。归约是利用多个节点的数据进行整个计算,最后存在一个目标节点上。

在下面这个图中,M代表缓冲区,p代表不同进程的数据。

并行计算入门_第31张图片

3.1.1、在换或者线性阵列上的广播与规约操作

并行计算入门_第32张图片

这里展现了一个环形网络,如果我们要将0节点上的数据广播给所有的数据。我们需要怎么做才能不导致网络的拥塞。

答案是下面这张图。

并行计算入门_第33张图片

首先是传到足够远的节点4,然后在0和4上做并行的分发。虚线上的数字是发送信息的时序,虚线代表一次发送操作。这种并行完全合理,因为并不会出现两次通信使用同一个链路的情况。

假设我们一开始将数据从0传到1,然后再做分发,就没有办法达到这种效果了。因为如果使用0和1的数据分别发送给节点2和3,那么1与2之间的链路就有阻塞。所以第一跳的传输必须选一定距离的是有道理的。

当然,反向就是一个规约操作。

并行计算入门_第34张图片

而同样的方法也适用于线性阵列,假设我们将0与7之间的网络断开,这一切还是成立的。

3.1.2、格状网络

对于格状网络来说,我们可以分别把横向部分和纵向部分当做多个线性阵列网络,来进行广播和规约操作。

并行计算入门_第35张图片

3.1.3、超立方体网络

对于超立方体来说,广播使用的一样的道理,对于格状网络来说广播的过程分为两个维度,分开进行,对于三维网络来说无非就是多一个方向。对每一个方向的节点都是使用线性阵列的方式。这样子无论是多少维网格都可以通过各个方向的线性阵列去递推。

3.2、多对多的广播规约

并行计算入门_第36张图片

每个进程都会把信息分配给其他所有的进程,相当于很多的一对多广播在同时进行。
而规约过程会在每个节点上都得到一个规约的结果。

3.2.1、线性阵列与环

在线性阵列与环中进行多对多广播的方式很简单,就是将当前节点的需要发送的信息不断向一个方向传递就好了,就好像一个循环的传送带。每个节点都把收到的信息以及一开始拥有的信息按照0→1→2→3→4→5→6→7→0的反向传送,

并行计算入门_第37张图片

而将上面这个图反过来就是一个规约过程。

3.2.2、格网与超立方体

将线性阵列的多对多广播方式迁移到更高维的网络结构上的方法和一对多的广播方式是一样的,都是现在一个维度上面进行广播,然后在更高维度上将当前维度的已经广播的内容再进行一次广播。大致一样,但是稍稍有点区别。其实的节点并不是从单一节点开始的,而是在一个方向的所有边开始的,也就是一个面上的所有点开始的。

并行计算入门_第38张图片

3.2.3、全归约操作

全归约操作是归约操作的一个变体。相当于是先对某一个节点进行一个归约操作,然后在把这个归约的结果进行一对多广播,复制到所有的节点。

有时候我们可以使用多对多广播来进行全归约操作。

我们还是使用按个立方体为例,我们假设要对0号节点做将所有节点数据的加和操作,然后再把加和的结果广播给所有的节点。我们可以修改一下多对多的广播操作,我们在每个节点上分别对收到的数据进行加和,也可以得到一样的效果。

3.2.4、前缀和操作

什么是前缀和操作?

对于序列(0,1,2,3,4,5,6,7),前缀和就是(0,1,3,6,10,15,21,28)。也就是某一位之前的所有节点的和。

我们可以在三维网格中实现前缀和操作。

并行计算入门_第39张图片
并行计算入门_第40张图片

我们只要修改一下多对多广播的算法就好了,实际上我们还是进行一样的多对多广播,但是我们只会将从低位节点传来的数据做加和。并且将加和之后的结果做传给下一个节点。

3.4、散发操作

并行计算入门_第41张图片

散发操作就是将一个节点的不同数据散发给不同的节点。散发操作的反义词是收集操作。就是将每个节点不同的数据收集到一个节点上。

在算法上和一对多广播很像。只是在每一个步骤中都把一半的信息给目标节点,这样子就分发完了。

并行计算入门_第42张图片

与一对多广播的通信过程是一模一样的,但是数据传输的内容是不一样的,并不全部传输,而是每次只发一半的内容。

3.5、多对多私自通信

并行计算入门_第43张图片

就好像散发操作之于一对多广播一样,多对多私自通信相当于每个节点都广播不同的内容给其他所有节点。

3.5.1、在环状网络中使用多对多私自通信

多对多私自通信与多对多广播的通信方式非常像,在一个环状网络中都是向传送带一样,将数据发送给相邻节点,然后循环起来。

并行计算入门_第44张图片

与多对多广播不同的是,发送信息的源节点会把需要发送的所有信息打包成一个大包来进行发送,所有信息沿相同方向依次传递。每个节点值从这个大包中截取他自己需要的那个部分。

3.5.2、在格网中使用多对多私自通信

在格网中也可以使用相同的方法来进行通信。我们首先先在一个方向上将数据分配好,然后再分别从纵向进行。

并行计算入门_第45张图片

我们以上面这个图为例。我们首先在格网中的横向进行一次多对多广播,将每一列需要的数据传递起来。比如第一组的6号节点,在“传送带”上不仅仅要取到传给自己的信息,还要取到0个3这两个节点的信息。然后我们再在纵向进行一轮通信就好了,在这一轮通信中我们只需要让节点各自获取传给自己的数据就好了。

3.5.3、在三维超立方体中使用多对多私自通信

并行计算入门_第46张图片

对于立方体状的网络来说也是同样的道理,我们需要对X、Y、Z三个方向分别进行通信。这个过程和数学的定积分有点像。一开始如果是X方向的通信,那么通信的不仅仅需要获取当前节点的,还需要获取当前节点所在YZ平面的所有节点。然后按照格网的通信方式就好了。

3.6、循环位移

循坏位移是一种特殊的广播操作。对于一个p个节点的网络来说,循环位移操作就是将i号节点的数据给(1+q)%p的节点。相当于是一组依次挪动。

4、并行程序的解析建模

这个章节主要探讨并行算法的性能。我们通常会期待使用n个处理器进行计算使用的时间可以缩短为最快串行算法的1/n。但是实际上并不能如愿。

4.1、并行程序的开销来源

进程之间的通信

有时候需要数据通信,这是串行算法所不需要的。这会带来额外开销。

空闲

实际上对比任务的分配来说,并不能总是做到完全的负载均衡,这就会导致有些进程会提前完成计算,导致进程的空闲。

额外计算

有时候最优的串行算法并不能很好地并行化,所以我们会使用不那么好的串行算法来进行并行化,这些“不那么好”的算法就会需要额外的计算。

4.2、并行系统的性能度量

4.2.1、执行时间

我们可以直接记录一个程序在不同场景下的执行时间来判断性能。

串行程序执行时间: T s T_{s} Ts

并行程序执行时间: T p T_{p} Tp

4.2.2、总并行开销

我们使用 T o T_{o} To来表示总并行开销。

T o = p T p − T s T_{o} = pT_{p} - T_{s} To=pTpTs

4.2.3、加速比

评估并行系统的时候,我们可以将串行算法的时间(复杂度)除并行算法的时间(复杂度)。那么我们就可以得到加速比。

S = T s T p S = \frac{T_{s}}{T_{p}} S=TpTs

通常来讲并行算法的加速比不会高于处理器数量p。但是实际上并不会完全如此。当并行算法的加速比超过p的话,就是超线性加速比。如果出现超线性加速比,这就说明这个算法的串行任务量是高于并行任务量的。换言之机试这个任务尤其适合并行实现,或者调用了硬件加速。

4.2.4、效率

对于p个处理器来说,加速比的理论最大值就是p。但是实际上因为种种原因我们的并行算法达不到这个加速比,而是有一个实际的加速比s。那么效率E我们这么定义:

E = s / p E = s/p E=s/p

4.2.5、成本

成本就是每个处理器的运行时间与处理器个数的乘积。如果成本与输入数据的关系与最优的串行算法是一样的,我们我们就称这个并行算法是成本最优的。

p T p pT_{p} pTp

4.3、粒度对性能的影响

对于一个算法来说,最暴力的改变数据分割粒度的方式就是将一个处理器当成多个处理器来使用。我们将一个物理处理器当成多个逻辑处理器来使用。但是一般来讲除非我们改变数据映射的方式或者调整算法,一般来讲改变处理的粒度是没有办法提高性能的。

4.4、并行系统的可扩展性

因为通常来讲,我们在进行并行测试编写和测试的时候,都是在比较小规模的机器上。但是实际上程序实际上会运行在比较大的规模上。所以说证明一个算法具有良好的可扩展性很重要。而我们需要仔细考虑的是“效率的保持”。

并行程序的效率在前文已经提及:

E = s p = T s p T p = 1 1 + T o T s E = \frac {s}{p} = \frac {T_{s}}{pT_{p}} = \frac {1}{1 + {\frac{T_{o}}{T_{s}}}} E=ps=pTpTs=1+TsTo1

s为实际加速比,p是处理器的数量,也是理论加速比。 T s T_{s} Ts是串行算法的时间, p T p pT_{p} pTp是并行算法总时间。 T s T_{s} Ts实际上就是这个问题的规模。而通常来讲为了适应更大的问题,也就是 T s {T_{s}} Ts更大的情况下,我们也要增加处理器的个数,随着处理器个数的增加,通常来讲 T o T_{o} To也就是额外开销也会增加。

通常来讲同个算法在更多的处理器下面对的额外开销会更多,这是很合理的,因为通信的进程空间的时间更多了。如果随着处理器的增加,相比更大的问题规模导致 T o T_{o} To的加快速度远高于 T s T_{s} Ts,也就是问题规模的速度,那么这个问题的扩展性是不足的,因为随着处理器数量的扩展,效率下降了。

一个具有良好扩展性的算法表现在随着问题规模 T s T_{s} Ts与处理器规模的增加,效率E可以保持相对稳定。

在很多并行系统中,都具备同时增加处理器数目和问题规模来保持效率为固定值的能力。我们称这种系统为可扩展(scalable)并行系统。并行系统的可扩展性(scalability)是与处理器数目成比例地增加加速比能力的度量。它反映一个并行系统有效地利用增加的处理资源的能力。

针对这个问题我们可以给出两个结论。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tHoImtad-1610969602185)(https://i.loli.net/2018/05/24/5b059c4c8629a.png)]

第一个结论是在问题规模一定的情况下,并行算法的效率会随着处理器的增加而降低。第二个结论是在处理器数量一定的情况下,效率随着问题规模的增加而增加。这是两个根据经验产生的结论。

问题规模

前面的两个结论都和问题规模有关,所以我们需要对问题规模有所定义。我们需要这个定义可以在不同的并行算法中横向都是受用的。我们使用一个并行算法的最优串行版本的计算步骤来规定问题规模。

等效率函数

等效率函数可以评判一个算法适不适合扩展。这是一个问题规模W关于处理器数量p的函数:

W = f ( p ) W = f(p) W=f(p)

通过这个函数,我们可以算出为了保证计算效率E不变,处理器数量p相对于问题规模W的增长速率。

我们可以一点一点推导这个函数的由来,首先我们定义并行算法运行时间Tp:

T p = W + T o ( W , p ) p T_{p} = \frac{W + T_{o}(W,p)}{p} Tp=pW+To(W,p)

运行时间和问题规模W以及额外开销 T o T_{o} To成正比,和处理器数量p成反比。而我们前文提到了加速比,加速比是串行算法的运行时间和:

S = T s T p = W T p = W p W + T o ( W , p ) S = \frac{T_{s}}{T_{p}} = \frac{W}{T_{p}} = \frac{Wp}{W + T_{o}(W,p)} S=TpTs=TpW=W+To(W,p)Wp

然后我们可以得到效率:

E = S p = W W + T o ( W , p ) = 1 1 + T o ( W , p ) W E = \frac{S}{p} = \frac{W}{W + T_{o}(W,p)} = \frac{1}{1 + \frac{T_{o}(W,p)}{W}} E=pS=W+To(W,p)W=1+WTo(W,p)1

然后我们就可以得到W与p之间的关系:

W = E 1 − E T o ( W , p ) W = \frac{E}{1-E}T_{o}(W,p) W=1EETo(W,p)

因为我们需要在问题规模和处理器数量不断扩大的时候保持E的稳定,所以我们可以把E当做常数。

W = K T o ( W , p ) W = KT_{o}(W,p) W=KTo(W,p)

如果我们可以得到这个函数,然后将 T o T_{o} To中的W整理到等式的左边就好了。即便很难整理成 W = f ( p ) W = f(p) W=f(p)的形式,我们可以通过逐项分析的方法来得到二者增长率的关系。

我们举个例子,对于这样的一个等效率函数:

W = K p 3 2 + K p 3 4 W 3 4 W = Kp^{\frac{3}{2}} + Kp^{\frac{3}{4}}W^{\frac{3}{4}} W=Kp23+Kp43W43

这个函数实际上整理为 W = f ( p ) W = f(p) W=f(p)的方式还是很难的,但是如果我们拆开看每一项,抓住主要矛盾就可以得到不同的东西。分开看可以得到两个式子。

W = K p 3 2 W = Kp^{\frac{3}{2}} W=Kp23
W = K p 3 W = Kp^{3} W=Kp3

那么下面这个式子就是就是主要矛盾。随着问题规模增加,为了保证一致的效率,处理器数量会按照3次方速度增加,这个算法好像不那么适合扩展。

成本最优性

一般来讲所谓成本最优,就是并行算法的总计算时间和最优的串行算法保持一致。

p T p = T s pT_{p} = T_{s} pTp=Ts

很据下面这个式子,如果 T o T_{o} To W W W都是以相同速度增长,那么我们就称这个算法是成本最优的。

E = 1 1 + T o T s = 1 1 + T o W E = \frac {1}{1 + {\frac{T_{o}}{T_{s}}}} = \frac {1}{1 + {\frac{T_{o}}{W}}} E=1+TsTo1=1+WTo1

对于一个理想的并行算法来说,最优的结果就是处理器p的增加速度和工作量W的增加速度是一样的。

实际上除了处理器数量p之外,算法的并发度和等效率函数也是有很大关系的。并发度代表了可以同时执行的最大任务数量。通常来讲只有并行算法的并发度和W处在同样的数量级和增长率的时候等效率函数才有最优的可能性。

4.5、最小执行时间和最小成本最优执行时间

最短的执行时间和最小的成本实际上是两个概念。首先我们先引入一个链接来规定下面我们将要用到的符号。

大 Θ记号、大 Ω记号、空间复杂度、时间复杂度

并行计算入门_第47张图片

实际上最小执行时间是很好计算的,我们知道对 T p T_{p} Tp求p的偏导就好了。另外还有一个时间叫做成本最优时间。对于成本最优来说就是让并行算法的总时间和最优的串行算法时间成比例,即 W = p T p W = pT_{p} W=pTp

根据等效率函数, W = f ( p ) W = f(p) W=f(p),也就是 p = f − 1 ( W ) p = f^{-1}(W) p=f1(W),也就是说成本最优的时候:

T c o s t _ o p t = W f − 1 ( W ) T_{cost\_opt} = \frac{W}{f^{-1}(W)} Tcost_opt=f1(W)W

但是因为实际上我们只能无限接近于这个结果,因为毕竟并行算法是有额外开销的。所以只能说是成本最优运行时间的下界。即:

T c o s t _ o p t = Ω ( W f − 1 ( W ) ) T_{ cost\_ opt }=\Omega (\frac { W }{ f^{ -1 }(W) } ) Tcost_opt=Ω(f1(W)W)

5、使用消息传递的方式编程

5.1、基本概念

在并行系统中通常同时运行很多不同任务,通常来讲,任务之间是存在交互的。这就需要我们在不同进程之间采用消息传递的方式编程。

消息传递的方式有很多种,最常见是异步消息传递以及松散同步消息传递。对于异步的消息传递模式来说,并行算法的实现可以非常灵活,所有的任务都是异步执行的,理论上可以实现所有的并行算法。对于松散同步的消息传递来说,任务之间的交互是同步进行的,但是任务的执行依旧是完全异步的。

在并行程序中我们使用一种叫做SPMD的编程模式,也就是“单程序多数据”。也就是说除了一些主进程之外,绝大多数进程执行的程序是一模一样的。并且不同进程采用锁步执行,使得执行过程是完全同步的。

5.2、发送和接收操作

我们可以构建一个简单的发送和接收操作的原型。

send(void * gendbuf,int nelems,int dest)
receive(void *recvbuf,int nelems,int source)

我们主要规定3点、一个是发送信息的缓冲区,一个是缓冲区大小,一个是目标节点和源节点编号。

5.2.1、阻塞式消息的发送

所谓阻塞式消息发送,就是发送和接收操作都不立即返回,而是需要完成一些操作之后才返回的消息发送机制。在程序运行的时候,程序会在send和receive操作上停留。

无缓冲

如果没有缓冲去,那么阻塞式消息通信的过程如果,首先发送放发送一个请求给接收方,然后发送方进入等待,知道接收方允许发送方接收数据。接收方在接受数据的过程中也会block在receive函数上,直到数据传输过程完成。

在无缓冲的阻塞式消息发送中,无论是发送方还是接受放,程序都有大量的时间在等待握手和消息发送和接收。只有当发送方发送请求的时候接受方正好需要接收数据,才能最小化等待的时间,这也是下图中中间这种情况所讲的场景。

并行计算入门_第48张图片

并且如果两个进程都在给对方同时send数据,那么这两个进程就会陷入死锁。

有缓冲

为了减少空闲时间,我们可以为消息的发送方和接收方设立缓冲区。要发送的信息可以先放到缓冲区中,而接收方可以先把收到的东西存到缓冲区。这样做的一个好处就是减少了握手等待的时间。当然在向缓冲区写入数据以及从缓冲区读取数据的过程还是同步阻塞的。

对于接受方来说整个过程还是阻塞的,如果发现发现缓冲区里面还是没有需要的数据,那么就会进入block等待。

5.2.2、非阻塞式消息发送

非阻塞式消息发送的过程最大不同就是无论是发送函数还是接收函数都是立刻返回的。但是整体的过程和阻塞式的通信方式是一模一样的。唯一的区别就是send和receive函数在调用之后就直接交给后台处理。这样子可能会带来一定的隐患,那就是数据的真正发送的时候可能已经被程序的其他逻辑修改了。

并行计算入门_第49张图片

对于接受操作来说可能也有隐患,上图是没有通信硬件的支持,那么接受进程就,如果有通信硬件,那么接收过程的数据拷贝对于接受进程来说就是异步的,那么在接收端也会出现数据不安全的问题,就好像下图所示。

并行计算入门_第50张图片

5.3、消息传递接口-MPI

有关MPI的介绍主要来自于这篇博客:MPI教程,这是一个很久没有被维护的博客。为了怕年久失修没人维护,在这一段中我们将转述这个博客的一些内容。

5.3.1、MPI阻塞式通信的使用

而这里有一篇文章介绍了MPI不同的阻塞式通信模式:[MPI] 不同通信模式MPI并行程序设计

首先是MPI上下文的开启和关闭:

MPI_Init(&argc, &argv); //初始化MPI环境
MPI_Finalize(); //结束MPI环境

就好像CUDA一样,我们可以查看进程的个数和编号。并根据当前进程的编号来规定当前进程需要执行的任务。

int MPI_Comm_size(MPI_Comm comm, int *size); //把comm通信下的processor的个数存入size中
int MPI_Comm_rank(MPI_Comm comm, int *rank); //当前进程的的标识存入rank中(0 ~ size-1)

在MPI中,所有节点运行的是同一个程序,所以需要使用rank,也就是当前进程的编号来区分每一个进程所应该执行的任务。

下面是一个点对点通信的例子。

#include 
#include 
#include 

int main(int argv, char* argc[]){
    int rank, tot, i;
    char msg[128], rev[128];
    MPI_Status state;
    MPI_Init(&argv, &argc);
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);
    MPI_Comm_size(MPI_COMM_WORLD, &tot);
    if (rank == 0){
        //这里规定Master节点的内容,分别给每一个Slave节点发送消息
        for(i = 1; i < tot; i++){
            sprintf(msg,"hello, %d, this is zero, I'am your master", i);
            MPI_Send(msg, 128, MPI_CHAR, i, 0, MPI_COMM_WORLD);
        }

        //从每一个
        for(i = 1; i < tot; i++){
            MPI_Recv(rev, 128, MPI_CHAR, i, 0, MPI_COMM_WORLD, &state);
            printf("P%d got: %s\n", rank, rev);
        }
    }else{
        //从Master节点接受消息,然后向Master节点发送内容。
        MPI_Recv(rev, 128, MPI_CHAR, 0, 0, MPI_COMM_WORLD, &state);
        printf("P%d got: %s\n", rank, rev);
        sprintf(msg, "hello, zero, this is %d, I'am your slave", rank);
        MPI_Send(msg, 128, MPI_CHAR, 0, 0, MPI_COMM_WORLD);
    }
    MPI_Finalize();
    return 0;
}

因为MPI的所有节点运行的是同一套代码,所以master进程和slave进程的代码都是写到一起的。在这个程序中,我们通过一个分支来分别规定不同进程的计算内容。这里的所有通信都是阻塞的,并且因为调用的是MPI_Send接口所以由MPI自己选择是不是使用缓冲区。这种通信方式被称作标准通信模式

并行计算入门_第51张图片

但是实际上我们可以自己规定是不是一定需要缓冲区,或者使用合适的策略。所以说我们可以选择是是要选左边的路,即同步通信模式,还是右边的路,即缓存通信模式。

此外还有一种通信模式被称作就绪通信模式,那就是只有当接收方已经准备好的时候才能发送。

并行计算入门_第52张图片

5.3.2、MPI非阻塞式通信的使用

对于非阻塞通信来说,所有的函数都是立即返回,但是我们可以使用一些方法来检查发送是不是成功了,来保证程序语义的正确性。

int MPI_Isend(void *buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm)
int MPI_Irecv(void *buf, int count, MPI_Datatype datatype,int source, int tag, MPI_Comm comm, MPI_Request *request)

MPI_Wait(MPI_Request *request, MPI_Status *status);
MPI_Waitall(int count, MPI_Request *array_of_requests[], MPI_Status *array_of_statuses[]);

MPI_Test(MPI_Request *request, int *flag, MPI_status *status);
MPI_Testall(int count, MPI_Request *array_of_requests[], int *flag, MPI_Status *array_of_statuses[]);

以上就是一系列非阻塞通信的接口,其中发送函数和接收函数都是直接返回的,我们需要使用Wait和Test函数来不断查询发送的结果。

下面展现了一个使用mpi异步通信的例子。在进程之间传递消息。

#include 
#include 
#include "mpi.h"

int main(int argc, char *argv[])  {
    int numtasks, rank, next, prev, buf[2], tag1=1, tag2=2;
    MPI_Request reqs[4];
    MPI_Status stats[4];
    buf[0] = -1;
    buf[1] = -1;
    MPI_Init(&argc,&argv);
    MPI_Comm_size(MPI_COMM_WORLD, &numtasks);
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);

    prev = rank-1;
    next = rank+1;
    if (rank == 0)  prev = numtasks - 1;
    if (rank == (numtasks - 1))  next = 0;

    MPI_Irecv(&buf[0], 1, MPI_INT, prev, tag1, MPI_COMM_WORLD, &reqs[0]);
    MPI_Irecv(&buf[1], 1, MPI_INT, next, tag2, MPI_COMM_WORLD, &reqs[1]);

    printf("buffer before wait%d %d\n",buf[0],buf[1]);
    sleep(2);

    MPI_Isend(&rank, 1, MPI_INT, prev, tag2, MPI_COMM_WORLD, &reqs[2]);
    MPI_Isend(&rank, 1, MPI_INT, next, tag1, MPI_COMM_WORLD, &reqs[3]);

    MPI_Waitall(4, reqs, stats);
    printf("task %d got %d from prev\n",rank, buf[0]);
    printf("task %d got %d from next\n",rank, buf[1]);

    MPI_Finalize();
    return 0;
}

这是一段一系列进程轮流发送消息函数片段。我们使用系列MPI_Request来保存请求,以及使用MPI_Status来保存请求的状态,然后因为所有的send和recv函数都是立即返回的,所以我们之后只能通过这些请求的句柄以及这些请求的状态来判断消息是不是已经发送完了。

我们使用Wait函数可以等待这个过程完成。

之前我们提及的都是点对点通信,实际上我们还可以进行更大范围的一对多通信。

并行计算入门_第53张图片

而这些在MPI中都有对应的接口。

int MPI_Bcast(void *buffer, int count, MPI_Datatype datatype,int root, MPI_Comm comm);
int MPI_Scatter(void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm);
int MPI_Gather(void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm);
int MPI_Alltoall (void *sendbuf, int sendcount, MPI_Datatype sendtype, void *recvbuf, int recvcnt, MPI_Datatype recvtype, MPI_Comm comm);
int MPI_Reduce(void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype,op, int root, MPI_Comm comm);
//MPI_Op是一个函数指针,自定义规约函数
int MPI_Op_create(MPI_User_function *function, int commute, MPI_Op *op);

实际上MPI的接口有很多,这里就不一一赘述了,MPI所做的主要工作就是通信,即将一部分进程的数据传输给其他进程。

6、共享地址空间的编程

在共享地址空间的编程中,所有的计算节点是共享内存的。所以主要矛盾不再是通信,而是对于一个共享空间访问一致性的问题,也就是上锁。

在共享地址空间的编程中,我们重点需要关注的是pThread和openMP两个库的API

6.1、pThread线程编程

6.1.1、线程的创建与终止

pthread_create

我们使用这个函数来创建线程。函数原型:

int pthread_create(pthread_t *tidp,const pthread_attr_t *attr,
(void*)(*start_rtn)(void*),void *arg);

四个参数分别是线程的指针、线程的属性、线程运行的函数、线程运行的函数需要传入的参数。

pthread_join

函数pthread_join用来等待一个线程的结束,线程间同步的操作。函数原型如下:

int pthread_join(pthread_t thread, void **retval);

第一个参数传入需要等待的函数的指针,第二个参数是线程执行的结果返回值。

pthread_mutex_lock

这是为线程之间上锁的函数,一个上过锁的mutex变量会在其他线程上锁的时候让其他线程阻塞等待,除非掌握的锁的线程将锁释放。函数原型:

int pthread_mutex_lock(pthread_mutex_t *mutex);

这样子就执行了上锁操作,mutex规定了需要上的锁。在多线程中使用锁为带来大量的空闲开销。所以我们要让锁约束尽可能少的代码。此外我们还能使用一种新的接口来保证上锁的过程足够快。那就是pthread_mutex_trylock。这个接口是异步上锁的接口,只会返回锁的状态,而不会阻塞。函数原型和pthread_mutex_lock一样。

nt pthread_mutex_trylock( pthread_mutex_t *mutex );

除了我们可以使用临界变量来上锁之外,我们还可以使用一种信号量来上锁,等于是这是一种条件,当一个信号量没有达到要求的时候一个线程就会一直被阻塞。

pthread_cond_wait

条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使"条件成立"(给出条件成立信号)。为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。

我们首先需要申请一个条件变量,条件变量是一个pthread_cond_t数据类型。

int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;

然后我们可以执行阻塞等待的操作。

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex)
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime)

前者是直接进行等待,后者是在等待一定之间之后继续执行。当然这个函数不是单独运作的,而是与pthread_cond_signal这个函数协同运作的。每当有一个进程执行signal,那么就意味着一个在wait的进程可以结束阻塞了。signal所做的工作就是让一个临界变量原子性+1,而wait的工作是让一个临界变量原子性-1。pthread_cond_t变量一开始是0,只有变量大于等于0的时候线程在wait接口的时候才不会阻塞。如果说mutex是基于临界变量的,那么pthread_cond_t就是在操作系统中常讲的信号量。

6.2、openMP的编程

这是一种新的编程方式,可以使用注释的方式让代码的迭代部分并行化。openMP与编译器相绑定,编译器在代码中看到这些命令之后就会改变相应代码的编译策略来实现并行化。

我们举一个例子,这个例子把32位的RGB颜色转换成8位的灰度数据:

#pragma omp parallel for
for (int i = 0; i < pixelCount; i++) {
    grayBitmap[i] = (uint8_t)(rgbBitmap[i].r * 0.229 +
                              rgbBitmap[i].g * 0.587 +
                              rgbBitmap[i].b * 0.114);
}

我们只要加一句话就可以#pragma omp parallel for多线程执行。

你可能感兴趣的:(科学计算)