OpenMP基础

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
23
void 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();
#pragma omp parallel for
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 forparallelfor语句的结合,也是用在一个for循环之前,表示for循环的代码将被多个线程并行执行。
  • sections,用在可能会被并行执行的代码段之前
  • parallel sectionsparallelsections两个语句的结合
  • 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
#include <omp.h>
int omp_get_num_threads(void)

注意,对于C/C++,通常需要包含<omp.h>头文件。

对于锁程序/函数:

  • 锁变量只能通过锁程序访问
  • 对于Fortran,锁变量的类型应该是integer,并且要足够大,以便容纳一个地址。
  • 对于C/C++,lock 变量的类型必须是omp_lock_tomp_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
2
csh/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
2
setenv 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
7
setenv 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
4
setenv 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
#pragma omp parallel default(shared) private(beta, pi)

后面将详细讨论编译器指令。

运行时库函数 Run-time Library Routines:

OpenMP API 包括越来越多的运行时库函数。这些程序用于各种目的:

  • 设置和查询线程的数量
  • 查询线程的唯一标识符(线程ID)、父线程的标识符、线程团队大小
  • 设置和查询动态线程特性
  • 查询是否在一个并行区域,以及在什么级别
  • 设置和查询嵌套并行性
  • 设置、初始化和终止锁以及嵌套锁
  • 查询 wall clock time 和分辨率

对于 C/C++,所有运行时库函数都是实际的子程序。对于Fortran来说,有些是函数,有些是子程序。例如:

1
2
#include <omp.h>
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
2
3
4
5
6
7
8
9
10
11
#pragma omp parallel [clause ...]  newline 
if (scalar_expression)
private (list)
shared (list)
default (shared | none)
firstprivate (list)
reduction (operator: list)
copyin (list)
num_threads (integer-expression)

structured_block

注意

  • 当一个线程执行到一个并行指令时,它创建一个线程组并成为该线程组的主线程。主线程是该团队的成员,在该团队中线程号为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
#include <stdio.h>
#include <omp.h>
int main(int argc, char *argv[]) {
int nthreads, tid;
/* Fork a team of threads with each thread having a private tid variable */
#pragma omp parallel private(tid)
{
/* 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
2
3
4
5
6
7
8
9
10
11
12
#pragma omp for [clause ...]  newline 
schedule (type [,chunk])
ordered
private (list)
firstprivate (list)
lastprivate (list)
shared (list)
reduction (operator: list)
collapse (n)
nowait

for_loop
  • 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
#include <omp.h>
#define N 1000
#define CHUNKSIZE 100

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;

#pragma omp parallel shared(a,b,c,chunk) private(i)
{

#pragma omp for schedule(dynamic,chunk) nowait
for (i = 0; i < N; i++)
c[i] = a[i] + b[i];

} /* end of parallel region */

return 0;
}

sections 指令

目的

sections 指令是一个非迭代的工作共享结构。它指定所包含的代码段将被分配给团队中的各个线程。

独立的 section 指令嵌套在 sections 指令中。每个部分由团队中的一个线程执行一次。不同的部分可以由不同的线程执行。如果一个线程执行多个部分的速度足够快,并且实现允许这样做,那么它就可以执行多个部分。

1
2
3
4
5
6
7
8
9
10
11
12
#pragma omp sections [clause ...]  newline 
private (list)
firstprivate (list)
lastprivate (list)
reduction (operator: list)
nowait
{
#pragma omp section newline
structured_block
#pragma omp section newline
structured_block
}
子句

除非使用了 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
#include <omp.h>
#define N 1000

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;
}

