OpenMP 教程(一) 深入人剖析 OpenMP reduction 子句

前言

在前面的教程OpenMP入门当中我们简要介绍了 OpenMP 的一些基础的使用方法,在本篇文章当中我们将从一些基础的问题开始,然后仔细介绍在 OpenMP 当中 reduction 子句的各种使用方法。

从并发求和开始

我们的任务是两个线程同时对一个变量 data 进行 ++操作,执行 10000 次,我们看下面的代码有什么问题:

  1. #include <stdio.h>
  2. #include <omp.h>
  3. #include <unistd.h>
  4. static int data;
  5. int main() {
  6. #pragma omp parallel num_threads(2) // 使用两个线程同时执行上面的代码块
  7. {
  8. for(int i = 0; i < 10000; i++) {
  9. data++;
  10. usleep(10);
  11. }
  12. // omp_get_thread_num 函数返回线程的 id 号 这个数据从 0 开始,0, 1, 2, 3, 4, ...
  13. printf("data = %d tid = %d\n", data, omp_get_thread_num());
  14. }
  15. printf("In main function data = %d\n", data);
  16. return 0;
  17. }

在上面的代码当中,我们开启了两个线程并且同时执行 $pragma 下面的代码块,但是上面的程序有一个问题,就是两个线程可能同时执行 data++ 操作,但是同时执行这个操作的话,就存在并发程序的数据竞争问题,在 OpenMP 当中默认的数据使用方式就是‍♂️线程之间是共享的比如下面的执行过程:

  • 首先线程 1 和线程 2 将 data 加载到 CPU 缓存当中,当前的两个线程得到的 data 的值都是 0 。
  • 线程 1 和线程 2 对 data 进行 ++ 操作,现在两个线程的 data 的值都是 1。
  • 线程 1 将 data 的值写回到主存当中,那么主存当中的数据的值就等于 1 。
  • 线程 2 将 data 的值写回到主存当中,那么主存当中的数据的值也等于 1 。

但是上面的执行过程是存在问题的,因为我们期望的是主存当中的 data 的值等于 2,因此上面的代码是存在错误的。

解决求和问题的各种办法

使用数组巧妙解决并发程序当中的数据竞争问题

在上面的程序当中我们使用了一个函数 omp_get_thread_num 这个函数可以返回线程的 id 号,我们可以根据这个 id 做一些文章,如下面的程序:


  1. #include <stdio.h>
  2. #include <omp.h>
  3. #include <unistd.h>
  4. static int data;
  5. static int tarr[2];
  6. int main() {
  7. #pragma omp parallel num_threads(2)
  8. {
  9. int tid = omp_get_thread_num();
  10. for(int i = 0; i < 10000; i++) {
  11. tarr[tid]++;
  12. usleep(10);
  13. }
  14. printf("tarr[%d] = %d tid = %d\n", tid, tarr[tid], tid);
  15. }
  16. data = tarr[0] + tarr[1];
  17. printf("In main function data = %d\n", data);
  18. return 0;
  19. }

在上面的程序当中我们额外的使用了一个数组 tarr 用于保存线程的本地的和,然后在最后在主线程里面讲线程本地得到的和相加起来,这样的话我们得到的结果就是正确的了。

  1. $./lockfree01.out
  2. tarr[1] = 10000 tid = 1
  3. tarr[0] = 10000 tid = 0
  4. In main function data = 20000

在上面的程序当中我们需要知道的是,只有当并行域当中所有的线程都执行完成之后,主线程才会继续执行并行域后面的代码,因此主线程在执行代码

  1. data = tarr[0] + tarr[1];
  2. printf("In main function data = %d\n", data);

之前,OpenMP 中并行域中的代码全部执行完成,因此上面的代码执行的时候数组 tarr 中的结果已经计算出来了,因此上面的代码最终的执行结果是 2000。

reduction 子句

