我正在优化一个求解器(线性方程组),其最关键的部分包括
许多(1000+)短(约10-1000微秒)大规模并行(64个CPU核上的128个线程)扫描小型(CPU缓存大小)阵列,伪代码:
for(i=0;i<num_iter;i++)
{
// SYNC-POINT
parallel_for(j=0;j<array_size;j++)
array_out[j] = some_function( array_in[j] )
swap( array_in, array_out );
}
不幸的是,到目前为止,我尝试过OMP或TBB提供的标准并行化结构(串行外循环加并行内循环,例如通过tbb::parallel_for)似乎不能很好地处理这种极细粒度的并行性,因为线程库的内循环设置成本似乎主导了在相对较短的内循环中花费的时间。(请注意,非常细粒度的内部循环对算法的总体性能至关重要,因为这样所有数据都可以保存在L2/L3 CPU缓存中)
编辑以解决答案、问题和;到目前为止的评论:
这个例子只是用来说明这个想法的伪代码。实际实现通过用CPU缓存行填充ARRAY行来处理错误共享。
some_func(array_in,j)是一个简单的模板,它访问当前点j及其周围的一个小邻域,例如sume_func(数组,j)=array[j-1]+array[j]+array[j+1];
Jérôme Richard给出的答案触及了一个非常有趣的点关于障碍(这是国际海事组织问题的根源)。建议";通过本地点对点邻居同步来替换屏障。使用基于任务的并行运行时可以帮助轻松做到这一点。较弱的同步模式是关键;。有趣但很一般。在这种情况下,究竟该如何实现?是否";点对点邻居同步";为数组的每个条目都包含一个原子基元?
这个问题的一般解决方案是创建线程并只分发一次工作,然后在线程中使用快速同步点。在这种情况下,外循环在线程函数中移动。通过向tbb::parallel_for
提供范围(tbb::blocked_range<size_t>
)和函数,TBB库可以实现这一点(参见此处的示例)。
众所周知,屏障在许多核心体系结构上的扩展性很差,尤其是在此类代码中。使程序规模化的唯一方法是减少线程之间的同步,使其更为本地。例如,对于模板,解决方案是通过本地点对点邻居同步来替换屏障。使用基于任务的并行运行时可以帮助轻松做到这一点较弱的同步模式是解决此类问题的关键。事实上,请注意,物理学的基本定律防止了规模的障碍,因为在广义相对论中,时钟不能完全同步,而计算机(不幸的是)遵守物理定律。
许多核心系统现在几乎总是NUMA。关于您的配置,您当然使用具有多个NUMA节点的AMD Threadipper处理器。这意味着您应该关心位置和NUMA分配策略。默认策略通常是第一次触摸。这意味着,如果初始化是顺序的,或者线程映射不同,那么核心必须从远程NUMA节点获取数据,这很慢。在最坏的情况下,所有核心都可以访问同一个NUMA节点并使其饱和,这可能导致执行速度比顺序版本慢。为了获得更好的性能,您通常应该使其平行。在这样的体系结构上获得高性能绝非易事。您需要仔细控制分配策略(numactl对此有帮助)、初始化(并行)、线程绑定(使用taskset
、hwloc
和/或环境变量)和内存访问模式(通过阅读有关NUMA机器如何工作和应用专用算法的文章/书籍)。
顺便说一句,代码中可能会出现错误共享效果,因为array_out
的缓存行肯定是在线程之间共享的。这不应该有严重的影响,但它确实会导致较差的可扩展性(尤其是在64核处理器上)。这个问题的一般解决方案是在缓存线上对齐内存中的数组,并在缓存线边界上执行并行拆分。或者,您可以在每个线程中分配数组部分。这通常是一种更好的方法,因为它可以确保数据在本地分配、本地填充,并使NUMA节点甚至核心之间的数据共享/通信更加明确(即更好的控制),尽管它会使代码更加复杂(没有免费的午餐)。
跨线程共享数据的速度很慢。这是因为每个CPU核心至少有一层个人缓存。在核心/线程之间共享数据的那一刻,个人缓存就需要同步,这很慢。
如果不共享数据,那么跨不同内核并行运行的线程工作得最好。
在您的情况下,如果数据是只读的,那么最好为每个线程提供自己的数据副本。对于读写数据,您必须接受同步速度的降低。