OpenMP

OpenMP(Open Multi-Processing)是一套支持跨平台共享内存方式的多线程并发的编程API,使用C,C++和Fortran语言,可以在大多数的处理器体系和操作系统中运行。


摘录

http://wdxtub.com/2016/03/20/openmp-guide/
https://www.ibm.com/developerworks/cn/aix/library/au-aix-openmp-framework/index.html
http://blog.csdn.net/drzhouweiming/article/details/4093624
http://blog.csdn.net/drzhouweiming/article/details/1131537
http://blog.csdn.net/drzhouweiming/article/details/2033276
http://blog.csdn.net/drzhouweiming/article/details/2033276
http://blog.csdn.net/drzhouweiming/article/details/1689853
http://blog.csdn.net/drzhouweiming/article/details/2472454


配置

在gcc编译器下,条件编译选项-fopenmp就可以开启openMP支持。
在Visual Studio下,项目属性 —> C/C++ —> 语言 —> OpenMP支持 —> Yes 就可以开启OpenMP支持。


OpenMP指令和库函数介绍

在C/C++中,OpenMP指令使用的格式为:

1
#pragma omp 指令 [子句[子句]…]

OpenMP的指令有以下一些:
1
2
3
4
5
6
7
8
9
10
11
12
13
parallel          // 用在一个代码段之前,表示这段代码将被多个线程并行执行
for // 用于for循环之前将循环并行,必须保证每次循环之间无相关性。
parallel for // 用在一个for循环之前,表示for循环的代码将被多个线程并行执行。
sections // 用在可能会被并行执行的代码段之前
parallel sections // parallel和sections两个语句的结合
critical // 用在一段代码临界区之前
single // 用在一段只被单个线程执行的代码段之前,表示后面的代码段将被单线程执行。
barrier // 用于并行区内代码的线程同步,所有线程执行到barrier时要停止,
// 直到所有线程都执行到barrier时才继续往下执行。
atomic // 用于指定一块内存区域被制动更新
master // 用于指定一段代码块由主线程执行
ordered // 用于指定并行区域的循环按顺序执行
threadprivate // 用于指定一个变量是线程私有的。

OpenMP除上述指令外,还有一些库函数,下面列出几个常用的库函数:
1
2
3
4
5
6
7
8
omp_get_num_procs()     // 返回运行本线程的多处理机的处理器个数
omp_get_num_threads() // 返回当前并行区域中的活动线程个数
omp_get_thread_num() // 返回线程号
omp_set_num_threads() // 设置并行执行代码时的线程个数
omp_init_lock() // 初始化一个简单锁
omp_set_lock() // 上锁操作
omp_unset_lock() // 解锁操作,要和omp_set_lock函数配对使用。
omp_destroy_lock() // omp_init_lock函数的配对操作函数,关闭一个锁

OpenMP的子句有以下一些:
1
2
3
4
5
6
7
8
9
10
11
12
private       // 指定每个线程都有它自己的变量私有副本
firstprivate // 指定每个线程都有它自己的变量私有副本,并且变量要被继承主线程中的初值
lastprivate // 主要是用来指定将线程中的私有变量的值在并行处理结束后复制回主线程中的对应变量
reduce // 用来指定一个或多个变量是私有的,并且在并行处理结束后这些变量要执行指定的运算
nowait // 忽略指定中暗含的等待
num_threads // 指定线程的个数
schedule // 指定如何调度for循环迭代
shared // 指定一个或多个变量为多个线程间的共享变量
ordered // 用来指定for循环的执行要按顺序执行
copyprivate // 用于single指令中的指定变量为多个线程的共享变量
copyin // 用来指定一个threadprivate的变量的值要用主线程的值进行初始化。
default // 用来指定并行处理区域内的变量的使用方式,缺省是shared

第一个程序:
1
2
3
4
#pragma omp parallel
{
std::cout << "Hello World!\n";
}