在上文当中我们使用数组去避免多个线程同时操作同一个数据的情况,除了上面的方法处理求和问题,我们还有很多其他方法去解决这个问题,下面我们使用 reduction 子句去解决这个问题:

  1. #include <stdio.h>
  2. #include <omp.h>
  3. #include <unistd.h>
  4. static int data;
  5. int main() {
  6. #pragma omp parallel num_threads(2) reduction(+:data)
  7. {
  8. for(int i = 0; i < 10000; i++) {
  9. data++;
  10. usleep(10);
  11. }
  12. printf("data = %d tid = %d\n", data, omp_get_thread_num());
  13. }
  14. printf("In main function data = %d\n", data);
  15. return 0;
  16. }

在上面的程序当中我们使用了一个子句 reduction(+:data) 在每个线程里面对变量 data 进行拷贝,然后在线程当中使用这个拷贝的变量,这样的话就不存在数据竞争了,因为每个线程使用的 data 是不一样的,在 reduction 当中还有一个加号,这个加号表示如何进行规约操作,所谓规约操作简单说来就是多个数据逐步进行操作最终得到一个不能够在进行规约的数据。

例如在上面的程序当中我们的规约操作是 + ,因此需要将线程 1 和线程 2 的数据进行 + 操作,即线程 1 的 data 加上 线程 2 的 data 值,然后将得到的结果赋值给全局变量 data,这样的话我们最终得到的结果就是正确的。

如果有 4 个线程的话,那么就有 4 个线程本地的 data(每个线程一个 data)。那么规约(reduction)操作的结果等于:

(((data1 + data2) + data3) + data4) 其中 datai 表示第 i 个线程的得到的 data 。

除了后面的两种方法解决多个线程同时对一个数据进行操作的问题的之外我们还有一些其他的办法去解决这个问题,我们在下一篇文章当中进行仔细分析。

深入剖析 reduction 子句

我们在写多线程程序的时候可能会存在这种需求,每个线程都会得到一个数据的结果,然后在最后需要将每个线程得到的数据进行求和,相乘,或者逻辑操作等等,在这种情况下我们可以使用 reduction 子句进行操作。redcution 子句的语法格式如下:

  1. reduction(操作符:变量)

当我们使用 reduction 子句的时候线程使用的是与外部变量同名的变量,那么这个同名的变量的初始值应该设置成什么呢?具体的设置规则如下所示:

运算符 初始值
+/加法 0
*/乘法 1
&&/逻辑与 1
||/逻辑或 0
min/最小值 对应类型的最大值
max/最大值 对应类型的最小值
&/按位与 所有位都是 1
|/按位或 所有位都是 0
^/按位异或 所有位都是 0

下面我们使用各种不同的例子去分析上面的所有的条目:

加法+操作符

我们使用下面的程序去测试使用加法规约的正确性,并且在并行域当中打印进行并行域之前变量的值。

  1. #include <stdio.h>
  2. #include <omp.h>
  3. static int data;
  4. int main() {
  5. #pragma omp parallel num_threads(2) reduction(+:data)
  6. {
  7. printf("初始值 : data = %d tid = %d\n", data, omp_get_thread_num());
  8. if(omp_get_thread_num() == 0) {
  9. data = 10;
  10. }else if(omp_get_thread_num() == 1){
  11. data = 20;
  12. }
  13. printf("变化后的值 : data = %d tid = %d\n", data, omp_get_thread_num());
  14. }
  15. printf("规约之后的值 : data = %d\n", data);
  16. return 0;
  17. }

上面的程序的输出结果如下所示:

  1. 初始值 : data = 0 tid = 0
  2. 变化后的值 : data = 10 tid = 0
  3. 初始值 : data = 0 tid = 1
  4. 变化后的值 : data = 20 tid = 1
  5. 规约之后的值 : data = 30

