并行初探

一些笔记整理

1. 基础认知

1.1 如何编写并行程序

基本思想:将要完成的任务分配给多个核。

两种广泛的方法:任务并行和数据并行

  • 任务并行

将待解决问题所需要执行的各个任务分配到各个核上执行。(执行不同的指令

  • 数据并行

将待解决问题所需要处理的数据分配给各个核,每个核在分配到的数据集上执行大致相似的操作。

1.2 两种并行内存系统

  • 共享内存系统

各个核能够共享到计算机的内存。

(比如Pthreads和OpenMP就是为此设计)

  • 分布式内存系统

每个核拥有自己的私有内存,核之间的通信是显式的,必须使用类似网络中发送消息的机制。

(MPI就是为分布式内存编程而设计)

1.3 目前关注的扩展

  • 消息传递接口MPI(Message-Passing Interface)
  • POSIX线程Pthreads(POSIX threads)
  • OpenMP

1.4 指令级并行(ILP)

Instruction-Level parallelism

通过让多个处理器部件或者功能单元同时执行指令来提高处理器的性能。

认知:指令级并行很难利用,因为程序中有许多部分之间存在依赖关系

两种主要的方法:流水线和多发射。

1.4.1 流水线

此处不详细表述。

通过将功能分成多个单独的硬件或者功能单元,并把它们按顺序串接起来提高性能。

1.4.2 多发射

通过复制功能单元来同时执行程序中的不同指令。

  • 静态多发射

功能单元在编译时调度

ps: 先编译成固定顺序,CPU再按顺序执行

  • 动态多发射

功能单元在运行时间调度

ps:处理器自动判断哪些指令可以并行

系统必须找出能够同时执行的指令,其中一种最重要的技术是:预测。

1.5 硬件多线程

线程级并行TLP:通过同时执行不同线程来提供并行性。与ILP相比,TLP提供了粗粒度的并行性,即同时执行的程序基本单元(线程)比细粒度的程序单元(单条指令)更大或更粗。

1.5.1 细粒度多线程

处理器在每条指令执行完后切换线程,从而跳过被阻塞的线程。

缺陷:执行很长一段指令的线程,在执行每条指令的时候都需要等待。

1.5.2 粗粒度多线程

为了避免上述问题,只切换那些需要等待较长时间才能完成操作(比如从主存中加载)而被阻塞的线程。

ps:只有线程遇到一定时间的阻塞时,才会切换执行其他的线程。

优势:不需要线程间的立即切换,切换的次数少了。

但仍有切换,阻塞要等待,导致延迟。

1.5.3 同步多线程

模拟多核的原理。

略。

1.6 并行硬件

如果我们能够通过修改源代码来开发并行性,或者必须修改源代码来开发并行性,那么我们认为这种硬件是并行硬件。

Flynn分类法经常用来对计算机体系结构进行分类,依据是它能够同时管理的指令流数目和数据流数目。

SISD(经典冯诺依曼架构)、SIMD、MISD(不做讨论)、MIMD

典型的冯·诺依曼系统是 单指令流单数据流SISD系统。它一次执行一条指令,一次存取一个数据项。

1.6.1 SIMD系统

单指令多数据流 Single Instruction, Multiple Data

通过对多个数据执行相同的指令,从而实现在多个数据流上的操作。

ps:就如同一个控制单元和多个ALU。

它适合对处理大型数组的简单循环实行并行化。通过将数据分配给多个处理器,然后让各个处理器使用相同的指令来操作数据子集实现并行,即数据并行

经典的SIMD系统是:向量处理器。此外,图形处理单元GPU和台式机的CPU也利用了SIMD计算方面的知识。

SSE、AVX、CUDA、Xeon Phi

1.6.2 MIMD系统

多指令多数据流 Multiple Instruction, Multiple Data

该系统支持同时多个指令流在多个数据流上操作。

MIMD系统通常包括一组完全独立的处理单元(核),每个处理单元都有自己的控制单元和ALU。而且它通常是异步的,各个处理器能够按他们自己的节奏运行,不同处理器上的系统时间之间没有关联。

  • 共享内存系统

一组自治的处理器通过互连网络与内存系统相互连接,每个处理器能够访问每个内存区域。处理器通过访问共享的数据结构,从而进行隐式的通信。

一致内存访问系统UMA 和 非一致内存访问系统 NUMA

详情见《并行程序设计导论》P23

  • 分布式内存系统

每个处理器有自己私有的内存空间,处理器-内存 对之间通过互连网络相互通信。处理器之间通过发送消息或者使用特殊的函数来访问其他处理器地内存,从而进行显式的通信。

最广泛使用的分布式内存系统称为集群(clusters)。而事实上,这些系统中的节点,通常都是有一个或者多个多核处理器的共享内存系统。这种系统并非纯粹的分布式内存系统,称为混合系统

1.6.3 互连网络

互连网络无论是在分布式内存系统还是在共享内存系统中,都扮演了一个决定性的角色。一个缓慢的互连网络会严重降低除了简单并行程序外所有程序的整体性能。

共享内存系统的互连网络和分布式内存系统的互连网络是有区别的。

1.6.3.1 共享内存互连网络

目前最常用的两种互连网络是总线和交叉开关矩阵。

  1. 总线(BUS)

总线是由一组 并行通信线控制对总线访问的硬件组成的。

核心特征:

连接到总线上的设备共享通信线。

优点:低成本、灵活性,多个设备能够以较小的额外开销连接到总线上。

缺点:因为通信线是共享的,随着连接的设备增多,争夺总线概率增大,预期性能下降,处理器会经常等待访问内存。

因此,随着内存系统规模的增大,总线会迅速被交换互连网络取代。

  1. 交叉开关矩阵(Crossbar)

交换互连网络使用交换器来控制相互连接设备之间的数据传递。

交叉开关矩阵中,线表示双向通信线路,方块表示核或者内存模块,圆圈表示交换器。

交叉开关矩阵允许在不同设备之间同时进行通信,所以比总线快。但交换器和链路带来的开销也相对高。

一个小型的基于总线系统 比 相等规模的 基于交叉开关矩阵系统便宜。

1.6.3.2 分布式内存互连网络

分布式内存互连网络通常分成两种:直接互连间接互连

a. 直接互连

在直接互连中,每个交换器与一个 处理器-内存 对 直接相连,交换器之间也相互连接。

  • 环和二维环面网络

环比简单的总线高级,因为它允许多个通信同时发生。在处理器必须等待其他处理器才能完成通信的情况下,制定通信方案会更容易。

在环中,每个交换器需要支持3个链路。// 在图中,连接到每一个圆圈的线,有3条,对应着3条双向通信线路,即3个链路。

环面网络更加昂贵,因为交换器更加复杂,每个交换器需要能够支持5个链路。

如果有p个处理器,在环面网络中链路的数目为3p,而在环中为2p。// 解释: 因为直接互连意味着一个交换器连一个处理器-内存对,因此有p个处理器就有p个交换器。

  • 等分宽度和等分带宽

衡量同时通信的链路数目或者连接性的一个标准——等分宽度。

想象并行系统被分成两部分,每部分都有一半的处理器或者节点,这两部分之间能够同时发生的通信数目(考虑最坏情况)。

如果认为以上说法不好判断的话,对于等分宽度的另一种计算方式为:

最少去除多少条线可以让两部分断开连接,去除的链路数就是等分宽度。

示例:对于正方形的二维环面网络,有p = q^2 个节点(q为偶数)

等分宽度为
2 p 2\sqrt{p} 2p
砍掉”中间“的水平链路和”回绕“的水平链路。

链路的带宽:是指它传输数据的速度。通常用兆位每秒或者兆字节每秒来表示。

等分带宽通常用来衡量网络的质量。

等分带宽 = 等分宽度 * 每条线的带宽

示例:在一个环中。等分宽度为2。如果链路的带宽为10亿位每秒,那么环的等分带宽就是20亿位每秒(2000兆位每秒)。

  • 全相连网络

这是最理想的直接互连网络,即每个交换器与每一个其他的交换器直接连接。它的等分宽度为p^2/4。但是这个需要的链路太多了。

  • 超立方体

一种已经用于实际系统中的高度互连的直接互连网络。

它是递归构造的。

维度为d的超立方体有p = 2^d个节点,并且,在d维超立方体中,每个交换器与一个处理器和d个交换器直接连接。

这样,超立方体的等分宽度为p/2,所以它比环或者二维环面网格连接性更高,但需要更加强大的交换器,因为每个交换器必须支持 1 + d = 1 + log2(p)条连线。所以,这是更贵的。

b. 间接互连

(简单理解)

交换器不一定与处理器直接连接。

它们通常由一些单向连接和一组处理器组成,每个处理器有一个输入链路和一个输出链路,这些链路通过一个交换网络连接。

交叉开关矩阵 和 omega网络。

延迟:从发送源开始发送数据,到目的地开始接收数据 之间的时间。

带宽:开始接收数据之后,接收数据的速度

一个互连网络的延迟为L秒,带宽为b字节每秒,则传输一个n字节的消息需要时间

time = L + n/b

一个新手的常识性问题:

Q:为什么不是所有的MIMD系统都是共享内存的?——大多数程序员觉得通过共享数据结构隐式地协调多个处理器的工作,比显式地发送信息更加吸引人。(巧了,原来不是我一个人这么觉得。)

A:这里有一些问题。其中主要的硬件方面的问题是互连网络扩展的代价。当向总线增加处理器时,访问总线发生冲突的可能性骤升,所以总线适用于处理器数目较少的系统。而大型的交叉开关矩阵非常昂贵。分布式内存互连网络相对便宜(哪怕是超立方体、环面网格)。因此分布式内存系统比较适合于那些需要大量数据和计算的问题。

1.6.4 Cache一致性

Cache一致性问题:在多核系统中,各个核的Cache存储了相同变量的副本,当一个处理器更新Cache中该变量的副本时,其他处理器应该知道该变量已更新,即其他处理器中Cache中的副本也应该更新。

(这种不可预测的行为与系统使用写直达策略还是写回策略无关。)

两种方法来保证Cache一致性:

  • 监听Cache一致性协议

  • 基于目录的Cache一致性协议

1.6.4.1 监听Cache一致性协议

这个想法来源于基于总线的系统:当多个核共享总线时,总线上传递的信号能够被所有连接到总线的核“看”到。当核0更新它Cache中的x的副本时,它把这个更新消息在总线上广播,此时若核1正在监听总线,则它能够得知x已经更新,并将自己Cache中的x副本标记为非法。

实际的监听协议,与上述大致原理的差别:广播会通知其他核,包含x的整个Cache行已经更新,而不是只有x更新。(相当于更粗粒度地通知)

补充:

  1. 互连网络不一定必须是总线,只要能够支持从每个处理器广播到其他处理器。
  2. 监听协议在写直达和写回cache上都可以工作。原则上,如果互连网络可以像总线那样被Cache共享,如果是写直达Cache,就不需要额外地互连网络开销,因为每个核都能“检测”写;如果是写回Cache,就需要额外的通信,因为对Cache的更新不会立即发送给内存。

缺陷:

在大型网络上,广播是非常贵的。监听Cache一致性协议每更新一个变量就要做一次广播。它不可扩展,对大型系统会导致性能的下降。

1.6.4.2 基于目录的Cache一致性协议

该协议通过使用一个叫做目录的数据结构来解决上述问题。

目录存储每个内存行的状态,该数据结构一般是分布式的。

每个 核-内存对,负责存储一部分的目录。这部分目录标识了局部内存对应高速缓存行的状态。

当一个高速缓存行被读入时,如核0的Cache,与这个高速缓存行相对应的目录项就会更新,表示核0有这个行的副本。当一个变量需要更新时,就会查询目录,将所有包含该变量高速缓存行置为非法。

目录需要大量额外的存储空间,但是当一个Cache变量更新时,只需要与存储这个变量的核交涉。(对应的部分目录在这个核上)

1.7 并行软件

前提:

  1. 这里只讨论MIMD系统的软件。

  2. 我们主要关注单程序多数据流(SPMD)程序。

SPMD不是在每个核上运行不同得程序,相反,SPMD程序仅包含一段可执行代码,通过使用条件转移语句,可以让这一段代码在执行时表现得像是在不同处理器上执行不同的程序。

SPMD可以实现数据并行,也能实现任务并行。

任务并行:

if (I'm thread/process 0)
    do this;
else
    do that;

数据并行:

if (I'm thread/process 0)
    operate on the first half of the array;
else	/* i'm thread/process 1 */
    operate on the second half of the array;

1.7.1 并行算法设计

  1. 进程/线程间的任务分配
    1. 负载均衡。使得每个进程/线程获得大致相等的工作量
    2. 使得需要的通信量尽可能少(一般涉及分布式系统)
  2. 安排进程/线程间的同步
  3. 安排进程/线程间的通信

单个机器上一般是多线程,而且一般不会涉及显式的通信。

1.7.2 并行程序设计的复杂性

  • 足够的并发度(阿姆达尔定律)
  • 并发粒度
    • 独立的计算任务的大小
  • 局部性
    • 对邻近的数据进行计算
  • 负载均衡
    • 处理器的工作量相近
  • 协调和同步
    • 谁负责?处理频率?

1.8 输入和输出

通常应该尽可能避免输入和输出的问题。

我们一般做一些假设并遵循一些规则:

  1. 在分布式内存程序中,只有进程0能够访问stdin。在共享内存程序中,只有主线程或者线程0能够访问stdin。
  2. 在分布式内存和共享内存系统中,所有进程/线程都能访问stdout和stderr。
  3. 调试程序输出在生成输出结果时,应该包括进程/线程的序号或者标识符以供区分。
  4. ……

见书P38

1.9 性能

串行算法评价:算法时间复杂度表示为输入规模的函数

并行算法评价:除了输入规模外,还考虑处理器数目、处理器相对运算速度、通信速度。

评价标准:

  1. 运行时间
  2. 加速比:并行算法比串行算法快多少

反直觉的地方

Q:如果有很多串行算法,选哪个来做比较?

A:拿最优的串行算法。

例如:串行冒泡排序算法时间150s,串行快速排序算法30s,并行冒泡排序算法40s。那么加速比S = 30 / 40 = 0.75,而不是150 / 40 = 3.75 !!!!

1.9.1 加速比和效率

在理想情况下(任务在核之间平均分配,又不会为每个核引入额外的工作量),在p核系统上运行程序,每个核运行一个进程或线程,并行程序运行速度就是串行程序速度的p倍。

串行运行时间为T串,并行运行时间为T并,则最佳的预期为:T并 = T串 / p。此时我们称并行程序有线性加速比

实际上是不可能得到线性加速比的。

并行程序的加速比S = T串 / T并

并行程序的效率E = S / p

并行算法总额外开销:T开销 = p * T并 - T串

一点常识问题:

一般都是S < p。S = p就是线性加速比。

S > p(超线性加速比)在实践中是可能出现的。比如

  • 串行算法计算量大于并行算法
  • 硬件问题不利于串行算法

超线性的例子:由cache引起的超线性加速;搜索分解导致超线性

1.9.2 阿姆达尔定律

事实:除非一个串行程序的执行几乎全部都并行化,否则,不论多少可以利用的核,通过并行化产生的加速比都会是受限的。

S = 1 / (1 - a + a/p)

a为串行程序中可被完美并行化的比例

分子可以视作 Ts = 1

分母可以视作 Tp = T不可并行 + T可并行 = 1 - a + a/p

例题:某串行程序运行时间为20s,可并行化比例为90%,若使用p个核将其并行化,加速比为多少?

a = 0.9

S = 1 / (1 - a + a/p) = 1 / (0.1 + 0.9 / p)

1.9.3 可扩展性

对于某并行程序,核数(线程/进程数)固定,且输入规模固定。效率为E。

现在增加核数p,如果输入规模也以相应增长率增加的情况下,若效率E一直是E(不降),则该程序是可扩展的。

强可扩展的:增加线程/进程个数时,不增加问题规模,就可以维持固定的效率E。

弱可扩展的:增加线程/进程个数时,只有亿相同倍率增加问题规模,才能维持固定效率E。

2. MPI编程

MPI基本原语

阻塞通信

编程模型(对等、主从)

组通信

了解非阻塞通信

了解混合编程

2.1 起步和基本原语

消息传递:这是超级计算机和集群主要的编程模型

可移植 + 偏底层

这是一种分布式的并行编程,它隔离了独立的地址空间。

  • 不会有数据竞争,可能有通信错误。
  • 暴露了执行模型,迫使程序员思考局部性,两点对性能都有好处。
  • 编程复杂,代码膨胀。

特性:

所有的通信、同步都通过调用函数来完成,不再有共享变量了。

基本原语:(这里是C版本的代码)

int MPI_Comm_size(MPI_Comm comm, int *size);

报告进程数,值存储在size中。

int MPI_Comm_rank(MPI_Comm comm, int *rank);

报告识别调用进程的rank,从0~size - 1。值存储在rank中。

#include

int main(int argc, char *argv[]){
    MPI_Init(&argc, &argv);
    
    /* MPI 程序主体 */
    
    MPI_Finalize();
    return 0;
}
  • 消息传递——发送
int MPI_Send(void *buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm);

消息缓冲区buf, count, datatype

目的进程dest(目的进程在comm指定的通信域中的编号)

阻塞发送——函数返回时,数据已经转给系统进行发送,缓冲区可作他用;消息可能还未送达目的进程。

  • 消息传递——接收
int MPI_Recv(void* buf, int count, MPI_Datatype, int source, int tag, MPI_Comm comm, MPI_Status *status);

阻塞接收——等待,直到收到匹配的消息(source和tag都相同),缓冲可作他用。

source可以是comm中的编号,或MPI_ANY_SOURCE

tag为需要匹配的特定标签,或MPI_ANY_TAG

接收的数据量比指定的少是允许的,但接收到更多数据就是错误。

status包含更多信息,比如接收到的消息大小。

基本概念:

进程可以组成进程组,每个消息都是在一个特定上下文中发送,必须在同一个上下文中接收。进程组和上下文一起形成了通信域,进程用它在进程组中的编号标识。

MPI_COMM_WORLD:默认通信域,其进程组包含所有初始进程。

  • MPI数据类型

MPI是强类型数据传输。消息中的数据用三元组(地址、个数、类型)描述。

比如MPI_CHAR、MPI_SHORT、MPI_FLOAT、MPI_BYTE、MPI_PACKED等等之类的。其中有的和C语言有类型对应,有的没有对应类型。比如哪个MPI_BYTE和MPI_PACKED就没有对应类型。

类型匹配——

宿主语言的类型和消息所指定的类型相匹配;

发送方和接收方的类型相匹配。

类型匹配规则——

有类型数据的通信,发送方和接收方使用的数据类型要相同。

无类型数据的通信,发送方和接收方均用MPI_BYTE作为数据类型。

打包数据的通信,发送方和接收方均使用MPI_PACKED。

  • MPI Tags

发送的消息都伴随一个用户定义的整数标签,帮助接收进程识别消息。

接收方通过指定特定标签来筛选消息,或者指定MPI_ANY_TAG来表示不筛选。

  • MPI_STATUS

在C实现中,status是至少由三个域组成的结构类型。

status.MPI_SOURCE(MPI_ANY_SOURCE)

status.MPI_TAG(MPI_ANY_TAG)

status.MPI_ERROR

接收函数获取status后,可以调用其属性来获得MPI_SOURCE和MPI_TAG,可得知其tag和source进程号。

2.2 编程模型

消息传递有两种编程模型

对等式:地位平等,功能相近

主从式:地位不同,功能不同

2.2.1 对等模式

Jacobi迭代例子。

每个进程的地位对等,处理方式基本相同。

2.2.2 主从模式

主进程和从进程。

通常是主进程将任务进行划分,然后发送给各个从进程,并负责回收各个从进程的计算结果。

从进程负责计算。

2.3 组通信

一个进程组(通信域)内的所有进程同时参加通信。

所有参与进程的函数调用形式完全相同

哪些进程参加、以及组通信的上下文都是由在调用时指定的通信域限定的。

组通信中不需要通信消息标志参数

三个功能:通信、同步和计算

操作是成对的——互为逆操作

2.3.1 广播

一个进程向其他所有进程发送相同数据

初始,只有源继承有一份m个字的数据。广播操作后,所有进程都拥有一份相同数据

int MPI_Bcast(void *buffer, int count, MPI_Datatype datatype, int root, MPI_Comm comm);

root: 每个调用广播操作的进程中root都相同

count:待广播的数据的个数

2.3.2 归约

初始,每个进程都有一份m个字的数据。归约操作后,p份数据经过计算(加、乘、。。。)得到一份数据结果,传送到目的进程。

int MPI_Reduce(void* sendbuf, void* recvbuf, int count, MPI_Datatype datatype, MPI_Op op, int root, MPI_Comm comm);

MPI预定义了很多归约操作,比如MPI_SUM就是求和,MPI_PROD求积。

2.4 非阻塞通信

含义:调用返回 != 通信完成

作用:计算和通信的重叠

形式:非阻塞发送和非阻塞接收

消息传递与缓冲

当用户提供的buffer可被重用时,才表明send完成。

send完成并不意味着receive也完成——

  • 消息可能被系统缓冲
  • 消息可能还在传输中

实现非阻塞通信的难点
系统层面:

硬件支持通信在后台完成,不需要CPU参与;

有相应的编程接口。

算法层面:

要保证与通信重叠的计算与通信无依赖关系;

很多时候要重构算法。

2.5 多线程混合编程

多核集群的常见编程方法

  1. 纯MPI

    1. 节点内和跨节点都采用MPI实现进程并行
    2. 节点间MPI,内部是采用共享内存通信。
  2. MPI + OpenMP

    1. 节点内使用OpenMP,跨节点用MPI
  3. MPI + Pthreads

    1. 节点内使用pthreads,跨节点用MPI

后两种都可以称为混合编程。

2.5.1 四种线程安全级别

MPI定义了四种线程安全级别。

MPI_THREAD_SINGLE:应用中只有一个线程

MPI_THREAD_FUNNELED:多线程,但只有主线程会进行MPI调用

MPI_THREAD_SERIALIZED:多线程,但同一时间只会有一个线程会进行MPI调用。

MPI_THREAD_MULTIPLE:多线程,且任何线程任何时候都会进行MPI调用。

安全级别递增。

多线程混合编程时,定义了一个替代API,用以替代MPI_Init。

MPI_Init_thread(request,provided)

3. SSE/AVX编程

SIMD编程问题(打包解包、对齐开销、控制流开销)

SIMD编程

3.1 SIMD并行

多个算术运算 ----> 一个SIMD操作

多个取数/存结果操作 ----> 一个更宽的内存操作

3.2 SIMD编程额外开销

3.2.1 打包/解包数据的开销

打包源运算对象——拷贝到连续内存区域

解包目的运算对象——拷贝回内存

(数据在读与写回时,需要以多个一组整体的形式进行,因此设计打包与解包。)

解决方案:

重排数据使之连续。

3.2.2 对齐开销

对齐与不对齐的内存访问是不同的。

如果地址总是向量长度的倍数(比如16字节),那么很完美。但如果不是的话,我们不得不进行处理。

静态对齐:对没有对齐的读操作,做两次相邻的对齐读操作,然后合并。

动态对齐:距16字节边界的偏移是变化的或未知的。合并点在运行时计算。

小结:

  • 最坏情况下,需要计算地址,动态对齐。

  • 编译器/程序员可以分析确认对齐,一般而言是从起始地址处开始对齐,如果是在一个循环中顺序访问数据,起始位置固定,则对其特性不变。

  • 可调整算法,先串行处理到对齐边界,然后再SIMD计算。

有时对齐开销会完全抵消SIMD的并行收益。

3.2.3 控制流导致额外开销

比如有一个if-else语句,有不同的处理。

程序会把两分支的运算都做了,存储在不同的寄存器t1和t2中,然后根据条件真假进行合并。

永远都是两个控制流路径都执行!

如何优化?

针对频率最高的路径优化代码。

小结:

一般观点:当存在控制流问题时,SIMD不是一个好的模型。

3.3 SIMD编程复杂性

  • 高层编程:利用编译器,但不总是有效。

  • 低层编程:使用intrinsic或汇编繁琐易错

  • 数据必须对齐,在内存中连须存储。未对齐的数据可能产生不正确结果;可能需要拷贝到连续区域产生额外开销。

  • 控制流问题引入了更多复杂性,还可能导致低效。

3.4 SSE/AVX编程

4. Pthread编程

共享内存编程

并行程序设计的复杂性

pthread一些基础api

同步相关概念

忙等待\互斥量\信号量\障碍

了解条件变量\读写锁

复杂均衡\任务划分

4.1 起步

pthreads是一个多线程编程库。

包含include 头文件

gcc编译时加上-lpthread连接到库。

线程安全:若多线程并行调用能正确执行,就称之为线程安全的。

  • Pthread时POSIX标准
  1. 相对底层
    1. 程序员控制线程管理和协调
    2. 程序员分解并行任务并管理任务调度
  2. 可移植但可能较慢
  3. 在系统级代码开发中广泛使用,也用于某些类型的应用程序。
  • 对比OpenMP新标准:
  1. 高层编程,适用于共享内存架构上的科学计算
    1. 程序员在较高层次上指出并行方式和数据特性,指导任务调度
    2. 系统负责实际的并行任务分解和调度管理
  2. 多种架构相关的编译指示。

一个例子:

#include 
#include 
#include 

int thread_count;

void* Hello(void* rank);

int main(int argc, char* argv[]){
    long thread;
    pthread_t* thread_handles;
    // 线程句柄
    
    thread_count = strtol(argv[1], NULL, 10);
    // 从命令行读取线程数,也可以直接赋值
    
    thread_handles = (pthread_t*)malloc(thread_count * sizeof(pthread_t));
    
    for(thread = 0; thread < thread_count; thread++)
        pthread_create(&thread_handles[thread], NULL, Hello, (void*)thread);
    // 函数参数传入线程ID
    
    printf("hello from the main thread.\n");
    
    for(thread = 0; thread < thread_count; thread++)
        pthread_join(thread_handles[thread], NULL);
    // 等待线程结束
    
    free(thread_handles);
    return 0;
    
}


void* Hello(void* rank){
    long my_rank = (long)rank;
    
    printf("hello from thread %ld of %d\n", my_rank, thread_count);
    return NULL;
}

4.2 基本API

fork一个线程

int errorcode = pthread_create(&thread_id, NULL, &thread_func, &func_args);
// 四个参数:指针,属性,线程要运行的函数,函数的参数
// 返回值,若创建失败返回非0值

4.3 同步控制

共享内存编程的基本问题:

当多个线程尝试更新一个共享资源(比如共享变量),其结果可能是无法预测的。

当多个线程都要访问共享变量或共享文件这样的共享资源时,如果至少其中一个访问是更新操作,那么这些访问就可能会导致某种错误,我们称之为竞争条件

为了保证正确,我们需要一个临界区。临界区就是一个更新共享资源的代码块,一次只允许一个线程执行该代码块。

数据依赖就是两个内存操作的书匈奴,为了保证结果正确性必须保持这个顺序。

同步在时间上强制使各执行线程在某一点必须互相等待,确保各线程的正常顺序和对共享可写数据的正确访问。

4.3.1 忙等待

一个例子:

y = compute(my_rank);
while(flag != my_rank);	
// 用一个共享变量flag来控制

x = x + y;	
// 该代码为临界区。保护共享变量x

flag++;	
// 不论是线程0还是线程1,执行完更新操作后都会执行flag++,从而使另一个被阻塞的线程往下执行

在忙等待中,线程不断地测试某个条件,但实际上直到某个条件满足之前,这些测试都是徒劳的。

前提:严格按照书写顺序来执行代码。

如果存在编译器优化,可能会影响到忙等待的正确执行。

优势:它虽然浪费资源,但是目前所知的,能够事先确定线程执行临界区代码顺序的最合适方法。(因为用flag逐步自增,对应线程id逐个进入。)互斥量的话,哪个线程先进入及之后的顺序,就由OS随机选取了。

缺陷:忙等待线程持续使用CPU,占用一个核的计算资源。——因此,当忙等待的多线程程序,在线程数超越了核的个数时,性能会下降

比如:2个核和5个线程。

线程0在执行临界区,线程1处于忙等待,线程2、3、4被操作系统挂起。

// 2个处理核心,同时只会有两个线程在运行,因为忙等待持续占用资源。

线程0处理完毕更新flag,线程1进入临界区,同时OS调度线程2、3、4、中的一个进入while忙等待,其他的继续挂。

这样自然会影响性能。

4.3.2 互斥量

互斥量是互斥锁的简称,是一个特殊类型的变量。通过一些特殊类型的函数,它可以用来限制每次只有一个线程进入临界区。

特殊类型: pthread_mutex_t。

使用互斥量:

#include 
// 创建mutex
pthread_mutex_t mymutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_init(&mymutex, NULL);

// 使用mutex
int pthread_mutex_lock(&mymutex);
// 加锁(获得锁),若已经上锁则阻塞,直至被解锁
int pthread_mutex_unlock(&mymutex);
// 解锁(释放锁),可能令其他线程推出阻塞。

// 释放mutex
int pthread_mutex_destroy(&mymutex);

解释理解:

pthread_mutex_lock函数调用——

倘若此时没有任何线程获得锁,则该调用线程获得锁。

倘若此时锁已经被某个线程占用,则该调用线程进入阻塞并等待。

注意:持有多个mutex可能导致死锁

thread1		thread2

lock(a)		lock(b)

lock(b)		lock(a)

4.3.3 信号量

信号量是一种特殊类型的unsigned int 无符号整型变量。

0对应上了锁的互斥量,1对应未上锁的互斥量。

使用信号量

// 初始化信号量
#include 
// 它不是pthread库中的,需要另引入。
int sem_init(sem_t* sem, int pshared, usigned value);
// 三个参数:信号量指针;不用,直接传0;信号量初始值。

// 使用信号量
int sem_wait(sem_t *sem);
// 首先判定sem,若sem为0则阻塞当前线程。否则,信号量sem值减1,并进入临界区
int sem_post(sem_t *sem);
// 信号量sem值+1。(若sem原本为0,则可能唤醒其他因sem_wait而阻塞的线程。)

// 释放信号量
int sem_destroy(sem_t *sem);

信号量与互斥量最大的区别在于,信号量是没有个体拥有权的。主线程将所有的信号量初始化为0,即加锁。其他线程都可以对任何信号量调用sem_post和sem_wait函数。

心得注意:sem_wait要先判断信号量是否为0!!!

4.3.4 路障

问题:通过保证所有线程在程序中处于同一个位置来同步线程。

该同步点称为路障,只有所有线程都抵达此路障,线程才能继续运行下去,否则会阻塞在路障处。

  • 最直接的方式
pthread_barrier_t b;

pthread_barrier_init(&b, NULL, threadNum);

// 需要设置路障的地方:
pthread_barrier_wait(&b);

这样很简单

押题:用其他同步机制实现barrier
a. 用忙等待和互斥量实现barrier

使用一个由互斥量保护的共享计数器,当计数器的值表明每个线程都进入了临界区,所有线程就可以离开忙等待的状态了。

int counter;
int thread_count;

pthread_mutex_t barrier_mutex;
...
    
void* Thread_work(...){
    pthread_mutex_lock(&barrier_mutex);
    counter++;
    pthread_mutex_unlock(&barrier_mutex);
    while(counter < thread_count);
    ...
}

缺点:

  1. 忙等待的浪费时间
  2. 希望设置多少个barrier,就需要多少个counter。
b. 用信号量实现barrier
int counter = 0;
sem_t count_sem;	// initialize to 1
sem_t barrier_sem;	// initialize to 0

void* Thread_work(...){
    ...
    sem_wait(&count_sem);
    if(counter == thread_count - 1){	
        // 最后一个线程来到时,counter为thread_count - 1
        counter = 0;			// 重置counter为0
		sem_post(&count_sem);	// 重置count_sem为1
        // 重置之后可以用于设置其他地方的路障。
        for(j = 0; j < thread_count - 1; j++){
			sem_post(&barrier_sem);	// 唤醒其他被阻塞的线程
        }
    } else {
        counter++;
        sem_post(&count_sem);
        sem_wait(&barrier_sem);
    }
    ...
}

用两个信号量:

count_sem来保护计数器(每次只有一个线程进入临界区),初始化为1,以便让第一个线程进入临界区;

barrier_sem用于阻塞已经进入路障的线程。

如果出现这种情况:线程提前执行了for循环的一系列sem_post(&barrier_sem),而此时却没有执行sem_wait(&barrier_sem)。这是不要紧的。因为信号量可以不是二元的,它不断累加数字,只要是正数,都会被sem_wait检测到从而减一后继续执行,

c. 用条件变量实现barrier(a better way)
int counter = 0;
pthread_mutex_t mutex;
pthread_cond_t cond_var;

void* Thread_work(...){
	pthread_mutex_lock(&mutex);
    counter++;
    if(counter == thread_count){
        counter = 0;
        pthread_cond_broadcast(&cond_var);
    } else {
        while(pthread_cond_wait(&cond_var, &mutex) != 0);
        // 其他某些事件也可能将挂起的线程解锁。因此这种一般写法来解决这个问题,如果它是被pthread的API解锁的话,返回值就是0。如果不为0是不应该的,写在while里它就会再次执行该wait函数。
    }
    pthread_mutex_unlock(&mutex);
}

4.3.5 条件变量

条件变量是一个数据对象。

一个条件变量总是与一个互斥量相关联。

条件不满足时,自动解锁并阻塞。被唤醒后自动加锁,再去检测条件。

// 条件变量类型: pthread_cond_t

// 初始化
pthread_cond_init(condition, attr);

// 通过互斥量来阻塞线程,直到其他线程调用唤醒来解锁它。解锁后会重新获得互斥量。
pthread_cond_wait(pthread_cond_t*, pthread_mutex_t*);
// 相当于按顺序执行以下指令:
{
    // 首先,mutex_p原始状态没有被上锁!!!
    pthread_mutex_unlock(&mutex_p);	// 如果条件不成立,则先解锁。(若条件成立,这一行便无所谓,可删除)
    wait_on_signal(&cond_var_p);	// 线程在条件cond_var_p上沉睡(阻塞)。
    pthread_mutex_lock(&mutex_p);	// 什么时候条件成立了(被唤醒了),就上锁(重新获取互斥量)
}


// 解锁一个阻塞在某条件变量上的线程
pthread_cond_signal(pthread_cond_t*);
// 解锁 所有 被阻塞在某条件变量上的线程
pthread_cond_broadcast(pthread_cond_t*);


// 销毁
pthread_cond_destroy(pthread_cond_t*, NULL);	//	第二个参数不用,不管。

注意:

pthread_cond_wait函数本身,有解锁功效。但该函数后面行的代码,却是上着锁的。因此应该在某一时刻调用pthread_mutex_unlock来释放锁。

4.3.6 读写锁

多线程操作一个链表函数(查找、插入、删除)。

方式:

  1. 对整个链表加锁。粒度太粗,线程等待。
  2. 节点级锁(节点struct里面有一个pthread_mutex_t成员)。
  3. 读写锁。性能最好。

4.4 任务划分/负载均衡

块划分(一维)

二维块划分

循环划分

当任务数 > 线程数,循环分配给线程;每个线程负责的区域散布在整个矩阵中,负载不均能够大大缓解。

随机块划分

计算量分布无任何规律——稀疏矩阵;循环分配也无法保证负载均衡。

4.5 小结

pthread基于OS特性,可用于多种语言,数据共享方便。

缺点:很难发现数据竞争。

5. OpenMP编程

openMP基础API

规约

parallel for

数据依赖\重排转换

循环调度

注意:对标准输出的访问是没有调度的,因此输出的实际顺序是不确定的。

5.1 起步

openMP是pthread的常见替代,更简单,但也更多限制。

它通过少量编译指示,指出并行部分和数据共享,即可实现很多串行程序的并行化。

从程序员的视角来看:

  • 只需要将程序分为串行和并行区域,而不是构建并发执行的多线程。
  • 隐藏了栈管理。
  • 提供了同步机制

缺点——

  • 不能自动并行化
  • 不能确保加速
  • 不能避免数据竞争。

5.2 openMP执行模型

Fork-join并行执行模型

  1. 执行最初是单进程(主线程)
  2. 并行结构开始——主线程创建一组线程(工作线程)
  3. 并行结构结束——线程组同步——隐式barrier
  4. 只有主线程继续执行
  5. 实现优化,工作线程等待下一次fork

5.3 基本API

shared变量为共享——等同于pthread中的全局作用域变量

private变量是私有的——等同于pthread中栈中分配的变量

默认是shared,循环变量是private

条件编译与头文件包含

#ifdef _OPENMP
#include 
	...
	printf(omp_get_num_proces());
#endif

格式

#pragma omp parallel 

后面的parallel表明之后的结构化代码块应该被多个线程并行执行。

一个结构化代码块是一条C语句,或者只有一个入口和一个出口的一组复合C语句,(即简单地禁止分支语句进入或离开结构化代码块。)

可以添加子句num_threads,允许程序员指定,执行后面代码的线程数。

#pragma omp parallel num_threads(thread_count)

如果太长换行要用\

执行到parallel时,另外的thread_count - 1个线程被启动。

在omp中,执行并行代码块的线程集合(原始线程+新的线程)称为线程组,原始线程称为主线程,额外的线程称为从线程

当代码块执行完时,有一个隐式路障。这意味着,已经完成代码块的线程,将等待线程组中的所有其他线程完成代码块。然后从线程终止,主线程继续执行后面的代码。

  • 返回当前并行区域的线程组中的线程数
int omp_get_num_threads(void);
  • 返回当前线程在线程组中的编号,从0开始,0是主线程编号。
int omp_get_thread_num(void);

多个线程执行相同的代码块,每个线程执行相同的代码(SPMD)。

  • 临界区指令

被临界区包围的代码。所有线程都执行,但每个时刻限制只有一个线程执行。

#pragma omp critical [(name)]
{
    ...
}

线程在临界区开始位置等待,直到组中没有其他线程在同名临界区中执行。所有未命名临界区指示都映射到相同的未指定名字。

5.4 归约

规约就是将相同的归约操作符重复地应用到操作数序列来得到一个结果的计算。

sum = 0;
#pragma omp parallel for reduction(+:sum)
for(i = 0; i < 100; i++){
    sum += array[i];
}

所有操作的中间结果存储在同一个变量里:归约变量

语法reduction(归约操作符:归约变量)

指明了操作、归约变量。从而它为每个线程创建了私有变量,还创建了临界区,在这里将私有变量的值相加

当一个变量被包含在reduction子句中时,变量本身是共享的。然而线程组中的每个线程都会创建自己的私有变量。每个线程执行到涉及该变量的语句时,使用的实际上是它的私有变量。当parallel块结束后,私有变量中的值整合到一个共享变量中去。(线程的私有变量初始化一般是与共享变量一致。)

5.5 parallel for

#pragma omp parallel for

parallel for指令后的结构化代码块必须是for循环。

parallel for 和parallel的区别显著。

  • 该指令允许,系统通过在线程间划分循环迭代,来并行化for循环。而paralle指令之前的块一般来说其工作必须由线程本身在线程之间划分。

  • 在parallel中变量的默认作用域是共享shared的,但在parallel for中,变量的默认作用域是私有的。(这是因为比如for循环的循环变量i,如果共享的话,变量更新i++将会是无保护的临界区)

  • parallel for的限制

openMP只能并行化在如下情况中,那些可以确定迭代次数的for循环。它不检查依赖性!不支持while等非for循环。

  1. 由for语句本身来确定迭代次数。
  2. 在循环执行之前确定迭代次数。

无限循环是不行的。循环迭代次数可能在循环中发生变动,也是不行的。此外还得满足结构化块的要求,即不能有其他出口,比如break之类的就不能用。唯一的例外是:循环体中可以用一个exit调用。

openMP只能并行化典型结构的for循环。

见书P151。

5.6 数据依赖与重排转换

执行结果依赖于两个或更多事件的时许,则存在竞争条件。

数据依赖就是两个内存操作的序,为了保证结果的正确性,必须保持这个序。

如果两个内存访问指向相同的内存位置,且其中一个是写操作,则它们产生数据依赖。

  • 重排转换

改变语句执行的顺序,不增加或删除任何语句的执行。

一个重排转换保持依赖关系:意思是说它保持了依赖源和目的语句的相对执行顺序。

依赖关系基本定理:

任何重排转换,只要保持了程序中所有的依赖关系,就保持了程序的含义。

比如:

归约计算:对于使用满足交换律和结合律的运算的归约操作,对其重排是安全的。

并行化:如果重排能保持代码中的依赖关系,则是安全的。

局部性优化:假定希望修改访问顺序以便更好地利用cache,若保持依赖关系,则是安全的。

一个例子来认知:

数组的数据依赖

for(i = 2; i < 5; i++){
    A[i] = A[i-2] + 1;
}
// 循环进位依赖关系

for(i = 1; i <= 3; i++){
    A[i] = i + 1;
    B[i] = A[i] * 3;
}
// 循环独立关系

寻找循环中的数据依赖,如果没有依赖关系跨越迭代步边界,则并行化是安全的。

5.7 循环调度

schedule子句确定如何在线程间划分循环。

static([chunk])静态划分.

给每个线程划分[chunk]步迭代,所有线程都分配完后继续循环分配,直到所有迭代步分配完毕。

默认[chunk]为cell(#iterations/#threads),即默认均分。

dynamic([chunk])动态划分

分给每个线程[chunk]步迭代,一个线程完成任务后再为其分配[chunk]步迭代。

逻辑上形成一个任务池,包含所有迭代步。默认[chunk]为1。(粒度可能过细了)

guided([chunk])动态划分,但划分过程中[chunk]指数减小

类似于dynamic调度,但分块最初偏大,随着迭代分块越来越少,循环区间的划分块会变小。

调度开销:

静态决策代价很低,因为不需要运行时协调

动态决策,依赖于决策方法的复杂性和频率

你可能感兴趣的:(parallel,分布式,并行计算)