内存屏障是否确保缓存一致性已完成?

31
说我有两个线程在操作全局变量 x。每个线程(或者每个核心)都会拥有 x 的缓存副本。
现在假设 线程A 执行以下指令:
set x to 5
some other instruction

现在当执行set x to 5时,x的缓存值将被设置为5,这将导致缓存一致性协议起作用,并使用x的新值更新其他核心的缓存。

现在我的问题是:当在Thread A的缓存中实际设置x5时,其他核心的缓存是否会在执行some other instruction之前得到更新?还是应该使用内存屏障来确保?

set x to 5
memory barrier
some other instruction

注意:假设指令按顺序执行,同时假设当执行set x to 5时,5立即被放置在线程A的缓存中(因此该指令不会被放入队列或其他地方以便稍后执行)。


1
只是猜测:不行。在我看来,更新其他核心缓存需要多个周期,因此您必须在集合上使用lock来等待它并使其正确分发。如果没有锁定,则线程B可能会看到部分修改,甚至部分覆盖x(甚至完全覆盖它或查看旧值)。如果两个线程都写入变量而没有锁定w / barrier,则内存屏障变体在我看来也无法帮助您,当每个线程将其不同部分写入时,您仍然可能最终得到两个线程的组合值。 - Ped7g
1
你是在问同步方法是否确保在其他处理器中缓存已更新吗? - Tony
@Tony Tannous 是的。例如:当线程A解锁互斥锁时,解锁代码是否包含内存屏障,以确保其他核心的缓存在实际使互斥锁可用于其他线程锁定之前已被更新?因此,在线程B锁定互斥锁时,线程B可以确信线程A对全局变量所做的所有修改都将被线程B看到? - Christopher
非常偏向硬件的事情,可能会因为实现不同而有所不同(一代x86可能与另一代有不同的答案),但应该都有很好的文档记录。在系统中,核心是在哪里汇聚的?L1、L2、系统内存?对于每个不共享的层次,文档中说了什么,以便将这些项目推到一个层次?最重要的是,当您尝试或不尝试其中的每一项时,发生了什么事情,对您有效吗? - old_timer
4
就你所说的来看,缓存一致性通常不是像你所建议的那样工作的。修改值的CPU通常不会在每次修改时向其他CPU的缓存“推送”该值。相反,在修改值之前,其他CPU缓存中的副本将被无效化(如果存在),然后CPU可以自由地多次私下修改该值,直到其他CPU需要该值为止。那时,就是“其他”CPU触发缓存一致性事务以获取修改后的值……至少在大多数类MESI系统中是这样。这是拉取而非推送。 - BeeOnRope
同样地,正如Margaret在她的回答中清楚表明的那样,这个问题实际上有两个不同的部分:CPU内存模型在形式上保证了什么以及底层是如何工作的。你混淆了这两个部分:你会发现内存模型以非常通用的方式编写,你无法回答关于“如何”发生的问题:但它应该回答你需要知道的内容来编写正确的程序。 “如何”实际上是一个EE问题,并且因架构而异。 - BeeOnRope
4个回答

46
在x86架构上存在的内存屏障 - 但这一点通常适用 - 不仅保证在执行任何后续的加载或存储之前,所有先前的加载或存储都已完成 - 它们还保证了存储已经变得“全局可见”。
所谓全局可见是指其他具有缓存感知能力的代理(如其他CPU)可以看到该存储。
对于不了解缓存的其他代理(如DMA设备),如果目标内存被标记为不强制立即写入内存的缓存类型,则通常不会看到该存储。
这与屏障本身无关,这是x86架构的一个简单事实:缓存对程序员是可见的,并且在处理硬件时它们通常是禁用的。
英特尔在屏障的描述上故意保持通用性,因为它不想将自己与特定的实现绑定在一起。
你需要以抽象的方式思考:全局可见意味着硬件将采取所有必要的步骤使存储全局可见。就这样。
然而,要理解屏障,值得看一下当前的实现。
请注意,英特尔可以随意颠倒现代实现,只要保持可见行为正确即可。
一个存储在x86 CPU中的指令在核心中执行,然后放置在“存储缓冲区”中。
例如,mov DWORD [eax+ebx*2+4], ecx,一旦解码完成,会等待eaxebxecx准备就绪2,然后被发送到能够计算其地址的执行单元中。
当执行完成后,存储变成了一个配对的形式(地址, 值),并被移动到“存储缓冲区”中。
这个存储被称为在核心中“局部完成”。
“存储缓冲区”允许CPU的乱序执行部分忘记该存储,并将其视为已完成,即使尚未尝试写入。
在特定事件发生时,比如序列化事件、异常、执行“屏障”或缓冲区耗尽时,CPU会刷新存储缓冲区。
刷新总是按顺序进行 - 先进先写。
从存储缓冲区进入缓存领域。
如果目标地址标记为WC缓存类型,则可以将其合并到另一个称为写组合缓冲区的缓冲区中(然后通过绕过缓存写入内存),如果缓存类型是WB或WT,则可以将其写入L1D缓存、L2、L3或LLC,如果不是前面提到的缓存类型,则也可以直接写入内存,如果缓存类型是UC或WT。