从上面的输出结果我们可以知道当进入并行域之后我们的变量的初始值等于 0 ,第一个线程的线程 id 号等于 0 ,它将 data 的值赋值成 10 ,第二个线程的线程 id 号 等于 1,它将 data 的值赋值成 20 。在出并行域之前会将两个线程得到的 data 值进行规约操作,在上面的代码当中也就是+操作,并且将这个值赋值给全局变量 data 。

乘法*操作符


  1. #include <stdio.h>
  2. #include <omp.h>
  3. static int data = 2;
  4. int main() {
  5. #pragma omp parallel num_threads(2) reduction(*:data)
  6. {
  7. printf("初始值 : data = %d tid = %d\n", data, omp_get_thread_num());
  8. if(omp_get_thread_num() == 0) {
  9. data = 10;
  10. }else if(omp_get_thread_num() == 1){
  11. data = 20;
  12. }
  13. printf("变化后的值 : data = %d tid = %d\n", data, omp_get_thread_num());
  14. }
  15. printf("规约之后的值 : data = %d\n", data);
  16. return 0;
  17. }

上面的程序输出结果如下所示:

  1. 初始值 : data = 1 tid = 0
  2. 变化后的值 : data = 10 tid = 0
  3. 初始值 : data = 1 tid = 1
  4. 变化后的值 : data = 20 tid = 1
  5. 规约之后的值 : data = 400

从上面的程序的输出结果来看,当我们使用*操作符的时候,我们可以看到程序当中 data 的初始值确实被初始化成了 1 ,而且最终在主函数当中的输出结果也是符合预期的,因为 400 = 2 * 10 * 20,其中 2 只在全局变量初始化的时候的值。

逻辑与&&操作符


  1. #include <stdio.h>
  2. #include <omp.h>
  3. static int data = 100;
  4. int main() {
  5. #pragma omp parallel num_threads(2) reduction(&&:data)
  6. {
  7. printf("data =\t %d tid = %d\n", data, omp_get_thread_num());
  8. if(omp_get_thread_num() == 0) {
  9. data = 10;
  10. }else if(omp_get_thread_num() == 1){
  11. data = 20;
  12. }
  13. }
  14. printf("data = %d\n", data);
  15. return 0;
  16. }

上面的程序的输出结果如下所示:

  1. 初始化值 : data = 1 tid = 0
  2. 初始化值 : data = 1 tid = 1
  3. 在主函数当中 : data = 1

从上面的输出结果我们可以知道,程序当中数据的初始化的值是没有问题的,你可能会疑惑为什么主函数当中的 data 值等于 1,这其实就是 C 语言当中对 && 操作服的定义,如果最终的结果为真,那么值就等于 1,即 100 && 10 && 20 == 1,你可以写一个程序去验证这一点。

或||操作符


  1. #include <stdio.h>
  2. #include <omp.h>
  3. static int data = 100;
  4. int main() {
  5. #pragma omp parallel num_threads(2) reduction(||:data)
  6. {
  7. printf("初始化值 : data = %d tid = %d\n", data, omp_get_thread_num());
  8. if(omp_get_thread_num() == 0) {
  9. data = 0;
  10. }else if(omp_get_thread_num() == 1){
  11. data = 0;
  12. }
  13. }
  14. printf("在主函数当中 : data = %d\n", data);
  15. return 0;
  16. }

上面的程序输出结果如下所示:

  1. 初始化值 : data = 1 tid = 0
  2. 初始化值 : data = 1 tid = 1
  3. 在主函数当中 : data = 1

从上面的结果看出,数据初始化的值是正确的,主函数当中得到的数据也是正确的,因为 100 || 0 || 0 == 1,这个也是 C 语言的条件或得到的结果。

