N体算法:为什么这种并行速度较慢?

时间:2015-10-04 14:37:53

标签: c++ performance openmp

我整理了一个模拟我正在处理的数据结构类型的示例程序。即我有n个对象,我需要在每个可能的对之间迭代一次并执行(对称)计算。此操作涉及将数据写入两对。在串行中,这将采取像这样的循环的形式

for(int i = 0; i < N-1; ++i)
   for(int j = i + 1; j < N; ++j)
      ...

但是,在互联网上搜索并没有花费太多时间来查找&#34;缓存无关的并行实现&#34;,我在下面编写并复制了这些内容。我在这里链接了一个帖子(使用英特尔TBB),详细描述了这个算法。

https://software.intel.com/en-us/blogs/2010/07/01/n-bodies-a-parallel-tbb-solution-parallel-code-balanced-recursive-parallelism-with-parallel_invoke

我尝试使用OpenMP任务来执行相同的操作,并且它总是比串行对应项运行得慢(只是编译时没有-fopenmp)。我用g++ -Wall -std=c++11 -O3 test.cpp -o test编译它。无论有没有-O3,都会观察到同样的情况;串口总是更快。

要添加更多信息,在我的实际应用程序中,通常会有几百到几千个元素(下例中的变量n),我需要在这两个元素中循环时尚很多次。数百万次。我下面的尝试试图模拟(虽然我只尝试循环10-100k次)。

我使用time ./test非常粗暴地定时,因为这有很大的不同。是的,我知道我的例子编写得很糟糕,并且我在我的例子中包含了创建向量所需的时间。但连续播放的时间给了我大约30秒和一分钟的时间,所以我不认为我还需要做更严格的事情。

我的问题是:为什么序列会做得更好?我在OpenMP中做错了吗?如何正确纠正我的错误?我误用了任务吗?我有一种感觉,递归任务与它有关,我尝试设置&#39; OMP_THREAD_LIMIT&#39;到4,但它并没有产生实质性的差异。有没有更好的方法使用OpenMP实现这个?

注意:我的问题是专门询问如何修复此特定实现,以便它可以并行正常工作。虽然如果有人知道这个问题的替代解决方案及其在OpenMP中的正确实现,我也对此持开放态度。

提前致谢。

#include <vector>
#include <iostream>

std::vector<std::vector<double>> testme;

void rect(int i0, int i1, int j0, int j1)
{
    int di = i1 - j0;
    int dj = j1 - j0;
    constexpr int threshold = 16;
    if(di > threshold && dj > threshold)
    {
        int im = i0 + di/2;
        int jm = j0 + dj/2;
        #pragma omp task
        { rect(i0, im, j0, jm); }
        rect(im, i1, jm, j1);
        #pragma omp taskwait

        #pragma omp task 
        { rect(i0, im, jm, j1); }
        rect(im, i1, j0, jm);
        #pragma omp taskwait
    }
    else
    {
        for(int i = i0; i < i1; ++i)
            for(int j = j0; j < j1; ++j) {
                testme[i][j] = 1;
                testme[j][i] = 1;
            }

    }
}

void triangle(int n0, int n1)
{
        int dn = n1 - n0;
        if(dn > 1)
        {
            int nm = n0 + dn/2;
            #pragma omp task
            { triangle(n0, nm); }
            triangle(nm, n1);
            #pragma omp taskwait

            rect(n0, nm, nm, n1);
        }
}


void calc_force(int nbodies)
{
    #pragma omp parallel num_threads(4)
    #pragma omp single
    triangle(0, nbodies);
}

int main()
{
    int n = 400;
    for(int i = 0; i < n; ++i)
    {
        std::vector<double> temp;
        for(int j = 0; j < n; ++j)
            temp.push_back(0);

        testme.push_back(temp);
    }

    // Repeat a bunch of times.
    for(int i = 0; i < 100000; ++i)
        calc_force(n);

    return 0;
}   

5 个答案:

答案 0 :(得分:5)

在应用三角形分区方案时,您当前的OMP任务实现似乎完全正确。似乎由于分解的递归性质,当前代码只是创建了太多子任务,调用递归三角形程序直到达到dn = 1的条件(在树的底部)。粒度太高了。这会给您的程序带来创建和完成任务的通信要求,从而减少创建任务的好处;因此超过了并行性的好处。我会尝试在大于1的某个dn值处切断递归三角形任务调用(我猜的是15左右)并让最后一个(最低)任务按顺序执行。