#pragma omp parallel shared(a,b,c,d) private(i)
{
#pragma omp sections nowait
{
#pragma omp section
for (i = 0; i < N; i++)
c[i] = a[i] + b[i];

#pragma omp section
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
2
3
4
5
6
#pragma omp single [clause ...]  newline 
private (list)
firstprivate (list)
nowait

structured_block
子句

除非指定了 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
#include <omp.h>
#define N 1000
#define CHUNKSIZE 100

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;

#pragma omp parallel for shared(a,b,c,chunk) private(i) schedule(static,chunk)
for (i = 0; i < N; i++)
c[i] = a[i] + b[i];
return 0;
}

任务结构

目的

任务结构定义了一个显式任务,该任务可以由遇到的线程执行,也可以由团队中的任何其他线程延迟执行。任务的数据环境由数据共享属性子句确定。任务执行取决于任务调度 — 详细信息请参阅 OpenMP 3.1 规范文档

格式

1
2
3
4
5
6
7
8
9
10
11
#pragma omp task [clause ...]  newline 
if (scalar expression)
final (scalar expression)
untied
default (shared | none)
mergeable
private (list)
firstprivate (list)
shared (list)

structured_block

同步结构

考虑一个简单的例子,两个线程都试图同时更新变量x:

1
2
3
4
5
6
7
8
9
THREAD1	
update(x)
{
    x = x + 1
}

x = 0
update(x)
print(x)

1
2
3
4
5
6
7
8
9
THREAD2
update(x)
{
    x = x + 1
}

x = 0
update(x)
print(x)

一种可能的执行顺序:

  • 线程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
2
#pragma omp master  newline
structured_block
限制条件

进入或跳出一个 master 代码块是非法的。

critical 指令

目的

critical 指令指定了一个只能由一个线程执行的代码区域。

格式
1
2
#pragma omp critical [ name ]  newline
structured_block
注意事项
  • 如果一个线程当前在一个 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
#include <omp.h>

int main() {
int x;
x = 0;

#pragma omp parallel shared(x)
{
#pragma omp critical
x = x + 1;
} /* end of parallel region */
return 0;
}

barrier 指令

目的

barrier 指令同步团队中的所有线程。当到达 barrier 指令时,一个线程将在该点等待,直到所有其他线程都到达了 barrier 指令。然后,所有线程继续并行执行 barrier 之后的代码。

格式
1
#pragma omp barrier  newline

f

限制条件

团队中的所有线程(或没有线程)都必须执行 barrier 区域。对于团队中的每个线程,遇到的 work-sharing 区域和 barrier 区域的顺序必须是相同的。

taskwait 指令

目的

taskwait 结构指定自当前任务开始以来生成的子任务完成时的等待时间。

格式
1
#pragma omp taskwait  newline
限制条件

因为 taskwait 结构是一个独立的指令,所以它在程序中的位置有一些限制。taskwait 指令只能放置在允许使用基本语言语句的地方。taskwait 指令不能代替 if、while、do、switch 或 label 后面的语句。有关详细信息,请参阅 OpenMP 3.1 规范文档。

atomic 指令

目的

atomic 结构确保以原子方式访问特定的存储位置,而不是将其暴露给多个线程同时读写,这些线程可能会导致不确定的值。本质上,这个指令提供了一个最小临界( mini-CRITICAL )区域。

格式
1
2
#pragma omp atomic  [ read | write | update | capture ] newline
statement_expression
限制条件

该指令仅适用于紧接其后的单个语句。原子语句必须遵循特定的语法。查看最新的OpenMP规范。

flush 指令

目的

flush 指令标识了一个同步点,在这个点上,内存数据必须一致。这时,线程可见的变量被写回内存。请参阅最新的 OpenMP 规范以获取详细信息。

格式
1
#pragma omp flush (list)  newline
注意事项

可选 list 参数包含一个将被刷新的已命名变量列表,以避免刷新所有变量。对于列表中的指针,请注意指针本身被刷新,而不是它指向的对象。实现必须确保线程可见变量的任何修改在此之后对所有线程都是可见的,例如编译器必须将值从寄存器恢复到内存,硬件可能需要刷新写缓冲区,等等。对于下面的指令,将使用 flush 指令。如果存在 nowait 子句,则该指令无效。

  • barrier
  • parallel - 进入和退出
  • critical - 进入和退出
  • ordered - 进入和退出
  • for - 退出
  • sections - 退出
  • single - 退出

ordered 指令

目的

ordered 指令指定封闭的循环迭代将以串行处理器上执行顺序执行。如果之前的迭代还没有完成,线程在执行它们的迭代块之前需要等待。在带有 ordered 子句的 for 循环中使用。ordered 指令提供了一种“微调”的方法,其中在循环中应用了排序。否则,它不是必需的。

格式
1
2
3
4
5
6
#pragma omp for ordered [clauses...]
(loop region)

#pragma omp ordered newline
structured_block
(endo of loop region)
限制条件
  • 一个 ordered 指令只能在以下指令的动态范围内出现:
    • for 或者 parallel for (C/C++)。
  • 在一个有序的区段中,任何时候都只允许一个线程。
  • 进入或跳出一个 ordered 代码块是非法的。
  • 一个循环的迭代不能多次执行同一个有序指令,也不能一次执行多个有序指令。
  • 包含有序指令的循环必须是带有 ordered 子句的循环。

threadprivate 指令

目的

threadprivate 指令指定复制变量,每个线程都有自己的副本。

可用于通过执行多个并行区域将全局文件作用域变量(C/C++/Fortran)或公共块(Fortran)局部化并持久化到一个线程。

格式

1
#pragma omp threadprivate (list)

注意事项

  • 指令必须出现在列出的变量/公共块的声明之后。每个线程都有自己的变量/公共块的副本,所以一个线程写的数据对其他线程是不可见的。
  • 在第一次进入一个并行区域时,应该假设 threadprivate 变量和公共块中的数据是未定义的,除非在并行指令中指定了 copyin 子句。
  • threadprivate 变量不同于 private 变量(稍后讨论),因为它们能够在代码的不同并行区域之间持久存在。

示例

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
#include <stdio.h>
#include <omp.h>
int a, b, i, tid;
float x;

#pragma omp threadprivate(a, x)

int main() {
/* 显式关闭动态线程 Explicitly turn off dynamic threads */
omp_set_dynamic(0);

printf("1st Parallel Region:\n");
#pragma omp parallel private(b,tid)
{
tid = omp_get_thread_num();
a = tid;
b = tid;
x = 1.1 * tid + 1.0;
printf("Thread %d: a,b,x= %d %d %f\n", tid, a, b, x);
} /* end of parallel region */

printf("************************************\n");
printf("Master thread doing serial work here\n");
printf("************************************\n");

printf("2nd Parallel Region:\n");
#pragma omp parallel private(tid)
{
tid = omp_get_thread_num();
printf("Thread %d: a,b,x= %d %d %f\n", tid, a, b, x);
} /* end of parallel region */
return 0;
}

Output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1st 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
#include <stdio.h>
#include <omp.h>

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;
}