MIN 最小值


  1. #include <stdio.h>
  2. #include <omp.h>
  3. static int data = 1000;
  4. int main() {
  5. printf("Int 类型的最大值等于 %d\n", __INT32_MAX__);
  6. #pragma omp parallel num_threads(2) reduction(min:data)
  7. {
  8. printf("data =\t\t %d tid = %d\n", data, omp_get_thread_num());
  9. if(omp_get_thread_num() == 0) {
  10. data = 10;
  11. }else if(omp_get_thread_num() == 1){
  12. data = 20;
  13. }
  14. }
  15. printf("data = %d\n", data);
  16. return 0;
  17. }

上面的程序执行结果如下所示:

  1. Int 类型的最大值等于 2147483647
  2. data = 2147483647 tid = 0
  3. data = 2147483647 tid = 1
  4. data = 10

可以看出来初始化的值是正确的,当我们求最小值的时候,数据被正确的初始化成对应数据的最大值了,然后我们需要去比较这几个值的最小值,即 min(1000, 0, 20) == 10 ,因此在主函数当中的到的值等于 10。

MAX 最大值


  1. #include <stdio.h>
  2. #include <omp.h>
  3. static int data = 1000;
  4. int main() {
  5. #pragma omp parallel num_threads(2) reduction(max:data)
  6. {
  7. printf("data = %d tid = %d\n", data, omp_get_thread_num());
  8. if(omp_get_thread_num() == 0) {
  9. data = 10;
  10. }else if(omp_get_thread_num() == 1){
  11. data = 20;
  12. }
  13. }
  14. printf("data = %d\n", data);
  15. return 0;
  16. }

上面的程序输出结果如下所示:

  1. data = -2147483648 tid = 0
  2. data = -2147483648 tid = 1
  3. data = 1000

可以看出程序被正确的初始化成最小值了,主函数当中输出的数据应该等于 max(1000, 10, 20) 因此也满足条件。

& 按位与


  1. #include <stdio.h>
  2. #include <omp.h>
  3. static int data = 15;
  4. int main() {
  5. #pragma omp parallel num_threads(2) reduction(&:data)
  6. {
  7. printf("data = %d tid = %d\n", data, omp_get_thread_num());
  8. if(omp_get_thread_num() == 0) {
  9. data = 8;
  10. }else if(omp_get_thread_num() == 1){
  11. data = 12;
  12. }
  13. }
  14. printf("data = %d\n", data);
  15. return 0;
  16. }

上面的程序输出结果如下:

  1. data = -1 tid = 0
  2. data = -1 tid = 1
  3. data = 8

首先我们需要知道上面几个数据的比特位表示:

  1. -1 = 1111_1111_1111_1111_1111_1111_1111_1111
  2. 8 = 0000_0000_0000_0000_0000_0000_0000_1000
  3. 12 = 0000_0000_0000_0000_0000_0000_0000_1100
  4. 15 = 0000_0000_0000_0000_0000_0000_0000_1111

我们知道当我们使用 & 操作符的时候初始值是比特为全部等于 1 的数据,也就是 -1,最终进行按位与操作的数据为 15、8、12,即在主函数当中输出的结果等于 (8 & 12 & 15) == 8,因为只有第四个比特位全部为 1,因此最终的结果等于 8 。

|按位或


  1. #include <stdio.h>
  2. #include <omp.h>
  3. static int data = 1;
  4. int main() {
  5. #pragma omp parallel num_threads(2) reduction(|:data)
  6. {
  7. printf("data = %d tid = %d\n", data, omp_get_thread_num());
  8. if(omp_get_thread_num() == 0) {
  9. data = 8;
  10. }else if(omp_get_thread_num() == 1){
  11. data = 12;
  12. }
  13. }
  14. printf("data = %d\n", data);
  15. return 0;
  16. }

上面的程序输出结果如下所示:

  1. data = 0 tid = 0
  2. data = 0 tid = 1
  3. data = 13

我们还是需要了解一下上面的数据的比特位表示:

  1. 0 = 0000_0000_0000_0000_0000_0000_0000_0000
  2. 1 = 0000_0000_0000_0000_0000_0000_0000_0001
  3. 8 = 0000_0000_0000_0000_0000_0000_0000_1000
  4. 12 = 0000_0000_0000_0000_0000_0000_0000_1100
  5. 13 = 0000_0000_0000_0000_0000_0000_0000_1101

