在对数时间内并行减少

15

给定 n 个部分和,可以在 log2 并行步骤中对所有部分和进行求和。例如,假设有八个线程和八个部分和:s0, s1, s2, s3, s4, s5, s6, s7。可以通过 log2(8) = 3 个顺序步骤将其缩减为一个和。

thread0     thread1    thread2    thread4
s0 += s1    s2 += s3   s4 += s5   s6 +=s7
s0 += s2    s4 += s6
s0 += s4

我希望使用OpenMP来实现这个,但是我不想使用OpenMP的reduction子句。我已经想出了一个解决方案,但我认为可能可以找到更好的解决方案,也许可以使用OpenMP的task子句。
这比标量加法更普遍。让我选择一个更有用的情况:数组归约(有关数组归约的更多信息,请参见hereherehere)。
假设我想在数组a上进行数组归约。这里是一些代码,它为每个线程并行填充私有数组。
int bins = 20;
int a[bins];
int **at;  // array of pointers to arrays
for(int i = 0; i<bins; i++) a[i] = 0;
#pragma omp parallel
{
    #pragma omp single   
    at = (int**)malloc(sizeof *at * omp_get_num_threads());        
    at[omp_get_thread_num()] = (int*)malloc(sizeof **at * bins);
    int a_private[bins];
    //arbitrary function to fill the arrays for each thread
    for(int i = 0; i<bins; i++) at[omp_get_thread_num()][i] = i + omp_get_thread_num();
}

此时,我有一个指向每个线程数组的指针数组。现在我想将所有这些数组相加并将最终总和写入a。这是我想出的解决方案。

#pragma omp parallel
{
    int n = omp_get_num_threads();
    for(int m=1; n>1; m*=2) {
        int c = n%2;
        n/=2;
        #pragma omp for
        for(int i = 0; i<n; i++) {
            int *p1 = at[2*i*m], *p2 = at[2*i*m+m];
            for(int j = 0; j<bins; j++) p1[j] += p2[j];
        }
        n+=c;
    }
    #pragma omp single
    memcpy(a, at[0], sizeof *a*bins);
    free(at[omp_get_thread_num()]);
    #pragma omp single
    free(at);
}

让我来解释一下这段代码的作用。假设有八个线程。我们定义+=操作符表示对数组求和。例如,s0 += s1是将s1加到s0上。
for(int i=0; i<bins; i++) s0[i] += s1[i]

那么这段代码将会执行

n   thread0     thread1    thread2    thread4
4   s0 += s1    s2 += s3   s4 += s5   s6 +=s7
2   s0 += s2    s4 += s6
1   s0 += s4

但是,我的代码并不理想。
其中一个问题是,有一些隐含的屏障需要所有线程进行同步。这些屏障不应该是必要的。第一个屏障在填充数组和进行规约之间。第二个屏障在规约中的#pragma omp for声明中。但是我不能使用nowait子句来移除屏障。
另一个问题是,有几个线程不需要使用。例如,有8个线程。规约中第一步只需要4个线程,第二步需要2个线程,最后一步只需要1个线程。然而,这种方法将涉及到所有8个线程的规约。虽然,其他线程不做什么,应该直接到达屏障等待,所以可能不是什么问题。
我的直觉是,可以使用omp task子句找到更好的方法。不幸的是,我对task子句的经验很少,迄今为止,我所做的所有努力都无法比现在更好地进行规约。
有人能建议一种更好的方法,使用例如OpenMP的task子句在对数时间内进行规约吗?
我找到了一种解决屏障问题的方法。这种方法是异步的。唯一剩下的问题是它仍然会将不参与缩减的线程放入繁忙循环中。该方法使用类似堆栈的东西,在关键部分中将指针推入堆栈(但从不弹出)。这是其中一个关键点,因为关键部分没有隐式屏障。堆栈是串行操作的,但缩减是并行的。
以下是一个可行的示例。
#include <stdio.h>
#include <omp.h>
#include <stdlib.h>
#include <string.h>

