5

假设我需要从一个竞争激烈的缓存行中获取三条数据,有没有办法“原子地”加载所有三样东西,以避免多次往返于任何其他核心?

对于所有 3 个成员的快照,我实际上并不需要原子性的正确性保证,只是在正常情况下,所有三个项目都在同一个时钟周期内读取。我想避免缓存行到达的情况,但是在读取所有 3 个对象之前出现无效请求。这将导致第三次访问需要发送另一个请求来共享线路,从而使争用更加严重。

例如,

class alignas(std::hardware_destructive_interference_size) Something {
    std::atomic<uint64_t> one;
    std::uint64_t two;
    std::uint64_t three;
};

void bar(std::uint64_t, std::uint64_t, std::uint64_t);

void f1(Something& something) {
    auto one = something.one.load(std::memory_order_relaxed);
    auto two = something.two;
    if (one == 0) {
        bar(one, two, something.three);
    } else {
        bar(one, two, 0);
    }

}

void f2(Something& something) {
    while (true) {
        baz(something.a.exchange(...));
    }
}

我能否以某种方式确保one,two并且three在没有多个 RFO 的情况下在激烈的争用下全部加载在一起(假设f1并且f2正在同时运行)?

这个问题的目标架构/平台是 Intel x86 Broadwell,但是如果有一种技术或编译器内在允许做一些像这样的尽力而为的事情,那也很好。

4

2 回答 2

3

只要 的大小std::atomic<uint64_t>最多为 16 字节(所有主要编译器都是这种情况), 、 和 的总大小onetwo超过three32 字节。因此,您可以定义该字段与 32 字节对齐的__m256i并集,以确保它完全包含在单个 64 字节高速缓存行中。要同时加载所有三个值,您可以使用单个 32 字节 AVX 加载微指令。相应的编译器内在函数是,它会导致编译器发出指令。Intel Haswell 及更高版本上的单个加载 uop 解码支持此指令。SomethingSomething_mm256_load_si256VMOVDQA ymm1, m256

32 字节对齐实际上只需要确保所有字段都包含在 64 字节高速缓存行中。但是,_mm256_load_si256要求指定的内存地址是 32 字节对齐的。或者,_mm256_loadu_si256可以在地址不是 32 字节对齐的情况下使用。

于 2019-05-30T23:14:33.687 回答
3

术语:负载不会产生 RFO,它不需要所有权。它仅发送共享数据的请求。多个内核可以从同一个物理地址并行读取,每个内核在其 L1d 缓存中都有一个热副本。

但是,写入该行的其他内核将发送 RFO,这会使我们缓存中的共享副本无效,是的,可能会在读取缓存行的一个或两个元素之后再读取所有元素。(我用这些术语对问题的描述更新了您的问题。)


Hadi 的 SIMD 加载是使用一条指令获取所有数据的好主意。

据我们所知,_mm_load_si128()它的 8 字节块实际上是原子的,因此它可以安全地替换.load(mo_relaxed)原子的。但是请参阅矢量加载/存储和收集/分散的每元素原子性?- 对此没有明确的书面保证。

如果您使用过_mm256_loadu_si256(),请注意 GCC 的默认调整-mavx256-split-unaligned-load为什么 gcc 不将 _mm256_loadu_pd 解析为单个 vmovupd? 因此,除了需要避免缓存行拆分之外,这是使用对齐负载的另一个好理由。

但是我们是用 C 语言编写的,而不是 asm,所以我们需要担心std::atomicwith的其他一些事情mo_relaxed:特别是从同一个地址重复加载可能不会给出相同的值。 您可能需要取消引用 avolatile __m256i*来模拟什么load(mo_relaxed)

atomic_thread_fence()如果您想要更强的排序,您可以使用;我认为在实践中支持英特尔内在函数的 C++11 编译器将订购 volatile 取消引用 wrt。栅栏的方式与std::atomic加载/存储相同。在 ISO C++ 中,volatile对象仍然受到数据竞争 UB 的影响,但在可以编译 Linux 内核的实际实现中,可以volatile用于多线程。(Linux 使用volatile和内联 asm 滚动它自己的原子,我认为这是 gcc/clang 支持的行为。)鉴于volatile实际所做的(内存中的对象与 C++ 抽象机匹配),它基本上只是自动工作,尽管有任何规则-律师担心它在技术上是 UB。编译器无法知道或关心的是 UB,因为这就是volatile.

在实践中,有充分的理由相信在 Haswell 及以后的整个对齐的 32 字节加载/存储是原子的。当然用于从 L1d 读取到乱序后端,甚至用于在内核之间传输缓存线。(例如,多套接字 K10 可以使用 HyperTransport 撕裂 8 字节边界,所以这确实是一个单独的问题)。利用它的唯一问题是缺乏任何书面保证或 CPU 供应商批准的方法来检测此“功能”。


除此之外,对于可移植代码,它可能有助于提升auto three = something.three;分支;分支错误预测使核心有更多时间在第三次加载之前使行无效。

但是编译器可能不会尊重该源更改,并且仅在需要它的情况下加载它。但是无分支代码总是会加载它,所以也许我们应该鼓励它

    bar(one, two, one == 0 ? something.three : 0);

Broadwell 可以在每个时钟周期运行 2 个负载(就像自 Sandybridge 和 K8 以来的所有主流 x86 一样);微指令通常以最旧的就绪优先顺序执行,因此很可能(如果此加载确实必须等待来自另一个内核的数据)我们的2 个加载微指令将在数据到达后可能的第一个周期中执行。

第三次加载 uop 有望在那之后的循环中运行,留下一个非常小的窗口让无效导致问题。

或者在每个时钟负载只有 1 个的 CPU 上,仍然在 asm 中相邻的所有 3 个负载会减少失效窗口。

但如果one == 0很少见,则three通常根本不需要,因此无条件加载会带来不必要的请求的风险。 因此,如果您不能用一个 SIMD 负载覆盖所有数据,则在调整时必须考虑这种权衡。


正如评论中所讨论的,软件预取可能有助于隐藏一些内核间延迟。

但是您必须比普通数组更晚地预取,因此在调用之前在代码中找到经常运行约 50 到约 100 个周期f1()的位置是一个难题,并且可能会“感染”许多其他代码的细节与其正常运行无关。你需要一个指向正确缓存行的指针。

您需要 PF 足够晚,以使需求负载在预取数据实际到达之前发生几个(几十个)周期。这与正常用例相反,其中 L1d 是一个缓冲区,用于在需求负载到达之前预取并保存已完成预取的数据。但是您需要 load_hit_pre.sw_pfperf 事件(负载命中预取),因为这意味着需求负载发生在数据仍在传输中时,然后才有可能失效。

这意味着调优比平时更加​​脆弱和困难,因为与早晚不会受到伤害的几乎平坦的预取距离最佳位置不同,早先隐藏了更多延迟,直到它允许失效为止,所以它是一个一直倾斜到悬崖。(并且任何过早的预取只会使整体争用变得更糟。)

于 2019-05-31T01:34:50.727 回答