OpenMP基础知识

408 阅读28分钟

1. OpenMP基础

并行计算是处理多任务或者处理多数据的一项技术

多任务:把任务分成多个片段,使用多个计算核心,同时执行任务我片段,达到减少计算耗时的目的,比如:OMP, MPI

多数据: 同时对多个数据进行相同的操作,比如:CUDA, SIMD

前提:1. 拥有多个计算设备或核心 2. 任务条件:任务或数据可划分成多个独立片段

  1. 并非所有任务适合并行
  2. 并行计算有额外的开销(调度,数据同步)总计算量多于 串行代码
  3. 并行可以减少一些计算耗时,但是并行计算的耗时也可能多于串行代码(任务并行度低或代码糟糕)
  4. 需要我们去选择合适的并行方式

单机多核计算:OpenMP 多节点并行:MPI GPU设备加速:CUDA / OpenACC 三种方案可混合使用,MPI也适合单机,OMP也支持设备加速

为了方便并行,减少错误,建议Fortran用户使用纯函数

1.1 OpenMP(Open Multi-Processing)

编译器自带OMP,无需额外安装

  1. 核心数指的是CPU的物理核心数
  2. OMP的线程数一般不大于CPU核数
  3. 区分CPU线程(客观存在)和 OMP线程(程序设定)

OpenMP是一种基于共享内存系统(单机)的多线程并行方案,支持C/C++ 和 Fortran语言,采用fork-join的模式,启用多个线程并行执行代码的特定部分

线程0分裂成多个,成为虚拟的并行区里的内容,后面回归主线程

优点:

  1. 简单易学,代码改动小
  2. 可以自由切换并行/串行模式
  3. 可以在线程间共享内存(变量),节省内存开销,便于线程间数据传递,减少数据同步耗时

缺点:

  1. 不规范会造成数据竞争,需要大幅度修改
  2. 不适合复杂的线程同步和互斥操作(但在计算密集的东西这是不多见的)
  3. 不能用在非共享内存系统(如计算集群),但支持主机意外的计算设备

推荐书籍:多核并行高性能计算OpenMP 雷洪,胡许冰 多核异构并行计算OpenMP4.5 C/C++

编译环境: C: gcc, intel oneAPI, visual studio 集成开发环境: vs, vs code

C语言(区分大小写)

//预处理
#ifdef _OPENMP/ #endif // pragma omp后面需要跟OMP指令
#pragma omp 

OMP库有很多函数,无论是否需要开启OMP,仅需在代码中 开启以后,就可以使用OMP库函数

#include <omp.h>

关闭OMP选项,#pragma omp 就会失效,没有任何作用,生成的程序是串行

  1. 如果要使用OMP库函数,需要开启OMP并且在代码中use omp_lib
  2. 关闭OMP, !omp将会被识别是主食,但可以使用OMP库的部分函数

1.2 MPI(Message Passing Interface)

OpenMP只能在单机上使用,如果有三台这样的机器,可以使用MPI实现跨节点并行。MPI在每个节点(机器) 启用一个或多个进程来并行执行任务;同样的,进程数一般不会大于CPU核数。一个进程可以含有多个线程,也可以将MPI和OMP结合使用

使用MPI的时候,需要额外安装MPI库:常用的有MS MPI, intel MPI, open MPI, MPICH

1.3 CUDA (Compute Unified Device Architecture)/ OpenACC

CUDA是NVIDA开发的并行计算的平台和模型,而OpenACC由多个组织包括NVIDA一起维护,二者都是利用GPU实现并行计算。

CUDA适用于NVIDA卡,目前仅有NVIDA HPC SDK (包含C和Fortran编译器)支持 cuda fortran

OpenACC同时适用于NVIDA和AMD卡,NVIDA HPC SDK和 GCC(免费)均支持

注:NVIDIA HPC SDK (High-Performance Computing Software Development Kit) 是一套专为高性能计算 (HPC) 设计的软件开发工具包,旨在帮助开发者加速科学计算和工程应用。它提供了针对 NVIDIA GPU 和 CPU 的编程工具、库、编译器和示例代码,用于加速计算密集型应用程序。

2. 并行域

2.1 理解并行域

OMP以 Fork-Join 模式并行执行特定的代码,组成的动态范围(dynamic extent)称为并行域

