OpenMP并发编程快速入门

OpenMP是目前被广泛接受的,用于共享内存并行系统的多处理器程序设计的一套指导性的编译处理方案。它提供了对并行算法的高层的抽象描述,程序员通过在源代码中加入专用的pragma来指明自己的意图,由此编译器可以自动将程序进行并行化,并在必要之处加入同步互斥以及通信。本文是OpenMP使用的一个初步介绍,期望能引领读者进入并发编程的世界。

下面这个两篇文章介绍了OpenMP环境配置方法的详细步骤,对此不甚了解的朋友可以先参阅它们以做为必要之准备:

  • OS X上基于OpenMP进行并行程序开发
  • OS X上安装Homebrew和GCC的图文攻略

欢迎关注白马负金羁的博客 http://blog.csdn.net/baimafujinji,为保证公式、图表得以正确显示,强烈建议你从该地址上查看原版博文。本博客主要关注方向包括:数字图像处理、算法设计与分析、数据结构、机器学习、数据挖掘、统计分析方法、自然语言处理。

本文中的示例程序主要围绕一个简单的“利用积分法求 π ”的问题展开。首先,我们给出一个串行执行的版本,通过下面这个示例代码,读者可以大致了解我们这个求圆周率程序的执行过程。因为是串行程序所以我们并不需要用到OpenMP中的任何函数,但是为了和后面的代码相统一,我们这里还是使用了OpenMP中的一个计时函数omp_get_wtime(),该函数返回一个double型的值,计时单位是秒。

#include "stdio.h"
#include "omp.h"

static long num_steps = 100000000;
double step;

int main()
{
    int i;
    double x, pi, sum = 0.0;

    step = 1.0/(double)num_steps;

    double start = omp_get_wtime();

    for(i=0; i<num_steps; i++){
        x = (i+0.5)*step;
        sum = sum + 4.0/(1.0+x*x);
    }
    pi = step * sum;

    double end = omp_get_wtime();


    printf("pi = %f, time = %f s\n", pi, end-start);

    return 0;
}

执行上述代码所得之结果如下:
pi = 3.141593, time = 0.307644 s

下面我们来写第一个基于OpenMP的并行程序。其实OpenMP并发编程的本质就是自动生成若干个线程,以期充分利用现代多核处理器的并行计算能力。现在很多编程语言(例如Java)中也提供有多线程编程的接口或者函数,但是开发人员必须显式地控制这些并发的线程。而OpenMP则相当于提供了一套自动的管理方案,大大简化了开发的难度。

  • 函数omp_set_num_threads()用来指定你准备创建的线程数;
  • 函数omp_get_thread_num()用来获取已经创建的线程编号;
  • 最后把你准备并发实现的部分放进#pragma omp parallel{}标记的大括号内即可。

下面给出示例代码:

#include "stdio.h"
#include "omp.h"

#define NUM_THREADS 4
static long num_steps = 100000000;
double step;

int main()
{
    int i, nthreads;
    double x, pi, sum[NUM_THREADS];
    step = 1.0/(double)num_steps;

    omp_set_num_threads(NUM_THREADS);

    double start = omp_get_wtime();
    #pragma omp parallel
    {
        int i, id, nthrds;
        double x;

        id = omp_get_thread_num();
        nthrds = omp_get_num_threads();
        if(id == 0) nthreads = nthrds;

        for(i=id, sum[id]=0.0; i<num_steps; i=i+nthrds){
            x = (i+0.5)*step;
            sum[id] += 4.0/(1.0+x*x);
        }
    }

    for (i = 0, pi=0.0; i < nthreads; i++)
    {
        pi += step * sum[i];
    }

    double end = omp_get_wtime();

    printf("pi = %f, number of threads = %d, time = %f s\n", pi, NUM_THREADS, end-start);

    return 0;
}

然后我们使用不同的线程数1~4来观测程序的输出如下:
pi = 3.141593, number of threads = 1, time = 0.444881 s
pi = 3.141593, number of threads = 2, time = 0.551260 s
pi = 3.141593, number of threads = 3, time = 0.564319 s
pi = 3.141593, number of threads = 4, time = 0.477291 s
这个结果值得我们讨论的地方有:1)首先当线程数是1的时候,我们的并发程序就会退化成一个串行程序,但是由于我们增加了很多分发和收集的工作(有时我们说这是communication成本),所以它会比我们的串行程序其实更加耗时;2)提高线程的数量,似乎也没有提高程序执行的速度。这是由于出现了“false sharing”现象,即如果独立的数据元素碰巧位于相同的cache line上,那么每次update都会导致cache lines与线程之间“slosh back and forth”(来来回回)。也就是说在串行时的内存访问次数因为比并行时访问的次数少,所以反而串行的程序执行得更快。