线程限制仅限制活动的线程数,但不限制递归调用或任务的数量。如果或者在你的三角形实现中添加else,我会尝试一个任务。

这样的事情:

#include <vector>
#include <iostream>

std::vector<std::vector<double>> testme;

void rect(int i0, int i1, int j0, int j1)
{
int di = i1 - j0;
int dj = j1 - j0;
constexpr int threshold = 64;
if(di > threshold && dj > threshold)
{
    int im = i0 + di/2;
    int jm = j0 + dj/2;
    #pragma omp task
    { rect(i0, im, j0, jm); }
    rect(im, i1, jm, j1);
    #pragma omp taskwait

    #pragma omp task 
    { rect(i0, im, jm, j1); }
    rect(im, i1, j0, jm);
    #pragma omp taskwait
}
else
{
 // #pragma omp parallel for collapse(2)  (was not implimented during testing)
    for(int i = i0; i < i1; ++i)
        for(int j = j0; j < j1; ++j) {
            testme[i][j] = 1;
            testme[j][i] = 1;
        }
    }
}

void triangle(int n0, int n1)
{
    int dn = n1 - n0;
    if(dn > 1)
    {
        int nm = n0 + dn/2;
        #pragma omp task if(nm > 50 )

        { triangle(n0, nm); }
        triangle(nm, n1);
       #pragma omp taskwait

       rect(n0, nm, nm, n1);
    }
}


void calc_force(int nbodies)
{
#pragma omp parallel num_threads(4)
#pragma omp single
triangle(0, nbodies);
}

int main()
{
int n = 400;
for(int i = 0; i < n; ++i)
{
    std::vector<double> temp;
    for(int j = 0; j < n; ++j)
        temp.push_back(0);

    testme.push_back(temp);
}

// Repeat a bunch of times.
for(int i = 0; i < 100000; ++i)
    calc_force(n);

return 0;
}  
  

注意:此实现也可能只是显示大规模加速,其中任务开销超过了程序的计算强度。

答案 1 :(得分:3)

并行代码未能发挥其潜力的原因之一是由于false sharing (Wikipedia)的问题。

解决方案是对问题进行分区,以便输出2-D矩阵(内部向量)中的每个缓存行仅由一个线程更新。通过构造三角形也是如此,分区已经保证了这一点。但是,如果imjm不是在缓存行边界对齐的条目的索引,则矩形中的并行性是有问题的。如果im和/或jm指示的分区不在高速缓存行边界,则两个线程将写入公共高速缓存行,但高速缓存行内的偏移量不同 - 假共享的定义。

This article by Intel很好地描述了虚假分享以及如何避免它的建议。

https://software.intel.com/en-us/articles/avoiding-and-identifying-false-sharing-among-threads

我引用相关部分作为参考:

  

虚假共享是SMP系统上众所周知的性能问题,其中   每个处理器都有一个本地缓存。它发生在线程不同时   处理器修改驻留在同一缓存行上的变量,如   如图1所示。这种情况称为虚假共享   因为每个线程实际上并不共享对它的访问权限   变量。需要访问相同的变量或真正的共享   程序化同步构造,以确保有序的数据访问。

     

以下示例代码中以红色显示的源代码行导致   虚假分享:

double sum=0.0, sum_local[NUM_THREADS];
#pragma omp parallel num_threads(NUM_THREADS)
{
 int me = omp_get_thread_num();
 sum_local[me] = 0.0;

 #pragma omp for
 for (i = 0; i < N; i++)
 sum_local[me] += x[i] * y[i];

 #pragma omp atomic
 sum += sum_local[me];
}  
     

数组sum_local可能存在错误共享。这个数组   根据线程数量确定尺寸,并且足够小   适合单个缓存行。当并行执行时,线程   修改sum_local的不同但相邻的元素(源代码行   显示为红色),它使所有处理器的高速缓存行无效。

     

enter image description here图1.发生虚假分享   当不同处理器上的线程修改驻留的变量时   相同的缓存行。这会使缓存行无效并强制执行   内存更新以保持缓存一致性。

     

在图1中,线程0和1需要相邻的变量   内存并驻留在同一缓存行上。缓存行已加载   进入CPU 0和CPU 1的缓存(灰色箭头)。即便如此   线程修改不同的变量(红色和蓝色箭头),缓存   行无效,强制内存更新以维护缓存   相干性。

