Cur*_*ous 5 c++ x86 multithreading micro-optimization false-sharing
假设从一个竞争激烈的高速缓存行中需要三段数据,是否有一种方法可以“原子地”加载所有这三件事,从而避免到任何其他内核的多次往返?
实际上,对于所有3个成员的快照,我实际上都不需要原子性的正确性保证,只是在正常情况下,所有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(...));
}
}
Run Code Online (Sandbox Code Playgroud)
我能否以某种方式确保one,two并且three所有组件都可以在没有大量RFO的情况下(f1而f2不是同时运行)一起加载到一起?
用于此问题的目标体系结构/平台是Intel x86 Broadwell,但是如果有某种技术或编译器内在函数可以允许某些可移植的工作尽力而为,那也很好。
术语:负载不会生成 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? 因此,除了需要避免缓存行分割之外,这是使用对齐加载的另一个充分理由。
std::atomic但我们是用 C 语言而不是 asm 编写的,因此我们需要担心with所做的其他一些事情mo_relaxed:特别是从同一地址重复加载可能不会给出相同的值。 您可能需要取消引用 avolatile __m256i*以模拟 what load(mo_relaxed)。
atomic_thread_fence()如果你想要更强的排序可以使用;我认为在实践中,支持 Intel 内在函数的 C++11 编译器将会对 易失性取消引用进行排序。std::atomic围栏的方式与装载/存储相同。在 ISO C++ 中,volatile对象仍然受到数据争用 UB 的影响,但在实际实现中,例如可以编译 Linux 内核,volatile可以用于多线程。(Linux 使用内联汇编来滚动自己的原子volatile,我认为这是 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);
Run Code Online (Sandbox Code Playgroud)
Broadwell 每个时钟周期可以运行 2 个负载(就像 Sandybridge 和 K8 以来的所有主流 x86 一样);微指令通常按照最旧就绪优先的顺序执行,因此(如果此负载必须等待来自另一个核心的数据)我们的2 个负载微指令可能会在数据到达后的第一个可能的周期中执行。
第三个加载 uop 希望在此之后的循环中运行,为无效导致问题留下一个非常小的窗口。
或者,在每个时钟只有 1 个负载的 CPU 上,仍然让所有 3 个负载在 asm 中相邻,从而减少了失效窗口。
但如果one == 0很少,那么three通常根本不需要,因此无条件加载会带来不必要的请求的风险。 因此,如果您无法用一个 SIMD 负载覆盖所有数据,则在调整时必须考虑这种权衡。
正如评论中所讨论的,软件预取可能有助于隐藏一些核心间延迟。
但是您必须比普通数组晚得多预取,因此在f1()调用之前在代码中查找通常运行约 50 到约 100 个周期的位置是一个难题,并且可能会“感染”许多其他带有详细信息的代码与其正常运行无关。并且您需要一个指向正确缓存行的指针。
您需要 PF 足够晚,以便在预取数据实际到达之前需求负载发生几个(数十个)周期。这与正常用例相反,其中 L1d 是一个缓冲区,用于在需求负载到达之前预取并保存已完成预取的数据。但是您需要 load_hit_pre.sw_pf性能事件(加载命中预取),因为这意味着需求加载发生在数据仍在传输中时,在数据有可能失效之前。
这意味着调整比平时更加脆弱和困难,因为预取距离的最佳点不是提前或推迟都不会造成伤害,而是提前隐藏更多延迟,直到允许失效为止,因此这是一个一路倾斜到悬崖。(任何过早的预取只会使整体争用变得更糟。)