OpenMP学习 第五章 并行化循环
第五章 并行化循环
共享工作循环构造
循环级并行: 将一定规模的涉及循环的问题转换为SPMD模式的并行.
共享工作循环构造: 在一个线程组中拆分循环迭代的指令.
- 使用共享工作循环构造的结构:
#pragma omp for
//for loop
在实际使用过程中,下面的模式是常常可见的:一个用来创建线程组的构造,一个用来分割线程之间循环迭代的构造.
- 单独式并行共享工作循环构造:
#pragma omp parallel
{
#pragma omp for
//for loop
}
为方便,二者可以结合:
- 组合式并行共享工作循环构造:
#pragma omp parallel for
//for loop
归约
循环依赖性: 在任何给定循环的迭代中计算的值都依赖于前面迭代产生的值.使用共享工作循环构造无法解决这种依赖性.
考虑下面这样的情景:
double ave = 0.0;
double A[N];
init(A,N);
for(int i=0;i<N;i++)
ave += A[i];
ave = ave/N
归约: 存在循环依赖性的形似SPMD结构的情景.
为了解决归约情况中的循环依赖性,提供一个reduction字句对其进行处理.
- 使用reduction字句解决循环依赖性:
#pragma omp parallel for reduction(op:list)
其中list为以逗号间隔的变量列表,op及其默认初始值见下表:
运算符 | 初始值 |
---|---|
+ | 0 |
* | 1 |
- | 0 |
min | 最大正数 |
max | 最大负数 |
对于列表中的每一个变量(归约变量),系统将为每个线程创建一个同名的私有变量,每个线程将其是有变量的副本初始化为字句中的指示符(op)的隐含初始值.
在线程完成构造并且退出栅栏之前,利用归约字句中的op将每个线程的归约变量的本地副本合并在一起产生最终的归约值,然后利用op将其与原始变量合并,产生最终结果.
下面是一个实例,其通过reduction字句进行归约,解决了n阶乘的计算问题:
#include <iostream>
#include <omp.h>
import <format>;
int main()
{
long long total = 1;
int n;
std::cin >> n;
double begin_time;
begin_time = omp_get_wtime();
omp_set_num_threads(10);
#pragma omp parallel for reduction(*:total)
for (int i = 1; i <= n; i++) {
total *= i;
int id = omp_get_thread_num();
#pragma omp critical
std::cout << std::format("the {} thread now over.i is {}",
id,
i
) << std::endl;
}
double run_time;
run_time = omp_get_wtime() - begin_time;
std::cout << std::format("the total is {},run_time is {:.15f}",
total,
run_time
) << std::endl;
return 0;
}
需要注意的,由于浮点运算不算是严格意义上的结合性运算,因而其归约结果可能会因为程序的不同运行而不同.
循环调度
在共享工作循环构造的基础上,为了实现循环调度,添加了一个schedule子句
- 使用schedule子句进行静态调度:
#pragma omp for schedule(static[,chunk])
- 使用schedule子句进行动态调度:
#pragma omp for schedule(dynamic[,chunk])
可选的分块(chunk)大小定义了构成调度的基本单元的循环迭代次数.分块大小可以是一个在编译时已知的值,也可以是一个在运行时计算的含有共享变量的整数表达式.
静态调度: 在共享工作循环构造的基础上,在"编译"时将循环迭代映射到线程上.
当没有提供分块大小时,编译器会将循环迭代分解为与线程总数相等数量的分块,
当提供了分块大小,OpenMP将把循环分成连续的迭代分块,以轮询调度的方式分配给每个线程.
通常而言,静态调度中,最佳分块大小需要通过一系列的尝试才能得到.
动态调度: 当循环迭代的运行时间大致相同时,静态调度可以很好地适应这种情况.当循环迭代具有可预测的运行时间时,它也很有用.
使用OpenMP的挑战之一是均衡各线程的负载.dynamic调度提供了自动负载均衡,然而,其运行时调度开销比使用static调度观察到的开销要高得多.
需要注意,schedule(static,1)实际上是循环迭代的周期分配,schedule(static)则为块状分配.
通常而言,共享工作构造在构造结束时有一个隐式栅栏,如果可以确定在一个共享工作构造的结尾不需要栅栏,那么需要一种方法来禁用它.
对于共享工作循环构造来说,可以通过nowait子句实现.
#pragma ompr for nowait
事实上,在某些合适的情景(可以禁用隐式栅栏)中对共享工作构造使用nowait子句可以为其带来不小的性能提升,下面通过一个实验说明:
#include <iostream>
#include <vector>
#include <omp.h>
import <format>;
#define N 1000
#define TURNS 10000
int arr[N][N];
int main()
{
double temp_time, run_time;
std::vector<double>time_1, time_2, time_3;
for (int iter = 0; iter < TURNS; iter++) {
temp_time = omp_get_wtime();
#pragma omp parallel
{
#pragma omp for
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
arr[i][j] = i * j;
}
run_time = omp_get_wtime() - temp_time;
time_1.push_back(run_time);
temp_time = omp_get_wtime();
#pragma omp parallel
{
#pragma omp for nowait
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
arr[i][j] = i * j;
}
run_time = omp_get_wtime() - temp_time;
time_2.push_back(run_time);
temp_time = omp_get_wtime();
#pragma omp parallel
{
#pragma omp for collapse(2)
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
arr[i][j] = i * j;
}
run_time = omp_get_wtime() - temp_time;
time_3.push_back(run_time);
}
double sum_1{ 0.0 }, sum_2{ 0.0 }, sum_3{ 0.0 };
for (auto iter : time_1)
sum_1 += iter;
for (auto iter : time_2)
sum_2 += iter;
for (auto iter : time_3)
sum_3 += iter;
std::cout << std::format("aver_1 is {:.15f}s, speedup {:.15f}%",
sum_1 / TURNS,
(sum_1 - sum_1) * 100.0 / sum_1
) << std::endl;
std::cout << std::format("aver_2 is {:.15f}s, speedup {:.15f}%",
sum_2 / TURNS,
(sum_1 - sum_2) * 100.0 / sum_1
) << std::endl;
std::cout << std::format("aver_3 is {:.15f}s, speedup {:.15f}%",
sum_3 / TURNS,
(sum_1 - sum_3) * 100.0 / sum_1
) << std::endl;
return 0;
}
带有并行循环共享工作的Pi程序
结合前面所学内容,我们现在开始考虑带有并行循环共享工作的Pi程序:
#include <iostream>
#include <fstream>
#include <omp.h>
import <format>;
#define TURNS 100
#define PI 3.141592653589793
long double num_steps = 1e8;
double step;
int main()
{
std::ofstream out;
out.open("example.csv", std::ios::ate);
out << "NTHREADS,pi,err,run_time,num_steps" << std::endl;
double sum = 0.0;
for (int NTHREADS = 1; NTHREADS < TURNS; NTHREADS++) {
double start_time, run_time;
double pi, err;
pi = sum = 0.0;
int actual_nthreads;
step = 1.0 / (double)num_steps;
omp_set_num_threads(NTHREADS);
start_time = omp_get_wtime();
actual_nthreads = omp_get_num_threads();
#pragma omp parallel
{
double x;
#pragma ompr for reduction(+:sum)
for (int i = 0; i < num_steps; i++) {
x = (i + 0.5) * step;
sum += 4.0 / (1.0 + x * x);
}
}//end of parallel
pi = step * sum;
err = pi - PI;
run_time = omp_get_wtime() - start_time;
std::cout << std::format("pi is {} in {} seconds {} thrds.step is {},err is {}",
pi,
run_time,
actual_nthreads,
step,
err
) << std::endl;
out << std::format("{},{:.15f},{:.15f},{:.15f},{}",
NTHREADS,
pi,
err,
run_time,
num_steps
) << std::endl;
}
out.close();
return 0;
}
不过经过一系列实验过后,我们发现如此设计的并行程序效率并不是非常理想.
一种循环级并行策略
- 首先,找到的计算密集型的循环,如此并行以抵消OpenMP调度的开销.
- 接着,检查这些循环是否能够并行执行,通过改造循环使其消除 循环携带依赖性.
下面给出了一个通过中性转换使得循环迭代独立的例子:
int i,j,A[MAX];
j=5;
for(i =0;i<MAX;i++){
j+=2;
A[i]=big(j);
}
//存在循环携带依赖性的情况
int i,A[MAX];
#pragma omp parallel for
for(i=0;i<MAX;i++){
int j = 5+2*(i+1);//消除携带依赖性的关键
A[i]=big(j);
}
//消除了循环携带依赖性
- 最后,应使用不同数量的线程和循环调度来优化程序.
为了分析程序并行情况,下面给出了一些OpenMP概要分析工具相关的链接:
官网: https://www.openmp.org/resources/openmp-compilers-tools/#tools
- scalasca: https://www.scalasca.org/
- HPCToolkit: http://hpctoolkit.org/
- vampir: https://vampir.eu/