浅谈Opem_MP

OpenMP2.5

有底层API后,就已经可以实现并行编程;然而,很多时候串行算法已经成型,如果继续使用原有的底层API,还将面临转换和调试的问题。OpenMP正是为了解决这样的问题。

一、OpenMP的先容

1.概览

  • 提供线程级别的并行模型
  • 基于共享内存的模型
  • 本身只是提供一种规范
    具体的实现由各个系统和编译器负责实现

2.本质

  • 一套多线程的API
  • 面向程序员的高层接口
  • 提供一系列的编译和预处理的引导语句
  • 主要提供Fortran、C、C++的多线程支撑
  • 以SMP的物理结构完成多线程的实现

3.实现层次

  • 编译时的引导语句
  • 库函数的支撑
  • 环境变量的支撑
  • OpenMP的标准可以实现在任何编译器上
    不同的编译器支撑程度不同

4.历史

(略)

5.OpenMP的目标

标准化

  • 在不同的语言和架构上都可以以相同的方式编写多核程序

简洁有效

  • 编译器的引导语句尽可能地少

易用性

  • 允许程序逐步并行化
  • 使对串行程序的修改尽可能地少

可移植性

  • 多种语言
  • 不同平台

6.OpenMP编程模型

共享内存、基于线程的并行模型

显式并行

Fork-Join模型

  • 程序启动后是单线程
  • 达到需要并行的部分(并行区)时,产生多个线程同时运行
  • 所有线程同时实行完后互相等待,一起结束

基于编译器引导语句

支撑嵌套并行

动态线程的创建与销毁

线程的数量可以由OpenMP自适应

I/O

  • OpenMP并没有指定I/O的接口,仍然按原有的方式进行读写
  • 因此并行区中的读写会面临冲突的问题,需要程序员自己解决

内存模型

7.OpenMP的层次

  • SMP的硬件结构
  • 系统的线程支撑与OpenMP的运行时库
  • 编译器引导语句、库函数和环境变量
  • 应用程序和最终用户

8.示例代码

    #include <omp.h>
    
    void main()
    {
        #pragma omp parallel            //编译引导语句,将大括号括起的范围内做成一个并行区
        {
            int ID=omp_get_thread_num();
            printf("hello(%d)",ID);
            printf("world(%d)\n",ID);
        }
    }

编译时,需要增加参数-fopenmp(gcc)、-mp(pgi)、/Qopenmp(Intel)、/openmp(Visual Studio,或直接在项目属性中添加OpenMP支撑)

更一般的形式

    #include <omp.h>
    int main()
    {
        int v1,v2,v3;
        //Serial code
        #pragma omp parallel private(v1,v2) shared(v3)
        {
            //
            //Join
        }
        //Back to serial code
    }
  • 大括号必须紧跟编译引导语句书写
  • 语法格式是固定的

二、创建线程

1.Fork-Join结构

  • 主线程按那些创建一组线程实行并行任务
  • 并行区完全可以嵌套
    • 并行区中,主线程担任一个线程的工作
    • 子并行区中,仍有相应概念上的主线程

2.指定线程的个数

虽然线程个数可以由OpenMP自动指定,但是也可以手动设置

omp_set_num_threads(4);

这使得此函数之后的每个并行区都是4个线程同时运行

也可以使用引导语句,这样只对一个并行区生效

`#pragma omp parallel num_threads(4)`

三、同步方式

1.临界区

多线程同时只能由一个进入临界区实行

    float res;
    #pragma omp parallel
    {
        float B;
        int i,id,nthrds;
        id=omp_get_thread_num();        //当前线程的ID
        nthrds=omp_get_num_threads();   //当前的线程个数
        for(i=id,i<niters;i+=thrds)     //巧妙的for循环,尽可能将循环任务平均地分配到各线程中去
        {
            B=big_job(i);
            #pragma omp critical
                consume(B,res);
        }
    }

2.原子操作

原子操作不会被多线程打断
然而原子操作和临界区的功能是一样的,因为有复合语句的存在,原子操作的功能实际上还要弱一些
原子操作中不能使用复合语句,也不能进行函数调用

    #pragma omp parallel
    {
        double tmp,B;
        B=DOIT();
        tmp=big_ugly(B);
        #pragma omp atomic
            X+=tmp;
    }
  • 提供原子操作的意义在于效率
    使用原子操作的效率,比使用临界区要高很多,因为可以调用一些系统底层的特殊功能来实现原子操作

