1. OpenMP基本介绍
OpenMP是一个编译器指令和库函数的集合,主要是为共享式存储计算机上的并行程序设计使用的。目前支持OpenMP的语言主要有Fortran,C/C++。
1.1 fork/join并行执行模式的概念
OpenMP在并行执行程序时,采用的是fork/join式并行模式,共享存储式并行程序就是使用fork/join式并行的。在开始时,只有一个叫做主线程的运行线程存在 。在运行过程中,当遇到需要进行并行计算的时候,派生出(Fork)线程来执行并行任务 。在并行代码结束执行,派生线程退出或挂起,控制流程回到单独的主线程中(Join)。
如图,标准并行模式执行代码的基本思想是,程序开始时只有一个主线程,程序中的串行部分都由主线程执行,并行的部分是通过派生其他线程来执行,但是如果并行部分没有结束时是不会执行串行部分的,先看一个简单例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23void test()
{
int a = 0;
clock_t t1 = clock();
for (int i = 0; i < 100000000; i++)
{
a = i+1;
}
clock_t t2 = clock();
printf("Time = %d\n", t2-t1);
}
int main(int argc, char* argv[])
{
clock_t t1 = clock();
for ( int j = 0; j < 2; j++ ){
test();
}
clock_t t2 = clock();
printf("Total time = %d\n", t2-t1);
test();
return 0;
}
在main()
函数中,没有执行完for循环中的代码之前,后面的clock_t t2 = clock();
这行代码是不会执行的,如果和调用线程创建函数相比,它相当于先创建线程,并等待线程执行完,所以这种并行模式中在主线程里创建的线程并没有和主线程并行运行。
OpenMP编程模型
共享内存模型
OpenMP是为多处理器或多核共享内存机器设计的。底层架构可以是共享内存 UMA 或 NUMA。
Uniform Memory Access 一致内存访问
Ununiform Memory Access 非一致内存访问
因为OpenMP是为共享内存并行编程而设计的,所以它在很大程度上局限于单节点并行性。通常,节点上处理元素(核心)的数量决定了可以实现多少并行性。
在 HPC 中使用 OpenMP 的动机
- OpenMP本身的并行性仅限于单个节点。
- 对于高性能计算(HPC - High Performance Computing)应用程序,OpenMP 与 MPI 相结合以实现分布式内存并行。这通常被称为混合并行编程。
- OpenMP 用于每个节点上的计算密集型工作。
- MPI 用于实现节点之间的通信和数据共享。
这使得并行性可以在集群的整个范围内实现Hybrid OpenMP-MPI Parallelism。
基于线程的并行性
OpenMP 程序仅通过使用线程来实现并行性。执行线程是操作系统可以调度的最小处理单元。一种可以自动运行的子程序,这个概念可能有助于解释什么是线程。线程存在于单个进程的资源中。没有这个进程,它们就不复存在。通常,线程的数量与机器处理器/核心的数量相匹配。但是,线程的实际使用取决于应用程序。
显式并行性
OpenMP 是一个显式的(而不是自动的)编程模型,为程序员提供了对并行化的完全控制。并行化可以像获取串行程序和插入编译器指令一样简单…或者像插入子程序来设置多个并行级别、锁甚至嵌套锁一样复杂
Fork - Join 模型
OpenMP 使用并行执行的 fork-join 模型:
- 所有 OpenMP 程序都开始于一个主线程。主线程按顺序执行,直到遇到第一个并行区域结构。
- FORK:主线程然后创建一组并行线程。
- 之后程序中由并行区域结构封装的语句在各个团队线程中并行执行。
- JOIN:当团队线程完成并行区域结构中的语句时,它们将进行同步并终止,只留下主线程。
并行区域的数量和组成它们的线程是任意的。
数据范围
- 因为 OpenMP 是共享内存编程模型,所以在默认情况下,并行区域中的大多数数据都是共享的。
- 一个并行区域中的所有线程都可以同时访问共享数据。
- OpenMP 为程序员提供了一种方法,可以在不需要默认共享范围的情况下显式地指定数据的“作用域”。
动态线程
该 API 为运行时环境提供了动态更改线程数量的功能,这些线程用于执行并行区域。如有可能,旨在促进更有效地利用资源。实现可能支持这个特性,也可能不支持。
I/O
OpenMP 没有指定任何关于并行 I/O 的内容。如果多个线程试图从同一个文件进行写/读操作,这一点尤其重要。如果每个线程都对不同的文件执行 I/O,那么问题就不那么重要了。完全由程序员来确保在多线程程序的上下文中正确地执行 I/O。
内存模型:经常刷新?
OpenMP 提供了线程内存的“宽松一致性”和“临时”视图(用他们的话说)。换句话说,线程可以“缓存”它们的数据,并且不需要始终与实际内存保持精确的一致性。当所有线程以相同的方式查看共享变量非常重要时,程序员负责确保所有线程根据需要刷新该变量。
2. OpenMP编程
2.1 OpenMP指令和库函数介绍
下面来介绍OpenMP的基本指令和常用指令的用法,在C/C++中,OpenMP指令使用的格式为1
#pragma omp 指令 [子句[子句]…]
前面提到的parallel for
就是一条指令,有些书中也将OpenMP的“指令”叫做“编译指导语句”,后面的子句是可选的。例如:1
#pragma omp parallel private(i, j)
parallel
就是指令,private
是子句。为叙述方便把包含#pragma和OpenMP指令的一行叫做语句,如上面那行叫parallel语句。
2.2 OpenMP指令列表
这里我们先列举出OpenMP常用的指令和函数,并附上一些简单的说明。如果你看不懂,没关系,后面我们会对每个指令有详细的例子介绍。
parallel
:用在一个代码段之前,表示这段代码将被多个线程并行执行for
:用于for循环之前,将循环分配到多个线程中并行执行,必须保证每次循环之间无相关性。parallel for
,parallel
和for
语句的结合,也是用在一个for循环之前,表示for循环的代码将被多个线程并行执行。sections
,用在可能会被并行执行的代码段之前parallel sections
,parallel
和sections
两个语句的结合critical
,用在一段代码临界区之前single
,用在一段只被单个线程执行的代码段之前,表示后面的代码段将被单线程执行。flush
,用来保证线程的内存临时视图和实际内存保持一致,即各个线程看到的共享变量是一致的barrier
,用于并行区内代码的线程同步,所有线程执行到barrier时要停止,直到所有线程都执行到barrier时才继续往下执行。atomic
,用于指定一块内存区域被制动更新master
,用于指定一段代码块由主线程执行ordered
, 用于指定并行区域的循环按顺序执行threadprivate
,用于指定一个变量是线程私有的copyprivate
,配合single指令,将指定线程的专有变量广播到并行域内其他线程的同名变量中;copyin n
,用来指定一个threadprivate类型的变量需要用主线程同名变量进行初始化;default
,用来指定并行域内的变量的使用方式,缺省是shared。
2.3 OpenMP库函数
OpenMP除上述指令外,还有一些库函数,OpenMP运行时库函数原本用以设置和获取执行环境相关的信息.其也包含一系列用以同步的API.要使用运行时函数库所包含的函数,应该在相应的源文件中包含OpenMP头文件omp.h.OpenMP的运行时库函数的使用类似于相应编程语言内部的函数调用.
由编译指导语句和运行时库函数可见,OpenMP同时结合了两种并行编程的方式,通过编译指导语句,可以将串行的程序逐步地改造成一个并行程序,达到增量更新程序的目的,从而减少程序编写人员的一定负担。同时,这样的方式也能将串行程序和并行程序保存在同一个源代码文件当中。OpenMP在运行的时候,需要运行函数库的支持,并会获取一些环境变量来控制运行的过程。环境变量是动态函数库中用来控制函数运行的一些参数.
OpenMP API 包括越来越多的运行时库函数。这些函数有多种用途,如下表所示:
Routine | Purpose |
---|---|
opm_set_num_threads | 设置将在下一个并行区域中使用的线程数 |
opm_get_num_threads | 返回当前在团队中执行并行区域的线程数,该区域是调用该线程的地方 |
opm_get_max_threads | 返回可通过调用 opm_get_num_threads 函数返回的最大值 |
opm_get_thread_num | 返回在团队中执行此调用的线程的线程号 |
opm_get_thread_limit | 返回程序可用的 OpenMP 线程的最大数量 |
opm_get_num_procs | 返回程序可用的处理器数量 |
opm_in_parallel | 用于确定正在执行的代码段是否并行 |
opm_set_dynamic | 启用或禁用(由运行时系统)可用于执行并行区域的线程数的动态调整 |
opm_get_dynamic | 用于确定是否启用动态线程调整 |
opm_set_nested | 用于启用或禁用嵌套并行性 |
opm_get_nested | 用于确定是否启用嵌套并行性 |
opm_set_schedule | 在 OpenMP 指令中将“runtime”用作调度类型时,设置循环调度策略 |
opm_get_schedule | 当 OpenMP 指令中使用“runtime”作为调度类型时,返回循环调度策略 |
opm_set_max-active_levels | 设置嵌套并行区域的最大数目 |
opm_get_max-active_levels | 返回嵌套并行区域的最大数目 |
opm_get_level | 返回嵌套并行区域的当前级别 |
opm_get_ancestor_thread_num | 对于当前线程的给定嵌套级别,返回祖先线程的线程数 |
opm_get_team_size | 对于当前线程的给定嵌套级别,返回线程团队的大小 |
opm_get_active_level | 返回包含调用的任务的嵌套活动并行区域的数目 |
opm_in_final | 如果程序在最后一个任务区域执行,则返回true;否则返回false |
opm_init_lock | 初始化与锁变量关联的锁 |
opm_destory_lock | 将给定的锁变量与任何锁分离 |
opm_set_lock | 获得锁的所有权 |
opm_unset_lock | 释放锁 |
opm_test_lock | 尝试设置锁,但如果锁不可用,则不会阻塞 |
opm_init_nest_lock | 初始化与锁变量关联的嵌套锁 |
opm_destory_nest_lock | 将给定的嵌套锁变量与任何锁分离 |
opm_set_nest_lock | 获取嵌套锁的所有权 |
opm_unset_nest_lock | 释放嵌套锁 |
opm_test_nest_lock | 尝试设置嵌套锁,但如果锁不可用,则不会阻塞 |
opm_get_wtime | 提供便携式挂钟定时程序 |
opm_get_wtick | 返回一个双精度浮点值,该值等于连续时钟滴答之间的秒数 |
对于C/C++,所有运行时库函数都是实际的子程序。对于Fortran来说,有些是函数,有些是子程序。例如:1
2
int omp_get_num_threads(void)
注意,对于C/C++,通常需要包含<omp.h>
头文件。
对于锁程序/函数:
- 锁变量只能通过锁程序访问
- 对于Fortran,锁变量的类型应该是integer,并且要足够大,以便容纳一个地址。
- 对于C/C++,lock 变量的类型必须是
omp_lock_t
或omp_nest_lock_t
,这取决于所使用的函数。
实现注意事项:
- 实现可能支持也可能不支持所有 OpenMP API 特性。例如,如果支持嵌套并行,那么它可能只是名义上的,因为嵌套并行区域可能只有一个线程。
- 有关详细信息,请参阅您的实现文档—或者亲自试验一下,如果您在文档中找不到它,请自己查找。
2.3 OpenMP子句
private
:指定每个线程都有它自己的变量私有副本。firstprivate
:指定每个线程都有它自己的变量私有副本,并且变量要被继承主线程中的初值。lastprivate
:主要是用来指定将线程中的私有变量的值在并行处理结束后复制回主线程中的对应变量。reduce
:用来指定一个或多个变量是私有的,并且在并行处理结束后这些变量要执行指定的运算。nowait
:忽略指定中暗含的等待num_threads
:指定线程的个数schedule
:指定如何调度for循环迭代shared
:指定一个或多个变量为多个线程间的共享变量ordered
:用来指定for循环的执行要按顺序执行copyprivate
:用于single指令中的指定变量为多个线程的共享变量copyin
:用来指定一个threadprivate的变量的值要用主线程的值进行初始化。default
:用来指定并行处理区域内的变量的使用方式,缺省是shared
2.4 环境变量
OpenMP 提供了几个环境变量,用于在运行时控制并行代码的执行。这些环境变量可以用来控制这些事情:
- 设置线程数
- 指定如何划分循环交互
- 将线程绑定到处理器
- 启用/禁用嵌套的并行性;设置嵌套并行度的最大级别
- 启用/禁用动态线程
- 设置线程堆栈大小
- 设置线程等待策略
设置 OpenMP 环境变量的方法与设置任何其他环境变量的方法相同,并且取决于您使用的是哪种 shell。例如:1
2csh/tcsh: setenv OMP_NUM_THREADS 8
sh/bash: export OMP_NUM_THREADS=8
OpenMP 提供了以下环境变量来控制并行代码的执行。所有环境变量名都是大写的。分配给它们的值不区分大小写。
OMP_SCHEDULE
只适用于DO, PARALLEL DO (Fortran)和 for, parallel for (C/C++)指令,它们的 schedule 子句设置为运行时。此变量的值决定如何在处理器上调度循环的迭代。例如:1
2setenv OMP_SCHEDULE "guided, 4"
setenv OMP_SCHEDULE "dynamic"
OMP_NUM_THREADS
设置执行期间使用的最大线程数。例如:1
setenv OMP_NUM_THREADS 8
OMP_DYNAMIC
启用或禁用可用于并行区域执行的线程数量的动态调整。有效值为 TRUE 或 FALSE。例如:1
setenv OMP_DYNAMIC TRUE
OMP_PROC_BIND
启用或禁用线程绑定到处理器。有效值为 TRUE 或 FALSE。例如:1
setenv OMP_PROC_BIND TRUE
OMP_NESTED
启用或禁用嵌套并行性。有效值为 TRUE 或 FALSE。例如:1
setenv OMP_NESTED TRUE
OMP_STACKSIZE
控制已创建(非主)线程的堆栈大小。例子:1
2
3
4
5
6
7setenv OMP_STACKSIZE 2000500B
setenv OMP_STACKSIZE "3000 k "
setenv OMP_STACKSIZE 10M
setenv OMP_STACKSIZE " 10 M "
setenv OMP_STACKSIZE "20 m "
setenv OMP_STACKSIZE " 1G"
setenv OMP_STACKSIZE 20000
OMP_WAIT_POLICY
为 OpenMP 实现提供有关等待线程的所需行为的提示。一个兼容的 OpenMP 实现可能遵守也可能不遵守环境变量的设置。有效值分为 ACTIVE 和 PASSIVE。ACTIVE 指定等待的线程大部分应该是活动的,即,在等待时消耗处理器周期。PASSIVE 指定等待的线程大部分应该是被动的,即,而不是在等待时消耗处理器周期。ACTIVE 和 PASSIVE 行为的细节是由实现定义的。例子:1
2
3
4setenv OMP_WAIT_POLICY ACTIVE
setenv OMP_WAIT_POLICY active
setenv OMP_WAIT_POLICY PASSIVE
setenv OMP_WAIT_POLICY passive
OMP_MAX_ACTIVE_LEVELS
控制嵌套的活动并行区域的最大数目。该环境变量的值必须是非负整数。如果 OMP_MAX_ACTIVE_LEVELS 的请求值大于实现所能支持的嵌套活动并行级别的最大数量,或者该值不是一个非负整数,则该程序的行为是由实现定义的。例子:1
setenv OMP_MAX_ACTIVE_LEVELS 2
OMP_THREAD_LIMIT
设置用于整个 OpenMP 程序的 OpenMP 线程的数量。这个环境变量的值必须是正整数。如果 OMP_THREAD_LIMIT 的请求值大于实现所能支持的线程数,或者该值不是正整数,则程序的行为是由实现定义的。例子:1
setenv OMP_THREAD_LIMIT 8
2.5 编译 OpenMP 程序
OpenMP 版本依赖的 GCC 版本
OpenMP 版本 | GCC版本 |
---|---|
OpenMP 5.0 | >= GCC 9.1 |
OpenMP 4.5 | >= GCC 6.1 |
OpenMP 4.0 | >= GCC 4.9.0 |
OpenMP 3.1 | >= GCC 4.7.0 |
OpenMP 3.0 | >= GCC 4.4.0 |
OpenMP 2.5 | >= GCC 4.2.0 |
linux 下编译命令示例:1
g++ Test.cpp -o omptest -fopenmp
2.6 OpenMP API 概述
三大构成
OpenMP 3.1 API 由三个不同的组件组成:
- 编译器指令
- 运行时库函数
- 环境变量
后来的一些 API 包含了这三个相同的组件,但是增加了指令、运行时库函数和环境变量的数量。应用程序开发人员决定如何使用这些组件。在最简单的情况下,只需要其中的几个。实现对所有 API 组件的支持各不相同。例如,一个实现可能声明它支持嵌套并行,但是 API 清楚地表明它可能被限制在一个线程上——主线程。不完全符合开发人员的期望?
编译器指令
编译器指令在源代码中以注释的形式出现,编译器会忽略它们,除非您另外告诉它们 — 通常通过指定适当的编译标志,如后面的编译部分所述。OpenMP 编译器指令用于各种目的:
- 生成一个并行区域
- 在线程之间划分代码块
- 在线程之间分配循环迭代
- 序列化代码段
- 线程之间的工作同步
编译器指令有以下语法:1
sentinel directive-name [clause, ...]
例如:1
后面将详细讨论编译器指令。
运行时库函数 Run-time Library Routines:
OpenMP API 包括越来越多的运行时库函数。这些程序用于各种目的:
- 设置和查询线程的数量
- 查询线程的唯一标识符(线程ID)、父线程的标识符、线程团队大小
- 设置和查询动态线程特性
- 查询是否在一个并行区域,以及在什么级别
- 设置和查询嵌套并行性
- 设置、初始化和终止锁以及嵌套锁
- 查询 wall clock time 和分辨率
对于 C/C++,所有运行时库函数都是实际的子程序。对于Fortran来说,有些是函数,有些是子程序。例如:1
2
int omp_get_num_threads(void)
注意,对于C/C++,通常需要包含<omp.h >
头文件。
运行时库函数将在运行时库函数一节中作为概述进行简要讨论,更多细节将在附录A中讨论。
2.7 OpenMP 指令
C/C++ 指令格式
格式1
#pragma omp directive-name [clause, ...] newline
所有 OpenMP C/C++ 指令都需要。 一个有效的 OpenMP 指令。必须出现在 pragma 之后和任何子句之前。 可选的。除非另有限制,子句可以按任何顺序重复。 必需的。在此指令所包含的结构化块之前。
一般规则
- 区分大小写。
- 指令遵循 C/C++ 编译器指令标准的约定。
- 每个指令只能指定一个指令名。
- 每个指令最多应用于一个后续语句,该语句必须是一个结构化块。
- 长指令行可以通过在指令行的末尾使用反斜杠(“\”)来转义换行符,从而在后续的行中“继续”。
指令范围
静态(词法)范围
- 在指令后面的结构化块的开始和结束之间以文本形式封装的代码。
- 指令的静态范围不跨越多个程序或代码文件。
孤立的指令
一个 OpenMP 指令,独立于另一个封闭指令,称为孤立型指令。它存在于另一个指令的静态(词法)范围之外。将跨越程序和可能的代码文件。
动态范围
指令的动态范围包括静态(词法)范围和孤立指令的范围。
为什么这很重要?
OpenMP 为指令如何相互关联(绑定)和嵌套指定了许多范围规则。如果忽略 OpenMP 绑定和嵌套规则,可能会导致非法或不正确的程序。有关详细信息,请参阅指令绑定和嵌套规则。
并行区域结构
目的
并行区域是由多个线程执行的代码块。这是基本的 OpenMP 并行结构。
格式
1 |
|
注意
- 当一个线程执行到一个并行指令时,它创建一个线程组并成为该线程组的主线程。主线程是该团队的成员,在该团队中线程号为0。
- 从这个并行区域开始,代码被复制,所有线程都将执行该代码。
- 在并行区域的末端有一个隐含的屏障。只有主线程在此之后继续执行。
- 如果任何线程在一个并行区域内终止,则团队中的所有线程都将终止,并且在此之前所做的工作都是未定义的。
有多少线程
并行区域内的线程数由以下因素决定,按优先级排序:
IF
子句的计算NUM_THREADS
子句的设置- 使用
omp_set_num_threads()
库函数 - 设置
OMP_NUM_THREADS
环境变量 - 实现缺省值 — 通常是一个节点上的 cpu 数量,尽管它可以是动态的(参见下一小节)
- 线程的编号从0(主线程)到N-1。
动态线程
使用omp_get_dynamic()
库函数来确定是否启用了动态线程。如果支持的话,启用动态线程的两种方法是:
omp_set_dynamic()
库函数- 将
OMP_NESTED
环境变量设置为 TRUE
如果不支持,则在另一个并行区域内嵌套一个并行区域,从而在默认情况下创建一个由单个线程组成的新团队。
子句
IF 子句:如果存在,它的值必须为非零,以便创建一个线程组。否则,该区域将由主线程串行执行。
限制条件
- 并行区域必须是不跨越多个程序或代码文件的结构化块。
- 从一个并行区域转入或转出是非法的。
- 只允许一个 IF 子句。
- 只允许一个 NUM_THREADS 子句。
- 程序不能依赖于子句的顺序。
并行区域例子
简单的“Hello World”程序。每个线程执行包含在并行区域中的所有代码。OpenMP 库函数用于获取线程标识符和线程总数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main(int argc, char *argv[]) {
int nthreads, tid;
/* Fork a team of threads with each thread having a private tid variable */
{
/* Obtain and print thread id */
tid = omp_get_thread_num();
printf("Hello World from thread = %d\n", tid);
/* Only master thread does this */
if (tid == 0) {
nthreads = omp_get_num_threads();
printf("Number of threads = %d\n", nthreads);
}
} /* All threads join master thread and terminate */
return 0;
}
工作共享结构
工作共享结构将封闭代码区域的执行划分给遇到它的团队成员。工作共享结构不会启动新线程。在进入工作共享结构时没有隐含的屏障,但是在工作共享结构的末尾有一个隐含的屏障。
工作共享结构的类型:
- DO / for - 整个团队的循环迭代。表示一种“数据并行性”。
- SECTIONS - 把工作分成单独的、不连续的部分。每个部分由一个线程执行。可以用来实现一种“函数并行性”。
- SINGLE - 序列化一段代码。
限制条件
为了使指令能够并行执行,必须将工作共享结构动态地封装在一个并行区域中。团队的所有成员都必须遇到工作共享结构,或者根本不遇到。团队的所有成员必须以相同的顺序遇到连续的工作共享结构。
DO / for 指令
DO / for 指令指定紧随其后的循环迭代必须由团队并行执行。这假定已经启动了并行区域,否则它将在单个处理器上串行执行。
1 |
|
schedule
:描述循环迭代如何在团队中的线程之间进行分配。默认的调度是依赖于实现的。有关如何使一种调度比其他调度更优的讨论,请参见http://openmp.org/forum/viewtopic.php?f=3&t=83 。- 静态(STATIC) - 循环迭代被分成小块,然后静态地分配给线程。如果没有指定 chunk,则迭代是均匀地(如果可能)在线程之间连续地划分。
- 动态(DYNAMIC) - 循环迭代分成小块,并在线程之间动态调度;当一个线程完成一个块时,它被动态地分配给另一个块。默认块大小为1。
- 引导(GUIDED) - 当线程请求迭代时,迭代被动态地分配给块中的线程,直到没有剩余的块需要分配为止。与 DYNAMIC 类似,只是每次将一个工作包分配给一个线程时,块的大小就会减小。
- 初始块的大小与 number_of_iteration / number_of_threads 成比例
- 后续块与number_of_iterations_remaining / number_of_threads 成比例
- chunk 参数定义最小块大小。默认块大小为1。
- 运行时(RUNTIME) - 环境变量 OMP_SCHEDULE 将调度决策延迟到运行时。为这个子句指定块大小是非法的。
- 自动(AUTO) - 调度决策被委托给编译器或运行时系统。
nowait
: 如果指定,那么线程在并行循环结束时不会同步。ordered
:指定循环的迭代必须像在串行程序中一样执行。collapse
:指定在一个嵌套循环中有多少个循环应该折叠成一个大的迭代空间,并根据 schedule 子句进行划分。折叠迭代空间中的迭代的顺序是确定的,就好像它们是按顺序执行的一样。可能会提高性能。- 其他子句稍后将在数据范围属性子句一节中详细描述。
限制条件
- DO 循环不能是 DO WHILE 循环,也不能是没有循环控制的循环。此外,循环迭代变量必须是整数,并且对于所有线程,循环控制参数必须相同。
- 程序的正确性不能依赖于哪个线程执行特定的迭代。
- 在与 DO / for 指令关联的循环中跳转(转到)是非法的。
- 块大小必须指定为循环不变的整数表达式,因为在不同线程求值期间不存在同步。
- ORDERED、COLLAPSE、SCHEDULE 子句可以出现一次。
- 有关其他限制,请参阅 OpenMP 规范文档。
DO / for 指令示例
简单的 vector 相加程序,数组 A、B、C 和变量 N 将由所有线程共享。变量 i 对每个线程都是私有的;每个线程都有自己唯一的副本。循环迭代将在 CHUNK 大小的块中动态分布。线程在完成各自的工作后将不会同步 (NOWAIT)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main(int argc, char *argv[]) {
int i, chunk;
float a[N], b[N], c[N];
/* Some initializations */
for (i = 0; i < N; i++)
a[i] = b[i] = i * 1.0;
chunk = CHUNKSIZE;
{
for (i = 0; i < N; i++)
c[i] = a[i] + b[i];
} /* end of parallel region */
return 0;
}
sections 指令
目的
sections 指令是一个非迭代的工作共享结构。它指定所包含的代码段将被分配给团队中的各个线程。
独立的 section 指令嵌套在 sections 指令中。每个部分由团队中的一个线程执行一次。不同的部分可以由不同的线程执行。如果一个线程执行多个部分的速度足够快,并且实现允许这样做,那么它就可以执行多个部分。
1 |
|
子句
除非使用了 NOWAIT/nowait 子句,否则在 sections 指令的末尾有一个隐含的屏障(译者注:an implied barrier 意思应该是线程会相互等待)。稍后将在数据范围属性子句一节中详细描述子句。
限制条件
跳转(转到)或跳出 section 代码块是非法的。section 指令必须出现在一个封闭的 sections 指令的词法范围内(没有独立部分)。
sections 指令示例
下面一个简单的程序演示不同的工作块将由不同的线程完成。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
int main() {
int i;
float a[N], b[N], c[N], d[N];
/* Some initializations */
for (i = 0; i < N; i++) {
a[i] = i * 1.5;
b[i] = i + 22.35;
}
{
{
for (i = 0; i < N; i++)
c[i] = a[i] + b[i];
for (i = 0; i < N; i++)
d[i] = a[i] * b[i];
} /* end of sections */
} /* end of parallel region */
return 0;
}
single 指令
目的
single 指令指定所包含的代码仅由团队中的一个线程执行。在处理非线程安全的代码段(如 I/O )时可能很有用
格式
1 |
|
子句
除非指定了 nowait 子句,否则团队中不执行 single 指令的线程将在代码块的末尾等待。稍后将在数据范围属性子句一节中详细描述子句。
限制条件
进入或跳出一个 single 代码块是非法的。
合并并行工作共享结构
OpenMP 提供了三个简单的指令:
- parallel for
- parallel sections
- PARALLEL WORKSHARE (fortran only)
在大多数情况下,这些指令的行为与单独的并行指令完全相同,并行指令后面紧跟着一个单独的工作共享指令。大多数适用于这两个指令的规则、条款和限制都是有效的。有关详细信息,请参阅 OpenMP API。
下面显示了一个使用 parallel for 组合指令的示例。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main() {
int i, chunk;
float a[N], b[N], c[N];
/* Some initializations */
for (i = 0; i < N; i++)
a[i] = b[i] = i * 1.0;
chunk = CHUNKSIZE;
for (i = 0; i < N; i++)
c[i] = a[i] + b[i];
return 0;
}
任务结构
目的
任务结构定义了一个显式任务,该任务可以由遇到的线程执行,也可以由团队中的任何其他线程延迟执行。任务的数据环境由数据共享属性子句确定。任务执行取决于任务调度 — 详细信息请参阅 OpenMP 3.1 规范文档
格式
1 |
|
同步结构
考虑一个简单的例子,两个线程都试图同时更新变量x:1
2
3
4
5
6
7
8
9THREAD1
update(x)
{
x = x + 1
}
x = 0
update(x)
print(x)
1 | THREAD2 |
一种可能的执行顺序:
- 线程1初始化 x 为0并调用
- 线程1将 x 加1,x 现在等于1
- 线程2初始化 x 为0并调用 update(x),x现在等于0
- 线程1输出 x,它等于0而不是1
- 线程2将 x 加1,x 现在等于1
- 线程2打印 x 为1
为了避免这种情况,必须在两个线程之间同步 x 的更新,以确保产生正确的结果。OpenMP 提供了各种同步结构,这些构造控制每个线程相对于其他团队线程的执行方式。
master 指令
目的
master 指令指定了一个区域,该区域只由团队的主线程执行。团队中的所有其他线程都将跳过这部分代码。这个指令没有隐含的障碍( implied barrier )。
格式
1 |
|
限制条件
进入或跳出一个 master 代码块是非法的。
critical 指令
目的
critical 指令指定了一个只能由一个线程执行的代码区域。
格式
1 |
|
注意事项
- 如果一个线程当前在一个 critical 区域内执行,而另一个线程到达该 critical 区域并试图执行它,那么它将阻塞,直到第一个线程退出该 critical 区域。
- 可选的名称使多个不同的临界区域存在:
- 名称充当全局标识符。具有相同名称的不同临界区被视为相同的区域。
- 所有未命名的临界段均视为同一段。
限制条件
进入或跳出一个 critical 代码块是非法的。
Fortran only: The names of critical constructs are global entities of the program. If a name conflicts with any other entity, the behavior of the program is unspecified.
critical 结构示例
团队中的所有线程都将尝试并行执行,但是由于 x 的增加由 critical 结构包围,在任何时候只有一个线程能够读/增量/写 x。1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
int x;
x = 0;
{
x = x + 1;
} /* end of parallel region */
return 0;
}
barrier 指令
目的
barrier 指令同步团队中的所有线程。当到达 barrier 指令时,一个线程将在该点等待,直到所有其他线程都到达了 barrier 指令。然后,所有线程继续并行执行 barrier 之后的代码。
格式
1 |
f
限制条件
团队中的所有线程(或没有线程)都必须执行 barrier 区域。对于团队中的每个线程,遇到的 work-sharing 区域和 barrier 区域的顺序必须是相同的。
taskwait 指令
目的
taskwait 结构指定自当前任务开始以来生成的子任务完成时的等待时间。
格式
1 |
限制条件
因为 taskwait 结构是一个独立的指令,所以它在程序中的位置有一些限制。taskwait 指令只能放置在允许使用基本语言语句的地方。taskwait 指令不能代替 if、while、do、switch 或 label 后面的语句。有关详细信息,请参阅 OpenMP 3.1 规范文档。
atomic 指令
目的
atomic 结构确保以原子方式访问特定的存储位置,而不是将其暴露给多个线程同时读写,这些线程可能会导致不确定的值。本质上,这个指令提供了一个最小临界( mini-CRITICAL )区域。
格式
1 | #pragma omp atomic [ read | write | update | capture ] newline |
限制条件
该指令仅适用于紧接其后的单个语句。原子语句必须遵循特定的语法。查看最新的OpenMP规范。
flush 指令
目的
flush 指令标识了一个同步点,在这个点上,内存数据必须一致。这时,线程可见的变量被写回内存。请参阅最新的 OpenMP 规范以获取详细信息。
格式
1 |
注意事项
可选 list 参数包含一个将被刷新的已命名变量列表,以避免刷新所有变量。对于列表中的指针,请注意指针本身被刷新,而不是它指向的对象。实现必须确保线程可见变量的任何修改在此之后对所有线程都是可见的,例如编译器必须将值从寄存器恢复到内存,硬件可能需要刷新写缓冲区,等等。对于下面的指令,将使用 flush 指令。如果存在 nowait 子句,则该指令无效。
- barrier
- parallel - 进入和退出
- critical - 进入和退出
- ordered - 进入和退出
- for - 退出
- sections - 退出
- single - 退出
ordered 指令
目的
ordered 指令指定封闭的循环迭代将以串行处理器上执行顺序执行。如果之前的迭代还没有完成,线程在执行它们的迭代块之前需要等待。在带有 ordered 子句的 for 循环中使用。ordered 指令提供了一种“微调”的方法,其中在循环中应用了排序。否则,它不是必需的。
格式
1 |
|
限制条件
- 一个 ordered 指令只能在以下指令的动态范围内出现:
- for 或者 parallel for (C/C++)。
- 在一个有序的区段中,任何时候都只允许一个线程。
- 进入或跳出一个 ordered 代码块是非法的。
- 一个循环的迭代不能多次执行同一个有序指令,也不能一次执行多个有序指令。
- 包含有序指令的循环必须是带有 ordered 子句的循环。
threadprivate 指令
目的
threadprivate 指令指定复制变量,每个线程都有自己的副本。
可用于通过执行多个并行区域将全局文件作用域变量(C/C++/Fortran)或公共块(Fortran)局部化并持久化到一个线程。
格式
1 |
注意事项
- 指令必须出现在列出的变量/公共块的声明之后。每个线程都有自己的变量/公共块的副本,所以一个线程写的数据对其他线程是不可见的。
- 在第一次进入一个并行区域时,应该假设 threadprivate 变量和公共块中的数据是未定义的,除非在并行指令中指定了 copyin 子句。
- threadprivate 变量不同于 private 变量(稍后讨论),因为它们能够在代码的不同并行区域之间持久存在。
示例
1 |
|
Output:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
211st Parallel Region:
Thread 4: a,b,x= 4 4 5.400000
Thread 7: a,b,x= 7 7 8.700000
Thread 2: a,b,x= 2 2 3.200000
Thread 3: a,b,x= 3 3 4.300000
Thread 6: a,b,x= 6 6 7.600000
Thread 1: a,b,x= 1 1 2.100000
Thread 5: a,b,x= 5 5 6.500000
Thread 0: a,b,x= 0 0 1.000000
************************************
Master thread doing serial work here
************************************
2nd Parallel Region:
Thread 1: a,b,x= 1 0 2.100000
Thread 6: a,b,x= 6 0 7.600000
Thread 4: a,b,x= 4 0 5.400000
Thread 5: a,b,x= 5 0 6.500000
Thread 2: a,b,x= 2 0 3.200000
Thread 7: a,b,x= 7 0 8.700000
Thread 0: a,b,x= 0 0 1.000000
Thread 3: a,b,x= 3 0 4.300000
限制条件
只有在动态线程机制“关闭”并且不同并行区域中的线程数量保持不变的情况下,threadprivate 对象中的数据才能保证持久。动态线程的默认设置是未定义的。
数据范围属性子句
也称为数据共享属性子句。
OpenMP 编程的一个重要考虑是理解和使用数据作用域。因为 OpenMP 是基于共享内存编程模型的,所以大多数变量在默认情况下是共享的。
全局变量包括:
- Fortran: COMMON blocks, SAVE variables, MODULE variables
- 文件作用域变量,static
私有变量包括:
- 循环索引变量
- 从并行区域调用的子程序中的堆栈变量
- Fortran: Automatic variables within a statement block
OpenMP 数据范围属性子句用于显式定义变量的范围。它们包括:
- private
- firstprivate
- lastprivate
- shared
- default
- reduction
- copyin
数据范围属性子句与几个指令(parallel、DO/for 和 sections)一起使用,以控制所包含变量的范围。这些结构提供了在并行结构执行期间控制数据环境的能力。它们定义了如何将程序的串行部分中的哪些数据变量传输到程序的并行区域(以及向后传输)。它们定义哪些变量将对并行区域中的所有线程可见,哪些变量以私有形式分配给所有线程。数据范围属性子句仅在其词法/静态范围内有效。
private 子句
目的
private 子句将在其列表中的变量声明为每个线程的私有变量。
格式
1 | private (list) |
注意事项
私有变量的行为如下:
- 为团队中的每个线程声明一个相同类型的新对象
- 所有对原始对象的引用都被替换为对新对象的引用
- 应该假定每个线程都没有初始化
shared 子句
目的
shared 子句声明其列表中的变量在团队中的所有线程之间共享。
格式
1 | shared (list) |
注意事项
共享变量只存在于一个内存位置,所有线程都可以读写该地址,程序员有责任确保多个线程正确地访问共享变量(例如通过临界区)
default 子句
目的
default 子句允许用户为任何并行区域的词法范围内的所有变量指定默认作用域。
格式
1 | default (shared | none) |
注意事项
使用 private、shared、firstprivate、lastprivate 和 reduction 子句可以避免使用特定变量。C/C++ OpenMP 规范不包括将 private 或 firstprivate 作为可能的默认值。但是,实际的实现可能会提供这个选项。使用 none 作为默认值要求程序员显式地限定所有变量的作用域。
firstprivate 子句
目的
firstprivate 子句将 private 子句的行为与它的列表中变量的自动初始化相结合。
格式
1 | firstprivate (list) |
注意事项
在进入并行或工作共享结构之前,将根据其原始对象的值初始化列出的变量。
lastprivate 子句
目的
lastprivate 子句将 private 子句的行为与从最后一个循环迭代或部分到原始变量对象的复制相结合。
格式
1 | lastprivate (list) |
注意事项
复制回原始变量对象的值是从封闭结构的最后一次(顺序)迭代或部分获得的。例如,为 DO 部分执行最后一次迭代的团队成员,或者执行 sections 上下文的最后一部分的团队成员,使用其自身的值执行副本。
copyin 子句
目的
copyin 子句提供了为团队中的所有线程分配相同值的 threadprivate 变量的方法。
格式
1 | copyin (list) |
注意事项
列表包含要复制的变量的名称。在Fortran中,列表既可以包含公共块的名称,也可以包含已命名变量的名称。主线程变量用作复制源。在进入并行结构时,将使用其值初始化团队线程。
copyprivate 子句
目的
copyprivate 子句可用于将单个线程获得的值直接传播到其他线程中私有变量的所有实例。与 single 指令相关联
格式
1 | copyprivate (list) |
reduction 子句
目的
reduction 子句对出现在其列表中的变量执行约简操作。为每个线程创建并初始化每个列表变量的私有副本。在约简结束时,将约简变量应用于共享变量的所有私有副本,并将最终结果写入全局共享变量。
格式
1 | reduction (operator: list) |
Example: REDUCTION - Vector Dot Product:
并行循环的迭代将以相同大小的块分配给团队中的每个线程(调度静态),在并行循环构造的末尾,所有线程将添加它们的“result”值来更新主线程的全局副本。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main() {
int i, n, chunk;
float a[100], b[100], result;
/* Some initializations */
n = 100;
chunk = 10;
result = 0.0;
for (i = 0; i < n; i++) {
a[i] = i * 1.0;
b[i] = i * 2.0;
}
for (i = 0; i < n; i++)
result = result + (a[i] * b[i]);
printf("Final result= %f\n", result);
return 0;
}
限制条件
- 列表项的类型必须对约简操作符有效。
- 列表项/变量不能声明为共享或私有。
- 约简操作可能与实数无关。
- 有关其他限制,请参见 OpenMP 标准 API。
指令绑定和嵌套规则
本节主要是作为管理 OpenMP 指令和绑定的规则的快速参考。用户应该参考他们的实现文档和 OpenMP 标准以了解其他规则和限制。除非另有说明,规则适用于 Fortran 和 C/C++ OpenMP 实现。
注意:Fortran API 还定义了许多数据环境规则。这些没有在这里复制。
指令绑定
- DO/for、sections、single、master 和 barrier 指令绑定到动态封闭的 parallel (如果存在的话)。如果当前没有并行区域被执行,指令就没有效果。
- 有序指令绑定到动态封闭的 DO/for 。
- atomic 指令强制对所有线程中的 atomic 指令进行独占访问,而不仅仅是当前的团队。
- critical 指令强制对所有线程中的 critical 指令进行独占访问,而不仅仅是当前的团队。
- 指令永远不能绑定到最接近的封闭并行之外的任何指令。
指令嵌套
- 工作共享区域不能紧密嵌套在工作共享、显式任务、关键区域、有序区域、原子区域或主区域内。
- 屏障区域不能紧密嵌套在工作共享、显式任务、关键区域、有序区域、原子区域或主区域中。
- 主区域不能紧密嵌套在工作共享、原子或显式任务区域内。
- 有序区域可能不会紧密嵌套在临界、原子或显式任务区域内。
- 一个有序区域必须与一个有序子句紧密嵌套在一个循环区域(或并行循环区域)内。
- 临界区不能嵌套(紧密嵌套或以其他方式嵌套)在具有相同名称的临界区内。注意,此限制不足以防止死锁。
- 并行、刷新、临界、原子、taskyield 和显式任务区域可能不会紧密嵌套在原子区域内。
2.8 线程堆栈大小和线程绑定
线程堆栈大小
OpenMP 标准没有指定一个线程应该有多少堆栈空间。因此,默认线程堆栈大小的实现将有所不同。
默认的线程堆栈大小很容易耗尽。它也可以在编译器之间不可移植。以过去版本的LC编译器为例:
Compiler | Approx. Stack Limit | Approx. Array Size (doubles) |
---|---|---|
Linux icc, ifort | 4 MB | 700 x 700 |
Linux pgcc, pgf90 | 8 MB | 1000 x 1000 |
Linux gcc, gfortran | 2 MB | 500 x 500 |
- 超出其堆栈分配的线程可能存在或不存在段错误。当数据被破坏时,应用程序可以继续运行。
- 静态链接代码可能受到进一步的堆栈限制。
- 用户的登录shell还可以限制堆栈大小
如果您的 OpenMP 环境支持 OpenMP 3.0 OMP_STACKSIZE 环境变量(在前一节中介绍过),那么您可以使用它在程序执行之前设置线程堆栈大小。例如:1
2
3
4
5
6
7setenv OMP_STACKSIZE 2000500B
setenv OMP_STACKSIZE "3000 k "
setenv OMP_STACKSIZE 10M
setenv OMP_STACKSIZE " 10 M "
setenv OMP_STACKSIZE "20 m "
setenv OMP_STACKSIZE " 1G"
setenv OMP_STACKSIZE 20000
否则,在LC上,您应该能够对Linux集群使用下面的方法。该示例显示将线程堆栈大小设置为12 MB,作为预防措施,将shell堆栈大小设置为无限制。
csh/tcsh:1
2setenv KMP_STACKSIZE 12000000
limit stacksize unlimited
ksh/sh/bash:1
2export KMP_STACKSIZE=12000000
ulimit -s unlimited
线程绑定
- 在某些情况下,如果一个程序的线程被绑定到处理器/核心,那么它的性能会更好。
- “绑定”一个线程到一个处理器意味着操作系统将调度一个线程始终在同一个处理器上运行。否则,可以将线程调度为在任何处理器上执行,并在每个时间片的处理器之间来回“弹回”。也称为“线程关联性”或“处理器关联性”。
- 将线程绑定到处理器可以更好地利用缓存,从而减少昂贵的内存访问。这是将线程绑定到处理器的主要动机。
根据平台、操作系统、编译器和 OpenMP 实现的不同,可以通过几种不同的方式将线程绑定到处理器。OpenMP 3.1 版 API 提供了一个环境变量来“打开”或“关闭”处理器绑定。例如:1
2setenv OMP_PROC_BIND TRUE
setenv OMP_PROC_BIND FALSE
在更高的级别上,进程也可以绑定到处理器。
3. OpenMP详细代码示例
3.1 hello_openmp.cpp
1 | /* 尝试着在编译选项里使用和不使用-openmp 这个编译选项分别编译并执行代码 */ |
3.2 header_and_env.cpp
1 | /* 引入<omp.h>头文件,OpenMP的几乎所有函数定义都在这个头文件中。 */ |
3.3 parallel.cpp
1 | /* |
3.4 parallel_cout.cpp
1 | /* |
3.5 nested.cpp
如果嵌套并行可用,则在并行区里还会继续创建线程,level 2会输出4次。否则的话level 2输出2次。
1 | /* |
3.6 parallel-for.cpp
1 | /* |
3.7 scoping.cpp
1 | /* |
3.8 firstprivate.cpp
firstprivate的作用是,让i默认使用并行区域外i的值来初始化并行区域内的私有i,但是初始化后并行去内部的i就跟外面的没有关系了,各个线程仍然持有一个i的私有备份,运行结束时,原有的i值保持i=10不变。
1 |
|
3.9 lastprivate.cpp
跟firstprivate相反,lastprivate主要是用来指定将线程中的私有变量的值在并行处理结束后复制回主线程中的对应变量。
1 |
|
3.10 single-master-critical.cpp
- omp critical -> execute by one thread at a time
- omp single -> execute by any one thread
- omp master -> execute by the master thread (id == 0)
1 |
|
3.11 mutex.cpp
锁是多线程计算里非常重要的概念,他是保证数据一致性的基础,没有锁的并行计算会导致非常奇怪的结果。
举个栗子:当多个线程操作一个数据时,一个线程在读取一个数的时候另两个线程在对这个数作写操作,那这个读线程到底应该拿哪一个数值去做计算呢?回答当然是,拿最后更改这个值的线程的值去计算,但是这个值就是对的么?
很多时候即使是并行的,但是对于一个简单操作来讲,它也是需要有先后循序的,比如这个线程在操作这个变量的时候要求别的线程要等待这个线程操作完成,这时候就使用锁将该变量锁住。
1 |
|
3.12 barrier.cpp
- 同步也是并行计算中特别重要的概念,跟上面讲的锁一样;
- 特别是在时间相关的计算领域里,如含时的有限差分等等;
- 因为在具体程序中,每个线程执行的任务不一样,即使执行的任务一样,也不能保证每个线程执行任务消费的时间都完全一致。有的线程已经执行了5行代码,有的线程才执行到第0行。而含时的迭代需要所有线程都执行完t步骤后,才能继续执行t+1时间步,不然会导致错误的结果。
1 |
|
3.13 atomic.cpp
原子变量跟锁有相近似的作用,都是保证变量或者事务的一致性;举个栗子:银行转账,A给B转账过程中突然停电,A账户前丢失了B却没有收到,这是谁的责任?当然是银行的责任。当然,银行的程序员们可不是吃白米饭的,转账的操作就是一个原子操作,只有成功或失败,没有成功了一半这个说法。
1 |
|
3.14 reduction.cpp
1 |
|
3.15 scheduling.cpp
schedule只能用于循环并行构造中,其作用是用于控制循环并行结构的任务调度。一个简单的理解,一个for循环假设有10次迭代,使用4个线程去执行,那么哪些线程去执行哪些迭代呢?可以通过schedule去控制迭代的调度和分配,从而适应不同的使用情况,提高性能。
- static -> 大部分的编译器实现,在没有使用schedule子句的时候,系统就是采用static方式调度的。
- 对于
schedule(static,size)
的含义,OpenMP会给每个线程分配size次迭代计算。这个分配是静态的,“静态”体现在这个分配过程跟实际的运行是无关的,可以从逻辑上推断出哪几次迭代会在哪几个线程上运行。具体而言,对于一个N次迭代,使用M个线程,那么,[0,size-1]
的size次的迭代是在第一个线程上运行,[size, size + size -1]
是在第二个线程上运行,依次类推。那么,如果M太大,size也很大,就可能出现很多个迭代在一个线程上运行,而某些线程不执行任何迭代。需要说明的是,这个分配过程就是这样确定的,不会因为运行的情况改变,比如,我们知道,进入OpenMP后,假设有M个线程,这M个线程开始执行的时间不一定是一样的,这是由OpenMP去调度的,并不会因为某一个线程先被启动,而去改变for的迭代的分配,这就是静态的含义。
- 对于
- dynamic -> 每个线程运行结束时获得新的计算任务
- 动态调度迭代的分配是依赖于运行状态进行动态确定的,所以哪个线程上将会运行哪些迭代是无法像静态一样事先预料的。对于dynamic,没有size参数的情况下,每个线程按先执行完先分配的方式执行1次循环,比如,刚开始,线程1先启动,那么会为线程1分配一次循环开始去执行(i=0的迭代),然后,可能线程2启动了,那么为线程2分配一次循环去执行(i=1的迭代),假设这时候线程0和线程3没有启动,而线程1的迭代已经执行完,可能会继续为线程1分配一次迭代,如果线程0或3先启动了,可能会为之分配一次迭代,直到把所有的迭代分配完。所以,动态分配的结果是无法事先知道的,因为我们无法知道哪一个线程会先启动,哪一个线程执行某一个迭代需要多久等等,这些都是取决于系统的资源、线程的调度等等。
- guided -> 类似动态钓鱼,但是 chunk size是自适应的。
- 类似于动态调度,但每次分配的循环次数不同,开始比较大,以后逐渐减小。size表示每次分配的迭代次数的最小值,由于每次分配的迭代次数会逐渐减少,较少到size时,将不再减少。如果不知道size的大小,那么默认size为1,即一直减少到1。具体是如何减少的,以及开始比较大(具体是多少?),参考相关手册的信息。
- auto -> 编译器动态决定采用那种策略
- runtime表示根据环境变量确定上述调度策略中的某一种,默认也是静态的(static)。
1 |
|
3.16 ordered.cpp
在循环代码中某些代码的执行需要按规定的顺序执行,比如在一个循环中,一部分的工作可以并行执行,而特定的部分需要按照串行的工作流程依次执行。
1 |
|
3.17 loop-dependencies.cpp
1 |
|
3.18 sections.cpp
- 有些需要并行的任务并不是一个for循环之类的,而是一个个代码块,这种情况下就可以使用sections的情形;
- sections下包含多个section,section相互之间只并行执行的,但是section内部是串行执行的;
- 多个sections之间也是串行执行的
- 如果
#pragma omp parallel sections
写成#pragma omp sections
,则各个section之间是串行执行的 - 如果变成两个线程的话,会有一个线程执行两个section
1 |
|
3.19 threadprivate.cpp
threadprivate指令用来指定全局的对象被各个线程各自复制了一个私有的拷贝,即各个线程具有各自私有的全局对象。threadprivate和private的区别在于threadprivate声明的变量通常是全局范围内有效的,而private声明的变量只在它所属的并行构造中有效。用作threadprivate的变量的地址不能是常数。对于C++的类(class)类型变量,用作threadprivate的参数时有些限制,当定义时带有外部初始化时,必须具有明确的拷贝构造函数。程序示例如下:
1 | int g; |
注意:在使用threadprivate的时候,要用omp_set_dynamic(0)关闭动态线程的属性,才能保证结果正确。
3.20 Copyin.cpp
copyin子句用于将主线程中threadprivate变量的值拷贝到执行并行区域的各个线程的threadprivate变量中,从而使得team内的子线程都拥有和主线程同样的初始值。程序示例如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int A = 100;
int main(int argc, _TCHAR* argv[])
{
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}
printf("Global A: %d\n",A); // 并行区域外的打印的“Globa A”的值总是和前面的thread 0的结果相等,因为退出并行区域后,只有master线程即0号线程运行。
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}
printf("Global A: %d\n",A); // #2
return 0;
}
3.21 Copyprivate.cpp
copyprivate子句用于将线程私有副本变量的值从一个线程广播到执行同一并行区域的其他线程的同一变量。copyprivate只能用于single指令(single指令:用在一段只被单个线程执行的代码段之前,表示后面的代码段将被单线程执行)的子句中,在一个single块的结尾处完成广播操作。copyprivate只能用于private/firstprivate或threadprivate修饰的变量。程序示例如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21int counter = 0;
int increment_counter()
{
counter++;
return(counter);
}
{
int count;
{
counter = 50;
}
count = increment_counter();
printf("ThreadId: %ld, count = %ld/n", omp_get_thread_num(), count);
}
// count 都是51
3.22 nowait.cpp
栅障(Barrier)是OpenMP用于线程同步的一种方法。线程遇到栅障是必须等待,直到并行区中的所有线程都到达同一点。注意:在任务分配for循环和任务分配section结构中,我们已经隐含了栅障,在parallel,for,sections,single结构的最后,也会有一个隐式的栅障。
隐式的栅障会使线程等到所有的线程继续完成当前的循环、结构化块或并行区,再继续执行后面的工作。可以使用nowait去掉这个隐式的栅障.去掉隐式栅障,例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
for(k=0;k<m;k++){
fun1(k);
}
{
{y=sectionA(x);}
{z=sectionB(x);}
}
}
因为第一个 任务分配for循环和第二个任务分配section代码块之间不存在数据相关。加上显示栅障,例如:1
2
3
4
5
6
7
8
9
10
11
12
13
{
int tid=omp_get_thread_num();
if(tid==0)
y=fun1();//第一个线程得到y
else
z=fun2();//第二个线程得到z
for(k=0;k<100;k++)
x[k]=y+z;
}
单线程和多线程交错执行: 当开发人员为了减少开销而把并行区设置的很大时,有些代码很可能只执行一次,并且由一个线程执行,这样单线程和多线程需要交错执行
举例如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
int tid=omp_get_thread_num();//每个线程都调用这个函数,得到线程号
//这个循环被划分到多个线程上进行
for(k=0;k<100;k++)
x[k]=fun1(tid);//这个循环的结束处不存在使所有线程进行同步的隐式栅障
y=fn_input_only(); //只有主线程会调用这个函数
//这个循环也被划分到多个线程上进行
for(k=0;k<100;k++)
x[k]=y+fn2(x[k]); //这个线程没有栅障,所以不会相互等待
//一旦某个线程执行完上面的代码,不需要等待就可以马上执行下面的代码
fn_single_print(y);
//所有的线程在执行下面的函数前会进行同步
fn_print_array(x);//只有主线程会调用这个函数
}
3.23 task
从功能上说:
- The TASK construct defines an explicit task, which may be executed by the encountering thread, or deferred for execution by any other thread in the team.
- The data environment of the task is determined by the data sharing attribute clauses.
- Task execution is subject to task scheduling - see the OpenMP 3.0 specification document for details.
任务构造定义一个显式的任务,可能会被遇到的线程马上执行,也可能被延迟给线程组内其他线程来执行。任务的执行,依赖于OpenMP的任务调度。
语法:1
2
3
4
5
6
7
8
9
10#pragma omp task [clause ...] newline
if (scalar expression)
final (scalar expression)
untied
default (shared | none)
mergeable
private (list)
firstprivate (list)
shared (list)
structured_block
task,简单的理解,就是定义一个任务,线程组内的某一个线程来执行此任务。和工作共享结构很类似,我们都知道,for也是某一个线程执行某一个迭代,如果把每一个迭代看成一个task,那么就是task的工作方式了,在for只能用于循环迭代的基础上,OpenMP提供了sections构造,用于构造一个sections,然后里面定义一堆的section,每一个section被一个线程去执行,这样,每一个section也类似于for的每一次迭代,只是使用sections会更灵活,更简单,但是其实,for和sections在某种程度上是可以转换的,用下面的例子来看一个使用sections和for指令分别执行“三个”任务的例子: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
38
39
40
41
42
43
44
45
46
47
48
49
void task1(int p)
{
printf("task1, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}
void task2(int p)
{
printf("task2, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}
void task3(int p)
{
printf("task3, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}
int main(int argc, _TCHAR* argv[])
{
{
{
task1(10);
task2(20);
task3(1000);
}
}
{
for(int i = 0;i < TASK_COUNT; i ++)
{
if(i == 0)
task1(10);
else if (i == 1)
task2(20);
else if (i == 2)
task3(1000);
}
}
return 0;
}
当然,这个程序不是这里要讨论的重点,只是为了说明for和sections的一些类似之处,或者其实可以理解为sections其实是for的展开形式,适合于少量的“任务”,并且适合于没有迭代关系的“任务”。很显然,上面的例子适合用sections去解决,因为本身是三个任务,不存在迭代的关系,三个任务和循环迭代变量没有什么关联。
接下来,分析下面的例子: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
38
39
40
41
42
void task(int p)
{
printf("task, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}
void init(int*a)
{
for(int i = 0;i < N;i++)
a[i] = i + 1;
}
int main(int argc, _TCHAR* argv[])
{
int a[N];
init(a);
{
{
task(a[0]);
task(a[1]);
task(a[2]);
}
}
{
for(int i = 0;i < N; i++)
{
task(a[i]);
}
}
return 0;
}
这个例子,很容易理解了,把一个数组内的每一个元素“并行”的传递给task()
函数,执行一个“任务”。同样,for和sections都能解决,但是如果N太大了,比如N是100,那sections就为难了,这里要说明的是:sections不能使用嵌套的形式,比如:1
2
3
4
5for(int i = 0;i < N;i++)
{
task(a[0]);
}
这样是不行的,section只能显式的,直接的在sections里面书写,可以理解为”静态“的。继续研究这个例子,假设现在的需求是对如下的代码进行并行化:1
2
3
4for(int i = 0;i < N; i=i+a[i])
{
task(a[i]);
}
对于这样的需求,OpenMP的for指令也是无法完成的,因为for指令在进行并行执行之前,就需要”静态“的知道任务该如何划分,而上面的i=i+a[i],在运行之前,是无法知道有那些迭代,需要如何进行划分,因为其迭代的循环依赖于数组a里面保存的值。那么对于这样的循环,该如何并行?最关键的是,从语义上,这个循环是明显可以进行并行的。这就是之所以OpenMP3.0提供task的原因了。
在此,先总结一下for和sections指令的”缺陷“:无法根据运行时的环境动态的进行任务划分,必须是预先能知道的任务划分的情况。
使用task解决上面遗留的问题的方法如下: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
void task(int p)
{
printf("task, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}
void init(int*a)
{
for(int i = 0;i < N;i++)
a[i] = i + 1;
}
int main(int argc, _TCHAR* argv[])
{
int a[N];
init(a);
{
{
for(int i = 0;i < N; i=i+a[i])
{
task(a[i]);
}
}
}
return 0;
}
这里之所以用single表示只有一个线程会执行下面的代码,否则会执行两次(这里用的线程数量为2),这是single的子句的理解,就不在此分析了。看其中task的代码,其实很简单,OpenMP遇到了task之后,就会使用当前的线程或者延迟一会后接下来的线程来执行task定义的任务。task的作用,就是定义了一个显式的任务。
那么,task和前面的for和sections的区别在于:task是“动态”定义任务的,在运行过程中,只需要使用task就会定义一个任务,任务就会在一个线程上去执行,那么其它的任务就可以并行的执行。可能某一个任务执行了一半的时候,或者甚至要执行完的时候,程序可以去创建第二个任务,任务在一个线程上去执行,一个动态的过程,不像sections和for那样,在运行之前,已经可以判断出可以如何去分配任务。而且,task是可以进行嵌套定义的,可以用于递归的情况等等。
总结task的使用:task主要适用于不规则的循环迭代(如上面的循环)和递归的函数调用。都是无法使用for来完成的情况。
显示任务和隐式任务(implicit&explicit)
task的作用就是创建一个显式的任务,那么什么是隐式的任务呢?OpenMP的任务分为显式和隐式两种,根据我的个人理解,分析下面的代码:1
2
3
4
5
6
7
8
9
10
11
12
{
{
for(int i = 0;i < N; i=i+a[i])
{
task(a[i]);
}
task(1000);
}
}
其中task(1000);就属于一个隐式的任务。因为执行完for后,会执行这一个任务,而上面的任务可能也会同时执行。
task的嵌套
任务构造结构可以嵌套在另一个task结构中,但是内部的task结构并不属于外部的task区域的一部分。1
2
3
4
5
6
{
task(a[i]);
task(a[i]);
}
简单的理解,OpenMP遇到task指令就会定义一个显式的任务,就会在当前的线程或者延迟等待其它线程去执行,而不是将嵌套task的部分当作外部task的一部分。
task指令的子句
如果给一个task使用了if子句,如果if子句的表达式是false,会生成一个不延迟的任务,这样,遇到这个task的当前线程必须挂起当前的task区域,直到当前的任务完成之后才会恢复。个人理解,当前线程挂起,那么这个task是不是由其它的线程去执行呢,还是就是当前的这个线程执行这个任务?
如果给task使用了final子句,如果final表达式的值为true,生成的任务是一个终结任务。所有任务遇到终结任务执行的时候会生成终结和包含的任务。PS:不太理解!
3.25 flush.cpp
当并行区域里存在一共享变量,并且对其进行修改时,需要用flush更新变量,确保并行的多线程对共享变量的读操作是最新值.1
2
3
4
5
6
7done=0;
if(!done)
{
...
done=1;
}
OpenMP中数据属性相关子句详解
private/firstprivate/lastprivate/threadprivate之间的比较
private/firstprivate/lastprivate/threadprivate,首先要知道的是,它们分为两大类,一类是private/firstprivate/lastprivate子句,另一类是threadprivate,为指令。
private
private子句将一个或多个变量声明为线程的私有变量。每个线程都有它自己的变量私有副本,其他线程无法访问。即使在并行区域外有同名的共享变量,共享变量在并行区域内不起任何作用,并且并行区域内不会操作到外面的共享变量。
注意:
- private variables are undefined on entry and exit of the parallel region.即private变量在进入和退出并行区域是“未定义“的。
- The value of the original variable (before the parallel region) is undefined after the parallel region!在并行区域之前定义的原来的变量,在并行区域后也是”未定义“的。
- A private variable within the parallel region has no storage association with the same variable outside of the region. 并行区域内的private变量和并行区域外同名的变量没有存储关联。
说明:private的很容易理解错误。下面用例子来说明上面的注意事项,
A. private变量在进入和退出并行区域是”未定义“的。1
2
3
4
5
6
7
8
9
10
11
12int main(int argc, _TCHAR* argv[])
{
int A=100;
for(int i = 0; i<10;i++)
{
printf("%d\n",A);
}
return 0;
}
初学OpenMP很容易认为这段代码是没有问题的。其实,这里的A在进入并行区域的时候是未定义的,所以在并行区域直接对其进行读操作,会导致运行时错误。
其实,在VS中编译这段代码,就会有编译警告:1
warning C4700: uninitialized local variable 'A' used
很清楚的指向”printf”这句,A是没有初始化的变量。所以,运行时候会出现运行时崩溃的错误。
这段代码能说明,private在进入并行区域是未定义的,至于退出并行区域就不容易举例说明了,本身,这里的三个注意事项是交叉理解的,说明的是一个含义,所以,看下面的例子来理解。
B. 在并行区域之前定义的原来的变量,在并行区域后也是”未定义“的。1
2
3
4
5
6
7
8
9
10
11
12
13
14int main(int argc, _TCHAR* argv[])
{
int B;
for(int i = 0; i<10;i++)
{
B = 100;
}
printf("%d\n",B);
return 0;
}
这里的B在并行区域内进行了赋值等操作,但是在退出并行区域后,是未定义的。理解”在并行区域之 前定义的 原来的变量,在并行区域 后也是” 未定义“的“这句话的时候,要注意,不是说所有的在并行区域内定义的原来的变量,使用了private子句后,退出并行区域后就一定是未定义的,如果原来的变量,本身已经初始化,那么,退出后,不会处于未定义的状态,就是下面的第三个注意事项要说明的问题。
C. 并行区域内的private变量和并行区域外同名的变量没有存储关联1
2
3
4
5
6
7
8
9
10
11
12
13int main(int argc, _TCHAR* argv[])
{
int C = 100;
for(int i = 0; i<10;i++)
{
C = 200;
printf("%d\n",C);
}
printf("%d\n",C);
return 0;
}
这里,在退出并行区域后,printf的C的结果是100,和并行区域内对其的操作无关。
总结来说,上面的三点是交叉的,第三点包含了所有的情况。所以,private的关键理解是:A private variable within the parallel region has no storage association with the same variable outside of the region. 简单点理解,可以认为,并行区域内的private变量和并行区域外的变量没有任何关联。如果非要说点关联就是,在使用private的时候,在之前要先定义一下这个变量,但是,到了并行区域后,并行区域的每个线程会产生此变量的副本,而且是没有初始化的。
下面是综合上面的例子,参考注释的解释:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18int main(int argc, _TCHAR* argv[])
{
int A=100,B,C=0;
for(int i = 0; i<10;i++)
{
B = A + i; // A is undefined! Runtime error!
printf("%d\n",i);
}
/*--End of OpemMP paralle region. --*/
C = B; // B is undefined outside of the parallel region!
printf("A:%d\n", A);
printf("B:%d\n", B);
return 0;
}
firstprivate
private子句的私有变量不能继承同名变量的值,firstprivate则用于实现这一功能-继承并行区域额之外的变量的值,用于在进入并行区域之前进行一次初始化。
Firstprivate(list): All variables in the list areinitialized with the value the original object had before entering the parallelconstruct.
分析下面的例子:1
2
3
4
5
6
7
8
9
10
11
12int main(int argc, _TCHAR* argv[])
{
int A;
for(int i = 0; i<10;i++)
{
printf("%d: %d\n",i, A); // #1
}
printf("%d\n",A); // #2
return 0;
}
用VS编译发现,也会报一个“warning C4700: uninitialized local variable ‘A’ used”的警告,但是这里其实两个地方用到了A。实际上,这个警告是针对第二处的,可以看出,VS并没有给第一处OpenMP并行区域内的A有警告,这是由于使用firstprivate的时候,会对并行区域内的A使用其外的同名共享变量就行初始化,当然,如果严格分析,外面的变量其实也是没有初始化的,理论上也是可以认为应该报警告,但是,具体而言,这是跟VS的实现有关的,另外,在debug下,上面的程序会崩溃,release下,其实是可以输出值的,总之,上面的输出是无法预料的。
再看下面的例子,和前面private的例子很类似:1
2
3
4
5
6
7
8
9
10
11
12
13
14int main(int argc, _TCHAR* argv[])
{
int A = 100;
for(int i = 0; i<10;i++)
{
printf("%d: %d\n",i, A); // #1
}
printf("%d\n",A); // #2
return 0;
}
这里,如果使用private,那么并行区域内是有问题的,因为并行区域内的A是没有初始化的,导致无法预料的输出或崩溃。但是,使用了firstprivate后,这样,进入并行区域的时候,每一个线程的A的副本都会利用并行区域外的同名共享变量A的值进行一次初始化,所以,输出的A都是100.
继续探讨这里的“进行一次初始化”,为了理解“一次”的含义,看下面的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int argc, _TCHAR* argv[])
{
int A = 100;
for(int i = 0; i<10;i++)
{
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
A = i;
}
printf("%d\n",A); // #2
return 0;
}
这里,每次输出后,改变A的值,需要注意的是,这里的“进行一次初始化”是针对team内的每一个线程进行一次初始化,对于上面的程序,在4核的CPU上运行,并行区域内有四个线程,所以每一个线程都会有A的一个副本,因而,上面的程序输出结果可能如下:1
2
3
4
5
6
7
8
9
10
11Thread ID: 0, 0: 100
Thread ID: 0, 1: 0
Thread ID: 0, 2: 1
Thread ID: 2, 6: 100
Thread ID: 2, 7: 6
Thread ID: 1, 3: 100
Thread ID: 2, 4: 3
Thread ID: 1, 5: 4
Thread ID: 3, 8: 100
Thread ID: 3, 9: 8
100
其实,这个结果是很容易理解的,不可能是每一个for都有一个变量的副本,而是每一个线程,所以这个结果在预料之中。
仍然借助上面这个例子,帮助理解private和firstprivate,从而引出lastprivate,private对于并行区域的每一个线程都有一个副本,并且和并行区域外的变量没有关联;firstprivate解决了进入并行区的问题,即在进入并行区域的每个线程的副本变量使用并行区域外的共享变量进行一个初始化的工作,那么下面有一个问题就是,如果希望并行区域的副本变量,在退出并行区的时候,能反过来赋值给并行区域外的共享变量,那么就需要依靠lastprivate了。
lastprivate
如果需要在并行区域内的私有变量经过计算后,在退出并行区域时,需要将其值赋给同名的共享变量,就可以使用lastprivate完成。
Lastprivate(list):The thread that executes the sequentially last iteration or section updates thevalue of the objects in the list.
从上面的firstprivate的最后一个例子可以看出,并行区域对A进行了赋值,但是退出并行区域后,其值仍然为原来的值。
这里首先有一个问题是:退出并行区域后,需要将并行区域内的副本的值赋值为同名的共享变量,那么,并行区域内有多个线程,是哪一个线程的副本用于赋值呢?
是否是最后一个运行完毕的线程?否!OpenMP规范中指出,如果是循环迭代,那么是将最后一次循环迭代中的值赋给对应的共享变量;如果是section构造,那么是最后一个section语句中的值赋给对应的共享变量。注意这里说的最后一个section是指程序语法上的最后一个,而不是实际运行时的最后一个运行完的。
在理解这句话之前,先利用一个简单的例子来理解一下lastprivate的作用:1
2
3
4
5
6
7
8
9
10
11
12int main(int argc, _TCHAR* argv[])
{
int A = 100;
for(int i = 0; i<10;i++)
{
A = 10;
}
printf("%d\n",A);
return 0;
}
这里,很容易知道结果为10,而不是100.这就是lastprivate带来的效果,退出后会有一个赋值的过程。
理解了lastprivate的基本含义,就可以继续来理解上面的红色文字部分的描述了,即到底是哪一个线程的副本用于对并行区域外的变量赋值的问题,下面的例子和前面firstprivate的例子很类似:1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, _TCHAR* argv[])
{
int A = 100;
for(int i = 0; i<10;i++)
{
printf("Thread ID: %d, %d\n",omp_get_thread_num(), i); // #1
A = i;
}
printf("%d\n",A); // #2
return 0;
}
1 | Thread ID: 0, 0 |
从结果可以看出,最后并行区域外的共享变量的值并不是最后一个线程退出的值,多次运行发现,并行区域的输出结果可能发生变化,但是最终的输出都是9,这就是上面的OpenMP规范说明的问题,退出并行区域的时候,是根据“逻辑上”的最后一个线程用于对共享变量赋值,而不是实际运行的最后一个线程,对于for而言,就是最后一个循环迭代所在线程的副本值,用于对共享变量赋值。
另外,firstprivate和lastprivate分别是利用共享变量对线程副本初始化(进入)以及利用线程副本对共享变量赋值(退出),private是线程副本和共享变量无任何关联,那么如果希望进入的时候初始化并且退出的时候赋值呢?事实上,可以对同一个变量使用firstprivate和lastprivate的,下面的例子即可看出:1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, _TCHAR* argv[])
{
int A = 100;
for(int i = 0; i<10;i++)
{
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
A = i;
}
printf("%d\n",A); // #2
return 0;
}
说明:不能对一个变量同时使用两次private,或者同时使用private和firstprivate/lastprivate,只能firstprivate和lastprivate一起使用。
关于lastprivate,还需要说明的一点是,如果是类(class)类型的变量使用在lastprivate参数中,那么使用时有些限制,需要一个可访问的,明确的缺省构造函数,除非变量也被使用作为firstprivate子句的参数;还需要一个拷贝赋值操作符,并且这个拷贝赋值操作符对于不同对象的操作顺序是未指定的,依赖于编译器的定义。
另外,firstprivate和private可以用于所有的并行构造块,但是lastprivate只能用于for和section组成的并行块之中。
threadprivate
首先,threadprivate和上面几个子句的区别在于,threadprivate是指令,不是子句。threadprivate指定全局变量被OpenMP所有的线程各自产生一个私有的拷贝,即各个线程都有自己私有的全局变量。一个很明显的区别在于,threadprivate并不是针对某一个并行区域,而是整个于整个程序,所以,其拷贝的副本变量也是全局的,即在不同的并行区域之间的同一个线程也是共享的。
threadprivate只能用于全局变量或静态变量,这是很容易理解的,根据其功能。
根据下面的例子,来进一步理解threadprivate的使用:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int A = 100;
int main(int argc, _TCHAR* argv[])
{
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}
printf("Global A: %d\n",A); // #2
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}
printf("Global A: %d\n",A); // #2
return 0;
}
1 | Thread ID: 0, 0: 101 |
分析结果,发现,第二个并行区域是在第一个并行区域的基础上继续递增的;每一个线程都有自己的全局私有变量。另外,观察在并行区域外的打印的“Globa A”的值可以看出,这个值总是前面的thread 0的结果,这也是预料之中的,因为退出并行区域后,只有master线程运行。
threadprivate指令也有自己的一些子句,就不在此分析了。另外,如果使用的是C++的类,对于类的构造函数也会有类似于lastprivate的一些限制。
总结
private/firstprivate/lastprivate都是子句,用于表示并行区域内的变量的数据范围属性。其中,private表示并行区域team内的每一个线程都会产生一个并行区域外同名变量的共享变量,且和共享变量没有任何关联;firstprivaet在private的基础上,在进入并行区域时(或说每个线程创建时,或副本变量构造时),会使用并行区域外的共享变量进行一次初始化工作;lastprivate在private的基础上,在退出并行区域时,会使用并行区域内的副本的变量,对共享变量进行赋值,由于有多个副本,OpenMP规定了如何确定使用哪个副本进行赋值。另外,private不能和firstprivate/lastprivate混用于同一个变量,firstprivate和lastprivate可以对同一变量使用,效果为两者的结合。
threadprivate是指令,和private的区别在于,private是针对并行区域内的变量的,而threadprivate是针对全局的变量的。
shared/default/copyin/copyprivate子句的使用
shared
shared子句可以用于声明一个或多个变量为共享变量。所谓的共享变量,是在一个并行区域的team内的所有线程只拥有变量的一个内存地址,所有线程访问同一地址。所以,对于并行区域内的共享变量,需要考虑数据竞争条件,要防止竞争,需要增加对应的保护,这是程序员需要自行考虑的。
下面的例子是一个求和的并行实现,使用共享变量,由于没有采取保护,会有数据竞争:1
2
3
4
5
6
7
8
9
10
11
12
13
int main(int argc, _TCHAR* argv[])
{
int sum = 0;
for(int i = 0; i < COUNT;i++)
{
sum = sum + i;
}
printf("%d\n",sum);
return 0;
}
多次运行,结果可能不一样。
另外,需要注意,循环迭代变量在循环构造区域里是私有的,声明在循环构造区域内的自动变量都是私有的。这一点其实也是比较容易理解的,很难想象,如果循环迭代变量也是共有的,OpenMP该如何去执行,所以也只能是私有的了。即使使用shared来修饰循环迭代变量,也不会改变循环迭代变量在循环构造区域中是私有的这一特点:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, _TCHAR* argv[])
{
int sum = 0;
int i = 0;
for(i = 0; i < COUNT;i++)
{
sum = sum + i;
}
printf("%d\n",i);
printf("%d\n",sum);
return 0;
}
这个例子能侧面能说明问题,这里的最后输出i是0,并不是0到COUNT之内的一个可能的值,尽管这里使用shared修饰变量i。注意,这里的规则只是针对循环并行区域,对于其他的并行区域没有这样的要求:
1 |
|
这里输出的i为0,如果改为shared,那么就是10了。当然,这段代码和上面的求和的代码含义上就是不一样的。
另外,这里顺便一个问题是,在循环并行区域内,循环迭代变量是不可修改的。这也是上面的例子,为何不采用下面的写法:1
2
3
4
5
6
7 int i = 0;
for(i = 0; i < COUNT;i++)
{
i++;
sum = sum + i;
}
这里,i++会报错,原因是在循环并行区域内,迭代变量i是可读不可写的。
default
default指定并行区域内变量的属性,C++的OpenMP中default的参数只能为shared或none,对于Fortran,可以为private等参数,具体参考手册。
default(shared)
:表示并行区域内的共享变量在不指定的情况下都是shared属性
default(none)
:表示必须显式指定所有共享变量的数据属性,否则会报错,除非变量有明确的属性定义(比如循环并行区域的循环迭代变量只能是私有的)
另外,如果一个并行区域,没有使用default子句,会是什么情况?实际测试,个人认为,没有使用default,那么其默认行为为default(shared)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int argc, _TCHAR* argv[])
{
int sum = 0;
int i = 0;
for(i = 0; i < COUNT;i++)
{
sum = sum + i;
}
printf("%d\n",i);
printf("%d\n",sum);
return 0;
}
这里,sum为shared属性,而i的属性不会改变,仍然只能为私有。这里的效果和加上default(shared)是一样的。如果使用default(none),那么编译会报错“没有给sum指定数据共享属性”,不会为变量i报错,因为i是有明确的含义的,只能为私有。
copyin
copyin子句用于将主线程中threadprivate变量的值拷贝到执行并行区域的各个线程的threadprivate变量中,从而使得team内的子线程都拥有和主线程同样的初始值。
1 |
|
输出如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23Thread ID: 0, 0: 101
Thread ID: 0, 1: 102
Thread ID: 0, 2: 103
Thread ID: 3, 8: 101
Thread ID: 3, 9: 102
Thread ID: 1, 3: 101
Thread ID: 1, 4: 102
Thread ID: 1, 5: 103
Thread ID: 2, 6: 101
Thread ID: 2, 7: 102
Global A: 103
Thread ID: 2, 6: 104
Thread ID: 2, 7: 105
Thread ID: 1, 3: 104
Thread ID: 1, 4: 105
Thread ID: 1, 5: 106
Thread ID: 3, 8: 104
Thread ID: 3, 9: 105
Thread ID: 0, 0: 104
Thread ID: 0, 1: 105
Thread ID: 0, 2: 106
运行此程序,得到的结果和不使用copyin的结果是不一样的。不使用copyin的情况下,进入第二个并行区域的时候,不同线程的私有副本A的初始值是不一样的,这里使用了copyin之后,发现所有的线程的初始值都使用主线程的值初始化,然后继续运算。
为了更好的理解copyin,分析下面的例子: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
int A = 100;
int main(int argc, _TCHAR* argv[])
{
{
printf("Initial A = %d\n", A);
A = omp_get_thread_num();
}
printf("Global A: %d\n",A);
{
printf("Initial A = %d\n", A);
A = omp_get_thread_num();
}
printf("Global A: %d\n",A);
{
printf("Initial A = %d\n", A);
A = omp_get_thread_num();
}
printf("Global A: %d\n",A);
return 0;
得到输出如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15Initial A = 100
Initial A = 100
Initial A = 100
Initial A = 100
Global A: 0
Initial A = 0
Initial A = 0
Initial A = 0
Initial A = 0
Global A: 0
Initial A = 0
Initial A = 3
Initial A = 2
Initial A = 1
Global A: 1
简单理解,在使用了copyin后,所有的线程的threadprivate类型的副本变量都会与主线程的副本变量进行一次“同步”。
另外,copyin中的参数必须被声明成threadprivate的,对于类类型的变量,必须带有明确的拷贝赋值操作符。而且,对于第一个并行区域,是默认含有copyin的功能的(比如上面的例子的前面的四个A的输出都是100)。copyin的一个可能需要用到的情况是,比如程序中有多个并行区域,每个线程希望保存一个私有的全局变量,但是其中某一个并行区域执行前,希望与主线程的值相同,就可以利用copyin进行赋值。
copyprivate
copyprivate子句用于将线程私有副本变量的值从一个线程广播到执行同一并行区域的其他线程的同一变量。
说明:copyprivate只能用于single指令的子句中,在一个single块的结尾处完成广播操作。copyprivate只能用于private/firstprivate或threadprivate修饰的变量。
根据下面的程序,可以理解copyprivate的使用:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24#include <omp.h>
int A = 100;
#pragma omp threadprivate(A)
int main(int argc, _TCHAR* argv[])
{
int B = 100;
int C = 1000;
#pragma omp parallel firstprivate(B) copyin(A) // copyin(A) can be ignored!
{
#pragma omp single copyprivate(A) copyprivate(B)// copyprivate(C) // C is shared, cannot use copyprivate!
{
A = 10;
B = 20;
}
printf("Initial A = %d\n", A); // 10 for all threads
printf("Initial B = %d\n", B); // 20 for all threads
}
printf("Global A: %d\n",A); // 10
printf("Global A: %d\n",B); // 100. B is still 100! Will not be affected here!
return 0;
}
reduction子句
reduction的作用: A private copy for each list variable is created for each thread. At the end of the reduction, the reduction variable is applied to all private copies of the shared variable, and the final result is written to the global shared variable.
reduction子句为变量指定一个操作符,每个线程都会创建reduction变量的私有拷贝,在OpenMP区域结束处,将使用各个线程的私有拷贝的值通过制定的操作符进行迭代运算,并赋值给原来的变量。
reduction的语法为recutioin(operator:list)和其他的数据属性子句不一样的是多了一个operator参数。由于最后会进行迭代运算,所以不是所有的运算符都能作为reduction的参数,而且,迭代运算需要一个初始值,不是所有的操作符需要有相同的初始值,一般而言,常见的reduction操作符的初始值为:+(0),*(1),-(0),&~(0),|(0),^(0),&&(1),||(0),当然,这不是必须的,比如叠加运算的初始值,可以是任意值,只是表达的含义不一样而已,但是对于某些操作符,有些初始值是没有什么意义的,比如乘法迭代如果初始值为0没有什么意义,结果肯定是0了!
典型的使用reduction的例子,就是迭加(求和)操作了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int argc, _TCHAR* argv[])
{
int sum = 100; // Assign an initial value.
for(int i = 0;i < COUNT; i++)
{
sum += i;
}
printf("Sum: %d\n",sum);
return 0;
}
这个例子就是对0到COUNT进行求和,由于初始值为100,所以还会加一个100,如果只是为了求和,只需要初始值为0即可。使用reduction可以避免数据竞争的发生,将上面例子的COUNT修改为一个比较大的值,如果不使用reduction,会发现有数据竞争导致结果不一致,使用reduction后,每次都能得到正确的结果。
reduction的使用是比较简单的,主要还是需要理解上面说到的“初始值”,第一个理解是这里的100这样的初始值,这是并行区域外的初始值,会在最后计算到迭代结果中,那么还有一个隐含的初始值,就是我们知道,使用了reduction,那么每个线程都会构造一个reduction变量的线程副本,那么其值为多少呢?从上面的例子可以看出,其初始值就是0,如果初始值都是100,那么结果应该是100会被加线程数目的次数。初始值的确定方法就是上面提到的:+(0),*(1),-(0),&~(0),|(0),^(0),&&(1),||(0)。
所以,理解reduction的工作过程:
- 进入并行区域后,team内的每个新的线程都会对reduction变量构造一个副本,比如上面的例子,假设有四个线程,那么,进入并行区域的初始化值分别为:sum0=100,sum1 = sum2 = sum3 = 0。为何sum0为100呢?因为主线程不是一个新的线程,所以不需要再为主线程构造一个副本(没有找到官方这样的说法,但是从理解上,应该就是这样工作的,只会有一个线程使用到并行区域外的初始值,其余的都是0)。
- 每个线程使用自己的副本变量完成计算。
- 在退出并行区域时,对所有的线程的副本变量使用指定的操作符进行迭代操作,对于上面的例子,即sum’ = sum0’+sum1’+sum2’+sum3’.
- 将迭代的结果赋值给原来的变量(sum),sum=sum’.
注意:
- reduction只能用于标量类型(int、float等等);
- reduction只用于一个区域构造或者工作共享构造的结构中,并且,在这个区域中,reduction的变量只能被用于类似如下的语句:
1 | x = x op expr |
说明:经过测试,其实不符合这一规则的时候,编译运行都不会有问题,有些甚至也是可以解释清楚为什么结果是这样的,但是无论如何,一般使用reduction的时候,都是一些迭代的情况,语义应该是很清楚的情况。看下面的一个“错误”的例子:
1 |
|
输出结果为104(4核机器)。这个例子,sum=1;
这个表达式是不应该出现的,但是如果就这么写,编译运行都没问题,而且,这个结果甚至也算是预料中的。每一个线程计算结束后,其sum的值都是1,四个线程,然后初始值是100,所以最后结果是104。:) 无论如何,即使可以解释得通,相信也没有这样使用的场合,至少,不要依赖于这样的实现的结果。从这个错误的例子,反过来,我发现上面的关于”理解reduction的工作过程“似乎不太完全正确,其中第一步,进入并行区域后,初始值为”sum0=100,sum1 = sum2 = sum3 = 0“,如果这样,只是一个初始值,那么计算后,在这个例子里,所有线程的sum都是1,结果应该为4才对。所以看来,实际的理解应该是,主线程也会创建一个副本变量,其初始值也为0,在最后迭代的时候,是用sum原来的值和每个线程的副本进行计算。过程大概如下:
- sum=100
- 进入并行区域,创建4个线程的4个副本:sum0=sum1=sum2=sum3=0;
- 计算完成后,得到sum0’,sum1’,sum2’,sum3’
- 计算sum,sum=sum op sum 0‘ op sum1’ op sum2‘ op sum3’。
总之,具体编译器是如何实现的并不重要,关键是理解reduction是如何工作的。