为了解决上述这个问题,我们就指定一下cache line的大小,这样单独一个线程每次访问内存时,会一次性带走它所能带走的最大数据量,而且我们还会通过调整代码使得这些数据都会被访问它的线程所使用。从而减少因为“slosh back and forth”而浪费的时间。来看下面这段示例代码:

#include "stdio.h"
#include "omp.h"

#define NUM_THREADS 4
#define PAD 8 //assume 64 byte L1 cache line size
static long num_steps = 100000000;
double step;

int main()
{
    int i, nthreads;
    double x, pi, sum[NUM_THREADS][PAD];

    step = 1.0/(double)num_steps;

    omp_set_num_threads(NUM_THREADS);

    double start = omp_get_wtime();

    #pragma omp parallel
    {
        int i, id, nthrds;
        double x;

        id = omp_get_thread_num();
        nthrds = omp_get_num_threads();
        if(id == 0) nthreads = nthrds;

        for(i=id, sum[id][0]=0.0; i<num_steps; i=i+nthrds){
            x = (i+0.5)*step;
            sum[id][0] += 4.0/(1.0+x*x);
        }
    }

    for (i = 0, pi=0.0; i < nthreads; i++)
    {
        pi += step * sum[i][0];
    }

    double end = omp_get_wtime();

    printf("pi = %f, number of threads = %d, time = %f s\n", pi, NUM_THREADS, end-start);

    return 0;
}

同样我们使用不同的线程数1~4来观测程序的输出如下:
pi = 3.141593, number of threads = 1, time = 0.446988 s
pi = 3.141593, number of threads = 2, time = 0.231959 s
pi = 3.141593, number of threads = 3, time = 0.224227 s
pi = 3.141593, number of threads = 4, time = 0.211466 s

显然这一次我们的程序就执行得快了很多!但是要知道我们所使用的机器的cache line的大小到底是多少,这显然有点为难开发人员了,我们能不能有一种更加优雅的方式来做上面那些事情呢?OpenMP确实也为我们提供了支持。

下面代码演示了OpenMP中提供的一种叫做 Mutual Exclusion的同步模式,在代码中由#pragma omp critical来指示:

#include "stdio.h"
#include "omp.h"

#define NUM_THREADS 2
static long num_steps = 100000000;
double step;

int main()
{
    int nthreads;
    double pi = 0.0;
    step = 1.0/(double)num_steps;

    omp_set_num_threads(NUM_THREADS);

    double start = omp_get_wtime();

    #pragma omp parallel
    {
        int i, id, nthrds;
        double x, sum;

        id = omp_get_thread_num();
        nthrds = omp_get_num_threads();
        if(id == 0) nthreads = nthrds;

        for(i=id, sum=0.0; i<num_steps; i=i+nthreads){
            x = (i+0.5)*step;
            sum += 4.0/(1.0+x*x);
        }
        #pragma omp critical
            pi += step * sum;
    }


    double end = omp_get_wtime();

    printf("pi = %f, number of threads = %d, time = %f s\n", pi, NUM_THREADS, end-start);

    return 0;
}

同样我们使用不同的线程数1~4来观测程序的输出如下:
pi = 3.141593, number of threads = 1, time = 0.367218 s
pi = 3.141593, number of threads = 2, time = 0.184200 s
pi = 3.141593, number of threads = 3, time = 0.183366 s
pi = 3.141593, number of threads = 4, time = 0.178658 s

恰当地运用OpenMP提供的各种同步模式(例如Atomic, Barrier, Critical等)能够对程序执行效率的提升起到相当大的帮助。OpenMP中还有很多话题值得探讨,有兴趣的读者可以参阅相关资料以了解更多。

等等,这样我就算OpenMP入门了吗?如果你翻看其他OpenMP的资料,下面我要介绍的这部分内容才往往是其他资料的开篇内容!这些方法和技巧会让你顿时感觉OpenMP真的很容易上手。那前面的内容到底算什么?按照Tim Mattson的话来说,前面这些其实是帮助你更好更深刻的理解OpenMP!