今天这就是成为全球可见的意义:离开存储缓冲区。
请注意两个非常重要的事情:

  1. 缓存类型仍然会影响可见性。
    全球可见并不意味着在内存中可见,而是表示其他核心加载时可见。
    如果内存区域是WB(写回)可缓存的,则加载可能会进入缓存,因此在那里是全球可见的,但只有知道缓存存在的代理能够看到它。(但请注意,现代x86上的大多数直接内存访问(DMA)都是具有缓存一致性的)。
  2. 这也适用于非一致的WC(写组合)缓冲区。
    WC不保持一致性 - 它的目的是合并对内存区域的存储,其中顺序无关紧要,比如帧缓冲区。 这实际上还不是真正的全球可见,只有在刷新写组合缓冲区后,核外的任何东西才能看到它。

sfence正是这样做的:等待所有先前的存储在本地完成,然后清空存储缓冲区。
由于存储缓冲区中的每个存储都有可能丢失,你可以看到这种指令有多重。 (但乱序执行,包括后续加载,可以继续进行。只有mfence会阻止后续加载在存储缓冲区提交到缓存之后全局可见(从L1d缓存读取))。

但是,sfence是否等待存储传播到其他缓存?
实际上不等待。
因为没有传播-让我们从高级角度来看看对缓存的写入意味着什么。

缓存通过MESI协议(对于多插槽Intel系统为MESIF,对于AMD系统为MOESI)在所有处理器之间保持一致。
我们将只看到MESI。

假设写入索引缓存行L,并且假设所有处理器在其缓存中具有相同值的该行L。
该行的状态是共享,在每个CPU中均如此。

当我们的存储器进入缓存时,L被标记为Modified,并在内部总线上进行特殊事务(或对于多插槽Intel系统,使用QPI)以使其他处理器中的线路L无效。

如果L最初不处于S状态,则协议相应地改变(例如,如果L处于Exclusive状态,则不执行总线上的事务[1])。

此时,写入操作完成,并完成sfence

这足以保持缓存一致。
当另一个CPU请求线路L时,我们的CPU监听该请求,并将L刷新到内存或内部总线,以便其他CPU可以读取更新后的版本。
L的状态再次设置为S

因此,基本上是按需读取L - 这是有道理的,因为向其他CPU传播写入操作是昂贵的,而某些体系结构通过将L写回内存来实现(这有效,因为其他CPU的L处于Invalid状态,因此必须从内存中读取它)。


终于,不是说sfence等通常没有用,相反它们非常有用。 只是通常我们不关心其他CPU如何看待我们进行存储操作 - 但是在没有像C++中定义的获取语义(并通过栅栏实现)的情况下获得锁定是完全不可理喻的。
你应该按照Intel所说的来考虑这些屏障:它们强制执行内存访问的全局可见性顺序。 你可以通过将屏障视为强制写入缓存的顺序来帮助自己理解。缓存一致性将确保对缓存的写入是全局可见的。
我不禁再次强调缓存一致性、全局可见性和内存排序是三个不同的概念。 第一个保证第二个,而第三个则强制执行这种保证。
Memory ordering -- enforces --> Global visibility -- needs -> Cache coherency
'.______________________________'_____________.'                            '
                 Architectural  '                                           '
                                 '._______________________________________.'
                                             micro-architectural

脚注:
1. 按程序顺序。 2. 这只是一个简化。在英特尔CPU上,mov [eax+ebx*2+4], ecx 解码为两个单独的微操作:存储地址和存储数据。存储地址微操作必须等待 eaxebx 就绪,然后被分派到能够计算其地址的执行单元。该执行单元将地址写入存储缓冲区,以便稍后的加载(按程序顺序)可以进行存储转发检查。
ecx 就绪时,存储数据微操作可以分派到存储数据端口,并将数据写入相同的存储缓冲区条目。
这可能发生在地址已知之前或之后,因为存储缓冲区条目按照程序顺序保留,所以存储缓冲区(也称为内存顺序缓冲区)可以跟踪加载/存储排序,一旦所有内容的地址最终确定,并检查是否有重叠。(对于那些最终违反了x86内存排序规则的推测性加载,如果另一个核心在它们被架构允许加载的最早时间点之前使其缓存行无效,就会导致内存顺序错误的流水线清除。)

