OpenMP 并行编程笔记

3 minutes read

Published:

OpenMP 是一种基于线程的并行编程模型。它使用 fork-join 并行执行,fork 与 join 之间的部分被称为并行域。OpenMP 程序开始于一个单独的主线程,主线程一直串行执行,直到遇到并行域才开始并行执行,退出并行域后恢复串行执行。

一、OpenMP 程序结构

#include <omp.h>
int main () {
    int var1, var2, var3;
    /*Serial code*/
    
    /*Beginning of parallel section. Fork a team of threads*/
    /*Specify variable scoping */
    #pragma omp parallel private(var1, var2) shared(var3)
    {
        /*Parallel section executed by all threads*/
        
        /*All threads join master thread and disband*/
    }
    /*Resume serial code */
    
}

二、编译制导与作用域

OpenMP 编译制导语句格式如下:

  • #pragma omp:制导指令前缀
  • directive-name:OpenMP 制导指令,位于制导前缀和子句之间
  • [clause, ...]:可选的子句
  • newline:换行符,标识制导语句的终止(这就是为什么后面的{必须换行)

编译制导语句的作用域可分为静态范围、孤立语句和动态范围。

  • 静态范围:代码在一个编译制导语句之后,被封装到一个结构块中。一个语句的静态范围不能跨越多个例程或代码文件。
  • 孤立语句:一个 OpenMP 的编译制导语句不依赖于其它语句,存在于其他静态范围语句之外,可跨越多个例程或代码文件。
  • 动态范围:一个语句的动态范围包括它的静态范围和孤立语句范围。

三、并行域

并行域是一个能被多个线程执行的程序代码块,是 OpenMP 中的基本并行结构,语法格式如下:

#pragma omp parallel [if(scalar_expression) | private(list) | shared(list) | default(shared | none) | firstprivate (list) |reduction (operator: list) | copyin (list)] newline  

当一个线程运行到 parallel 指令时,它会创建一个线程组并成为该组的主线程,主线程的线程号为 0。并行域开始时,程序代码被复制,每个线程都会执行此代码。并行域结束时,只有主线程越过路障继续执行。

  • omp_set_num_threads():指定并行域的线程数
  • omp_get_num_threads():获取当前并行域使用的线程数
  • omp_get_thread_num():获取当前执行线程在并行域中的线程号

四、共享任务结构

共享任务结构将代码划分给线程组的各成员执行,它不产生新的线程,在结束处有一个隐含的路障。共享任务结构必须动态地封装在一个并行域中。

1. for 编译制导语句

for 语句指定紧随它的循环语句必须由线程组并行执行。语句格式如下:

#pragma omp for [schedule(type[, chunk]) | ordered | private(list) | firstprivate(list) | lastprivate(list) | shared(list) | reduction(operator:list) | nowait] newline

其中,schedule 子句描述如何将循环的迭代划分给线程组中的线程。

  • typestatic类型时,循环被划分成大小为chunk的块,并静态地分配给各线程。若未指定chunk,循环迭代空间会被分成连续的、近似相等大小的块。
  • typedynamic类型时,循环被动态地划分成大小为chunk的块,且动态地分配给各线程。线程在运行时动态请求新的迭代块,而不是预先分配所有迭代,因此==分配的顺序具有随机性==。默认的块长为 1。

所有迭代的计算时间基本相同时,用static更高效;dynamic适用于负载不均衡的循环迭代任务。

openmp

2. sections 编译制导语句

sections 语句将内部的代码划分给线程组中的各个线程。嵌套在 sections 语句中的每个独立的 section 编译制导语句由线程组中的一个线程执行。不同的 section 由不同的线程执行。sections 语句的格式为:

#pragma omp sections [private(list) | firstprivate(list) | lastprivate(list) | reduction(operator:list) | nowait] newline
{
    [ #pragma omp section newline]
    ...
    [ #pragma omp section newline]
    ...
}

3. single 编译制导语句

single 语句指定代码只由线程组中的一个线程执行。除非使用了 nowait 子句, 否则线程组中没有执行 single 语句的线程会一直等待代码块的结束。语句格式如下:

#pragma omp single [private(list) | firstprivate(list) | nowait] newline

组合的并行共享任务结构包括 parallel for 编译制导语句和 parallel sections 编译制导语句。使用这些语句主要是为了代码编写简洁方便。

4. parallel for 编译制导语句

parallel for 语句表明一个并行域包含一个单独的 for 语句。语句格式如下:

#pragma omp parallel for [if(scalar_logical_expression) | default(shared | none) | schedule(type[, chunk]) | shared(list) | private(list) | firstprivate(list) | lastprivate(list) | reduction(operator:list) | copyin(list)] newline

5. parallel sections 编译制导语句

parallel sections 语句表明一个并行域包含单独的一个 sections 语句。语句格式如下:

#pragma omp parallel sections [default(shared | none) | shared(list) | private(list) | firstprivate(list) | lastprivate(list) | reduction(operator:list) | copyin(list) | ordered] newline

五、同步结构

  • master 编译制导语句(#pragma omp master newline):指定代码段将只由主线程执行。

  • critical 编译制导语句(#pragma omp critical [(name)] newline):表明域中的代码在同一时刻只能由一个线程执行,其他线程被阻塞在临界区。可选的name字段可指定互斥锁的名称,不同名称的锁相互独立;如果缺省,所有未命名的 critical 区域默认共享同一把全局锁。

  • barrier 编译制导语句(#pragma omp barrier newline):用来同步一个线程组中所有的线程。先到达的线程会在此阻塞,等待其他线程。

  • atomic 编译制导语句(#pragma omp atomic newline):指定特定的存储单元将被原子地更新,而不允许多个进程同时执行更新操作。只能用于复合赋值、自增、自减语句。

    atomic 和 critical 语句都可以避免多个线程同时修改共享变量导致的竞态条件。atomic 性能更好,但是只能用于针对单个变量的简单更新操作;critical 可适用于任意代码块,但性能较低。

  • flush 编译制导语句(#pragma omp flush (list) newline):用以标识一个同步点,以确保所有的线程看到一致的存储器视图。

    在 parallel、for、sections、single 语句的退出部分,critical、ordered 语句的进入与退出部分,以及 barrier 语句中均隐含了 flush 的运行(除非指定 nowait)。实践中较少需要手动 flush。

  • ordered 编译制导语句(#pragma omp ordered newline):将其所包含循环串行化,即任何时候只能有一个线程执行其限定的部分,只能出现在 for 或 parallel for 语句的动态范围中。这样,在 for 循环的并行化中,强制按迭代顺序执行特定代码块。

特性mastersingleorderedcritical
执行线程只有主线程(线程 0)执行任意一个线程执行循环中按迭代顺序执行所有线程均可执行
其他线程行为直接跳过代码块默认等待该区域完成,除非 nowait跳过未分配的迭代阻塞等待锁释放
隐式屏障无隐式屏障有隐式屏障,可用 nowait 取消有隐式屏障无隐式屏障
适用场景主线程独占任务,如初始化一次性任务,如文件 I/O需保持顺序的循环操作保护共享资源,如变量更新
是否互斥是,同一时间仅一个线程执行

六、数据域属性子句

默认情况下,并行域外声明的变量是共享的,并行域内声明的变量是私有的。可以用数据域属性子句控制变量的作用范围。

  • private 子句:表示它列出的变量对于每个线程是私有的。private 子句是非持久的。

    threadprivate 编译制导语句(#pragma omp threadprivate (list) newline)使一个全局文件作用域的变量在并行域内变成每个线程私有。

    • 必须出现在声明变量之后。执行语句后,每个线程都将对该变量复制一份私有拷贝。
    • 变量的值在线程中是持久的,即在不同并行区域中可以保留上一次的值(类似静态存储)。
    • 用于需要线程私有的全局变量或静态变量的场景。
  • shared 子句:表示它所列出的变量被线程组中所有的线程共享。

  • default 子句:让用户自行规定在一个并行域的静态范围中所定义的变量的缺省作用范围(包括 private、shared 或 none)

  • firstprivate 子句:是 private 子句的超集,即不仅包含了 private 子句的功能,而且还要对变量做原子初始化

  • lastprivate 子句:是 private 子句的超集,即不仅包含了 private 子句的功能,还要将变量从最后的循环迭代或section 复制给原始的变量。

  • copyin 子句:用来为线程组中所有线程的 threadprivate 变量赋相同的值,其中主线程该变量的值作为初始值。

  • reduction 子句(reduction (operator: list)):使用指定的操作operatorlist中的变量进行归约。初始时,先将该变量当成 private 类型,在每个线程都保留一份私有拷贝;任务结构结束后,根据指定的操作对线程中的各个变量进行归约,并更新该变量的全局值。

    • reduction 子句只能用于单个变量的更新操作,如复合赋值、自增、自减等。
    • list中的变量必须为 shared 类型的标量。

    通俗地来讲,reduction 的本质就是每个线程先算自己的部分(私有),最后把所有线程的结果合并(归约)。

七、 语句绑定和嵌套规则

  • 语句绑定
    • 语句 for、sections、single、master 和 barrier 绑定到动态封装的 parallel 中,如果没有并行域在执行,这些语句是无效的;
    • 语句 ordered 指令绑定到动态封装的 for 中;
    • 语句 atomic 使得 atomic 语句在所有的线程中独立存取,而并不只是当前的线程;
    • 语句 critical 在所有线程有关 critical 指令中独立存取,而不是只对当前的线程;
    • 在并行域外,一个语句不能绑定到其它的语句中。
  • 语句嵌套
    • parallel 语句可以嵌套,实现多级并行性,外层和内层的线程数可以独立设置。需要先调用omp_set_nested(1)启用嵌套并行;
    • 任何允许在并行域内动态执行的语句,在并行域外执行也是合法的。
    • 绑定到同一个 parallel 语句中的 for、section 和 single 语句不允许互相嵌套;
    • 同名的 critical 语句不允许互相嵌套;
    • for、section 和 single 语句不允许出现在 critical、ordered 和 master 域的动态范围中;
    • barrier 语句不允许出现在 for、ordered、sections、single、master 和 critical 域的动态范围中;
    • master 语句不允许出现在 for、sections 和 single 语句的动态范围中;
    • ordered 语句不允许出现在 critical 域的动态范围中。

八、环境变量

OpenMP 提供了一系列环境变量来控制并行程序的运行时行为。这些变量通常在运行程序前通过命令行设置(如 export OMP_NUM_THREADS=4),或在程序内部通过 omp_set_* 函数修改。常见的环境变量有:

  • OMP_NUM_THREADS:定义执行中最大的线程数;
  • OMP_SCHEDULE:设定循环的调度策略;
  • OMP_DYNAMIC:是否动态调整并行域的线程数;
  • OMP_NESTED:是否启用并行嵌套。