OpenMP与C++:事半功倍地获得多线程的好处(上)
KangSu Gatlin & Pete Isensee 著
赖勇浩 译
在并行计算领域有一个广为流传的笑话——并行计算是未来之事并且永远都是。这个小笑话几十年来一直都是对的。一种类似的观点在计算机架构社区中流传,处理器时钟速度的极限似乎近在眼前,但时钟速度却一直在加快。多核革命是并行社区的乐观和架构社区的悲观的冲突。
现在主流的CPU厂商开始从追求时钟频率转移到通过多核处理器来增加并行支持。原因很简单:把多个CPU内核封装在一个芯片里可以让双核单处理器系统就像双处理器系统一样、四核单处理器系统像四处理器系统一样。这一实用方法让CPU厂商在能够提供更强大的处理器的同时规避了加速频率的诸多障碍。
到此为止这听起来是一个好消息,但事实上如果你的程序没有从多核里获取优势的话,它并不会运行得更快。这就是OpenMP的用武之地了。OpenMP可以帮助C++开发者更快地开发出多线程应用程序。
在这短小的篇幅里完整讲述OpenMP这个大而强的API库的相关内容是不可能的。因此,本文仅作一些初始介绍,通过示例让你能够快速地应用OpenMP的诸多特性编写多线程应用程序。如果你希望阅读更深入的内容,我们建议你去OpenMP的网站看看。
在VisualC++中使用OpenMP
OpenMP标准作为一个用以编写可移植的多线程应用程序的API库,规划于1997年。它一开始是一个基于Fortran的标准,但很快就支持C和C++了。当前的版本是OpenMP 2.0(译者注:最新版本已经是2.5版), VisualC++ 2005和XBox360平台都完全支持这一标准。
在我们开始编码之前,你需要知道如何让编译器支持OpenMP。VisualC++ 2005提供了一个新的/openmp开关来使能编译器支持OpenMP指令。(你也可以通过项目属性页来使能OpenMP指令。点击配置属性页,然后[C/C++],然后[语言],选中OpenMP支持。)当/openmp参数被设定,编译器将定义一个标识符_OPENMP,使得可以用#ifndef _OPENMP来检测OpenMP是否可用。
OpenMP通过导入vcomp.lib来连接应用程序,相应的运行时库是vcomp.dll。Debug版本导入的连接库和运行时库(分别为vcompd.lib和vcompd.dll)有额外的错误消息,当发生异常操作时被发出以辅助调试。记住尽管Xbox360平台支持静态连接OpenMP,但VisualC++并不支持。
OpenMP中的并行
OpenMP应用程序刚运行时只有一条线程,这个线程我们叫它主线程。当程序执行时,主线程生成一组线程(包括主线程),随着应用程序执行可能会有一些区域并行执行。在并行结束后,主线程继续执行,其它线程被挂起。在一个并行区域内能够嵌套并行区域,此时原来的线程就成为它所拥有的线程组的主线程。嵌套的并行区域能够再嵌套并行区域。
(图1)OpenMP并行段
图1展示了OpenMP如何并行工作。在最左边黄色的线是主线程,之前这一线程就像单线程程序那样运行,直到在执行到点1——它的第一个并行区域。在并行区域主线程生成了一个线程组(参照黄色和桔黄色的线条),并且这组线程同时运行在并行区域。
在点2,有4条线程运行在并行区域并且在嵌套并行区域里生成了新的线程组(粉红、绿和蓝)。黄色和桔黄色进程分别作为他们生成的线程组的主线程。记住每一个线程都可以在不同的时间点生成一个新的线程组,即便它们没有遇到嵌套并行区域。
在点3,嵌套的并行区域结束,每一个嵌套线程在并行区域同步,但并非整个区域的嵌套线程都同步。点4是第一个并行区域的终点,点5则开始了一个新的并行区域。在点5开始的新的并行区域,每一个线程从前一并行区域继承利用线程本地数据。
现在你基本了解了执行模型,可以真正地开始练习并行应用程序开发了。
OpenMP的构成
OpenMP易于使用和组合,它仅有的两个基本构成部分:编译器指令和运行时例程。OpenMP编译器指令用以告知编译器哪一段代码需要并行,所有的OpenMP编译器指令都 以#pragmaomp开始。就像其它编译器指令一样,在编译器不支持这些特征的时候OpenMP指令将被忽略。
OpenMP运行时例程原本用以设置和获取执行环境相关的信息,它们当中也包含一系列用以同步的API。要使用这些例程,必须包含OpenMP头文件——omp.h。如果应用程序仅仅使用编译器指令,你可以忽略omp.h。
为一个应用程序增加OpenMP并行能力只需要增加几个编译器指令或者在需要的地方调用OpenMP函数。这些编译器指令的格式如下:
#pragma omp<directive> [clause[ [,]clause]…]
dierctive(指令)包含如下几种:parallel,for,parallelfor,section,sections,single,master,criticle,flush,ordered和atomic。这些指令指定要么是用以工作共享要么是用以同步。本文将讨论大部分的编译器指令。
对于directive(指令)而言clause(子句)是可选的,但子句可以影响到指令的行为。每一个指令有一系列适合它的子句,但有五个指令(master,cirticle,flush,ordered和atomic)不能使用子句。
指定并行
尽管有很多指令,但易于作为初学用例的只有极少数的一部分。最常用并且最重要的指令是parallel。这条指令为动态长度的结构化程序块创建一个并行区域。如:
#pragma omp <directive> [clause[ [,]clause]…]
structured-block
这条指令告知编译器这一程序块应被多线程并行执行。每一条指令都执行一样的指令流,但可能不是完全相同的指令集合。这可能依赖于if-else这样的控制流语句。
这里有一个惯常使用的“Hello,World!”程序:
#pragma ompparallel
{
printf("HelloWorld/n");
}
在一个双处理器系统上,你可能认为输入出下:
HelloWorld
Hello World
但你可能得到的输出如下:
HellHell ooWorWlodrl
d
出现这种情况是因为两条线程同时并行运行并且都在同一时间尝试输出。任何时候超过一个线程尝试读取或者改变共享资源(在这里共享资源是控制台窗口),那就可能会发生紊乱。这是一种非确定性的bug并且难以查出。程序员有责任让这种情况不会发生,一般通过使用线程锁或者避免使用共享资源来解决。
现在来看一个比较实用的例子——计算一个数组里两个值的平均值并将结果存放到另一个数组。这里我们引入一个新的OpenMP指令:#pragma omp parallelfor。这是一个工作共享指令。工作共享指令并不产生并行,#pragma omp for工作共享指令告诉OpenMP将紧随的for循环的迭代工作分给线程组并行处理:
#pragma ompparallel
{
#pragma ompfor
for(int i = 1; i< size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
}
在这个例子中,设size的值为100并且运行在一个四处理器的计算机上,那么循环的迭代可能分配给处理器p1迭代1-25,处理器p2迭代26-50,处理器p3迭代51-75,处理器p4迭代76-99。在这里假设使用静态调度的调度策略,我们将在下文讨论更深层次的调度策略。
还有一点需要指出的是这一程序在并行区域的结束处需要同步,即所有的线程将阻塞在并行区域结束处,直到所有线程都完成。
如果前面的代码没有使用#pragmaomp for指令,那么每一个线程都将完全执行这个循环,造成的后果就是线程冗余计算:
#pragma ompparallel
{
for(int i = 1; i< size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
}
因为并行循环是极常见的的可并行工作共享结构,所以OpenMP提供了一个简短的写法用以取代在#pragma omp parallel后面紧跟#pragma omp for的形式:
#pragma omp parallel for
for(int i = 1; i< size; ++i)
x[i] = (y[i-1] +y[i+1])/2;
你必须确保没有循环依赖,即循环中的某一次迭代不依赖于其它迭代的结果。例如下面两个循环就有不同的循环依赖问题:
for(int i = 1; i<= n;++i) // Loop(1)
a[i] = a[i-1] + b[i];
for(int i = 0; i< n;++i) // Loop (2)
x[i] = x[i+1] + b[i];
并行的Loop1的问题是因为当执行第i层迭代时需要用到i-1次迭代的结果,这是迭代i到i-1的依赖。并行的Loop2同样有问题,尽管原因有些不同。在这个循环中能够在计算x[i-1]的值之前计算x[i]的值,但在这样并行的时候不能再计算x[i-1]的值,这是迭代i-1到i的依赖。
当并行执行循环的时候必须确保没有循环依赖。当没有循环依赖的时候,编译器将能够以任意的次序执行迭代,甚至在并行中也一样。这是一个编译器并不检测的重要需求。你应该有力地向编译器断言将要并行执行的循环中没有循环依赖。如果一个循环存在循环依赖而你告诉编译器要并行执行它,编译器仍然会按你说的做,但结果应该是错误的。
另外,OpenMP对在#pragma omp for或#pragma omp parallelfor里的循环体有形式上的限制,循环必须使用下面的形式:
for([integer type] i= loop invariant value;
i{<,>,=,<=,>=}loop invariant value;
i {+,-}= loop invariant value)
这样OpenMP才能知道在进入循环时需要执行多少次迭代。
OpenMP和Win32线程比较
当使用WindowsAPI进行线程化的时候,用#pragma ompparallel为例来比较它们有利于更好地比较异同。从图2可见为达到同样的效果Win32线程需要更多的代码,并且有很多幕后魔术般的细节难以了解。例如ThreadData的构造函数必须指定每一个线程被调用时开始和结束的值。OpenMP自动地掌管这些细节,并额外地给予程序员配置并行区域和代码的能力。
DWORD ThreadFn(void*passedInData) { ThreadData*threadData = (ThreadData *)passedInData; for(int i =threadData->start; i <threadData->stop; ++i ) x[i] = (y[i-1] + y[i+1]) / 2; return0; } voidParallelFor() { // Startthread teams for(int i=0;i < nTeams; ++i) ResumeThread(hTeams[i]); // ThreadFnimplicitly called here on each thread // Wait forcompletion WaitForMultipleObjects(nTeams, hTeams, TRUE,INFINITE); } int main(int argc, char*argv[]) { // Createthread teams for(int i=0;i < nTeams; ++i) { ThreadData *threadData = new ThreadData(i); hTeams[i] = CreateThread(NULL, 0, ThreadFn,threadData, CREATE_SUSPENDED, NULL); } ParallelFor(); // simulate OpenMP parallel for // Cleanup for(int i=0;i < nTeams; ++i) CloseHandle(hTeams[i]); } |
(图2)Win32多线程编程
共享数据与私有数据
在编写并行程序的时候,理解什么数据是共享的、什么数据是私有的变得非常重要——不仅因为性能,更因为正确的操作。OpenMP让共享和私有的差别显而易见,并且你能手动干涉。
共享变量在线程组内的所有线程间共享。因此在并行区域里某一条线程改变的共享变量可能被其它线程访问。反过来说,在线程组的线程都拥有一份私有变量的拷贝,所以在某一线程中改变私有变量对于其它线程是不可访问的。
默认地,并行区域的所有变量都是共享的,除非如下三种特别情况:一、在并行for循环中,循环变量是私有的。如图3里面的例子,变量i是私有的,变量j默认是共享的,但使用了firstprivate子句将其声明为私有的。
float sum =10.0f; MatrixClassmyMatrix; int j =myMatrix.RowStart(); int i; #pragma ompparallel { #pragma ompfor firstprivate(j) lastprivate(i) reduction(+:sum) for(i = 0; i< count; ++i) { int doubleI = 2 * i; for(; j < doubleI; ++j) { sum += myMatrix.GetElement(i, j); } } } |
(图3)OpenMP子句与嵌套for循环
二、并行区域代码块里的本地变量是私有的。在图3中,变量doubleI是一个私有变量——因为它声明在并行区域。任一声明在myMatrix::GetElement里的非静态变量和非成员变量都是私有的。
三、所有通过private,firstprivate,lastprivate和reduction子句声明的变量为私有变量。在图3中变量i,j和sum是线程组里每一个线程的私有变量,它们将被拷贝到每一个线程。
这四个子句每个都有一序列的变量,但它们的语义完全不同。private子句说明变量序列里的每一个变量都应该为每一条线程作私有拷贝。这些私有拷贝将被初始化为默认值(使用适当的构造函数),例如int型的变量的默认值是0。
firstprivate有着与private一样的语义外,它使用拷贝构造函数在线程进入并行区域之前拷贝私有变量。
lastprivate有着与private一样的语义外,在工作共享结构里的最后一次迭代或者代码段执行之后,lastprivate子句的变量序列里的值将赋值给主线程的同名变量,如果合适,在这里使用拷贝赋值操作符来拷贝对象。
reduction与private的语义相近,但它同时接受变量和操作符(可接受的操作符被限制为图4列出的这几种之一),并且reduction变量必须为标量变量(如浮点型、整型、长整型,但不可为std::vector,int[]等)。reduction变量初始化为图4表中所示的值。在代码块的结束处,为变量的私有拷贝和变量原值一起应用reduction操作符。
Reduction operator | Initialized value (canonicalvalue) |
+ | 0 |
* | 1 |
- | 0 |
& | ~0(every bit set) |
| | 0 |
^ | 0 |
&& | 1 |
|| | 0 |
(图4)Reductoin操作符
在图3的例子中,sum对应于每一个线程的私有拷贝的值在后台被初始化为0.0f(记住图4表中的规范值为0,如果数据类型为浮点型就转化为0.0f。)在#pragmaomp for代码块完成后,线程为所有的私有sum和原值做+操作(sum的原值在例子中是10.0f),再把结果赋值给原本的共享的sum变量。
非循环并行
OpenMP经常用以循环层并行,但它同样支持函数层并行,这个机制称为OpenMP sections。sections的结构是简明易懂的,并且很多例子都证明它相当有用。
现在来看一下计算机科学里一个极其重要的算法——快速排序(QuickSort)。在这里使用的例子是为一序列整型数进行递归的快速排序。为了简单化,我们不使用泛型模板版本,但其仍然可以表达OpenMP的思想。图5的代码展示了如何在快速排序的主要函数中应用sections(为简单起见我们忽略了划分函数)。
void QuickSort (intnumList[], int nLower, int nUpper) { if (nLower<nUpper) { // create partitions int nSplit = Partition (numList, nLower, nUpper); #pragma omp parallel sections { #pragma omp section QuickSort (numList, nLower, nSplit - 1); #pragma omp section QuickSort (numList, nSplit + 1, nUpper); } } } |
(图5)用OpenMPsections实现Quicksort
在这个例子中,第一个#pragma创建一个sections并行区域,每一个section用#pragmaomp section前缀指令声明。每一个section都将被分配线程组里的单独线程执行,并且所有的sectoins能够确保一致并行。每一个并行section都递归地调用QuickSort。
就像在#pragma omp parallelfor结构中一样,你有责任确保每一个section都不依赖于其它的sectoin,以使得它们能够并行执行。如果sectoins在没有同步存取资源的情况下改变了共享资源,将导致未定义结果。
在本例中像使用#pragma omp parallelfor一样使用了简短的#pragma omp parallelsections。你也可以使用单独使用#pragma ompsections,后跟一个并行区域,就像你在#pragmaomp for里做的一样。
在图5的程序实现中我们需要了解一些东西。首先,并行的sections递归调用,并行区域是支持递归调用的,特别在本例中并行sectoins就只是递归调用。因此如果使能并行嵌套机制,程序递归调用QuickSort时将产生大量新线程。这可能是也可能不是程序员所期望的,因为它导致产生相当多的线程。程序能够不使能并行嵌套机制以限制线程数量。不使能嵌套机制的时候,应用程序将在两条线程上递归调用QuickSort,而绝不会产生多于两条的线程。
另外,如果没有打开/openmp开关,编译器将生成完美的正确的串行快速排序实现。OpenMP的好处之一就是能够与不支持OpenMP的编译器中共存。
用以同步的编译器指令
在多个线程并发的时候,某一线程常常会需要同步其它线程。OpenMP支持多种类型的同步,以在不同的情境下解决问题。
其中之一就是暗含的barrier同步。在每一个并行区域都有一个暗含的barrier,用以同步并行区域中的所有线程。一个barrier同步要求所有线程执行到此,然后才能往下执行。
#pragma omp for,#pragma ompsingle和#pragma ompsections程序块都有暗含的barrier同步。从上述三种工作共享的程序块中去除暗含的barrier同步的方法是增加nowait子句:
#pragma omp parallel
{
#pragma omp for nowait
for(int i = 1; i < size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
}
如你所见,工作共享指令中的nowait子句指明线程不需要在for循环结束时同步,尽管线程将在并行区域结束处同步。
另一种是明确声明barrier同步,在一些情境下你可能需要在并行区域出口之外放置barrier同步,这时你可以在代码里加一个#pragmaomp barrier指令。
临界区能够像barrier那样使用,在Win32 API中通过EnterCriticalSection和ExitCriticleSection来进出临界区。OpenMP通过#pragma omp critical[name]指令给予程序员同样的能力。这与Win32临界区有同样的语义,并且隐藏了EnterCriticleSection的调用。你可以使用命名的临界区,这种情况下代码段仅与同名临界区互斥。如果没有指定临界区名字,则映射到用户未定义的名字。这些未命名的临界区与区域相关的每一临界区互斥。
在一个并行区域里,经常限制同时只有一条线程能够访问一段代码,例如在并行区域的中间写文件。大多数这种情况下,并不关心哪一条线程执行这段代码,只要只有一条线程执行这段代码即可,OpenMP用#pragmaomp single指令来完成这个工作。
有此时候用single指令声明必须由单一线程执行并行区域中的一段代码并不满足需要。有些情况下你希望确保主线程来执行这段代码——例如主线程是GUI线程并且你希望GUI线程完成一些工作。#pragmaomp master指令可以做到这一点。不像single,在进出一个master代码块的时候并没有暗含的barrier。
内存界定(Memory Fence)可用#pragma ompflush实现,这条指令在程序中生成内存界定,它的本质上等效于_ReadWriteBarrier。
切记OpenMP指令同时影响线程组里的所有线程。因此下面的代码片段是非法的并且有未定义的运行时行为(崩溃或者在特别情况下被挂起):
#pragma ompparallel
{
if(omp_get_thread_num() > 3)
{
#pragma ompsingle // Maynot be accessed by all threads
x++;
}
}
执行环境例程
除了前文讨论的编译器指令OpenMP也包含一系列极为有用的运行时例程,用以编写OpenMP应用程序。有三大类型的例程可用:执行环境例程,锁/同步例程和定时例程(定时例程不在本文讨论)。所有的OpenMP例程都在omp.h头文件中定义并皆以omp_开头。
运行时环境例程提供允许你查询和设置OpenMP环境的各个方面的功能。以omp_set_开头的函数只能在并行区域外调用,其它函数可在并行和非并行区域使用。
可以用omp_get_num_threads和omp_set_num_threads来读取或者设置线程组的线程数量。omp_get_num_threads返回当前线程组的线程数目。如果调用此函数的线程不在并行区域,返回1。omp_set_num_threads用以设置当前线程执行下一个并行区域的线程数。
但这并非设置线程数目的全部,并行区域的线程数目同样依赖于OpenMP的另两方面的配置环境:动态线程和嵌套。
动态线程是一个默认为不使能的布尔属性。当线程将执行一块并行区域的时候如果这个属性为不使能,那么OpenMP就生线程数量为omp_get_max_threads返回值的线程组。omp_get_max_threads默认为计算机的硬件线程数或者环境变量OMP_NUM_THREADS的值。如果使能动态线程OpenMP将生成一个线程数量可变的线程组,但这个数量不会超过omp_get_max_threads的返回值。
嵌套是另一个默认为不使能的布尔属性。并行区域嵌套出现在当线程已经运行在并行区域又遇到另一个并行区域的时候。如果嵌套被使能,那么就按前文关于动态线程的规则生成一个新的线程组。相反地,线程组就只有单独一个线程。
可以通过omp_set_dynamic、omp_get_dynamic、omp_set_nested和 omp_get_nested来设置或者查询动态线程和嵌套的使能状态。每一条线程都可以查询它所处的环境。线程可以通过调用omp_get_thread_num来获得它所处的线程组的线程数目——一个比调用omp_get_num_threads的返回值少0或者1的值。
omp_in_parallel用以查询本线程是否正在并行区域执行。omp_get_num_proc用以获知计算机有多少个CPU。
#include<stdio.h> #include<omp.h> int main() { omp_set_dynamic(1); omp_set_num_threads(10); #pragma ompparallel // parallel region 1 { #pragma omp single printf("Num threads in dynamic region is = %d/n", omp_get_num_threads()); } printf("/n"); omp_set_dynamic(0); omp_set_num_threads(10); #pragma ompparallel // parallel region 2 { #pragma omp single printf("Numthreads in non-dynamic region is = %d/n", omp_get_num_threads()); } printf("/n"); omp_set_dynamic(1); omp_set_num_threads(10); #pragma ompparallel // parallel region 3 { #pragma omp parallel { #pragmaomp single printf("Num threads in nesting disabled region is =%d/n", omp_get_num_threads()); } } printf("/n"); omp_set_nested(1); #pragma ompparallel // parallel region 4 { #pragma omp parallel { #pragma omp single printf("Num threads in nested region is = %d/n", omp_get_num_threads()); } } } |
(图6)使用OpenMP例程
图6可以帮助你更清晰地理解这些不同的互相作用的环境例程。在这个例子中有4个截然不同的并行区域,包括两个嵌套并行区域。
用Visual Studio2005编译之后在双处理器的计算机上执行上例,输出如下:
Num threads indynamic region is = 2
Num threads innon-dynamic region is = 10
Num threads innesting disabled region is = 1
Num threads innesting disabled region is = 1
Num threads innested region is = 2
Num threads in nestedregion is = 2
在第一个并行区域使能了动态线程并设置线程数为10。从程序的输出可以看到使能了动态线程的OpenMP在运行时仅为线程组分派两条线程——因为计算机只有两个处理器。在第二个并行区域,未使能动态线程的OpenMP为线程组分派了10条线程。
在第三、四个并行区域,你可以看到使能和未使能嵌套的影响。在第三个并行区域,因为没有使能嵌套,所以没有为嵌套的并行区域分派新的线程。因此嵌套和外部并行区域加起来只有两条线程。在使能了嵌套的第四个并行区域中为嵌套并行区域生成了一个拥有两条线程的线程组(故在嵌套并行区域总计有四条线程)。这种为每一个嵌套并行区域加倍增加线程的处理能够一直进行下去,直到用完栈空间。实际上你可以生成几百条线程,但这样做的话开销将会远大于使用多线程获得的性能优势。
可能你已经留意到在第三、四并行区域中是使能了动态线程的,那么下面这段未使能动态线程的代码又会有什么样的执行结果?
omp_set_dynamic(0);
omp_set_nested(1);
omp_set_num_threads(10);
#pragma ompparallel
{
#pragma omp parallel
{
#pragma omp single
printf("Num threads in nested region is = %d/n",
omp_get_num_threads());
}
}
下面你可以看到预期的结果。在第一个并行区域开始处由一个10个线程的线程组执行,后来并发的嵌套并行区域则为10个线程中的每一个线程分派有10个线程的线程组来执行内部并行区域。因此在嵌套并行区域内部总计有100条线程执行。
Num threads innested region is = 10
Num threads innested region is = 10
Num threads innested region is = 10
Num threads innested region is = 10
Num threads innested region is = 10
Num threads innested region is = 10
Num threads innested region is = 10
Num threads innested region is = 10
Num threads innested region is = 10
Num threads in nestedregion is = 10
同步与锁
OpenMP内含用以帮助代码同步的运行时例程;且内含两种类型的锁——简单的和可嵌套的,每一种都可以有三种状态——未初始化、已上锁和未上锁。
简单锁(omp_lock_t)不可以多次上锁,即使是同一线程也不允许。除了当线程尝试给已经持有的锁上锁时不会阻塞外,可嵌套锁(omp_nest_lock_t)与简单锁没有不同。另外,可嵌套锁使用引用计数并且知道已经被上锁了几次。
同步例程能够作用于锁,每一个例程都有简单锁和可嵌套锁变量。可以对锁实行以下五个操作:initialize(初始化)、set(上锁)、unset(解锁)、test(测试)和destory(销毁)。这些与Win32临界区例程非常相似——事实上OpenMP就是通过在它们上层进行简单封装来实现的。图7展示了OpenMP例程与Win32例程的对应关系。
OpenMP Simple Lock | OpenMP Nested Lock | Win32 Routine |
omp_lock_t | omp_nest_lock_t | CRITICAL_SECTION |
omp_init_lock | omp_init_nest_lock | InitializeCriticalSection |
omp_destroy_lock | omp_destroy_nest_lock | DeleteCriticalSection |
omp_set_lock | omp_set_nest_lock | EnterCriticalSection |
omp_unset_lock | omp_unset_nest_lock | LeaveCriticalSection |
omp_test_lock | omp_test_nest_lock | TryEnterCriticalSection |
(图7)OpenMP与Win32的锁例程对比
开发人员能够在同步例程和同步编译器指令之间任选其一。编译器指令的优势是它们非常结构化,这让它们变得易懂并且容易从程序上测定你的同步区域的入口与出口。
同步例程的优势是它们的伸缩性。你能把锁通过参数传递给函数,在函数中这个锁可以被上锁或者解锁。这是编译器指令无法做到的。通常情况下应该选择使用编译器指令,除非你需要只有使用运行时例程才能够得到的伸缩性。
数据结构遍历并行化
图8展示了两个并行执行迭代次数未知的for循环的例子,第一个例子是遍历一个STL的std::vector窗口,另一个是标准链表。
#pragma ompparallel { //Traversing an STL vector in parallel std::vector<int>::iteratoriter; for(iter =xVect.begin(); iter != xVect.end(); ++iter) { #pragma omp single nowait { process1(*iter); } } // Walking astandard linked-list in parallel for(LList*listWalk = listHead; listWalk != NULL; listWalk = listWalk->next) { #pragma omp single nowait { process2(listWalk); } } } |
(图8)处理可变次数的循环迭代
在例子的STL部分,线程组的每一条线程都执行for循环并且拥有自己的迭代器拷贝。但每一次迭代时都只有一条线程进入循环体里的single代码块(语义上的single)。在运行时OpenMP执行了用以确保single块被且仅被执行一次“魔法”。这种方式的迭代的开销是巨大的,因此只有处理函数要做很多工作的时候才值得使用这种方式。链表的例子也是一样的逻辑,就不多言了。
值得一提的是STL的std::vector那个例子里我们可以在需要进入循环之前用std::vector::size测定迭代次数,这样我们就可以重写代码为OpenMP规范的for循环形式,如下面的代码所示:
#pragma omp parallelfor
for(int i =0; i < xVect.size(); ++i)
process(xVect[i]);
因为这种方式的运行时开销要小得多,所以我们建议在数组、vector和其它任何能够使用OpenMP规范的for循环遍历的时候使用这一方式。
高级调度算法
默认情况下,OpenMP在并行化for循环的时候使用一个名为静态调度的线程调度算法,这一算法使得线程组的每一条线程获得同样多的迭代次数;如果有n次迭代和T条线程,那每一条线程就得到n/T次迭代(OpenMP可以正确处理n不被T整除的情况)。但是OpenMP也提供了一些其它的调度机制以适应不同的需要:动态调度、运行时调度和导向(guided)调度。
指定其它调度的方法是使用#pragma ompfor或者#pragma ompparallel指令的schedule子句。这个子句的格式如下:
schedule(schedule-algorithm[, chunk-size])
下面是一些例子:
#pragma omp parallelfor schedule(dynamic, 15)
for(int i = 0; i< 100; ++i)
{...
#pragma ompparallel
#pragma ompfor schedule(guided)
动态调度让每一条线程执行通过块大小(chunk-size)(默认为1)指定数量的迭代。当线程执行完交给它的迭代,它就请求再次执行chunk-size次迭代,直到所有迭代结束。显而易见,最后一次迭代可能少于chunk-size次。
导向调度是让每一条线程执行的迭代次数与线程数成比例:
iterations_to_do =max(iterations_not_assigned/omp_get_num_threads(),chunk-size)
当线程执行完交给它的迭代任务,它请求基于iterations_to_do这一公式的数量的迭代。因此交给线程的迭代次数递减,最后一次迭代调度的次数可能少于tierations_to_do函数定义的值。
下面是使用#pragma omp for schedule(dynamic,15)指令调度4条线程处理100次迭代的过程:
Thread 0 getsiterations 1-15
Thread 1 getsiterations 16-30
Thread 2 getsiterations 31-45
Thread 3 getsiterations 46-60
Thread 2finishes
Thread 2 getsiterations 61-75
Thread 3finishes
Thread 3 getsiterations 76-90
Thread 0finishes
Thread 0 gets iterations91-100
接下来是使用#pragma ompfor schedule(guided, 15)指令调度4条线程处理100次迭代的过程:
Thread 0 getsiterations 1-25
Thread 1 getsiterations 26-44
Thread 2 getsiterations 45-59
Thread 3 getsiterations 60-64
Thread 2finishes
Thread 2 getsiterations 65-79
Thread 3finishes
Thread 3 getsiterations 80-94
Thread 2finishes
Thread 2 gets iterations95-100
动态调度和导向调度是当每一次迭代的工作量不尽相同时或者处理器的速度快慢不一时完美的调度机制。使用静态调度是无法达到这样的迭代负载平衡的。动态和导向调度通过它们非常自然的工作自动地平衡迭代负载。特别地,导向调度由于更少的调度开销而比动态调度有更好性能。
最后要讨论的是运行时调度,确切来说它并不是调度算法,但有时是上文提及的三种调度算法之外更好的选择。当通过schedule子句指定runtime方式,OpenMP在运行时就为当前for循环使用通过OMP_SCHEDULE环境变量指定的调度方法。OMP_SCHEDULE环境变量的格式是type,[chunk-size]。例如:
setOMP_SCHEDULE=dynamic,8
使用运行时调度可以带给终端用户当默认为静态调度时可以选择调度算法的伸缩性。
应用OpenMP的时机
知道什么时候应用OpenMP与懂得如何使用OpenMP同样重要。一般而言,下面的几条指导方针可以帮助你做出决定:
目标平台是多核或者多处理器平台。在这种情况下如果单核或者单处理器的处理能力已经被应用程序用尽,那么使用OpenMP使之成为多线程应用程序肯定可以增进性能。
应用程序需要跨平台。OpenMP是一个广受支持的跨平台API库,而且因为OpenMP通过编译器指令实现,故而使得使用了OpenMP的应用程序能够在不支持OpenMP标准的编译器上编译通过。
需要并行循环。OpenMP最多地被用以循环并行化,如果应用程序有一些没有循环依赖的循环,使用OpenMP是个好主意。
最后的优化需要。因为使用OpenMP不需要对已有的程序伤筋动骨,所以它是一个理想的进行小改动而获取性能增进好工具。
如上所言,OpenMP不能用来处理所有多线程问题。从根源上说OpenMP就有所偏重,因为它原本是为高性能计算社区的应用需要而开发的,所以它在有大量数据共享且含有复杂循环体的循环中表现更优异。
就像使用原生线程会有额外开销一样,使用OpenMP也不是不用付出代价的。要想从OpenMP获取性能提升就必须让并行区域的加速比大于线程组的开销。VisualC++的实现中是在第一次执行到并行区域的时候生成线程组,然后把这些线程挂起直到再次利用。OpenMP内部使用Windows线程池。图9展示了OpenMP在双处理器机器上执行本文开始时的例子程序进行不同迭代次数的加速比,大约在1.7倍左右,这在双处理器系统上是很典型的。(图中Y轴的刻度显示串行性能与并行性能的比率。)可以看到在迭代少于5000次之前并行版本更慢,而原因就在于并行带来的线程开销冲抵了并行的优势;大多数多次迭代的并行循环都要比串行版本理会快,但这与每一次迭代执行的任务大小相关,并非使用了OpenMP就会增进性能。
(图9)双处理器上串行与并行的性能比较
OpenMP的编译器指令虽然易于使用,但没有提供强大的错误反馈功能。如果你正在编写一个进度很赶的程序,需要能够快速检测到错误并且容易解决错误,那OpenMP可能不是适合于你的工具(最少当前实现的OpenMP不是)。例如当OpenMP不能为并行区域创建线程或者不能创建一个临界区都会导致未定义的行为,VisualC++ 2005实现的OpenMP运行时例程会继续尝试,直到退出。我们计划在接下来的OpenMP未来版本中增加标准错误报告机制。
另外需要注意的是在OpenMP线程之外使用Windows线程,因为OpenMP建立在Windows线程之上(译者注:此处仅止VC2005上的OpenMP实现),所以它们处理同样的过程是一样。而问题在于OpenMP对于非自己创建的Windows线程一无所知,这导致两个棘手的麻烦:OpenMP不会对Windows线程进行计数和OpenMP同步例程并不同步Windows线程——因为它们不是线程组的一部分。
OpenMP常见缺陷
虽然OpenMP可以轻易地为应用程序增加并行能力,但仍然必须知道另一些事情:默认情况下最外层并行for循环的索引变量是是私有的,但在嵌套的并行for循环里是共享的。当存在循环嵌套的时候,你经常希望内部循环的索引变量是私有的,那就需要用private子句来指定这些变量。
编写OpenMP应用程序时应该在抛出C++异常时加倍小心。特别地,当应用程序在并行区域抛出一个异常,这个异常必须被同一并行区域的同一线程处理,而不应让它外流。一个普遍法则就是:如果在并行区域可能抛出异常,那就必须捕捉它;如果没有在抛出异常的并行区域捕捉到它,应用程序通常情况下会崩溃。
#pragma omp <directive>[clause]语句必须以换行符结束,而不是用以标识代码块开始的大括号。以大括号结束的指令会导致编译错误:
// BadBracket
#pragma omp parallel{
// won'tcompile Code
}
// GoodBracket
#pragma ompparallel
{
//Code
}
使用Visual C++2005调试OpenMP应用程序有时候会比较麻烦。特别是在使用F10/F11键进入或者跳出并行区域的时候,简直跟星际旅行差不多!这是因为编译器增加很多额外的代码去调用运行库和调用线程组,而调试器却并不知道这些,所以程序员看起来调试器行为跟以前一样。我们建议在并行区域内设置一个断点,然后用F5运行到断点处;跳出并行区域可以在并行区域外设置一个断点,然后再按F5。
在并行区域执行时,调试器的“ThreadsWindows”里将显示当前线程组的多个线程,但这里的线程ID与OpenMP线程ID是无关的,它只是Windows线程ID,因为OpenMP构建在Windows线程之上。
OpenMP现在不支持剖分导向优化(ProfileGuided Optimization,PGO),幸运的是OpenMP是基于编译器指令的,你可以分别在/openmp和PGO两种配置下编译程序,以确定哪一种方法更加改进性能。
OpenMP与.Net
高性能计算与.Net乍听起来似乎风牛马不相及,但Visual C++2005在这方面有了很大进步。我们做的其中一件事就是让OpenMP可以在托管C++代码里工作,只要让/openmp开关与/clr和/clr:OldSyntax同时使用即可。这意味着你能够在接受垃圾收集的.net类型的方法里并行执行代码。但请记住现在OpenMP与/clr:saft和/clr:pure并不兼容,我们计划在以后实现它们的兼容。
同时使用OpenMP和托管代码的另一件要事是使用了OpenMP的应用程序只能运行在单独的进程空间;如果其它的应用程序装载一个已经装载OpenMP的进程到自己的空间,那这个应用程序可能会异常中止。
OpenMP是应用程序增加并行能力的简单有效的工具,它提供并行化数据处理循环和功能化代码块的多种途径。它易于集成到已有的应用程序且可以简单地通过编译器开关来使用或者停用。OpenMP是一个充分利用多核CPU强大处理能力的简单方法。最后我们强烈建议你阅读OpenMP文档以了解更多细节,祝多线程之旅愉快!
Kang SuGatlin VisualC++开发团队的程序经理,日常工作是找出让程序运行更快的系统方案。加入到微软之前他从事高性能和网格计算相关的工作。
PeteIsensee 微软Xbox高级技术组的开发经理,他在游戏行业有12年经验,并经常关于优化和性能方面的会议上发表演讲。