1
@IsuruH 存储缓冲区位于高速缓存之前。当 CPU 清空存储缓冲区时,它会写入高速缓存(如果适用),每次写操作都需要管理 MESI(等等)状态。 - Margaret Bloom
1
在讨论内存模型的细微差别时,最好避免使用“全局可见性”等术语:这很难定义,因为它本质上是相对的:没有“全局时钟”可以用来决定什么时候变得可见。这就是为什么内存模型大多以相对术语定义:如果您已经看到了A,那么您观察到B的情况,以及其他某个参与者将观察到这两件事情的情况等。您可以谈论“顺序一致性”,但这又回避了可见性。在英特尔排序白皮书中甚至找不到“可见性”一词的提及。 - BeeOnRope
2
“the store is complete”这样的术语使用是造成混淆的一部分。它存在解释上的歧义。我可以辩称,当存储器不再是ROB中的猜测时,或者当它到达DRAM时,或者当它到达内存映射文件的磁盘时等,存储器才算完成。SFENCE并不能清空存储缓冲区! SFENCE仅适用于某些“奇怪”的绕过存储缓冲区的存储器类型。存储缓冲区本身是固有排序的:这是它首先存在的一个重要原因(也为了消除猜测性存储)。 - BeeOnRope
2
@BeeOnRope 等等...你是说Intel在第11.10节中使用“store buffer”的术语实际上应该读作“WC buffer”吗?感谢提供这些链接,我不知道NT移动到/从WC内存类型是弱有序的(除了缓存旁路)!无论如何,我没有找到证据表明sfence实际上不会排空SB。尽管它对于重新排序普通存储是无用的,但这并不能说明sfence没有辅助功能(即排序+可见性)。我的Fog inst表格版本没有列出栅栏的延迟时间。老实说,我很困惑...我不知道该怎么想。 - Margaret Bloom
2
好的,现在我可以正确地读取11.10了。之前我有些看错了,读成了11.3.1(它确实涉及到WC缓冲区)。你知道吗?我会把SFENCE的性能和LFENCE的性能混淆在一起,所以在Ryzen上没有所谓的“1个周期的SFENCE”-它需要20个时钟周期。更多数字请见此处。所以,我想你是对的:SFENCE需要清除_store buffer_和_WC buffers_,才能发挥作用,否则它怎么能保证[normal store, sfence, weak store]被适当排序呢? - BeeOnRope
显示剩余18条评论

4
现在执行设置x为5的操作时,x的缓存值将被设置为5,这将导致高速缓存协议执行并使用x的新值更新其他内核的缓存。
有多种不同的x86 CPU,具有不同的高速缓存一致性协议(none、MESI、MOESI),以及不同类型的缓存(未缓存、写组合、只写、写穿透、写回)。
通常,在执行写入操作(当将x设置为5时),CPU确定正在进行的缓存类型(来自MTRRs或TLBs),如果可以缓存缓存行,则检查其自己的缓存以确定该缓存行的状态(从其自己的角度来看)。
然后使用缓存的类型和缓存行的状态来确定数据是直接写入物理地址空间(绕过缓存),还是必须从别处获取缓存行,同时告诉其他CPU使旧副本无效,或者它在自己的缓存中拥有独占访问权,并且可以在缓存中修改它而无需告知任何内容。
CPU从不向另一个CPU的缓存“注入”数据(仅告诉其他CPU使缓存行的旧副本失效/丢弃)。告诉其他CPU使缓存行的旧副本失效/丢弃会导致它们在需要时再次获取其当前副本。
请注意,这与内存屏障没有任何关系。
有3种内存屏障(sfence、lfence和mfence),它们告诉CPU在允许后续存储、加载或两者发生之前完成存储、加载或两者。由于CPU通常已经具有高速缓存一致性,因此这些内存屏障/栅栏通常是无意义的/不必要的。但是,存在CPU不具有高速缓存一致性的情况(包括“存储转发”,使用写组合缓存类型时,使用非暂态存储器时等)。内存屏障/栅栏需要为这些特殊/罕见情况强制执行排序(如果必要)。

