我们为什么需要缓存一致性?

6
在像C这样的语言中,不同线程对同一内存位置进行未同步的读写是未定义的行为。但在CPU中,高速缓存一致性 表示如果一个核心写入了一个内存位置,稍后另一个核心读取它,那么另一个核心必须读取已经写入的值。
为什么处理器需要努力展现内存层次结构的协调抽象,如果上面的下一层只会将其丢弃呢?为什么不让高速缓存变得不一致,并要求软件在想要共享某些内容时发出特殊指令?

3
内存屏障和缓存一致性是两个不同的概念。 - Support Ukraine
1
如果上一层是什么并不一定是C语言,而在C语言中未定义的行为仅意味着C标准没有对程序请求的行为做出要求 - 其他标准可能有要求,并且特定的C程序可能依赖于特定的硬件和编译器行为。 - KamilCuk
5
假设CPU A和CPU B几乎同时设置了缓存行的字节0和字节15。如果没有缓存一致性,就无法解决这个问题。进行两个操作总会存在竞争。 - stark
3
@stark,这是一个很好的观点,语言确实表明您可以在字节以外的任何粒度进行写入,而不会影响相邻的内存位置。 - Dan
1
实际上,标准C或多或少地要求您拥有一致的缓存。对于具有不一致缓存的机器来说,为了符合规范,它必须具有1字节的缓存行。此外,每个获取屏障都必须使核心的整个缓存失效,每个释放屏障都必须刷新整个缓存。这将是代价高昂的。 - Nate Eldredge
显示剩余6条评论
3个回答

11
C++11的std::mutex(以及其他语言中的等效物和早期的pthread_mutex)所需的acquirerelease语义如果没有一致的高速缓存将会非常昂贵。如果无法依靠硬件使您的存储可见并使您的加载不从私有缓存中获取陈旧数据,则每次释放锁时都必须写回每个脏行,并且每次获取锁时都必须驱逐每个干净行。
但是,有了高速缓存一致性,acquire and release仅涉及对其自己的私有缓存的访问进行排序,该缓存是与其他核心的L1d缓存一起属于同一一致性域的。因此,它们是本地操作,而且非常便宜,甚至不需要排空存储器缓冲区。互斥量的成本仅在于它需要执行的原子RMW操作,以及如果拥有互斥量的最后一个核心不是这个核心,则在缓存未命中时。
C11和C++11分别添加了stdatomic和std::atomic,可以定义良好地访问共享的_Atomic int变量,因此不正确的说高级语言不公开此功能。理论上可以在需要显式刷新/使存储器可见以使其对其他核心可见的机器上实现它,但那将非常缓慢。语言模型假定具有一致的高速缓存,不提供范围的显式刷新,而是具有释放操作,该操作使每个旧存储器对在此线程中同步与释放存储器的获取加载的其他线程可见。(请参见When to use volatile with multi threading?进行讨论,尽管该答案主要是驳斥了混淆编译器可以在寄存器中“缓存”非原子非易失性值的人们的误解。)
事实上,C++ atomic的某些保证实际上被描述为向软件公开HW一致性保证,例如“写入读取一致性”等,最后注释如下:

http://eel.is/c++draft/intro.races#19