发生了什么?#pragma omp parallel 仅在指定了-fopenmp编译器选项后才会发挥作用。在编译期间, 会根据硬件和操作系统配置在运行时生成代码,创建尽可能多的线程。每个线程的起始例程为代码块中位于指令之后的代码。这种行为是隐式的并行化,而OpenMP本质上由一组功能强大的编译指示组成,省去了编写大量样本文件的工作。
在电脑上输出了4行”Hello World”,说明创建了4个线程。
使用编译命令的num_threads参数控制线程的数量非常简单。如下,可用线程的数量被指定为 5
1
#pragma omp parallel num_threads(5)

这里没有使用num_threads方法,而是使用另一种方法来修改运行代码的线程的数量。这还使用的第一个OpenMP API:omp_set_num_threads。在omp.h头文件中定义该函数。不需要链接到额外的库就可以运行。
1
2
3
4
5
6
7
#include <omp.h>
omp_set_num_threads(5);
// ...
#pragma omp parallel
{
std::cout << "Hello World!\n";
}

OpenMP使用隐式并行化技术,可以使用编译指示、显式函数和环境变量来指导编译器的行为。例如,可以将 for 循环分为几个部分,在不同的核心中运行它们。parallel for编译指示可以将 for 循环工作负载划分到多个线程中,每个线程都可以在不同的核心上运行,这显著减少了总的计算时间。

1
2
3
4
5
int a[1000000], b[1000000]; 
int c[1000000];
#pragma omp parallel for
for (int i = 0; i < 1000000; ++i)
c[i] = a[i] * b[i] + a[i-1] * b[i+1];

fork / join并行执行模式

OpenMP是一个编译器指令和库函数的集合,主要是为共享式存储计算机上的并行程序设计使用的。OpenMP并行执行的程序要全部结束后才能执行后面的非并行部分的代码。这就是标准的并行模式:fork/join式并行模式,共享存储式并行程序就是使用fork / join式并行的。
标准并行模式执行代码的基本思想是:程序开始时只有一个主线程,程序中的串行部分都由主线程执行,并行的部分是通过派生其他线程来执行,但是如果并行部分没有结束时是不会执行串行部分的。


parallel 指令的用法

parallel 是用来构造一个并行块的,可以使用其他指令如forsections等和它配合使用。
在C/C++中,parallel的使用方法如下:

1
2
3
4
#pragma omp parallel [for | sections] [子句[子句]…]
{
// 多个线程并行的代码
}

parallel块中的每行代码都被多个线程重复执行,等于给一个线程入口函数重复调用创建线程函数来创建线程并等待线程执行完。
for指令则是用来将一个for循环分配到多个线程中执行。for指令一般和parallel指令合起来形成parallel for使用,也可以单独用在parallel语句的并行块中。
for指令要和parallel指令结合起来使用才有效果,单纯的#pragma omp for是没有用的,来一个例子:
1
2
3
4
#pragma omp parallel for
for (int j = 0; j < 4; j++) {
printf("j = %d, ThreadId = %d \n", j, omp_get_thread_num());
}

上面这段代码也可以改写成以下形式:
1
2
3
4
5
6
7
8
	int j = 0; // 变量 j 只定义一次,不要放在parallel块里面
#pragma omp parallel
{
#pragma omp for
for (j = 0; j < 4; j++) {
printf("j = %d, ThreadId = %d \n", j, omp_get_thread_num());
}
}

值得一提的是,在一个parallel块中也可以有多个for语句

1
2
3
4
5
6
7
8
9
10
11
12
13
    int j;  // 并行外定义j
#pragma omp parallel
{
#pragma omp for
for ( j = 0; j < 100; j++ ){ // j赋值0
// 循环体1 …
}
#pragma omp for
for ( j = 0; j < 100; j++ ){ // j重新赋值0
// 循环体2 …
}

}

for 循环并行化的约束条件

