观察到的行为是由于您没有嵌套的事实parallel
启用的区域。发生的情况是,在第一种情况下,您实际上正在经历 OpenMP 任务的巨大开销。这很可能是因为check()
与 OpenMP 运行时引入的开销相比,它没有做足够的工作。为什么 1 和 2 个生产者的表现会如此?
当仅与一个生产者一起运行时,外部parallel
区域仅使用一个线程执行。这样的parallel
地区是inactive根据 OpenMP API 规范,它们只是串行执行内部代码(唯一的开销是额外的函数调用和通过指针访问共享变量)。在这种情况下,内部parallel
区域,虽然在嵌套并行性被禁用时被嵌套,但变为active并激发了很多任务。任务会带来相对较高的开销,并且这种开销随着线程数量的增加而增加。与 1 个消费者的内部parallel
地区也是inactive因此串行运行,没有任务开销。
当与两个生产者一起运行时,外部parallel
地区是active因而内在parallel
区域被渲染inactive(记住 - 没有启用嵌套并行性),因此,根本不会创建任何任务 -seek()
只是串行运行。没有任务开销,代码的运行速度几乎是 1 个生产者/1 个消费者情况的两倍。运行时间不依赖于消费者的数量,因为内部parallel
地区始终是inactive,无论指定了多少个线程。
任务分配和对共享变量的协调访问会带来多大的开销?我创建了一个简单的综合基准测试,它执行以下代码:
for (int i = 0; i < 10000000; i++) {
ssum += sin(i*0.001);
}
在默认优化级别为 GCC 4.7.2 的 Westmere CPU 上,串行执行时间不到一秒。然后我使用简单的方法介绍了任务atomic
构造以保护对共享变量的访问ssum
:
#pragma omp parallel
{
#pragma omp single
for (int i = 0; i < 10000000; i++) {
#pragma omp task
{
#pragma omp atomic
ssum += sin(i*0.001);
}
}
}
(不需要taskwait
这里因为在隐式屏障末尾有一个调度点parallel
region)
我还创建了一个更复杂的变体,它按照 Massimiliano 提出的相同方式执行缩减:
#define STRIDE 8
#pragma omp parallel
{
#pragma omp single
for (int i = 0; i < 10000000; i++) {
#pragma omp task
{
const int idx = omp_get_thread_num();
ssumt[idx*STRIDE] += sin(i*0.001);
}
}
#pragma omp taskwait
const int idx = omp_get_thread_num();
#pragma omp atomic
ssum += ssumt[idx*STRIDE];
}
代码是用 GCC 4.7.2 编译的,如下所示:
g++ -fopenmp -o test.exe test.cc
在双插槽 Westmere 系统(总共 12 个核心)上以批处理模式运行它(因此没有其他进程可以干预),并且具有不同数量的线程和插槽上不同的线程放置,可以获得两个代码的以下运行时间:
Configuration ATOMIC Reduction ATOMIC slowdown
2 + 0 2,79 ±0,15 2,74 ±0,19 1,8%
1 + 1 2,72 ±0,21 2,51 ±0,22 8,4% <-----
6 + 0 10,14 ±0,01 10,12 ±0,01 0,2%
3 + 3 22,60 ±0,24 22,69 ±0,33 -0,4%
6 + 6 37,85 ±0,67 38,90 ±0,89 -2,7%
(运行时间以秒为单位给出,测量方法为omp_get_wtime()
,平均超过 10 次运行/标准。还显示了偏差/;x + y
in the Configuration
列表示x
第一个套接字上的线程和y
第二个插座上的螺纹)
正如您所看到的,任务的开销是巨大的。它比使用的开销高得多atomic
而不是对线程私有累加器应用归约。此外,分配部分atomic
with +=
编译为锁定的比较和交换指令(LOCK CMPXCHG
) - 开销并不比调用高多少omp_get_thread_num()
每一次。
还应该注意的是,双插槽 Westmere 系统是 NUMA,因为每个 CPU 都有自己的内存,并且对另一个 CPU 内存的访问要通过 QPI 链路,因此延迟会增加(并且可能会降低带宽)。作为ssum
变量是共享的atomic
在这种情况下,在第二个处理器上运行的线程本质上是在发出远程请求。尽管如此,两个代码之间的差异可以忽略不计(除了标记的双线程情况 - 我必须调查原因)并且atomic
当线程数量增加时,代码的性能甚至开始优于减少后的代码。
在多尺度 NUMA 系统上,同步atomic
这种方法可能会成为更大的负担,因为它会增加本来就较慢的远程访问的锁定开销。我们的 BCS 耦合节点之一就是这样的系统之一。 BCS (Bull Coherence Switch) 是 Bull 的专有解决方案,它使用 XQPI (eXternal QPI) 将多个 Nehalem-EX 板连接到一个系统中,从而引入三个级别的 NUMA(本地内存;同一板上的远程内存) ;远程板上的远程存储器)。当在一个这样的系统上运行时,该系统由 4 个板组成,每个板有 4 个八核 Nehalem-EX CPU(总共 128 个内核),atomic
可执行文件运行 1036 秒(!!),而缩减方法运行 1047 秒,即两者仍然执行大约相同的时间(我之前的声明是atomic
方法慢了 21.5%,这是由于测量期间操作系统服务抖动所致)。这两个数字均来自单次运行,因此不太具有代表性。请注意,在此系统上,XQPI 链路为板间 QPI 消息引入了非常高的延迟,因此锁定的成本非常高,但不是that昂贵的。可以通过使用归约来消除部分开销,但必须正确实施。首先,归约变量的本地副本也应该位于线程执行的 NUMA 节点的本地副本,其次,应该找到一种方法来不调用omp_get_thread_num()
。这两个可以通过许多不同的方式来实现,但最简单的一种就是使用threadprivate
变量:
static double ssumt;
#pragma omp threadprivate(ssumt)
#pragma omp parallel
{
ssumt = 0.0;
#pragma omp single
for (int i = 0; i < 10000000; i++) {
#pragma omp task
{
ssumt += sin(i*0.001);
}
}
#pragma omp taskwait
#pragma omp atomic
ssum += ssumt;
}
进入ssumt
不需要保护,因为两个任务很少在同一线程中同时执行(必须进一步调查这是否符合 OpenMP 规范)。此版本的代码执行了 972 秒。同样,这与 1036 秒相差不远,并且仅来自一次测量(即,它可能只是统计波动),但从理论上讲,它应该更快。
带回家的教训:
- 阅读有关嵌套的 OpenMP 规范
parallel
地区。通过设置环境变量来启用嵌套并行性OMP_NESTED
to true
或通过致电omp_set_nested(1);
。如果启用,活动嵌套的级别可以由OMP_MAX_ACTIVE_LEVELS
正如马西米利亚诺所指出的。
- 留意数据争用,并尝试使用最简单的方法来防止它们。并非每次使用更复杂的方法都能带来更好的性能。
- 特殊系统通常需要特殊编程。
- 如有疑问,请使用螺纹检查工具(如果有)。 Intel 有一个(商业),Oracle 的 Solaris Studio(以前称为 Sun Studio)也有一个(免费;尽管产品名称有 Linux 版本)。
- 注意开销!尝试将作业分割成足够大的块,以便创建数百万个任务所产生的开销不会抵消所获得的并行增益。