子句是构造的修饰和补充。不同的构造支持不同的子句组合。

1. collapse子句

collapse(n)表示紧随其后的 n 层循环会被合并然后并行化。在一些情况下,collapse 能够解决线程间负载均衡或线程负载太小的问题。一个常见的场景如代码清单3-13的伪代码所示。假设有一个双层循环,外层循环次数都比较少,内层循环的计算量也不大。单独使用OpenMP线程化内层循环都会存在负载不够的问题,即每个线程的计算量比较小,导致线程的计算时间相比线程的建立、销毁时间不够长;单独使用OpenMP线程化外层循环则会存在负载均衡问题。

代码清单3-13 OpenMP collapse示例

 

1

2

3

4

5

for(int i = 0; i < 3; i++){

 for(int j = 0; j < num; j++){

  y[i][j] = …;//computing

 }

}

对代码清单3-13代码的一个常见更好的线程化方法是:同时线程化两层循环。OpenMP的collapse子句提供了直接的支持,使用OpenMP collapse子句线程化两层循环的伪代码如代码清单3-14所示。

代码清单3-14 OpenMP collapse示例

 

1

2

3

4

5

6

#pragma omp parallel for collapse(2)

for(int i = 0; i < 3; i++){

  for(int j = 0; j < num; j++){

  y[i][j] = …;//computing

 }

}

collapse(2)表示同时线程化接下来的两层循环,这样循环次数就变成了3×num,这既改善了负载均衡性,又增加了每个线程的计算量。

2. private子句

private将一个或多个变量声明成线程私有,每个线程都会拥有该变量的一个副本,且不允许其他线程染指。private子句声明的变量的初始值在并行区域的入口处是未定义的,即使并行区域外已经给予了值也不会初始化为在并行区域前同名的共享变量值。一般而言,private声明的变量都可以使用线程私有栈上的变量替代。另外,出现在reduction子句中的参数不能出现在private子句中。

代码清单3-15展示了private子句的功能。

代码清单3-15 OpenMP private示例

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

#include <omp.h>

#include <stdio.h>

 

int main(int argc, char *argv[]){

    int x = 5;

    printf("x = %d, address of x = %p\n", x, &x);

#pragma omp parallel num_threads(3) private(x)

{

    printf("i am %d, x = %d, address of x = %p\n", omp_get_thread_num(), x, &x);

}

    printf("x = %d, address of x = %p\n", x, &x);

 

    return 0;

}

从输出可以看出,并行区域内x的值和并行区域外毫无关系,而并行区域前后两者又是一样的。

3. firstprivate子句

private变量不能继承并行区域前同名变量的值,但实践中有时需要初始化为原有共享变量的值,OpenMP提供了firstprivate子句来实现这个功能。firstprivate指定的变量每个线程都有它自己的私有副本,并且继承主线程中的初值(并行区域前的值)。firstprivate 子句并不会更改原共享变量的值。

代码清单3-16展示了firstprivate的功能。

代码清单3-16 firstprivate的功能

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

#include <omp.h>

#include <stdio.h>

 

int main(int argc, char *argv[]){

    int x = 5;

#pragma omp parallel num_threads(3) firstprivate(x)

{

printf("i am %d, x = %d\n", omp_get_thread_num(), x);

x++;

}

    printf("x = %d\n", x);

 

    return 0;

}

从结果可以看出,并行区域内的变量x值为5,退出并行区域后,变量x的值仍为5。

4. lastprivate子句

有时需要在退出并行区域时,将它的值赋给同名的并行区域后变量,lastprivate子句实现了这一功能。lastprivate用来指定将线程中的私有变量的值在并行处理结束后复制回主线程中的对应变量。括号内变量值在离开并行域后会保留下来。

代码清单3-17展示了lastprivate 的功能。

代码清单3-17 lastprivate的功能

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

#include <omp.h>

#include <stdio.h>

 

int main(int argc, char *argv[]){

    int x = 5;

#pragma omp parallel for num_threads(3) lastprivate(x)

for(int i = 0; i < 3; i++){

    x = 36;

    printf("i am %d, %d\n", omp_get_thread_num(), x+=i);

}

 

    printf("x = %d\n", x);

 

    return 0;

}