线程初始化的数据等于 0 ,这个和前面谈到的所有的比特位都设置成 0 是一致的,我们对上面的数据进行或操作之后得到的结果和对应的按位或得到的结果是相符的。

^按位异或


  1. #include <stdio.h>
  2. #include <omp.h>
  3. static int data = 1;
  4. int main() {
  5. #pragma omp parallel num_threads(2) reduction(^:data)
  6. {
  7. printf("data = %d tid = %d\n", data, omp_get_thread_num());
  8. if(omp_get_thread_num() == 0) {
  9. data = 8;
  10. }else if(omp_get_thread_num() == 1){
  11. data = 12;
  12. }
  13. }
  14. printf("data = %d\n", data);
  15. return 0;
  16. }

上面的程序的输出结果如下所示:

  1. data = 0 tid = 0
  2. data = 0 tid = 1
  3. data = 5

各个数据的比特位表示:

  1. 0 = 0000_0000_0000_0000_0000_0000_0000_0000
  2. 1 = 0000_0000_0000_0000_0000_0000_0000_0001
  3. 8 = 0000_0000_0000_0000_0000_0000_0000_1000
  4. 12 = 0000_0000_0000_0000_0000_0000_0000_1100
  5. 5 = 0000_0000_0000_0000_0000_0000_0000_0101

大家可以自己对照的进行异或操作,得到的结果是正确的。

总结

在本篇文章当中我们主要使用一个例子介绍了如何解决并发程序当中的竞争问题,然后也使用了 reduction 子句去解决这个问题,随后介绍了在 OpenMP 当中 reduction 各种规约符号的使用!

在本篇文章当中主要给大家介绍了 OpenMP 的基本使用和程序执行的基本原理,在后续的文章当中我们将仔细介绍各种 OpenMP 的子句和指令的使用方法,希望大家有所收获!


更多精彩内容合集可访问项目:https://github.com/Chang-LeHung/CSCore

关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识。

