OPENMP学习笔记(3)——命令
工作共享构造
Do/For | SECTIONS | SINGLE |
---|---|---|
在组线程成员之间分享循环迭代。这种构造代表“数据并行” | 分解为独立部分,每个部分由一个线程执行,代表“功能并行” | 将一段代码串行执行。该部分的所有代码都由一个线程完成 |
paralell
parallel表示其后语句将被多个线程并行执行,“#pragma omp parallel”后面的语句(或者,语句块)被称为parallel region。
多个线程的执行顺序是不能保证的。
parallel 是用来构造一个并行块的,也可以使用其他指令如for、sections等和它配合使用。parallel指令是用来为一段代码创建多个线程来执行它的。parallel块中的每行代码都被多个线程重复执行。和传统的创建线程函数比起来,相当于为一个线程入口函数重复调用创建线程函数来创建线程并等待线程执行完
for
对一个计算量庞大的任务进行划分,让多个线程分别执行计算任务的某一部分,从而达到缩短计算时间的目的。关键是,每个线程执行的计算互不相同(操作的数据不同或者计算任务本身不同),多个线程协作完成所有计算。
OpenMP for指示将C++ for循环的多次迭代划分给多个线程(划分指,每个线程执行的迭代互不重复,所有线程的迭代并起来正好是C++ for循环的所有迭代),这里C++ for循环需要一些限制从而能在执行C++ for之前确定循环次数,例如C++ for中不应含有break等。
#pragma omp parallel for
for(...)
#pragma omp parallel
{//注意:大括号必须要另起一行
#pragma omp for
for(...)
}
第一种形式作用域只是紧跟着的那个for循环,而第二种形式在整个并行块中可以出现多个for制导指令。
第二种形式中并行块里面不要再出现parallel制导指令。
#pragma omp for [clause ...] newline
schedule (type [,chunk])
ordered
private (list)
firstprivate (list)
lastprivate (list)
shared (list)
reduction (operator: list)
collapse (n)
nowait
for_loop
task
当处理多重for循环时,如果是OpenMP3.0及以后的版本,则已经支持task,能够较有效的解决不规则循环和递归函数调用等问题。
基本思路为:创建一个线程组,主线程负责创建task,负线程负责执行task。
#pragma omp parallel
{
#pragma omp single
{
for (i = 0;i < N; ++i)
{
for (j = 0; j < M; ++j)
{
#pragma omp task
{
//计算部分
}
}
}
}
}
schedule
schedule的使用格式为:
schedule(type[,size])
schedule有两个参数:type和size,size参数是可选的。
type参数
表示调度类型,有四种调度类型如下:
(1)dynamic
(2)guided
(3)runtime
(4)static
这四种调度类型实际上只有static、dynamic、guided三种调度方式,runtime实际上是根据环境变量来选择前三种中的某中类型。
run-sched-var
size参数 (可选)
size参数表示循环迭代次数,size参数必须是整数。static、dynamic、guided三种调度方式都可以使用size参数,也可以不使用size参数。当type参数类型为runtime时,size参数是非法的(不需要使用,如果使用的话编译器会报错)。
静态调度static
静态调度(static)
当parallel for编译指导语句没有带schedule子句时,大部分系统中默认采用static调度方式,这种调度方式非常简单。假设有n次循环迭代,t个线程,那么给每个线程静态分配大约n/t次迭代计算。因为n/t不一定是整数,因此实际分配的迭代次数可能存在差1的情况,如果指定了size参数的话,那么可能相差一个size。
静态调度时可以不使用size参数,也可以使用size参数。
对于schedule(static,size)的含义,OpenMP会给每个线程分配size次迭代计算。这个分配是静态的,“静态”体现在这个分配过程跟实际的运行是无关的,可以从逻辑上推断出哪几次迭代会在哪几个线程上运行。具体而言,对于一个N次迭代,使用M个线程,那么,[0,size-1]的size次的迭代是在第一个线程上运行,[size, size + size -1]是在第二个线程上运行,依次类推。那么,如果M太大,size也很大,就可能出现很多个迭代在一个线程上运行,而某些线程不执行任何迭代。需要说明的是,这个分配过程就是这样确定的,不会因为运行的情况改变,比如,我们知道,进入OpenMP后,假设有M个线程,这M个线程开始执行的时间不一定是一样的,这是由OpenMP去调度的,并不会因为某一个线程先被启动,而去改变for的迭代的分配,这就是静态的含义。
不使用size参数时,分配给每个线程的是n/t次连续的迭代,不使用size参数的用法如下:
#include <iostream>
#include <omp.h>
void main()
{
#pragma omp parallel for schedule(static)
for (int i = 0; i < 24; i++)
{
printf("i=%d, thread NO=%d\n", i, omp_get_thread_num());
}
getchar();
}
12核,平均每个刚好2次,因为设置24
使用size参数时,分配给每个线程的size次连续的迭代计算,每次指定运行n次
#pragma omp parallel for schedule(static,6)
从结果可以看到,无论是哪一个线程先启动,team内的ID为0的线程,总是会执行0,1,2,3,4,5,6对应的迭代,对于这里的情况,12个线程,却只使用了4个线程去计算,所以这样分配当然是不平衡的。
上面是针对给定size的情况,如果不指定size,只是指定static类型,那么OpenMP为使用迭代数/线程数作为size的值,采取同样的策略来进行分配,这样每个线程执行的迭代数目就是一样的(注意,如果迭代数/线程数不是整除的,那就不完全一样了,但是整体是比较平衡的),一般而言,这就是不加任何schedule修饰下的调度情况了。
动态调度(dynamic)
动态调度是动态地将迭代分配到各个线程,迭代的分配是依赖于运行状态进行动态确定的,所以哪个线程上将会运行哪些迭代是无法像静态一样事先预料的。
对于dynamic,没有size参数的情况下,每个线程按先执行完先分配的方式执行1次循环,比如,刚开始,线程1先启动,那么会为线程1分配一次循环开始去执行(i=0的迭代),然后,可能线程2启动了,那么为线程2分配一次循环去执行(i=1的迭代),假设这时候线程0和线程3没有启动,而线程1的迭代已经执行完,可能会继续为线程1分配一次迭代,如果线程0或3先启动了,可能会为之分配一次迭代,直到把所有的迭代分配完。所以,动态分配的结果是无法事先知道的,因为我们无法知道哪一个线程会先启动,哪一个线程执行某一个迭代需要多久等等,这些都是取决于系统的资源、线程的调度等等。
下面为使用动态调度不带size参数的例子:
#pragma omp parallel for schedule(dynamic)
分析,为什么线程2先执行完,其对应的迭代却是2而不是0呢?不是先执行完的先分配么?我的理解是,这里,刚开始的时候,我们不知道线程的状态,实际的一种可能是,线程0先启动了,所以会去执行迭代0里面的内容,但是,只是开始去执行,而这里用printf测试,是输出的结果,输出的顺序不代表开始执行此迭代的顺序,所以可能的情况是,线程0执行迭代0还没有完成的时候,线程1空闲,为线程1分配了迭代1,然后线程1一直运行迭代1的时候,线程0仍然在运行迭代0,这中间,线程2可能会分配了迭代2开始执行,线程1又获得了分配机会,被分配了迭代3等等.总之,这里的输出顺序是不代表每一个迭代开始被分配执行的时间顺序的。总之,理解这个动态的过程,简单理解,就是谁有空,给谁分配一次迭代让它去跑!
比如标出来的线程8,共运行了3次,每个线程的运行次数是不确定的。
那么同样,dynamic也可以有一个size参数,size表示,每次线程执行完(空闲)的时候给其一次分配的迭代的数量,如果没有知道size(上面的分析),那么每次就分配一个迭代。有了前面的理解,这个size的含义是很容易理解的了。
#pragma omp parallel for schedule(dynamic, 3)
每3个作一次动态分配,3个之后看哪个线程空闲。
guided调度(guided)
guided调度是一种采用指导性的启发式自调度方法。类似于动态调度,但每次分配的循环次数不同,开始比较大,以后逐渐减小。size表示每次分配的迭代次数的最小值,由于每次分配的迭代次数会逐渐减少,较少到size时,将不再减少。如果不知道size的大小,那么默认size为1,即一直减少到1。
初始块的大小与:number_of_iteration / number_of_threads成正比
后续块与number_of_iterations_remaining / number_of_threads成比例
chunk参数定义最小块大小。默认块大小为1。
#pragma omp parallel for schedule(guided)
分配数量从2减少到1
在ID从12开始就是每次1,相当于(dynamic,1)
当N次数增加时,开始分配的数量会增加,自动设定开始时的数量
runtime调度(rumtime)
runtime调度并不是和前面三种调度方式似的真实调度方式,它是在运行时根据环境变量OMP_SCHEDULE来确定调度类型,最终使用的调度类型仍然是上述三种调度方式中的某种。
例如在unix系统中,可以使用setenv命令来设置OMP_SCHEDULE环境变量:
setenv OMP_SCHEDULE “dynamic, 2”
上述命令设置调度类型为动态调度,动态调度的迭代次数为2。
在windows环境中,可以在”系统属性|高级|环境变量”对话框中进行设置环境变量。
#pragma omp parallel for schedule(runtime)
三种运行方式总结:
静态调度static:每次哪些循环由那个线程执行时固定的,编译调试。由于每个线程的任务是固定的,但是可能有的循环任务执行快,有的慢,不能达到最优。
动态调度dynamic:根据线程的执行快慢,已经完成任务的线程会自动请求新的任务或者任务块,每次领取的任务块是固定的。
启发式调度guided:每个任务分配的任务是先大后小,指数下降。当有大量任务需要循环时,刚开始为线程分配大量任务,最后任务不多时,给每个线程少量任务,可以达到线程任务均衡。
NO WAIT / nowait
如果指定,那么线程在并行循环结束时不同步。
ORDERED
执行循环的迭代必须像在串行程序中一样执行。
COLLAPSE
指定嵌套循环中的循环应该被折叠成一个大的迭代空间,并根据SCHEDULE子句进行划分。所有关联循环中的迭代的执行顺序确定了折叠迭代空间中的迭代顺序。
综合示例:
#include <omp.h>
#define N 1000
#define CHUNKSIZE 100
main(int argc, char *argv[]) {
int i, chunk;
float a[N], b[N], c[N];
/* Some initializations */
for (i=0; i < N; i++)
a[i] = b[i] = i * 1.0;
chunk = CHUNKSIZE;
#pragma omp parallel shared(a,b,c,chunk) private(i)
{
#pragma omp for schedule(dynamic,chunk) nowait
for (i=0; i < N; i++)
c[i] = a[i] + b[i];
} /* end of parallel region */
}
sections & section
SECTIONS指令是一个非迭代的工作共享结构,它表明封闭的代码段将在组内线程之间划分。独立的SECTION指令被嵌套在SECTIONS指令内。每个SECTION由组内的一个线程执行一次,不同的SECTION部分可能会由不同的线程来执行。如果某个线程执行的足够快并且实现中也允许这样,那么一个线程也有可能在实际中执行多个SECTION部分。
section语句是用在sections语句里用来将sections语句里的代码划分成几个不同的段,每段都并行执行。
#pragma omp sections [clause ...] newline
private (list)
firstprivate (list)
lastprivate (list)
reduction (operator: list)
nowait
{
#pragma omp section newline
structured_block
#pragma omp section newline
structured_block
}
各个section里的代码都是并行执行的,并且各个section被分配到不同的线程执行。
如果在parallel下有多个sections,每个sections内又有多个section。那么与for制导指令一样,在sections的结束处有一个隐含的路障同步。这也就说明每个section执行的时间要差不多,否则将出现部分先执行完的线程空闲等待的情况。for的任务分担由系统自动化分,但sections则由程序员手工指定,这是一点重要的区别。
示例:
#include <omp.h>
#define N 1000
main(int argc, char *argv[]) {
int i;
float a[N], b[N], c[N], d[N];
/* Some initializations */
for (i=0; i < N; i++) {
a[i] = i * 1.5;
b[i] = i + 22.35;
}
#pragma omp parallel shared(a,b,c,d) private(i)
{
#pragma omp sections nowait
{
#pragma omp section
for (i=0; i < N; i++)
c[i] = a[i] + b[i];
#pragma omp section
for (i=0; i < N; i++)
d[i] = a[i] * b[i];
} /* end of sections */
} /* end of parallel region */
}
single
SINGLE指令指定所附代码仅由组内的一个线程来执行。这在处理非线程安全的代码部分(如I/O时)可能会很有用。
#pragma omp single [clause ...] newline
private (list)
firstprivate (list)
nowait
structured_block
单线程执行single制导指令指定所包含的代码只由一个线程执行,别的线程跳过这段代码。如果没有nowait从句,所有线程在single制导指令结束处隐式同步点同步。如果single制导指令有nowait从句,则别的线程直接向下执行,不在隐式同步点等待;single制导指令用在一段只被单个线程执行的代码段之前,表示后面的代码段将被单线程执行。
#include <omp.h>
void main()
{
#pragma omp parallel
{
#pragma omp single
printf("Beginning work1.\n");
printf("work on 1 parallelly.%d\n", omp_get_thread_num());
#pragma omp single
printf("Finishing work1.\n"); // 决定了全部线程执行完1,再去管2
#pragma omp single nowait
printf("Beginning work2.\n");
printf("work on 2 parallelly.%d\n", omp_get_thread_num());
}
getchar();
}
同步:
OpenMP支持两种不同类型的线程同步机制,一种是互斥锁的机制,可以用来保护一块共享的存储空间,使任何时候访问这块共享内存空间的线程最多只有一个,从而保证了数据的完整性;另外一种同步机制是事件同步机制,这种机制保证了多个线程之间的执行顺序。互斥的操作针对需要保护的数据而言,在产生了数据竞争的内存区域加入互斥,可以使用包括critical、atomic等制导指令以及API中的互斥函数。而事件机制则控制线程执行顺序,包括barrier同步路障、ordered定序区段、master主线程执行等。
critical
如果一个线程当前正在CRITICAL区域内执行,如果另一个线程到达CRITICAL区域并尝试执行它,那么后到的线程将被阻塞,直到第一个线程退出该CRITICAL区域。
可选名称允许多个CRITICAL区块同时存在:1)名称将被作为全局标识符。具有相同名字的不同CRITICAL区块将会被认为是同一区块;2)所有匿名的CRITICAL区块将会被认为是同一个区块。
对于可能产生内存数据访问竞争的地方要插入相应的临界区制导指令critical,critical语句不允许互相嵌套。以下面的代码为例,在一个并行域内的for任务分担域中,各个线程逐个进入到critical保护的区域内,比较当前元素和最大值的关系并可能进行最大值的更替,从而避免了数据竞争的情况。
int i;
int max_num_x=max_num_y=-1;
#pragma omp parallel for
for (i=0; i<n; ++i)
{
#pragma omp critical(max_arx);
if (arx[i] >max_num_x)
{
max_num_x = arx[i];
}
#pragma omp critical(max_ary)
if (ary[i]>max_num_y)
{
max_num_y = ary[i];
}
}
#include <omp.h>
main(int argc, char *argv[]) {
int x = 0;
#pragma omp parallel shared(x)
{
#pragma omp critical
x = x + 1;
} /* end of parallel region */
}
组内所有的线程都试图去并行执行。但是由于CRITICAL区块的存在,任何时刻最多只能有一个线程去执行自增操作。
barrier
有时候我们需要显示的路障barrier。只有等所有的线程都完成Initialization()初始化操作以后,才能够进行下一步的处理动作,因此,在此处插入一个明确的同步路障操作以实现线程之间的同步。
#pragma omp parallel
{
Initialization();
#pragma omp barrier
Process();
}
atomic
ATOMIC指令指定特定的内存位置必须为原子更新,而不是让多个线程尝试写入它。事实上,该指令提供了一个最小单位的CRITICAL区域。
子操作atomic只能作用在单条赋值语句中。能够使用原子语句的前提条件是相应的语句能够转化成一条机器指令,使得相应的功能能够一次执行完毕而不会被打断。C/C++中可用的原子操作:+、-、*、/、&、^、<<、>>。值得注意的是,当对一个数据进行原子操作保护的时候,就不能对数据进行临界区的保护,OpenMP运行时并不能在这两种保护机制之间建立配合机制。用户在针对同一个内存单元使用原子操作的时候,需要在程序所有涉及到该变量并行赋值的部位都加入原子操作的保护。
#pragma omp parallel
{
for (int i=0; i<10000; ++i)
{
#pragma omp atomic //atomic operation
counter++;
}
}
master
master制导指令和single制导指令类似,区别在于,master制导指令包含的代码段只由主线程执行,而single制导指令包含的代码段可由任一线程执行,并且master制导指令在结束处没有隐式同步,也不能指定nowait从句。如下例,只由主线程来打印数组结果。
#pragma omp parallel
{
#pragma omp for
for (i=0; i<5; ++i)
{
a[i] = i * i;
}
#pragma omp master
for (i=0; i<5; ++i)
{
printf("a[%d]=%d\n", i, a[i]);
}
}
ordered
对于循环代码的任务分担中,某些代码的执行需要按规定的顺序执行。典型的情况如下:在一次循环的过程中大部分的工作是可以并行执行的,而特定部分代码的工作需要等到前面的工作全部完成之后才能够执行。这时,可以使用ordered子句使特定的代码按照串行循环的次序来执行。
#pragma omp parallel
{
// some code
#pragma omp ordered
for(int i=0;i<5;i++) {
printf("%d",i);
}
}
ORDERED指令指定封闭循环中的迭代顺序将与其对应的串行代码的执行顺序完全一样;
如果某个线程执行某个迭代时,发现其之前的迭代尚未完成,那么该线程将等待;
任何时刻只能顺次地有一个线程在执行。
threadprivate
THREADPRIVATE指令用于在执行并行区域时,将全局变量(C/C++)变为线程的本地变量。
该指令必须在声明列出的变量/公共块之后出现。然后每个线程都将获得自己的变量/公共块的副本,所以一个线程写入的数据对于其它编程而言是不可见的。
#include <omp.h>
int a, b, i, tid;
float x;
#pragma omp threadprivate(a, x)
main(int argc, char *argv[]) {
/* Explicitly turn off dynamic threads */
omp_set_dynamic(0);
printf("1st Parallel Region:\n");
#pragma omp parallel private(b,tid)
{
tid = omp_get_thread_num();
a = tid;
b = tid;
x = 1.1 * tid +1.0;
printf("Thread %d: a,b,x= %d %d %f\n",tid,a,b,x);
} /* end of parallel region */
printf("************************************\n");
printf("Master thread doing serial work here\n");
printf("************************************\n");
printf("2nd Parallel Region:\n");
#pragma omp parallel private(tid)
{
tid = omp_get_thread_num();
printf("Thread %d: a,b,x= %d %d %f\n",tid,a,b,x);
} /* end of parallel region */
}
Output:
1st Parallel Region:
Thread 0: a,b,x= 0 0 1.000000
Thread 2: a,b,x= 2 2 3.200000
Thread 3: a,b,x= 3 3 4.300000
Thread 1: a,b,x= 1 1 2.100000
************************************
Master thread doing serial work here
************************************
2nd Parallel Region:
Thread 0: a,b,x= 0 0 1.000000
Thread 3: a,b,x= 3 0 4.300000
Thread 1: a,b,x= 1 0 2.100000
Thread 2: a,b,x= 2 0 3.200000
数据范围属性从句
全局变量包括:
(C/C++)文件范围内的变量,静态变量。
私有变量包括:
循环索引变量(译注:也就是我们编程中常用的i,j等);
从并行区域中调用的子程序的栈变量。
OpenMP数据范围属性从句用来显式定义各个变量的有效范围,它们包括:
PRIVATE
FIRSTPRIVATE
LASTPRIVATE
SHARED
DEFAULT
REDUCTION
COPYIN
数据范围属性从句和一些指令(PARALLEL, DO/for以及SECTIONS)等被一起使用,以控制封闭区域内的变量的有效范围。
private
private子句用于将一个或多个变量声明成线程私有的变量,变量声明成私有变量后,指定每个线程都有它自己的变量私有副本,其他线程无法访问私有副本。即使在并行区域外有同名的共享变量,共享变量在并行区域内不起任何作用,并且并行区域内不会操作到外面的共享变量。
#include <iostream>
#include <omp.h>
int main() {
int k = 100;
#pragma omp parallel for private(k)
for(k = 0; k < 3; k++)
{
std::cout << k << std::endl;
}
std::cout << k << std::endl;
getchar();
return 0;
}
结果:
k=0
k=1
k=2
k=3
last k=100
从打印结果可以看出,for循环前的变量k和循环区域内的变量k其实是两个不同的变量。用private子句声明的私有变量的初始值在并行区域的入口处是未定义的,它并不会继承同名共享变量的值。
private声明的私有变量不能继承同名变量的值,但实际情况中有时需要继承原有共享变量的值,OpenMP提供了firstprivate子句来实现这个功能。若上述程序使用firstprivate(k),则并行区域内的私有变量k继承了外面共享变量k的值100作为初始值,并且在退出并行区域后,共享变量k的值保持为100未变。
有时在并行区域内的私有变量的值经过计算后,在退出并行区域时,需要将它的值赋给同名的共享变量,前面的private和firstprivate子句在退出并行区域时都没有将私有变量的最后取值赋给对应的共享变量,lastprivate子句就是用来实现在退出并行区域时将私有变量的值赋给共享变量。程序示例如下:
#include <iostream>
#include <omp.h>
int main() {
int k = 100;
#pragma omp parallel for firstprivate(k),lastprivate(k)
for (int i = 0; i < 4; i++)
{
k += i;
std::cout << k << std::endl;
}
std::cout << k << std::endl;
getchar();
return 0;
}
从打印结果可以看出,退出for循环的并行区域后,共享变量k的值变成了103,而不是保持原来的100不变。OpenMP规范中指出,如果是循环迭代,那么是将最后一次循环迭代中的值赋给对应的共享变量;如果是section构造,那么是最后一个section语句中的值赋给对应的共享变量。注意这里说的最后一个section是指程序语法上的最后一个,而不是实际运行时的最后一个运行完的。如果是类(class)类型的变量使用在lastprivate参数中,那么使用时有些限制,需要一个可访问的,明确的缺省构造函数,除非变量也被使用作为firstprivate子句的参数;还需要一个拷贝赋值操作符,并且这个拷贝赋值操作符对于不同对象的操作顺序是未指定的,依赖于编译器的定义。
private声明的私有变量不会继承同名变量的值,于是OpenMP提供了firstprivate子句来实现这个功能。firstprivate子句是private子句的超集,即不仅包含了private子句的功能,而且还要对变量做进行初始化。
有时要将任务分担域内私有变量的值经过计算后,在退出时,将它的值赋给同名的共享变量(private和firstprivate子句在退出并行域时都没有将私有变量的最后取值赋给对应的共享变量),lastprivate子句就是用来实现在退出并行域时将私有变量的值赋给共享变量。lastprivate子句也是private子句的超集,即不仅包含了private子句的功能,而且还要将变量从for、sections的任务分担域中最后的线程中复制给外部同名变量。
PRIVATE和THREADPRIVATE的对比:
从句/指令总结:
从句与OpenMP指令之间的兼容性关系。