因为CPU通常是高速缓存一致的,所以这些内存屏障/栅栏通常是无意义/不必要的。但是你说过内存屏障用于告诉CPU在允许后续存储、加载或两者发生之前完成存储、加载或两者。我读过一个CPU可以将存储操作放入队列并稍后执行它们,因此如果我们希望在继续执行其余指令之前执行它们,我们应该使用内存屏障。我有什么遗漏吗? - Christopher
4
你的回答抓住了重点(MESI / MOESI不会将数据推送到其他缓存,因此OP问题不正确 - 无需等待任何内容完成),但最后一段是错误的。你混淆了内存排序和缓存一致性。对于x86系统中的数据,一旦在缓存中,就是全局可见的。但由于重新排序和存储缓冲区,存储器成为全局可见的时间不是按程序顺序或存储完成的时间->因此需要屏障。 - Margaret Bloom
@Christopher:对于使用正常写回缓存的普通RAM,CPU的内存排序确保一切都以合理的方式排序,没有任何屏障/栅栏。将“放置存储操作在队列中并稍后执行它们”视为相对异常的特殊情况(涉及“写组合缓存而不是写回”和/或非暂态存储),其中有意绕过了CPU的正常内存排序(并导致需要屏障/栅栏,因为有意绕过了正常的内存排序)。 - Brendan
缓存确实会缓存物理地址空间。我认为您试图使用一个广泛的术语来覆盖DRAM和I/O空间,但是一旦存储提交到L1d缓存并因此变得全局可见,它就被写入了“物理地址空间”。我不知道在现代x86上是否仍然可能进行非高速缓存一致的DMA;而有了集成内存控制器,设备DMA通常会在前往DRAM的途中检查缓存。 - Peter Cordes

3

,内存屏障不会确保缓存一致性已经“完成”。它通常根本没有涉及到任何一致性操作,可以作为投机执行或无操作执行。

它只强制执行屏障中描述的排序语义。例如,实现可能只是将标记放在存储队列中,使得早于标记的存储不能进行存储到加载的转发。

特别是英特尔已经为正常的加载和存储(编译器生成的以及您在汇编中使用的)具有强大的内存模型,其中唯一可能的重新排序是后续的加载超越了先前的存储。 在 SPARC 内存屏障的术语中,除了StoreLoad之外的每个屏障已经是no-op

实际上,在x86上,有趣的屏障与“LOCKed”指令相关联,执行这样的指令并不一定涉及任何缓存一致性。如果该行已处于独占状态,则CPU可能会简单地执行该指令,确保在操作进行中(即在参数读取和结果写回之间),不释放该行的独占状态,然后仅处理防止存储到加载转发破坏“LOCK”指令所带来的总排序问题。目前,他们通过排空存储器队列来实现这一点,但在未来的处理器中,甚至可能是推测性的。
内存屏障或屏障+操作的作用是确保其他代理以遵守屏障的所有限制的相对顺序看到该操作。这通常不涉及将结果作为一项一致性操作推送到其他CPU,正如您提问的那样。

-1
如果没有其他处理器在其缓存中具有X,则在处理器A上执行x=5不会更新任何其他处理器的缓存。 如果处理器B读取变量X,则处理器A将检测到读取(这称为窥探),并将数据5提供给处理器B的总线。 现在,处理器B将在其缓存中具有值5。 如果没有其他处理器读取变量X,则它们的缓存永远不会使用新值5进行更新。

当一个核心使得其他所有核心的该行缓存失效后,如果没有其他核心读取 x,那么它们将不会缓存新值(或任何值)。 - Peter Cordes
谢谢Peter,你说得对。我假设没有其他处理器缓存了X。我会进行编辑和澄清。 - David P
这可能是缓存理论中的一种可能方式(并且是https://en.wikipedia.org/wiki/MESI_protocol所描述的方式),但这是一个关于内存屏障指令的x86问题。您的回答没有提到存储缓冲区或屏障。那种窥视模型与CPU实际工作方式不符。让每个核心窥视其他每个核心执行的每个非核心加载将根本无法扩展。它们不都连接到单个共享总线以访问所有其他请求的内存(或L3)。例如,英特尔CPU在核心之间使用环形总线,并使用L3标记作为窥视过滤器。 - Peter Cordes
如果一个非邻居核心进行X的加载,它将如何获取数据? - David P
1
在像我之前提到的英特尔CPU上,它会先在L1中缺失,然后是L2,然后通过环形总线发送消息以请求从L3获取该行。如果另一个核心已经写回,则可能会命中,但如果没有,则在L3中缺失,并且L3标记指示哪个核心拥有修改后的该行副本,因此L3高速缓存控制器可以通过环形总线向该核心发送共享请求,以将该缓存行写回到L3,并满足其他核心的负载。这就是我所说的L3标记充当嗅探过滤器的含义:而不是所有核心都自己嗅探,有一个共享缓存知道谁拥有什么。 - Peter Cordes
显示剩余3条评论

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