#pragma omp parallel for default(shared) private(i) \
schedule(static,chunk) reduction(+:result)
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
7
setenv 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
2
setenv KMP_STACKSIZE 12000000
limit stacksize unlimited

ksh/sh/bash:

1
2
export KMP_STACKSIZE=12000000
ulimit -s unlimited

线程绑定

  • 在某些情况下,如果一个程序的线程被绑定到处理器/核心,那么它的性能会更好。
  • “绑定”一个线程到一个处理器意味着操作系统将调度一个线程始终在同一个处理器上运行。否则,可以将线程调度为在任何处理器上执行,并在每个时间片的处理器之间来回“弹回”。也称为“线程关联性”或“处理器关联性”。
  • 将线程绑定到处理器可以更好地利用缓存,从而减少昂贵的内存访问。这是将线程绑定到处理器的主要动机。

根据平台、操作系统、编译器和 OpenMP 实现的不同,可以通过几种不同的方式将线程绑定到处理器。OpenMP 3.1 版 API 提供了一个环境变量来“打开”或“关闭”处理器绑定。例如:

1
2
setenv OMP_PROC_BIND  TRUE
setenv OMP_PROC_BIND FALSE

在更高的级别上,进程也可以绑定到处理器。

3. OpenMP详细代码示例

3.1 hello_openmp.cpp

1
2
3
4
5
6
7
8
9
10
11
12
/* 尝试着在编译选项里使用和不使用-openmp 这个编译选项分别编译并执行代码 */
#include <iostream>

int main()
{
#ifdef _OPENMP // 如果定义了这个宏
std::cout << "Hello, OpenMP!" << std::endl;
#else
std::cout << "OpenMP is not enabled." << std::endl;
#endif
return 0;
}

3.2 header_and_env.cpp

1
2
3
4
5
6
7
8
9
10
11
12
/*  引入<omp.h>头文件,OpenMP的几乎所有函数定义都在这个头文件中。 */
#include <iostream>
#include <omp.h> // 包含OpenMP头文件

int main()
{
// omp_get_max_threads() 等其它函数都定义在omp.h头文件中
// omp_get_max_threads() 获取本机的CPU线程数
std::cout << "OpenMP will use " << omp_get_max_threads() <<
" threads maximum." << std::endl;
return 0;
}

3.3 parallel.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
1. 尝试将环境变量改为 OMP_NUM_THREADS=2 和 OMP_NUM_THREADS=3 再编译运行程序试试
2. 尝试在 #pragma omp parallel 后添加num_threads(5) 试试
*/
#include <stdio.h>
#include <omp.h>

int main()
{
#pragma omp parallel // OpenMP 并行区域
{
// 花括号里的内容会被N个线程同时执行,N定义在环境变量OMP_NUM_THREADS中
printf("Hello from thread %d of %d\n", omp_get_thread_num(), omp_get_num_threads());
}
return 0;
}

3.4 parallel_cout.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
程序说明: 我们会发现打印在控制台的内容是乱的,这是因为在一个线程还没输出完成时,另一个线程就抢着要输出了!
*/
#include <iostream>
#include <sstream>
#include <omp.h>

int main()
{
#pragma omp parallel
{
std::cout << "Hello from thread " << omp_get_thread_num() << " of " << omp_get_num_threads() << std::endl;
}
return 0;
}

3.5 nested.cpp

如果嵌套并行可用,则在并行区里还会继续创建线程,level 2会输出4次。否则的话level 2输出2次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
1. 看看打印输出是否跟您想的一样,如果不一样,为什么?
2. 试着禁止嵌套并行;
*/
#include <stdio.h>
#include <omp.h>

