借助 OpenMP* 实现更多工作共享

 

摘要

作者:Richard Gerber

正如您所知,OpenMP* 包含一组非常强大的编译制导语句,能够帮助实现循环的并行化。但是您可能还不知道,OpenMP 的线程化功能不仅仅局限于循环。一旦“parallel for”结构出现故障,OpenMP 便可发出其它制导语句、结构和函数调用来进行救援。

这是白皮书三部曲中的第二篇。我们的一系列白皮书将为您——经验丰富的 C/C++ 程序员详细介绍 OpenMP 的入门知识,以及如何在您的应用中简化编程的创作、同步处理以及删除工作。其中第一篇向您简要介绍了 OpenMP 最为常见的特性:循环工作共享。第二篇白皮书将教您如何利用非循环并行能力和 OpenMP 的其它一些常见特性进行编程。最后一篇将重点讨论 OpenMP 的运行库,以及如何在出现错误时调试您的应用。

并行 (Parallel) 结构

在本系列的第一篇白皮书《OpenMP* 入门》中,您已经了解了“parallel for”指令是如何将循环迭代拆分到多个线程上的。当 OpenMP 遇到“parallel for”指令时,便会创建线程,并将循环迭代分配到其中。这时,在并行区域的末尾,线程将被暂挂,并等待下一个并行段。可是,这种创建和暂挂功能会产生一些开销,而且如果两个循环相邻(如下例所示),就没必要使用此功能。

     #pragma omp parallel for
     for (i=0; i<x; i++)
     fn1();
     #pragma omp parallel for // adds unnecessary 
                              // overhead
     for (i=0; i<y; i++)
     fn2();

如果一次性进入一个并行段,并直接对其进行工作拆分,便可避免上述开销。并且,由于以下代码进入并行段的流程仅执行一次,所以尽管看似与前面的代码在功能上完全相同,但是运行速度会更快。

     #pragma omp parallel
     {
     #pragma omp for
     for (i=0; i<x; i++)
         fn1();
     #pragma omp for
     for (i=0; i<y; i++)
           fn2();
     }

理想情况下,应用的所有关键性能部分(也称为热点)都将在并行段中执行。但是在现实中由于仅由循环构成的程序基本不存在,因此需要更多的结构来处理非循环代码。

区段