[注意:前面的四个一致性要求有效地禁止编译器对单个对象的原子操作进行重新排序,即使两个操作都是松散加载。这实际上使得大多数硬件提供的缓存一致性保证可用于C ++原子操作。——结束说明

在C11和C++11之前,SMP内核和一些用户空间的多线程程序手动编写原子操作,使用了与C11和C++11最终以可移植方式公开的相同的硬件支持。


另外,正如评论中指出的那样,对于其他核心对同一行的不同部分进行写操作时,一致的缓存对于防止彼此干扰至关重要。

ISO C11保证了一个char arr[16]可以由一个线程写入arr[0],而另一个线程写入arr[1]。如果它们都在同一缓存行中,并且存在两个冲突的脏副本,则只有一个可以“获胜”并被写回。C++内存模型和char数组的竞争条件

ISO C有效地要求char的大小与你可以写入而不影响周围字节的最小单位一样大。在几乎所有机器上(不包括早期的Alpha和一些DSP),这是一个字节,即使在某些非x86 ISA上,一个字节存储可能需要比对齐的字单词多花费一个周期提交到L1d缓存。

该语言直到C11才正式要求这样做,但这只是标准化了编译器和硬件已经工作的“众所周知”的唯一明智选择。


1
@WeipengL:有点类似但又不完全一样。缓存一致性意味着通过控制本地排序(提交到L1d缓存的存储和从L1d读取的加载)就可以实现线程间可见性。对于释放操作本身,根本不需要任何刷新。只有当另一个核心实际上加载了该核心处于修改状态的行时,才需要将缓存行写回(或直接从核心传输到核心)。 - Peter Cordes
1
@WeipengL:但是如果没有硬件缓存一致性,释放操作就必须手动将整个L1d缓存与共享级别的缓存(如现代x86中的L3或在多插槽系统中不是所有核心都共享相同的L3时的DRAM)进行同步。当然,如果您设计一个需要显式刷新的系统,您可以提供一些硬件辅助功能,例如记录起点和终点之间的一组行,以便软件仅在持有锁时刷新它们所触及的行。但这仍然意味着在发布存储可见之前必须发生一些写回。 - Peter Cordes
1
@WeipengL:这也意味着原子松弛存储将不得不立即或在某个即将到来的同步点刷新自己的缓存行,以便跨线程可见。因此,同一线程对同一对象的重复存储将始终必须重新拥有它的所有权,而不仅仅像真正的硬件一样在 L1d 缓存中命中。另请参见为什么要使用'memory_order_seq_cst'设置停止标志,如果您使用'memory_order_relaxed'检查它?(没有理由,甚至不能减少延迟)。 - Peter Cordes
1
@WeipengL:另请参阅https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/,该文章将一致缓存比作共享服务器,并将各个核心的推送/拉取与本地重排序的某些控制进行了类比。 - Peter Cordes
1
@WeipengL:是的,有一致性缓存,每个核心只需要对全局可见缓存进行自己的访问排序。如果需要,这就足以恢复顺序一致性或获取/释放。至于那些不使用一致性缓存的情况,GPU通常不会使用。我认为共享计数器的例子没有意义。如果你不需要它是精确的,那么只需让线程每100次增加一次全局计数器,或者让它们各自增加,而读取者在计数数组上循环即可。(分行翻译) - Peter Cordes
显示剩余3条评论

1

啊,这确实是一个非常深奥的话题!

核之间的缓存一致性用于尽可能地合成对称多处理(SMP)环境。这可以追溯到20世纪90年代中期,当时多个单核CPU只是简单地连接到同一个单一内存总线上,缓存并不是什么大事。随着每个CPU具有多个核心、多个缓存和多个内存接口,合成类似SMP的环境变得更加复杂,而缓存一致性在其中起着重要作用。

因此,当有人问:“为什么处理器需要费心暴露内存层次结构的一致抽象,如果上一层只是要将其丢弃?”时,实际上是在问“我们是否仍然需要SMP环境?”。

答案是软件。大量的软件,包括所有主要操作系统,都是基于它们正在运行在SMP环境的假设编写的。如果去掉SMP,我们就必须重新编写几乎所有东西。

现在有许多聪明的评论家开始怀疑SMP是否是一条死路,我们应该开始担心如何走出这条死路。我认为这不会很快发生;CPU制造商可能还有更多的技巧来提高性能,只要这些技巧不断被提供,没有人愿意承受软件不兼容的痛苦。安全性是避免SMP的另一个原因——Meltdown和Spectre利用了SMP合成的弱点——但我猜想,在其他缓解措施(无论多么令人不悦)可用的情况下,仅凭安全性就不足以放弃SMP。

“为什么不让高速缓存变得不协调,并要求软件在想共享某些东西时发出特殊指令?” 为什么不呢?我们以前做过这样的事情。Transputers(20世纪80年代,90年代初)实现了通信顺序进程(CSP),如果应用程序需要不同的CPU来处理某些数据,则应用程序必须有意地将数据传输到该CPU。这些传输(在CSP中称为“通道”)更像是网络套接字或IPC管道,而不是共享内存空间。

CSP正经历一种复兴——作为一个多进程编程范式,它具有一些非常有益的特性——比如Go、Rust、Erlang等语言都在实现它。关于这些语言在CSP方面的实现,它们不得不在SMP环境之上合成CSP,而SMP环境本身则被合成在电子架构之上,更像Transputer!

通过与CSP的大量经验,我的观点是,每个多进程软件都应该使用CSP;它更加可靠。在SMP之上正确实现CSP需要“复制”数据(这是必须的),这会带来“性能损失”,但实际上它并没有那么严重;从一个CPU复制数据到另一个CPU所需的缓存一致性连接流量与以SMP方式访问数据所需的流量相当。

Rust非常有趣,因为它的语法可以强烈表达数据所有权,我认为它不必复制数据就可以实现CSP,而是可以在线程(进程)之间传递所有权。因此,即使每个线程都在单核CPU上运行,它也可以获得CSP的好处,并且效率非常高。我还没有深入研究Rust,不知道它是否正在做这件事,但我有希望。

CSP 的一个好处是,由于通道类似于网络套接字或 IPC 管道,因此可以很容易地在实际的网络套接字上实现 CSP。原始套接字本身并不理想 - 它们是异步的,因此更类似于 Actor 模型(就像 ZeroMQ 一样)。Actor 模型还算可以 - 我用过它 - 但它并不像 CSP 那样保证没有运行时问题。因此,人们必须自己实现 CSP 部分或找到一个库。然而,有了这个东西,CSP 就成为了一种软件架构,可以更轻松地跨越任意计算机网络而无需改变软件架构;本地通道和网络通道是“相同”的,只是网络通道稍微慢一些。
要使一个多线程的软件假定 SMP、使用信号量等在多台计算机上扩展,这是非常困难的。事实上,它是不可能的,必须重新编写。

比起Transputers更近期的,Cell 处理器(Playstation 3 的名声)是一种多核设备,正如您所建议的那样。它有一个单独的 CPU 核心和 8 个 SPE 数学核心,每个核心上都有 255k 的芯片速度静态 RAM。要使用 SPE,您必须编写软件将代码和数据传输进出那个 256k(有一个巨大快速的内部环形总线来完成这个任务,并且一个非常快速的外部存储器接口)。结果是,在正确的开发人员的帮助下,可以实现非常好的结果。

Intel 花费了大约另外 10 年的时间才使 x64 实现了大致相同的性能;将聚合乘加指令添加到 SSE 中就终于让它们达到了这个水平,这是他们一直在 Itanium 的曲目中保留的指令,希望提高其吸引力。Cell (SPE 基于 PowerPC 相当于 SSE 的 Altivec)从一开始就有 FMA 指令。


缓存在90年代中期是非常普遍的一件事情,尤其是那些处于当时技术水平较高端的处理器(否则你通常只会购买一个更快的CPU而不是在系统中组装多个)。也许你想到的是早期的386 SMP系统(没有缓存和极少的CPU流水线,其内存模型是顺序一致的!)。甚至可以追溯到1962年的早期历史机器。https://en.wikipedia.org/wiki/Symmetric_multiprocessing#History - 早期的Unix SMP包括1984年Sequent Balance 8000,其中包括“小的写通缓存”和共享内存。 - Peter Cordes
Meltdown完全适用于单核系统。它涉及CPU保护该核上的操作系统免受用户空间的影响(页面表条目中的用户/监管员位)。问题在于错误的假设,即由推测性(和错误推测)执行更改的任何微架构状态只要秘密永远不直接成为架构状态就不相关。Spectre也适用于单核系统,再次是为了保护内核免受用户空间(特别是在系统调用时)或切换到其他用户空间的影响。这涉及训练分支预测器,这些预测器是每个核心的。 - Peter Cordes
@PeterCordes,是的,我指的是那些早期的386 SMP系统 :-) 我还记得人们购买它们,购买昂贵的MatLab许可证,并想知道为什么性能不会神奇地提高一倍! - bazza
请查看http://hw-museum.cz/article/5/cpu-history-tour--1995---1999-/3以获取CPU日期。PPro从发布时(1995年11月)就具有多插槽SMP功能,因此我们谈论的是具有* 2级*写回缓存的P6核心(尽管最初版本中L2仅打包在其中,而不是实际上在芯片上)。我没有找到386 SMP的数据,或者SMP系统何时开始具有缓存,但主流RISC芯片(如MIPS)都具有一些缓存。 - Peter Cordes
@PeterCordes 还有VME系统-多处理器卡,独立存储卡,其他外设,全部在单个VME总线上。令人惊讶的是,VME仍然是当前的!通常在VME系统中,您会将处理器卡设置为在VME总线上不缓存内存。Cell确实指出了无高速缓存通用计算的可能未来,但没有人感兴趣。可惜!尽管如此,那对当时来说是一种非常令人印象深刻的芯片。那种架构,在现代Si工艺中进行更新,仍然能够击败今天市场上的任何其他芯片。 - bazza
显示剩余4条评论

