OpenMP -嵌套for循环在外部循环之前并行时变得更快.为什么

OpenMP - Nested for-loop becomes faster when having parallel before outer loop. Why?

本文关键字:循环 为什么 并行 嵌套 for OpenMP 外部      更新时间:2023-10-16

我目前正在实现一个动态规划算法来解决背包问题。因此,我的代码有两个for循环,一个外部循环和一个内部循环。

从逻辑的角度来看,我可以并行化内部for循环,因为那里的计算是相互独立的。由于依赖关系,外部for循环不能并行化。

这是我的第一个方法:

for(int i=1; i < itemRows; i++){
        int itemsIndex = i-1;
        int itemWeight = integerItems[itemsIndex].weight;
        int itemWorth = integerItems[itemsIndex].worth;
        #pragma omp parallel for if(weightColumns > THRESHOLD)
        for(int c=1; c < weightColumns; c++){
            if(c < itemWeight){
                table[i][c] = table[i-1][c];
            }else{
                int worthOfNotUsingItem = table[i-1][c];
                int worthOfUsingItem = itemWorth + table[i-1][c-itemWeight];
                table[i][c] = worthOfNotUsingItem < worthOfUsingItem ? worthOfUsingItem : worthOfNotUsingItem;
            }
        }
}

代码运行良好,算法正确地解决了问题。然后我在考虑优化这个,因为我不确定OpenMP的线程管理是如何工作的。我想在每次迭代中防止不必要的线程初始化,因此我在外部循环周围放置了一个外部并行块。

第二种方法:

#pragma omp parallel if(weightColumns > THRESHOLD)
{
    for(int i=1; i < itemRows; i++){
        int itemsIndex = i-1;
        int itemWeight = integerItems[itemsIndex].weight;
        int itemWorth = integerItems[itemsIndex].worth;
        #pragma omp for
        for(int c=1; c < weightColumns; c++){
            if(c < itemWeight){
                table[i][c] = table[i-1][c];
            }else{
                int worthOfNotUsingItem = table[i-1][c];
                int worthOfUsingItem = itemWorth + table[i-1][c-itemWeight];
                table[i][c] = worthOfNotUsingItem < worthOfUsingItem ? worthOfUsingItem : worthOfNotUsingItem;
            }
        }
     }
}

这有一个不想要的副作用:并行块中的所有内容现在都将执行n次,其中n是可用内核的数量。我已经尝试使用pragmas singlecritical来强制外部for循环在一个线程中执行,但是我不能通过多个线程计算内部循环,除非我打开一个新的并行块(但是那样就不会有速度上的增益)。但没关系,因为好处是:这不会影响结果。问题还是正确解决了。

现在奇怪的是:第二种方法比第一种方法快!

这是怎么回事?我的意思是,尽管外部for循环被计算n次(并行),内部for循环在n个内核中被分配n次,但它比第一种方法快,第一种方法只计算外部循环一次,并平均分配内部for循环的工作负载。

起初我在想:"嗯,是的,这可能是因为线程管理",但后来我读到OpenMP池的实例化线程,这将违背我的假设。然后我禁用了编译器优化(编译器标志- 0)来检查它是否与。但这并不影响测量。

你们谁能解释得更清楚一点?

测量的时间用于解决包含7500个物品的背包问题,最大容量为45000(创建7500x45000的矩阵,这远远超过代码中使用的THRESHOLD变量):

  • 方法1:~0.88s
  • 方法2:~0.52s

提前感谢,

phineliner

编辑:

测量一个更复杂的问题:问题增加2500项(从7500项增加到10000项)(由于内存原因,目前无法处理更复杂的问题)

  • 方法1:~1.19s
  • 方法二:~0.71s

EDIT2 :我误解了编译器的优化。这并不影响测量。至少我不能再现我之前测量到的差异。

让我们首先考虑一下代码在做什么。本质上你的代码是转换矩阵(二维数组),其中的行值依赖于前一行,但列的值是独立于其他列。让我选择一个更简单的例子

for(int i=1; i<n; i++) {
    for(int j=0; j<n; j++) {
        a[i*n+j] += a[(i-1)*n+j];
    }
}

一种并行化的方法是像这样交换循环

方法1:

#pragma omp parallel for
for(int j=0; j<n; j++) {
    for(int i=1; i<n; i++) {
        a[i*n+j] += a[(i-1)*n+j];
    }
}

