从 OpenMP* 着手入门

摘要


您现在可能已经了解到,如果想充分利用含超线程(HT)技术的处理器的性能优势,就必须并行执行应用。可是,应用的并行执行需要线程参与,且应用的线程化也并非易事。其实,我们可以借助 OpenMP* 这样的工具更轻松地实现应用线程化。

本文作为系列白皮书三步曲之第一步,旨在教授那些具有丰富经验的 C/C++ 编程人员如何借助 OpenMP 充分利用超线程(HT)技术的优势。本文作为开篇之首,首先将为您详细介绍如何并行执行循环,即:工作共享。我们的第二篇白皮书将教您如何利用非循环并行能力和 OpenMP 的其它特性进行编程。最后,我们将会在末篇与您深入探讨 OpenMP 的运行时库函数以及英特尔® C++ 编译器是如何在出现错误时调试您的应用。


OpenMp 简介

OpenMP 的设计人员希望能为编程人员提供一个简单方法,以使他们无需了解如何制作、同步和毁坏线程的知识,甚至无需决定创建的线程数,即可轻松地线程化其应用程序。他们专门开发了一套独立于平台的编译器范式、指令、函数调用和环境变量,以明确地指导编译器如何将线程插入应用,并明晰地指出应用插入的准确位置。这样,大多数循环仅在开始循环前直接插入一条编译指令即可实现线程化。此外,您还可以将细节事情留给编译器和 OpenMP 处理,从而赢取更多时间来决定哪些循环应该线程化,以及如何最佳重组算法获得最高性能。至此,在使用OpenMP 对“热点”(您应用中最耗时的循环)进行线程化时,OpenMP 的性能将可实现最优化。

下面这个例子就为我们展示了 OpenMP强大的功能和简易性。下列循环可将 32 位 RGB(红、绿、蓝)像素转换为 8 位灰阶像素。它只需在开始循环前直接插入一条编译指令,即可实施并行执行。

#pragma omp parallel for

for (i=0; i < numPixels; i++)

{

   pGrayScaleBitmap[i] = (unsigned BYTE)

            (pRGBBitmap[i].red * 0.299 +

             pRGBBitmap[i].green * 0.587 +

             pRGBBitmap[i].blue * 0.114);

}

让我们来详细了解一下这个循环。首先,上例中使用了“工作共享”。“工作共享”作为 OpenMP所用的一个一般术语,主要用于描述线程间工作的分配情况。如上例所示,当工作共享用于 for 构造函数时,循环迭代会在多个线程间进行分配,这样就可确保每个循环迭代刚好执行一次,并与一个或以上的线程并行执行。由于 OpenMP 完全可以自行决定创建的线程数量,以及如何以最佳方式创建、同步和毁坏线程。所以,编程人员只需告知 OpenMP 应该针对哪个循环进行线程化,即可轻松完成全部工作。

OpenMP 对可以线程化的循环设置了以下五个限制条件:

  • 循环变量必须为带符号整数型。DWORD 等无符号整数无法实现线程化。
  • 必须按照loop_variable <、<=、>、或 >= loop_invariant_integer 形式执行比较操作。
  • for 循环的第三个表达式或增量部分必须与一个循环不变式值进行整数相加或整数相减。
  • 如果比较操作的形式为 < 或 <=,则循环变量必须每个迭代增加一次;反之,如果比较操作的形式为 > 或 >=,则循环变量必须每个迭代减少一次。
  • 循环必须为一个基本模块,意即不允许从内循环跳至外循环,除非使用 exit 语句来终止整个应用。如果使用 goto 或 break 语句,则它们必须在循环内部,而非外部进行跳跃。这同样适用于异常处理;但异常情况必须控制在循环内部。
尽管上述限制条件显得有些苛刻,但您可以依照这些限制条件轻松地对不符合条件的循环进行重新编写。


编译基本要点

如欲使用 OpenMP 范式进行编程,则需要一个与 OpenMP 相兼容的编译器和多个线程安全库。通常,最理想的选择是英特尔® C++ 编译器 7.0 版或更高版本。(英特尔® Fortran 编译器也支持 OpenMP。)而将下列命令行选项加入编译器,则可以起到提醒它注意 OpenMP 编译指令并插入线程的作用。

如果您省略命令行上的  /Qopenmp ,则编译器将忽略 OpenMP 编译指令,为您提供一个十分简单的方法,这样无需改变任何源代码即可生成单线程版本。此外,英特尔® C++ 编译器还支持 OpenMP 2.0 规范。如欲获得最新信息,请您务必查看与英特尔® C++ 编译器一道发布的版本说明和兼容性信息。如欲获得完整的 OpenMP 规范,请访问: http://www.openmp.org

对于条件编译而言,编译器会定义 _OPENMP。如有必要,此定义可接受下列测试。

    #ifdef _OPENMP


        fn();


    #endif

所有 OpenMP 编译指令的通用形式是:

如果命令行不以  pragma omp  开头,则它不是 OpenMP 编译指令。如欲获得规范中的编译指令完整列表,请访问: http://www.openmp.org  

您可使用  /MD  或  /MDd  (调试用)命令行编译器选项挑选 C 运行时线程安全库。通过在面向 C/C++ 项目设置的代码生成类中使用 Microsoft Visual C++* 来选择多线程 DLL 或调试多线程 DLL,即可获得这些选项。


几个简单的示例

下列示例表明了 OpenMP 使用起来十分简单。在一般的实践中,我们都会不可避免地遇到一些突发问题,而只有坚持不懈解决难题,才能在发展路上不断前行。

问题 1: 下列循环将一个阵列修剪为 0 到 255 之间的范围。您需要使用 OpenMP 编译指令对其进行线程化。

解决方案: 在循环开始前直接插入下列编译指令即可。

问题 2: 下列循环可生成从数字 0 到 100 的平方根表。您需要使用 OpenMP 对其进行线程化。

解决方案: 循环变量不是带符号整数,因此需要对其进行更改,并为其添加编译指令。


避免数据相关性和竞态条件

由于循环存在着数据相关性,因此即使循环全部满足上述五个限制条件,且编译器对循环也进行了线程化,循环仍可能无法正常进行。这是因为,只要循环进行不同迭代,或者更确切点说,当循环迭代在不同的线程读写共享内存上执行时,数据相关性就会存在。请参考下列阶乘计算范例。
    // Do NOT do this. It will fail due to data dependencies.
    // Each loop iteration writes a value that a different
    iteration reads.
    #pragma omp parallel for
    for (i=2; i < 10; i++)
    {
       factorial[i] = i * factorial[i-1];
    }

编译器对循环进行线程化之所以失败,是因为至少有一个循环迭代与另外一个不同的迭代存在着数据相关性。这样的情形我们称之为“竞态条件”。竞态条件只在使用共享资源(如内存)和并行执行时出现。要解决这一问题,您可以重新编写循环,或者选择一个不包含竞态条件的算法。

一般来说,竞态条件很难被检测到,因为在给定的情况下,变量有可能会以程序函数正常运行的顺序“赢得竞争”。而一项程序一次正常运行恰恰不表示永远正常运行,所以,您最好先在各种机器上测试您的程序,譬如某些支持超线程(HT)技术的机器和某些采用了多个物理处理器的机器等,才能确保程序万无一失,顺利运行。此外,英特尔® 线程检测器等工具犹如敏锐的“眼睛”,也会对您的检测工作提供帮助。而此时,传统的调试器在检测竞态条件方面将变得毫无用处,因为一旦它们让一个线程停止“竞争”,其它线程将继续大幅度地改变运行时行为。


管理共享数据和私人数据

几乎每个循环(至少在其有用时)都会读取和写入内存,所以编程人员只需告知编译器哪部分内存应在线程间共享、以及哪部分应保持私有状态即可轻松完成工作任务。而一旦内存确定为共享,所有线程就将访问同一内存地址。当内容确定为私有时,每个线程将获得一份用于私有访问的单独变量副本。当循环结束时,这些私有副本将被销毁。在默认情况下,除了私有的循环变量外,所有变量均可共享。内存将通过以下两种方式宣告私有:

  • 宣告循环内部的变量不含静态关键字,且位于并行 OpenMP 指令内部。
  • 指定 OpenMP 指令上的私有子句。
由于变量  temp  为共享,所以下列循环无法正常运行。只有将它变为私有,循环才能正常运行。

    // WRONG. Fails due to shared memory.


    // Variable temp is shared among all threads, so while one

    thread


    // is reading variable temp another thread might be writing to

    it


    #pragma omp parallel for


    for (i=0; i < 100; i++)


    {


       temp = array[i];


       array[i] = do_something(temp);


    }


下列两例均宣告变量  temp  为私有内存,这样便轻松解决了上述问题。

    // This works. The variable temp is now private


    #pragma omp parallel for


    for (i=0; i < 100; i++)


    {


       int temp; // variables declared within a

    parallel construct


        // are, by definition, private


       temp = array[i];


       array[i] = do_something(temp);


    }


    // This also works. The variable temp is declared private


    #pragma omp parallel for private(temp)


    for (i=0; i < 100; i++)


    {


       temp = array[i];


       array[i] = do_something(temp);


    }


每次在指示 OpenMP 对循环进行并行化处理时,您应仔细检查包括调用函数所作的引用在内的所有内存引用。并行构造函数内部声明的变量被定义为私有变量,但其采用静态声明符声明的情况除外(因为静态变量不在堆栈上分配)。


Reduction 子句

事实上,累计值的循环十分普遍,OpenMP 即是采用一个特定的子句来支持此类循环。您可以采用下列循环来计算一组整数的和。
    sum = 0;


    for (i=0; i < 100; i++)


    {


       sum += array[i];   // this

    variable needs to be shared to generate


                //

    the correct results, but private to avoid


                 //

    race conditions from parallel execution


    }


