一次加载整个缓存行以避免争用其中的多个元素
Loading an entire cache line at once to avoid contention for multiple elements of it
假设我需要从竞争激烈的高速缓存行中获取三份数据,是否有一种方法可以加载所有这三样东西 "atomically" 以避免多次往返于任何其他核心?
我实际上不需要所有 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,但如果有一种技术或编译器内在允许做一些像这样的尽力而为的事情有点可移植,那也很好。
只要 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
。
术语:负载不会生成 RFO,它不需要所有权。它只发送请求 share 数据。多个核心可以并行读取同一物理地址,每个核心在其 L1d 缓存中都有一份热副本。
写入该行的其他核心将发送 RFO,这会使我们的缓存中的共享副本无效,但是,是的,在读取所有缓存行的一个或两个元素之后,它可能会进入。 (我用这些术语对问题的描述更新了你的问题。)
Hadi 的 SIMD 加载是一个用一条指令抓取所有数据的好主意。
据我们所知,_mm_load_si128()
对于它的 8 字节块实际上是原子的,因此它可以安全地替换原子的 .load(mo_relaxed)
。但是请参阅 Per-element atomicity of vector load/store and gather/scatter? - 对此没有明确的书面保证。
如果您使用 _mm256_loadu_si256()
,请注意 GCC 的默认调整 -mavx256-split-unaligned-load
: 所以这是使用对齐加载的另一个很好的理由,除了需要避免 cache-line 分裂.
但是我们是用 C 编写的,而不是 asm,所以我们需要担心 std::atomic
和 mo_relaxed
会做的一些其他事情:特别是从同一地址重复加载可能不给同样的价值。 您可能需要取消引用 volatile __m256i*
来模拟 load(mo_relaxed)
.
如果你想要更强的顺序,你可以使用atomic_thread_fence()
;我认为在实践中,支持英特尔内在函数的 C++11 编译器将对 volatile 取消引用进行排序。围栏的方式与 std::atomic
loads/stores 相同。在 ISO C++ 中,volatile
对象仍受制于 data-race UB,但在实际实现中,例如可以编译 Linux 内核,volatile
可用于 multi-threading. (Linux 使用 volatile
和内联 asm 滚动自己的原子,我认为这是 gcc/clang 支持的行为。)鉴于 volatile
实际做了什么(内存中的对象匹配C++ 抽象机),它基本上只是自动工作,尽管 rules-lawyer 担心它在技术上是 UB。编译器无法知道或关心的是 UB,因为这是 volatile
.
的重点
在实践中,有充分的理由相信 Haswell 及更高版本上的整个对齐的 32 字节 loads/store 是原子的。当然是为了从 L1d 读取到 out-of-order 后端,甚至是为了在内核之间传输缓存行。 (例如 multi-socket K10 可以使用 HyperTransport 在 8 字节边界上撕裂,所以这确实是一个单独的问题)。利用它的唯一问题是缺乏任何书面保证或 CPU-vendor-approved 方法来 检测 这个 "feature".
除此之外,对于可移植代码它可以帮助将auto three = something.three;
提升出分支;分支预测错误使核心有更多时间在第三次加载之前使行无效。
但是编译器可能不会尊重该源更改,并且只会在需要它的情况下加载它。但是无分支代码总是会加载它,所以也许我们应该鼓励
bar(one, two, one == 0 ? something.three : 0);
Broadwell 每个时钟周期可以 运行 2 次加载(就像自 Sandybridge 和 K8 以来的所有主流 x86); uops 通常以 oldest-ready-first 的顺序执行,所以很可能(如果此负载确实必须等待来自另一个核心的数据) our 2 load uops 将在可能的第一个循环之后执行数据到达。
第 3 个加载 uop 有望在之后的循环中 运行,留下非常小的 window 用于无效导致问题。
或者在每个时钟负载只有 1 个的 CPU 上,仍然有所有 3 个负载在 asm 中相邻减少了 window 失效。
但是如果one == 0
很少见,那么three
通常根本就不需要,所以无条件加载会带来不必要的请求风险。 因此,如果您不能用一个 SIMD 负载覆盖所有数据,则在调整时必须考虑这种权衡。
正如评论中所讨论的,软件预取可能有助于隐藏一些 inter-core 延迟。
但是你必须比普通数组更晚地预取,所以在你的代码中找到通常在调用 f1()
之前 运行ning ~50 到 ~100 个周期的地方是一个难题,可以 "infect" 许多其他代码,其中包含与其正常操作无关的细节。你需要一个指向正确缓存行的指针。
您需要 PF 足够晚,以便在预取数据实际到达之前发生几个(数十个)周期的需求负载。这与正常的 use-case 相反,其中 L1d 是一个缓冲区,用于在 demand-loads 到达它们之前预取并保存来自已完成预取的数据。但是您 想要 load_hit_pre.sw_pf
perf 事件(加载命中预取),因为这意味着需求加载发生在数据仍在运行时,在它有任何可能失效之前。
这意味着调整比平时更加脆弱和困难,因为不是 nearly-flat 更早或更晚的预取距离最佳点'伤害,早期隐藏更多的延迟,直到它允许失效的点,所以它一直到悬崖的斜坡。 (而且任何 too-early 预取只会让整体竞争变得更糟。)
假设我需要从竞争激烈的高速缓存行中获取三份数据,是否有一种方法可以加载所有这三样东西 "atomically" 以避免多次往返于任何其他核心?
我实际上不需要所有 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,但如果有一种技术或编译器内在允许做一些像这样的尽力而为的事情有点可移植,那也很好。
只要 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
。
术语:负载不会生成 RFO,它不需要所有权。它只发送请求 share 数据。多个核心可以并行读取同一物理地址,每个核心在其 L1d 缓存中都有一份热副本。
写入该行的其他核心将发送 RFO,这会使我们的缓存中的共享副本无效,但是,是的,在读取所有缓存行的一个或两个元素之后,它可能会进入。 (我用这些术语对问题的描述更新了你的问题。)
Hadi 的 SIMD 加载是一个用一条指令抓取所有数据的好主意。
据我们所知,_mm_load_si128()
对于它的 8 字节块实际上是原子的,因此它可以安全地替换原子的 .load(mo_relaxed)
。但是请参阅 Per-element atomicity of vector load/store and gather/scatter? - 对此没有明确的书面保证。
如果您使用 _mm256_loadu_si256()
,请注意 GCC 的默认调整 -mavx256-split-unaligned-load
:
但是我们是用 C 编写的,而不是 asm,所以我们需要担心 std::atomic
和 mo_relaxed
会做的一些其他事情:特别是从同一地址重复加载可能不给同样的价值。 您可能需要取消引用 volatile __m256i*
来模拟 load(mo_relaxed)
.
如果你想要更强的顺序,你可以使用atomic_thread_fence()
;我认为在实践中,支持英特尔内在函数的 C++11 编译器将对 volatile 取消引用进行排序。围栏的方式与 std::atomic
loads/stores 相同。在 ISO C++ 中,volatile
对象仍受制于 data-race UB,但在实际实现中,例如可以编译 Linux 内核,volatile
可用于 multi-threading. (Linux 使用 volatile
和内联 asm 滚动自己的原子,我认为这是 gcc/clang 支持的行为。)鉴于 volatile
实际做了什么(内存中的对象匹配C++ 抽象机),它基本上只是自动工作,尽管 rules-lawyer 担心它在技术上是 UB。编译器无法知道或关心的是 UB,因为这是 volatile
.
在实践中,有充分的理由相信 Haswell 及更高版本上的整个对齐的 32 字节 loads/store 是原子的。当然是为了从 L1d 读取到 out-of-order 后端,甚至是为了在内核之间传输缓存行。 (例如 multi-socket K10 可以使用 HyperTransport 在 8 字节边界上撕裂,所以这确实是一个单独的问题)。利用它的唯一问题是缺乏任何书面保证或 CPU-vendor-approved 方法来 检测 这个 "feature".
除此之外,对于可移植代码它可以帮助将auto three = something.three;
提升出分支;分支预测错误使核心有更多时间在第三次加载之前使行无效。
但是编译器可能不会尊重该源更改,并且只会在需要它的情况下加载它。但是无分支代码总是会加载它,所以也许我们应该鼓励
bar(one, two, one == 0 ? something.three : 0);
Broadwell 每个时钟周期可以 运行 2 次加载(就像自 Sandybridge 和 K8 以来的所有主流 x86); uops 通常以 oldest-ready-first 的顺序执行,所以很可能(如果此负载确实必须等待来自另一个核心的数据) our 2 load uops 将在可能的第一个循环之后执行数据到达。
第 3 个加载 uop 有望在之后的循环中 运行,留下非常小的 window 用于无效导致问题。
或者在每个时钟负载只有 1 个的 CPU 上,仍然有所有 3 个负载在 asm 中相邻减少了 window 失效。
但是如果one == 0
很少见,那么three
通常根本就不需要,所以无条件加载会带来不必要的请求风险。 因此,如果您不能用一个 SIMD 负载覆盖所有数据,则在调整时必须考虑这种权衡。
正如评论中所讨论的,软件预取可能有助于隐藏一些 inter-core 延迟。
但是你必须比普通数组更晚地预取,所以在你的代码中找到通常在调用 f1()
之前 运行ning ~50 到 ~100 个周期的地方是一个难题,可以 "infect" 许多其他代码,其中包含与其正常操作无关的细节。你需要一个指向正确缓存行的指针。
您需要 PF 足够晚,以便在预取数据实际到达之前发生几个(数十个)周期的需求负载。这与正常的 use-case 相反,其中 L1d 是一个缓冲区,用于在 demand-loads 到达它们之前预取并保存来自已完成预取的数据。但是您 想要 load_hit_pre.sw_pf
perf 事件(加载命中预取),因为这意味着需求加载发生在数据仍在运行时,在它有任何可能失效之前。
这意味着调整比平时更加脆弱和困难,因为不是 nearly-flat 更早或更晚的预取距离最佳点'伤害,早期隐藏更多的延迟,直到它允许失效的点,所以它一直到悬崖的斜坡。 (而且任何 too-early 预取只会让整体竞争变得更糟。)