工作中遇到可能需要优化代码,考虑使用openmp
测试编译器是否支持openmp
#include <omp.h>
#include <iostream>
// int main() {
// long long sum = 0;
// #pragma omp parallel for
// for (int i = 0; i < 100000; ++i) {
// sum += i;
// }
// std::cout << "Sum: " << sum << std::endl;
// return 0;
// }
int main() {
long long sum = 0;
// 并行区域,输出使用的线程数
#pragma omp parallel
{
int thread_id = omp_get_thread_num(); // 获取当前线程的 ID
int num_threads = omp_get_num_threads(); // 获取总线程数
// 只有一个线程会执行此部分,避免重复输出
if (thread_id == 0) {
std::cout << "Total threads used: " << num_threads << std::endl;
}
#pragma omp for reduction(+:sum)
for (int i = 0; i < 100000; ++i) {
sum += i;
}
}
std::cout << "Sum: " << sum << std::endl;
return 0;
}
/home/softwares/arm-gnu-toolchain-12.2.rel1-x86_64-aarch64-none-linux-gnu/bin/aarch64-none-linux-gnu-g++ -fopenmp -o my_program 1.cpp
scp -P 6322 my_program root@10.0.0.34:/app_new
export OMP_NUM_THREADS=1 //openmp的支持线程数,不能小于0
只需要加上编译选项-fopenmp,交给编译器优化并行代码。实际上会链接一个动态库libgomp,这个是编译工具链里自带的。
linux-vdso.so.1 (0x0000ffff83510000)
/app_new/adas/lib/tros_lib/libtcmalloc.so.4 (0x0000ffff82e00000)
libhlog.so.1 => /app_new/adas/lib/tros_lib/libhlog.so.1 (0x0000ffff82c60000)
libstdc++.so.6 => /usr/lib/aarch64-linux-gnu/libstdc++.so.6 (0x0000ffff82a40000)
libm.so.6 => /usr/lib/aarch64-linux-gnu/libm.so.6 (0x0000ffff829a0000)
libgcc_s.so.1 => /usr/lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000ffff82960000)
libc.so.6 => /usr/lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff827b0000)
libalog.so.1 => /usr/hobot/lib/libalog.so.1 (0x0000ffff82790000)
/lib/ld-linux-aarch64.so.1 (0x0000ffff83520000)
37M libhat_sim.so
linux-vdso.so.1 (0x0000ffffbcc80000)
/app_new/adas/lib/tros_lib/libtcmalloc.so.4 (0x0000ffffbc570000)
libhlog.so.1 => /app_new/adas/lib/tros_lib/libhlog.so.1 (0x0000ffffbc3d0000)
libstdc++.so.6 => /usr/lib/aarch64-linux-gnu/libstdc++.so.6 (0x0000ffffbc1b0000)
libm.so.6 => /usr/lib/aarch64-linux-gnu/libm.so.6 (0x0000ffffbc110000)
libgomp.so.1 => /usr/lib/aarch64-linux-gnu/libgomp.so.1 (0x0000ffffbc0a0000)
libgcc_s.so.1 => /usr/lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000ffffbc060000)
libc.so.6 => /usr/lib/aarch64-linux-gnu/libc.so.6 (0x0000ffffbbeb0000)
libalog.so.1 => /usr/hobot/lib/libalog.so.1 (0x0000ffffbbe90000)
/lib/ld-linux-aarch64.so.1 (0x0000ffffbcc90000)
37M libhat_sim.so
268k的一个动态库
基于openmp的优化原理就是fork-join,是可以用async进行替代的。
// 待优化
for (int i = 0; i < c - 1; ++i) {
for (int j = 0; j < h; ++j) {
for (int k = 0; k < w; ++k) {
transposed_nodust[j][k][i] = expInput.at<float>(i, j * w + k);
}
}
}
// async 优化
auto future1 = std::async(std::launch::async, [&]() {
for (int i = 0; i < (c - 1) / 2; ++i) {
for (int j = 0; j < h; ++j) {
for (int k = 0; k < w; ++k) {
transposed_nodust[j][k][i] = expInput.at<float>(i, j * w + k);
}
}
}
});
for (int i = (c - 1) / 2; i < c - 1; ++i) {
for (int j = 0; j < h; ++j) {
for (int k = 0; k < w; ++k) {
transposed_nodust[j][k][i] = expInput.at<float>(i, j * w + k);
}
}
}
future1.get();
// openmp优化
#pragma omp parallel for
for (int i = 0; i < c - 1; ++i) {
for (int j = 0; j < h; ++j) {
for (int k = 0; k < w; ++k) {
transposed_nodust[j][k][i] = expInput.at<float>(i, j * w + k);
}
}
}
1 CPU 核数扩展性问题
多核编程需要考虑程序性能随 CPU 核数的扩展性,即硬件升级到更多核后,能够不修改程序就让程序性能增长,这要求程序中创建的线程数量需要动态随 CPU 核数变化,不能创建固定数量的线程,否则在 CPU 核数超过线程数量上的机器上运行,将无法完全利用机器性能。
2 方便性问题
在多核编程时,要求计算均摊到各个 CPU 核上去,所有的程序都需要并行化执行,对计算的负载均衡有很高要求。这就要求在同一个函数内或同一个循环中,可能也需要将计算分摊到各个 CPU 核上,需要创建多个线程。操作系统 API 创建线程时,需要线程入口函数,很难满足这个需求,除非将一个函数内的代码手工拆成多个线程入口函数,这将大大增加程序员的工作量。使用 OpenMP 创建线程则不需要入口函数,非常方便,可以将同一函数内的代码分解成多个线程执行,也可以将一个 for 循环分解成多个线程执行。
3 可移植性问题
目前各个主流操作系统的线程 API 互不兼容,缺乏事实上的统一规范,要满足可移植性得自己写一些代码,将各种不同操作系统的 api 封装成一套统一的接口。 OpenMP 是标准规范,所有支持它的编译器都是执行同一套标准,不存在可移植性问题。
在 C/C++中, OpenMP 指令使用的格式为
# pragma omp 指令 [子句[子句]…]
parallel,用在一个代码段之前,表示这段代码将被多个线程并行执行
for,用于for循环之前,将循环分配到多个线程中并行执行,必须保证每次循环之间无相关性
parallel for,parallel和for语句的结合,也是用在一个for循环之前,表示for循环的代码将被多个线程并行执行
sections,用在可能会被并行执行的代码段之前
parallel sections,parallel和sections两个语句的结合
critical,用在一段代码临界区之前
single,用在一段只被单个线程执行的代码段之前,表示后面的代码段将被单线程执行。
barrier,用于并行区内代码的线程同步,所有线程执行到barrier时要停止,直到所有线程都执行到barrier时才继续往下执行。
atomic,用于指定一块内存区域被自动更新
master,用于指定一段代码块由主线程执行
ordered,用于指定并行区域的循环按顺序执行
thread private,用于指定一个变量是线程私有的。
OpenMP除上述指令外,还有一些库函数,下面列出几个常用的库函数:
// 线程和处理器相关
omp_get_num_procs, 返回运行本线程的多处理机的处理器个数。
omp_get_num_threads, 返回当前并行区域中的活动线程个数。
omp_get_thread_num, 返回线程号
omp_set_num_threads, 设置并行执行代码时的线程个数
// 锁相关
omp_init_lock, 初始化一个简单锁
omp_set_lock, 上锁操作
omp_unset_lock, 解锁操作, 要和 omp_set_lock 函数配对使用。
omp_destroy_lock, omp_init_lock 函数的配对操作函数,关闭一个
OpenMP的子句有以下一些
private, 指定每个线程都有它自己的变量私有副本
first private, 指定每个线程都有它自己的变量私有副本,并且变量要被继承主线程中的初值
last private, 主要是用来指定将线程中的私有变量的值在并行处理结束后复制回主线程中的对应变量
reduce, 用来指定一个或多个变量是私有的,并且在并行处理结束后这些变量要执行指定的运算
nowait, 忽略指定中暗含的等待
num_threads, 指定线程的个数
schedule, 指定如何调度for循环迭代
shared, 指定一个或多个变量为多个线程间的共享变量
ordered, 用来指定for循环的执行要按顺序执行
copyprivate, 用于single指令中的指定变量为多个线程的共享变量
copyin, 用来指定一个threadprivate的变量的值要用主线程的值进行初始化.
default, 用来指定并行处理区域内的变量的使用方式,缺省是shared
OpenMP 基本概念和创建线程
parallel 是用来构造一个并行块的,也可以使用其他指令如 for、 sections 等和它配合使用。在 C/C++中, parallel 的使用方法如下:
#pragma omp parallel [for | sections] [子句[子句]…]
{
//代码
}
int main(int argc, char *argv[]) {
#pragma omp parallel
{
printf("Hello, World!\n");
}
// 可以指定线程数
#pragma omp parallel num_threads(8)
{
printf("Hello, World!, ThreadId=%d\n", omp_get_thread_num() );
}
// 不指定的话就用默认的核数的线程 或者通过外部的export OMP_NUM_THREADS=1
#pragma omp parallel for
for (int j = 0; j < 20; j++) {
printf("j = % d, ThreadId = % d\n", j, omp_get_thread_num());
}
#pragma omp parallel
{
#pragma omp for
for (int j = 0; j < 4; j++ ){
printf("j = %d, ThreadId = %d\n", j, omp_get_thread_num());
}
}
}
section
section 语句是用在 sections 语句里用来将 sections 语句里的代码划分成几个不同的段,每段都并行执行。用法
int main() {
#pragma omp parallel sections {
#pragma omp section
printf("section 1 ThreadId = %d\n", omp_get_thread_num());
#pragma omp section
printf("section 2 ThreadId = %d\n", omp_get_thread_num());
#pragma omp section
printf("section 3 ThreadId = %d\n", omp_get_thread_num());
#pragma omp section
printf("section 4 ThreadId = %d\n", omp_get_thread_num());
}
}
执行后将打印出以下结果:
section 1 ThreadId = 0
section 2 ThreadId = 2
section 4 ThreadId = 3
section 3 ThreadId = 1
说明各个 section 里的代码都是并行执行的,并且各个 section被分配到不同的线程执行。
使用 section 语句时,需要注意的是这种方式需要保证各个 section 里的代码执行时间相差不大,否则某个 section执行时间比其他 section 过长就达不到并行执行的效果了。
#include <omp.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
#pragma omp parallel
{
#pragma omp sections
{
#pragma omp section
{
printf("section 1 ThreadId = %d\n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 2 ThreadId = %d\n", omp_get_thread_num());
}
}
#pragma omp sections
{
#pragma omp section
{
printf("section 3 ThreadId = %d\n", omp_get_thread_num());
}
#pragma omp section
{
printf("section 4 ThreadId = %d\n", omp_get_thread_num());
}
}
}
return 0;
}
这种方式和前面那种方式的区别是,两个 sections 语句是串行执行的,即第二个 sections 语句里的代码要等第一个 sections 语句里的代码执行完后才能执行。
用 for 语句来分摊是由系统自动进行,只要每次循环间没有时间上的差距,那么分摊是很均匀的,使用 section 来划分线程是一种手工划分线程的方式,最终并行性的好坏得依赖程序员。
OpenMP 中的数据处理子句
1 private 子句
private 子句用于将一个或多个变量声明成线程私有的变量,变量声明成私有变量后,指定每个线程都有它自己的变量私有副本,其他线程无法访问私有副本。即使在并行区域外有同名的共享变量,共享变量在并行区域内不起任何作用,并且并行区域内不会操作到外面的共享变量。
int k = 100;
#pragma omp parallel for private(k)
for (k = 0; k < 10; k++) {
printf("k=%d\n", k);
}
printf("last k=%d\n", k);
上面程序执行后打印的结果如下:
k=6 k=7 k=8 k=9 k=0 k=1 k=2 k=3 k=4 k=5
last k=100
仍打印结果可以看出, for 循环前的变量 k 和循环区域内的变量 k 其实是两个不同的变量。
用 private 子句声明的私有变量的初始值在并行区域的入口处是未定义的,它并不会继承同名共享变量的值。出现在 reduction 子句中的参数不能出现在 private 子句中。
对于最初的例子,如何在并行执行时,线程 2 知道它从 k = 5
开始,而不是从 0 或其他值开始。
可以理解为原来的for循环被拆了,根据线程数拆了。
for (k = 0; k < 10; k++) {
printf("k=%d\n", k);
}
// 被拆分为 线程1
for (k = 0; k < 5; k++) {
printf("k=%d\n", k);
}
// 线程 2
for (k = 5; k < 10; k++) {
printf("k=%d\n", k);
}
// 1.cpp:85:17: error: break statement used with OpenMP for loop
int k = 100;
#pragma omp parallel for private(k)
for (k = 0; k < 10; k++) {
if (k == 5) break;
printf("k=%d\n", k);
}
printf("last k=%d\n", k);
那如果在loop里有break,会怎么拆分呢,直接不拆分了,直接报错break statement used with OpenMP for loop。所以openmp适合固定迭代次数的for循环,循环变量只在循环条件中变化。
这个问题其实是关于 迭代变量(如 k
)和 OpenMP 的循环调度机制 的。
1 OpenMP 循环调度(Iteration Scheduling)
当你使用 #pragma omp parallel for
来并行化一个 for
循环时,OpenMP 会自动将循环的迭代分配给各个线程。具体的分配方式有很多种,取决于 OpenMP 实现和编译器的默认行为。常见的调度方式包括:
- 静态调度(static scheduling):OpenMP 将迭代平均分配给各个线程。
- 动态调度(dynamic scheduling):OpenMP 根据线程的空闲情况动态分配迭代任务给线程。
- 引导调度(guided scheduling):任务分配逐渐减小,较多的迭代分配给线程后期。
默认情况下,大多数 OpenMP 实现采用 静态调度,这意味着循环的迭代会均匀地分配给各个线程。
2 迭代任务分配
假设你有 10 次循环迭代,k
从 0 到 9,并且你有 2 个线程来执行它们:
c复制代码for (k = 0; k < 10; k++) {
printf("k=%d\n", k);
}
静态调度(默认)
如果使用静态调度,OpenMP 会把这 10 次迭代分配给 2 个线程,均匀地分配任务。具体来说:
- 线程 1 可能会得到
k = 0, 1, 2, 3, 4
这些迭代任务; - 线程 2 可能会得到
k = 5, 6, 7, 8, 9
这些迭代任务。
每个线程的 k
值会根据它们分配到的迭代任务而变化。例如,线程 2 从 k = 5
开始,然后按顺序执行 k = 6, 7, 8, 9
,而线程 1 从 k = 0
开始,依次执行 k = 1, 2, 3, 4
。
如何知道从 5 开始?
这个是 OpenMP 调度策略 的问题。在并行执行时,每个线程 不会直接操作全局的 k
值,它们只会操作自己所分配的迭代值。每个线程的私有 k
变量的初值实际上是从它所在线程分配的迭代的起始值开始的。
- 线程 1 会知道它负责从
k = 0
到k = 4
的迭代; - 线程 2 会知道它负责从
k = 5
到k = 9
的迭代。
这个分配和管理是由 OpenMP 内部自动完成的,用户不需要手动指定。OpenMP 会根据线程的数量、循环的迭代次数,以及调度策略来决定每个线程的迭代范围。
3 private(k)
的作用
使用 private(k)
后,每个线程都有自己独立的 k
变量副本,因此每个线程的 k
会从它自己负责的迭代的起始值开始(而不是主线程的 k
)。即:
- 线程 1 在并行区域开始时会有一个私有的
k
,它会从0
开始,执行k = 0, 1, 2, 3, 4
。 - 线程 2 在并行区域开始时会有一个私有的
k
,它会从5
开始,执行k = 5, 6, 7, 8, 9
。
线程 2 怎么知道它从 5 开始呢?这完全是因为 OpenMP 分配了它处理 k = 5
到 k = 9
这部分迭代。
4 线程之间的同步和迭代值
重要的一点是 每个线程只知道它分配的迭代范围,而不需要知道其他线程的迭代值。因为每个线程都有自己的私有 k
副本,所以它们之间不会互相影响。
break会被编译不通过,如果这样呢。
int main() {
int k = 100;
#pragma omp parallel for private(k)
for (k = 0; k < 10; k++) {
printf("k=%d\n", k++); // 增
}
printf("last k=%d\n", k);
}
这次循环次数会变少,是因为默认线程数是6,如果改多后,会得到原来的结果,每个线程都有它自己的变量私有副本,但是每个线程可能会跑多个循环,除非线程足够多。
k=0
k=2
k=8
k=4
k=6
k=9
last k=100。
所以openmp适合固定迭代次数的for循环,循环变量只在循环条件中变化,如果循环体中变化,会带来未知的结果。
2 firstprivate 子句
private 声明的私有变量不能继承同名变量的值,但实际情况中有时需要继承原有共享变量的值, OpenMP 提供了 firstprivate 子句来实现这个功能。确实继承了,且每个线程中的k的值是不共享的。
int k = 100;
#pragma omp parallel for firstprivate(k)
for (int i=0; i < 4; i++)
{
k+=i;
printf("k=%d\n",k);
}
printf("last k=%d\n", k);
k=100 k=101 k=103 k=102 last k=100
仍打印结果可以看出,并行区域内的私有变量 k 继承了外面共享变量 k 的值 100 作为初始值,并且在退出并行区域后,共享变量 k 的值保持为 100 未变。
修改OMP_NUM_THREADS环境变量,得到不同的运行结果,可知确实是每个线程都有自己变量副本继承主线程的值,而不是每个循环体有自己的变量副本。
root@j6e-ngx-a1:/app_new# ./my_program
k=100
k=103
k=101
k=102
last k=100
root@j6e-ngx-a1:/app_new# export OMP_NUM_THREADS=2
root@j6e-ngx-a1:/app_new# ./my_program
k=100
k=101
k=102
k=105
last k=100
root@j6e-ngx-a1:/app_new# export OMP_NUM_THREADS=1
root@j6e-ngx-a1:/app_new# ./my_program//k都在一个线程中
k=100
k=101
k=103
k=106
last k=100
3. lastprivate 子句
有时在并行区域内的私有变量的值经过计算后,在退出并行区域时,需要将它的值赋给同名的共享变量,前面的 private 和 firstprivate 子句在退出并行区域时都没有将私有变量的最后取值赋给对应的共享变量, lastprivate子句就是用来实现在退出并行区域时将私有变量的值赋给共享变量。
int k = 100;
#pragma omp parallel for firstprivate(k), lastprivate(k)
for (int i = 0; i < 4; i++) {
k += i;
printf("k=%d\n", k);
}
printf("last k=%d\n", k);
上面代码执行后的打印结果如下:
k=100 k=101 k=103 k=102
last k=103
打印结果可以看出,退出 for 循环的并行区域后,共享变量 k 的值变成了 103,而不是保持原来的 100 不变。
由于在并行区域内是多个线程并行执行的,最后到底是将那个线程的最终计算结果赋给了对应的共享变量呢? OpenMP 规范中指出,如果是循环迭代,那么是将最后一次循环迭代中的值赋给对应的共享变量;如果是 section构造,那么是最后一个 section 语句中的值赋给对应的共享变量。注意这里说的最后一个 section 是指程序语法上的最后一个,而不是实际运行时的最后一个运行完的。
如果是类( class)类型的变量使用在 lastprivate 参数中,那么使用时有些限制,需要一个可访问的,明确的缺省构造函数,除非变量也被使用作为 firstprivate 子句的参数;还需要一个拷贝赋值操作符, 并且这个拷贝赋值操作符对于不同对象的操作顺序是未指定的,依赖于编译器的定义。
4 threadprivate 子句
threadprivate 子句用来指定全局的对象被各个线程各自复制了一个私有的拷贝,即各个线程具有各自私有的全局对象。
int counter = 0; // 全局变量
#pragma omp threadprivate(counter)
void increment_counter() {
counter++; // 每个线程独立修改自己的 counter
}
int main() {
#pragma omp parallel
{
increment_counter();
printf("Thread %d: counter = %d\n", omp_get_thread_num(), counter);
}
printf("After parallel region: counter = %d\n", counter); // 还是全局变量
return 0;
}
root@j6e-ngx-a1:/app_new# export OMP_NUM_THREADS=20
root@j6e-ngx-a1:/app_new# ./my_program
Thread 5: counter = 1
Thread 6: counter = 1
Thread 2: counter = 1
Thread 7: counter = 1
Thread 8: counter = 1
Thread 4: counter = 1
Thread 1: counter = 1
Thread 3: counter = 1
Thread 9: counter = 1
Thread 10: counter = 1
Thread 11: counter = 1
Thread 12: counter = 1
Thread 13: counter = 1
Thread 14: counter = 1
Thread 15: counter = 1
Thread 16: counter = 1
Thread 17: counter = 1
Thread 18: counter = 1
Thread 19: counter = 1
Thread 0: counter = 1
After parallel region: counter = 1 // 主线程会参与并行计算
主线程是否作为工作线程:在 OpenMP 中,主线程会参与并行计算,因此它的编号也是 0
。虽然它是主线程,但在并行区域内,它并不特殊,它会像其他工作线程一样执行并行代码。所以主线程编号为 0
,也参与并行计算,通常可以在 omp parallel
块内进行相同的操作。
counter
变量是全局变量,通过 #pragma omp threadprivate(counter)
使得它在每个线程中有自己的副本。
在并行区域中,每个线程修改自己独立的 counter
副本。
在并行区域外,counter
仍然是一个全局变量。
threadprivate 和 private 的区别在于 threadprivate 声明的变量通常是全局范围内有效的,而 private 声明的变量只在它所属的并行构造中有效。
threadprivate可以让全局/局部变量变得在并行区域内线程局部持有,thread_local
可以用于 局部变量 和 全局变量,只要该变量被声明为 thread_local,它在每个线程中都是独立的,作用域是全局。
threadprivate 的对应只能用于 copyin, copyprivate, schedule, num_threads 和 if 子句中,不能用于任何其他子句中。
用作 threadprivate 的变量的地址不能是常数。
对于 C++的类( class)类型变量,用作 threadprivate 的参数时有些限制,当定义时带有外部初始化时,必须具有明确的拷贝构造函数。
对于 windows 系统, threadprivate 不能用于动态装载(使用 LoadLibrary 装载)的 DLL 中,可以用于静态装载的 DLL 中,关于 windows 系统中的更多限制。有关 threadprivate 命令的更多限制方面的信息,详情请参阅 OpenMP2.5 规范。
5 shared 子句
shared 子句用来声明一个戒多个变量是共享变量。
shared(list)
需要注意的是,在并行区域内使用共享变量时,如果存在写操作,必须对共享变量加以保护,否则不要轻易使用共享变量,尽量将共享变量的访问转化为私有变量的访问。
循环迭代变量在循环构造区域里是私有的。声明在循环构造区域内的自动变量都是私有的。
6 default 子句
default 子句用来允许用户控制并行区域中变量的共享属性。用法如下:
default(shared | none)
使用 shared 时,缺省情况下,传入并行区域内的同名变量被当作共享变量来处理,不会产生线程私有副本,除非使用 private 等子句来指定某些变量为私有的才会产生副本。
如果使用 none 作为参数,那么线程中用到的变量必须显示指定是共享的还是私有的,除了那些由明确定义的除外。
int counter = 0;
#pragma omp parallel default(none) shared(counter)
{
counter++; // 必须显式指定 'counter' 为共享变量
}
7 reduction 子句
reduction(operator : list)
reduction 子句主要用来对一个或多个参数条目指定一个操作符,每个线程将创建参数条目的一个私有拷贝,在区域的结束处,将用私有拷贝的值通过指定的运行符运算,原始的参数条目被运算结果的值更新。
reduction 操作中各种操作符号对应拷贝变量的缺省初始值
Operator | Initialization value |
---|---|
+ | 0 |
* | 1 |
- | 0 |
& | ~0 |
| | 0 |
^ | 0 |
&& | 1 |
|| | 0 |
int main() {
int i, sum = 100;
#pragma omp parallel for reduction(+: sum)
for ( i = 0; i <= 10; i++ )
{
sum += i; // 每个线程都有自己的sum,并行区域的结束时,所有的sum加和赋值到主线程的sum
}
printf( "sum = %d\n", sum);
}
注意,如果在并行区域内不加锁保护就直接对共享变量进行写操作,存在数据竞争问题,会导致不可预测的异常结果。共享数据作为 private、 firstprivate、 lastprivate、 threadprivate、 reduction 子句的参数进入并行区域后,就变成线程私有了,不需要加锁保护了。
8 copyin 子句
copyin 子句用来将主线程中 threadprivate 变量的值拷贝到执行并行区域的各个线程的 threadprivate 变量中,便于线程可以访问主线程中的变量值,
用法如下:
copyin(list)
copyin 中的参数必须被声明成 threadprivate 的,对于类类型的变量,必须带有明确的拷贝赋值操作符。(否则报错:‘counter’ must be ‘threadprivate’ for ‘copyin’)
对于前面 threadprivate 中讲过的计数器函数,如果多个线程使用时,各个线程都需要对全局变量 counter 的副本进行初始化,可以使用 copyin 子句来实现,示例代码如下:
#include <stdio.h>
#include <omp.h>
int counter = 0; // 全局变量
#pragma omp threadprivate(counter)
long increment_counter() {
counter++; // 每个线程独立修改自己的 counter
return counter;
}
int main() {
int iterator;
#pragma omp parallel sections copyin(counter) // 并行区域,copyin:复制主线程的 counter 值
{
#pragma omp section
{
int count1 = 0;
for (iterator = 0; iterator < 100; iterator++) {
count1 = increment_counter();
}
printf("count1 = %d\n", count1); // 输出第一个区域的计数
}
#pragma omp section
{
int count2 = 0;
for (iterator = 0; iterator < 200; iterator++) {
count2 = increment_counter();
}
printf("count2 = %d\n", count2); // 输出第二个区域的计数
}
}
printf("counter = %d\n", counter); // 输出主线程的共享计数器
}
count2 = 200
count1 = 100
counter = 0 主线程的值不变,因为section中操作的都是counter的副本
9 copyprivate 子句
copyprivate 子句提供了一种机制用一个私有变量将一个值从一个线程广播到执行同一并行区域的其他线程。用法如下:
copyprivate(list)
#include <omp.h>
#include <stdio.h>
int counter = 0;
#pragma omp threadprivate(counter) // 使 'counter' 变量在每个线程中为私有
int increment_counter() {
counter++; // 增加线程私有的 counter
return (counter); // 返回增加后的值
}
int main() {
#pragma omp parallel // 启动并行区域
{
int count; // 声明局部变量 'count'
#pragma omp single copyprivate(counter) // 只有一个线程执行此部分,且将'counter' 从主线程复制到其他线程
{
counter = 50; // 在单个线程中设置 'counter' 值为 50
}
count = increment_counter(); // 调用函数并更新 'counter'
printf("ThreadId: %ld, count = %ld\n", omp_get_thread_num(),
count); // 输出线程 ID 和 count 值
}
}
root@j6e-ngx-a1:/app_new# ./my_program
ThreadId: 0, count = 51
ThreadId: 1, count = 51
ThreadId: 4, count = 51
ThreadId: 5, count = 51
ThreadId: 3, count = 51
ThreadId: 2, count = 51
如果没有使用 copyprivate 子句,那么打印结果为:
ThreadId: 2, count = 1
ThreadId: 1, count = 1
ThreadId: 0, count = 51
ThreadId: 3, count = 1
仍打印结果可以看出,使用 copyprivate 子句后, single 构造内给 counter 赋的值被广播到了其他线程里,但没有使用 copyprivate 子句时,叧有一个线程获得了 single 构造内的赋值,其他线程没有获取 single 构造内的赋值。
OpenMP 中的任务调度
OpenMP 中,任务调度主要用亍并行的 for 循环中,当循环中每次迭代的计算量不相等时,如果简单地给各个线程分配相同次数的迭代的话,会造成各个线程计算负载不均衡,这会使得有些线程先执行完,有些后执行完,造成某些 CPU 核空闲,影响程序性能。
int i, j;
int a[100][100] = {0};
for ( i =0; i < 100; i++)
{
for( j = i; j < 100; j++ )
{
a[i][j] = i*j;
}
}
如果将最外层循环并行化的话,比如使用 4 个线程,如果给每个线程平均分配 25 次循环迭代计算的话,显然i= 0 和 i= 99 的计算量相差了 100 倍,那么各个线程间可能出现较大的负载不平衡情况。为了解决这些问题, OpenMP 中提供了几种对 for 循环并行化的任务调度方案。
在 OpenMP 中,对 for 循环并行化的任务调度使用 schedule 子句来实现
1 Schedule 子句用法
schedule(type[,size])
schedule 有两个参数: type 和 size, size 参数是可选的。
type 参数表示调度类型,有四种调度类型如下:
· dynamic · guided · runtime · static
这四种调度类型实际上只有 static、dynamic、 guided 三种调度方式, runtime 实际上是根据环境变量来选择前三种中的某中类型。
size 参数 (可选) size 参数表示循环迭代次数, size 参数必须是整数。 static、 dynamic、 guided 三种调度方式都可以使用 size参数,也可以不使用 size 参数。当 type 参数类型为 runtime 时, size 参数是非法的(不需要使用,如果使用的话编译器会报错)。
2 静态调度(static)
当 parallel for 编译指导语句没有带 schedule 子句时,大部分系统中默认采用 static 调度方式,这种调度方式非常简单。假设有 n 次循环迭代, t 个线程,那么给每个线程静态分配大约 n/t 次迭代计算。这里为什么说大约分配 n/t 次呢?因为 n/t 不一定是整数,因此实际分配的迭代次数可能存在差 1 的情况,如果指定了 size 参数的话,那么可能相差一个 size。
静态调度时可以不使用 size 参数,也可以使用 size 参数。
#pragma omp parallel for schedule(static)
for(i = 0; i < 10; i++ )
{
printf("i=%d, thread_id=%d\n", i, omp_get_thread_num());
}
i=0, thread_id=0
i=1, thread_id=0
i=2, thread_id=0
i=3, thread_id=0
i=4, thread_id=0
i=5, thread_id=1
i=6, thread_id=1
i=7, thread_id=1
i=8, thread_id=1
i=9, thread_id=1
// 可以看出线程 0 得到了 0~ 4 次连续迭代,线程 1 得到 5~ 9 次连续迭代。
使用 size 参数时,分配给每个线程的 size 次连续的迭代计算,用法如下:
schedule(static, size)
#pragma omp parallel for schedule(static, 2)
for(i = 0; i < 10; i++ )
{
printf("i=%d, thread_id=%d\n", i, omp_get_thread_num());
}
i=0, thread_id=0
i=1, thread_id=0
i=4, thread_id=0
i=5, thread_id=0
i=8, thread_id=0
i=9, thread_id=0
i=2, thread_id=1
i=3, thread_id=1
i=6, thread_id=1
i=7, thread_id=1
// 0、1 次迭代分配给线程 0, 2、3 次迭代分配给线程 1,
// 4、5 次迭代分配给线程 0, 6、7 次迭代分配给线程 1, …。
// 每个线程依次分配到 2 次连续的迭代计算
3 动态调度(dynamic)
动态调度是动态地将迭代分配到各个线程,动态调度可以使用 size 参数也可以不使用 size 参数,不使用 size参数时是将迭代逐个地分配到各个线程,使用 size 参数时,每次分配给线程的迭代次数为指定的 size 次。
线程会先执行自己分配的任务,执行完成后会向调度器请求新的任务,直到所有任务完成。
行为:
- 在并行区域开始时,OpenMP 不会预先决定每个线程的任务,而是根据线程空闲的情况动态分配任务。
- 每个线程完成自己任务后,会请求新的任务,直到没有任务可以执行。
- 这种方式适用于任务的工作量不均匀的情况,确保负载均衡。
#pragma omp parallel for schedule(dynamic)
for(i = 0; i < 10; i++ )
{
printf("i=%d, thread_id=%d\n", i, omp_get_thread_num());
}
这里的size2,就是每次执行完任务后,可以再去取多少个任务。取后执行完毕再来取。
#pragma omp parallel for schedule(dynamic, 2)
for(i = d0; i < 10; i++ )
{
printf("i=%d, thread_id=%d\n", i, omp_get_thread_num());
}
i=0, thread_id=0
i=1, thread_id=0
i=4, thread_id=0
i=2, thread_id=1
i=5, thread_id=0
i=3, thread_id=1
i=6, thread_id=0
i=8, thread_id=1
i=7, thread_id=0
i=9, thread_id=1
// 打印结果可以看出第 0、 1, 4、 5, 6、 7 次迭代被分配给了线程 0,第 2、 3, 8、 9 次迭代则分配给了线程1,每次分配的迭代次数为 2。
4 guided 调度( guided)
guided 调度是一种采用指导性的启发式自调度方法。开始时每个线程会分配到较大的迭代块,之后分配到的迭代块会逐渐递减。迭代块的大小会按指数级下降到指定的 size 大小,如果没有指定 size 参数,那么迭代块大小最小会降到 1。
第 0、 1、 2、 3、 4 次迭代被分配给线程 0,
第 5、 6、 7 次迭代被分配给线程 1,
第 8、 9 次迭代被分配给线程0,
分配的迭代次数呈递减趋势,最后一次递减到 2 次。
5 runtime 调度( rumtime)
runtime 调度并不是和前面三种调度方式似的真实调度方式,它是在运行时根据环境变量 OMP_SCHEDULE来确定调度类型,最终使用的调度类型仍然是上述三种调度方式中的某种。
例如在 unix 系统中,可以使用 setenv 命令来设置 OMP_SCHEDULE 环境变量: setenv OMP_SCHEDULE “dynamic, 2”
上述命令设置调度类型为动态调度,动态调度的迭代次数为 2
6 *动态设置并行循环的线程数量
num_threads中的结果也可以是计算得来的。
const int g_ncore = omp_get_num_procs(); //获取执行核的数量
// 获取合适的线程数量
int dtn(int n, int min_n)
{
int max_tn = n / min_n; // 计算最大线程数量
int tn = max_tn > g_ncore ? g_ncore : max_tn; // 选择最大线程数与核心数的较小值
if (tn < 1)
{
tn = 1; // 保证线程数至少为 1
}
return tn;
}
// 并行化循环
#pragma omp parallel for num_threads(dtn(n, MIN_ITERATOR_NUM))
for (int i = 0; i < n; i++)
{
printf("Thread Id = %ld\n", omp_get_thread_num());
// Do some work here
}
#include <omp.h>
#include <cstdio>
#define MIN_ITERATOR_NUM 10 // 每个线程最小任务量
int g_ncore = 4; // 假设机器有 4 个核心
// 动态计算线程数
int dtn(int n, int min_n)
{
int max_tn = n / min_n; // 最大线程数(根据任务量计算)
int tn = max_tn > g_ncore ? g_ncore : max_tn; // 最大线程数与核心数取较小值
return tn < 1 ? 1 : tn; // 保证线程数至少为 1
}
int main()
{
for (int iter = 0; iter < 5; iter++) // 模拟多次任务,n 动态变化
{
int n = (iter + 1) * 50; // 假设 n 随迭代次数增长
printf("Iteration %d, n = %d\n", iter, n);
#pragma omp parallel for num_threads(dtn(n, MIN_ITERATOR_NUM))
for (int i = 0; i < n; i++)
{
printf("Thread Id = %d processing i = %d\n", omp_get_thread_num(), i);
}
printf("\n");
}
return 0;
}
dtn
逻辑支持 n
动态变化。
每次调用 dtn
时,线程数会动态调整,确保性能和资源利用率。
只需确保在并行化循环前调用 dtn
,即可实现动态线程分配。