赞
踩
单处理器性能大幅度提升的主要方法是增加集成电路晶体管密度。但是随着晶体管尺寸的减小,晶体管的传递速度增快,它们的能耗也在增加,大多数的能量是以热能的形式消耗,当一块集成电路变得太热的时候,就会变得不可靠。在 21 世纪的第一个 10 年中,使用空气冷却集成电路的散热能力已达到极限。也就是说,只通过增加集成电路的速度来提升处理器的性能的方法不可取。在这种条件下,集成电路制造商提出多核处理器的思路。
大多数为传统单核系统编写的程序无法利用多核处理器,为了使程序能够更快地运行,有更加逼真的图像,就需要将串行程序改写为并行程序。
想要解决某个问题,需要编写其对应的并行程序,首先需要对任务进行划分,确定其属于任务并行还是数据并行。
首先我会参考《并行程序设计导论》所讲的 C 语言的三种拓展:消息传递接口(Message-Passing Interface,MPI)、POSIX 线程(POSIX threads,Pthreads) 和 OpenMP 来编写基本的并行程序。
选择并行程序实现框架的时候应该根据计算机的架构来选择,即根据硬件选择软件。Pthreads 和 OpenMP 是为 共享内存系统 的编程而设计的,它们提过访问共享内存的机制;而 MPI 是为 分布式内存系统 的编程而设计的,它提供发送消息的机制。
图中(a)表示共享内存系统,(b)表示分布式内存系统。
并行计算涉及到两个不同的技术领域:
硬件主要的目标就是为软件提供更快的计算速度,更低的性能功耗比,硬件结构上支持更快的并行。
软件的主要目的是使用当前的硬件压榨出最高的性能,给应用提供更稳定快速的计算结果。
经典的冯 · 洛依曼结构包括存储器、运算器、控制器、输入设备、输出设备,其中运算器和控制器都在 CPU 之中,CPU 和主存通过总线连接。当数据或指令从主存传送到 CPU 时,称为数据或指令从内存中取出或者读出;当数据或指令从 CPU 传送到主存时,称为数据或指令写入或者存入内存中。这样主存和 CPU 之间的分离称为 冯 · 洛依曼瓶颈。
进程:正在运行的程序的一个实例。
多任务:通过时间片轮转的方式使人产生多个任务同时执行的错觉。
线程:线程包含在进程中,同一个进程内的线程共享内存和 I/O 设备,利用线程程序员可以将程序划分为多个大致独立的任务。
《并行程序设计导论》介绍了三种对冯 · 洛依曼模型的改进措施:缓存、虚拟内存、低层次并行
基于访存局部性而设计的 CPU 缓存(CPU Cache)有着比其他存储器更小的访问开销,CPU Cache 通常和 CPU 位于同一块芯片上,访存开销比普通内存小很多。
局部性原理
- 访问一个位置之后,接着访问其附近的位置
- 空间局部性:访问临近的位置
- 时间局部性:最近访问的位置,在不久的将来还会访问
CPU Cache 一般分为不同的层(level),第一层(L1)最小但是访问速度最快,更高层的 Cache (L2, L3, …)更大但访问速度较慢。大多数系统采用 3 层 Cache,每层 Cache 中的数据不重合,且都会存放在主存中。
当 CPU 需要访问数据或指令时,它会沿着 L1 Cache -> L2 Cache -> L3 Cache -> 主存 这条路径查询,若从 Cache 中查询到数据或指令时,则称为 Cache 命中 或 命中;若没有从 Cache 中查询到则称为 Cache 缺失 或 缺失。
当 CPU 向 Cache 写数据时,会出现 Cache 中的值与主存中的值不一致的情况,为了解决这个问题,数中介绍了两种方法:
在 Cache 设计中,另一个问题是 Cache line 应该存储在什么位置。当从主存中取出一个 Cache line 时,应该把这个 Cache line 放到 Cache 中的什么位置,不同的系统采用不同的方式,这些方式分别为:
当主存中的行能被映射到不同到 Cache 中的不同位置时,需要决定替换或者驱逐 Cache 中的某一行。常用的方案是最近最少使用。
如果一个大型的程序或者程序需要访问大型数据集,那么所有的指令或者数据可能在主存中放不下。采用 虚拟内存,使得主存可以作为辅存的缓存。利用时空局部性的原理,只把正在运行程序的活动部分保存在主存中。
指令级并行
指令级并行通过让多个处理器部件或者功能单元同时执行指令来提高处理器的性能。有两种主要方法来实现指令级并行:
硬件多线程
硬件多线程为系统提供了一种机制,使得当前执行的任务被阻塞时,系统能够继续其他有用的工作。
利用 Flynn 分类法 对计算机体系结构进行划分:
分别以数据和指令进行分析:
注:GPU 属于 SPMD,但是其可以使用 SIMD 并行
计算机架构也可以 根据内存划分:
伪共享:
在一个核更新了一个缓存行上某变量的值后,如果另一个核想要访问同一缓存行上的另一个变量,那么它不得不访问主存,因为缓存一致性的单位是行。
负载均衡:将任务在线程或进程之间分配,并使得每个进程或线程获得大致相等的工作量。
将串行程序或者算法转换为并行程序的过程称为 并行化。某些程序,如果能够通过简单地将会任务分配给进程或线程来实现并行化,我们称该程序是 易并行的。
动态线程:
静态线程:
创建线程池并分配任务,但线程不被终止直到被清理。
性能更好,但可能会浪费系统资源。
非确定性:对于一个给定的输入,程序的两次运行会有不同的结果。
临界区:一次只能被一个线程执行的代码块。访问临界区的行为应该是 互斥的。
保证互斥执行的最常用机制是 互斥锁 或者 互斥量。
为了解决多个进程或线程直接输入或输出的矛盾,需要遵循以下规则:
线性加速比: T 并行 = T 串行 / p T_{并行}=T_{串行}/p T并行=T串行/p,其中 p p p 表示程序运行所运行的系统的核数。
加速比:
S
=
T
并行
T
串行
S=\cfrac{T_{并行}}{T_{串行}}
S=T串行T并行
并行程序的效率:
E
=
S
P
=
T
串行
p
⋅
T
并行
E=\frac{S}{P}=\cfrac{T_{串行}}{p\cdot{T_{并行}}}
E=PS=p⋅T并行T串行
并行开销的影响:
T
并行
=
T
串行
/
p
+
T
开销
T_{并行}=T_{串行}/p\ +\ T_{开销}
T并行=T串行/p + T开销
阿姆达尔定律:
除非一个串行程序的执行几乎全都并行化,否则,不论有多少可以利用的核,通过并行化所产生的加速比都会是受限的。
假设原串行程序所需时间为
T
串行
T_{串行}
T串行,其中的
α
\alpha
α 的比例能够并行化,假设并行化的这一部分使用
p
p
p 个核,则程序中可以并行化的部分的加速比为
p
p
p。当
p
p
p 趋于无穷大时,该程序的加速比为:
S
∞
=
1
1
−
α
S_{\infty} = \frac{1}{1-\alpha}
S∞=1−α1
假设该程序可并行化部分并行化之后,其整体的加速比为:
S
=
T
串行
α
×
T
串行
/
p
+
(
1
−
α
)
×
T
串行
=
1
α
/
p
+
(
1
−
α
)
S=\frac{T_{串行}}{\alpha\times{T_{串行}}/p\ +\ (1-\alpha)\times{T_{串行}}}=\frac{1}{\alpha/p\ +\ (1-\alpha)}
S=α×T串行/p + (1−α)×T串行T串行=α/p + (1−α)1
所以可得:
S
≤
1
1
−
α
S\le \frac{1}{1-\alpha}
S≤1−α1
可扩展性
假设我们运行一个拥有固定进程或线程数目的并行程序,并且它的输入规模也是固定的,那么我们可以得到一个效率值 E E E。现在,我们增加该程序所用的进程或线程数,如果在输入规模也以相应增长率增加的情况下,该程序的效率一直都是 E E E,那么我们就称该程序是 可拓展的。
如果在增加进程或线程的个数时,可以维持固定的效率,却不增加问题的规模,那么程序称为 强可拓展性的。
如果在增加进程或线程个数的同时,只有以相同倍率增加问题的规模才能使效率值保持不变,那么程序就称为 弱可拓展的。
Foster 方法
MPI 是为 分布式内存系统 的编程而设计的,它提供发送消息的机制。本节将会介绍怎么利用 MPI 使用 消息传递 来对分布式内存系统进行编程。
#include <mpi.h>
mpicc -g -Wall -o outputname.out filename.c
mpiexec -n <number of processes> <executable>
例如:
mpiexec -n 4 ./mpi_hello.out
运行 mpi_hello.out
文件并开启 4 个进程。
//...
#include <mpi.h>
//...
int main(int argc, char *argv[])
{
//...
MPI_Init(&argc, &argv);
//...
MPI_Finalize();
//...
return 0;
}
int my_rank;//进程编号
int comm_sz;//进程数量
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
MPI_Comm_size(MPI_COMM_WORLD, &Comm_sz);
其中 MPI 创建,由所有进程组成的通信子,称为 MPI_COMM_WORLD。
MPI_Send(send_buf_p, send_buf_sz, send_type, dest, send_tag, send_comm);//q 号进程调用
MPI_Recv(recv_buf_p, recv_buf_sz, recv_type, src, recv_tag, recv_comm, &status);//r 号进程调用
则 q 号进程调用 MPI_Send 函数所发送的消息可以被 r 号进程调用 MPI_Recv 函数接受,如果:
MPI_Recv(result, result_sz, result_type, MPI_ANY_SOURCE, result_tag, comm, MPI_STATUS_IGNORE);
- 1
当设置 src 为 MPI_ANY_SOURCE 时,该进程接收所有进程发送给他的信息。
集合通信 不同于只涉及两个进程的 MPI_Send 和 MPI_Recv,它涉及到一个通信子汇总的所有进程。为了区分这两种通信方式,MPI_Send 和 MPI_Recv 常常称为 点对点通信。
#include <pthread.h>
gcc -g -Wall -o outputname.out filename.c -lpthread
./filename.out <number of threads>
例如,运行 4 个线程的程序:
./filename.out 4
当多个线程同时执行时,多个线程执行语句的顺序通常是非确定的。当多个线程试图访问同一个共享资源时,并且其中至少有一个访问是更新操作,这样的访问可能会导致错误,导致结果的不确定性,我们称这种现象为 竞争条件。编写共享内存程序时最重要的任务之一就是识别和更正竞争条件。
临界区 是一个代码块,在这个代码块中,任意时刻还有一个线程能够更新共享资源,因此临界区中的代码执行应该作为串行代码执行。因此在设计程序时,应该尽可能少地使用临界区,并且使用的临界区应该尽可能短。
有三种避免对临界区竞争访问的基本方法:忙等待、互斥量和信号量。
在忙等待中,线程不停地测试某个条件,但实际上,直到某个条件满足之前,不会执行任何有用的工作。
但是要注意编译器的优化会影响忙等待的正确执行。
互斥量 是互斥锁的简称,它是一个特殊类型的变量,通过某些特殊类型的函数,互斥量可以用来限制每次只有一个线程能进入临界区。互斥量保证了一个线程独享临界区,其他线程在有线程已经进入该临界区的情况下,不能同时进入。
产生原因:
忙等待强制顺序线程访问临界区,造成资源浪费。
使用互斥量,顺序由系统自己决定。
在一些应用程序中,我们需要控制线程访问临界区的顺序。
预处理器指令:#pragma
gcc -g -Wall -fopenmp -o outputname.out filename.c
./filename.out <number of processes>
例如:
./omp_hello.out 4
最基本的 parallel 指令可以以如下简单的形式表示:
#pragma omp parallel
许多 OpenMP 指令可以被 子句 修改。使用最频繁的是 num_threads 子句,当时用 OpenMP 指令启动线程组时,可以通过修改 num_threads 子句来启动需要数目的线程。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。