[英]Loading an entire cache line at once to avoid contention for multiple elements of it
假设我需要从一个高度争用的缓存行中获得三个数据,是否有办法“原子地”加载所有三个东西,以避免多个往返任何其他核心?
对于所有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
同时运行)?
出于这个问题的目的,目标架构/平台是英特尔x86 Broadwell,但是如果有一种技术或编译器内在允许做一些像这样可以轻松移植的最佳工作,那也会很棒。
只要std::atomic<uint64_t>
的大小最多为16个字节(在所有主要编译器中就是这种情况), one
, two
和three
的总大小不超过32个字节。 因此,您可以定义__m256i
和Something
并集,其中Something
字段与32字节对齐,以确保它完全包含在单个64字节高速缓存行中。 要同时加载所有三个值,可以使用单个32字节AVX加载uop。 相应的编译器内在函数是_mm256_load_si256
,这会导致编译器发出VMOVDQA ymm1, m256
指令。 在Intel Haswell及更高版本上通过单个加载uop解码支持此指令。
实际上只需要32字节对齐来确保所有字段都包含在64字节高速缓存行中。 但是, _mm256_load_si256
要求指定的内存地址为32字节对齐。 或者,如果地址不是32字节对齐, _mm256_loadu_si256
可以使用_mm256_loadu_si256
。
术语:负载不会产生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::atomic
与mo_relaxed
的其他一些事情:特别是来自同一地址的重复加载可能不会给出相同的值。 您可能需要取消引用volatile __m256i*
以模拟load(mo_relaxed)
。
如果想要更强的排序,可以使用atomic_thread_fence()
; 我认为在实践中,支持英特尔内部函数的C ++ 11编译器将命令挥发性解除引用。 与std::atomic
load / stores相同的方式。 在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;
走出分支 ; 分支错误预测使核心有更多时间在第3次加载之前使该行无效。
但是编译器可能不会尊重源更改,只会在需要它的情况下加载它。 但无分支代码总是加载它,所以也许我们应该鼓励它
bar(one, two, one == 0 ? something.three : 0);
Broadwell可以在每个时钟周期运行2个负载(就像Sandybridge和K8以来的所有主流x86一样); uops通常以最早就绪的顺序执行,因此很可能(如果此负载必须等待来自另一个核心的数据) 我们的 2个负载uop将在数据到达后的第一个周期中执行。
希望第三个加载uop在此之后的循环中运行,留下一个非常小的窗口,导致出现问题。
或者在每个时钟负载仅为1的CPU上,仍然在asm中相邻的所有3个负载都会减少失效窗口。
但是如果one == 0
很少,那么通常根本不需要three
,所以无条件加载会带来不必要的请求。 因此,如果无法用一个SIMD负载覆盖所有数据,那么在调整时必须考虑权衡。
正如评论中所讨论的,软件预取可能有助于隐藏一些核心间延迟。
但是你必须比普通数组更早地预取,所以在调用f1()
之前在代码中找到经常运行~50到~100个周期的位置是一个难题并且可以“感染”很多其他的具有与其正常操作无关的详细信息的代码。 你需要一个指向正确缓存行的指针。
您需要PF足够晚,以便在预取数据实际到达之前需求负载发生几个(几十个)周期。 这与正常用例相反,其中L1d是一个缓冲区,用于在需求负载到达之前预取并保存完成的预取数据。 但是你想要 load_hit_pre.sw_pf
事件(加载命中预取),因为这意味着在数据仍然有效的情况下发生了需求加载,之后它就有可能被无效。
这意味着调整比平时更加脆弱和困难,因为早期或稍后不会伤害预取距离的近乎平坦的最佳位置,而是更早地隐藏更多延迟,直到它允许失效,所以它是一个一直爬到悬崖边。 (任何过早的预取都会让整体争论更加糟糕。)
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.