Here's a lecture关于使用OpenMP组织N-Body算法的各种方式的利弊,但请注意Walter在评论中的注意事项,这个讲座更多的是关于编程而不是关于物理,正如Walter在一个N-Body问题,其中一些粒子近距离遇到力的计算以确定身体上的净力(加速度)和积分以确定速度然后再次对位置必须仔细进行 - 全局步长函数是不合适的

http://www.cs.usask.ca/~spiteri/CMPT851/notes/nBody.pdf

特别参见第18页,我在此复制以供参考:

  

应用福斯特的方法   因此,核心任务图减少了   将粒子映射到核心。假设每步完成的工作是   大致相等,块分区大致分配n / p粒子   每个核心应该提供均衡的负载。这个假设是有效的   对于在计算时没有利用对称性的情况   音响,J(t)的。当利用对称性时,低于i的循环   将比较大的i贵。在这种情况下,循环   分区更有效。但是,在共享内存框架中,a   循环分区几乎肯定会导致更高数量的缓存   错过了比块分区。在分布式内存框架中,   可能涉及循环分区的通信开销   大于块分区

答案 2 :(得分:3)

对这种工作负载使用递归算法的简单想法对我来说已经很奇怪了。然后,使用OpenMP任务并行化它似乎更奇怪......为什么不用更传统的方法解决问题呢?

所以我决定试一试我想到的一些方法。但是为了使练习变得合理,重要的是要完成一些实际的工作来计算“对称计算”,否则,只是迭代所有元素而不考虑对称方面肯定是最好的选择。

