Swi*_*ank 2 c++ performance x86-64 c++11 stdatomic
我有十几个线程读取指针,并且一个线程可能每小时左右更改该指针一次。
读者对时间超级、超级、超级敏感。我听说这个atomic<char**>或其他什么是进入主内存的速度,我想避免这种情况。
在现代(比如2012年及以后)服务器和高端桌面Intel中,8字节对齐的正则指针能否保证正常读写时不会撕裂?我的测试运行了一个小时,没有看到撕裂。
否则,如果我以原子方式进行写入并正常读取,会更好(或更糟)吗?例如通过将两者结合起来?
请注意,还有其他有关混合原子和非原子操作的问题,这些问题没有指定 CPU,并且讨论会转向语言法律主义。这个问题与规范无关,而是关于到底会发生什么,包括我们是否知道在未定义规范的情况下会发生什么。
x86 永远不会将 asm 加载或存储为对齐的指针宽度值。这个问题的这一部分以及您的其他问题(现代英特尔上的 C++11:我疯了还是非原子对齐的 64 位加载/存储实际上是原子的?)都是 Why is integer assignment on anaturallyaligned 的重复x86 上的变量原子?
atomic<T>这就是为什么编译器的实现成本如此之低,以及使用它没有任何缺点的部分原因。
在 x86 上读取 an 的唯一真正成本atomic<T>是它无法在多次读取同一变量时优化到寄存器中。但无论如何,您都需要让程序正常工作(即让线程注意到指针的更新)。 在非 x86 上,onlymo_relaxed与普通 asm 加载一样便宜,但 x86 强大的内存模型甚至使 seq_cst 加载也便宜。
如果在一个函数中多次使用指针,T* local_copy = global_ptr;编译器可以将其保存local_copy在寄存器中。将此视为从内存加载到私有寄存器中,因为这正是它的编译方式。原子对象上的操作不会优化,因此如果您想每个循环重新读取全局指针一次,请以这种方式编写源代码。或者一旦在循环之外:以这种方式编写源代码并让编译器管理本地变量。
显然,您一直在试图避免,因为您对纯负载操作 atomic<T*>的性能有一个巨大的误解。除非您使用释放或宽松的内存顺序,否则会有点慢,但在 x86 上,std::atomic 对于 seq_cst 加载没有额外的成本。std::atomic::load()std::atomic::store()
避免这里没有任何性能优势atomic<T*>。 它将安全、便携地满足您的需求,并为您的主要阅读用例提供高性能。每个读取它的核心都可以访问其私有 L1d 缓存中的副本。写入会使该行的所有副本无效,因此写入者拥有独占所有权 (MESI),但每个核心的下一次读取将获得一个共享副本,该副本可以在其私有缓存中再次保持热状态。
(这是一致缓存的好处之一:读取器不必不断检查某些单个共享副本。写入器在写入之前必须确保任何地方都没有过时的副本。这都是由硬件完成的,而不是通过硬件来完成的。软件asm指令。我们运行多个C++线程的所有ISA都具有缓存一致的共享内存,这就是为什么volatile可以滚动你自己的原子(但不要这样做),就像人们在C+之前必须做的那样+11。或者就像您尝试在不使用 的情况下执行的操作volatile,它仅适用于调试版本。绝对不要这样做!)
原子加载编译为编译器用于其他所有内容的相同指令,例如mov. 在 asm 级别,每个对齐的加载和存储都是一个原子操作(对于 2 的幂大小,最大为 8 字节)。 atomic<T> 只需阻止编译器假设没有其他线程在访问之间写入对象。
(与纯加载/纯存储不同,整个 RMW 的原子性不会免费发生;ptr_to_int++会编译为lock add qword [ptr], 4。但在无竞争的情况下,这仍然比一直缓存未命中到 DRAM 快得多,只需要一个“缓存锁”在拥有线路专有所有权的核心内部。如果您除了在 Haswell 上背对背执行任何操作(https://agner.org/optimize/)之外什么都不做,则每个操作需要 20 个周期,但只有一个原子 RMW其他代码的中间可以与周围的 ALU 操作很好地重叠。)
与需要 RWlock 的任何内容相比,纯只读访问是使用原子的无锁代码真正闪耀的地方-atomic<>读取器不会相互竞争,因此读取端可以完美地适应这样的用例(或 RCU 或 SeqLock) 。
在 x86 上,seq_cst加载(默认排序)不需要任何屏障指令,这要归功于 x86 的硬件内存排序模型(程序顺序加载/存储,加上具有存储转发的存储缓冲区)。这意味着您可以在使用指针的读取端获得充分的性能,而不必削弱acquire内存consume顺序。
如果存储性能是一个因素,您可以使用std::memory_order_releaseso 存储也可以是普通的mov,而不需要使用mfence或耗尽存储缓冲区xchg。
我听说
atomic<char**>或者其他什么是进入主内存的速度
无论你读到什么,都会误导你。
即使在核心之间获取数据也不需要进入实际的 DRAM,只需共享最后一级缓存即可。由于您使用的是 Intel CPU,L3 缓存是缓存一致性的后盾。
在核心写入高速缓存行后,它仍将处于 MESI 修改状态的私有 L1d 高速缓存中(并且在所有其他高速缓存中无效;这就是 MESI 维护高速缓存一致性的方式 = 任何地方都不会出现过时的行副本)。因此,从该高速缓存行加载到另一个核心上的负载将在私有 L1d 和 L2 高速缓存中丢失,但 L3 标签将告诉硬件哪个核心拥有该行的副本。一条消息通过环形总线到达该核心,使其将线路写回到 L3。从那里它可以被转发到仍在等待加载数据的核心。这几乎就是内核间延迟的衡量标准——一个内核上的存储和另一个内核上的值获取之间的时间。
所花费的时间(核心间延迟)大致类似于 L3 缓存中未命中的负载并且必须等待 DRAM,例如可能需要 40 纳秒或 70 纳秒,具体取决于 CPU。也许这就是你读到的。(多核至强在环形总线上有更多的跳数,并且内核之间以及从内核到 DRAM 的延迟也更长。)
但这仅适用于写入后的第一次加载。 数据由加载它的核心上的 L2 和 L1d 缓存进行缓存,并在 L3 中处于共享状态。此后,任何频繁读取指针的线程往往会使该行在运行该线程的核心上的快速专用 L2 甚至 L1d 缓存中保持热状态。L1d 缓存具有 4-5 个周期延迟,每个时钟周期可处理 2 个负载。
并且该线路将在 L3 中处于共享状态,任何其他核心都可以访问,因此只有第一个核心支付全部核心间延迟损失。
(在 Skylake-AVX512 之前,Intel 芯片使用包容性 L3 缓存,因此 L3 标签可以用作内核之间基于目录的缓存一致性的窥探过滤器。如果某个行在某些私有缓存中处于共享状态,那么它在共享状态下也有效在 L3 中。即使在 L3 缓存不保持包容性的 SKX 上,数据在核心之间共享后也会在 L3 中存在一段时间。)
在调试版本中,每个变量都在 C++ 语句之间存储/重新加载到内存中。事实上,这(通常)不会比正常的优化构建慢 400 倍,这一事实表明,在无竞争的情况下,当内存访问命中缓存时,内存访问并不会太慢。(将数据保存在寄存器中比内存更快,因此调试构建通常非常糟糕。如果您使用 来创建每个变量,那么这有点类似于未经优化的编译,除了诸如 之类的东西)。需要明确的是,我并不是说这会使您的代码以调试模式速度运行。可能已异步更改的共享变量需要在每次源提到它时从内存中重新加载(通过缓存),并执行此操作。atomic<T>memory_order_relaxed++atomic<T>atomic<T>
正如我所说,读取一个atomic<char**> ptr将编译为movx86 上的负载,没有额外的围栏,与读取非原子对象完全相同。
除了它会阻止一些编译时重新排序volatile之外,它还阻止编译器假设该值永远不会改变并将负载提升出循环。它还可以阻止编译器发明额外的读取。参见https://lwn.net/Articles/793253/
我有十几个线程读取指针,并且一个线程可能每小时左右更改该指针一次。
您可能需要 RCU,即使这意味着为每个非常不频繁的写入复制相对较大的数据结构。RCU 使读取器真正成为只读状态,因此读取端缩放是完美的。
C++11/14/17的其他答案:读者/作者锁...没有读者锁?建议涉及多个 RWlock 的事情,以确保读者始终可以获取一个。这仍然涉及某个共享缓存行上的原子 RMW,所有读取器都争相修改该行。如果您有采用 RWlock 的读取器,它们可能会因内核间延迟而停滞,因为它们将包含锁的缓存行置于 MESI 修改状态。
(硬件锁消除用于解决避免读者之间争用的问题,但它已被所有现有硬件上的微代码更新禁用。)
| 归档时间: |
|
| 查看次数: |
1075 次 |
| 最近记录: |