#pragma omp parallel clause ...
{
    ...
}

注:C语言大小写敏感

执行模式:

  1. 启动程序,主线程(线程0)执行代码块1;
  2. 遇到 !$omp parallel后,主线程分裂(fork)为多个线程(此处线程为2),每个线程均尝试执行代码块2
  3. 各线程遇到 !$omp end parallel后,合并(join)为一个线程(主线程),并由主线程执行代码块3;
  4. 主线程结束程序

文本范围(lexical extent) parallel 指令对 之间的语句

动态范围(dynamic extent) 并行域中所有被执行的指令

在 OpenMP 中,文本范围(lexical extent)和 动态范围(dynamic extent)是两个重要的概念,它们帮助理解并行程序的执行顺序和作用域。以下是它们的详细解释:

文本范围指的是在程序代码中,OpenMP 指令的作用区域。这是基于源代码本身的静态范围,也就是在编译时确定的。

  • 定义:文本范围是从并行指令(如 #pragma omp parallel)开始,到结束该指令的括号或者代码块的地方为止的部分。在此范围内的代码是按顺序书写的,但不一定按顺序执行。
  • 作用:它主要描述代码在源代码层面上的结构和组织。你可以将它理解为程序员可以“看到”并且在编写代码时直接定义的范围。

例子

  • 在这个例子中,#pragma omp parallel{} 内的所有代码(即 printf 函数)就是文本范围。这是在代码中定义的逻辑区域。
#pragma omp parallel
{
    printf("Hello, world!\n");
}
  1. 动态范围 (Dynamic Extent)

动态范围指的是在程序运行时,真正被执行的指令的范围,也可以称为程序的执行路径。在并行执行时,由多个线程执行的代码会有动态的路径。

  • 定义:动态范围包括并行区域中所有实际被执行的代码片段。换句话说,它是程序运行时每个线程实际遍历的指令范围,包括函数调用和其他在并行区域内执行的操作。它不仅仅局限于源代码中的并行区域,还包括那些在并行域中被调用的函数和代码。

  • 作用:动态范围描述的是程序在运行时的行为。它包括所有线程在运行时所执行的代码,不管这些代码是不是直接位于 OpenMP 指令块内。

  • 例子

    void do_work() {
        printf("Working...\n");
    }
    ​
    #pragma omp parallel
    {
        do_work();
    }
    

    在这个例子中,虽然 do_work() 函数不在并行指令的文本范围内(#pragma omp parallel 块内),但因为它在并行区域被调用了,所以它属于动态范围。每个线程在并行区域内都会调用 do_work(),并执行 printf("Working...\n");

2.2 构造并行域

线程数可以由环境变量(或内部控制变量ICV, 可以认为是默认值)、OMP函数(子程序),并行的子句等确定

  1. 环境变量 OMP_NUM_THREADS 缺省(默认)线程数一般等于CPU核数
  2. 子程序 omp_set_num_threads 影响后续所有并行域的线程数
  3. 子句 num_threads 设置当前并行的线程数

优先级:子句 > 子程序 > 环境变量

常用函数(子程序)

omp_set_max_active_levels: // 最大嵌套并行层数
​
omp_get_num_procs; // 获取CPU核心数
​
omp_set_num_threads; // 设置后续并行域线程数
​
omp_get_num_threads // 获取函数调用处线程数
​
omp_get_thread_num // 获取当前线程号

注意:使用的时候需要

#include "omp.h"

接下来是两种改变线程数的方法

#include<iostream>
#include "omp.h" // 包括这个omp.h这个头文件,程序可以使用OpenMP提供的函数和常量,从而实现并行编程int main()
{
    std::cout << "begin ... " << std::endl;
    std::cout << "CPU此时的核心数目是 "<< omp_get_num_procs() << std::endl; //获取处理器的核心数量
#pragma omp parallel
    std::cout << "region 1 " << omp_get_thread_num() << " " << omp_get_num_threads() << std::endl;//第一个thread_num是获取线程号,第二个num_threads是获取正在运行的总线数//以上是一个系统核心的线程数,接下来外面手动设置
    //第一种手动设置的方式:omp_set_num_threads(n)
    omp_set_num_threads(2);
#pragma omp parallel
    std::cout << "region 2 " << omp_get_thread_num() << " " << omp_get_num_threads() << std::endl;//第一个thread_num是获取线程号,第二个num_threads是获取正在运行的总线数
    std::cout << "*******" << std::endl;
​
#pragma omp parallel
    std::cout << "region 3 " << omp_get_thread_num() << " " << omp_get_num_threads() << std::endl;//第一个thread_num是获取线程号,第二个num_threads是获取正在运行的总线数
    std::cout << "*******" << std::endl;
​
#pragma omp parallel num_threads(3);
    std::cout << "region 4 " << omp_get_thread_num() << " " << omp_get_num_threads() << std::endl;//第一个thread_num是获取线程号,第二个num_threads是获取正在运行的总线数
    std::cout << "*******" << std::endl;
​
    return 0;
}

Visual Studio 运行不了需要去项目的属性里C/C++开启支持openmp

omp_get_thread_limit可以查询线程的数量,理论上是一个很大的,但是实际上是收到物理核心的限制的(vs2019以后,前面可能不支持)

线程数不宜超过CPU物理核心数(CPU的超线程对计算密集型任务无益) ex:CPU超线程 - 办公室来了个精神小伙可以写字是其他人的两倍,让他去搬砖(计算密集型)没有益

超线程(Hyper-Threading,HT) :这是英特尔等厂商的一种技术,允许每个物理核心模拟成两个逻辑核心。这样看起来像是双倍的核心数,但实际上物理核心并没有增加,只是通过更好地利用核心的空闲时间来处理任务。

不宜超过:

  1. 开辟,释放线程比较耗时
  2. 单个核心处理多个线程会降低效率(线程切换)
  3. 线程数过多会造成内存和堆栈负担

嵌套并行

嵌套并行(nested parallelism)是一种并行计算的技术,允许一个并行任务的内部再启动一个或多个并行任务,也就是“并行中的并行”。这种模式通常出现在复杂的应用程序中,当一个任务内部的子任务也具备并行处理的潜力时,使用嵌套并行能够进一步提升性能。

嵌套并行的核心概念:

  1. 一级并行(outer parallelism) :最外层的并行任务,例如,在一个大规模矩阵运算中,将矩阵划分为多个子块,每个子块在不同的线程上并行处理。
  2. 二级或更多级别的并行(inner parallelism) :在某些情况下,每个子块的内部操作也可以并行化。比如,子块内的计算任务可以再拆分成更小的任务,每个任务分配给不同的线程,进一步加速运算。

2.3 子句

omp的数据属性可归为三类:shared, private, threadprivate

进入并行域的时候,如果变量具有私有属性(private, firstprivate, lastprivate, reduction, linear),则会额外开辟nT份(线程数)临时空间来存储数据,并在退出该结构的时候销毁临时数据

在C语言中,#include指令用于引入头文件,头文件的路径可以通过两种不同的方式指定,分别是使用双引号("")或尖括号(<>)。这两者有不同的搜索顺序和用途:

  1. 使用双引号 ("")
#include "myheader.h"
  • 搜索顺序

    1. 编译器会首先从当前源文件所在的目录开始搜索该头文件。
    2. 如果在当前目录中找不到,则会转到标准系统路径(例如系统头文件目录)进行搜索。
  • 常见用途:通常用于项目自定义的头文件,这些文件一般位于当前项目的源文件目录或其他用户指定的目录中。

  1. 使用尖括号 (<>)
#include <stdio.h>
  • 搜索顺序

    1. 编译器会直接从系统的标准头文件路径(如库的安装目录、系统默认的头文件目录)进行搜索。
    2. 它不会先检查当前目录。
  • 常见用途:主要用于系统提供的标准库第三方库的头文件,如C标准库中的文件(stdio.h, stdlib.h等)或者你安装的一些库的头文件。

总结:

  • #include "filename.h":优先从当前目录开始搜索,适用于项目中的自定义头文件。
  • #include <filename.h>:从系统标准路径搜索,适用于系统或第三方库的头文件。

这两个方式的差异可以帮助区分头文件的来源,并避免命名冲突。

  1. if子句
#define _CRT_SECURE_NO_WARNINGS //vs默认放弃scanf,使用scanf_s或者#define _CRT_SECURE_NO_WARNINGS
#include "stdio.h" // 回一下""和<>的区别
#include "omp.h"

int main()
{
	int nT, nP;
	printf("线程数: ");
	scanf("%d", &nT); //读取线程数
	nP = omp_get_num_procs();//读取核心数
	printf("CPU的核心数为:%d\n",nP);
#pragma omp parallel num_threads(nT) if(nP>nT) //nT是自己输入的,nP是系统的,如果自己输入的小于系统的,就会并行输出;反之不会并行
	printf("hello world\n");
	return 0;
}
  1. num_threads(nT) 参数nT是整型变量或表达式(正整数),设置并行域的线程数

  2. default(private | firstprivate | shared | none) 设置并行域变量的默认属性,如果没有显式给定,则其属性就是默认属性,参数为以上四种(私有,承前私有,共享,无)中的一个(C语言只可选shared | none) 注意:C语言中也有 private | firstprivate, 只是default里只有两个可选,C可以在并行域定义局部变量

  3. private(a, b, c) 变量a, b, c等为私有变量,每个线程都生成变量的私有备份,同一个变量在不同线程可以有不同值。初始值未知

  4. firstprivate(a, b, c) 变量a, b, c等为私有变量,每个线程都生成变量的私有备份,同一个变量在不同线程可以有不同值。初始值为主线程的值

    1. shared(a, b, c) 变量a, b, c为共享变量,所有线程共享同一片内存。初始值是主线程的值
  5. linear 子句用于指示 OpenMP 在并行循环中如何处理一个或多个变量,使其以线性方式增加。它特别适合用于那些迭代变量的更新是线性关系的情况,比如 i += stride,其中 stride 是一个常量。

#include <omp.h>
#include <stdio.h>

int main() {
    int N = 10;
    int sum = 0;
	//linear(i:1) 的含义是指定循环变量 i 应该以线性方式递增,每次递增的步长为 1。
    #pragma omp parallel for linear(i:1)
    for (int i = 0; i < N; i++) {
        sum += i;  // 累加
    }

    printf("Sum: %d\n", sum);
    return 0;
}
  1. ordered 子句用于控制并行循环中的某些迭代,以保证这些迭代按照特定的顺序执行。

    #include <omp.h>
    #include <stdio.h>
    
    int main() {
        int N = 5;
        int results[N];
    
        #pragma omp parallel for
        for (int i = 0; i < N; i++) {
            // 并行计算
            results[i] = i * i; // 计算平方
    
            #pragma omp ordered
            {
                // 按照 i 的顺序输出结果
                printf("Result[%d] = %d\n", i, results[i]);
            }
        }
    
        return 0;
    }
    
    1. task结构,untied子句 task 是一种并行编程模型中的基本单元,代表一个可以在多个线程中执行的独立计算块。使用 task 可以使得任务的创建与执行更加灵活,适合于动态负载均衡的场景。 untied 子句与任务相关,用于指定任务在执行时的行为。默认情况下,当任务被创建并分配给一个线程时,该任务会被绑定到这个线程,直到任务完成。使用 untied 子句可以使任务在执行时不再绑定到创建它的线程上,允许其他线程执行这个任务。

      #include "stdio.h"
      #include "omp.h"
      
      int main()
      {
          int i = 0, a[5];  // 定义整型变量 i 和整型数组 a,大小为 5。
      
      #pragma omp parallel num_threads(8)  // 创建一个包含 8 个线程的并行区域。
      #pragma omp single  // 只有一个线程执行下面的代码。
          while (i < 5)  // 当 i 小于 5 时,循环执行。
          {
              i++;  // 增加 i 的值。
      
      #pragma omp task firstprivate(i) untied  // 创建一个新的任务。
              {
                  a[i - 1] = i;  // 将当前的 i 值存入数组 a 的第 i-1 个位置。
                  printf("%d %d\n", i, omp_get_thread_num());  // 打印当前的 i 值和执行此代码的线程编号。
              }
          }
      
          for (i = 0; i < 5; ++i)  // 循环打印数组 a 的所有值。
              printf("%d ", a[i]);
          printf("\n");
          return 0;
      }
      

singleuntied 的交互

当你将 untied 用于一个任务时,这个任务可以在不同的线程上执行,因此有可能破坏 single 指令的结构,具体原因如下:

  1. 任务调度

    • 使用 single 指令的线程在执行到 while 循环时,实际上是由这个线程控制 i 的更新和任务的创建。
    • 当创建的任务带有 untied 子句时,这些任务可能被调度到其他线程上执行,而不是在创建它们的线程上执行。
  2. 动态执行

    • 因为任务是“未绑定”的,任何可用线程都可以执行这些任务,这意味着任务的执行顺序不是固定的。这样,多个任务可能会同时执行并且彼此独立运行,导致多个线程同时进行更新或打印输出。
  3. 并发执行

    • 由于有多个线程可以并发执行这些“未绑定”的任务,可能会出现多线程同时尝试更新或读取共享变量(如 a 数组或 i 变量)的情况。这可能导致竞争条件。

结果

  • untied 被用在任务中时,即使你有一个 single 指令控制了对 while 循环的访问,这仍然允许其他线程并发执行 untied 任务。这种行为导致了在多个线程之间的执行不再是顺序的,而是并发的,从而破坏了 single 的原始结构。

在并行计算中,taskloop 是 OpenMP 提供的一种指令,专门用于将循环中的迭代分成多个任务来并行执行。它允许你在有多个循环迭代时,将每次迭代分配给不同的线程,以提高并行计算的效率。通过 taskloop,你可以更灵活地控制任务的生成和调度,而不是仅仅依赖静态的循环划分。

  1. 基本概念
  • 并行任务taskloop 会将循环的每次迭代视为一个独立的任务。这些任务由 OpenMP 运行时系统管理,运行时系统可以动态调度它们,分配给不同的线程执行。
  • 动态调度:与传统的循环划分(如 for 循环并行化)不同,taskloop 更加动态,线程不需要一次分配固定的迭代范围,而是可以根据任务调度器的安排,灵活地接收和执行任务。
  1. 语法示例
#pragma omp parallel
{
    #pragma omp single
    {
        #pragma omp taskloop
        for (int i = 0; i < N; i++) {
            // 执行循环体内容
        }
    }
}
  • #pragma omp parallel:创建一个并行区域,所有线程都在此区域内执行。
  • #pragma omp single:指定只有一个线程生成任务(避免多个线程重复生成相同任务)。
  • #pragma omp taskloop:将循环 for (int i = 0; i < N; i++) 的每次迭代分成不同的任务。

计时函数

omp_get_wtimeclockcpu_time 是三种常见的计时方法,但它们在工作原理和使用场景上有所不同。下面对它们分别进行介绍,并对比它们的区别和适用场景。

  1. omp_get_wtime
  • 功能omp_get_wtime 是 OpenMP 提供的计时函数,返回的是“墙上时间”(wall-clock time),即从某个参考时间点到当前的实际时间,通常用于测量程序的整体执行时间。

  • 单位:秒(double 类型),单位通常为秒,返回的是自程序开始运行到调用时的时间间隔。

  • 线程安全omp_get_wtime 是线程安全的,适合并行计算。

  • 适用场景:用于测量整个程序或代码块的执行时间,尤其是并行计算的时间测量。

  • 示例

    #include <omp.h>
    #include <stdio.h>
    
    int main() {
        double start_time, end_time;
    
        start_time = omp_get_wtime();
        // 模拟一些需要测量的操作
        #pragma omp parallel for
        for (int i = 0; i < 1000000; i++);
        end_time = omp_get_wtime();
    
        printf("Execution time: %f seconds\n", end_time - start_time);
        return 0;
    }
    
  1. clock
  • 功能clock 是 C 标准库中的函数,用于返回程序消耗的处理器时间(CPU time)。处理器时间是程序在 CPU 上实际执行的时间,通常不包括程序被阻塞或等待的时间。

  • 单位:返回的是 clock_t 类型的处理器时钟滴答数(clock ticks)。要将其转换为秒,需要除以 CLOCKS_PER_SEC,它定义了一秒内的滴答数。

  • 线程安全性clock 在多线程环境中并不一定能正确反映多线程程序的执行时间,因为它仅测量单个线程的 CPU 时间。

  • 适用场景:适合测量程序在 CPU 上消耗的时间,通常用于单线程环境。

  • 示例

    #include <time.h>
    #include <stdio.h>
    
    int main() {
        clock_t start_time, end_time;
    
        start_time = clock();
        // 模拟一些需要测量的操作
        for (int i = 0; i < 1000000; i++);
        end_time = clock();
    
        double cpu_time_used = ((double)(end_time - start_time)) / CLOCKS_PER_SEC;
        printf("CPU time: %f seconds\n", cpu_time_used);
        return 0;
    }
    
  1. cpu_time
  • 功能cpu_time 是 Fortran 中的内置函数,用于返回程序消耗的 CPU 时间。与 C 语言中的 clock 类似,cpu_time 也测量处理器上程序的实际执行时间,而非实际的墙上时间。

  • 单位:秒(real 类型),返回处理器执行此程序的时间。

  • 线程安全性:同 clock 一样,cpu_time 只测量单个线程的 CPU 时间,在多线程环境下不适用。

  • 适用场景:Fortran 语言中用于测量单线程程序的 CPU 执行时间。

  • 示例

    (Fortran):

    fortran复制代码program test_cpu_time
        real :: start_time, end_time
    
        call cpu_time(start_time)
        ! 模拟一些需要测量的操作
        do i = 1, 1000000
        end do
        call cpu_time(end_time)
    
        print *, 'CPU time:', end_time - start_time
    end program
    
  1. 对比总结
特性omp_get_wtimeclockcpu_time
类型墙上时间(wall-clock time)CPU 时间CPU 时间
返回值单位秒(doubleCPU 时钟滴答数秒(real
测量的时间实际运行时间程序在 CPU 上的执行时间程序在 CPU 上的执行时间
是否线程安全
适用语言C、C++(OpenMP 环境)C、C++Fortran
适用场景并行或整体程序时间测量单线程的 CPU 时间测量单线程的 CPU 时间测量
是否适合并行程序

加速比 = 串行时间 / 并行时间

效率:加速比 / 线程数

3. threadprivate属性

进入并行域的时候,如果某个变量具有私有属性(private, firstprivate, lastprivate, reduction, linear),就会额外开辟nT(线程数)临时空间存储数据,并在退出并行域的时候销毁临时数据。

如果某些变量在各线程中有不同值(私有,例如线程号),且需要在多个并行域中重复使用,多次开辟/释放空间会降低性能,也可能会增加工作量(重复计算)。

一种解决方式是通过共享数组,缺点是:

  1. 表示更复杂(一维变二维),用线程号确定值
  2. 不便于并行/串行切换(增加代码修改量)

共享数组优点: 各并行域线程不一致时,可保证值的延续性

在并行编程中,OpenMP 提供了 threadprivate 变量来支持每个线程维护一份独立的副本。与普通的全局变量或静态变量不同,threadprivate 变量允许在多个线程中独立存储和访问同一变量,而不会互相干扰。它是 OpenMP 中非常重要的机制之一,适用于在不同线程中需要不同副本的全局或静态变量场景。

  1. threadprivate 的基本概念

在 C 语言中,普通的全局变量和静态变量在所有线程间共享,所有线程对它们的访问会影响同一个存储区域。而 threadprivate 允许将全局或静态变量定义为每个线程的独立副本,也就是说,每个线程都有自己私有的变量副本,互不影响。

  1. threadprivate 的语法

要将全局变量或静态变量声明为 threadprivate,你可以使用 OpenMP 的 #pragma omp threadprivate 指令。其语法如下:

#pragma omp threadprivate(list)
  • list:要定义为 threadprivate 的全局变量或静态变量的列表(逗号分隔)。
  1. 示例

以下是一个简单的 threadprivate 变量使用示例:

#include <omp.h>
#include <stdio.h>

int global_var;  // 全局变量

#pragma omp threadprivate(global_var)  // 将全局变量声明为 threadprivate

int main() {
    // 在并行区域中设置和访问 threadprivate 变量
    #pragma omp parallel num_threads(4)
    {
        int thread_id = omp_get_thread_num();  // 获取线程 ID
        global_var = thread_id;                // 每个线程给自己的 global_var 赋值
        printf("Thread %d: global_var = %d\n", thread_id, global_var);
    }

    return 0;
}

解释:

  • global_var 是一个全局变量,通过 #pragma omp threadprivate(global_var) 声明为 threadprivate 变量。
  • 在并行区域内,每个线程独立地对自己的 global_var 进行赋值和访问,输出结果表明每个线程都有自己的变量副本。
  1. threadprivate 的特点
  • 线程私有副本:每个线程都有独立的 threadprivate 变量副本,线程之间不会共享这个变量。

  • 全局或静态变量threadprivate 只能用于全局变量或静态变量。局部变量不能使用 threadprivate

  • 初始化threadprivate 变量在每个线程中独立初始化。默认情况下,初始化时 threadprivate 变量的值未定义,但可以通过在主线程初始化变量来在其他线程中继承该初始值。

    可以使用 OpenMP 的 copyin 子句来为所有线程初始化 threadprivate 变量。例如:

    #pragma omp parallel copyin(global_var)
    
  • 持久性threadprivate 变量的生命周期与整个程序一致,它在所有线程的生命周期内保持存在和可访问。

  1. threadprivateprivate 的区别
  • threadprivate:适用于全局变量和静态变量。每个线程有一份独立的副本,且变量的生命周期跨越整个并行区域,甚至在并行区域之外也可以被线程访问。
  • private:只能用于局部变量或循环变量。每个线程在进入并行区域时都会创建一个局部副本,但这个局部变量只在该并行区域内有效,超出并行区域后它的副本就会被销毁。
  1. 初始化问题与 copyin 子句

默认情况下,threadprivate 变量在并行区域中的初始值未定义。如果你希望所有线程在进入并行区域时使用主线程的 threadprivate 变量的初始值,可以使用 copyin 子句。

例如,初始化主线程的 global_var,并将其值复制到所有线程的副本:

#include <omp.h>
#include <stdio.h>

int global_var;  // 全局变量
#pragma omp threadprivate(global_var)

int main() {
    global_var = 10;  // 在主线程中初始化

    // 使用 copyin 将主线程的初始值复制到其他线程的 global_var
    #pragma omp parallel num_threads(4) copyin(global_var)
    {
        int thread_id = omp_get_thread_num();
        printf("Thread %d: global_var = %d\n", thread_id, global_var);
    }

    return 0;
}

在此示例中,所有线程的 global_var 变量在并行区域中都被初始化为主线程的初始值 10

  1. 应用场景

threadprivate 适用于以下几种场景:

  • 并行计算中需要独立的数据副本:当多个线程需要同时操作全局变量或静态变量时,使用 threadprivate 可以避免数据竞争,保证每个线程有自己独立的数据副本。
  • 线程持久性数据:某些全局数据需要在程序整个执行过程中保持线程独立性,特别是跨多个并行区域的情况下,threadprivate 提供了这种持久性。
  1. 注意事项
  • 变量作用域threadprivate 只能作用于全局或静态变量,不能作用于局部变量。
  • 生命周期threadprivate 变量的生命周期与整个程序一致,而不是仅限于并行区域。在并行区域外也可以访问 threadprivate 变量,但每个线程仍然有独立的副本。

总结

threadprivate 是 OpenMP 中的一个重要特性,允许全局和静态变量在不同线程中拥有独立的副本,从而避免多线程并发操作时的数据竞争。它广泛应用于需要线程独立数据的并行计算场景中,同时可以通过 copyin 子句来初始化线程的变量副本。


copyin: 仅可以使用在线程私有变量,目的是 在进入并行域的时候,将主线程的值传递给其他线程(类似firstprivate)

4. 线程绑定

计算资源由操作系统统一调度,默认情况下,线程会在多个核心上来回切换,可能会影响执行效率。使用线程绑定可将线程固定于某核心上执行(单项绑定,非独占)。

线程绑定默认处于关闭状态,通过设置/修改环境变量OMP_PROC_BIND来启用 有五个可选值:

  1. false, 禁用
  2. true, 启用线程绑定,具体行为在程序中设定,ivf默认spread模式
  3. master,主线程所在的place执行所有线程(坐同一个凳子)
  4. close,线程紧密排列所在places(挨着坐)
  5. spread,线程分散在所有places(尽量分开坐)

也可以在omp parallel结构添加proc_bind(master| close| spread)子句来指定线程绑定策略(环境变量OMP_PROC_BIND不能是false)

PLACE: 一个PLACE代表一组核心的集合,实质就是把核心分组

使用 OMP_PROC_BIND=true 来绑定线程到处理器核心:

export OMP_PROC_BIND=true

或者:

#include <omp.h>
#include <stdio.h>

int main() {
    omp_set_num_threads(4);  // 设置使用4个线程

    #pragma omp parallel
    {
        int thread_id = omp_get_thread_num();
        int num_threads = omp_get_num_threads();
        printf("Thread %d out of %d threads\n", thread_id, num_threads);
    }

    return 0;
}

在此示例中,如果 OMP_PROC_BIND 设置为 true,每个线程都会绑定到一个特定的 CPU 核心。

函数 omp_set_proc_bind

从 OpenMP 4.0 开始,可以使用函数 omp_set_proc_bind 来在代码中动态设置线程绑定策略。它的用法如下:

omp_set_proc_bind(omp_proc_bind_t proc_bind);

omp_proc_bind_t 是一个枚举类型,允许的值包括:

  • omp_proc_bind_false:不进行绑定。
  • omp_proc_bind_true:启用绑定。
  • omp_proc_bind_spread:线程在核上均匀分布。
  • omp_proc_bind_close:线程尽可能紧密地绑定在相邻的核上。
  1. 线程绑定示例

以下是一个使用 OMP_PROC_BIND 的简单示例,说明如何在 OpenMP 中进行线程绑定:

#include <omp.h>
#include <stdio.h>

int main() {
    omp_set_num_threads(4);  // 设置4个线程

    #pragma omp parallel
    {
        int thread_id = omp_get_thread_num();
        int num_procs = omp_get_num_procs();
        int proc_num = omp_get_thread_num();
        printf("Thread %d is running on processor %d out of %d processors\n",
               thread_id, proc_num, num_procs);
    }

    return 0;
}

输出(假设有 4 个处理器核心):

Thread 0 is running on processor 0 out of 4 processors
Thread 1 is running on processor 1 out of 4 processors
Thread 2 is running on processor 2 out of 4 processors
Thread 3 is running on processor 3 out of 4 processors

这里每个线程被绑定到不同的处理器核心。omp_get_num_procs() 获取系统中可用的处理器数,omp_get_thread_num() 获取当前线程的编号。

  1. 线程绑定的策略

在 OpenMP 中,OMP_PROC_BINDomp_set_proc_bind 提供了几种线程绑定策略,可以根据硬件架构和应用程序的特定需求选择合适的策略:

  • spread:用于将线程均匀地分配到多个处理器核上。这种策略适用于具有大量线程的并行程序,旨在最大限度利用 CPU 核的可用性。
  • close:将线程尽可能分配到相邻的核心。适合那些线程间通信频繁的程序,能够减少跨 CPU 核通信的延迟。
  • master:将线程绑定到主线程的核心,这种策略有助于优化数据在局部缓存中的利用。
  1. Pthreads 中的线程绑定

在 POSIX 线程(Pthreads)中,可以通过使用 pthread_setaffinity_np 函数来设置线程的 CPU 亲和性(CPU Affinity),即将线程绑定到特定的处理器上。

#include <pthread.h>
#include <stdio.h>
#include <sched.h>

void *thread_function(void *arg) {
    int thread_id = *((int *)arg);
    cpu_set_t cpuset;
    
    // 初始化 CPU 亲和性集,并将线程绑定到核心 thread_id
    CPU_ZERO(&cpuset);
    CPU_SET(thread_id, &cpuset);
    
    pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);

    printf("Thread %d is bound to CPU %d\n", thread_id, thread_id);
    return NULL;
}

int main() {
    pthread_t threads[4];
    int thread_ids[4] = {0, 1, 2, 3};

    // 创建4个线程,并分别绑定到不同的 CPU 核
    for (int i = 0; i < 4; i++) {
        pthread_create(&threads[i], NULL, thread_function, &thread_ids[i]);
    }

    // 等待线程结束
    for (int i = 0; i < 4; i++) {
        pthread_join(threads[i], NULL);
    }

    return 0;
}

在这个例子中,pthread_setaffinity_np 函数将每个线程绑定到指定的 CPU 核心。