使用OpenMP-Tasks的生产者 - 消费者

时间:2012-10-23 16:05:43

标签: c multithreading task openmp

我正在尝试使用OpenMP中的任务实现并行算法。 并行编程模式基于生产者 - 消费者的想法,但是 由于消费者流程比生产者慢,我想使用一些 生产者和几个消费者。 主要思想是创建与生产者一样多的OS线程,然后创建每个 这些将创建要由消费者并行完成的任务。一切 生产者将与相应数量的消费者相关联(即 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;
}

2 个答案:

答案 0 :(得分:5)

观察到的行为是由于您没有启用嵌套的parallel区域。会发生的是,在第一种情况下,您实际上遇到了OpenMP任务的巨大开销。这很可能是由于{0}与OpenMP运行时引入的开销相比没有做足够的工作。为什么它与1和2个生产者一样?

当只使用一个生产者运行时,外部check()区域仅使用一个线程执行。根据OpenMP API规范,此类parallel区域非活动,它们只是串行执行代码(唯一的开销是附加函数调用和通过指针访问共享变量)。在这种情况下,内部parallel区域虽然在嵌套并行性被禁用时嵌套,但变为活动并且会激活许多任务。任务引入了相对较高的开销,这种开销随着线程数的增加而增加。对于1个消费者,内部parallel区域也不活动,因此可以顺序运行而无需任务开销。

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

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

parallel

Serulous在Westmere CPU上执行的时间不到一秒,默认优化级别为GCC 4.7.2。然后,我使用简单的for (int i = 0; i < 10000000; i++) { ssum += sin(i*0.001); } 构造介绍了任务,以保护对共享变量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区域末尾的隐式屏障处有一个调度点

我还创造了一个更复杂的变体,以与Massimiliano提出的方式相同的方式进行缩小:

parallel

代码是用GCC 4.7.2编译的,如:

#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];
}

在双插槽Westmere系统(总共12个核心)上以批处理模式运行它(因此没有其他进程可以介入),在插槽上有不同数量的线程和不同的线程放置,一个获得以下运行时间两个代码:

g++ -fopenmp -o test.exe test.cc

(运行时间以秒为单位,以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% 衡量,平均超过10次运行/标准偏差也显示/; omp_get_wtime()x + y表示Configuration第一个套接字上的线程和第二个套接字上的x个线程)

正如您所看到的,任务的开销很大。它远远高于使用y而不是将减少应用于线程专用累加器的开销。此外,带有atomic的{​​{1}}的赋值部分编译为锁定的比较和交换指令(atomic) - 每次调用+=的开销不会高很多。

还应该注意,双插槽Westmere系统是NUMA,因为每个CPU都有自己的内存,并且访问另一个CPU的内存通过QPI链路,因此延迟增加(并且带宽可能更低)。由于LOCK CMPXCHG变量在omp_get_thread_num()情况下共享,因此在第二个处理器上运行的线程实际上是在发出远程请求。尽管如此,两个代码之间的差异可以忽略不计(除了标记的双线程情况 - 我必须调查原因)并且ssum代码甚至在线程数越来越高时开始优于具有减少的代码。

在多尺度NUMA系统上,atomic方法中的同步可能会成为更多的负担,因为它会增加已经较慢的远程访问的锁定开销。一个这样的系统是我们的BCS耦合节点之一。 BCS(Bull Coherence Switch)是Bull的专有解决方案,它使用XQPI(eXternal QPI)将几个Nehalem-EX板连接到一个系统中,引入三级NUMA(本地存储器;同一块板上的远程存储器) ;远程板上的远程内存)。当在一个这样的系统上运行时,由4个板组成,每个板有4个octocore Nehalem-EX CPU(总共128个核心),atomic可执行文件运行1036秒(!!),而减少方法运行1047秒,即两者仍然执行大约相同的时间(我先前声明atomic方法慢了21.5%是由于测量期间OS服务抖动造成的)。这两个数字都来自单次运行,因此不具有代表性。请注意,在此系统上,XQPI链接为板间QPI消息引入了非常高的延迟,因此锁定非常昂贵,但 昂贵。部分开销可以通过使用还原来消除,但必须正确实施。首先,reduce变量的本地副本也应该是线程执行的NUMA节点的本地副本,其次,应该找到一种不调用atomic的方法。这两个方法可以通过多种方式实现,但最简单的方法就是使用atomic变量:

omp_get_thread_num()

threadprivate的访问不需要保护,因为在同一个线程中很少同时执行两个任务(如果这符合OpenMP规范,则必须进一步调查)。此版本的代码执行972秒。再一次,这距离1036秒不远,仅来自一次测量(即它可能只是一个统计波动),但从理论上讲,它应该更快。

带回家的教训:

  • 阅读有关嵌套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; } 区域的OpenMP规范。通过将环境变量ssumt设置为parallel或调用OMP_NESTED,可以启用嵌套并行性。如果启用,活动嵌套的级别可以由Massimiliano指出的true控制。
  • 留意数据竞赛,并尝试使用最简单的方法来阻止它们。并非每次使用更复杂的方法都会为您带来更好的性能。
  • 特殊系统通常需要特殊编程。
  • 如有疑问,请使用线程检查工具(如果有)。英特尔有一个(商业),而Oracle的Solaris Studio(以前称为Sun Studio)也有一个(免费;尽管产品名称也有Linux版本)。
  • 记住开销!尝试以足够大的块分割作业,以便创建数百万个任务的开销不会否定获得的并行增益。

答案 1 :(得分: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不允许