所以我写了一个force()函数,根据坐标计算与两个物体之间的引力相互作用松散相关的东西。 然后我测试了4种不同的方法来迭代粒子:

  1. 一种天真的三角形方法,例如你提出的方法。由于它是固有的负载不平衡方面,因此它与schedule(auto)子句并行化,以允许运行时库采取它认为最佳性能的决策。
  2. 三角域的“巧妙”遍历,包括在j方向上将其切成两半以允许使用2个常规循环。基本上,它对应于这样的事情:

    . /| / | __ __ / | => | // | /___| |//____|

  3. 一种简单的矩形方法,只是忽略了对称性。注意,这个,就像你的递归方法一样,保证对force数组的非并发访问。
  4. 一种线性化方法,包括预先计算ij索引的顺序以访问三角域,并迭代包含这些索引的向量。
  5. 由于使用force[i] += fij; force[j] -= fij;方法累积力的向量将为非并行化索引(例如方法#1中的j)中的更新生成竞争条件,我已经创建了一个局部预螺纹力数组,在进入并行区域时初始化为0。然后在该“私有”阵列上预先进行计算,并且在并行区域退出时,使用critical构造在全局力阵列上累积各个贡献。这是OpenMP中数组的典型缩减模式。

    以下是完整代码:

    #include <iostream>
    #include <vector>
    #include <cstdlib>
    #include <cmath>
    #include <omp.h>
    
    std::vector< std::vector<double> > l_f;
    std::vector<double> x, y, f;
    std::vector<int> vi, vj;
    
    int numth, tid;
    #pragma omp threadprivate( tid )
    
    double force( int i, int j ) {
        double dx = x[i] - x[j];
        double dy = y[i] - y[j];
        double dist2 = dx*dx + dy*dy;
        return dist2 * std::sqrt( dist2 );
    }
    
    void loop1( int n ) {
        #pragma omp parallel
        {
            for ( int i = 0; i < n; i++ ) {
                l_f[tid][i] = 0;
            }
            #pragma omp for schedule(auto) nowait
            for ( int i = 0; i < n-1; i++ ) {
                for ( int j = i+1; j < n; j++ ) {
                    double fij = force( i, j );
                    l_f[tid][i] += fij;
                    l_f[tid][j] -= fij;
                }
            }
            #pragma omp critical
            for ( int i = 0; i < n; i++ ) {
                f[i] += l_f[tid][i];
            }
        }
    }
    
    void loop2( int n ) {
        int m = n/2-1+n%2;
        #pragma omp parallel
        {
            for ( int i = 0; i < n; i++ ) {
                l_f[tid][i] = 0;
            }
            #pragma omp for schedule(static) nowait
            for ( int i = 0; i < n; i++ ) { 
                for ( int j = 0; j < m; j++ ) {
                    int ii, jj;
                    if ( j < i ) {
                        ii = n-1-i;
                        jj = n-1-j;
                    }
                    else {
                        ii = i;
                        jj = j+1;
                    }
                    double fij = force( ii, jj );
                    l_f[tid][ii] += fij;
                    l_f[tid][jj] -= fij;
                }
            }
            if ( n%2 == 0 ) {
                #pragma omp for schedule(static) nowait
                for ( int i = 0; i < n/2; i++ ) {
                    double fij = force( i, n/2 );
                    l_f[tid][i] += fij;
                    l_f[tid][n/2] -= fij;
                }
            }
            #pragma omp critical
            for ( int i = 0; i < n; i++ ) {
                f[i] += l_f[tid][i];
            }
        }
    }
    
    void loop3( int n ) {
        #pragma omp parallel for schedule(static)
        for ( int i = 0; i < n; i++ ) {
            for ( int j = 0; j < n; j++ ) {
                if ( i < j ) {
                    f[i] += force( i, j );
                }
                else if ( i > j ) {
                    f[i] -= force( i, j );
                }
            }
        }
    }
    
    void loop4( int n ) {
        #pragma omp parallel
        {
            for ( int i = 0; i < n; i++ ) {
                l_f[tid][i] = 0;
            }
            #pragma omp for schedule(static) nowait
            for ( int k = 0; k < vi.size(); k++ ) {
                int i = vi[k];
                int j = vj[k];
                double fij = force( i, j );
                l_f[tid][i] += fij;
                l_f[tid][j] -= fij;
            }
            #pragma omp critical
            for ( int i = 0; i < n; i++ ) {
                f[i] += l_f[tid][i];
            }
        }
    }
    
    int main( int argc, char *argv[] ) {
        if ( argc != 2 ) {
            std::cout << "need the dim as argument\n";
            return 1;
        }
        int n = std::atoi( argv[1] );
    
        // initialise data
        f.resize( n );
        x.resize( n );
        y.resize( n );
        for ( int i = 0; i < n; ++i ) {
            x[i] = y[i] = i;
            f[i] = 0;
        }
    
        // initialising linearised index vectors
        for ( int i = 0; i < n-1; i++ ) {
            for ( int j = i+1; j < n; j++ ) {
                vi.push_back( i );
                vj.push_back( j );
            }
        }
        // initialising the local forces vectors
        #pragma omp parallel
        {
            tid = omp_get_thread_num();
            #pragma master
            numth = omp_get_num_threads();
        }
        l_f.resize( numth );
        for ( int i = 0; i < numth; i++ ) {
            l_f[i].resize( n );
        }
    
        // testing all methods one after the other, with a warm up before each
        int lmax = 10000;
        loop1( n );
        double tbeg = omp_get_wtime();
        for ( int l = 0; l < lmax; l++ ) {
            loop1( n );
        }
        double tend = omp_get_wtime();
        std::cout << "Time for triangular loop is " << tend-tbeg << "s\n";
    
        loop2( n );
        tbeg = omp_get_wtime();
        for ( int l = 0; l < lmax; l++ ) {
            loop2( n );
        }
        tend = omp_get_wtime();
        std::cout << "Time for mangled rectangular loop is " << tend-tbeg << "s\n";
    
        loop3( n );
        tbeg = omp_get_wtime();
        for ( int l = 0; l < lmax; l++ ) {
            loop3( n );
        }
        tend = omp_get_wtime();
        std::cout << "Time for naive rectangular loop is " << tend-tbeg << "s\n";
    
        loop4( n );
        tbeg = omp_get_wtime();
        for ( int l = 0; l < lmax; l++ ) {
            loop4( n );
        }
        tend = omp_get_wtime();
        std::cout << "Time for linearised loop is " << tend-tbeg << "s\n";
    
        int ret = f[n-1];
        return ret;
    }
    

    现在,评估相对性能和可扩展性变得简单。 在第一次非定时预热迭代之后,所有方法都在循环上定时。

    编译:{{1​​}}

    8核IvyBridge CPU上的结果:

    g++ -O3 -mtune=native -march=native -fopenmp tbf.cc -o tbf

    因此,在这种情况下,方法#4似乎是具有良好性能和非常好的可伸缩性的最佳选择。请注意,由于> OMP_NUM_THREADS=1 numactl -N 0 -m 0 ./tbf 500 Time for triangular loop is 9.21198s Time for mangled rectangular loop is 10.1316s Time for naive rectangular loop is 15.9408s Time for linearised loop is 10.6449s > OMP_NUM_THREADS=2 numactl -N 0 -m 0 ./tbf 500 Time for triangular loop is 6.84671s Time for mangled rectangular loop is 5.13731s Time for naive rectangular loop is 8.09542s Time for linearised loop is 5.4654s > OMP_NUM_THREADS=4 numactl -N 0 -m 0 ./tbf 500 Time for triangular loop is 4.03016s Time for mangled rectangular loop is 2.90809s Time for naive rectangular loop is 4.45373s Time for linearised loop is 2.7733s > OMP_NUM_THREADS=8 numactl -N 0 -m 0 ./tbf 500 Time for triangular loop is 2.31051s Time for mangled rectangular loop is 2.05854s Time for naive rectangular loop is 3.03463s Time for linearised loop is 1.7106s 指令中的良好负载平衡作业,直接的三角形方法并不算太糟糕。但最终,我鼓励你用自己的工作量进行测试......

    供参考,您的初始代码(为计算schedule(auto)而修改的方式与其他测试完全相同,包括使用的OpenMP线程数,但不需要本地强制数组和最终减少,坦克到递归方法)给出了这个:

    force()

答案 3 :(得分:1)

基于任务的并行性的技巧是避免订阅不足和超额订阅。这意味着在某些时候必须串行执行任务,因为并行执行由于开销而变得太慢(另请参阅discussion here)。达到此点取决于工作量和任务计划程序。

rect()函数中,您已使用threshold将任务并行执行限制为每边元素数超过threshold的区域。但很奇怪,你不会在triangle()中这样做。所以我的第一道攻击线就是在这个例程中尝试类似的技术。

void triangle(int n0, int n1, const int threshold)
{
    int dn = n1 - n0;
    if(dn > threshold)
    {
        int nm = n0 + dn/2;
        #pragma omp task
        { triangle(n0, nm, threshold); }
        triangle(nm, n1, threshold);
        #pragma omp taskwait
        rect(n0, nm, nm, n1, threshold);  // pass threshold on
    } else {
        for(int i = n0; i < n1; ++i)
            for(int j = i+1; j < n1; ++j) {   // excludes self-interactions
                auto fij  = mutual_force(i,j);
                force[i] += fij;
                force[j] -= fij;
            }
    }
}

请注意,我将threshold设为运行时变量。这允许您尝试使用它来查看时间对它的敏感程度。通常的依赖性是具有良好缩放的长谷,但是对于太大或太小的值而言是差的结果。对于一个好的任务调度程序,您希望生成比线程更多的任务,但是threshold远大于1,比如说64-1024。

当然,这两个要求之间存在一种挤压:你不能有效地将小问题扩展到许多线程,强扩展有其限制(N个操作不能在超过N个线程之间共享)。

很可能你的问题太小而无法以这种方式有效地并行化,特别是只有几百个粒子。另一种并行化策略是计算每对的力两次并使用简单的for - 基于循环的并行性

#pragma omp parallel for
for(int i=0; i<n; ++i) {
    force[i] = 0;
    for(int j=0; j<n; ++j)
        force[i] += mutual_force(i,j);
}

编译器会发现这很容易优化,静态并行也可能很好。

答案 4 :(得分:0)

我猜你看到次优缓存使用率。现在的内存比CPU要慢得多 - 多个数量级。

当线程A占用字节1时,线程B占用字节2,而线程C占用字节3,那么三个CPU内核将各自必须将完整的高速缓存线路接入其L1高速缓存,只需使用其中的一个字节。 CPU必须确保缓存是连贯的,并且由于缓存行开头的数据在其他任何东西之前是可用的,因此它们将以不同的速度进行。然后可能会开始捶打常见的高级缓存。

另一方面,在单线程版本中,一个CPU内核必须完成所有工作,但它获得了最佳的内存访问:可预测的升序访问并始终使用完整的缓存行。

如何解决这样的问题?那么,你已经开始做了,在性能工程中最重要的是:衡量。也许你可以通过确保每个线程在一个完整的缓存行上工作,而另一个线程获得不同的线程来解决问题。我不知道OpenMP是否支持对线程上工作负载分布的细粒度控制。衡量一个并看看它是否有帮助。