全部博文(77)
分类: LINUX
2009-06-10 10:55:53
OpenMP的规范由SGI发起,它是一种面向共享内存以及分布式共享内存的多处理器多线程并行编程语言。OpenMP是一种共享内存并行的应用程序编程接口。所有的处理器都被连接到一个共享的内存单元上,处理器在访问内存的时候使用的是相同的内存编址空间。由于内存是共享的,因此,某一处理器写入内存的数据会立刻被其它处理器访问到。
OpenMP具有良好的可移植性,支持Fortran和C/C++编程语言,操作系统平台方面则支持UNIX系统以及Windows 系统。OpenMP的重要性在于,它能够为编写多线程程序提供一种简单的方法,而无需程序员进行复杂的线程创建、同步、负载平衡和销毁工作[1]。
OpenMP的编程模型以线程为基础,通过编译指导语句来显式地指导并行化,为编程人员提供了对并行化的完整的控制。在并行执行的时候,主线程和派生线程共同工作。在并行代码结束执行后,派生线程退出或者挂起,不再工作,控制流回到单独的主线程中。OpenMP的功能由两种形式提供:编译指导语句和运行时库函数,下面分别介绍。
编译指导语句的含义是在编译器编译程序的时候,会识别特定的注释,而这些特定的注释就包含着OpenMP程序的一些语意。例如在C/C++程序中,用#pragma opm parallel来标示一段并行程序块。在一个无法识别OpenMP语意的编译器中,会将这些特定的注释当作普通的程序注释而被忽略。因此,仅使用编译指导语句编写的OpenMP程序就能够同时被普通编译器和支持OpenMP的编译器处理。这种性质带来的好处就是用户可以用同一份代码来编写串行和并行程序,或者在把串行程序改编成并行程序的时候,保持串行源代码部分不变,从而极大地方便了程序编写人员。
编译指导语句的形式为:
#pragam omp
其中directive部分就包含了具体的编译指导语句,包括parallel, for, parallel for, section, sections, single, master, critical, flush, ordered和atomic。这些编译指导语句或者用来分配任务,或者用来同步。后面可选的子句clause给出了相应的编译指导语句的参数,子句可以影响到编译指导语句的具体行为,每一个编译指导语句都有一系列适合它的子句。
另外一种提供OpenMP功能的形式就是OpenMP的运行时库函数,它用于设置和获取执行环境的相关信息,它们当中也包含一系列用以同步的API。要使用运行时库函数所包含的库函数,必须在相应的源文件中包含头文件omp.h。OpenMP库函数类似于相应编程语言内部的函数调用,因此在没有库支持的编译器上就无法正确识别OpenMP程序,这是库函数与编译指导语句不同的地方。
编译指导语句的优势体现在编译阶段,对于运行阶段则支持较少。OpenMP提供了运行时库函数来支持运行时对并行环境的改编和优化,但这种方式打破了源代码在串行和并行之间的一致性。
Microsoft Visual Studio 2005完全支持OpenMP编程[5]。Visual Studio. Net 2005 Professional安装之后,即可编写OpenMP程序,无须另外安装其它软件。当前的Visual Studio. Net 2005完全支持OpenMP 2.0标准。通过新的编译器选项/openmp来支持OpenMP程序的编译和连接,编译器会自动地将用户的代码和OpenMP在Windows下实现的库vcomp.dll连接在一起。程序在运行的时候会自动地寻找vcomp.dll。下面用Visual Studio.Net 2005生成一个新OpenMP项目OpenMP1。
启动Visual Studio.Net 2005,新建一个Win32 控制台程序,并命名OpenMP1。然后在项目上用鼠标右键单击,在弹出的菜单中选择“属性”,在弹出的对话框中如图2-1所示设置,完成项目对OpenMP的支持。在OpenMP1.cpp输入如下代码:
#include "omp.h"
#include "conio.h"
int _tmain(int argc, _TCHAR* argv[])
{ printf("Hello from serial.\n");
printf("Thread number=%d\n",omp_get_thread_num()); // 串行执行
#pragma omp parallel // 并行执行
{ printf("Hello from parallel. thread number=%d\n", omp_get_thread_num());}
printf("Hello from serial again"); // 串行执行
getche();
return 0;
}
程序运行结果如下:
Hello from serial.
Thread number=0
Hello from parallel. Thread number=0
Hello from parallel. Thread number=1
Hello from serial again
#pragma omp parallel标志着一个并行区域的开始。在支持OpenMP的编译器中,根据线程的数目,随后的程序块会被编译到不同的线程中执行。omp_get_thread_num()函数用来获得当前线程号码。在OpenMP程序中,每一个线程会被分配给一个唯一的线程号码,用来标识不同的线程,在并行部分执行完毕后,程序又回到串行部分,打印最后一个语句。默认情况下,系统会根据逻辑CPU的数量来确定线程数,运行本程序的计算机CPU为单核超线程,所以运行时分配了两个线程。
图2-1 配置项目属性以支持OpenMP程序
循环并行化是使用OpenMP并行化程序的最重要部分。由于大量科学计算程序将很大一部分的的时间用在处理循环计算上,而对于循环并行化处理来说,这一部分的应用非常关键,因此循环并行化在OpenMP应用程序中是一个相对独立且非常重要的组成部分。在C/C++中,循环并行化语句的编译指导语句的格式为:
#pragma omp parallel for [clause[clause…]]
for (index = first ; test_expression ; increment_expr) {body of the loop; }
使用这个编译指导语句能将for循环中工作分配到一个线程组中,而线程组中的每一个线程将完成循环中的一部分内容。
需要并行化的语句有一定的限制。首先,并行化的语句必须是for循环语句,且能够确定循环次数。其次,循环语句快应该是单出口单入口的。即在循环的过程中不允许没有执行完所有的循环就跳出循环,也不能从循环的外面跳到循环中。
由于多个线程同时执行循环语句中的功能指令,这就涉及到数据的作用域问题。这里所说的作用域是用来控制某一变量是否是在各个线程之间共享或者是某一个线程私有的。数据的作用域子句用shared来表示变量是在各个线程之间共享的,而用private来表示变量是一个线程私有的。在OpenMP中,如果没有指定变量的作用域,则默认的变量作用域是共享的。为了对一个循环进行并行化操作,必须要保证数据两次循环之间不存在数据相关性。数据相关性又称为竞争。当两个线程对一个数据进行操作,并且有一个操作为写操作时,就说明这两个线程存在数据竞争。此时读出的数据不一定就是前一次写操作写入的数据,而写入的数据也可能并不是程序所需要的。如下面这段代码必须使用private声明变量j是私有的,否则将发生数据竞争。
omp parallel for private(j)
for(i=0; i
j++;
并行区域简单地说就是通过循环并行化编译指导语句使一段代码能够在多个线程内部同时执行。在C/C++语言中,并行区域编写的格式为:
#pragma omp parallel [clause[clause]…]
block
其中block是需要在多个线程中执行的代码块,每一个线程在遇到并行区域的编译指导语句时,都会同时执行跟随其后的程序代码块。在编译指导语句后面也可以跟随一些子句,包括private,reduction等子句都可以在并行区域指导语句中出现。parallel与parallel for语句类似,在使用时也有一定的限制。程序块必须是单一入口和单一出口的,不能从外面转入到程序块内部,也不能从程序块内部有多个出口转到程序块之外。下面用两个示例程序来说明编译指导语句parallel的使用,例3_1使用的是并行区域编译指导语句,例3_2使用的循环并行化的编译指导语句。
例2_1 使用并行区域编译指导语句
#pragma omp parallel // 并行执行
for(int i=0; i<2; i++)
printf("Hello world i=%d\n", i);
输出结果为:
Hello world i=0
Hello world i=1
Hello world i=0
Hello world i=1
例2_2 使用循环并行化的编译指导语句
#pragma omp parallel for // 并行执行
for(int i=0; i<2; i++)
printf("Hello world i=%d\n", i);
输出结果为:
Hello world i=0
Hello world i=1
两个程序的唯一区别是使用的编译指导语句不同,一个使用的是parallel另一个使用的是parallel for。从结果可以看出并行区域与循环并行化的区别,即并行区域采用复制执行方式,代码在所有线程都执行一遍(环境变量OMP_NUM_THREADS=2);而循环并行化则采用了工作分配的执行方式,将循环的所有工作分配到各个线程中执行,所有线程工作的总和等于原来串行时的工作量。
小结:在程序遇到编译指导语句#pragma omp parallel时,会根据环境变量OMP_NUM_THREADS的值生成相应数量的线程,将代码复制到各个线程中执行。
上述parallel编译指导语句提供了一种简单的并行方法,能够将工作在多个线程的代码重复运行。但是这并不能提高程序的效率,我们希望的是工作被分配到多个线程中,由各个线程合作完成。在OpenMP中,每一个线程都可以调用omp_get_thread_num()函数来获得自己唯一的线程号,并可以利用这个线程号来获得不同的工作任务。在OpenMP语法中可以通过编译指导语句#pragma omp parallel for进行循环并行化达到并行的目的,也可以用sections编译指导语句和section子句将不同的任务编写成不同的代码片段并行执行,下面将举例说明。
1. 工作分区编码
例2_3
#pragma omp parallel sections
{ #pragma omp section
printf("section 1 thread=%d\n", omp_get_thread_num());
#pragma omp section
printf("section 2 thread=%d\n", omp_get_thread_num());
#pragma omp section
printf("section 3 thread=%d\n", omp_get_thread_num());
}
输出结果为:
section 1 thread=0
section 2 thread=1
section 3 thread=0
可以看到,在使用工作分区编码的时候,各个线程自动从各个分区中获得任务执行。并且在执行完一个分区的时候,如果分区里还有未完成的工作,则继续取得任务执行。
2. 线程私有数据与threadprivate子句
除了private子句能够产生线程私有的变量之外,还需要考虑一些全局的数据。这些全局的数据可能是整个程序运行过程中都需要的数据,或是在源程序中跨多个文件所需要的变量。在通常情况下,这些数据都是共享的数据,所有线程访问的都是共享内存空间中的同一内存地址内容。然而,有些时候,对于每一个线程来说,可能需要生成自己私有的线程数据,此时,就需要使用threadprivate子句来标明某一个变量是线程私有数据,在程序运行的过程中,不能够被其它线程访问到。
例2_4
int counter=0;
#pragma omp threadprivate(counter) //使用threadprivate子句
void inc_counter()
{counter++;}
int _tmain(int argc, _TCHAR* argv[]){
#pragma omp parallel
for(int i=0;i<10000;i++)
inc_counter();
printf("counter=%d\n",counter);
}
输出结果为:
counter=10000
程序中使用了threadprivate子句,将counter变量变成一个私有变量。若将#pragma omp threadprivate(counter)语句去掉,则全局变量counter变为共享,此时下面区域并行部分会产生数据冲突,执行结果将不可知。
在OpenMP应用程序中,由于是多线程执行,所以必须具有必要的线程同步机制来保证程序在出现数据竞争的时候能够得出正确的结果,并且在适当的时候控制线程的执行顺序。OpenMP支持两种不同类型的线程同步机制,一种是互斥锁的机制,另外一种同步机制是事件通知机制。互斥的操作针对需要保护的数据而言,在产生了数据竞争的内存区域加入互斥,包括critical、atomic等语句以及函数库中的互斥函数。而事件机制则控制规定线程执行顺序时所需要的同步屏障。
1. 互斥锁机制
在OpenMP中,提供了三种不同的互斥锁机制,用来对一块内存进行保护,它们分别是临界区、原子操作(atomic)以及由库函数提供的同步操作。
(1) 临界区
临界区通过编译指导语句对产生数据竞争的内存变量进行保护。在程序需要访问可能产生竞争的内存数据时,都需要插入相应的临界区代码。临界区编译指导语句的格式为:
#pragma omp critical [(name)]
block
在执行程序块block之前,必须先获得临界区的控制权,在多线程执行时,OpenMP会保证每次最多只有一个线程执行临界区,name是临界区的名字。临界区的使用如下例所示。
代码片段2_1
// 将数组ar中的最大值取出赋值给变量max
#pragma omp parallel for
for(i=0;i<100;i++)
{ #pragma omp critical
if(ar[i]>max) max = ar[i];
}
(2) 原子操作
原子操作是OpenMP编程方式给同步编程带来的特殊的功能。通过一条指令就能够完成数据的读取与更新操作,原子操作在执行过程中是不会被打断的。因此这种方式提供了一种更高效的互斥锁机制。在OpenMP这种功能是通过编译指导语句#pragma omp atomic提供的,原子操作如下所示:
代码片段2_2
#pragma omp parallel
{ for(int i=0;i<10000;i++)
#pragma omp atomic
counter++;
}
输出结果为:
counter=20000
当使用atomic语句时,执行结果是20000(两个线程进行并行区域运算,counter自加20000次);当不使用atomic语句时,程序会产生数据竞争,例如counter当前值为100,当线程0执行counter+1操作后,线程0挂起,线程1执行counter+1并赋值操作,这时counter值是101,现在继续线程0的操作,它将刚执行完的加法操作的结果(101)赋值给counter,counter最后的值为101,显然结果是不正确的。
(3) 运行时库函数的互斥锁支持
除了critical、atomic编译语句外,OpenMP还通过一系列库函数支持更加细致的互斥操作。表2-1列出了OpenMP函数库提供的互斥锁函数。
函数名称 |
描述 |
void omp_init_lock(omp_lock_t *) |
初始化一个互斥锁 |
void omp_destroy_lock(omp_lock_t *) |
结束一个互斥锁的使用并释放内存 |
void omp_set_lock(omp_lock_t *) |
获得一个互斥锁 |
void omp_unset_lock(omp_lock_t *) |
释放一个互斥锁 |
int omp_test_lock(omp_lock_t *) |
试图获得一个互斥锁,并在成功时返回真,失败时返回假 |
表 2-1
互斥锁函数的使用比起编译指导语句更加灵活。编译指导语句进行的互斥锁支持只能放置在一段代码之前,作用在这段代码之上,使用函数的互斥锁支持则可以将函数放置在程序员所需的任意位置。
2. 同步屏障语句
在并行执行的时候,有些情况下需要程序员插入同步屏障语句#pragma omp barrier。此时,在并行区域的执行过程中,所有的执行线程都会在同步屏障语句上进行同步。
#pragma omp parallel
{ initialization();
#pragma omp barrier
process();
}
在上面例子中,只有等所有线程都完成初始化操作后,才能进行下一步的处理。
影响性能的主要因素有
(1) OpenMP本身的开销
OpenMP获得应用程序多线程并行化的能力不是凭空而来的,它需要一定程序库的支持,库中代码的运行必然会带来一定的开销。实际上并不是所有的代码都需要并行化,有些代码在并行化后执行的效率反而不如串行时高,原因就是加上了并行化所带来的开销后,代码的执行效率降低。因此,只有在并行执行代码段负担足够大,而引入OpenMP本身的开销又足够小时,引入并行化操作才能提高程序执行效率。
(2) 负载均衡
已知一个OpenMP应用程序在执行的过程中,有很多的同步点,线程只有在进行同步之后才能继续执行下面的代码。因此某一个线程在执行到同步点之后,若没有进一步的工作需要完成,此线程只有等待其它线程执行完毕后才能继续执行。此时,如果各个线程之间的负载不均衡,就有可能出现某些线程“空等”,而另外一些线程因负担沉重,要很长事件才能完成任务。看下面的例子:
示例程序见例2_5
程序运行结果为:
count=9350609
考虑负载均衡修改程序后的运行结果
count=5191944
上面程序中的QueryPerformanceCounter()函数用于获得CPU内部的计数器的值,在代码前后分别获得的计数器的值的差越大,说明这段代码执行的时间越长。该值和CPU的主频有关,在不同的计算机上,该值是没有可比性的。
可以看到,这两个循环的工作量是一样的,但是运行时间几乎差了一倍。由于使用了循环并行化,第一个循环OpenMP将前50个循环任务分配给0号线程,将后面50个循环任务分配给1号线程。前面50循环执行的是50个空操作,而后面的50个循环执行的负担沉重的任务,造成了负载不均衡,0号线程很快执行完毕,用很长的时间等待1号线程执行。在第二个循环中,OpenMP仍然将前面50个循环分配给0号线程,将后面50个循环分配给1号线程,,但是负载的分配发生了很大的变化,负载被均衡地分配到两个线程,两个线程几乎同时完成工作,这样可获得执行效率的提高。
(3) 线程同步带来的开销
线程之间存在同步开销是多线程应用程序的特点,在进行同步时候必然会带来一定的开销。很多情况下,不合适的同步机制或算法会使代码的运行效率下降。
示例程序见例2_6
程序运行结果为:
sum=1000000, count=10548
sum=1000000, count=935416
程序的第一个循环是串行的,第二个循环是在第一个循环的基础上加上了并行化支持,为了消除数据竞争又加入了同步操作,由于对内存单元的操作是同步的,产生的实际运行过程是串行的,并且加上了并行化负担,使用运行效率比串行的效率还要低的多。
下面我将使用VS 2005 + OpenMP编写一个实现选择排序算法的程序实例。选择排序的基本思想是:每一趟在n-i+1(i=1,2,...,n-1)个记录中选取关键字最小的记录作为有序序列中第i个记录。它的时间复杂度为O(n
void SelectionSort(SqList &L){
for(i=1; i
// 在L.r[i…L.length]中选择key最小的记录(使用for循环完成)
j = SelectionMinKey(L,i);
if(i!=j) L.r[i]<—>L.r[j];
}
}// SelectionSort
经过对OpenMP的学习,可以将这个算法并行化。我采取的办法是用条件编译语句#pragma omp parallel sections使两个并行区共同完成最外层的循环,这样可以将循环次数减半,当然也可以是四个或八个,但是考虑到在双核处理器上运行,过多的分区、线程反而会带来额外的开销,降低程序运行效率。附件中的“SelectionSort_UnOpt”和“SelectionSort_Opt”分别是用传统串行方法编写的和采用并行工作区编码方式进行优化后的程序。经过编译,两种方法编写的程序运行时间相差很大,采取并行算法后,程序的运行效率显著提高了。图2-2和2-3是两个程序在双核处理器上的运行结果。
前者266ms,后者78ms.
本章介绍了OpenMP的开发环境和开发OpenMP程序的基本方法。实现OpenMP程序有两种方式:编译指导语句和运行时库函数。库函数的使用类似于编程语言的内部函数调用,因此在没有库支持的编译器上是无法正确识别OpenMP程序的,这是库函数和编译指导语句的区别。在并行方式上也有两种不同的形式:循环并行化和并行区域编程。前者是以利用不同线程分别完成循环中的一部分的方式实现并行,而并行区域编程则是每个线程均完全地执行循环部分的代码。