void foo6() {
    int nthreads = 13;
    omp_set_num_threads(nthreads);
    int bins= 21;
    int a[bins];
    int **at;
    int m = 0;
    int nsums = 0;
    for(int i = 0; i<bins; i++) a[i] = 0;
    #pragma omp parallel
    {
        int n = omp_get_num_threads();
        int ithread = omp_get_thread_num();
        #pragma omp single
        at = (int**)malloc(sizeof *at * n * 2);
        int* a_private = (int*)malloc(sizeof *a_private * bins);

        //arbitrary fill function
        for(int i = 0; i<bins; i++) a_private[i] = i + omp_get_thread_num();

        #pragma omp critical (stack_section)
        at[nsums++] = a_private;

        while(nsums<2*n-2) {
            int *p1, *p2;
            char pop = 0;
            #pragma omp critical (stack_section)
            if((nsums-m)>1) p1 = at[m], p2 = at[m+1], m +=2, pop = 1;
            if(pop) {
                for(int i = 0; i<bins; i++) p1[i] += p2[i];
                #pragma omp critical (stack_section)
                at[nsums++] = p1;
            }
        }

        #pragma omp barrier
        #pragma omp single
        memcpy(a, at[2*n-2], sizeof **at *bins);
        free(a_private);
        #pragma omp single
        free(at);
    }
    for(int i = 0; i<bins; i++) printf("%d ", a[i]); puts("");
    for(int i = 0; i<bins; i++) printf("%d ", (nthreads-1)*nthreads/2 +nthreads*i); puts("");
}

int main(void) {
    foo6();
}

我认为可以使用任务来寻找更好的方法,而不会使未被使用的线程处于忙碌状态。


3
为什么你不想使用OpenMP的归约操作? - Jeff Hammond
1
@Jeff,因为reduction是一个黑盒子。因为我不知道它是如何工作的,甚至不知道它是否使用了log(nthreads)的约简。因为当操作不可交换时,reduction不起作用。因为我认为学会如何手动处理事物很有用。因为我认为OpenMP是教授并行编程概念的良好范例。 - Z boson
3
你是否读过规范或者任何开源运行时(如GCC和Clang,或者Pathscale的)?只有你拒绝打开盖子才会让它变成黑匣子。 - Jeff Hammond
2
OpenMP 应该实现实现者所知道的最快的归约操作。我预计许多操作的复杂度为 log(N)。无论你是否能在测量中看到这一点,取决于你如何构建它们。如果你没有摊销并行区域成本,许多实验将被内存成本或运行时开销所主导。 - Jeff Hammond
2
@我不存在我不曾存在,通常 n >> N 所以第二阶段如何处理并不重要,因为时间完全由第一阶段主导。但是如果 n ≈ N 呢?在这种情况下,第二阶段将不会无关紧要。我承认我应该想出一个例子来展示这一点(我的意思是用时间来衡量),但是 SO 上的每个人都说要使用 reduction 子句来进行 OpenMP,因为它可能会在 log(t) 操作中执行第二阶段。所以我认为这可能是一个例子。 - Z boson
显示剩余11条评论
1个回答

11

实际上,使用递归分治方法以任务的形式实现这一点非常简单。这几乎就是textbook中的代码。

void operation(int* p1, int* p2, size_t bins)
{
    for (int i = 0; i < bins; i++)
        p1[i] += p2[i];
}

void reduce(int** arrs, size_t bins, int begin, int end)
{
    assert(begin < end);
    if (end - begin == 1) {
        return;
    }
    int pivot = (begin + end) / 2;
    /* Moving the termination condition here will avoid very short tasks,
     * but make the code less nice. */
#pragma omp task
    reduce(arrs, bins, begin, pivot);
#pragma omp task
    reduce(arrs, bins, pivot, end);
#pragma omp taskwait
    /* now begin and pivot contain the partial sums. */
    operation(arrs[begin], arrs[pivot], bins);
}

/* call this within a parallel region */
#pragma omp single
reduce(at, bins, 0, n);

据我所知,没有不必要的同步,也没有在关键部分进行奇怪的轮询。它还可以自然地处理数据大小与您的排名数不同的情况。我认为这非常干净且易于理解。因此,我确实认为这比你们两个的解决方案都要

但是让我们看看它在实践中的表现*。为此,我们可以使用Score-pVampir

