简明 OpenMP: C/C++ 并行计算
本文使用的 OpenMP 版本为 \(4.5\), 机器的处理器个数为 \(16\).
1. 简介
1.1. 执行方式
OpenMP 采用 fork-join 的方式进行并行运算:
__ __ / _ 并 _ \ / / \ \ ------> FORK -- 行 -- JOIN --> 主线程 \ \_ _/ / \__ 域 __/
在这个模型中, 一开始只有一个主线程, 然后主线程遇到相关的命令就会创建多个线程.
OpenMP 就是关于
- 如何控制并行域
- 并行域中的任务该如何分配
- 设置集合点控制各个线程
- 线程之间如何 访问/修改 数据
的一种机制.
1.2. 语句的书写
OpenMP 通过 pragma 语句的书写来指导程序进行并行计算, 格式如下:
#pragma omp DIRECTIVE [CLAUSE [[, ] CLAUSE] ...]
指令 (directive) 可以单独出现, 子句 (clause) 必须出现在指令之后.
每条 pragma 仅指导下一句代码的执行方式.
1.3. 并行域
#pragma omp parallel
cout << 1 << 2;
以上在并行域中调用 std::cout.operator<<
, 默认有处理器数量个线程执行了这句代码.
2. 设置线程数量
2.1. 在 pragma 中设定线程数量
#pragma omp parallel num_threads(3) cout << 1 << 2;
2.2. 运行时 API 调整线程数量
omp_set_num_threads(5); #pragma omp parallel cout << 1 << 2;
2.3. 环境变量控制线程数量
#pragma omp parallel
cout << 1 << 2;
(save-excursion (org-previous-block 1) (with-environment-variables (("OMP_NUM_THREADS" "3")) (org-babel-execute-src-block)))
3. 查询线程信息
3.1. 获取线程数量
const int n = 100, dest = rand() % n; #pragma omp parallel for for (int i = 0; i < n; ++i) if (i == dest) cout << omp_get_num_threads();
3.2. 获取线程编号
#pragma omp parallel num_threads(5) cout << omp_get_thread_num();
34021
从结果看出, 它是从 0 编号的.
4. 分配 for-loop
4.1. 并行域中的 for-loop
4.1.1. 在并行域中包含 for-loop 和其它代码
我们先来看下并非是单独为 for-loop 创建的并行域, 以和后文作对比.
#pragma omp parallel { cout << '.'; #pragma omp for for (int i = 0; i < 10; ++i) // 标准写法, 不建议写成其它形式. cout << i; }
上述并行域中, 众线程首先打印了各自的 '.'
.
之后, (从结果可以看出) 都停下来等待, 直到所有线程准备就绪, 才开始执行下一句由
#pragma omp for
制导的 for-loop. 这个 for-loop 根据默认的策略, 被分配给了若干线程, i.e., 所有 iteration 都被 不重复 不遗漏 地执行了.
4.1.2. 为 for-loop 单独创建并行域
#pragma omp parallel for for (int i = 0; i < 10; ++i) cout << omp_get_thread_num();
这种写法更加方便, 但是在并行域中创建的线程会在离开 for-loop 后被立刻销毁. 这对性能有影响.
4.2. for-loop 的分配策略
我们可以使用 schedule(TYPE, CHUNK_SIZE)
子句设置 OpenMP 分配 for-loop 时使用的策略.
4.2.1. static
for-loop schedule
constexpr int number_of_threads = 3; array<vector<int>, number_of_threads> their_vectors; #pragma omp parallel for schedule(static), num_threads(number_of_threads) for (int i = 0; i < 20; ++i) their_vectors[omp_get_thread_num()].push_back(i); for (const auto& its_vec : their_vectors) { for (const int i : its_vec) cout << i << ' '; cout << endl; }
众线程按照次序, 每次取 CHUNK_SIZE
个 连续的 iteration.
省略 CHUNK_SIZE
则表示, 每次取尽可能多的 iteration, 且尽量平均分配.
4.2.2. dynamic
for-loop schedule
先到先得的方式进行任务分配.
一次性分配 CHUNK_SIZE
个连续的 iteration, 先把任务干完的线程先取下一段任务, 而不是一开始就分配固定的任务数;
CHUNK_SIZE
默认为 \(1\).
constexpr int number_of_threads = 3; array<vector<int>, number_of_threads> their_vectors; #pragma omp parallel for schedule(dynamic, 4), num_threads(number_of_threads) for (int i = 0; i < 24; ++i) this_thread::sleep_for(1us * rand()), their_vectors[omp_get_thread_num()].push_back(i); for (const auto& its_vec : their_vectors) { for (const int i : its_vec) cout << i << ' '; cout << endl; }
在任务难度不均衡的时候适合用 dynamic
; 其余情况下则不推荐, 毕竟频繁的动态的任务申请会造成较大的开销.
4.2.3. guided
for-loop schedule
刚开始给每个线程分配比较多的连续的 iteration.
后来每次分配的 iteration 的数量逐渐递减至 CHUNK_SIZE
, 省略该参数则降至 \(1\).
constexpr int number_of_threads = 2; array<vector<int>, number_of_threads> their_vectors; #pragma omp parallel for schedule(guided), num_threads(number_of_threads) for (int i = 0; i < 25; ++i) this_thread::sleep_for(1us * rand()), their_vectors[omp_get_thread_num()].push_back(i); for (const auto& its_vec : their_vectors) { for (const int i : its_vec) cout << i << ' '; cout << endl; }
4.2.4. TODO runtime
for-loop schedule
4.3. 串行执行 parallel for
中的某段代码
以下这段代码要求众线程串行地打印各自 iteration 中的 i
:
constexpr int num_of_threads = 4; #pragma omp parallel for ordered, schedule(static, 1), num_threads(num_of_threads) for (int i = 0; i < num_of_threads; ++i) { this_thread::sleep_for(50ms * i); cout << '('; #pragma omp ordered { cout << i; this_thread::sleep_for(50ms * i); cout << i; } cout << ')'; }
(00)(1(1)2(2)33)
可以看到, 结果是一个 S-表达式2; 删除括号之后 (00112233
), 数字按顺序出现, 且 相同数字之间无间隔.
这些是 串行 的特征.
而结果中的 (
/)
并没有连续出现, 这说明 #pragma omp ordered
的前后并没有隐式同步点.
它只是保证: 在 当前 iteration 执行完 被 #pragma omp ordered
制导的代码 之前, 下一个 iteration 不会 开始执行 当前 iteration 正在 执行的 那段代码.
5. 词法地划分并行域
5.1. 并行域中任一代码仅由一个线程执行
使用 section
指令, 对 由 parallel sections
指令制导的并行域 中的代码文本进行划分, 分配给众线程, 划分的区域只会被执行一次.
#pragma omp parallel sections num_threads(10) { #pragma omp section cout << omp_get_thread_num(); #pragma omp section cout << omp_get_thread_num(); #pragma omp section cout << omp_get_thread_num(); }
5.2. 并行域中某段代码仅由一个线程执行
5.2.1. 并行域中某段代码由任意一个线程执行
#pragma omp parallel num_threads(10) { #pragma omp single nowait for (int i = 0; i < 5; this_thread::sleep_for(4ms), ++i) cout << '.'; this_thread::sleep_for(5ms); cout << omp_get_thread_num(); }
若不写 nowait
子句, 则其它线程会等待那个 正在执行 由 single
指令制导的语句 的线程 执行完成, 在一起执行后续的代码. (I.e., 存在一个隐式同步点.)
5.2.2. 并行域中某段代码仅由主线程执行
指令 master
和 single
相似, 区别在于 master
制导的代码块只能由主线程执行, 而且 master
指令在代码块结束时没有隐式同步, 不能指定 nowait
子句.
#pragma omp parallel { #pragma omp master /* nowait */ cout << omp_get_thread_num(); cout << '.'; }
6. 同步
6.1. 路障
当遇到 barrier
指令时, 线程必须停下来等待, 直到所有的线程都执行到了这一点, 才能继续往后执行.
E.g.,
#pragma omp parallel { this_thread::sleep_for(1us * rand()); cout << 1; #pragma omp barrier cout << 2; }
6.2. 取消隐式同步
容易猜到, 在 parallel
&for
§ions
&single
指令之后都有一个隐式的同步点.
我们可以添加 nowait
子句以取消这类隐式路障, e.g.,
constexpr int num_of_threads = 18; #pragma omp parallel num_threads(num_of_threads) { #pragma omp for nowait for (int i = 0; i < num_of_threads / 2; ++i) cout << '^'; #pragma omp for for (int i = 0; i < num_of_threads / 2; ++i) cout << '.'; }
7. 附录
7.1. TODO 预编译指令
parallel |
制导并行域 |
for |
用在并行域中的 for 语句之前, for-loop 的迭代将会被分配给若干线程去执行 |
parallel for |
parallel 和 for 的组合, 制导 for 语句 |
sections |
作用域中, 每一个由 section 子句 制导的代码块 将会被若干线程执行 |
parallel sections |
parallel 和 sections 的组合 |
single |
用在并行域内, 标注的代码块将只被单个线程执行 |
critical |
互斥域 |
flush |
保证线程内数据影响的一致性 |
barrier |
使并行域内的线程同步 |
atomic |
原子地执行 |
master |
只由主线程执行 |
threadprivate |
指定若干变量为线程专有 |
private |
指定若干变量在各线程中都有自己的私有副本 |
firstprivate |
同 private ; 在变量进入 并行域/任务分担域 时, 继承主线程的同名变量作为初值 |
lastprivate |
指定若干私有变量的值在并行处理之后复制到主线程的同名变量中, 负责拷贝的线程是 for /sections 任务分担中的最后一个线程 |
reduction |
指定若干变量是私有的, 并且在并行处理完这些变量后指定要规约的操作 |
nowait |
指出并发线程可以忽略其它制导指令暗含的路障同步 |
num_threads |
指定并行域内的线程数目 |
schedule(type, chunk_size) |
指定 for 任务当中任务分配调度的类型 |
shared |
指定若干变量为线程间的共享变量 |
ordered |
按照串行循环次序执行 for 任务分担域内指定的代码 |
copyprivate |
配合 single 指令, 将指定线程的专有变量广播到并行域内其它线程的同名变量中 |
copyin |
指定一个 threadprivate 类型的变量需要用主线程的同名变量进行初始化 |
default |
并行域内变量的使用方式, 默认为 shared |
7.2. TODO API 函数
omp_in_paralled |
处于并行域? |
omp_get_thread_num |
线程号 |
omp_set_num_threads |
设置后续的并行域的线程个数 |
omp_get_num_threads |
当前并行域中的线程个数 |
omp_get_max_threads |
并行域中可用的最大线程数目 |
omp_get_num_procs |
处理器的个数 |
omp_get_dynamic |
支持动态改变线程数目? |
omp_set_dynamic |
设置线程数目动态改变的功能 |
omp_get_nested |
系统支持并行嵌套? |
omp_set_nested |
设置并行嵌套的功能 |
omp_init(_nest)_lock |
初始化 (嵌套) 锁 |
omp_destroy(_nest)_lock |
销毁 (嵌套) 锁 |
omp_set(_nest)_lock |
设置 (嵌套) 锁 |
omp_unset(_nest)_lock |
(嵌套) 解锁操作 |
omp_test(_nest)_lock |
非阻塞的 (嵌套) 加锁 |
omp_get_wtime |
获取 wall time |
omp_set_wtime |
设置 wall time |