跳转到内容

OpenMP/Tasks

来自维基教科书,开放的书籍,开放的世界

如果你跟着上一章并做了练习,你就会发现我们开发的求和函数很不稳定。(公平地说,我们设置了数据来展示这种不稳定性。)实际上,简单地求和的 舍入误差:误差与我们要累加的元素数量成正比。我们可以通过更改求和算法来解决这个问题。有三个候选的稳定求和算法

  • 将数字从小到大排序,然后像以前一样累加它们。问题:这将问题的复杂度从线性变为.
  • 使用Kahan 算法。虽然该算法需要线性时间并且非常稳定,但在实践中它相当缓慢,并且比我们的第三种选择更难并行化,第三种选择是
  • 分治 递归。这种算法的舍入误差是,即与元素数量的对数成正比。

基本的分治求和在 C 中很容易表达

float sum(const float *a, size_t n)
{
    // base cases
    if (n == 0) {
        return 0;
    }
    else if (n == 1) {
        return *a;
    }

    // recursive case
    size_t half = n / 2;
    return sum(a, half) + sum(a + half, n - half);
}

如果你在上一章为该程序开发的程序中使用这个 sum 的定义,你会发现它产生了完全预期的结果。但是这个算法没有循环,那么我们如何使用 OpenMP 制作并行版本呢?

我们将使用 OpenMP 中的任务构造,将问题视为任务并行而不是数据并行。在第一个版本中,sum 的任务递归版本如下所示

float sum(const float *a, size_t n)
{
    // base cases
    if (n == 0) {
        return 0;
    }
    else if (n == 1) {
        return 1;
    }

    // recursive case
    size_t half = n / 2;
    float x, y;

    #pragma omp parallel
    #pragma omp single nowait
    {
        #pragma omp task shared(x)
        x = sum(a, half);
        #pragma omp task shared(y)
        y = sum(a + half, n - half);
        #pragma omp taskwait
        x += y;
    }
    return x;
}

我们引入了两个任务,每个任务都设置一个被声明为 shared 的变量,该变量与另一个任务共享。如果我们没有声明共享变量,每个任务都会设置自己的局部变量,然后丢弃结果。然后我们使用 #pragma omp taskwait 等待任务完成,并结合递归结果。

你可能会对紧跟着 #pragma omp single nowait#pragma omp parallel 感到惊讶。问题是第一个pragma会导致池中的所有线程执行下一段代码。single 指令会导致除了一个线程(通常是第一个遇到该代码块的线程)以外的所有线程不执行它,而 nowait 会关闭 single 上的屏障;在封闭的 parallel 区域上已经有一个屏障,其他线程会蜂拥而至。

不幸的是,如果你真的尝试运行这段代码,你会发现它仍然不是特别快。原因是任务太细粒度了:在递归树的底部附近, 次调用将两个元素的数组分成每个处理一个元素的子任务。我们可以通过引入除了基本情况和递归情况之外的“中间情况”来解决这个问题,该情况是递归的,但不涉及设置并行任务:如果递归达到预先指定的截止值,它将不再尝试为 OpenMP 线程池设置任务,而是只进行递归求和。

练习:在递归中引入额外的情况,并衡量程序的速度。不要提前查看下一个程序,因为它包含了这个练习的答案。

现在我们实际上有两个递归合二为一:一个带并行递归情况,另一个是串行递归。我们可以通过在每个级别进行更少的检查来解开这两个递归,以获得更好的性能。我们还将并行设置代码分离到驱动函数中。

#include <stddef.h>

#define CUTOFF 100  // arbitrary

static float parallel_sum(const float *, size_t);
static float serial_sum(const float *, size_t);

float sum(const float *a, size_t n)
{
    float r;

    #pragma omp parallel
    #pragma omp single nowait
    r = parallel_sum(a, n);
    return r;
}

static float parallel_sum(const float *a, size_t n)
{
    // base case
    if (n <= CUTOFF) {
        return serial_sum(a, n);
    }

    // recursive case
    float x, y;
    size_t half = n / 2;

    #pragma omp task shared(x)
    x = parallel_sum(a, half);
    #pragma omp task shared(y)
    y = parallel_sum(a + half, n - half);
    #pragma omp taskwait
    x += y;

    return x;
}

static float serial_sum(const float *a, size_t n)
{
    // base cases
    if (n == 0) {
        return 0.;
    }
    else if (n == 1) {
        return a[0];
    }

    // recursive case
    size_t half = n / 2;
    return serial_sum(a, half) + serial_sum(a + half, n - half);
}

当并行任务内的代码花费更多时间进行计算,而花费更少时间进行内存访问时,这种技术效果更好,因为这些内存访问可能需要在处理器之间进行同步。

华夏公益教科书