*bins=10000,因此缩小实际上需要一点时间。在没有turbo的24核Haswell系统上执行。gcc 4.8.4,-O3。我添加了一些缓冲区来隐藏初始化/后处理

execution of the three variants

该图片展示了应用程序中任何线程在水平时间轴上发生的情况。从上到下树实现如下:
  1. omp for 循环
  2. omp critical 任务类型。
  3. omp task
这很好地展示了具体的实现如何执行。现在看来,尽管存在不必要的同步,但for循环实际上是最快的。但是,在这种性能分析中仍然存在许多缺陷。例如,我没有将线程固定。在实践中,NUMA(非一致性内存访问)非常重要:核心是否具有其自己套接字的缓存/内存中的数据?这就是任务解决方案变得不确定的地方。简单比较中未考虑重复之间的显着差异。
如果运行时减少操作变量,则任务解决方案将优于同步的for循环。 critical 解决方案具有一些有趣的方面,被动线程不会持续等待,因此它们更可能消耗CPU资源。这对于性能来说可能是不好的,例如在Turbo模式下。
记住避免生成立即返回的任务,因为task解决方案还具有更多的优化潜力。这些解决方案的性能也高度取决于特定的OpenMP运行时。Intel的运行时似乎对任务的表现要差得多。
我的建议是:
  • 使用最可维护的解决方案和最佳算法复杂度
  • 测量实际运行时哪些代码部分真正重要
  • 根据实际测量结果分析瓶颈所在。据我经验,它更多地涉及NUMA和调度而不是一些不必要的屏障。
  • 基于实际测量进行微观优化

线性解决方案

这是来自此问题的线性proccess_data_v1的时间线。

parallel timeline

OpenMP 4 Reduction

我想到了 OpenMP 的 reduction。看起来棘手的部分似乎是在循环内部从 at 数组获取数据而不进行复制。我使用 NULL 初始化了工作数组,并且第一次只是简单地移动指针:

void meta_op(int** pp1, int* p2, size_t bins)
{
    if (*pp1 == NULL) {
        *pp1 = p2;
        return;
    }
    operation(*pp1, p2, bins);
}

// ...

// declare before parallel region as global
int* awork = NULL;

#pragma omp declare reduction(merge : int* : meta_op(&omp_out, omp_in, 100000)) initializer (omp_priv=NULL)

#pragma omp for reduction(merge : awork)
        for (int t = 0; t < n; t++) {
            meta_op(&awork, at[t], bins);
        }

令人惊讶的是,这看起来不太好:

timeline for omp4 reduction

顶部是icc 16.0.2,底部是gcc 5.3.0,两者都使用-O3

两者似乎都实现了串行化的约简。我尝试研究gcc/libgomp,但我不清楚正在发生什么。从中间代码/反汇编中,它们似乎将最终合并包装在一个GOMP_atomic_start/end中-这似乎是全局互斥。同样,icc将对operation的调用包装在kmpc_critical中。我想可能没有进行大量优化以进行昂贵的自定义约简操作。传统约简可以使用硬件支持的原子操作完成。

请注意,每个operation因输入被本地缓存而更快,但由于串行化,整体上变慢。再次说明,由于高方差,之前的截图是使用不同的gcc版本。但趋势是明确的,我还有有关缓存效果的数据。


使用关键部分的O(n)方法来绘制这个图表将会很有趣。我指的是在这个问题中定义的proccess_data_v1方法。它应该显示只有一个线程在每次执行规约操作,而且我预计它将是最慢的方法。 - Z boson
1
@Zboson,根据当前的实现,需要使用屏障。但是你可以在递归的终止条件处将“填充函数”作为任务运行。然后可以独立地开始减少。 - Zulan
使用OpenMP的reduction子句并使用omp declare reduction进行数组约简,看到图形也会很有趣。也许我会在某个时候制作自己的图表。 - Z boson
1
@Zboson,我添加了一个来自process_data_v1的跟踪以确认这个假设。 - Zulan
1
@Zboson,我尝试了OpenMP4的omp declare reduction,并编辑了答案。结果让我感到非常惊讶。 - Zulan
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接