并行计算中最值得我们优化的地方就是循环。计算机最擅长做的事情就是重复大量地简单计算,而这个“重复”计算就是通过循环来实现的。如果循环里面的内容不存在相互依赖关系,也就是循环体中语句之间的顺序是可以任意调整的,那么你可以采用下面这种语法来让OpenMP协助进行循环的并行优化:

#pragma omp parallel
{
#pragma omp for
    for(i = 0; i < N; i++){
        do_something;
    }
}

特别地,如果#pragma omp parallel{}的大括号中唯一的内容就是一个for循环,那么上面的代码还可以采用下面这种简单的写法,它们的作用是完全等价的:

#pragma omp parallel for
    for(i = 0; i < N; i++){
        do_something;
    }

但是如果循环体中的内容之间有一定的依赖关系,我们该怎么做呢?举个例子:

int i, j, A[MAX];
j = 5;
for (i = 0; i < MAX; i++){
    j+=2;
    A[i]=big(j);
}

这个代码中,循环体里的两条语句之间就具有一定的依赖性,每次big(j)操作都依赖于本轮循环最新得到之j值,如果你贸然使用并行方法,那么整个循环中的顺序将被彻底打乱,那程序最终所得之结果就难以保证了!

一个解决方案就是改写代码消除依赖性,例如:

int i, A[MAX];
#pragma omp parallel for
for (i = 0; i < MAX; i++){
    int j = 5 + 2*(i+1);
    A[i]=big(j);
}

上述代码的思路就是每轮循环都重新创建一个变量j,这个j的值由当前的i直接算得,那么下面的big(j)就只能依赖于本轮的变量。因为没有全局变量,只有局部变量,如果j还没被创建,那么big(j)也不会执行,这就是与之前程序最大不同的地方。

另外一个比较特殊,但也更为常见的情况是类似下面这种的:

double ave = 0.0, A[MAX];
int i;
for(i=0;i<MAX;i++){
    ave+=A[i];
}
ave = ave/MAX;

注意累加操作必须依赖于之前(也就是上一轮循环)所得之ave才能算得本轮之ave。这种“累加”操作非常常见,但由于存在依赖关系,我们之前的做法并不能奏效。类似的还有“累乘”等等。OpenMP把这类情况统称为Reduction,并提供了很好的支持。
reduction(op:list)
A local copy of each list variables is made and initialized depending on the “op”。例如如果是累加那么op就被初始为0,如果是累乘op就被初始为1。
Then updates occur on the local copy. Local copies are reduced into a single value and combined with the original global value.
下面这个例子演示了Reduction的使用

double ave = 0.0, A[MAX];
int i;
#pragma omp parallel for reduction (+:ave)
for(i = 0; i < MAX; i++){
    ave += A[i];
}
ave = ave/MAX;

最后我们改写本文最初给出的那个串行程序,只作简单改动:

#include "stdio.h"
#include "omp.h"

static long num_steps = 100000000;
double step;

int main()
{
    int i;
    double pi, sum = 0.0;

    step = 1.0/(double)num_steps;

    double start = omp_get_wtime();
    #pragma omp parallel
    {
        double x;
        #pragma omp for reduction(+:sum)
        for(i=0; i<num_steps; i++){
            x = (i+0.5)*step;
            sum = sum + 4.0/(1.0+x*x);
        }
    }
    pi = step * sum;

    double end = omp_get_wtime();


    printf("pi = %f, time = %f s\n", pi, end-start);

    return 0;
}

请读者自行编译并执行上述程序,正常情况下,这个程序应该会比使用critical的程序略慢一些,但速度提升仍然非常显效。到此为止,你已经算是基本掌握了OpenMP并行编程的一些方法和思路了,对于有深入学习需求的朋友,可以参阅一下文献【1】中后半程(14~27)视频课程的学习。本文中的程序主要来自(1~13)视频教学片中的示例。

参考文献

[1] Tim Mattson,Introduction to OpenMP,Intel公司视频课程(https://www.youtube.com/watch?v=nE-xN4Bf8XI&feature=youtu.be&list=PLLX-Q6B8xqZ8n8bwjGdzBJ25X2utwnoEG)

[2] 左飞,代码揭秘——从C/C++的角度探秘计算机系统,电子工业出版社

你可能感兴趣的:(并发编程,高性能计算,openmp,多核处理器)