通过避免或消除人工相关性实现并行性 (PDF 186KB)
摘要
实现线程之间应用工作负载平衡是确保出色性能的关键。 实现负载平衡主要是为了最大限度缩短线程的闲置时间。 在尽量减少工作任务分配开销的情况下,在所有线程之间平均分配工作负载,可以减少浪费在不能进行运算的闲置线程上的时间,进而可显著提高性能。 然而,实现完美的负载平衡绝非易事,这主要取决于应用并行性、工作负载、线程数量、负载平衡策略和线程实施情况。
本文是“英特尔多线程应用开发指南”系列的一部分,该系列介绍了针对英特尔® 平台开发高效多线程应用的指导原则。
背景
面向并行处理的多线程是确保性能的重要因素,同时也对确保每个线程高效运行起着重要的作用。 尽管优化编译器有助于实现这一点,程序员一般不会通过重复利用数据以及选择有利于设备的指令,来更改源代码以提高性能。 遗憾的是,能够提高串行性能的相同方法也会产生数据相关性,从而难以通过多线程获得更出色的性能。
重复使用中间结果来避免重复计算就是一个例子。 例如,用相邻图像中的加权平均像素(包括该图像)来替换每个图像像素,便可通过模糊的方式来弱化图像。 以下伪代码介绍了 3x3 模糊模板:
for each pixel in (imageIn) sum = value of pixel // compute the average of 9 pixels from imageIn for each neighbor of (pixel) sum += value of neighbor // store the resulting value in imageOut pixelOut = sum / 9
事实上,多种计算都会使用所有的像素值,从而有助于通过重复使用数据来提高性能。 在以下伪代码中,中间结果被计算和使用了三次,从而获得了更佳的串行性能。
subroutine BlurLine (lineIn, lineOut) for each pixel j in (lineIn) // compute the average of 3 pixels from line // and store the resulting value in lineout pixelOut = (pixel j-1 + pixel j + pixel j+1) / 3 declare lineCache[3] lineCache[0] = 0 BlurLine (line 1 of imageIn, lineCache[1]) for each line i in (imageIn) BlurLine (line i+1 of imageIn, lineCache[i mod 3]) lineSums = lineCache[0] + lineCache[1] + lineCache[2] lineOut = lineSums / 3
这种优化方式使得输出图像的相邻行的计算之间产生了相关性。 如果并行计算该循环的迭代,相关性将会造成错误的结果。
另一个常见的例子是循环内部的指针发生偏移:
ptr = &someArray[0] for (i = 0; i < N; i++) { Compute (ptr); ptr++; }
通过增加 ptr,代码可能会利用寄存器增量的快速运算方法,并避免使用计算所有迭代的someArray[i]的算法。 在所有计算相互独立时,指针将会产生依赖性,它在每次迭代中的值将取决于其在之前迭代中的值。
最后,经常会发生这样的情况,当需要执行并行算法时,数据结构已被用于其它目的,从而会无意地阻碍并行性。 稀疏矩阵算法就是这样的例子。 因为大多数矩阵元素都为零,常规矩阵表达式通常会被“封装的 (packed)”形式所代替,包含了元素值以及相关的偏移量,用来跳过零值的项。
本文介绍了在这些棘手情形中有效引入并行性的策略。
建议
当然,最好能够找到无需移除现有优化或进行大量源代码变更同时能够充分利用并行性的方式。 在移除串行优化以利用并行性之前,需要考虑是否能够通过将现有内核运用于整体问题的子集来保留优化。 通常情况下,如果初始算法包含并行性,则也可以将子集作为独立单元进行定义,并进行并行计算。
为了有效地实现模糊运算线程化,可以考虑将图像细分为子图像,或固定大小的数据块。 模糊算法支持独立地对数据块进行计算。 以下伪代码阐释了图像模块化的使用方法:
// One time operation: // Decompose the image into non-overlapping blocks. blockList = Decompose (image, xRes, yRes) foreach (block in blockList) { BlurBlock (block, imageIn, imageOut) }
用于模糊整个图像的现有代码可在执行 BlurBlockk 时重复使用。 利用 OpenMP 或显式线程来并行运算多个数据块有助于获得多线程优势,并保留最初优化的内核。
在其它情况下,由于现有串行优化的优势不足以冲抵所有迭代的整体成本,所以没有必要进行模块化。 通常在迭代的粗粒度足以使并行处理加速时会出现此类情况。 指针增量就是这样的例子。 归纳变量可以轻松地为显式指数 (explicit indexing) 所取代,从而移除相关性,并支持循环进行简单的并行处理。
ptr = &someArray[0] for (i = 0; i < N; i++) { Compute (ptr[i]); }
注意,初始优化虽然比较小,但不可丢失。 编译器通常会通过利用增量或其它快速运算对指数计算进行大范围的优化,从而带来串行和并行性能的双重优势。
在其它情况下,比如涉及封装稀疏矩阵的代码,进行线程化会更加困难。 通常情况下,对数据结构进行解包并不可行,但通常可以将矩阵细分为数据块,将指针存储在每个数据块的开始位置。 当这些矩阵与合适的数据块算法实现配对时,便可同时获得封装表达式和并行性的双重优势。
上面介绍的模块化方法更加普遍,被称为“域分解 (domain decomposition)。” 分解后,每个线程都可在一个或多个域中独立运行。 在某些情况下,算法和数据的特性可以表明,每个域中的工作几乎是连续进行的。 在其它情况下,工作的总量会因域的不同而有所差异。 而在域的数量与线程的数量相等的情况下,并行性能将会受到负载不平衡的限制。 总而言之,最好能够确保域的数量要大于线程的数量。 这将有助于通过动态调度 (dynamic scheduling) 等技术来实现整个线程的负载平衡。
使用指南
一些串行优化能够带来巨大的性能提升。 要确保并行处理加速的优势超过与移除优化有关的性能损失,考虑一下所需的处理器的数量。
引入数据块算法有时会阻碍编译器区分别名 (aliased) 和非别名 (unaliased) 数据。 如果在模块化后,编译器无法再确定数据是否属于非别名数据,那么性能将会受到影响。 可考虑利用严格的关键词来明确地阻止进行别名化 (aliasing)。 利用程序间的优化也能帮助编译器检测非别名数据。