尽管OpenMP可以方便地对for循环进行并行化,但并不是所有的for循环都可以进行并行化。以下几种情况不能进行并行化:

  1. for循环中的循环变量必须是有符号整形。例如,for (unsigned int i = 0; i < 10; ++i){}会编译不通过
  2. for循环中比较操作符必须是<, <=, >, >=。例如for (int i = 0; i != 10; ++i){}会编译不通过
  3. for循环中的第三个表达式,必须是整数的加减,并且加减的值必须是一个循环不变量。例如for (int i = 0; i != 10; i = i + 1){}会编译不通过;感觉只能++i; i++; –i; 或i–
  4. 如果for循环中的比较操作为<或<=,那么循环变量只能增加;反之亦然。例如for (int i = 0; i != 10; –i)会编译不通过
    循环必须是单入口、单出口,也就是说循环内部不允许能够达到循环以外的跳转语句,exit除外。异常的处理也必须在循环体内处理。例如:若循环体内的break或goto会跳转到循环体外,那么会编译不通过

sections和section指令

section语句是用在sections语句里用来将sections语句里的代码划分成几个不同的段,每段都并行执行。用法如下:

1
2
3
4
5
6
7
8
9
10
11
#pragma omp [parallel] sections [子句]
{
#pragma omp section
{
// 并行代码块1
}
#pragma omp section
{
// 并行代码块2
}
}

使用section语句时,需要注意的是这种方式需要保证各个section里的代码执行时间相差不大,否则某个section执行时间比其他section过长就达不到并行执行的效果了。
用for语句来分摊是由系统自动进行,只要每次循环间没有时间上的差距,那么分摊是很均匀的,使用section来划分线程是一种手工划分线程的方式,最终并行性的好坏得依赖于程序员。
再看一种写法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#pragma omp parallel 
{
#pragma omp sections
{
#pragma omp section
printf("section 1 ThreadId = %d \n", omp_get_thread_num());
#pragma omp section
printf("section 2 ThreadId = %d \n", omp_get_thread_num());
}
#pragma omp sections
{
#pragma omp section
printf("section 3 ThreadId = %d \n", omp_get_thread_num());
#pragma omp section
printf("section 4 ThreadId = %d \n", omp_get_thread_num());
}
}

这种方式和前面那种方式的区别是,两个sections语句是串行执行的,即第二个sections语句里的代码要等第一个sections语句里的代码执行完后才能执行。


数据的共享与私有化

在并行区域中,若多个线程共同访问同一存储单元,并且至少会有一个线程更新数据单元中的内容时,会发送数据竞争。
除了以下三种情况外,并行区域中的所有变量都是共享的:

  1. 并行区域中定义的变量
  2. 多个线程用来完成循环的循环变量
  3. private、firstprivate、lastprivate或reduction字句修饰的变量

并行区域中变量val是私有的,即每个线程拥有该变量的一个拷贝

1
private(val1, val2, ...)

与private不同的是,每个线程在开始的时候都会对该变量进行一次初始化。
1
firstprivate(val1, val2, ...)

与private不同的是,并发执行的最后一次循环的私有变量将会拷贝到val
1
lastprivate(val1, val2, ...)

声明val是共享的
1
shared(val1, val2, ...)

如果使用private,无论该变量在并行区域外是否初始化,在进入并行区域后,该变量均不会初始化。

private子句

private子句用于将一个或多个变量声明成线程私有的变量,变量声明成私有变量后,指定每个线程都有它自己的变量私有副本,其他线程无法访问私有副本。即使在并行区域外有同名的共享变量,共享变量在并行区域内不起任何作用,并且并行区域内不会操作到外面的共享变量。

1
2
3
4
5
6
7
    int k = 100;
#pragma omp parallel for private(k)
for (k = 0; k < 10; k++)
{
printf("k=%d \n", k);
}
printf("last k=%d \n", k);

