中文图书推荐:《OpenMP编译原理及实现技术》
5.2 串行程序的性能考虑
目前,单核处理器的性能经常归因为未充分利用的cache内存子系统。特别地,缓存分层中的最高层缓存未命中的代价是高昂的,因为这意味着数据在使用之前必须从主内存中获取。典型地,相比从缓存中获取数据,通常需要付出5-10倍更多的代价。在一个共享内存多核处理器系统中,这一负面影响更为严重:涉及的线程越多,潜在地性能问题越大。
我们简约的讨论了存储器分层和它的影响,因为这对OpenMP来说是如此的重要。我们强烈建议,编程者在创建OpenMP代码时考虑串行性能,特别是目标是一个可扩展的OpenMP应用。
5.2.1 内存访问模式和性能
现代内存系统被组织为一个分层次的等级结构,最大也是最慢的内存部分被称为主存(main memory)。主存被组织成页,主存的页是应用程序可以获取的一个子集。靠近处理器的内存层相对较小较快,共同被称为缓存(cache)。当一个程序被编译,编译器会安排它的数据对象被存储在主存中;它们会在需要时被传输到缓冲中。如果一个计算需要的值尚未在缓存中(我们称之为缓存“丢失”),它必须从更高层次的内存等级结构中被获取到,这个过程的代价是相当昂贵的。程序数据被带进缓存块,每一个都会占据缓存的一行。在缓存中已存在的数据可能需要被移除,为新的数据块腾出空间。不同的系统在决定移除的缓存具有不同的策略。
存储器分层不能被用户或者编译器明确的可编程(除了极少数例外)。数据在必要时动态地获取到缓存和驱逐出缓存。有很多策略可以帮助编译器和编程者间接地减少缓存“丢失”。一个主要的目标是组织数据访问,从而使数据值在缓存中时,尽可能地多被使用。这么做最常见的策略是基于一个事实,编程语言通常指定数组元素被连续地存储在存储器中。因此,如果一个数组元素被取到缓存中,数组的“附近”元素会在相同的缓存块,作为相同的传输中的一部分被获取。如果一个计算在它们仍然在缓存中时,使用任何这些可被执行的值,会有性能上的提升。
在C语言中,一个二维数组按行存储。当一个数组元素被传输到缓存,同一行内相邻元素通常也会被传输作为同一缓存行。
5.2.2 翻译后备缓冲区(TLB)
我们已跳过了关于存储系统的一个重要细节。TLB在性能的关键路径上。当一个计算所需的数据和确定物理位置所需的信息不在TLB中,处理器等待直到请求的信息可用。只有那时,它才能传输值并回复执行。因此,和数据缓存一样,好好利用TLB入口是重要的。当一个页的位置被存储在TLB中时,我们喜欢经常引用这个页。无论何时当一个程序访问数据不是以存储器顺序,经常性的缓存重载附加大量的TLB丢失就可能会发生。
5.2.3 循环优化
编程者和编译器都可以提升内存的使用效率。因为很多程序花费了它们很多时间来执行循环,并且因为绝大多数数组访问是在那里,在循环嵌套中一个合适的计算重组织以利用缓存可以明显提升一个程序的性能。很多循环转换可能帮组实现这一目标。这个测试如下:
在一个循环嵌套中,如果任何存储器位置被引用超过一次,并且如果这些引用中至少有一个修改了它的值,那么它们的相对顺序不能被转换所修改。
循环展开是一种强大的技术,有效减少循环执行的开销(由循环变量的递增,完成测试和到循环代码开始的分支引起的)。循环展开通过提升数据复用,可以帮组提高缓存行利用率。它也可以帮组提高指令级并行化(ILP)。为了完成这点,转换工作打包几次循环迭代到一次,通过复制和恰当修改循环中的声明。
for (int i=1; i
计算5.3 一个短循环嵌套 --- 当每次迭代只有少量运算时,循环开销相对较高
计算5.3中的循环每次迭代加载四个数组元素,执行三次浮点加法,存储两个值。循环开销包括循环变量的递增,测试它的值和跳到循环开始的分支。相比之下,计算5.4显示了循环展开后,加载五个值,执行六次浮点加法,和存储四个值,以相同的开销。执行循环嵌套的总的开销已被减半。数据复用也已提升。
for (int i=1; i
计算5.4 一个展开的训话 --- 计算5.3中的循环已被展开,以减少循环开销。我们假设迭代的次数可以被2整除
在此例子中,循环体一次执行两次迭代。这一数字被称为“展开因子”。合适的选择取决于多种约束。较高的值会有较高的性能,但是也增加了所需寄存器的数目。现在,编程者很少需要手动做这一转换,因为编译器非常擅长做这个。它们也非常擅长确定最优的展开因子。
如果循环已包含大量计算或者如果它包含过程调用,循环展开通常不是个好主意。前一种情况很可能意味着会使缓存使用较为低效,后一种引入新的开销相比所节省的。如果在循环中有分支,收益也可能是低的。
循环融合(Loop fusion)合并两个或更多的循环,创建一个大循环。这可能会使缓存中的数据更高频率被重用,或者会提升每次迭代的计算量以提高指令级并行化,也使得有较低的循环开销,因为每次迭代做了更多的工作。
for (int i=0; i
计算5.10:都访问数组a的一对循环 --- 第二个循环重用a[i],但是当它被执行,这一元素所在缓存行可能不再在缓存中
for (int i=0; i
计算5.11:循环融合的一个例子 --- 计算5.10的循环对已被组合,并且声明被记录。这允许数组a的值被立即重用
循环分裂(Loop fission)是一种将一个循环打破分成几个循环的转换。有时,我们可能使用这种方式提高缓存的利用率或者孤立阻止循环完全优化的一部分。如果一个循环嵌套很大并且它的数据不能正好放入缓存,或者是我们可以对循环的一部分以不同的方式进行优化,这种技术是最有用的。
for (int i=0; i
计算5.12 缓存利用率佳并且内存访问不好的循环 --- 如果我们可以分离数组c的更新,循环交换可以被使用来修复这个问题
for (int i=0; i
计算5.13 循环分裂 --- 计算5.12的循环嵌套已被分成循环对,紧接着循环交换被使用到第二个循环来提升缓存利用率
5.2.4 在C语言中使用指针和连续内存
内存在C应用中被广泛使用,但是在性能调整时它们造成一些列挑战。C语言的内存模型是这样的,没有附加信息,必须假定所有内存可能引用任何内存地址。这通常被称为指针别名问题。它阻止了编译器执行很多程序优化,因为它不能确定它们是安全的。结果,性能会有损失。但是如果指针确保指向不重叠的内存,例如因为每个指针目标内存通过一个明确的malloc函数被分配,更为激进的优化技术可以被使用。通常,只有编程者知道一个指针可能指向的内存位置。restrict关键字告诉编译器,一个指针指向的内存不会被另一个指针指向的内存区域所覆盖。
声明一个线性数组代表一个二维数组。如果被声明为二维数组的指针,编译器在考虑内存布局时,必须做一个更为保守的假设。这对编译器优化代码的能力有负面影响。矩阵的线性化确保一个连续的内存块被使用,这帮助编译器分析和优化循环嵌套来提高内存利用率。它也会引起更少的内存访问,并且可能提高软件控制的数据预取,如果支持的话。
5.2.5 使用编译器
现代编译器实现了5.2.3中所述绝大多数的循环优化。它们执行很多分析来决定是否它们可能被使用(主要一个被称为数据依赖性分析)。它们也应用很多技术来减少执行的操作数量,重排代码以更好地利用硬件。它们执行的工作量可以被应用开发者所影响。一旦计算结果的正确性是受保证的,试验编译器选项来榨取应用的最大性能是值得的。这些选项(或标志)在编译器之间有很大不同,因此必须为每个编译器重新探索。回想编译器转换代码的能力受限于它分析程序和决定可以安全修改部分的能力。我们已经看到这可能受指针存在的影响。另一种问题出现,当编译器不能提高内存使用效率,由于涉及修改非本地数据的结构。这里编程者必须采取行动,一些代码的重写可能会有更好的结果。
5.3 衡量OpenMP性能
做并行化后的Amdahl定律:
在此模型中,Tserial是应用程序原本串行版本的CPU时间。处理器核数为P。并行开销表示为Op乘以P,Op被假设为常量百分比(这做了简化,因为开销可能会随着处理器数目的增加而增加)。已被并行化的部分被指定为f,在0到1之间。f=0表示应用时串行的。f=1是最佳的并行应用。
5.3.1 理解一个OpenMP程序的性能
在前面部分,我们已看到存储器行为对于串行应用的性能是至关重要的,我们注意到这也适用于OpenMP代码。
OpenMP的显著性能受下列因素的影响,除了那些在串行程序中发挥作用的性能:
>独立线程访问存储器的方式。如后面会看到的,这对性能有重大的影响。在整个程序中,如果每个线程一致地访问数据的一个独特部分,可能会非常好的使用存储器分层,包括线程本地缓存。
> 处理OpenMP结构的时间量。我们称这些为(OpenMP)并行开销。
> 同步点的负载均衡。
> 其它同步开销。典型地,线程浪费时间等待访问一个临界区或一个包含原子更新的变量,或者为了获取一个锁。这些共同被称为同步开销。
5.4 最佳实践
在此部分,我们提供一些关于如何写一个高效的OpenMP程序的一般性建议。
5.4.1 优化屏障使用
无论屏障(barrier)如何高效地被实现,它们是代价高昂的操作。减少它们的使用到代码的最少需求是值得的。幸运的是,nowait条例使得很容易地消除一些结构中隐含的屏障。
一个推荐的策略:首先确保OpenMP程序工作正确,然后尽可能使用nowait条例,需要时在程序的特殊点小心地插入显示的屏障。当这么做时,需要特别小心地识别和排序读写相同内存部分的计算。
如计算5.21中所演示。向量a和c是独立被更新的。因为新值a和c随后被用于计算sum,我们在此之前必须加入屏障。具体如下:
#pragma omp parallel default(none) \
shared(n,a,b,c,d,sum) private(i)
{
#pragma omp for nowait
for (i=0; i
计算5.21 减少屏障的数目 --- 在读取向量a和c的值之前,所有这些向量的更新必须完成。一个屏障确保这点。
5.4.2 避免顺序结构
顺序结构通常可以避免。例如,在循环之外等待和执行I/O。
5.4.3 避免大片临界区
如果可能,一个原子更新是更好的选择。另一种方法是重写代码片段,尽可能分开这些不会导致竞争的计算,它们不需要被保护。
5.4.4 最大化并行区域
滥用并行区域可能导致表现不佳。开销伴随启动和终止一个并行区域。大并行区域提供使用缓冲中数据更多的机会,并且为编译器提供一个更大的上下文来优化。因此,最小化并行区域的数量是值得的。
例如,如果我们有多个并行循环,我们必须选择是否封装每个循环到一个独立的并行区域,或创建一个并行区域包括他们中的所有。
替代方法在计算5.24中被列出。它具有更少的隐含屏障,并且可能有循环之间的潜在的缓存数据复用。这个方法的缺点是,不能调整基于每个循环的线程数,但这通常不是个限制。
#pragma omp parallel for
for (.....)
{
/*-- Work-sharing loop 1 --*/
}
#pragma omp parallel for
for (.....)
{
/*-- Work-sharing loop 2 --*/
}
.........
#pragma omp parallel for
for (.....)
{
/*-- Work-sharing loop N --*/
}
计算5.23 多个组合的并行化的工作共享循环 --- 每个并行化的循环增加并行化开销,并且具有不能忽略的隐含屏障
#pragma omp parallel
{
#pragma omp for /*-- Work-sharing loop 1 --*/
{ ...... }
#pragma omp for /*-- Work-sharing loop 2 --*/
{ ...... }
.........
#pragma omp for /*-- Work-sharing loop N --*/
{ ...... }
}
5.24 单个并行化区域包含所有工作共享的for循环 --- 并行化区域的开销分摊到多个工作共享循环中
5.4.5 避免在内循环中的并行区域
另一个提升性能的常用技术是移出最内部循环中的并行区域。否则,我们会一再体验并行结构的开销。例如,计算5.25中所示循环迭代,#pragma omp parallel for结构的开销是n方次。
for (i=0; i
计算5.25 并行区域嵌入到循环迭代中 --- 并行区域的开销发生了n方次
一种更为高效的解决方案显示在计算5.26中。#pragma omp parallel for结构被分裂它的构成指令,#pragma omp parallel已被移动来包括整个循环嵌套。#pragma omp for任然在最内层循环。取决于最内层循环的工作量,可以看到一个显著地性能增益。
#pragma omp parallel
for (i=0; i
计算5.26 并行区域移出循环迭代 --- 并行化结构开销最小化。
5.4.6 解决贫穷的负载均衡
在一些并行化算法中,线程有不同的工作量要做。在此警告,dynamic和guided负载分配调度具有更高的开销,相比做static方案。如果负载均衡足够严重,这个代价通过更为灵活的分配到线程的工作抵消掉。实验这些方案是个好主意,同时包括块大小的多种值。