OpenMP 教程(一) 深入人剖析 OpenMP reduction 子句的更多相关文章

  1. WPF入门教程系列(二) 深入剖析WPF Binding的使用方法

    WPF入门教程系列(二) 深入剖析WPF Binding的使用方法 同一个对象(特指System.Windows.DependencyObject的子类)的同一种属性(特指DependencyProp ...

  2. Git教程之多人协作

    当你从远程仓库克隆时,实际上Git自动把本地的master分支和远程的master分支对应起来了,并且,远程仓库的默认名称是origin.要查看远程库的信息,用git remote:

  3. Git详细教程(2)---多人协作开发

    Git可以完成两件事情: 1. 版本控制 2.多人协作开发 如今的项目,规模越来越大,功能越来越多,需要有一个团队进行开发. 如果有多个开发人员共同开发一个项目,如何进行协作的呢. Git提供了一个非 ...

  4. Git教程Git多人协作开发

    Git可以完成两件事情: 1. 版本控制 2.多人协作开发 如今的项目,规模越来越大,功能越来越多,需要有一个团队进行开发. 如果有多个开发人员共同开发一个项目,如何进行协作的呢. Git提供了一个非 ...

  5. Git 基础教程 之 多人协作

           多人协作时,从远程克隆时,默认情况下,只能看到master分支 git checkout -b dev origin/dev 创建远程origin的dev分支到本地 git branch ...

  6. Delphi XE5教程7:单元引用和uses 子句

    内容源自Delphi XE5 UPDATE 2官方帮助<Delphi Reference>,本人水平有限,欢迎各位高人修正相关错误! 也欢迎各位加入到Delphi学习资料汉化中来,有兴趣者 ...

  7. 利用OpenMP实现埃拉托斯特尼(Eratosthenes)素数筛法并行化 分类: 算法与数据结构 2015-05-09 12:24 157人阅读 评论(0) 收藏

    1.算法简介 1.1筛法起源 筛法是一种简单检定素数的算法.据说是古希腊的埃拉托斯特尼(Eratosthenes,约公元前274-194年)发明的,又称埃拉托斯特尼筛法(sieve of Eratos ...

  8. OpenMP入门教程(三)

    承接前面两篇,这里直接逐一介绍和使用有关OpenMP的指令和函数 Directives 1.for 作用:for指令指定紧随其后的程序的循环的迭代必须由团队并行执行,只是假设已经建立了并行区域,否则它 ...

  9. openMP的一点使用经验【非原创】

    按照百科上说的,针对于openmp的编程,最简单的就是在开头加个#include<omp.h>,然后在后面的for上加一行#pragma omp parallel for即可,下面的是较为 ...

随机推荐

  1. 刷题记录:Codeforces Round #719 (Div. 3)

    Codeforces Round #719 (Div. 3) 20210703.网址:https://codeforces.com/contest/1520. 没错,我是个做div3的蒟蒻-- A 大 ...

  2. CF914G Sum the Fibonacci (快速沃尔什变换FWT + 子集卷积)

    题面 题解 这是一道FWT和子集卷积的应用题. 我们先设 cnt[x] 表示 Si = x 的 i 的数量,那么 这里的Nab[x]指满足条件的 Sa|Sb=x.Sa&Sb=0 的(a,b)二 ...

  3. .NET 7 性能改进 -- 至今为止最快的.NET平台

    2022年8月31日 Stephen Toub 发布的关于 .NET 7 性能改进的博客, 核心主题是 .NET 7 速度很快. 这篇博客非常的长,我尝试将它拷贝到Word 里,拷贝的时间都花了几分钟 ...

  4. 第六章:Django 综合篇 - 18:国际化和本地化

    所谓的国际化,是指使用不同语言的用户在访问同一个网站页面时能够看到符合其自身语言的文本页面. 国际化的基本原理是: 浏览器通过LANGUAGE_CODE在HTTP请求头中告诉网站后台服务器用户所需要的 ...

  5. 使用kubeoperator安装k8s集群时自带的traefik-ingress-controller

    前提 承接上一篇文章:https://www.cnblogs.com/sanduzxcvbnm/p/15740596.html traefik-ingress-controller和nginx-ing ...

  6. Raft 共识算法

    转载请注明出处:https://www.cnblogs.com/morningli/p/16745294.html raft是一种管理复制日志的算法,raft可以分解成三个相对独立的子问题: 选主(L ...

  7. 分布式存储系统之Ceph集群MDS扩展

    前文我们了解了cephfs使用相关话题,回顾请参考https://www.cnblogs.com/qiuhom-1874/p/16758866.html:今天我们来聊一聊MDS组件扩展相关话题: 我们 ...

  8. 巧用VBA实现:基于多个关键词模糊匹配Excel多行数据

    在用Excel处理实际业务中,我们会碰到如下场景: 1.从一堆人名中找到包含某些关键字的名字: 2.从银行流水文件中根据[备注]字段找到包含某些关键字的,统一识别为[手续费业务]等. 这本质说的都是一 ...

  9. Java学习之路:快捷键

    常用的快捷键 Ctrl+Shift:切换输入法 Ctrl+C:复制 Ctrl+V:粘贴 Ctrl+X:剪切 Ctrl+A:全选 Ctrl+Z:撤销 Ctrl+Y:返回撤销 Ctrl+S:保存 Shif ...

  10. TomCat之负载均衡

    TomCat之负载均衡 本文讲述了tomcat当nginx负载均衡服务器配置步骤 以下是Tomcat负载均衡配置信息 1.修改nginx的nginx.conf文件 添加如下属性:localhost是名 ...