从打印结果可以看出,for循环前的变量k和循环区域内的变量k其实是两个不同的变量。
用private子句声明的私有变量的初始值在并行区域的入口处是未定义的,它并不会继承同名共享变量的值。
出现在reduction子句中的参数不能出现在private子句中。

firstprivate子句

private声明的私有变量不能继承同名变量的值,但实际情况中有时需要继承原有共享变量的值,OpenMP提供了firstprivate子句来实现这个功能。
以下的代码例子:

1
2
3
4
5
6
7
8
    int k = 100, i=0;
#pragma omp parallel for firstprivate(k)
for (i = 0; i < 4; i++)
{
k += i;
printf("k=%d \n", k);
}
printf("last k=%d \n", k);

从打印结果可以看出,并行区域内的私有变量k继承了外面共享变量k的值100作为初始值,并且在退出并行区域后,共享变量k的值保持为100未变。

lastprivate子句

有时在并行区域内的私有变量的值经过计算后,在退出并行区域时,需要将它的值赋给同名的共享变量,前面的private和firstprivate子句在退出并行区域时都没有将私有变量的最后取值赋给对应的共享变量,lastprivate子句就是用来实现在退出并行区域时将私有变量的值赋给共享变量。

1
2
3
4
5
6
7
8
    int k = 100, i=0;
#pragma omp parallel for firstprivate(k),lastprivate(k)
for (i = 0; i < 4; i++)
{
k += i;
printf("k=%d \n", k);
}
printf("last k=%d \n", k);

从打印结果可以看出,退出for循环的并行区域后,共享变量k的值变成了103,而不是保持原来的100不变。
由于在并行区域内是多个线程并行执行的,最后到底是将那个线程的最终计算结果赋给了对应的共享变量呢?OpenMP规范中指出,如果是循环迭代,那么是将最后一次循环迭代中的值赋给对应的共享变量;如果是section构造,那么是最后一个section语句中的值赋给对应的共享变量。注意这里说的最后一个section是指程序语法上的最后一个,而不是实际运行时的最后一个运行完的。
如果是类(class)类型的变量使用在lastprivate参数中,那么使用时有些限制,需要一个可访问的,明确的缺省构造函数,除非变量也被使用作为firstprivate子句的参数;还需要一个拷贝赋值操作符,并且这个拷贝赋值操作符对于不同对象的操作顺序是未指定的,依赖于编译器的定义。

threadprivate子句

threadprivate子句用来指定全局的对象被各个线程各自复制了一个私有的拷贝,即各个线程具有各自私有的全局对象。
用法如下:

1
#pragma omp threadprivate(list) new-line

下面用threadprivate命令来实现一个各个线程私有的计数器,各个线程使用同一个函数来实现自己的计数。计数器代码如下:
1
2
3
4
5
6
7
int counter = 0;
#pragma omp threadprivate(counter)
int increment_counter()
{
counter++;
return counter;
}

如果对于静态变量也同样可以使用threadprivate声明成线程私有的,上面的counter变量如改成用static类型来实现时,代码如下:
1
2
3
4
5
6
7
int increment_counter2()
{
static int counter = 0;
#pragma omp threadprivate(counter)
counter++;
return counter;
}

threadprivate和private的区别在于threadprivate声明的变量通常是全局范围内有效的,而private声明的变量只在它所属的并行构造中有效。
threadprivate的对应只能用于copyin,copyprivate,schedule,num_threads和if子句中,不能用于任何其他子句中。
用作threadprivate的变量的地址不能是常数。
对于C++的类(class)类型变量,用作threadprivate的参数时有些限制,当定义时带有外部初始化时,必须具有明确的拷贝构造函数。
对于windows系统,threadprivate不能用于动态装载(使用LoadLibrary装载)的DLL中,可以用于静态装载的DLL中,关于windows系统中的更多限制,请参阅MSDN中有关threadprivate子句的帮助材料。
有关threadprivate命令的更多限制方面的信息,详情请参阅OpenMP2.5规范。