前面循环中的变量  sum  必须与其它循环共享,才能生成正确的结果,而它只有是私有变量时,才可允许多个线程访问。为了解除变量的这一两难处境,我们可以使用 OpenMP 提供的  reduction  子句,高效地对循环中的一个或以上变量进行数学归约运算联合操作。下列循环就是采用 reduction  子句,生成了正确的结果。

    sum = 0;


    #pragma omp parallel for reduction(+:sum)


    for (i=0; i < 100; i++)


    {


       sum += array[i];


        }


实际上,OpenMP 可为每个线程提供变量  sum  的私有副本。而当线程退出时,它还会将值加在一起,并将结果放在变量的一个全局副本中。

下表列出了可能的归约运算和临时私有变量的初始变量值(也是数学标识值)。



在给定并行结构上指定以逗号分隔的变量和归约运算符后,即可在循环中执行多个归约运算操作。它的唯一要求如下:
  1. 仅在一次归约中列出归约变量
  2. 它们无法声明为常量,且
  3. 它们在并行结构中无法声明为私有。


循环排程

负载均衡(即在线程间平均分配工作)是并行应用性能最为重要的属性之一。它之所以十分重要,主要在于它能确保处理器大多数时间(如果不是所有时间)均处于运行状态。如果负载不均衡,有些线程就可能会领先其它线程,先行完成工作,这就会使处理器资源闲置,白白流失性能提升机会。

在循环结构内部,负载不均衡通常来自循环迭代之间计算时间的差异。而通过检查资源代码,就可轻松确定循环迭代计算时间的差异。在大多数情况下,您会看到各个循环迭代所消耗的时间量是相同的。如果事实并非如此,则也可能找到一套消耗近似时间量的迭代。例如,有时所有偶数迭代消耗的时间与所有奇数迭代所消耗的时间大致相当。同样,一个循环上半部分消耗的时间与下半部分所消耗的时间也有可能大致一样。当然,退一步讲,我们也可能无法找到一套执行时间完全相同的循环迭代。但是,无论应用处于上述何种情形,您都应为 OpenMP 提供额外的循环排程信息,以便它更平均地在线程之间(乃至处理器之间)分配循环迭代,从而实现最佳负载均衡。

默认情况下,OpenMP 一般都会假定所有循环迭代所消耗的时间相同。在此前提下,OpenMP 在线程间大致平均分配循环迭代,就可将因错误共享引起的内存冲突几率降至最低。这是因为循环一般按顺序读写内存,因此将循环分成几大块(正如使用两个线程时要分成前半部分和后半部分一样)就会使内存重叠的机会降到最低,所以这种做法完全可行。但是,事物都有其两面性,尽管这种方法可能是解决内存问题的理想之举,但它却不利于保持负载均衡。尤为遗憾的是,这两方面的矛盾性业已证实:一般来说,对负载均衡有利则可能对内存性能不利。因此,性能工程师必须通过测量性能,以此找寻何种方法能够实现理想效果,从而在最佳内存使用和最佳负载均衡之间找到一个完美的平衡点。

采用下列句法可以将循环排程信息传送给并行结构上的 OpenMP。

    #pragma omp parallel for schedule(kind [, chunk size])


如下表所示,可为 OpenMP 提供四种不同的循环排程(提示)。但是,可选参数(块)(指定的)必须是一个循环不变量正整数。




范例

问题:将下列循环并行化
    for (i=0; i < NumElements; i++)


    {


       array[i] = StartVal;


       StartVal++;


    }

解决方案:注意数据相关性

如上所述,由于循环包含数据相关性,因此只有进行快速更改才可能使其并行化。下列所示的新循环就以同样的方式填充阵列,但却不包含数据相关性。此外,新循环还可采用 SIMD 指令进行编写。

    #pragma omp parallel for


     for (i=0; i < NumElements; i++)


    {


       array[i] = StartVal + i;


    }

值得一提的是:代码并非百分之百相同,这是因为变量  StartVal  的值没有增加。所以,当并行循环完成时,得到的变量值会不同于串行版本生成的值。如果您在循环结束后还需要  StartVal  的值,则需要添加下列所示的语句。

    // This works and is identical to the serial version.


    #pragma omp parallel for


     for (i=0; i < NumElements; i++)


    {


       array[i] = StartVal + i;


    }


    StartVal += NumElements;



总结

我们此次编写 OpenMP 入门知识,主要是为了让编程人员能够从线程化的细枝末节中解放出来,有更多的时间关注更重要的问题。有了 OpenMP 编译指令,大多数循环仅使用一个简单的语句即可实现线程化。这也正是 OpenMP 的威力所在,是其关键优势的完美体现。本白皮书向您介绍了 OpenMP 的相关概念和简易方法,便于读者入门实践。我们后两篇文章将在此基础上,教您如何使用 OpenMP 线程化更复杂的循环和更常见的编程结构。 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值