多线程循环的效率

问候高贵社区,

我想要以下循环:

for(i = 0; i < MAX; i++)
    A[i] = B[i] + C[i];

这将使用线程在共享内存四核计算机上并行运行。对于这些线程执行的代码,正在考虑以下两种替代方法,其中线程的 ID 为:0、1、2 或 3。tid

(为简单起见,假设是 4 的倍数)MAX

选项 1:

for(i = tid; i < MAX; i += 4)
    A[i] = B[i] + C[i];

选项 2:

for(i = tid*(MAX/4); i < (tid+1)*(MAX/4); i++)
    A[i] = B[i] + C[i];

我的问题是,如果有一个比另一个更有效,为什么?


答案 1

第二个比第一个更好。答案很简单:第二个尽量减少错误共享

现代 CPU 不会不将字节逐个加载到缓存中。它在称为缓存行的批处理中读取一次。当两个线程尝试修改同一缓存行上的不同变量时,必须在修改缓存后重新加载缓存。

什么时候会发生这种情况?

基本上,内存中的附近元素将位于同一缓存行中。因此,数组中的相邻元素将位于同一缓存行中,因为数组只是一个内存块。foo1 和 foo2 也可能在同一缓存行中,因为它们在同一类中定义得很近。

class Foo {

private int foo1;
private int foo2;

}

虚假分享有多糟糕?

我参考了处理器缓存效果库中的示例 6

private static int[] s_counter = new int[1024];
private void UpdateCounter(int position)
{
    for (int j = 0; j < 100000000; j++)
    {
        s_counter[position] = s_counter[position] + 3;
    }
}

在我的四核计算机上,如果我从四个不同的线程调用参数 0,1,2,3 的 UpdateCounter,则需要 4.3 秒才能完成所有线程。另一方面,如果我使用参数16,32,48,64调用UpdateCounter,则操作将在0.28秒内完成!

如何检测虚假共享?

Linux Perf可用于检测缓存未命中,从而帮助您分析此类问题。

参考CPU Cache Effects和Linux Perf的分析,使用perf从上面几乎相同的代码示例中找出L1缓存未命中:

Performance counter stats for './cache_line_test 0 1 2 3':
10,055,747 L1-dcache-load-misses     #    1.54% of all L1-dcache hits   [51.24%]
Performance counter stats for './cache_line_test 16 32 48 64':
  36,992 L1-dcache-load-misses     #    0.01% of all L1-dcache hits   [50.51%]

它在此处显示,如果没有错误共享,L1 缓存命中总数将从 10,055,747 下降到 36,992。而且性能开销不在这里,而是在加载L2,L3缓存,错误共享后加载内存的系列。

行业内是否有一些好的做法?

LMAX Disruptor是一个高性能的线程间消息传递库,它是Apache Storm中工作内通信的默认消息传递系统底层数据结构是一个简单的环形缓冲区。但为了快速,它使用了很多技巧来减少错误共享。

例如,它定义了超类RingBufferPad,用于在RingBuffer中的元素之间创建pad:

abstract class RingBufferPad
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}

此外,当它为缓冲区分配内存时,它会在前面和后面创建pad,这样它就不会受到相邻内存空间中数据的影响:

this.entries   = new Object[sequencer.getBufferSize() + 2 * BUFFER_PAD];

您可能想了解有关所有魔术技巧的更多信息。看看作者的一篇文章:剖析破坏者:为什么它这么快


答案 2

有两个不同的原因,为什么您应该更喜欢选项 2 而不是选项 1。其中之一是缓存局部性/缓存争用,如@qqibrow的答案中所述;我不会在这里解释这一点,因为已经有一个很好的答案来解释它。

另一个原因是矢量化。大多数高端现代处理器都有矢量单元,能够同时对多个不同的数据运行相同的指令(特别是,如果处理器有多个内核,它几乎肯定在每个内核上都有一个矢量单元,甚至可能有多个矢量单元)。例如,如果没有矢量单元,处理器就有一条指令来执行加法:

A = B + C;

并且向量单元中的相应指令将同时执行多个加法:

A1 = B1 + C1;
A2 = B2 + C2;
A3 = B3 + C3;
A4 = B4 + C4;

(确切的添加次数因处理器型号而异;在s上,常见的“矢量宽度”包括4和8个同时添加,一些最近的处理器可以做16个。int

您的循环看起来像是使用矢量单位的明显候选者;只要 、 和 都不是指向同一数组但具有不同偏移量的指针(这在 C++ 中是可能的,但不能在 Java 中),编译器就可以将选项 2 优化为forABC

for(i = tid*(MAX/4); i < (tid+1)*(MAX/4); i+=4) {
    A[i+0] = B[i+0] + C[i+0];
    A[i+1] = B[i+1] + C[i+1];
    A[i+2] = B[i+2] + C[i+2];
    A[i+3] = B[i+3] + C[i+3];
}

但是,矢量单元的一个限制与内存访问有关:矢量单元仅在访问相邻位置(例如数组中的相邻元素或C的相邻字段)时访问内存的速度很快。上面的选项2代码几乎是代码矢量化的最佳情况:矢量单元可以从每个数组中作为单个块访问所需的所有元素。如果您尝试对选项 1 代码进行矢量化,矢量单位将花费很长时间才能在内存中找到它正在处理的所有值,以至于矢量化带来的收益将被抵消;它不太可能比非矢量化代码运行得更快,因为内存访问不会更快,并且通过比较添加不需要时间(因为处理器可以在等待值从内存到达时进行添加)。struct

不能保证编译器能够使用向量单元,但使用选项 2 比选项 1 更有可能这样做。因此,您可能会发现,选项 2 相对于选项 1 的优势比仅考虑缓存效果时预期的要高出 4/8/16。


推荐