-1
  • 如果开发人员注意到发出锁(+内存屏障)/(内存屏障)解锁,那么缓存一致性就不是必需的。

  • 从成本、功耗、性能、验证等方面来看,缓存一致性的价值很小,甚至具有负面价值。

  • 如今,软件越来越分布式。无论如何,一致性都不能帮助在两台不同机器上运行的两个进程。

  • 即使对于多线程SW,我们最终还是使用IPC。

  • 只有少量数据在多线程SW中共享。

  • 大部分数据不共享,如果共享,则内存屏障应该解决缓存同步问题。

  • 实际上,SW开发人员依赖显式锁定来访问共享数据。这些锁可以通过硬件辅助有效地刷新缓存(有效意味着仅刷新已修改且在其他地方也被缓存的缓存行)。当我们锁定/解锁时,已经完全做到了这一点。由于每个人都执行上述锁定/解锁操作,因此缓存一致性是多余的,是硅空间/功率的浪费,硬件工程师可以放心睡觉。

  • 所有编译器(至少C/C++、Python VM)都会为单线程、非共享数据生成代码。如果我需要共享数据,我只需告诉它是共享的,但不告诉如何和为什么(volatile?)。开发人员需要注意跨硬件核心/SW线程/hw线程进行管理(再次锁定/解锁)。大多数情况下,我们使用非原子数据编写HLL。缓存一致性对开发人员没有任何价值,因此,他/她会退回到通过锁来管理它,这些锁指示缓存系统有效地刷新。所有缓存系统都有缓存行日志,可以在有或没有一致性支持的情况下高效地刷新。(想想缓存但非一致性内存。这种内存仍然具有可用于高效刷新的日志)

  • 缓存一致性在硅上非常复杂,占用空间和功耗。无论如何,SW开发人员都要注意发出内存屏障(通过锁定)。

所以,我认为摆脱一致性并告诉开发人员自己拥有它是好的。

但是我看到趋势是相反的。 看看 CXL 存储器等……它是一致的。

  • 我正在寻找一个系统调用,可以关闭我的线程的缓存一致性,进行实验

1
缓存一致性是使锁和内存屏障在CPU上足够的关键。如果没有一致的缓存,您需要不同类型的操作(例如显式刷新共享数据),以及锁定和/或屏障来实现线程间可见性,正如我在我的答案中所说。 - Peter Cordes
当你说“要刷新哪些行的日志”时,你是指脏位吗?缓存一致性避免了对于其他核心实际上没有读取的行进行写回,让它们保持在正在写入它们的核心的私有L1d缓存中。如果/当它们被驱逐时,这些数据确实需要被写回,但这并不需要在每次锁定/解锁时发生。(因此,细粒度锁定,在每个访问周围获取/释放锁定,比如果数据必须在每次解锁时刷新到共享/全局可见的缓存级别上会更好。) - Peter Cordes
你的修改已经有所改善,它暗示了一个比较合理的机制,即编译器会特殊处理锁定和解锁操作,并刷新所有存储在关键段内的缓存行。或者unlock会刷新整个私有缓存中的所有脏数据。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接