1. OpenMP基础
并行计算是处理多任务或者处理多数据的一项技术
多任务:把任务分成多个片段,使用多个计算核心,同时执行任务我片段,达到减少计算耗时的目的,比如:OMP, MPI
多数据: 同时对多个数据进行相同的操作,比如:CUDA, SIMD
前提:1. 拥有多个计算设备或核心 2. 任务条件:任务或数据可划分成多个独立片段
- 并非所有任务适合并行
- 并行计算有额外的开销(调度,数据同步)总计算量多于 串行代码
- 并行可以减少一些计算耗时,但是并行计算的耗时也可能多于串行代码(任务并行度低或代码糟糕)
- 需要我们去选择合适的并行方式
单机多核计算:OpenMP 多节点并行:MPI GPU设备加速:CUDA / OpenACC 三种方案可混合使用,MPI也适合单机,OMP也支持设备加速
为了方便并行,减少错误,建议Fortran用户使用纯函数
1.1 OpenMP(Open Multi-Processing)
编译器自带OMP,无需额外安装
- 核心数指的是CPU的物理核心数
- OMP的线程数一般不大于CPU核数
- 区分CPU线程(客观存在)和 OMP线程(程序设定)
OpenMP是一种基于共享内存系统(单机)的多线程并行方案,支持C/C++ 和 Fortran语言,采用fork-join的模式,启用多个线程并行执行代码的特定部分
线程0分裂成多个,成为虚拟的并行区里的内容,后面回归主线程
优点:
- 简单易学,代码改动小
- 可以自由切换并行/串行模式
- 可以在线程间共享内存(变量),节省内存开销,便于线程间数据传递,减少数据同步耗时
缺点:
- 不规范会造成数据竞争,需要大幅度修改
- 不适合复杂的线程同步和互斥操作(但在计算密集的东西这是不多见的)
- 不能用在非共享内存系统(如计算集群),但支持主机意外的计算设备
推荐书籍:多核并行高性能计算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 就会失效,没有任何作用,生成的程序是串行
- 如果要使用OMP库函数,需要开启OMP并且在代码中use omp_lib
- 关闭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语言大小写敏感
执行模式:
- 启动程序,主线程(线程0)执行代码块1;
- 遇到 !$omp parallel后,主线程分裂(fork)为多个线程(此处线程为2),每个线程均尝试执行代码块2
- 各线程遇到 !$omp end parallel后,合并(join)为一个线程(主线程),并由主线程执行代码块3;
- 主线程结束程序
文本范围(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");
}
- 动态范围 (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函数(子程序),并行的子句等确定
- 环境变量 OMP_NUM_THREADS 缺省(默认)线程数一般等于CPU核数
- 子程序 omp_set_num_threads 影响后续所有并行域的线程数
- 子句 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) :这是英特尔等厂商的一种技术,允许每个物理核心模拟成两个逻辑核心。这样看起来像是双倍的核心数,但实际上物理核心并没有增加,只是通过更好地利用核心的空闲时间来处理任务。
不宜超过:
- 开辟,释放线程比较耗时
- 单个核心处理多个线程会降低效率(线程切换)
- 线程数过多会造成内存和堆栈负担
嵌套并行
嵌套并行(nested parallelism)是一种并行计算的技术,允许一个并行任务的内部再启动一个或多个并行任务,也就是“并行中的并行”。这种模式通常出现在复杂的应用程序中,当一个任务内部的子任务也具备并行处理的潜力时,使用嵌套并行能够进一步提升性能。
嵌套并行的核心概念:
- 一级并行(outer parallelism) :最外层的并行任务,例如,在一个大规模矩阵运算中,将矩阵划分为多个子块,每个子块在不同的线程上并行处理。
- 二级或更多级别的并行(inner parallelism) :在某些情况下,每个子块的内部操作也可以并行化。比如,子块内的计算任务可以再拆分成更小的任务,每个任务分配给不同的线程,进一步加速运算。
2.3 子句
omp的数据属性可归为三类:shared, private, threadprivate
进入并行域的时候,如果变量具有私有属性(private, firstprivate, lastprivate, reduction, linear),则会额外开辟nT份(线程数)临时空间来存储数据,并在退出该结构的时候销毁临时数据
在C语言中,#include指令用于引入头文件,头文件的路径可以通过两种不同的方式指定,分别是使用双引号("")或尖括号(<>)。这两者有不同的搜索顺序和用途:
- 使用双引号 (
"")
#include "myheader.h"
-
搜索顺序:
- 编译器会首先从当前源文件所在的目录开始搜索该头文件。
- 如果在当前目录中找不到,则会转到标准系统路径(例如系统头文件目录)进行搜索。
-
常见用途:通常用于项目自定义的头文件,这些文件一般位于当前项目的源文件目录或其他用户指定的目录中。
- 使用尖括号 (
<>)
#include <stdio.h>
-
搜索顺序:
- 编译器会直接从系统的标准头文件路径(如库的安装目录、系统默认的头文件目录)进行搜索。
- 它不会先检查当前目录。
-
常见用途:主要用于系统提供的标准库或第三方库的头文件,如C标准库中的文件(
stdio.h,stdlib.h等)或者你安装的一些库的头文件。
总结:
#include "filename.h":优先从当前目录开始搜索,适用于项目中的自定义头文件。#include <filename.h>:从系统标准路径搜索,适用于系统或第三方库的头文件。
这两个方式的差异可以帮助区分头文件的来源,并避免命名冲突。
- 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;
}
-
num_threads(nT) 参数nT是整型变量或表达式(正整数),设置并行域的线程数
-
default(private | firstprivate | shared | none) 设置并行域变量的默认属性,如果没有显式给定,则其属性就是默认属性,参数为以上四种(私有,承前私有,共享,无)中的一个(C语言只可选shared | none) 注意:C语言中也有 private | firstprivate, 只是default里只有两个可选,C可以在并行域定义局部变量
-
private(a, b, c) 变量a, b, c等为私有变量,每个线程都生成变量的私有备份,同一个变量在不同线程可以有不同值。初始值未知
-
firstprivate(a, b, c) 变量a, b, c等为私有变量,每个线程都生成变量的私有备份,同一个变量在不同线程可以有不同值。初始值为主线程的值
- shared(a, b, c) 变量a, b, c为共享变量,所有线程共享同一片内存。初始值是主线程的值
-
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;
}
-
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; }-
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; }
-
single 与 untied 的交互
当你将 untied 用于一个任务时,这个任务可以在不同的线程上执行,因此有可能破坏 single 指令的结构,具体原因如下:
-
任务调度:
- 使用
single指令的线程在执行到while循环时,实际上是由这个线程控制i的更新和任务的创建。 - 当创建的任务带有
untied子句时,这些任务可能被调度到其他线程上执行,而不是在创建它们的线程上执行。
- 使用
-
动态执行:
- 因为任务是“未绑定”的,任何可用线程都可以执行这些任务,这意味着任务的执行顺序不是固定的。这样,多个任务可能会同时执行并且彼此独立运行,导致多个线程同时进行更新或打印输出。
-
并发执行:
- 由于有多个线程可以并发执行这些“未绑定”的任务,可能会出现多线程同时尝试更新或读取共享变量(如
a数组或i变量)的情况。这可能导致竞争条件。
- 由于有多个线程可以并发执行这些“未绑定”的任务,可能会出现多线程同时尝试更新或读取共享变量(如
结果
- 当
untied被用在任务中时,即使你有一个single指令控制了对while循环的访问,这仍然允许其他线程并发执行untied任务。这种行为导致了在多个线程之间的执行不再是顺序的,而是并发的,从而破坏了single的原始结构。
在并行计算中,taskloop 是 OpenMP 提供的一种指令,专门用于将循环中的迭代分成多个任务来并行执行。它允许你在有多个循环迭代时,将每次迭代分配给不同的线程,以提高并行计算的效率。通过 taskloop,你可以更灵活地控制任务的生成和调度,而不是仅仅依赖静态的循环划分。
- 基本概念
- 并行任务:
taskloop会将循环的每次迭代视为一个独立的任务。这些任务由 OpenMP 运行时系统管理,运行时系统可以动态调度它们,分配给不同的线程执行。 - 动态调度:与传统的循环划分(如
for循环并行化)不同,taskloop更加动态,线程不需要一次分配固定的迭代范围,而是可以根据任务调度器的安排,灵活地接收和执行任务。
- 语法示例
#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_wtime、clock 和 cpu_time 是三种常见的计时方法,但它们在工作原理和使用场景上有所不同。下面对它们分别进行介绍,并对比它们的区别和适用场景。
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; }
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; }
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
- 对比总结
| 特性 | omp_get_wtime | clock | cpu_time |
|---|---|---|---|
| 类型 | 墙上时间(wall-clock time) | CPU 时间 | CPU 时间 |
| 返回值单位 | 秒(double) | CPU 时钟滴答数 | 秒(real) |
| 测量的时间 | 实际运行时间 | 程序在 CPU 上的执行时间 | 程序在 CPU 上的执行时间 |
| 是否线程安全 | 是 | 否 | 否 |
| 适用语言 | C、C++(OpenMP 环境) | C、C++ | Fortran |
| 适用场景 | 并行或整体程序时间测量 | 单线程的 CPU 时间测量 | 单线程的 CPU 时间测量 |
| 是否适合并行程序 | 是 | 否 | 否 |
加速比 = 串行时间 / 并行时间
效率:加速比 / 线程数
3. threadprivate属性
进入并行域的时候,如果某个变量具有私有属性(private, firstprivate, lastprivate, reduction, linear),就会额外开辟nT(线程数)临时空间存储数据,并在退出并行域的时候销毁临时数据。
如果某些变量在各线程中有不同值(私有,例如线程号),且需要在多个并行域中重复使用,多次开辟/释放空间会降低性能,也可能会增加工作量(重复计算)。
一种解决方式是通过共享数组,缺点是:
- 表示更复杂(一维变二维),用线程号确定值
- 不便于并行/串行切换(增加代码修改量)
共享数组优点: 各并行域线程不一致时,可保证值的延续性
在并行编程中,OpenMP 提供了 threadprivate 变量来支持每个线程维护一份独立的副本。与普通的全局变量或静态变量不同,threadprivate 变量允许在多个线程中独立存储和访问同一变量,而不会互相干扰。它是 OpenMP 中非常重要的机制之一,适用于在不同线程中需要不同副本的全局或静态变量场景。
threadprivate的基本概念
在 C 语言中,普通的全局变量和静态变量在所有线程间共享,所有线程对它们的访问会影响同一个存储区域。而 threadprivate 允许将全局或静态变量定义为每个线程的独立副本,也就是说,每个线程都有自己私有的变量副本,互不影响。
threadprivate的语法
要将全局变量或静态变量声明为 threadprivate,你可以使用 OpenMP 的 #pragma omp threadprivate 指令。其语法如下:
#pragma omp threadprivate(list)
list:要定义为threadprivate的全局变量或静态变量的列表(逗号分隔)。
- 示例
以下是一个简单的 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进行赋值和访问,输出结果表明每个线程都有自己的变量副本。
threadprivate的特点
-
线程私有副本:每个线程都有独立的
threadprivate变量副本,线程之间不会共享这个变量。 -
全局或静态变量:
threadprivate只能用于全局变量或静态变量。局部变量不能使用threadprivate。 -
初始化:
threadprivate变量在每个线程中独立初始化。默认情况下,初始化时threadprivate变量的值未定义,但可以通过在主线程初始化变量来在其他线程中继承该初始值。可以使用 OpenMP 的
copyin子句来为所有线程初始化threadprivate变量。例如:#pragma omp parallel copyin(global_var) -
持久性:
threadprivate变量的生命周期与整个程序一致,它在所有线程的生命周期内保持存在和可访问。
threadprivate和private的区别
threadprivate:适用于全局变量和静态变量。每个线程有一份独立的副本,且变量的生命周期跨越整个并行区域,甚至在并行区域之外也可以被线程访问。private:只能用于局部变量或循环变量。每个线程在进入并行区域时都会创建一个局部副本,但这个局部变量只在该并行区域内有效,超出并行区域后它的副本就会被销毁。
- 初始化问题与
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。
- 应用场景
threadprivate 适用于以下几种场景:
- 并行计算中需要独立的数据副本:当多个线程需要同时操作全局变量或静态变量时,使用
threadprivate可以避免数据竞争,保证每个线程有自己独立的数据副本。 - 线程持久性数据:某些全局数据需要在程序整个执行过程中保持线程独立性,特别是跨多个并行区域的情况下,
threadprivate提供了这种持久性。
- 注意事项
- 变量作用域:
threadprivate只能作用于全局或静态变量,不能作用于局部变量。 - 生命周期:
threadprivate变量的生命周期与整个程序一致,而不是仅限于并行区域。在并行区域外也可以访问threadprivate变量,但每个线程仍然有独立的副本。
总结
threadprivate 是 OpenMP 中的一个重要特性,允许全局和静态变量在不同线程中拥有独立的副本,从而避免多线程并发操作时的数据竞争。它广泛应用于需要线程独立数据的并行计算场景中,同时可以通过 copyin 子句来初始化线程的变量副本。
copyin: 仅可以使用在线程私有变量,目的是 在进入并行域的时候,将主线程的值传递给其他线程(类似firstprivate)
4. 线程绑定
计算资源由操作系统统一调度,默认情况下,线程会在多个核心上来回切换,可能会影响执行效率。使用线程绑定可将线程固定于某核心上执行(单项绑定,非独占)。
线程绑定默认处于关闭状态,通过设置/修改环境变量OMP_PROC_BIND来启用 有五个可选值:
- false, 禁用
- true, 启用线程绑定,具体行为在程序中设定,ivf默认spread模式
- master,主线程所在的place执行所有线程(坐同一个凳子)
- close,线程紧密排列所在places(挨着坐)
- 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:线程尽可能紧密地绑定在相邻的核上。
- 线程绑定示例
以下是一个使用 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() 获取当前线程的编号。
- 线程绑定的策略
在 OpenMP 中,OMP_PROC_BIND 和 omp_set_proc_bind 提供了几种线程绑定策略,可以根据硬件架构和应用程序的特定需求选择合适的策略:
spread:用于将线程均匀地分配到多个处理器核上。这种策略适用于具有大量线程的并行程序,旨在最大限度利用 CPU 核的可用性。close:将线程尽可能分配到相邻的核心。适合那些线程间通信频繁的程序,能够减少跨 CPU 核通信的延迟。master:将线程绑定到主线程的核心,这种策略有助于优化数据在局部缓存中的利用。
- 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 核心。