一、OpenMP简介
1. 内存共享并行模型。
机器必须多处理器/核,共享内存。
底层架构可以是UMA和NUMA。
UMA | NUMA |
---|---|
2. Fork-Join模型
重复fork-join操作。
3. OpenMP特性
OpenMP仅通过线程来完成并行。
OpenMP的程序一般由串行等价性,也就是并行运算最终的结果会和某个串行程序相等。
支持 c / c++ 等语言。 编译器VS、gcc、clang等都支持OpenMP。可移植性好。
编译命令一般加上-fopenmp就可以。
二、OpenMP的API及工作机制
OpenMP由三部分组成,编译指令、运行时库函数和环境变量。
三个部分都可以控制OpenMP,编译优先级为编译指令 > 运行时库函数 > 环境变量。
1. 基本语法
- 需要 include <omp.h> 头文件。
- 分为串行区和并行区,由编译指令控制。串行区只有一个 Master 线程存在。
2. 编译指令
基本格式为:
#pragma omp <编译关键字> [ 子句[ [,] 子句]…… ]
编译关键字必选,决定该编译指令的作用
子句可选,控制变量的访问、初始化、共享等。
区分大小写。
遵循编译指令的C/C++规则。
通过在指令行末尾用反斜杠(“\”)转义换行符,可以在后续行上“继续”长指令行
每个指令最多使用一个后续子句,该子句必须是结构化块
2.1 编译指令的作用
告诉编译器在什么地方执行什么样的操作。
如果编译器不能识别编译指令,就会跳过。
具体作用有:
- 产生并发区域
- 在线程之间划分代码块、数据
- 在线程之间分配循环迭代
- 序列化代码段
- 线程之间的工作同步
- 共享数据
2.2 常用编译指令
- parallel:创建线程组 + 并行执行
- for : 将for循环分配给各个线程并行化执行,循环变量只能是整型。
- sections : 非迭代式共享任务并行化
- single : 代码段由一个线程执行(不一定是主线程)
- atomic : 指定的内存某位置被原子更新
- barrier : 实现所有线程同步
- critical :创建临界区,线程互斥访问
- flush : 所有线程对所有共享对象具有相同的内存视图。当并行区域里存在一共享变量,并且对其进行修改时,需要用flush更新变量,确保并行的多线程对共享变量的读操作是最新值。共享的语句一般都隐含了flush。
- master :指定由主线程来运行接下来的程序
- ordered :指定在接下来的代码块中,被并行化的 for循环将依序运行
- threadprivate :指定一个变量是线程局部存储
2.3 常用子句
因为OpenMP是基于共享内存的,所以大部分变量默认是共享的。
- private :其列出来的变量对于线程私有
- firstprivate : private的功能 + 对于线程局部存储的变量,其初值是进入并行区之前的值
- lastprivate : private的功能 + 并行区里的值在最后会赋值给并行区前面的变量。
- default :自定义一个并行区的默认的变量的作用范围
- shared :其列出来的变量对于所有线程共享
- reduction : 对于各个线程私有的变量,在并行区结束时通过某种运算归一。
- schedule : 线程调度,有 dynamic、guided、runtime、static 四种方法。
- ordered :将for循环按照顺序执行
- num_threads : 设置线程数量的数量。默认值为当前计算机硬件支持的最大并发数。
- nowait :忽略barrier的同步等待。
- if : 决定是否并行化
- copyin :控制变量在串行和并行部分传递,让threadprivate的变量的初始值和主线程的值相同。copyin中的参数必须被声明成threadprivate。
- copyprivate :不同线程中的变量在所有线程中共享。copyprivate只能用于single指令的子句中,在一个single块的结尾处完成广播操作。copyprivate只能用于private/firstprivate或threadprivate修饰的变量。
3. 运行时库函数
3.1 环境函数
函数API | 作用 |
---|---|
void omp_set_num_threads(int _Num_threads) | 设置线程数,此调用只影响调用线程所遇到的同一级或内部嵌套级别的后续并行区域,含有num_threads子句的并行区不受影响 |
int omp_get_num_threads(void) | 返回当前线程数目 |
int omp_get_max_threads(void) | 最大可用线程数量,通常这个最大数量由omp_set_num_threads()或OMP_NUM_THREADS环境变量决定 |
int omp_get_thread_num(void) | 返回当前线程id,master线程的id是0 |
int omp_get_num_procs(void) | 返回处理器数量 |
void omp_set_dynamic(int _Dynamic_threads) | 启用或禁用可用线程数的动态调整.(缺省情况下启用动态调整.)此调用只影响调用线程所遇到的同一级或内部嵌套级别的后续并行区域.如果 _Dynamic_threads 的值为非零值,启用动态调整;否则,禁用动态调整。在设置了”动态“之后,接下里的并行区域会根据系统的当前状况进行判断来分配合理的线程数量 |
int omp_get_dynamic(void) | 是否启用了动态线程调整 |
int omp_in_parallel(void) | 确定线程是否在并行区域的动态范围内执行 |
void omp_set_nested(int _Nested) | 启用或禁用嵌套并行操作.此调用只影响调用线程所遇到的同一级或内部嵌套级别的后续并行区域. |
int omp_get_nested(void) | 是否启用了嵌套并行操作 |
3.2 定时函数
函数API | 函数作用 |
---|---|
double omp_get_wtime(void) | 获取wall clock time,返回一个double的数,表示从过去的某一时刻经历的时间,一般用于成对出现,进行时间比较. 此函数得到的时间是相对于线程的,也就是每一个线程都有自己的时间. |
double omp_get_wtick(void) | 得到clock ticks的秒数 |
3.3 锁相关函数
函数API | 函数作用 |
---|---|
void omp_init(_nest)_lock(omp_nest_lock_t * _Lock) | 初始化一个(嵌套)互斥锁 |
void omp_destroy(_nest)_lock(omp_nest_lock_t * _Lock) | 销毁(嵌套)锁,释放内存 |
void omp_set(_nest)_lock(omp_nest_lock_t * _Lock) | 对下面的代码加(嵌套)锁,直到遇到unset语句,中间的部分互斥进入 |
void omp_unset(_nest)_lock(omp_nest_lock_t * _Lock) | 释放(嵌套)锁 |
int omp_test(_nest)_lock(omp_nest_lock_t * _Lock) | 试图获得一个(嵌套)互斥锁,并在成功时放回真(true),失败是返回假(false). |
4. 环境变量
变量名 | 解释 |
---|---|
OMP_SCHEDULE | 格式为"type,[chunk_size]"。设置运行时的任务调度方式和调度块的大小,有 dynamic、guided、runtime、static 四种方法。后面会详细讲schedule |
OMP_NUM_THREADS | 设置执行期间要使用的最大线程数 |
OMP_DYNAMIC | 启用或禁用动态调整可用于执行并行区域的线程数 |
OMP_PROC_BIND | 启用或禁用绑定到处理器的线程 |
OMP_NESTED | OMP_NESTED |
OMP_STACKSIZE | 控制创建(非主)线程的堆栈大小,格式为“[ ]数字[ ]单位”,比如“10 G”、“ 1M”等 |
OMP_WAIT_POLICY | 控制等待线程的行为。兼容的OpenMP实现可能会也可能不会遵守环境变量的设置。有效值为ACTIVE和PASSIVE。ACTIVE指定等待线程应该主动处于活动状态,即在等待时消耗处理器周期。PASSIVE指定等待线程应该主要是被动的,即在等待时不消耗处理器周期。ACTIVE和PASSIVE行为的细节是实现定义的 |
OMP_MAX_ACTIVE_LEVELS | 控制嵌套活动并行区域的最大数量。此环境变量的值必须是非负整数。如果请求的OMP_MAX_ACTIVE_LEVELS值大于实现可以支持的嵌套活动并行级别的最大数量,或者该值不是非负整数,则程序的行为是实现定义的 |
OMP_THREAD_LIMIT | 设置要用于整个OpenMP程序的OpenMP线程数。此环境变量的值必须是正整数。如果请求的OMP_THREAD_LIMIT值大于实现可以支持的线程数,或者该值不是正整数,则程序的行为是实现定义的 |
三. OpenMP编程
1. parallel for
- 将一个for循环里的程序分配到一个线程组里,每个线程执行一部分。
- 该编译指令需要放到for循环前面,并行化部分为整个for循环。
- 后面接子句控制具体行为。
- 各个分量直接没有数据(空间上的)依赖性
- 循环计算没有时间上的依赖性
- 数据竞争性例子: for(i, 0, 100) a[i] = a[i] + a[i + 1]。a[i+1] 可能已经被其他线程修改,从而导致和串行运算结果不一致。
#pragma parallel for
for()
相当于下面代码的缩写
#pragma parall{
#pragma for
for()
}
2. 控制数据共享
我们已经知道,OpenMP是在共享内存上运行的程序,因此我们可以在任意线程间进行数据传递:
- 操作系统里学过,每个线程的栈空间是私有的
- 全局变量、代码、动态内存分配等是共享的,在堆中
- threadprivate 的数据每个线程都会有自己的副本
- shared 的数据是共享的
- firstprivate、lastprivate理解:
对于代码清单:
观察这个代码,我们如果做修改产生三份代码副本,即上面代码本身,删除last只留下first,删除first只留下last,那么运行三份代码我们将得到:
其中,对象需要支持 “=” 运算符(c++语法)。
3. schedule 调度策略
调度策略 | 功能 | 适合使用的场合 |
---|---|---|
static | 任务平分,每个线程分别执行 | 各个CPU的差距不大 |
dynamic | 任务平分,每个线程分别执行,首先执行完的再执行下一份 | CPU差距大 |
guided | 任务不平分成n份,从大到小,每块为 “剩余任务 / (线程数*2)”,其余和dynamic相似 | CPU差距大,一般优于dynamic |
runtime | 根据环境变量来选择前三种中的某类型,默认stastic |
4. reduction(operator : list)
- 将一组变量用 operator 运算连起来,并且提供互斥
- 每个线程自己运算时使用私有副本运算,运算完毕再归约
- 例如一个for循环 for(i, 100) a = a + 1; 不用reduction在循环后a的值不确定,因为 a 是共享的变量;加上 ruduction(+: a) 的话 a 就确定了, 因为每个线程里运算的是私有副本,最终用 “+” 将各个私有副本连起来。
- operator的选项(c++)有 * + & | ^ && || max min。
5. 锁与互斥
OpenMP有三种机制实现互斥。
5.1 锁(类似信号灯机制)
要用上面的五(十)个API。
- 锁一般声明内存中,作为全局变量或者类的对象作用于类等,因为要让各个线程都访问到。
- 使用前需要初始化,比如类的构造函数里初始化锁,并行化的 for 循环前面初始化锁
- omp_set_lock 和 omp_unset_lock 之间是互斥区域。
- 最后要销毁锁。
- 嵌套锁就是可以多次 set 多次 unset,相当于数量无限但是只能正数的信号灯。
5.2 临界区critical
#pragma omp critical { }
只能有一个线程访问该区域,其他线程会被阻塞直到该线程退出。
5.3 原子操作atomic
#pragma omp atomic
其中的更新被保证为原子操作。
6. 线程同步
相关的关键字有nowait 、 single 、 master 、 barrier等。
6.1 barrier
实现一个线程组里所有的线程同步。先到达 barrier 的线程阻塞,直到所有线程到达。
一般使用方法为 在 #pragma omp parallel
里需要同步的语句前加上#pragma omp barrier
6.2 master
#pragma omp master
指定的语句将由 master 线程执行,别的线程忽略。
6.3 nowait
nowait 子句一般用于消除隐式的 barrier。
#pragma omp for
后面的for循环会等所有线程一起执行完这个for才一起退出,也就是存在一个隐形的 barrier。在 parrllel、 for、sections、single等的最后都会有一个隐式的的 barrier。
7. 非迭代结构并行化
无循环依赖的代码可以并行化执行。(也就是 DAG 的入度为 0 的节点)
7.1 sections
#pragma omp parallel {
#pragma omp sections {
#pragma omp section {}
#pragma omp section {}
……
}
#pragma omp sections {
#pragma omp section {}
#pragma omp section {}
……
}
……
}
sections之间是串行执行的,各个 sections 的 section 之间是并行执行的。
7.2 为每个线程写一段代码
#pragma omp parallel private(threadID) {
threadID = omp_get_thread_num();
if(threadID == 0) do1;
else if(threaID == 1) do2;
……
}
8. 循环嵌套并行化
循环嵌套的并行化加在哪几层 for 上呢?答案应该是保证正确性的基础上(消除依赖性)尽量保证访问的局部性,高效利用cache。
9. 其他技巧
- if子句决定线程是否创建(会影响线程数量)。
- omp_get_num_threads/omp_set_num_threads.尽管从函数名上看,它们是一对set/get函数,但是要区分它们的含义,set之后马上get,其值不一定等于set的结果,而且大部分情况都是不相等的,事实上,omp_get_num_threads()用于获取当前线程组(team)的线程数量,如果不在并行区调用,返回1,也就是 master 线程。
- 并行度、粒度、cache、负载均衡等方面注意代码优化。