【问题标题】:Efficiency of Multithreaded Loops多线程循环的效率
【发布时间】:2015-03-26 14:51:30
【问题描述】:

问候贵族社区,

我想要以下循环:

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

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

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

选项 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];

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

【问题讨论】:

  • 这类问题的通常答案是“视情况而定;尝试两种方法并自己衡量结果,否则你永远无法确定”
  • 我没有以正确方式对其进行测试的必要条件。另一方面,我的结论是,因为我们在 "Option 1" 上有一个常数 4 的增量,所以我在这个常数的 4 个值上跳 4 以计算线程 A[i ]。在 “选项 2” 上,我可以得出变量 i 的单位增量,但线程在 tid*(MAX/4)(tid+1)*(MAX/4) 之间工作。
  • 这样我将在 "Option 2" 上拥有比 "Option 1" 更大的线程和更少的空间,因此通过选择可以提高效率“选项 2”.

标签: java c++ multithreading performance shared-memory


【解决方案1】:

第二个比第一个好。简单回答:第二个最小化false sharing

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

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

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

class Foo {

private int foo1;
private int foo2;

}

虚假分享有多严重?

我参考Gallery of Processor Cache Effects中的示例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 and 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];

source

您可能想了解有关所有魔术技巧的更多信息。看看作者的一个帖子:Dissecting the Disruptor: Why it's so fast

【讨论】:

  • 优秀的qqibrow,谢谢你的清晰解释。
【解决方案2】:

您应该选择选项 2 而不是选项 1 有两个不同的原因。其中之一是缓存位置/缓存争用,如 @qqibrow 的回答中所述;我不会在这里解释,因为已经有一个很好的答案来解释它。

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

A = B + C;

向量单元中对应的指令会同时做多次加法:

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

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

您的for 循环看起来像是使用向量单元的明显候选者;只要ABC 都不是指向同一个数组但具有不同偏移量的指针(这在 C++ 中是可能的,但在 Java 中是可能的),编译器将被允许将选项 2 优化为

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 struct 的相邻字段)时才能快速访问内存)。上面的选项 2 代码几乎是代码向量化的最佳情况:向量单元可以从每个数组中作为单个块访问它需要的所有元素。如果您尝试对选项 1 代码进行矢量化,则矢量单元将花费很长时间来尝试在内存中找到它正在处理的所有值,以至于矢量化的收益将被否定;它不太可能比非向量化代码运行得更快,因为内存访问不会更快,并且相比较而言,加法不需要时间(因为处理器可以在等待值到达时进行加法凭记忆)。

不能保证编译器能够使用向量单元,但选项 2 比选项 1 更有可能这样做。所以您可能会发现选项 2 优于选项 1如果仅考虑缓存效果,则比您预期的多 4/8/16 倍。

【讨论】:

    猜你喜欢
    • 2011-05-24
    • 1970-01-01
    • 2013-04-18
    • 2013-09-04
    • 2014-03-08
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多