使用此方法,每个线程运行内部循环的i的所有n-1迭代,而jn/nthreads迭代。这有效地并行处理列条。但是,这种方法对缓存非常不友好。

另一种可能是只并行处理内部循环。

方法2:

for(int i=1; i<n; i++) {
    #pragma omp parallel for 
    for(int j=0; j<n; j++) {
        a[i*n+j] += a[(i-1)*n+j];
    }
}

这实际上是并行地处理单行中的列,但每一行都是顺序的。i的值仅由主线程运行。

另一种并行处理列但每行顺序的方法是:

方法3:

#pragma omp parallel
for(int i=1; i<n; i++) {
    #pragma omp for
    for(int j=0; j<n; j++) {
        a[i*n+j] += a[(i-1)*n+j];
    }
}

在这个方法中,与方法1一样,每个线程运行在i上的所有n-1迭代。然而,这个方法在内部循环之后有一个隐式的屏障,它导致每个线程暂停,直到所有线程都完成一行,使得这个方法对每一行都是顺序的,就像方法2一样。

最好的解决方案是像方法1一样并行处理列条,但仍然是缓存友好的。这可以使用nowait子句来实现。

方法4:

#pragma omp parallel
for(int i=1; i<n; i++) {
    #pragma omp for nowait
    for(int j=0; j<n; j++) {
        a[i*n+j] += a[(i-1)*n+j];
    }
}

在我的测试中,nowait子句没有多大区别。这可能是因为负载是均匀的(这就是为什么静态调度在这种情况下是理想的)。如果负载更少,nowait可能会产生更大的差异。

以下是n=3000在我的四核IVB系统GCC 4.9.2上以秒为单位的时间:

method 1: 3.00
method 2: 0.26 
method 3: 0.21
method 4: 0.21

这个测试可能是内存带宽限制,所以我可以选择一个更好的情况下使用更多的计算,但是差异是足够显著的。为了消除由于创建线程池而产生的偏差,我没有先对其中一个方法进行计时就运行了它。

从时间上可以清楚地看出方法1是多么的非缓存友好。同样明显的是,方法3比方法2更快,nowait在这种情况下几乎没有影响。

由于方法2和方法3都并行地处理一行中的列,但是顺序地处理行,因此可以期望它们的时间相同。那么它们为什么不同呢?让我做一些观察:

  1. 由于线程池,线程不会为方法2的外部循环的每次迭代创建和销毁,所以我不清楚额外的开销是什么。注意,OpenMP没有提到线程池。

  2. 方法3和方法2之间唯一的其他区别是,在方法2中只有主线程处理i,而在方法3中每个线程处理一个私有i。但是对我来说,这似乎太微不足道了,无法解释方法之间的显著差异,因为方法3中的隐式障碍导致它们无论如何都同步,处理i是一个增量和条件测试的问题。

  3. 方法3并不比并行处理整条列的方法4慢,这说明方法2的额外开销都是在每次迭代i时离开和进入并行区域

所以我的结论是,要解释为什么方法2比方法3慢得多,需要研究线程池的实现。对于使用pthread的GCC,这可能可以通过创建一个线程池的玩具模型来解释,但我还没有足够的经验。

我认为原因很简单,因为您将#pragma omp parallel置于外部作用域级别(第二个版本),因此调用线程的开销更少。

换句话说,在第一个版本中,您在第一个循环itemRows时间调用线程创建,而在第二个版本中,您只调用创建一次。我不知道为什么!

我试着重现一个简单的例子来说明这一点,使用启用了HT的4个线程:

#include <iostream>
#include <vector>
#include <algorithm>
#include <omp.h>
int main()
{
    std::vector<double> v(10000);
    std::generate(v.begin(),  v.end(), []() { static double n{0.0}; return n ++;} );
    double start = omp_get_wtime();
    #pragma omp parallel // version 2
    for (auto& el :  v) 
    {
        double t = el - 1.0;
        // #pragma omp parallel // version 1
        #pragma omp for
        for (size_t i = 0; i < v.size(); i ++)
        {
            el += v[i];
            el-= t;
        }
    }
    double end = omp_get_wtime();
    std::cout << "   wall time : " << end - start << std::endl;
    // for (const auto& el :  v) { std::cout << el << ";"; }
}

根据您想要的版本注释/取消注释。如果你使用:-std=c++11 -fopenmp -O2编译,你应该会看到版本2更快了。

Coliru Demo

Live Version 1 wall time : 0.512144

Live version 2 wall time : 0.333664