我整理了一个模拟我正在处理的数据结构类型的示例程序。即我有n
个对象,我需要在每个可能的对之间迭代一次并执行(对称)计算。此操作涉及将数据写入两对。在串行中,这将采取像这样的循环的形式
for(int i = 0; i < N-1; ++i)
for(int j = i + 1; j < N; ++j)
...
但是,在互联网上搜索并没有花费太多时间来查找&#34;缓存无关的并行实现&#34;,我在下面编写并复制了这些内容。我在这里链接了一个帖子(使用英特尔TBB),详细描述了这个算法。
我尝试使用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;
}
答案 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矩阵(内部向量)中的每个缓存行仅由一个线程更新。通过构造三角形也是如此,分区已经保证了这一点。但是,如果im
和jm
不是在缓存行边界对齐的条目的索引,则矩形中的并行性是有问题的。如果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的不同但相邻的元素(源代码行 显示为红色),它使所有处理器的高速缓存行无效。
图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种不同的方法来迭代粒子:
schedule(auto)
子句并行化,以允许运行时库采取它认为最佳性能的决策。三角域的“巧妙”遍历,包括在j
方向上将其切成两半以允许使用2个常规循环。基本上,它对应于这样的事情:
. /|
/ | __ __
/ | => | // |
/___| |//____|
i
和j
索引的顺序以访问三角域,并迭代包含这些索引的向量。由于使用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是否支持对线程上工作负载分布的细粒度控制。衡量一个并看看它是否有帮助。