【问题标题】:Can atomic operations on a non-atomic<> pointer be safe and faster than atomic<>?非原子<> 指针上的原子操作是否比原子<> 更安全且更快?
【发布时间】:2020-04-20 14:16:25
【问题描述】:

我有十几个线程在读取一个指针,而一个线程可能会在一小时左右更改一次指针。

读者超级、超级、超级时间敏感。我听说atomic&lt;char**&gt; 或进入主内存的速度是什么,我想避免。

在现代(比如 2012 年及以后)服务器和高端台式机 Intel 中,如果正常读写,是否可以保证 8 字节对齐的常规指针不撕裂?我的一个测试运行了一个小时,没有看到眼泪。

否则,如果我以原子方式写入并正常读取,会更好(或更糟)吗?例如通过将两者结合起来?

请注意,还有其他关于混合原子和非原子操作的问题,这些问题没有指定 CPU,并且讨论转向语言律师。这个问题不是关于规范,而是关于究竟会发生什么,包括我们是否知道在规范未定义的地方会发生什么。

【问题讨论】:

  • 1) std::atomic 是 C++ 而不是 C,所以我删除了标题中提到的 C;请包括一个通用语言标签,而不仅仅是一个特定于版本的标签(固定)。 2) 你的 Q 是特定于 C++11 的吗?为什么要坚持这么老的 C++ 标准?
  • 您实际上在更高级别上做什么?可能有更好的方法。

标签: c++ performance c++11 x86-64 stdatomic


【解决方案1】:

x86 永远不会将 asm 加载或存储撕成对齐的指针宽度值。这个问题的那一部分和你的另一个问题(C++11 on modern Intel: am I crazy or are non-atomic aligned 64-bit load/store actually atomic?)都是重复的 Why is integer assignment on a naturally aligned variable atomic on x86?

这就是为什么atomic&lt;T&gt; 编译器实现起来如此便宜,以及为什么使用它没有缺点的部分原因。

在 x86 上读取 atomic&lt;T&gt; 的唯一真正成本是它无法在多次读取同一 var 时优化到寄存器中。但无论如何,您都需要让程序正常工作(即让线程通知指针的更新)。 在非 x86 上,只有 mo_relaxed 与普通 asm 加载一样便宜,但 x86 的强大内存模型甚至使 seq_cst 负载变得便宜。

如果您在一个函数中多次使用指针,请执行T* local_copy = global_ptr;,以便编译器可以将local_copy 保存在寄存器中。将其视为从内存加载到私有寄存器中,因为这正是它的编译方式。原子对象上的操作不会优化,因此如果您想在每个循环中重新读取一次全局指针,请以这种方式编写您的源代码。或者一旦在循环之外:以这种方式编写你的源代码并让编译器管理本地变量。


显然你一直试图避免atomic&lt;T*&gt;,因为你对std::atomic::load() 纯加载操作的性能有一个巨大的误解。 std::atomic::store() 会稍慢一些,除非您使用 memory_order 释放或放松,但在 x86 上 std::atomic 没有额外的 seq_cst 加载成本。

在此处避免使用atomic&lt;T*&gt; 并没有性能优势。它将完全满足您的安全和便携需求,并为您的主要阅读用例提供高性能。每个读取它的核心都可以访问其私有 L1d 缓存中的副本。写入会使该行的所有副本无效,因此写入者拥有独占所有权 (MESI),但从每个内核的下一次读取将获得一个共享副本,该副本可以再次在其私有缓存中保持热状态。