int main()
{
omp_set_nested(1); // 允许嵌套并行可用

#pragma omp parallel num_threads(2)
{
printf("Level 1, thread %d of %d\n", omp_get_thread_num(),omp_get_num_threads());

#pragma omp parallel num_threads(2)
{
printf("Level 2, thread %d of %d\n", omp_get_thread_num(),omp_get_num_threads());
}
}
return 0;
}

3.6 parallel-for.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
for循环的并行
*/
#include <iostream>

int main()
{
const int size = 50;
int a[size];

#pragma omp parallel for
for (int i = 0; i < size; i++)
a[i] = i; // 这里的代码是并行执行的

for (int i = 0; i < size; i++) // 这里是串行执行的,#pragma omp parallel for作用范围只有紧接着的for循环,当然这个for循环是可以嵌套的.
std::cout << a[i] << std::endl;

return 0;
}

3.7 scoping.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
1. 变量的作用范围在并行程序设计中非常重要
2. 如果在并行区域再加一个私有的a变量,想想会发生什么?
*/
#include <iostream>
#include <omp.h>

int main()
{
int a = -1;
#pragma omp parallel
{
// a在并行块内部是共享(默认)的,所有线程都有权操作它(操作的都是同一个变量,没有备份),而且并行快结束后,块内代码对其的修改有效
int b; // 在并行区域外不可见,每个线程有一个备份拷贝
a = omp_get_thread_num() + 100;
b = omp_get_thread_num() + 200;
}
std::cout << "a = " << a << std::endl; // 理论上这里的输出是[100+0,100+threads)之间随机的, 得看哪个线程最后执行完
// b = 0; // 对外不可见,这里会发生错误,所以注释
return 0;
}

3.8 firstprivate.cpp

firstprivate的作用是,让i默认使用并行区域外i的值来初始化并行区域内的私有i,但是初始化后并行去内部的i就跟外面的没有关系了,各个线程仍然持有一个i的私有备份,运行结束时,原有的i值保持i=10不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <omp.h>

int main()
{
int i = 10;

#pragma omp parallel private(i)
{
// 私有变量i是覆盖了并行区域外的共享变量i,所以这里并没有初始化,值应该为0
printf("thread %d, i = %d\n", omp_get_thread_num(), i);
i = 200 + omp_get_thread_num();
}
printf("i = %d\n", i);
return 0;
}

3.9 lastprivate.cpp

跟firstprivate相反,lastprivate主要是用来指定将线程中的私有变量的值在并行处理结束后复制回主线程中的对应变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int main()
{
const int size = 1000;
int i = -1, a[size];

#pragma omp parallel for lastprivate(i)
for (i = 0; i < size; i++)
a[i] = i;

std::cout << "i = " << i << std::endl;
return 0;
}

3.10 single-master-critical.cpp

  1. omp critical -> execute by one thread at a time
  2. omp single -> execute by any one thread
  3. omp master -> execute by the master thread (id == 0)
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <omp.h>

int main()
{
#pragma omp parallel num_threads(8)
{
#pragma omp critical
std::cout << "Hello from thread " << omp_get_thread_num() << " of " <<
omp_get_num_threads() << std::endl;
}
return 0;
}

3.11 mutex.cpp

锁是多线程计算里非常重要的概念,他是保证数据一致性的基础,没有锁的并行计算会导致非常奇怪的结果。

举个栗子:当多个线程操作一个数据时,一个线程在读取一个数的时候另两个线程在对这个数作写操作,那这个读线程到底应该拿哪一个数值去做计算呢?回答当然是,拿最后更改这个值的线程的值去计算,但是这个值就是对的么?

很多时候即使是并行的,但是对于一个简单操作来讲,它也是需要有先后循序的,比如这个线程在操作这个变量的时候要求别的线程要等待这个线程操作完成,这时候就使用锁将该变量锁住。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <omp.h>
#include <unistd.h>

int main()
{
omp_lock_t lock;
omp_init_lock(&lock);

#pragma omp parallel num_threads(4)
{
omp_set_lock(&lock); // mutual exclusion (mutex)
std::cout << "Thread " << omp_get_thread_num() << " has acquired the lock. Sleeping 2 seconds..." << std::endl;
sleep(2);
std::cout << "Thread " << omp_get_thread_num() <<" is releasing the lock..." << std:: endl; omp_unset_lock(&lock);
}
omp_destroy_lock(&lock);
return 0;
}

3.12 barrier.cpp

  1. 同步也是并行计算中特别重要的概念,跟上面讲的锁一样;
  2. 特别是在时间相关的计算领域里,如含时的有限差分等等;
  3. 因为在具体程序中,每个线程执行的任务不一样,即使执行的任务一样,也不能保证每个线程执行任务消费的时间都完全一致。有的线程已经执行了5行代码,有的线程才执行到第0行。而含时的迭代需要所有线程都执行完t步骤后,才能继续执行t+1时间步,不然会导致错误的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <omp.h>