其他

shared子句

shared子句用来声明一个或多个变量是共享变量。

1
shared(list)

需要注意的是,在并行区域内使用共享变量时,如果存在写操作,必须对共享变量加以保护,否则不要轻易使用共享变量,尽量将共享变量的访问转化为私有变量的访问。
循环迭代变量在循环构造区域里是私有的。声明在循环构造区域内的自动变量都是私有的。

default子句

default子句用来允许用户控制并行区域中变量的共享属性。

1
default(shared | none)

使用shared时,缺省情况下,传入并行区域内的同名变量被当作共享变量来处理,不会产生线程私有副本,除非使用private等子句来指定某些变量为私有的才会产生副本。
如果使用none作为参数,那么线程中用到的变量必须显示指定是共享的还是私有的,除了那些由明确定义的除外。

reduction子句

reduction子句主要用来对一个或多个参数条目指定一个操作符,每个线程将创建参数条目的一个私有拷贝,在区域的结束处,将用私有拷贝的值通过指定的运行符运算,原始的参数条目被运算结果的值更新。

1
reduction(operator:list)

下表列出了可以用于reduction子句的一些操作符以及对应私有拷贝变量缺省的初始值,私有拷贝变量的实际初始值依赖于redtucion变量的数据类型。

运算符 数据类型 默认初始值
+ 整数、浮点 0
- 整数、浮点 0
* 整数、浮点 1
& 整数 所有位均为1
| 整数 0
^ 整数 0
&& 整数 1
|| 整数 0

例如一个整数求和的程序如下:

1
2
3
4
    int i, sum = 0;
#pragma omp parallel for reduction(+: sum)
for (i = 0; i < 100; i++) sum += i;
printf("sum = %ld \n", sum);

其中sum是共享的,采用reduction之后,每个线程根据reduction(+: sum)的声明算出自己的sum,然后再将每个线程的sum加起来。
reduction声明可以看作:

  1. 保证了对sum的原则操作
  2. 多个线程的执行结果通过reduction中声明的操作符进行计算

以加法操作符为例:
假设sum的初始值为10,reduction(+: sum)声明的并行区域中每个线程的sum初始值为0(规定),并行处理结束之后,会将sum的初始化值10以及每个线程所计算的sum值相加。
注意:
如果在并行区域内不加锁保护就直接对共享变量进行写操作,存在数据竞争问题,会导致不可预测的异常结果。共享数据作为private、firstprivate、lastprivate、threadprivate、reduction子句的参数进入并行区域后,就变成线程私有了,不需要加锁保护了。

copyin子句

copyin子句用来将主线程中threadprivate变量的值拷贝到执行并行区域的各个线程的threadprivate变量中,便于线程可以访问主线程中的变量值

1
copyin(list)

copyin中的参数必须被声明成threadprivate的,对于类类型的变量,必须带有明确的拷贝赋值操作符。
对于前面threadprivate中讲过的计数器函数,如果多个线程使用时,各个线程都需要对全局变量counter的副本进行初始化,可以使用copyin子句来实现,示例代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    int iterator;
#pragma omp parallel sections copyin(counter)
{
#pragma omp section
{
int count1;
for (iterator = 0; iterator < 100; iterator++)
{
count1 = increment_counter();
}
printf("count1 = %ld \n", count1);
}
#pragma omp section
{
int count2;
for (iterator = 0; iterator < 200; iterator++)
{
count2 = increment_counter();
}
printf("count2 = %ld \n", count2);
}
}
printf("counter = %ld \n", counter);

从打印结果可以看出,两个线程都正确实现了各自的计数。

copyprivate子句

copyprivate子句提供了一种机制用一个私有变量将一个值从一个线程广播到执行同一并行区域的其他线程。

1
copyprivate(list)