(这是一致性缓存的好处之一:读者不必一直检查某个共享副本。写入者在写入之前必须确保任何地方都没有陈旧的副本。这一切都是由硬件完成的, 而不是软件 asm 指令。我们运行多个 C++ 线程的所有 ISA 都具有缓存一致的共享内存,这就是为什么 volatile 可以像人们过去那样滚动你自己的原子 (but don't do it)在 C++11 之前。或者就像您尝试使用 volatile 进行 一样,这仅适用于调试版本。绝对不要这样做 那个!)

原子加载编译为编译器用于其他所有内容的相同指令,例如mov。在 asm 级别,每个对齐的加载和存储都是一个原子操作(对于 2 个大小的幂,最多 8 个字节)。 atomic&lt;T&gt; only 必须阻止编译器假设没有其他线程在访问之间写入对象。

(与纯加载/纯存储不同,atomicity of a whole RMW doesn't happen for free;ptr_to_int++ 将编译为 lock add qword [ptr], 4。但在无竞争的情况下,这仍然比一直到 DRAM 的缓存未命中要快得多,只需要一个“缓存锁” " 在拥有该行专有所有权的核心内部。就像每次操作 20 个周期,如果您只在 Haswell (https://agner.org/optimize/) 上背靠背做任何事情,但其他代码中间只有一个原子 RMW 可以与周围的 ALU 操作很好地重叠。)

纯只读访问是使用原子的无锁代码与任何需要 RWlock 相比真正闪耀的地方 - atomic&lt;&gt; 读者不会相互竞争,因此读取端可以完美地扩展像这样的用例 (or RCU or a SeqLock)。

在 x86 上,seq_cst 加载(默认排序)不需要任何屏障指令,这要归功于 x86 的硬件内存排序模型(程序顺序加载/存储,加上带有存储转发的存储缓冲区)。这意味着您可以在使用指针的读取端获得完整的性能,而不必削弱到 acquireconsume 内存顺序。

如果存储性能是一个因素,您可以使用std::memory_order_release,这样存储也可以是简单的mov,而无需使用mfencexchg 耗尽存储缓冲区。


我听说atomic&lt;char**&gt; 或者进入主存的速度是什么

无论你读到什么都误导了你。

即使在内核之间获取数据也不需要进入实际的 DRAM,只需要共享最后一级缓存即可。由于您使用的是 Intel CPU,因此 L3 缓存是缓存一致性的后盾。

在内核写入缓存行之后,它仍将处于其私有 L1d 缓存中,处于 MESI 修改状态(并且在所有其他缓存中无效;这就是 MESI 保持缓存一致性的方式 = 任何地方都没有行的陈旧副本)。因此,来自该高速缓存行的另一个核心上的负载将在私有 L1d 和 L2 高速缓存中丢失,但 L3 标签会告诉硬件哪个核心拥有该行的副本。一条消息通过环形总线到达该核心,使其将线路写回 L3。 从那里可以将其转发到仍在等待加载数据的核心。这几乎就是 内核间延迟 衡量的指标 - 在一个内核上存储和在另一个内核上获得价值之间的时间。

这所花费的时间(内核间延迟)大致类似于 L3 缓存中未命中的负载并且必须等待 DRAM,例如可能需要 40ns 和 70ns,具体取决于 CPU。也许这就是你读到的。 (多核 Xeon 在环形总线上的跳数更多,内核之间以及从内核到 DRAM 的延迟更多。)

但这仅适用于写入后的第一次加载。 数据由加载它的内核上的 L2 和 L1d 缓存进行缓存,并在 L3 中处于共享状态。在那之后,任何频繁读取指针的线程都会使该行在运行该线程的核心上的快速私有 L2 甚至 L1d 缓存中保持热状态。 L1d 缓存有 4-5 个周期的延迟,每个时钟周期可以处理 2 个负载。

并且线路将在 L3 中处于任何其他核心都可以命中的共享状态,因此只有第一个核心支付全部核心间延迟损失。

(在 Skylake-AVX512 之前,英特尔芯片使用包容性 L3 缓存,因此 L3 标签可以用作窥探过滤器,用于内核之间基于目录的缓存一致性。如果某行在某些私有缓存中处于共享状态,它也是有效的在 L3 中处于 Shared 状态。即使在 L3 缓存不保持 inclusive 属性的 SKX 上,数据在内核之间共享后也会在 L3 中存在一段时间。)

在调试版本中,每个变量在 C++ 语句之间存储/重新加载到内存中。这并不(通常)比正常优化构建慢 400 倍这一事实表明,在非竞争情况下,当它在缓存中命中时,内存访问并不会太慢。 (将数据保存在寄存器中比内存更快,因此调试构建通常非常糟糕。如果您将 every 变量 atomic&lt;T&gt;memory_order_relaxed 一起制作,这将有点类似于没有优化的编译,除了像++这样的东西。为了清楚起见,我不是atomic&lt;T&gt; 使您的代码以调试模式的速度运行。每次源提到它时,都需要从内存(通过缓存)重新加载可能异步更改的共享变量,atomic&lt;T&gt; 会这样做。


正如我所说,读取 atomic&lt;char**&gt; ptr 将编译为 x86 上的 mov 加载,没有额外的栅栏,与读取非原子对象完全相同。

除了它会阻止一些编译时重新排序,并且像volatile 这样会阻止编译器假设值永远不会改变并将负载提升到循环之外。它还阻止编译器发明额外的读取。见https://lwn.net/Articles/793253/


我有十几个线程读取一个指针,而一个线程可能会在一小时左右更改一次指针。

您可能需要 RCU,即使这意味着为每个非常不频繁的写入复制一个相对较大的数据结构。 RCU 使阅读器成为真正的只读阅读器,因此阅读端缩放是完美的。

您的C++11/14/17: a readers/writer lock... without having a lock for the readers? 的其他答案建议涉及多个 RWlock 的内容,以确保读者总能拿到一个。这仍然涉及所有读者都争相修改的某个共享缓存行上的原子 RMW。如果您有读取 RWlock 的读取器,他们可能会因内核间延迟而停滞,因为他们将包含锁定的缓存行置于 MESI 修改状态。

(硬件锁消除用于解决避免读取器之间争用的问题,但已被微码更新禁用on all existing hardware。)

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2014-02-25
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多