int main()
{
#pragma omp parallel
{
printf("Hello from thread %d of %d\n", omp_get_thread_num(),
omp_get_num_threads());
#pragma omp barrier // 所有的线程都执行到这里时才能继续往后执行
printf("Thread %d of %d have passed the barrier\n",
omp_get_thread_num(), omp_get_num_threads());
}
return 0;
}

3.13 atomic.cpp

原子变量跟锁有相近似的作用,都是保证变量或者事务的一致性;举个栗子:银行转账,A给B转账过程中突然停电,A账户前丢失了B却没有收到,这是谁的责任?当然是银行的责任。当然,银行的程序员们可不是吃白米饭的,转账的操作就是一个原子操作,只有成功或失败,没有成功了一半这个说法。

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
#include <iostream>
#include <omp.h>

inline double two_body_energy(int i, int j)
{
return (2.0 * i + 3.0 * j) / 10.0; // some dummy return value
}

int main()
{
const int nbodies = 1000;
double energy = 0.0;

#pragma omp parallel
for (int i = 0; i < nbodies; i++) {
for (int j = i+1; j < nbodies; j++) {
double eij = two_body_energy(i, j);
#pragma omp atomic
energy += eij;
}
}

std::cout << "energy = " << energy << std::endl;
return 0;
}

3.14 reduction.cpp

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
#include <iostream>
#include <omp.h>

double
two_body_energy(int i, int j)
{
return (2.0 * i + 3.0 * j) / 10.0; // some dummy return value
}

int main()
{
const int nbodies = 1000;
double energy = 0.0;

// + 是进行加运算
// energy 是要进行归约的变量
#pragma omp parallel for reduction(+:energy)
for (int i = 0; i < nbodies; i++) {
for (int j = i+1; j < nbodies; j++) {
double eij = two_body_energy(i, j);
energy += eij;
}
}

std::cout << "energy = " << energy << std::endl;
return 0;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <omp.h>

#define CHUNK_SIZE 5

// scheduling:

int main()
{
const int niter = 25;

#pragma omp parallel for schedule(static, CHUNK_SIZE)
for (int i = 0; i < niter; i++) {
int thr = omp_get_thread_num();
printf("iter %d of %d on thread %d\n", i, niter, thr);
}

return 0;
}

3.16 ordered.cpp

在循环代码中某些代码的执行需要按规定的顺序执行,比如在一个循环中,一部分的工作可以并行执行,而特定的部分需要按照串行的工作流程依次执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <omp.h>

int main()
{
const int niter = 10;
#pragma omp parallel for ordered // 这里必须这么写
for (int i = 0; i < niter; i++) {
int thr = omp_get_thread_num();
printf("unordered iter %d of %d on thread %d\n", i, niter, thr);
#pragma omp ordered // 这里是需要顺序执行的部分
printf("ordered iter %d of %d on thread %d\n", i, niter, thr);
}
return 0;
}

3.17 loop-dependencies.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <omp.h>

void work( int N, int M, double **A, double **B, double **C )
{
int i, j;
double alpha = 1.2;

#pragma omp for collapse(2) ordered(2)
for (i = 1; i < N-1; i++)
{
for (j = 1; j < M-1; j++)
{
A[i][j] = foo(i, j);
#pragma omp ordered depend(source)

B[i][j] = alpha * A[i][j];

#pragma omp ordered depend(sink: i-1,j) depend(sink: i,j-1)
C[i][j] = 0.2 * (A[i-1][j] + A[i+1][j] + A[i][j-1] + A[i][j+1] + A[i][j]);
}
}
}

3.18 sections.cpp

  1. 有些需要并行的任务并不是一个for循环之类的,而是一个个代码块,这种情况下就可以使用sections的情形;
  2. sections下包含多个section,section相互之间只并行执行的,但是section内部是串行执行的;
  3. 多个sections之间也是串行执行的
  4. 如果#pragma omp parallel sections 写成#pragma omp sections,则各个section之间是串行执行的
  5. 如果变成两个线程的话,会有一个线程执行两个section
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
#include <stdio.h>
#include <omp.h>
#include <unistd.h>

int main()
{
#pragma omp parallel sections num_threads(4)
{
#pragma omp section // 独立线程
{
int thr = omp_get_thread_num();
printf("section 1, thread %d - sleeping 1 second\n", thr);
sleep(1);
printf("section 1 done\n");
}
#pragma omp section // 独立线程
{
int thr = omp_get_thread_num();
printf("section 2, thread %d - sleeping 2 second\n", thr);
sleep(2);
printf("section 2 done\n");
}
#pragma omp section // 独立线程
{
int thr = omp_get_thread_num();
printf("section 3, thread %d - sleeping 3 second\n", thr);
sleep(3);
printf("section 3 done\n");
}
// printf("not in omp section"); // error - code must be in section
}
return 0;
}

3.19 threadprivate.cpp

threadprivate指令用来指定全局的对象被各个线程各自复制了一个私有的拷贝,即各个线程具有各自私有的全局对象。threadprivate和private的区别在于threadprivate声明的变量通常是全局范围内有效的,而private声明的变量只在它所属的并行构造中有效。用作threadprivate的变量的地址不能是常数。对于C++的类(class)类型变量,用作threadprivate的参数时有些限制,当定义时带有外部初始化时,必须具有明确的拷贝构造函数。程序示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int g;
#pragma omp threadprivate(g) //一定要先声明
int main(int argc, char *argv[])
{
/* Explicitly turn off dynamic threads */
omp_set_dynamic(0);

#pragma omp parallel
{
g = omp_get_thread_num();
printf("tid: %d\n",g); //随机依次输出0~3
} // End of parallel region


#pragma omp parallel
{
int temp = g*g;
printf("tid : %d, tid*tid: %d\n",g, temp); //不同线程中全局变量值不同
} // End of parallel region
}

注意:在使用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
#include <omp.h> 
int A = 100;

#pragma omp threadprivate(A)
int main(int argc, _TCHAR* argv[])
{
#pragma omp parallel for
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号线程运行。
#pragma omp parallel for copyin(A)
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
21
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);
}
// 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
#pragma omp parallel //并行区内
{
#pragma omp for nowait // 任务分配for循环
for(k=0;k<m;k++){
fun1(k);
}
#pragma omp sections private(y,z)
{
#pragme omp section//任务分配section
{y=sectionA(x);}
#pragme omp section
{z=sectionB(x);}
}
}