3.路障同步

4.同步次序

5.flush

6.锁

四、并行循环

1.SPMD与worksharing

  • 工作共享创建了一个Single Program Multiple Data的程序结构
  • 使得多个线程以看起来一样的代码完成不同的工作

2.分配循环用的worksharing

    #pragma omp for
        for(i=0;i<N;i++)
        {
            something();
        }
  • i将自动地成为每个线程的私有变量
  • 默认得到{0,1,2,3},{4,5,6,7},...这样的循环划分方法
    可以调整,但无法任意划分

3.worksharing的结构特点

  • worksharing结构不会创建线程
    仅仅对实行做分配
  • worksharing结构在入口没有路障同步,但出口处有
    而且都是隐式的

4.worksharing结构的限制

  • 必须放在并行区内
  • 待分配的任务无法实行一部分,要么整个分配,要么不分配
  • 分配时有固定的次序,不支撑自定义的次序
    也不会随机分配

5.worksharing结构的类型

  • section可以进行手动分配
  • single可以分配给单个线程

6.parallel与worksharing的组合

    double res[MAX];
    int i;
    #pragma omp parallel for
        for(i=0;i<MAX;i++)
            res[i]=huge();

7.规约

  • OpenMP提供的特殊、常见数据类型的支撑

编译引导语句的基本格式

`#pragma omp directive-name [clause,...] newline`

规约引导语句

`reduction(op:list)`

归约操作的操作符和初始值

  • 由OpenMP规定
  • 无法自行定义

五、同步

1.Barrier

    #pragma omp barrier             //手动的路障同步
    #pragma omp for nowait          //指明取消末尾的隐式路障同步
  • 直到所有线程实行到此位置才继续实行
  • 离开临界区时有隐式的路障同步

2.Master结构

  • 标记一个代码块只被一个线程实行
  • 其它线程简单跳过
  • 默认没有路障同步,需要显式指定

3.Single结构

  • 此结构中的内容只有一个线程实行
  • 可能由任何一个线程实行,未必是master线程
  • 出口处有隐式的路障同步

4.ordered

  • 只加在for循环后
  • 表明for循环存在次序依赖
    标记出的语句将按照for循环的串行迭代序被实行
  • 对性能将产生很大的影响

5.锁

简单锁

可以认为是简单的布尔变量
omp_*_lock

  • init
  • set
  • unset
  • test
  • destroy

嵌套锁

与简单锁不同,可以被同一个进程反复地加锁,解锁时也要进行相应数量的解锁
omp_*_nest_lock

  • init
  • set
  • unset
  • test
  • destroy

简单锁的例子

    #include <omp.h>
    omp_lock_t lock;
    omp_init_lock(&lck);
    
    #pragma omp parallel private(tmp,id)
    {
        id=omp_get_thread_num();
        tmp=do_lots_of_work(id);
        omp_set_lock(&lock);
        omp_unset_lock(&lock);
    }
    omp_destroy_lock(&lock);

六、OpenMP的库函数

1.修改、设置线程数量

  • omp_set_num_threads(int)
  • omp_get_num_threads()
    获取此韩式调用时的线程数量
  • omp_get_thread_num()
    获取当前线程的线程号
  • omp_get_max_threads()
    获取下一个开辟的并行区每个线程要开启的线程数

2.是否在并行区域内

  • omp_in_parallel()

3.是否允许系统动态调整线程数量

  • omp_set_dynamic(int)
  • omp_get_dynamic()

4.系统处理器数量

  • omp_num_procs()

5.环境变量

环境变量的优先级比库函数要低一些

  • OMP_NUM_THREADS
  • OMP_SCHEDULE
    设置for循环是横切或竖切

七、数据环境

1.默认存储属性

  • 共享内存的编程模型
  • 全局变量在线程间共享
  • 静态变量是共享的
  • 堆内存是共享的
    动态分配的内存

默认情况下的私有变量

  • 并行区内定义的变量

2.private子句

  • 为变量创建每个线程一份的副本
  • 未经初始化的变量,在OpenMP中的初始值未被定义
    主流平台上,private变量的修改对外围没有改变
  • 外部变量作为私有变量,对定义为私有变量的变量的修改,修改谁并没有明确的定义
    实际平台上的主流编译器都修改全局变量

