OpenMP 入门教程
前两天(其实是几个月以前了)看到了代码中有 #pragma omp parallel for
一段,感觉好像是 OpenMP,以前看到并行化的东西都是直接躲开,既然躲不开了,不妨研究一下:
OpenMP 是 Open MultiProcessing 的缩写。OpenMP 并不是一个简单的函数库,而是一个诸多编译器支持的框架,或者说是协议吧,总之,不需要任何配置,你就可以在 Visual Studio 或者 gcc 中使用它了。
我们就分三部分来介绍吧,因为我看的那个英文教程就是分了三部分(哈哈) . 以下翻译自英特尔的文档
Hello World
把下面的代码保存为 omp.cc
#include
#include
int main()
{
#pragma omp parallel for
for (char i = 'a'; i <= 'z'; i++)
std::cout << i << std::endl;
return 0;
}
然后 g++ omp.cc -fopenmp
就可以了
入门
循环的并行化
OpenMP的设计们希望提供一种简单的方式让程序员不需要懂得创建和销毁线程就能写出多线程化程序。为此他们设计了一些pragma,指令和函数来让编译器能够在合适的地方插入线程大多数的循环只需要在for之前插入一个pragma就可以实现并行化。而且,通过把这些恼人的细节都丢给编译器,你可以花费更多的时间来决定哪里需要多线程和优化数据结构
下面个这个例子把32位的RGB颜色转换成8位的灰度数据,你只需要在for之前加上一句pragma就可以实现并行化了
#pragma omp parallel for
for (int i = 0; i < pixelCount; i++) {
grayBitmap[i] = (uint8_t)(rgbBitmap[i].r * 0.229 +
rgbBitmap[i].g * 0.587 +
rgbBitmap[i].b * 0.114);
}
神奇吧,首先,这个例子使用了“work sharing”,当“work sharing”被用在for循环的时候,每个循环都被分配到了不同的线程,并且保证只执行一次。OpenMP决定了多少线程需要被打开,销毁和创建,你需要做的就是告诉OpenMP哪里需要被线程化。
OpenMP 对可以多线程化的循环有如下五个要求:
- 循环的变量变量(就是i)必须是有符号整形,其他的都不行。
- 循环的比较条件必须是< <= > >=中的一种
- 循环的增量部分必须是增减一个不变的值(即每次循环是不变的)。
- 如果比较符号是< <=,那每次循环i应该增加,反之应该减小
- 循环必须是没有奇奇怪怪的东西,不能从内部循环跳到外部循环,goto和break只能在循环内部跳转,异常必须在循环内部被捕获。
如果你的循环不符合这些条件,那就只好改写了
检测是否支持 OpenMP
#ifndef _OPENMP
fprintf(stderr, "OpenMP not supported");
#endif
避免数据依赖和竞争
当一个循环满足以上五个条件时,依然可能因为数据依赖而不能够合理的并行化。当两个不同的迭代之间的数据存在依赖关系时,就会发生这种情况。
// 假设数组已经初始化为1
#pragma omp parallel for
for (int i = 2; i < 10; i++) {
factorial[i] = i * factorial[i-1];
}
编译器会把这个循环多线程化,但是并不能实现我们想要的加速效果,得出的数组含有错误的结构。因为每次迭代都依赖于另一个不同的迭代,这被称之为竞态条件。要解决这个问题只能够重写循环或者选择不同的算法。
竞态条件很难被检测到,因为也有可能恰好程序是按你想要的顺序执行的。
管理公有和私有数据
基本上每个循环都会读写数据,确定那个数据时线程之间共有的,那些数据时线程私有的就是程序员的责任了。当数据被设置为公有的时候,所有的线程访问的都是相同的内存地址,当数据被设为私有的时候,每个线程都有自己的一份拷贝。默认情况下,除了循环变量以外,所有数据都被设定为公有的。可以通过以下两种方法把变量设置为私有的:
- 在循环内部声明变量,注意不要是static的
- 通过OpenMP指令声明私有变量
// 下面这个例子是错误的
int temp; // 在循环之外声明
#pragma omp parallel for
for (int i = 0; i < 100; i++) {
temp = array[i];
array[i] = doSomething(temp);
}
可以通过以下两种方法改正
// 1. 在循环内部声明变量
#pragma omp parallel for
for (int i = 0; i < 100; i++) {
int temp = array[i];
array[i] = doSomething(temp);
}
// 2. 通过OpenMP指令说明私有变量
int temp;
#pragma omp parallel for private(temp)
for (int i = 0; i < 100; i++) {
temp = array[i];
array[i] = doSomething(temp);
}
Reductions
一种常见的循环就是累加变量,对此,OpenMP 有专门的语句
例如下面的程序:
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += array[i]; // sum需要私有才能实现并行化,但是又必须是公有的才能产生正确结果
}
上面的这个程序里,sum公有或者私有都不对,为了解决这个问题,OpenMP 提供了reduction语句;
int sum = 0;
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < 100; i++) {
sum += array[i];
}
内部实现中,OpenMP 为每个线程提供了私有的sum变量,当线程退出时,OpenMP 再把每个线程的部分和加在一起得到最终结果。
当然,OpenMP 不止能做累加,凡是累计运算都是可以的,如下表:
操作 | 私有临时变量初值 |
---|---|
+、- | 0 |
- |1
& |~0
|
|0
^|0
&&|1(true)
||
|0(false
循环调度
负载均衡是多线程程序中对性能影响最大的因素了,只有实现了负载均衡才能保证所有的核心都是忙的,而不会出现空闲时间。如果没有负载均衡, 有一些线程会远远早于其他线程结束, 导致处理器空闲浪费优化的可能.
在循环中,经常会由于每次迭代的相差时间较大和破坏负载平衡。通常可以通过检查源码来发现循环的变动可能. 大多数情况下每次迭代可能会发现大概一致的时间,当这个条件不能满足的时候,你可能能找到一个花费了大概一致时间的子集。例如, 有时候所有偶数循环花费了和所有奇数循环一样的时间, 有时候可能前一半循环和后一半循环花费了相似的时间. 另一方面, 有时候你可能找不到花费相同时间的一组循环. 不论如何, 你应该把这些信息提供给 OpenMP, 这样才能让 OpenMP 有更好的机会去优化循环.
默认情况下,OpenMP认为所有的循环迭代运行的时间都是一样的,这就导致了OpenMP会把不同的迭代等分到不同的核心上,并且让他们分布的尽可能减小内存访问冲突,这样做是因为循环一般会线性地访问内存, 所以把循环按照前一半后一半的方法分配可以最大程度的减少冲突. 然而对内存访问来说这可能是最好的方法, 但是对于负载均衡可能并不是最好的方法, 而且反过来最好的负载均衡可能也会破坏内存访问. 因此必须折衷考虑.
OpenMP 负载均衡使用下面的语法
#pragma omp parallel for schedule(kind [, chunk size])
其中kind可以是下面的这些类型, 而 chunk size 则必须是循环不变的正整数
例子
#pragma omp parallel for
for (int i = 0; i < numElements; i++) {
array[i] = initValue;
initValue++;
}
显然这个循环里就有了竞态条件, 每个循环都依赖于 initValue 这个变量, 我们需要去掉它.
#pragma omp parallel for
for (int i = 0; i < numElements; i++) {
array[i] = initValue + i;
}
这样就可以了, 因为现在我们没有让 initValue 去被依赖
所以, 对于一个循环来说, 应该尽可能地把 loop-variant 变量建立在 i 上.
待续...