C/C++实现高性能并行计算——2.使用OpenMP进行共享内存编程

发布于:2024-05-04 ⋅ 阅读:(31) ⋅ 点赞:(0)

系列文章目录

  1. pthreads并行编程(上)
  2. pthreads并行编程(中)
  3. pthreads并行编程(下)
  4. 使用OpenMP进行共享内存编程


前言

OpenMP(Open Multi-Processing)是一个支持多平台共享内存多处理编程的应用程序接口(API),它用于编写在多处理器计算机上高效运行的程序。OpenMP是一种使用编译器指令以及库调用和环境变量来实现的并行编程模型。它主要用于C、C++和Fortran语言。

OpenMP和Pthread都是统一内存访问,而MPI是非统一内存访问。


一、简单介绍OpenMP

在这里插入图片描述
OpenMP在编译过程中通过代码中的简单声明,编译器会自动进行并行(这就需要编译器支持一些操作)

在这里插入图片描述
在这里插入图片描述


二、预处理器指令

预处理器指令是C、C++和其他一些编程语言中的一种特殊的代码行,它们在编译代码之前由预处理器执行。预处理器指令提供了一种机制,通过它可以在实际编译之前对源代码文件进行修改和配置。这些指令以井号(#)开始,表明它们是给预处理器的指示,而不是编译器。

OpenMP提供基于指令的共享内存API:OpenMP通过编译器指令(也称为编译器指示或编译器pragma)来实现共享内存并行编程模型。这种方式允许程序员通过在代码中插入特定的指令来控制多线程的行为,而无需修改底层线程管理或内存同步的机制。这些指令为程序员提供了一个相对简单的方法来实现并行处理,同时还可以保持代码的可读性和可维护性。

#pragma:这条指令用于向编译器提供特定的命令或指示,其具体含义依赖于编译器。例如,在OpenMP中,#pragma omp parallel用于指示编译器下面的代码块应该并行执行。

基于指令的共享内存API的工作原理:

  1. 共享内存并行性:在OpenMP中,所有线程都可以访问相同的物理内存地址空间(共享内存),这意味着任何线程都可以访问程序中的任何变量。这种模型简化了数据的管理和访问,但也需要注意数据同步和防止竞争条件。
  2. 编译器指令:OpenMP使用特殊的预处理指令(如#pragma omp)来标识并行区域的开始和结束,以及其他并行控制结构,例如循环并行化(#pragma omp for)、任务并行化(#pragma omp task)等。这些指令告诉编译器哪些代码块应该并行执行。
  3. 简化并行编程:通过使用这些指令,OpenMP允许开发者专注于程序的逻辑部分,而不必担心底层的线程创建、管理或复杂的同步机制。编译器和OpenMP运行时库共同处理这些复杂的任务。

为了使用gcc编译omp程序,需要包含-fopenmp选项


三、Hello world代码

  #include <stdio.h>
  #include <stdlib.h>
  #include <omp.h>
  
  void hello(void){
  |   int my_rank = omp_get_thread_num();
  |   int thread_count = omp_get_num_threads();  // 一共有多少个线程
  |   printf("Hello from thread %d of %d.\n", my_rank, thread_count);
  |   return;
  }
  
  int main(int argc, char* argv[]){
  |   int thread_count = strtol(argv[1], NULL, 10);
  
      #pragma omp parallel num_threads(thread_count)
  |   hello();
  
  |   return 0;
  }

运行结果:
在这里插入图片描述在这里插入图片描述

注意:如果没有指定 num_threads 子句,OpenMP会自动根据环境设置(如环境变量或系统配置)决定使用多少线程。这可以是一个基于系统资源自动优化的选择。


四、pragma omp parallel for num_thread<num>

在这里插入图片描述如果只想要for循环并行的话,上述的parallel是必须要的

例子:

#pragma omp parallel for num_threads(thread_count)
for (int i = 0; i < 1000; i++)
	s[i] = a[i] + b[i];

五、数据依赖

在循环中的计算依赖于一个或者更多个先前的迭代结果。
例如考虑斐波那契数列:

f[0] = f[1] = 1;
#pragma omp parallel for num_threads)thread_count)
for (int i = 2; i < 10; i++)
	f[i] = f[i-1] + f[i-2];

这样就会出错
在这里插入图片描述


六、OpenMP Private Variables

在这里插入图片描述

  • Private在#pragma中作为一个可选的,附加的选项
  • 它能够直接的告诉编译器去使得共享变量作为每个线程的私有变量
  • #pragma omp … private(< variable list >)

例子:

int i;
float *a, *b, *c, tmp;  //tmp应该是一个私有的
...
for (i = 0; i < N; i++){
	tmp = a[i]/b[i];
	c[i] = tmp*tmp;
}

如果要并行的话就改成

int i;
float *a, *b, *c, tmp;  //tmp应该是一个私有的
...
#pragma omp parallel for num_threads(thread_count) private(tmp)
for (i = 0; i < N; i++){
	tmp = a[i]/b[i];
	c[i] = tmp*tmp;
}

在这里插入图片描述

6.1 firstprivate

在OpenMP中,firstprivate是一种数据共享属性指令,它用来管理变量在并行区域中的作用域和行为。使用firstprivate指令时,每个线程获得指定变量的私有拷贝,且该拷贝的初始值是该变量在并行区域开始前的值。

firstprivate 的功能和用途:

  1. 初始值的复制:通常在并行区域内创建的私有变量不会自动初始化,即它们的初始值是不确定的。但当使用firstprivate时,每个线程中的私有变量都会以并行区域之外该变量的最后值作为初始值。

  2. 适用性:这个属性特别有用当需要在并行执行的多个线程中使用同一变量,但又希望每个线程都有自己的一份带有初始值的拷贝时。

  3. 避免数据竞争:通过为每个线程创建变量的私有拷贝,firstprivate有助于避免数据竞争和不必要的数据依赖,这对于保持代码的正确性和提高性能非常重要。

下面是一个简单的例子,展示了如何在OpenMP中使用firstprivate

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

int main() {
    int a = 5;  // 初始化变量a

    #pragma omp parallel for firstprivate(a)
    for (int i = 0; i < 4; i++) {
        a += i;  // 每个线程都会有自己的a,并从5开始递增
        printf("Thread %d, a = %d\n", omp_get_thread_num(), a);
    }

    // 在并行区域之后输出a的值,检查其是否被改变
    printf("Outside parallel region, a = %d\n", a);

    return 0;
}

在这个程序中:

  • 变量a在并行区域开始之前被初始化为5。
  • 每个线程获得a的一个私有拷贝,初始值为5。
  • 每个线程更新其私有变量a,但这些更新不会影响其他线程或主线程中的a
  • 并行区域外的变量a的值仍然是5,因为它没有被并行执行的代码修改。

通过使用firstprivate,你可以确保每个线程在并行计算开始时都有正确的初始值,并且修改这些私有变量不会影响到并行区域外的原始变量。这使得firstprivate在处理需要初始化状态的并行计算中非常有用。
在这里插入图片描述

6.2 lastprivate

在OpenMP中,lastprivate 是一个数据共享属性指令,类似于 firstprivate,但用途和行为有所不同。lastprivate 使得在并行区域中,每个线程都有一个指定变量的私有拷贝,并且在并行区域结束时,由执行最后迭代的线程中的变量的值赋给原始变量。这在确保从并行循环中获取最后一个迭代的结果时非常有用。

lastprivate 的功能和用途:

  1. 保持最后迭代的结果:在并行循环结构中,lastprivate确保循环结束后,可以获取到在最后一个迭代中计算的值。这对于依赖循环最后状态的计算特别重要。

  2. 适用性:这个属性适用于那些需要在并行区域完成后获取最后一次迭代中计算的值的情况。比如,计算累积或序列的最终状态。

  3. 避免数据竞争:与firstprivate 类似,lastprivate 也通过为每个线程提供私有拷贝来帮助避免数据竞争。此外,它保证了循环结束后,主线程能够访问最后一次迭代更新的值。

以下是一个使用 lastprivate 的例子,展示了如何在并行循环中使用这个指令:

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

int main() {
    int x;

    #pragma omp parallel for lastprivate(x)
    for (int i = 0; i < 10; i++) {
        x = i * 2;  // 每个迭代都更新x的值
    }

    // 循环结束后,x的值应该是最后一次迭代的结果
    printf("The last value of x is %d\n", x);

    return 0;
}

在这个程序中:

  • x 是通过 lastprivate 使得每个线程都有其私有副本,并在并行区域结束时,循环中最后一个迭代(i = 9)的结果(x = 18)被赋值给原始的 x
  • 因此,主线程中的 x 将包含循环中的最后一个迭代计算的结果。

lastprivate 在处理需要保留来自并行循环最后迭代的结果的计算时特别有用。通过使用这个指令,开发者可以简化代码,同时保证并行区域之外可以访问到重要的计算结果。


七、section & sections

section 指令是一个用来指定并行区域内不同任务或代码块的工具,它使得不同的线程可以执行不同的任务。这是通过 sectionssection 指令的组合来实现的,其中 sections 指令定义了一个包含多个 section 的区域,每个 section 则定义了一个单独的代码块。

使用 sectionssection 的目的:

  1. 并行执行不同任务:使用 sectionssection 可以并行执行代码中不同的部分。这对于程序中有独立可并行的不同代码块的情况非常适用。

  2. 提高效率:允许不同线程同时执行不同的任务,可以有效利用系统资源,提高程序的执行效率。

  3. 简化代码结构:通过明确区分不同的任务,可以让程序的结构更清晰,易于理解和维护。

下面是一个使用 sectionssection 的例子,展示了如何在OpenMP中使用这些指令:

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

int main() {
    #pragma omp parallel sections
    {
        #pragma omp section
        {
            printf("Section 1 executed by thread %d\n", omp_get_thread_num());
            // 在这里执行Section 1的任务
        }

        #pragma omp section
        {
            printf("Section 2 executed by thread %d\n", omp_get_thread_num());
            // 在这里执行Section 2的任务
        }

        #pragma omp section
        {
            printf("Section 3 executed by thread %d\n", omp_get_thread_num());
            // 在这里执行Section 3的任务
        }
    }

    return 0;
}
  • sections在封闭代码的指定部分中,由线程进行分配任务
  • 每个独立的section都需要在sections里面
    • 每个section都是被一个线程执行的
    • 不同的section可能执行不同的任务
    • 如果一个线程够快,该线程可能执行多个section

注意事项:

  • 确保每个 section 中的代码可以安全地并行执行,不会出现数据竞争或其它并发问题。
  • 如果 sections 数量超过了线程数,有些线程可能会执行多个 section,或者某些 section 可能会排队等待可用线程。

通过合理使用 section 指令,可以在OpenMP程序中实现复杂的并行任务,从而有效提升程序性能和并行处理能力。

7.1 single & master指令

在这里插入图片描述在这里插入图片描述
为什么最后是约等于呢?是因为最后那个执行这段命令的不一定是rank等于0的线程,有可能是其他线程。


八、reduction(归并)

reduction 是一种非常有用的数据共享属性指令,用于在并行计算中实现对特定变量进行跨多个线程的归约操作。这种归约操作包括求和、求积、找最大值、最小值等,是并行计算中常见的需求。

在这里插入图片描述在这里插入图片描述本地变量的初始化的值相当于单位元

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

int main() {
    int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int sum = 10;

    #pragma omp parallel for reduction(+:sum)
    for (int i = 0; i < 10; i++) {
        sum += a[i];
    }

    printf("Total sum is %d\n", sum); // 这里计算结果是要考虑最开始sum的值(10),它和lastprivate不一样(lp是直接覆盖)

    return 0;
}

九、同步与互斥

在很多时候,需要线程之间团结协作完成某个任务,这就要求线程能够完成一致协调合作。
Barrier、critical分别用于实现同步与互斥。

9.1 barrier

在这里插入图片描述

使用barrier可以确保所有线程在继续执行前都达到了程序的一个同步点。这通常在以下情况下非常有用:

  • 当前一个操作的结果需要被后续操作使用时。
  • 确保所有线程在继续之前完成了它们的工作部分。

以下是一个使用barrier的示例,展示如何确保所有线程完成了数据的初始化,然后再进入下一阶段的计算:

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

int main() {
    int a[10];
    int total = 0;

    #pragma omp parallel num_threads(4)
    {
        int tid = omp_get_thread_num();

        // 每个线程初始化数组的一部分
        #pragma omp for
        for (int i = 0; i < 10; i++) {
            a[i] = i * 10;
            printf("Thread %d: a[%d] = %d\n", tid, i, a[i]);
        }

        // 等待所有线程完成初始化
        #pragma omp barrier

        // 累加数组元素的值
        #pragma omp for reduction(+:total)
        for (int i = 0; i < 10; i++) {
            total += a[i];
        }
    }

    printf("Total sum is %d\n", total);

    return 0;
}

在这个程序中:

  • 数组a被所有线程共同初始化。每个线程负责数组的一部分。
  • 使用#pragma omp barrier确保所有线程都完成了数组的初始化工作。这是必要的,因为数组元素的初始化和累加是分开进行的,我们需要确保在开始累加之前所有元素都已经被正确初始化。
  • barrier之后,所有线程共同参与对数组元素的累加操作,使用了reduction来合并结果。

注意事项:

  • barrier 会引入显著的同步开销,因为它需要等待所有线程到达同步点。因此,应当仅在必要时使用。
  • OpenMP的循环指令(如#pragma omp for)在循环结束时自带隐式的barrier,除非指定了nowait子句。

使用barrier可以有效地控制程序的执行流程,在需要严格同步的场景中保证数据的一致性和正确性。

在这里插入图片描述

9.1.1 nowait

nowait在OpenMP中,用于打断自动添加的barrier的类型。用法如下

#pragma omp for nowait
#pragma omp signle nowait

在这里插入图片描述

9.2 竞争

以下是正确的程序运行的时候
在这里插入图片描述
下面是一种错误(出现竞争)的情况
在这里插入图片描述
链表节点并行插入也会有竞争情况
在这里插入图片描述

9.2.1 critical

在这里插入图片描述一段代码只能由一个线程进行,这段代码就是临界区,往往和共享数据更新有关

添加头节点示例:

void addHead(struct List* list, struct Node* node){
	#pragma omp critical
	{
		node->next = list->head;
		list->head = node;
	}
}

9.2.2 atomic

在编程中,“原子执行”(Atomic Execution)是指在多线程环境下,一个操作或一系列操作作为一个不可分割的单元执行,即这个操作要么完全执行,要么完全不执行,不会被其他线程的操作打断。这是并行计算中确保数据一致性和防止竞态条件的关键概念。

原子操作的特点

  1. 不可中断:原子操作一旦开始,就会运行完成,不会在中间被其他线程的操作中断。
  2. 可见性:当原子操作完成时,其结果立即对所有线程可见。
  3. 序列化:对同一数据的原子操作,从任何线程看来都是按一定顺序执行的,这个顺序由系统的调度决定。

它只在特殊的情况下使用:

  • 在自增或者自减的情况下使用
  • 在二元操作数的情况下使用
  • 只会应用于一条指令:
    #pragma omp atomic
    counter += 5;

下面是一个使用OpenMP的atomic指令的示例,展示如何安全地在多线程环境中更新共享变量:

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

int main() {
    int sum = 0;

    #pragma omp parallel num_threads(4)
    {
        #pragma omp for
        for (int i = 0; i < 100; i++) {
            #pragma omp atomic
            sum += 1;  // 保证这个操作是原子的
        }
    }

    printf("Sum = %d\n", sum);  // 输出应为100

    return 0;
}

在这个程序中,虽然多个线程都在试图更新sum变量,但通过atomic指令,每次加1的操作都是原子的,确保了最终结果的正确性。

在并行编程中,多个线程可能会同时尝试修改同一个变量,如果没有适当的控制,这会导致不可预测的结果和竞态条件。原子操作通过确保操作的不可分割性,帮助程序员控制这种并发访问,从而保证数据的正确性和程序的稳定性。

总之,原子操作是并行编程中保证数据完整性和程序正确性的重要机制,尤其是在涉及共享资源的情况下。

在这里插入图片描述


十、schedule(调度)

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
schedule指令是一个用于控制循环迭代在多个线程之间的分配方式的指令。这个指令对于优化并行循环的性能非常关键,因为它可以帮助平衡负载、提高缓存利用率和减少线程之间的同步开销。

10.1 schedule指令的类型

OpenMP提供了几种不同的调度策略来分配循环迭代给线程:

  1. 静态调度(static):
    • 迭代在所有线程之间提前分配,通常均匀分配。
    • 适用于迭代执行时间预测较为一致的情况。
    • 可以指定一个块大小(chunk size),即每个线程应该连续处理的迭代数。
  2. 动态调度(dynamic):
    • 迭代在运行时动态分配给线程。
    • 每个线程完成其分配的迭代后,会再次请求新的迭代块。
    • 这种方式适合迭代之间执行时间差异较大的情况,可以较好地实现负载平衡。
    • 同样可以指定块大小。
  3. 指导性调度(guided):
    • 类似于动态调度,但块大小会从较大值逐渐减小到指定的最小块大小,或默认情况下为1。
    • 这种方式试图在减少调度开销和实现负载平衡之间找到平衡。

10.2 静态调度

静态调度(static scheduling)是一种循环迭代分配策略,它在并行区域开始之前将所有迭代均匀地(或尽可能均匀地)分配给线程。这种分配是在运行时之前确定的,并且在循环执行过程中不会改变。静态调度主要用于那些每次迭代所需执行时间大致相同的循环,因为它可以有效地减少运行时的调度开销。

  1. 均匀分配:在没有指定块大小(chunk size)的情况下,OpenMP将尽量均匀地分配迭代给所有线程。例如,如果有20个迭代和4个线程,每个线程将分别处理5个迭代。
  2. 块分配:可以指定一个块大小来决定每个线程一次应处理多少迭代。如果指定了块大小,OpenMP会将迭代分成指定大小的连续块,然后将这些块分配给线程。例如,如果有20个迭代、4个线程和块大小为3,分配会是这样的:线程0处理迭代0-2, 12-14;线程1处理迭代3-5, 15-17;线程2处理迭代6-8, 18-19;线程3处理迭代9-11。
#include <stdio.h>
#include <omp.h>

int main() {
    int i;

    // 使用默认块大小的静态调度
    #pragma omp parallel for schedule(static)
    for (i = 0; i < 20; i++) {
        printf("Thread %d handles iteration %d (static default chunk size)\n", omp_get_thread_num(), i);
    }

    // 使用块大小为3的静态调度
    #pragma omp parallel for schedule(static, 3)
    for (i = 0; i < 20; i++) {
        printf("Thread %d handles iteration %d (static with chunk size 3)\n", omp_get_thread_num(), i);
    }

    return 0;
}

优点:

  • 低调度开销:由于迭代在运行之前就已经分配好,不需要在运行时进行额外的调度计算,这减少了调度开销。
  • 预测性好:迭代的分配是可预测的,这有助于理解程序的行为并优化性能。

缺点:

  • 负载不平衡:如果循环迭代之间的执行时间差异较大,静态调度可能会导致严重的负载不平衡,使得某些线程早早完成任务而其他线程还在忙碌,从而影响程序的总体性能。

在这里插入图片描述

10.3 动态调度

动态调度(dynamic scheduling)是一种在运行时动态分配循环迭代给线程的方法。这种调度方式适用于循环迭代之间的工作负载可能存在较大差异的情况,因为它能够在运行时根据各线程的工作进度动态调整迭代的分配,从而帮助实现更好的负载平衡。

工作原理:
动态调度将循环迭代分组到一定大小的“块”中,并在运行时将这些块分配给请求任务的线程。当一个线程完成其当前块的所有迭代后,它会再次向调度器请求新的迭代块,直到所有的迭代都被处理完毕。

块大小(Chunk Size)

  • 块大小是指定给每个线程的连续迭代数。块大小的选择对性能有重要影响:
    • 较小的块大小可以增强负载平衡,因为工作更频繁地在线程之间重新分配。
    • 较大的块大小可以减少调度开销,因为线程不需要那么频繁地从调度器请求新的工作。
#include <stdio.h>
#include <omp.h>
#include <unistd.h> // For sleep function

int main() {
    // 使用块大小为2的动态调度
    #pragma omp parallel for schedule(dynamic, 2)
    for (int i = 0; i < 10; i++) {
        sleep(i % 3); // 模拟不同迭代的不同工作负载
        printf("Thread %d processed iteration %d\n", omp_get_thread_num(), i);
    }

    return 0;
}

优点:

  • 负载平衡:动态调度通过在运行时动态分配工作,可以更好地处理迭代之间执行时间差异较大的情况,有助于避免某些线程闲置而其他线程过载的情况。
  • 灵活性:适应各种不同工作负载的变化,尤其适用于工作量难以预测的情况。

缺点:

  • 调度开销:与静态调度相比,动态调度在运行时需要更多的调度交互,这增加了开销。
  • 同步开销:线程在完成一个块后需要与调度器交互以获取下一个块,这种频繁的同步可能在某些情况下成为性能瓶颈。

在这里插入图片描述

10.4 指导性调度

在OpenMP中,指导性调度(guided scheduling)是一种动态调度策略,设计用来处理循环迭代之间工作负载差异显著的情况,同时尝试平衡调度开销和负载平衡之间的折衷。指导性调度的关键特点是,它会在运行时动态调整分配给线程的迭代块大小,这些块大小随着未分配的迭代数的减少而逐渐减小。

工作原理

指导性调度开始时分配较大的块,随着时间的推移和可用迭代的减少,块的大小会逐渐减小。这种方法旨在减少早期的调度开销,同时在任务接近结束时提高负载平衡。

块大小计算

块大小是根据剩余迭代数和线程数动态计算的。初始块的大小接近于总迭代数除以线程数的值,而随着每个线程完成其任务块,新的块大小会减少,通常遵循某种减小比率或公式计算。具体的减少策略可能因编译器和库的实现不同而有所差异。

以下示例展示了如何使用指导性调度来并行处理一个循环:

#include <stdio.h>
#include <omp.h>
#include <unistd.h>  // For sleep function

int main() {
    // 使用指导性调度,指定最小块大小为1
    #pragma omp parallel for schedule(guided, 1)
    for (int i = 0; i < 20; i++) {
        sleep(1);  // 模拟每个迭代需要相同时间完成的工作负载
        printf("Thread %d processed iteration %d\n", omp_get_thread_num(), i);
    }

    return 0;
}

优点

  • 负载平衡与调度开销的折衷:通过开始时分配大块且逐渐减小,指导性调度旨在优化调度开销和负载平衡之间的关系,特别适用于迭代工作量逐渐减少的情况。
  • 适应性强:自动调整块大小使得指导性调度能够适应多种运行时情况,尤其是在循环迭代执行时间不确定或非常不均匀时。

缺点

  • 实现依赖性:不同的OpenMP实现可能会以不同的方式执行块大小的减小,这可能会导致性能表现的不一致。
  • 调度开销:虽然比纯动态调度有所改善,但与静态调度相比,指导性调度仍有较高的调度开销。

在这里插入图片描述


总结

  1. openmp的介绍以及使用——预处理器指令,还有专门针对循环的指令。
  2. fork_join模型
  3. 并行区域,并行区内线程之间和非并行区域的数据交互:
    • firstprivate
    • lastprivate
    • reduction
  4. 指定并行区域内不同任务或代码块的工具——sections
  5. 同步与互斥:
    • 同步采取barrier
    • 竞争关系所以需要用到互斥:
      • critical
      • atomic
  6. 调度:
    • 静态调度 static
    • 动态调度 dynamic
    • 指导性调度 guided

参考

  1. C++实现高性能并行计算——⑩使用OpenMP进行共享内存编程

网站公告

今日签到

点亮在社区的每一天
去签到