并行硬件和并行软件
背景
并行硬件和软件是从传统的串行硬件和软件发展而来的。
冯·诺依曼结构 (The von Neumann architecture)
经典冯·诺伊曼体系结构由主存储器 (main memory)、中央处理单元 (CPU)或处理器或核心,以及存储器和CPU之间的互连 (interconnection)组成。
主存储器由一组locations组成,每个location都会存储指令和数据。每个location由一个地址组成,这个地址用来访问对应的location及存储在其中的指令和数据。
中央处理单元分为控制单元(control unit)和算术逻辑单元(arithmetic and logic unit, ALU)。控制单元负责决定程序中的哪些指令应该被执行,而ALU负责执行实际的指令。CPU中的数据和关于正在执行的程序的状态的信息存储在寄存器中。控制单元有一个叫做程序计数器的特殊寄存器。它存储要执行的下一条指令的地址。
指令和数据通过互连在CPU和内存之间传输。这里有一个总线 (bus)的概念,它由一组并行电线和一些硬件控制对电线的访问组成。一台冯·诺伊曼机器一次只执行一条指令,而且每条指令只对几条数据进行操作。
在这个结构中,内存和CPU的分离通常被称为冯·诺依曼瓶颈,因为互连决定了访问指令和数据的速率。
对冯·诺伊曼模型的修改
自从20世纪40年代第一台电子数字计算机被开发出来以来,计算机科学家和计算机工程师已经对基本的冯·诺伊曼结构做了许多改进。许多目标是减少冯·诺依曼瓶颈问题,但也有许多目标仅仅是让CPU运作得更快。这里讨论缓存、虚拟内存和低级并行这三个改进。
缓存
缓存是解决冯·诺伊曼瓶颈最广泛使用的方法之一。一般来说,缓存是一组内存位置的集合,访问这些位置的时间比访问其他内存位置的时间要短。此时,决定哪些数据和指令应该存储在缓存中就成了一个问题。 关于这个问题,普遍的想法是程序倾向于使用与最近使用的数据和指令在物理上接近的数据和指令。以如下循环为例:
float z[1000];
...
sum = 0.0;
for (i = 0 ; i < 1000; i++)
sum += z[i];
数组被分配在连续内存位置的块。例如,存储z[1]
的位置紧跟在z[0]
之后。因此,只要i < 999
,读入z[i]
后立即读入z[i + 1]
。
这就称为局部性 (locality):即在访问一个内存位置(指令或数据)之后,程序通常会在不久的将来(时间局部性, temporal locality)访问一个附近的位置(空间局部性, spatial locality)。
为了利用局部性原则,内存访问将有效地操作数据块和指令,而不是单个指令和单个数据项。这些块称为缓存块或缓存线。
仍以上面循环举例,如果一条高速缓存线存储了 16 16 16个浮点数,那么当第一次计算sum += z[0]
时,系统可能会从内存读取z
, z[0]
, z[1]
,…, z[15]
到缓存。所以接下来的 15 15 15次加法运行将会使用已经在缓存中的z
元素。
缓存通常被划分为不同的级别:第一级(L1)是最小和最快的,还有更高的级别(L2, L3,…),级别越高容量越大,访问速度越慢。
当CPU需要访问一条指令或数据时,它沿着缓存的层次结构依次运行:首先检查一级缓存,然后检查二级缓存,依此类推。最后,如果需要的信息不在任何缓存中,它就访问主存。当缓存中存在所需信息且该信息可用时,被称为缓存命中 (hit)。如果信息不可用,则称为缓存丢失 (miss)。
缓存设计中的另一个问题是决定线路应该存储在哪里。也就是说,如果从主存中取出一条高速缓存线,它应该放在高速缓存的哪个位置?这个问题的答案因系统的不同而不同。
-
一个极端是完全关联缓存 (fully associative cache),在这种情况下,可以将新行放在缓存中的任何位置。
-
另一种极端是直接映射缓存 (direct mapped cache),其中每个缓存线在缓存中都有一个唯一的位置,它将被分配到该指定位置。
-
中间方案称为n向集结合 (n-way set associative)。在这类方案中,每条缓存线可以被放置在缓存中的 n n n个不同位置中的一个。
当超过一行数据要映射到缓存中的几个不同的位置时,(比如line 4可以映射到位置0或者位置1,但是位置0和位置1上已经存有个各自的数据或指令),此时需要能够决定哪一行应该被替换或逐出。针对这个问题,最常用的方案是替换“最近最少使用”的那一行数据。
CPU缓存的工作是由系统硬件控制的,在编程过程中,程序员并不能直接决定哪些数据和哪些指令在缓存中。因此,了解了空间和时间局部性的原理,就可以 间接地 控制缓存。
比较以下两段嵌套for
循环代码:
double A[Max][MAX], x[MAX], y[MAX];
/* 初始化A[MAX][MAX]和x[MAX],令y[MAX]=0 */
for (i = 0; i < MAX; i++)
for (j = 0; j < MAX; j++)
y[i] += A[i][j] * x[j];
/* 令y[MAX]=0 */
for (j = 0; j < MAX; j++)
for (i = 0; i < MAX; i++)
y[i] += A[i][j] * x[j];
根据上述描述,可以分析得出,第一段嵌套for
循环代码要比第二段快很多。因为对于数组A
的访问,第一段代码一共缓存丢失 MAX \texttt{MAX} MAX次,剩下 MAX ⋅ ( MAX − 1 ) \texttt{MAX}\cdot(\texttt{MAX}-1) MAX⋅(MAX−1)次均能命中;而第二段代码则丢失 MAX 2 \texttt{MAX}^2 MAX2次。
实际上,如果令 MAX = 1000 \texttt{MAX} = 1000 MAX=1000,第一段嵌套循环大约比第二段快三倍。