因为第一个 任务分配for循环和第二个任务分配section代码块之间不存在数据相关。加上显示栅障,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma omp parallel shared(x,y,z) num_threads(2)//使用的线程数为2
{
int tid=omp_get_thread_num();
if(tid==0)
y=fun1();//第一个线程得到y
else
z=fun2();//第二个线程得到z
#pragma omp barrier //显示加上栅障,保证y和z在使用前已有值

#pragma omp for
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
#pragma omp parallel //并行区
{
int tid=omp_get_thread_num();//每个线程都调用这个函数,得到线程号
//这个循环被划分到多个线程上进行
#pragma omp for nowait
for(k=0;k<100;k++)
x[k]=fun1(tid);//这个循环的结束处不存在使所有线程进行同步的隐式栅障
#pragma omp master
y=fn_input_only(); //只有主线程会调用这个函数
#pragma omp barrier //添加一个显示的栅障对所有的线程同步,从而确保x[0-99]和y处于就绪状态
//这个循环也被划分到多个线程上进行

#pragma omp for nowait
for(k=0;k<100;k++)
x[k]=y+fn2(x[k]); //这个线程没有栅障,所以不会相互等待
//一旦某个线程执行完上面的代码,不需要等待就可以马上执行下面的代码
#pragma omp single //注意:single后面意味着有隐式barrier
fn_single_print(y);
//所有的线程在执行下面的函数前会进行同步
#pragma omp master
fn_print_array(x);//只有主线程会调用这个函数
}

3.23 task

从功能上说:

  1. 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.
  2. The data environment of the task is determined by the data sharing attribute clauses.
  3. 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
#include <omp.h>
#define TASK_COUNT 3

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[])
{

#pragma omp parallel num_threads(2)
{
#pragma omp sections
{
#pragma omp section
task1(10);
#pragma omp section
task2(20);
#pragma omp section
task3(1000);
}
}

#pragma omp parallel num_threads(2)
{
#pragma omp for
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
#include <omp.h>

void task(int p)
{
printf("task, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}

#define N 3
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);

#pragma omp parallel num_threads(2)
{
#pragma omp sections
{
#pragma omp section
task(a[0]);
#pragma omp section
task(a[1]);
#pragma omp section
task(a[2]);
}
}

#pragma omp parallel num_threads(2)
{
#pragma omp for
for(int i = 0;i < N; i++)
{
task(a[i]);
}
}
return 0;
}

这个例子,很容易理解了,把一个数组内的每一个元素“并行”的传递给task()函数,执行一个“任务”。同样,for和sections都能解决,但是如果N太大了,比如N是100,那sections就为难了,这里要说明的是:sections不能使用嵌套的形式,比如:

1
2
3
4
5
for(int i = 0;i < N;i++)
{
#pragma omp section
task(a[0]);
}

这样是不行的,section只能显式的,直接的在sections里面书写,可以理解为”静态“的。继续研究这个例子,假设现在的需求是对如下的代码进行并行化:

1
2
3
4
for(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
#include <omp.h>

void task(int p)
{
printf("task, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}

#define N 50
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);

#pragma omp parallel num_threads(2)
{
#pragma omp single
{
for(int i = 0;i < N; i=i+a[i])
{
#pragma omp task
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
#pragma omp parallel num_threads(2)
{
#pragma omp single
{
for(int i = 0;i < N; i=i+a[i])
{
#pragma omp task
task(a[i]);
}
task(1000);
}
}

其中task(1000);就属于一个隐式的任务。因为执行完for后,会执行这一个任务,而上面的任务可能也会同时执行。

task的嵌套

任务构造结构可以嵌套在另一个task结构中,但是内部的task结构并不属于外部的task区域的一部分。

1
2
3
4
5
6
#pragma omp task
{
task(a[i]);
#pragma omp task
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
7
done=0;
#pragma omp flush(done)
if(!done)
{
...
done=1;
}

OpenMP中数据属性相关子句详解

private/firstprivate/lastprivate/threadprivate之间的比较

private/firstprivate/lastprivate/threadprivate,首先要知道的是,它们分为两大类,一类是private/firstprivate/lastprivate子句,另一类是threadprivate,为指令。

private

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

  1. private variables are undefined on entry and exit of the parallel region.即private变量在进入和退出并行区域是“未定义“的。
  2. The value of the original variable (before the parallel region) is undefined after the parallel region!在并行区域之前定义的原来的变量,在并行区域后也是”未定义“的。
  3. 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
12
int main(int argc, _TCHAR* argv[])
{
int A=100;

#pragma omp parallel for private(A)
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
14
int main(int argc, _TCHAR* argv[])
{
int B;

#pragma omp parallel for private(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
13
int main(int argc, _TCHAR* argv[])
{
int C = 100;

#pragma omp parallel for private(C)
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
18
int main(int argc, _TCHAR* argv[])
{
int A=100,B,C=0;

#pragma omp parallel for private(A) private(B)
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
12
int main(int argc, _TCHAR* argv[])
{
int A;

#pragma omp parallel for firstprivate(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
14
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for firstprivate(A)
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
#include <omp.h>
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for firstprivate(A)
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
11
Thread 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
12
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for lastprivate(A)
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
#include <omp.h>
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for lastprivate(A)
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
2
3
4
5
6
7
8
9
10
11
Thread ID: 0, 0
Thread ID: 0, 1
Thread ID: 0, 2
Thread ID: 3, 8
Thread ID: 3, 9
Thread ID: 2, 6
Thread ID: 1, 3
Thread ID: 1, 4
Thread ID: 1, 5
Thread ID: 2, 7
9

从结果可以看出,最后并行区域外的共享变量的值并不是最后一个线程退出的值,多次运行发现,并行区域的输出结果可能发生变化,但是最终的输出都是9,这就是上面的OpenMP规范说明的问题,退出并行区域的时候,是根据“逻辑上”的最后一个线程用于对共享变量赋值,而不是实际运行的最后一个线程,对于for而言,就是最后一个循环迭代所在线程的副本值,用于对共享变量赋值。

另外,firstprivate和lastprivate分别是利用共享变量对线程副本初始化(进入)以及利用线程副本对共享变量赋值(退出),private是线程副本和共享变量无任何关联,那么如果希望进入的时候初始化并且退出的时候赋值呢?事实上,可以对同一个变量使用firstprivate和lastprivate的,下面的例子即可看出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <omp.h>
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for firstprivate(A) lastprivate(A)
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
#include <omp.h>
int A = 100;
#pragma omp threadprivate(A)

int main(int argc, _TCHAR* argv[])
{
#pragma omp parallel for
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

#pragma omp parallel for
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Thread 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: 103
Thread ID: 2, 7: 104
Thread ID: 0, 0: 104
Thread ID: 0, 1: 105
Thread ID: 0, 2: 106
Thread ID: 1, 3: 104
Thread ID: 1, 4: 105
Thread ID: 1, 5: 106
Thread ID: 3, 8: 103
Thread ID: 3, 9: 104
Global A: 106

分析结果,发现,第二个并行区域是在第一个并行区域的基础上继续递增的;每一个线程都有自己的全局私有变量。另外,观察在并行区域外的打印的“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
#define COUNT	10000

int main(int argc, _TCHAR* argv[])
{
int sum = 0;
#pragma omp parallel for shared(sum)
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
#define COUNT	10

int main(int argc, _TCHAR* argv[])
{
int sum = 0;
int i = 0;
#pragma omp parallel for shared(sum, i)
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define COUNT	10

int main(int argc, _TCHAR* argv[])
{
int sum = 0;
int i = 0;
#pragma omp parallel shared(sum) private(i)
for(i = 0; i < COUNT;i++)
{
sum = sum + i;
}
printf("%d\n",i);
printf("%d\n",sum);
return 0;
}

这里输出的i为0,如果改为shared,那么就是10了。当然,这段代码和上面的求和的代码含义上就是不一样的。

另外,这里顺便一个问题是,在循环并行区域内,循环迭代变量是不可修改的。这也是上面的例子,为何不采用下面的写法:

1
2
3
4
5
6
7
	int i = 0;
#pragma omp parallel for shared(sum) shared(i)
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
#define COUNT	10

int main(int argc, _TCHAR* argv[])
{
int sum = 0;
int i = 0;
#pragma omp parallel for
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
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
#include <omp.h>  
int A = 100;
#pragma omp threadprivate(A)

int main(int argc, _TCHAR* argv[])
{
#pragma omp parallel for
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

#pragma omp parallel for copyin(A)
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Thread 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

#include <omp.h>
int A = 100;
#pragma omp threadprivate(A)

int main(int argc, _TCHAR* argv[])
{
#pragma omp parallel
{
printf("Initial A = %d\n", A);
A = omp_get_thread_num();
}

printf("Global A: %d\n",A);

#pragma omp parallel copyin(A) // copyin
{
printf("Initial A = %d\n", A);
A = omp_get_thread_num();
}

printf("Global A: %d\n",A);

#pragma omp parallel // Will not copy, to check the result.
{
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
15
Initial 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

#include <omp.h>
#define COUNT 10

int main(int argc, _TCHAR* argv[])
{
int sum = 100; // Assign an initial value.
#pragma omp parallel for reduction(+:sum)
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的工作过程:

  1. 进入并行区域后,team内的每个新的线程都会对reduction变量构造一个副本,比如上面的例子,假设有四个线程,那么,进入并行区域的初始化值分别为:sum0=100,sum1 = sum2 = sum3 = 0。为何sum0为100呢?因为主线程不是一个新的线程,所以不需要再为主线程构造一个副本(没有找到官方这样的说法,但是从理解上,应该就是这样工作的,只会有一个线程使用到并行区域外的初始值,其余的都是0)。
  2. 每个线程使用自己的副本变量完成计算。
  3. 在退出并行区域时,对所有的线程的副本变量使用指定的操作符进行迭代操作,对于上面的例子,即sum’ = sum0’+sum1’+sum2’+sum3’.
  4. 将迭代的结果赋值给原来的变量(sum),sum=sum’.

注意:

  • reduction只能用于标量类型(int、float等等);
  • reduction只用于一个区域构造或者工作共享构造的结构中,并且,在这个区域中,reduction的变量只能被用于类似如下的语句:
1
2
3
4
5
6
7
x = x op expr 
x = expr op x (except subtraction)
x binop = expr
x++
++x
x--
--x

说明:经过测试,其实不符合这一规则的时候,编译运行都不会有问题,有些甚至也是可以解释清楚为什么结果是这样的,但是无论如何,一般使用reduction的时候,都是一些迭代的情况,语义应该是很清楚的情况。看下面的一个“错误”的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define COUNT 10

int main(int argc, _TCHAR* argv[])
{
int sum = 100; // Assign an initial value.
#pragma omp parallel for reduction(+:sum)
for(int i = 0;i < COUNT; i++)
{
sum += i;
sum = 1;
}
printf("Sum: %d\n",sum);

return 0;
}

输出结果为104(4核机器)。这个例子,sum=1;这个表达式是不应该出现的,但是如果就这么写,编译运行都没问题,而且,这个结果甚至也算是预料中的。每一个线程计算结束后,其sum的值都是1,四个线程,然后初始值是100,所以最后结果是104。:) 无论如何,即使可以解释得通,相信也没有这样使用的场合,至少,不要依赖于这样的实现的结果。从这个错误的例子,反过来,我发现上面的关于”理解reduction的工作过程“似乎不太完全正确,其中第一步,进入并行区域后,初始值为”sum0=100,sum1 = sum2 = sum3 = 0“,如果这样,只是一个初始值,那么计算后,在这个例子里,所有线程的sum都是1,结果应该为4才对。所以看来,实际的理解应该是,主线程也会创建一个副本变量,其初始值也为0,在最后迭代的时候,是用sum原来的值和每个线程的副本进行计算。过程大概如下:

  1. sum=100
  2. 进入并行区域,创建4个线程的4个副本:sum0=sum1=sum2=sum3=0;
  3. 计算完成后,得到sum0’,sum1’,sum2’,sum3’
  4. 计算sum,sum=sum op sum 0‘ op sum1’ op sum2‘ op sum3’。

总之,具体编译器是如何实现的并不重要,关键是理解reduction是如何工作的。

OpenMP参考文档