copyprivate子句可以关联single构造,在single构造的barrier到达之前就完成了广播工作。copyprivate可以对private和threadprivate子句中的变量进行操作,但是当使用single构造时,copyprivate的变量不能用于private和firstprivate子句中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int counter = 0;
#pragma omp threadprivate(counter)
int increment_counter()
{
counter++;
return counter;
}
#pragma omp parallel
{
int count;
#pragma omp single copyprivate(counter)
{
counter = 50;
}
count = increment_counter();
printf("ThreadId: %ld, count = %ld \n", omp_get_thread_num(), count);
}

从打印结果可以看出,使用copyprivate子句后,single构造内给counter赋的值被广播到了其他线程里,但没有使用copyprivate子句时,只有一个线程获得了single构造内的赋值,其他线程没有获取single构造内的赋值。


OpenMP中的任务调度

OpenMP中,任务调度主要用于并行的for循环中,当循环中每次迭代的计算量不相等时,如果简单地给各个线程分配相同次数的迭代的话,会造成各个线程计算负载不均衡,这会使得有些线程先执行完,有些后执行完,造成某些CPU核空闲,影响程序性能。
为了解决这些问题,OpenMP中提供了几种对for循环并行化的任务调度方案。

Schedule子句用法

1
schedule(type[,size])  // size参数是可选的

type参数

表示调度类型,有四种调度类型如下:

  • dynamic
  • guided
  • runtime
  • static
    这四种调度类型实际上只有static、dynamic、guided三种调度方式,runtime实际上是根据环境变量来选择前三种中的某中类型。

    size参数 (可选)

    size参数表示循环迭代次数,size参数必须是整数。static、dynamic、guided三种调度方式都可以使用size参数,也可以不使用size参数。当type参数类型为runtime时,size参数是非法的(不需要使用,如果使用的话编译器会报错)。

静态调度(static)

parallel for编译指导语句没有带schedule子句时,大部分系统中默认采用static调度方式,这种调度方式非常简单。假设有n次循环迭代,t个线程,那么给每个线程静态分配大约n/t次迭代计算。这里为什么说大约分配n/t次呢?因为n/t不一定是整数,因此实际分配的迭代次数可能存在差1的情况,如果指定了size参数的话,那么可能相差一个size。
静态调度时可以不使用size参数,也可以使用size参数。
不使用size参数时,分配给每个线程的是n/t次连续的迭代。使用size参数时,分配给每个线程的size次连续的迭代计算。

1
2
3
4
5
6
int i = 0;
#pragma omp parallel for schedule(static,5)
for (i = 0; i < 10; i++)
{
printf("i=%d, thread_id=%d \n", i, omp_get_thread_num());
}

动态调度(dynamic)

动态调度是动态地将迭代分配到各个线程,动态调度可以使用size参数也可以不使用size参数,不使用size参数时是将迭代逐个地分配到各个线程,使用size参数时,每次分配给线程的迭代次数为指定的size次。

1
#pragma omp parallel for schedule(dynamic,size)

guided调度(guided)

guided调度是一种采用指导性的启发式自调度方法。开始时每个线程会分配到较大的迭代块,之后分配到的迭代块会逐渐递减。迭代块的大小会按指数级下降到指定的size大小,如果没有指定size参数,那么迭代块大小最小会降到1。

1
#pragma omp parallel for schedule(guided,size)

runtime调度(rumtime)

runtime调度并不是和前面三种调度方式似的真实调度方式,它是在运行时根据环境变量OMP_SCHEDULE来确定调度类型,最终使用的调度类型仍然是上述三种调度方式中的某种。
例如在unix系统中,可以使用setenv命令来设置OMP_SCHEDULE环境变量:

1
setenv OMP_SCHEDULE “dynamic, 2”

上述命令设置调度类型为动态调度,动态调度的迭代次数为2。
在windows环境中,可以在 “系统属性 | 高级 | 环境变量” 对话框中进行设置环境变量。