5

我正在尝试使用 OpenMP 中的任务来实现并行算法。并行编程模式是基于生产者-消费者的思想,但是由于消费者进程比生产者慢,所以我想用几个生产者和几个消费者。主要思想是创建与生产者一样多的操作系统线程,然后每个线程将创建要并行完成的任务(由消费者)。每个生产者都将与一定数量的消费者相关联(即 numCheckers/numSeekers)。我在英特尔双芯片服务器上运行算法,每个芯片有 6 个内核。问题是,当我只使用一个生产者(搜索者)和越来越多的消费者(检查者)时,性能会随着消费者数量的增加而迅速下降(见下表),即使正确数量的内核正在以100%。另一方面,如果我增加生产者的数量,平均时间会减少或至少保持稳定,即使消费者数量成比例。在我看来,所有的改进都是通过生产者之间的投入分配来实现的,而任务只是窃听而已。但同样,我对一位制片人的行为没有任何解释。我在 OpenMP-Task 逻辑中遗漏了什么吗?难道我做错了什么?

-------------------------------------------------------------------------
|   producers   |   consumers   |   time        |
-------------------------------------------------------------------------
|       1       |       1       |   0.642935    |
|       1       |       2       |   3.004023    |
|       1       |       3       |   5.332524    |
|       1       |       4       |   7.222009    |
|       1       |       5       |   9.472093    |
|       1       |       6       |   10.372389   |
|       1       |       7       |   12.671839   |
|       1       |       8       |   14.631013   |
|       1       |       9       |   14.500603   |
|       1       |      10       |   18.034931   |
|       1       |      11       |   17.835978   |
-------------------------------------------------------------------------
|       2       |       2       |   0.357881    |
|       2       |       4       |   0.361383    |
|       2       |       6       |   0.362556    |
|       2       |       8       |   0.359722    |
|       2       |      10       |   0.358816    |
-------------------------------------------------------------------------

我的代码的主要部分是休闲:

int main( int argc, char** argv) {

  // ... process the input (read from file, etc...)

  const char *buffer_start[numSeekers];
  int buffer_len[numSeekers];

  //populate these arrays dividing the input
  //I need to do this because I need to overlap the buffers for
  //correctness, so I simple parallel-for it's not enough 

  //Here is where I create the producers
  int num = 0;
  #pragma omp parallel for num_threads(numSeekers) reduction(+:num)
  for (int i = 0; i < numSeekers; i++) {
      num += seek(buffer_start[i], buffer_len[i]);
  }

  return (int*)num;
}

int seek(const char* buffer, int n){

  int num = 0;

  //asign the same number of consumers for each producer 
  #pragma omp parallel num_threads(numCheckers/numSeekers) shared(num)
  {
    //only one time for every producer
    #pragma omp single
    {
      for(int pos = 0; pos < n; pos += STEP){
    if (condition(buffer[pos])){
      #pragma omp task shared(num)
      {
        //check() is a sequential function
        num += check(buffer[pos]);
      }
    }
      }
      #pragma omp taskwait
    }
  return num;
}
4

2 回答 2

5

观察到的行为是由于您没有parallel启用嵌套区域这一事实。发生的情况是,在第一种情况下,您实际上正在经历 OpenMP 任务的巨大开销。这很可能是由于与check()OpenMP 运行时引入的开销相比,没有做足够的工作。为什么它对 1 个和 2 个生产者的行为如此?

当仅使用一个生产者运行时,外部parallel区域仅使用一个线程执行。根据 OpenMP API 规范,这些parallel区域是非活动的,它们只是串行执行内部代码(唯一的开销是额外的函数调用和通过指针访问共享变量)。parallel在这种情况下,尽管在禁用嵌套并行性的情况下嵌套了内部区域,但它变得活跃并刺激了许多任务。任务引入了相对较高的开销,并且这种开销随着线程数的增加而增加。对于 1 个消费者,内部parallel区域也处于非活动状态,因此可以连续运行而没有任务开销。

当与两个生产者一起运行时,外部parallel区域处于活动状态,因此内部parallel区域处于非活动状态(请记住 - 没有启用嵌套并行),因此根本没有创建任何任务 -seek()只是串行运行。没有任务开销,代码运行速度几乎是 1 个生产者/1 个消费者案例的两倍。运行时间不依赖于消费者的数量,因为无论指定多少线程,内部parallel区域始终处于非活动状态。

任务分配和对共享变量的协同访问引入的开销有多大?我创建了一个执行以下代码的简单综合基准测试:

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

我还创建了一个更复杂的变体,它执行缩减的方式与 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列中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 消息引入了非常高的延迟,因此锁定非常昂贵,但并不那么昂贵。使用归约可以消除部分开销,但必须正确实施。首先,reduction 变量的本地副本也应该是线程执行的 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 秒并不远,并且仅来自一次测量(即它可能只是一个统计波动),但理论上它应该更快。

带回家的教训:

  • 阅读有关嵌套parallel区域的 OpenMP 规范。通过将环境变量设置OMP_NESTEDtrue或调用来启用嵌套并行omp_set_nested(1);。如果启用,则活动嵌套的级别可以OMP_MAX_ACTIVE_LEVELS由 Massimiliano 指出的 控制。
  • 寻找数据竞争并尝试使用最简单的方法来防止它们。并非每次使用更复杂的方法都能为您带来更好的性能。
  • 特殊系统通常需要特殊编程。
  • 如有疑问,请使用线程检查器工具(如果有)。Intel 有一个(商业),Oracle 的 Solaris Studio(以前称为 Sun Studio)也有一个(免费;尽管产品名称有 Linux 版本)。
  • 注意开销!尝试将作业分成足够大的块,以便创建数百万个任务的开销不会抵消获得的并行增益。
于 2012-10-24T13:13:40.190 回答
0

正如 Hristo 在评论中已经建议的那样,您应该启用嵌套并行性。这是通过设置环境变量完成的:

  • OMP_NESTED(启用或禁用嵌套并行)
  • OMP_MAX_ACTIVE_LEVELS(控制嵌套活动并行区域的最大数量)

另一方面atomic,我建议采用以下策略,而不是用构造来保护积累:

...
// Create a local buffer to accumulate partial results
const int nthreads = numCheckers/numSeekers;
const int stride   = 32; // Choose a value that avoids false sharing
int numt[stride*nthreads];
// Initialize to zero as we are reducing on + operator
for (int ii = 0; ii < stride*nthreads; ii++)    
  numt[ii] = 0;

#pragma omp parallel num_threads(numCheckers/numSeekers)
{

  //only one time for every producer
  #pragma omp single
  {
    for(int pos = 0; pos < n; pos += STEP){
      if (condition(buffer[pos])){
      #pragma omp task
      {
        //check() is a sequential function
        const int idx = omp_get_thread_num();
        numt[idx*stride] += check(buffer[pos]);
      }
    }
  }
  #pragma omp taskwait

  // Accumulate partial results
  const int idx = omp_get_thread_num();
  #pragma atomic
  num += numt[stride*idx];
}

这应该可以防止由于同时请求写入同一内​​存位置而导致的潜在减速。

请注意,答案的先前版本表明reduction 在最里面的并行区域中使用 是错误的,因为:

出现在最里面的封闭工作共享或并行构造的缩减子句中的列表项可能无法在显式任务中访问

OpenMP 3.1 规范的 §2.9.3.6 不允许。

于 2012-10-23T20:03:05.277 回答