3.firstprivate与lastprivate子句

  • 和private子句几乎相同
  • firstprivate
    私有变量的初值定义为全局变量原先的值
  • lastprivate
    出并行区时,全局变量的值将被改变
    通常实行的最后一条更新的值反映到全局变量中

4.default子句

default(PRIVATE|SHARED|NONE)
  • default(SHARED)是默认存在的,因此不需写出来
    #pragma omp task除外
  • 在C中,default(PRIVATE)不被支撑
  • default(NONE)将不为变量设定默认值
    此时必须为每个变量显式指定属性
    良好的自虐的编程实践~
    通常只在需要编译器提醒哪个变量没有指定属性时才使用

5.threadprivate子句

    int counter=0;
    #pragma omp threadprivate(counter)
  • 定义为threadprivate的变量是可以穿越多个并行区的
    变量的值以线程号一一对应

copyin子句

    int a=100;
    #pragma omp threadprivate copyin(a)
  • 可以将全局变量的值拷贝进对应的私有变量

copyprivate子句

  • 只能在single中使用
  • 在路障同步点处由实行single的线程拷贝到所有其它线程

指针的传递

  • 在线程之间,指针不要随便乱传
        #pragma omp parallel private(x) shared(p0,p1)
        x=...;
        p0=&x;
在另一个线程中使用p0指针会造成不可预料的后果

八、Schedule子句

1.section子句

    #pragma omp parallel
    {
        #pragma omp sections
        {
            #pragma omp section
            calculation1();
            #pragma omp section
            calculation2();
            #pragma omp section
            calculation3();
        }
    }
  • 这些任务由系统自由分配给不同线程运行
  • 任务数与线程数相等时,分配显然
  • 任务数多于线程数时
    先用任务把线程占满,哪个线程实行完在分配剩下的任务
  • 任务数少于线程数时
    其它线程等待

2.schedule子句

`schedule(mode[,chunk])`

实际上大多数编译器除了static,另外三种都没实现

静态调度

  • 所有分配方式在编码时写死
  • 默认的分配方式
  • chunk默认为最大值(迭代数/线程数)
    chunk是循环任务分块的大小
    如果需要循环纵切,chunk设置为1即可
  • 静态调度的分配方式是非常明确的,第一个chunk给线程0,以此类推

动态调度

  • 每个chunk可以动态分配给某个线程了

guided调度

  • chunk定义的是块的最小值
  • 实际上可以更大

runtime调度

  • 全部参数交由编译器决定

九、内存模型

1.弱一致性

  • 在代码中,读写顺序在不改变语义的情况下是可以改变的
  • 以S表示数据同步操作
    OpenMP中保证,S->W、S->R、R->S、W->S、S->S
    在OpenMP中就是flush操作

2.flush

    a=...;
    <other computaion>
    #pragma omp flush(a)
  • 变量值在内存中的改变最早发生在写操作,最晚在数据同步操作时进行

隐式数据同步

其它所有同步都会自动带上数据同步

十、OpenMP 3.0与任务

1.任务

  • 其它结构的工作量都是静态的,但task的任务是可以动态分的

2.例子

    for(int i=0;i<N;i+=a[i])
        task(a[i]);
  • 此循环不能使用#pragma omp for
  • 想要并行就必须使用task

3.task的结构

`#pragma omp task [clause[[,],clause]...]`
  • 子句可以加入ifuntitled与所有数据环境

并行的链表举例

    #pragma omp parallel
    {
        #pragma omp single private(p)   //由一个线程进行预处理,其它线程什么都不做
        {
            p=listhead;
            while(p)
            {
                #pragma omp task
                process(p);             //将链表内多个结点的处理并行进行,
                                        //占用并行区内原本闲置的线程
                p=next(p);
            }
        }
    }

4.untied子句

  • 创建的任务,默认将会与某个线程绑定,只能由某个线程来完成
  • untied可以用来解除这样的绑定

举例

    #pragma omp single
    {
        #pragma omp task untied
        for(i=0;i<ONEZILLION;i++)
            #pragma omp task
            process(item[i]);
    }
  • 如果不作为united的任务,源源不断的新任务将撑爆内存
  • untied允许任务的创建在其它线程间迁移

5.if子句

  • 如果表达式为false,整个编译引导语句无效
  • 默认为if(true)

推荐阅读更多精彩内容