“section” 结构指示 OpenMP 将应用的已识别段分配到多个线程上。下面的示例中便使用了工作共享来处理循环和区段:

     #pragma omp parallel
     {
        #pragma omp for
           for (i=0; i<x;
    i++)
    Fn1();
        #pragma omp sections
        {
     #pragma omp section
     {
     TaskA();
     }
     #pragma omp section
     {
           TaskB();
     }
     #pragma omp section
     {
           TaskC();
     }
        }

在这里,OpenMP 首先创建了一组线程,然后将循环迭代分配到其中。循环结束后,区段被分配到线程中,从而可使每条线程与其它线程精确地并行执行一次。如果程序包括的区段数量多于线程数,则剩余的区段将在线程处理完前面的区段之后再行调度。与循环迭代不同,OpenMP 将完全控制线程执行区段的方式、时间和顺序。但是,您仍然可以通过与循环结构相同的方式使用“私有”和“减少”子句,来控制变量的分享或私有属性。

Barrier 与 Nowait

Barrier 是 OpenMP 用于同步处理线程的一种同步化形式。所有线程执行到“barrier” 时要停止,直到并行段中的所有线程都执行到该“barrier”时才继续往下执行。您一直都在使用隐式“barrier”,只不过没有意识到它在针对结构的工作共享中的作用。该隐式“barrier”在循环结束时,会等待所有线程完成循环后,再让程序继续执行更多工作。该“barrier”可使用“nowait” 子句移除,如以下代码范例所示。

     #pragma omp parallel
     {
     #pragma omp for nowait
         for (i=0; i<100; i++)
    compute_something();
     #pragma omp for
        for (j=0; j<500; j++)
     more_computations();
     }

在以上范例中,程序不等所有线程处理完第一个循环便会立即继续处理第二个循环。这种“因情而定”的处理方式将使您受益匪浅,可显著缩短线程的空闲时间。此外,“nowait”子句可与区段结构共同使用,移除隐式“barrier”。

反之,它也支持“barrier”的添加,如下例所示。

     #pragma omp parallel
     {
     A bunch of work...
     #pragma omp barrier
     Additional work...
     }

当所有线程需要在完成更多工作之前结束一项任务时,这一结构就会非常实用,例如在显示帧缓冲前对其进行更新。

主线程与单线程

不幸的是,应用却不仅仅只由循环和并行段两部分构成。并行区域内无疑更需要仅由一条线程执行一次的情况,尤其是当您为了减少开销而尽可能的增大并行段的大小的时候。为了满足这一需求,OpenMP 内置了一种方法,用以指定并行段中的代码顺序应仅由一条线程执行一次。并且,OpenMP 还指派了特定的单线程来执行。但是如果需要,您也可指定仅由主线程来执行代码,如下例所示。

#pragma omp parallel
    {
        do_multiple_times();
        // every thread calls
        //this function
        #pragma omp for
           for (i=0; i<100;
    i++)
        // this loop is divided among the
    threads
       
       fn1();
    // implicit barrier at the end of the above loop
    // will cause all thread to synchronize here
        #pragma omp master
       
       fn_for_master_only();
        // only the master thread calls this
    function
        #pragma omp for nowait
           for (i=0; i<100;
    i++)
        // again, loop is divided among
    threads
          
       fn2();
// The above loop does not have a barrier, and
// threads will not wait for each other. One thread,
// hopefully the first one done with the above loop,
// will continue and execute the code below.
    #pragma omp single
       one_time_any_thread();
    // any thread will execute this
  }

“Atomic”指令操作

并行执行代码时,很有可能需要同步共享内存。根据定义,“atomic”指令操作保证不会被打断,并且可用于更新共享内存位置的语句,以避免一些竞态条件。在以下代码行中,程序员已经明确了变量在语句当中保持稳定的重要性。

a[i] += x; // may be interrupted half-complete

虽然单条汇编指令的执行从未被打断过,但是高级语言(如 C/C++)中的语句却会被转化为多条汇编指令,因而有可能被打断。在上例中,a[i] 的值可能会在读值、添加变量 x,以及将值写回内存等的汇编语句中变化。但是,以下 OpenMP 结构却能确保语句自动执行代码,不存在被打断的可能性。

#pragma omp atomic
a[i] += x; // never interrupted because defined 
           //atomic

“Atomic”指令操作是下列非过载操作中的基本形式之一。

Expr1++++expr1
Expr1----Expr1
Expr1 += expr2Expr1 -= expr2
Expr1 *= expr2Expr1 /= expr2
Expr1 <<= expr2Expr1 >>= expr2
Expr1 &= expr2Expr1 ^=expr2
Expr1 |= expr2 

 

如果操作系统具备相关特性和硬件能力,那么 OpenMP 可选择最有效的方法来执行语句。

 

“Critical Section”

“critical section”可以保护代码块免受多次访问。当一条线程遇到关键段时,只有在其它线程都没有处于任何关键段时,才能进入其中一个或几个区段。以下范例使用了一个未命名的关键段。

     #pragma omp critical
     {
         if (max < new_value)
    max = new_value
     }

由于每个线程都会有力竞争相同的全局关键段,因而全局或未命名的关键段未必会影响性能。为此,OpenMP 对关键段进行了命名。这些命名的关键段可支持更细化的同步,所以只有那些需要在特殊区段上隔离的线程才会被隔离。下面的范例即依此对以上范例的编码进行了改进。

     #pragma omp critical(maxvalue)
     {
           if (max <
    new_value)
     max = new_value
     }

借助命名的关键段,应用可以拥有多个关键段,线程也可一次性进入一个以上的关键段。值得注意的是,进入嵌套的关键段有可能导致死锁,这一点 OpenMP 无法探测到。因此,在使用多个关键段时,要格外细心检查那些可能“隐藏”在子程序中的关键段。

First Private, Last Private

添加到您工具条中的 OpenMP 制导语句和功能是“firstprivate”“lastprivate” 子句。这些子句可在变量的全局“主”副本与私有临时副本之间复制数值,反之亦然。

“firstprivate”子句用于指定 OpenMP 采用主变量的值对私有变量的值进行初始化。通常,临时私有变量并未定义初始值,因而可节省副本的性能开销。“lastprivate”子句可以在破坏发生前,将变量中最后一个迭代(按照顺序)计算出的值复制到主变量中。因此,变量可以同时声明为“firstprivate”“lastprivate”

通常,我们可通过对代码进行细小改动来避免使用这些子句。但是在特定环境中,它们却十分有用。例如,以下代码可将彩色图片转换为黑白图片。

     for (row=0; row<height; row++)
     {
        for (col=0; col<width; col++)
        {
           pGray[col] = (BYTE)
     (pRGB[row].red * 0.299 +
     pRGB[row].green * 0.587 +
     pRGB[row].blue * 0.114);
        }
        pGray += GrayStride;
        pRGB += RGBStride;
     }

但是,如何在位图内将指示器移动到正确的位置呢?我们可以通过以下代码执行每一迭代的地址计算:

     pDestLoc = pGray + col + row * GrayStride;
     pSrcLoc = pRGB + col + row * RGBStride;

不仅如此,该代码还能够在每个像素上执行许多额外的计算。同样,如下例所示,“firstprivate” 子句可用作一种初始化指令:

BOOL FirstTime=TRUE;

#pragma omp parallel for private (col) 
firstprivate(FirstTime, pGray, pRGB)
   for (row=0; row<height; row++)
   {
      if (FirstTime == TRUE)
      {
         FirstTime = FALSE;
         pRGB += (row * RGBStride);
         pGray += (row * GrayStride);
      }
      for (col=0; col<width; col++)
      {
         pGray[col] = (BYTE) (pRGB[row].red * 0.299 +
                  pRGB[row].green * 0.587 +
                  pRGB[row].blue * 0.114);
      }
      pGray += GrayStride;
      pRGB += RGBStride;
   }
}

 

技巧范例

 

问:请描述以下两个循环在执行上的不同之处。

循环 1:(parallel for)

     int i;
     #pragma omp parallel for
     for (i='a'; i<='z'; i++)
     printf ("%c", i);

循环 2:(仅 parallel)

     int i;
     #pragma omp parallel
     for (i='a'; i<='z'; i++)
     printf ("%c", i);

答:第一个循环仅打印一次字母表,第二个循环可将字母表打印多次。如果变量 i 被声明为私有,那么它将在每个线程上打印一次字母表。

总结

OpenMP 拥有多种功能,旨在简化实现应用线程化所需的编程工作。结合本文及本系列第一篇白皮书中介绍的技巧,您将能够并行处理大部分算法。

参考资料 

OpenMP 规范:http://www.openmp.org*

了解更多信息 

 

连接 

 

有关编译器优化的更完整信息,请参阅优化通知