从结果可以看出,退出for循环的并行区域后,共享变量x的值发生了改变,而不是保持原来的值不变。

由于并行区域内有多个线程并行执行,因此最后到底是将哪个线程的最终计算结果赋给了对应的变量是一个问题。如果是for循环,那么是最后一次循环计算得到的值;如果是section构造,那么是最后一个section语句中计算得到的值。

5. shared子句

shared子句用来声明一个或多个变量是共享变量。需要注意的是,由于在并行区域内的多个线程都可以访问共享变量,因此必须对共享变量的写操作加以保护。为了提高性能,应尽量使用私有变量而不是共享变量。

循环迭代变量在for循环并行区域内是私有的。声明在循环构造区域内的自动变量都是私有的。

6. default子句

default子句用来指定并行处理区域内没有显式指定访问权限的变量的默认访问权限,其取值有shared和none两个。指定为shared表示在没有显式指定访问权限时,传入并行区域内的变量访问权限为shared;指定为none意味着必须显式地为这些变量指定访问权限。

在某些情况下,OpenMP默认变量访问权限会导致一些问题,如需要private访问权限的数组被默认成shared了。故建议显式地使用default(none)来去掉变量的默认访问权限。

7. reduction子句

reduction子句用来在运算结束时对并行区域内的一个或多个参数执行一个操作。每个线程将创建参数的一个副本,在运算结束时,将各线程的副本进行指定的操作,操作的结果赋值给原始的参数。

reduction支持的操作符是有限的,支持+ - * / += -= *= /= |&^,且不能是C++重载后的运算符,具体可参见OpenMP规范。

代码清单3-18是使用reduction子句求π的OpenMP代码示例。

代码清单3-18 使用reduction 子句求π

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

#include <omp.h>

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <time.h>

 

double computePI(int numThread, int num){

   double PI = 0.0;

   const double delta = 1.0/(numThread*num);

#pragma omp parallel for num_threads(numThread) reduction(+:PI)

   for(int i = 0; i < num*numThread; i++){

     double x = (0.5+i)*delta;

     PI += 1.0/(1+x*x);

    }

    return PI*delta*4;

}

 

int main(int argc, char *argv[]){

    int numThread, num;

    if(argc != 3){

     printf("usage: ./a.out numThread num\n");

     return 1;

   }else{

     numThread = atoi(argv[1]);

     num = atoi(argv[2]);

     printf("use thread %d, every thread compute times %d\n", numThread, num);

     if(0 == numThread || 0 == num){

        printf("input data format error\n");

        return 1;

     }

    }

clock_t s = clock();

   double PI = computePI(numThread, num);

clock_t e = clock();

   printf("PI is %1.15f, use clock %ld\n", PI, e-s);

   return 0;

}

8. schedule子句

schedule子句指定采取的负载均衡策略及每次分发的数据大小。其使用方式如下:

schedule(type[,size])

其中,size参数是可选的;type参数表示负载均衡策略,主要有4种:dynamic、guided、static和runtime。

实际上只有static、dynamic、guided三种方式,runtime指的是根据环境变量的设置来选择前3种中的某一种。

size参数表示每次分发的循环迭代次数,必须是整数。3种策略都可用可不用size参数。当type为runtime时,需要忽略size参数。

1)静态负载均衡策略(static):当编译指导语句没有指定schedule子句时,大部分系统中默认采用static,static方式使用的算法非常简单。假设有从0开始的N次循环迭代,K个线程,如果没有指定size参数,那么给第一个线程分配的迭代为[0,N/K]。因为N/K不一定是整数,因此存在着轻微的负载均衡问题;如果指定size参数的话,那么分配给第一个线程的是[0,size-1][size*K,size*K+size-1]…,其他线程类推,直到分配完循环迭代。

2)动态负载均衡策略(dynamic):动态负载均衡策略动态地将迭代分发到各个线程。当线程计算完时,就会取得下一次任务。在静态负载均衡策略失效时,使用动态负载均衡策略通常可以得到性能提升。

动态负载均衡的机制类似于工作队列,线程在并行执行的时候,不断从这个队列中取出相应的工作完成,直到队列为空为止。由于每一个线程在执行的过程中的线程标识号是不同的,可以根据这个线程标识号来分配不同的任务。OpenMP中动态负载均衡时,每次分发size个循环计算,而队列中的数据就是所有的循环变量值。

动态负载均衡策略可用可不用size参数,不使用size参数时等价于size为1。使用size参数时,每次分配给线程的迭代次数为指定的size次。

选择size参数时,要注意伪共享问题。通常要使得每个线程从内存中加载的数据大小可以占满一个缓存线。

3)指导负载均衡策略(guided):guided负载均衡策略采用启发式自调度方法。开始时每个线程会分配较大的循环次数,之后分配的循环次数会逐渐减小。通常每次分配的循环次数会指数级下降到指定的size大小,如果没有指定size参数,那么最后会降到1。

4)运行时负载均衡策略(rumtime):runtime是指在运行时根据环境变量OMP_SCHEDULE的值来确定负载均衡策略,最终使用的负载均衡策略是上述3种中的一种。

例子代码清单3-19展示了不同的负载均衡策略的行为,读者可使用不同的负载均衡策略仔细观察结果,以加深对OpenMP负载均衡策略的理解。

代码清单3-19 不同负载均衡策略示例

 

1

2

3

4

5

6

7

8

9

10

11

#include <omp.h>

#include <stdio.h>

 

int main(int argc, char *argv[]){

#pragma omp parallel for num_threads(3) schedule(dynamic, 2)

for(int i = 0; i < 300; i++){

    printf("i am %d, i = %d\n", omp_get_thread_num(), i);

}

 

    return 0;

}

代码代码清单3-20展示了因为负载均衡策略不当而导致的伪共享问题。

代码清单3-20 伪共享示例

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

#include <time.h>

#include <omp.h>

#include <stdlib.h>

#include <stdio.h>

int main(int argc, char *argv[]){

    const int n = 500000;

    float a[n], b[n], c[n];

 

    for(int i = 0; i < n; i++){

     a[i] = 1.0f*rand()/n;

     b[i] = 1.0f*rand()/n;

    }

clock_t s = clock();

#pragma omp parallel for num_threads(4) schedule(dynamic, 1)

   for(int i = 0; i < n; i++)

     c[i] = a[i]*6/b[i] + 5;

clock_t e = clock();

printf("use time = %ld clocks, %f\n", e-s, c[250]);

    return 0;

}

简单分析如下:以第一次计算为例,线程0读写c[0],线程1读写c[1],线程2读写c[2],线程3读写c[3],这4个线程读写的c数组缓存在同一缓存线上,因此存在伪共享问题。解决这个性能问题的方法很简单,去掉负载均衡语句,或改成静态负载均衡,或将每次分配的块大小改为16(这个在将来的机器上可能不是最优的)。

实际上,上述例子的性能可能不会受影响,因为现代X86 CPU具有流处理指令,对于只读不写或只写不读的数据,如果访问步长为1,就可能会被流处理。

9. if子句

子句if提供了一种由运行时决定是否并行的机制,如果if条件为真,并行区域会被多个线程执行,否则只由一个线程执行。if语句示例如代码清单3-21所示。

代码清单3-21 if语句示例

 

1

2

3

4

5

6

7

8

9

10

11

12

13

#include <omp.h>

#include <stdio.h>

 

int main(int argc, char *argv[]){

    int x = 5;

 

#pragma omp parallel for num_threads(3) if(x <= 5)

for(int i = 0; i < 3; i++){

    printf("i am %d, address of x = %p\n", omp_get_thread_num(), &x);

}

 

    return 0;

}

由于x等于5,因此满足if条件,所以并行区域parallel for循环会被多个线程执行;如果将if条件改为x>5;那么并行区域将会只有一个线程执行。

此处参考博客:https://book.2cto.com/201507/53222.html

Logo

汇聚原天河团队并行计算工程师、中科院计算所专家以及头部AI名企HPC专家,助力解决“卡脖子”问题

更多推荐