内存一致性模型
2003年,Java内存模型(JSR-133)的制定者花了整整5年讨论一个看似简单的问题:当两个线程同时读写一个变量时,程序员应该看到什么结果?这个问题的答案取决于硬件的内存一致性模型——而不同处理器的答案竟然完全不同。x86说"Store按程序顺序可见"(TSO),ARM说"几乎不保证任何顺序"(弱序)。这不是bug,是设计权衡:弱序模型给硬件更大的优化自由度,允许Store Buffer更激进地延迟写入、Load更自由地乱序完成,从而获得更高的单线程性能。
从本书的统一视角看——处理器设计的本质是在有限的晶体管预算和功耗约束下,通过投机和并行的层层叠加来逼近指令吞吐率的理论上限——弱内存模型是对内存操作顺序的"投机"。处理器投机地假设大多数内存操作不需要严格排序,从而允许更激进的乱序执行和Store Buffer优化。只有在程序员显式插入fence指令时(即"投机失败"的标记),处理器才强制恢复排序约束。这种"默认宽松、显式收紧"的设计哲学与分支预测的"默认投机、失败恢复"如出一辙——都是以少量异常情况的代价换取大多数情况下的高性能。
在第 36.0 章中,我们讨论了存储器指令的加速技术——Store Buffer、Store-to-Load转发、内存消歧和Load违规检测。这些技术的核心目标是在保证正确性的前提下,尽可能地让Load和Store指令乱序执行。然而,一个至关重要的问题被隐含地回避了:当多个处理器核心同时访问共享内存时,每个核心观察到的内存操作顺序应该是什么?
这个问题的答案由内存一致性模型(Memory Consistency Model)来定义。内存一致性模型是多核处理器硬件与软件之间的一份"契约":它精确地规定了一个处理器核心的Store操作在何时、以何种顺序对其他核心变得可见。这份契约的宽松程度直接影响处理器的微架构设计自由度——模型越严格,硬件约束越多,性能代价越大;模型越宽松,硬件的优化空间越大,但软件的编程复杂度也随之增加。
从硬件设计者的角度看,内存一致性模型决定了以下关键问题:
Store Buffer可以在多大程度上延迟将数据写入Cache?
Load指令是否可以绕过尚未完成的Store指令?
不同地址的Load指令之间是否可以乱序完成?
需要什么样的fence指令来恢复排序约束?
原子操作的硬件实现需要提供何种保证?
本章系统地讨论内存一致性模型的概念体系、主流模型的硬件实现以及原子操作的微架构设计。我们将看到,从最严格的顺序一致性(SC)到最宽松的弱内存模型,每一级宽松化都对应着具体的硬件优化机会——而处理器设计师的任务就是在这些优化机会与编程模型的复杂度之间找到最佳平衡点。
表表 37.1概览了主流内存一致性模型的核心特征。
| 模型 | 架构 | Store-Load | Store-Store | Load-Load | Load-Store |
|---|---|---|---|---|---|
| SC | 理论模型 | 禁止 | 禁止 | 禁止 | 禁止 |
| TSO | x86 / SPARC | 允许 | 禁止 | 禁止 | 禁止 |
| RMO | ARM | 允许 | 允许 | 允许 | 允许 |
| RVWMO | RISC-V | 允许 | 允许 | 允许 | 允许 |
主流内存一致性模型的特征对比
一致性模型的概念
要理解内存一致性模型,首先需要理解它要解决的根本问题。在单核处理器中,程序的内存操作按照程序顺序(program order)执行——或者至少看起来是按程序顺序执行的。即使微架构内部进行了乱序执行,单核处理器的流水线冲刷和顺序退休机制保证了对外可见的效果与程序顺序一致。但在多核环境中,情况发生了根本性变化:每个核心都有自己的Store Buffer、Cache和流水线,不同核心的内存操作之间没有统一的程序顺序可以参照。
需要注意的是,内存一致性(consistency)和Cache一致性(coherence)是两个不同但相关的概念。Cache一致性(也称Cache一致性协议,如MESI)解决的是同一个地址在多个Cache副本之间的一致性问题——确保一个核心对地址的写入最终会被其他核心看到。内存一致性模型解决的是不同地址之间的排序问题——规定核心对地址的写入和核心对地址的读取之间的可见顺序。Cache一致性是内存一致性的基础——没有Cache一致性协议,内存一致性模型就无从谈起。
在讨论内存一致性模型时,我们使用以下术语约定:
程序顺序(Program Order):同一核心内,指令在程序源代码中的出现顺序。
全局内存顺序(Global Memory Order):所有核心的内存操作在"全局内存"上生效的顺序。
对其他核心可见:一个Store操作"对其他核心可见"意味着该Store的值已经写入了一个共享的Cache层级或内存,其他核心的后续Load可以读取到该值。
完成:一个Load操作"完成"意味着它已经获取了返回值,且该值不会再被更改。
顺序一致性(SC)
顺序一致性(Sequential Consistency,SC)是Lamport在1979年提出的最直观的内存一致性模型。其定义简洁而有力:
一个多核系统是顺序一致的,当且仅当所有核心的所有内存操作的执行结果与某个全局的交替执行顺序(global interleaving)一致,并且在这个全局顺序中,每个核心的操作保持其程序顺序。
换言之,SC要求存在一个全局的内存操作序列——所有核心的Load和Store操作像是在一条单一的内存总线上依次执行。这个全局序列必须满足两个约束:(1)每个核心内部的操作顺序与其程序顺序一致;(2)每个Load返回的值是全局序列中最近一次对该地址执行Store写入的值。
SC模型对于程序员来说是最友好的——它的语义与"多个线程共享一块白板,每次只有一个线程在白板上读或写"的直觉完全吻合。然而,SC对硬件施加了极其严格的约束。
硬件描述 1 — SC模型的硬件约束
在SC模型下,处理器微架构必须满足以下约束:
Load-Load序:核心的两个Load操作和,若在程序顺序中先于,则必须先于完成。
Load-Store序:核心的Load 和Store ,若先于,则必须在对其他核心可见之前完成。
Store-Store序:核心的两个Store 和,若先于,则必须先于对所有其他核心可见。
Store-Load序:核心的Store 和后续Load ,若先于,则必须对所有核心可见后,才能执行。
其中第4条约束(Store-Load序)是代价最高的——它意味着Store必须完全写入Cache(而非停留在Store Buffer中)后,后续的Load才能开始执行。
SC模型的性能代价可以通过一个经典的例子来说明。考虑Dekker算法的核心片段:
| 核心0 | 核心1 |
|---|---|
Store flag0 = 1 | Store flag1 = 1 |
Load r1 = flag1 | Load r2 = flag0 |
Dekker算法中的内存操作序列
在SC模型下,不可能出现且的结果——因为两个Store中至少有一个在全局序列中排在两个Load之前。但如果允许Store-Load重排序(即Load可以绕过Store Buffer中尚未写回的Store),两个Load都可能在对方的Store写入Cache之前完成,导致。
性能分析 1 — SC模型的性能代价
SC模型的Store-Load排序约束对流水线性能的影响是显著的。假设一个4-wide超标量处理器,每个周期平均执行1.2条Load和0.6条Store指令:
在SC模型下,每条Load必须等待前面所有Store完成写入Cache。假设Store写入Cache的延迟为310个周期(取决于Cache命中/缺失),这意味着每条Load平均增加25个周期的等待延迟。
Store Buffer在SC模型下不能被用于缓冲Store——Store必须立即写入Cache才能让后续Load继续执行。这消除了Store Buffer的最大价值之一。
实验表明,严格实现SC模型的处理器相比TSO模型的处理器,IPC下降10%30%(取决于工作负载中Load和Store的密度及其相互关系)。
正因为SC模型的硬件代价过高,现代商用处理器几乎没有严格实现SC的。取而代之的是各种宽松内存模型(Relaxed Memory Model),它们通过有选择地放宽SC的某些排序约束来换取更高的性能。
为了更直观地理解SC模型的行为,我们通过一个具体的两核执行例子来展示SC模型如何约束内存操作的全局顺序。考虑以下程序片段,初始条件:
| 核心0 | 核心1 |
|---|---|
(a) Store x = 1 | (c) Store y = 1 |
(b) Load r1 = y | (d) Load r2 = x |
SC模型下的两核交替执行示例
在SC模型下,所有可能的全局交替执行顺序及其结果如表 表 37.4所示。
| 编号 | 全局顺序 | r1 | r2 |
|---|---|---|---|
| 1 | a b c d | 0 | 1 |
| 2 | a c b d | 1 | 1 |
| 3 | a c d b | 1 | 1 |
| 4 | c d a b | 1 | 0 |
| 5 | c a b d | 1 | 1 |
| 6 | c a d b | 1 | 1 |
SC模型下所有合法的全局内存顺序
注意在所有6种合法的全局顺序中,且的结果从未出现。原因是:如果(即在之前执行),则在全局顺序中排在之前,进而排在之前(因为程序顺序要求在前),又排在前,所以在前,因此读到,即。对称地,蕴含。这种推理正是SC模型的强大之处——程序员可以用简单的"全局顺序"推理来验证并发程序的正确性。
SC模型的硬件实现方案
尽管SC模型在商用处理器中很少被严格实现,理解其硬件实现方案有助于理解宽松模型的设计动机。严格实现SC有两种基本策略:
策略一:顺序执行所有内存操作。最朴素的SC实现是将每个核心的内存操作串行化——每条Load或Store必须在前一条内存操作完成后才能开始执行。这种方式完全消除了Store Buffer的作用,Load和Store的延迟直接暴露给流水线。在一个4-wide乱序处理器中,这会将有效的内存指令吞吐量降至每周期不到1条,严重制约IPC。
策略二:推测性SC(Speculative SC)。学术界提出了多种在保持SC语义的同时降低硬件代价的技术。推测性SC允许处理器像TSO那样让Load绕过Store Buffer推测性执行,但同时监控这些推测Load是否违反了SC约束。具体来说:
Load指令推测性地执行(绕过Store Buffer读取Cache),结果暂存在Load Queue中。
当Store从Store Buffer提交到Cache时,检查是否有推测性执行的后续Load读取了已被修改的数据。
如果检测到违规(某个推测Load读到的值与SC语义下应该读到的值不一致),触发流水线冲刷并从该Load开始重新执行。
推测性SC的核心观察是:绝大多数情况下(99%以上),SC约束不会被实际违反,因此推测性地放宽约束并在极少数违规时付出回滚代价,整体性能接近TSO而语义保持SC。
性能分析 2 — 推测性SC的性能分析
Ceze等人在2007年的研究表明,推测性SC实现的IPC与TSO实现相比:
在SPLASH-2基准测试中,IPC差距不到3%——因为SC违规事件非常罕见。
SC违规的检测率约为每百万条指令0.12次,回滚代价极低。
主要的硬件开销是Load Queue中需要增加额外的地址比较逻辑——在每次Store提交时搜索所有推测性Load,检查是否有地址冲突。
然而,推测性SC的一个实际问题是安全性:推测性执行可能产生微架构侧信道(类似Spectre),攻击者可以通过侧信道观察到推测执行的内存访问模式。这使得推测性SC在安全敏感的应用场景中需要额外的防护措施。
TSO(Total Store Order)
全存储序(Total Store Order,TSO)是最接近SC的宽松模型,也是x86和SPARC架构采用的内存模型。TSO的核心思想可以用一句话概括:允许Store-Load重排序,禁止其他所有重排序。
更精确地说,TSO模型的语义等价于:每个处理器核心拥有一个FIFO的Store Buffer,Store指令先写入Store Buffer,随后按FIFO顺序排入一个全局内存序列。Load指令首先检查本核心的Store Buffer(以获取最新的Store值),如果Store Buffer中没有匹配的地址,则直接从Cache/内存读取。
TSO相对于SC放宽的唯一排序约束是:一个核心的Store操作可能在其后续Load操作之后才对其他核心可见——因为Store可以在Store Buffer中排队等待,而后续Load不需要等待Store Buffer排空就可以执行。TSO保留了SC的其他三条约束:
Load-Load序:同一核心的Load按程序顺序完成。一个核心不会看到Load被重排序。
Store-Store序:同一核心的Store按程序顺序对其他核心可见(因为Store Buffer是FIFO的)。
Load-Store序:Load必须在后续Store被写入Store Buffer之前完成。
图图 37.1对比了SC和TSO模型在硬件上的关键区别。
TSO模型的一个关键特性是Store Buffer旁路(Store Buffer Bypassing):当Load查找本核心Store Buffer并发现一个匹配的地址时,Load可以直接读取Store Buffer中的最新值,而无需等待该Store被写入Cache。这就是第 36.0 章中讨论的Store-to-Load转发机制。在TSO模型下,这种转发不仅是一种性能优化,更是模型语义的一部分——它保证了单核内的读-写一致性。
为了更好地理解TSO与SC的区别,考虑另一个经典的石蕊测试(litmus test)——Store Buffer测试:
| 核心0 | 核心1 |
|---|---|
Store x = 1 | Store y = 1 |
Load r1 = y | Load r2 = x |
Store Buffer石蕊测试(SB litmus test)
在SC模型下:结果且是不可能的。因为在全局顺序中,至少有一个Store在两个Load之前执行。
在TSO模型下:结果且是可能的。核心0的Store写入Store Buffer后,Load立即从Cache读取(此时核心1的Store尚未对核心0可见),得到。核心1的情况对称,得到。
这个例子精确地展示了TSO放宽的唯一排序——Store-Load排序。在x86处理器上运行这个测试,如果不使用MFENCE,确实可以观察到的结果(虽然概率通常很低,因为Store通常很快就从Store Buffer提交到Cache)。
为了更深入地理解TSO的行为边界,我们再考察两个石蕊测试。
消息传递测试(Message Passing)在TSO下的行为
| 核心0(生产者) | 核心1(消费者) |
|---|---|
Store data = 42 | Load r1 = flag |
Store flag = 1 | if (r1 == 1) |
Load r2 = data |
消息传递测试在TSO下的行为
在TSO模型下,如果,则一定等于42。原因是:TSO保证了Store-Store顺序(核心0的Store Buffer是FIFO的),因此一定在之前对核心1可见。同时,TSO保证了Load-Load顺序,因此核心1读到之后再读,一定能看到42。这意味着在TSO模型下,消息传递模式不需要任何fence指令即可正确工作——这是TSO编程模型的一个重要优势。
IRIW(Independent Reads of Independent Writes)测试
考虑一个更复杂的四核测试——IRIW测试(Independent Reads of Independent Writes),它涉及两个写核心和两个观察核心。初始:
| 核心0 | 核心1 | 核心2 | 核心3 |
|---|---|---|---|
Store x = 1 | Store y = 1 | Load r1 = x | Load r3 = y |
Load r2 = y | Load r4 = x |
IRIW石蕊测试
问题是:是否可能出现?即核心2看到先于更新,而核心3看到先于更新——两个观察者对两个独立写入的顺序产生分歧。
在TSO模型下,这个结果是不可能的。因为TSO保证所有核心的Store进入一个全局的Store顺序——所有核心看到的Store顺序是一致的。如果核心2看到但,说明在全局顺序中在之前,那么核心3也必须看到同样的顺序。但在某些弱内存模型(如ARM早期的非多副本原子模型)下,这个结果是可能的——因为Store在不同核心上变得可见的时间可能不同。
设计提示
TSO模型之所以在工业界广泛采用,是因为它在性能和编程模型简洁性之间取得了良好的平衡。大多数常见的多线程程序模式(如锁、条件变量、消息传递)在TSO模型下可以正确工作而无需额外的fence指令。只有少数精心设计的无锁算法(如Dekker算法、Peterson算法)才需要显式的fence指令。从硬件角度看,TSO的实现代价比SC大幅降低——只需要一个FIFO Store Buffer和Store-to-Load转发逻辑,而这些结构在现代处理器中本来就存在。
TSO模型的另一个实际优势是可移植性。绝大多数为x86编写的多线程程序在移植到其他TSO架构(如SPARC)时无需任何修改。而将x86程序移植到ARM或RISC-V时,则需要在关键同步点添加fence指令或使用acquire/release语义。
弱一致性模型
弱一致性(Weak Consistency / Weak Ordering)模型比TSO更进一步,放宽了所有四种排序约束。在弱一致性模型下,处理器可以自由地重排序任意类型的内存操作——Load-Load、Load-Store、Store-Load、Store-Store——除非程序员通过同步操作(synchronization operations)显式地恢复顺序。
弱一致性模型的核心思想是:大多数内存操作之间不存在跨核心的依赖关系,对它们施加排序约束纯属浪费。只有在同步点(synchronization point)——如锁的获取和释放、屏障指令——才需要保证内存操作的顺序。
弱一致性模型允许的额外重排序可以通过一个经典的例子来说明——消息传递(Message Passing,MP)模式:
| 核心0(生产者) | 核心1(消费者) |
|---|---|
Store data = 42 | Load r1 = flag |
Store flag = 1 | if (r1 == 1) |
Load r2 = data |
消息传递石蕊测试
在SC和TSO模型下,如果,则必定等于42——因为核心0的两个Store保持程序顺序(Store-Store序),核心1读到意味着的Store一定已经完成。但在弱内存模型下,核心0的两个Store可能被重排序——可能先于对核心1可见,导致但(读到的旧值)。要在弱内存模型下保证消息传递的正确性,核心0必须在两个Store之间插入一条fence w,w(Store屏障),核心1必须在两个Load之间插入一条fence r,r(Load屏障)。
为了更具体地展示弱内存模型与TSO的区别,我们用一个两核例子来说明Store-Store重排序的可观察效果。考虑消息传递模式在弱内存模型下的执行,初始:
| 核心0(生产者) | 核心1(消费者) |
|---|---|
Store data = 42 | Load r1 = flag // r1 = 1 |
Store flag = 1 | Load r2 = data // r2 = 0 ?? |
弱内存模型下消息传递可能出错
在弱内存模型(如ARM、RISC-V)下,可能出现但的结果。具体的硬件路径如下:
核心0执行,该Store进入Store Buffer的位置。
核心0执行,该Store进入Store Buffer的位置。
在非FIFO Store Buffer中,位置的先获取到Cache行写权限(恰好所在Cache行在本核心是M状态),提前提交到Cache。
核心1执行,读到。
核心1执行,但此时核心0的仍在Store Buffer中尚未提交,核心1读到(旧值)。
最终结果:——消息传递失败。
修正方法是在核心0的两个Store之间插入一条fence w,w(Store屏障),在核心1的两个Load之间插入一条fence r,r(Load屏障),保证操作的顺序可见性。
再考察Load-Load重排序的效果。在TSO模型下,同一核心的两个Load必须按程序顺序完成。但在弱内存模型下,情况不同:
| 核心0 | 核心1 |
|---|---|
Store x = 1 | Load r1 = y // r1 = 1 |
Store y = 1 | Load r2 = x // r2 = 0 ?? |
弱内存模型下Load-Load重排序示例
在弱内存模型下,即使核心0的两个Store按序完成(假设有fence w,w),核心1仍可能出现——因为核心1的可能在之前执行。硬件路径是:先发射并Cache命中(读到旧值0),而后发射但恰好在核心0的提交之后完成(读到1)。这就是为什么消息传递模式在弱内存模型下需要同时在生产者端插入Store屏障和在消费者端插入Load屏障。
ARM架构和RISC-V架构采用的内存模型都属于弱内存模型的范畴。它们允许微架构进行更激进的优化:
Load可以乱序完成:不同地址的Load可以以任意顺序完成并返回值,而不需要遵循程序顺序。这使得处理器可以优先完成Cache命中的Load,而不必等待Cache缺失的Load。
Store可以乱序提交:Store Buffer中的Store条目可以不按FIFO顺序写入Cache,允许Store合并(store coalescing)和Store重排序优化。
Load可以推测执行:Load可以在前面的分支指令或其他Load完成之前推测性地执行。
写缓冲区可以更深:由于不需要保证Store-Store顺序(除非显式fence),Store Buffer可以使用更灵活的数据结构而非严格的FIFO队列。
设计权衡 1 — 强一致性vs弱一致性
强一致性(SC/TSO)的优势:
编程模型直觉简单,bug更少
既有的多线程代码无需修改即可正确运行
硬件验证的复杂度较低
弱一致性(ARM/RISC-V)的优势:
硬件有更多的重排序自由度,IPC更高(尤其在访存密集型工作负载中)
Store Buffer和Load Queue的设计更灵活,功耗更低
更适合高度乱序的宽发射核心和能效优先的移动处理器
经验数据:从TSO放宽到弱内存模型,在访存密集型工作负载中可获得5%15%的IPC提升,主要来源于Load-Load重排序和非FIFO Store Buffer的灵活性。对于计算密集型工作负载,提升较小(1%5%)。
为了全面对比三种模型在同一程序上的行为差异,表 表 37.11展示了几个石蕊测试在SC、TSO和弱内存模型下的可观察结果。
| 石蕊测试 | SC | TSO | 弱模型 |
|---|---|---|---|
| SB: | 禁止 | 允许 | 允许 |
| MP: | 禁止 | 禁止 | 允许 |
| LB: | 禁止 | 禁止 | 允许 |
| IRIW: 观察者不一致 | 禁止 | 禁止 | 允许* |
三种一致性模型下石蕊测试结果的对比
*仅在非多副本原子模型下允许;ARMv8.4+的多副本原子模型禁止IRIW异常。
SB = Store Buffer测试; MP = Message Passing测试; LB = Load Buffering测试; IRIW = Independent Reads of Independent Writes测试。
表中的关键观察是:每一级宽松化都增加了一组新的允许行为。TSO相比SC增加了SB测试的异常行为(因为允许Store-Load重排);弱模型相比TSO增加了MP和LB测试的异常行为(因为允许Store-Store和Load-Load重排)。软件必须通过fence指令来"禁止"这些额外的行为,使程序在宽松模型上也能正确运行。
Load Buffering(LB)测试
Load Buffering测试是另一个经典的石蕊测试,它展示了Load-Store重排序的效果。初始:
| 核心0 | 核心1 |
|---|---|
Load r1 = x | Load r2 = y |
Store y = 1 | Store x = 1 |
Load Buffering石蕊测试
在SC和TSO模型下,且是不可能的——因为这要求核心0的Load(读x)在核心1的Store(写x)之后执行,同时核心1的Load(读y)在核心0的Store(写y)之后执行,形成循环依赖。但在弱内存模型下,由于允许Load-Store重排序,两个核心的Store都可以在自己的Load之前提交到Cache。具体路径是:核心0的被提前提交(Load-Store重排),核心1的也被提前提交,然后两个核心的Load分别读到对方Store的值,导致。
弱内存模型通过fence指令(barrier / fence)来让程序员在需要时显式地恢复排序约束。例如ARM的DMB(Data Memory Barrier)指令保证DMB之前的所有内存操作在DMB之后的内存操作之前对其他核心可见。RISC-V的fence指令提供更细粒度的控制——可以单独指定前后操作的类型(Load/Store/IO)。
弱内存模型的一个重要特性是数据依赖保序(data dependency ordering)。即使在最宽松的弱内存模型下,如果Load 的地址依赖于Load 的结果(即返回的值被用于计算的地址),则必须在之前完成。这是一种天然的排序,不需要fence指令。ARM和RISC-V都保证数据依赖保序,但注意控制依赖(control dependency,如分支指令依赖)不提供排序保证——因为分支预测可能导致依赖指令在分支解析之前就被推测执行。
数据依赖保序在硬件中是"免费"的——因为乱序处理器的数据流引擎天然地遵循数据依赖关系。Load 的地址由的结果计算,这意味着的地址计算指令依赖于的结果,在数据流图中排在之后。即使乱序调度器试图提前发射,也无法做到——因为的地址尚未就绪。
然而,数据依赖保序有一个微妙的陷阱:编译器可能打破数据依赖。例如,编译器的常量传播和值预测优化可能将一个依赖Load的地址计算替换为常量,从而消除了数据依赖关系。考虑以下代码:
int *p = READ_ONCE(shared_ptr); // Load A
int val = *p; // Load B: 地址依赖于Load A如果编译器通过分析发现shared_ptr的值总是某个特定地址(例如在所有调用路径中都指向同一个全局变量),它可能将*p替换为对该全局变量的直接访问——消除了Load B对Load A的地址依赖。在弱内存模型下,Load B可能被提前执行,读到旧值。
Linux内核的READ_ONCE()宏通过volatile语义阻止编译器进行这种优化,确保硬件的数据依赖保序保证不被编译器破坏。Linux内核还使用smp_load_acquire()和smp_store_release()等API,它们在编译器层面保留依赖关系的同时,在需要时插入适当的硬件fence指令。
设计提示
C++11标准曾定义了memory_order_consume来利用硬件的数据依赖保序——它比memory_order_acquire更弱(不需要fence指令,只需要保留数据依赖),理论上在ARM和RISC-V上可以实现零开销的同步。然而,由于编译器实现的困难(正确追踪和保留数据依赖关系非常复杂),所有主流编译器都将memory_order_consume提升为memory_order_acquire来实现——放弃了零开销的优势。C++标准委员会已经在C++23中弃用了memory_order_consume。这是一个"硬件可以做到但编译器做不到"的典型案例。
释放一致性
释放一致性(Release Consistency,RC)是弱一致性模型的一种精细化变体,由Gharachorloo等人在1990年提出。RC模型将同步操作进一步细分为两类:
获取操作(Acquire):在获取锁或进入临界区时执行。语义上,acquire保证它之后的所有内存操作不会被重排序到acquire之前。
释放操作(Release):在释放锁或离开临界区时执行。语义上,release保证它之前的所有内存操作不会被重排序到release之后。
Acquire和Release的语义可以理解为单向屏障(one-way barrier):Acquire是一个"向下的屏障"——它下面的操作不能移到它上面,但它上面的操作可以移到它下面;Release是一个"向上的屏障"——它上面的操作不能移到它下面,但它下面的操作可以移到它上面。
RC模型的硬件优势在于,它允许在Acquire和Release之间的普通内存操作自由重排序。只有Acquire和Release操作本身需要特殊处理——Acquire操作需要等待其执行完成后才允许后续操作开始执行,Release操作需要等待所有先前操作完成后才能执行。
用一个锁保护的临界区来说明RC的语义:
; 线程0
LDAR w0, [lock] ; Acquire: 获取锁
; --- 临界区开始 ---
LDR w1, [shared_data] ; 读取共享数据
ADD w1, w1, #1
STR w1, [shared_data] ; 修改共享数据
; --- 临界区结束 ---
STLR wzr, [lock] ; Release: 释放锁在上述代码中,Acquire保证临界区内的操作不会被重排到锁获取之前——否则线程在获取锁之前就读取了共享数据,失去了锁的保护。Release保证临界区内的操作不会被重排到锁释放之后——否则线程释放锁之后其他线程进入临界区时,可能看到不完整的数据修改。
RC模型比使用完整fence的弱一致性模型更高效,因为Acquire/Release是单向屏障——临界区外的无关操作可以自由地跨越Acquire或Release进行重排。例如,锁获取之前的某个无关Load可以被重排到临界区内执行(Acquire允许操作从上方下移),这不影响正确性但可以减少Load的等待延迟。
现代ISA对RC模型的支持体现在:
ARMv8:
LDAR(Load-Acquire)和STLR(Store-Release)指令。LDAR保证其后续操作不会被重排到LDAR之前;STLR保证其先前操作不会被重排到STLR之后。RISC-V:原子指令(AMO和LR/SC)的
.aq(acquire)和.rl(release)修饰位。.aq位使该指令具有acquire语义,.rl位使其具有release语义,两位同时置位则提供顺序一致性(SC)语义。C++11:
std::memory_order_acquire和std::memory_order_release直接映射到硬件的acquire/release语义。
硬件描述 2 — Acquire/Release的硬件实现要点
在乱序处理器中,Acquire和Release语义的实现通常不需要专门的硬件结构,而是利用已有的Load Queue和Store Buffer:
Acquire(Load-Acquire):Load指令照常执行并从Cache读取数据。但在该Load退休之前,后续所有内存操作不能对外可见。实现方式:在Load Queue中标记该Load为acquire操作,在其完成之前阻止后续Load的完成和后续Store的提交。
Release(Store-Release):Store指令正常写入Store Buffer。但该Store在提交到Cache之前,Store Buffer中所有排在它前面的Store必须已经提交完毕,且所有先前的Load必须已经完成。实现方式:在Store Buffer中标记该Store为release操作,在提交时检查所有先前操作的完成状态。
TSO的硬件实现
x86架构的内存模型是TSO(尽管Intel的官方文档并未使用"TSO"这个术语,而是以一组具体的排序规则来描述x86-TSO模型)。理解TSO的硬件实现对于理解现代x86处理器的存储子系统设计至关重要。
Intel在2008年发布的"Intel 64 Architecture Memory Ordering White Paper"中,将x86的内存模型描述为以下8条规则(简化版):
Load不会与其他Load重排序。
Store不会与其他Store重排序。
Store不会与先前的Load重排序。
Load可能与先前的Store重排序(到不同地址)。
在多核系统中,不同核心的Store操作不存在统一的全局顺序。
在多核系统中,每个核心观察到的Store顺序保持其他核心的程序顺序。
带
lock前缀的指令具有全排序性。Load/Store不会与I/O指令重排序。
其中规则4就是TSO相对于SC放宽的唯一排序——Store-Load重排序。规则7说明lock前缀指令提供了SC语义。
回顾第 9.0 章中讨论的Cache一致性协议(MESI/MOESI):一致性协议保证了同一地址的写入最终对所有核心可见,但它不保证不同地址之间的可见顺序。内存一致性模型正是在Cache一致性协议之上定义了跨地址的排序规则。同时,第 36.0 章中介绍的Store Buffer与本章的TSO/弱序模型直接关联:Store Buffer的FIFO行为天然产生TSO语义(Store-Store有序),而弱序模型允许非FIFO的Store Buffer设计,从而获得更高的提交带宽和更低的头部阻塞概率。
本节详细讨论TSO模型的核心硬件机制:Store Buffer的排序保证、Load的顺序保证、MFENCE指令的实现、Dekker互斥算法的分析以及Store Buffer如何自然产生TSO语义。
Store Buffer排序
在TSO模型下,Store Buffer必须是严格FIFO的——Store按照程序顺序进入Store Buffer,并严格按照FIFO顺序从Store Buffer提交到L1 Cache。这个FIFO约束保证了TSO模型中的Store-Store排序。
Store Buffer的FIFO特性带来了一个重要的设计约束:即使Store Buffer中后面的条目已经拿到了Cache行的写权限(MESI协议中的M或E状态),它也不能提前于前面的条目提交。这意味着Store Buffer的"头部阻塞"(head-of-line blocking)问题在TSO模型下是不可避免的。
硬件描述 3 — TSO Store Buffer的微架构实现
一个典型的TSO Store Buffer的实现包含以下组件:
循环缓冲区:Store Buffer使用头指针(head pointer)和尾指针(tail pointer)管理的循环缓冲区。新Store在尾部插入,已提交的Store从头部移除。
地址CAM:用于Store-to-Load转发时的地址匹配。每个条目存储Store的物理地址和数据。
状态位:每个条目包含若干状态位——地址有效位(address valid)、数据有效位(data valid)、Cache权限已获取位(permission acquired)。
提交逻辑:头部条目在同时满足"地址有效"、"数据有效"和"Cache权限已获取"三个条件时才能提交。提交操作将Store数据写入L1 Cache并释放Store Buffer条目。
在Intel的实现中,Store Buffer的深度通常为5672个条目(如Skylake为56个、Golden Cove为72个)。Store Buffer的条目数是影响乱序窗口的关键资源之一——如果Store Buffer满了,后续的Store指令(以及所有在其后的指令)都必须停顿。
Store Buffer的FIFO排序与Store合并(Store Coalescing)之间存在微妙的交互。在某些情况下,两个相邻的Store写入同一个Cache行的不同字节,将它们合并为一个写操作可以减少Cache写端口的压力。但在严格的TSO模型下,合并只允许在不违反Store-Store顺序的前提下进行——即只有相邻的(在FIFO顺序中相邻的)且写入同一Cache行的Store才能被合并。Intel处理器通过写合并缓冲区(Write Combining Buffer,WCB)来实现这种有序的Store合并。
表表 37.13列出了几代x86处理器Store Buffer深度的演进。
| 微架构 | 年份 | Store Buffer条目数 | 备注 |
|---|---|---|---|
| Nehalem | 2008 | 32 | 首个集成内存控制器 |
| Sandy Bridge | 2011 | 36 | 引入融合微操作 |
| Haswell | 2013 | 42 | ROB扩大至192条目 |
| Skylake | 2015 | 56 | ROB扩大至224条目 |
| Golden Cove | 2021 | 72 | ROB扩大至512条目 |
| Lion Cove | 2024 | 128 | 大幅扩展乱序窗口 |
x86处理器Store Buffer深度的演进
Store Buffer深度的增长与ROB深度的增长基本同步——因为Store Buffer的条目数限制了处理器能够"看到"的Store指令窗口。如果Store Buffer满了,即使ROB中还有空位,后续的Store指令也无法进入流水线,形成背压(back pressure),最终导致整个流水线停顿。经验法则是Store Buffer的条目数应约为ROB深度的到(因为典型程序中Store指令约占总指令数的25%30%)。
Store Buffer的头部阻塞问题
FIFO Store Buffer的头部阻塞(head-of-line blocking)是TSO硬件实现中的一个重要性能瓶颈。考虑以下场景:Store Buffer头部的Store 目标地址的Cache行尚未获取写权限(例如该Cache行在其他核心中处于M状态,需要通过一致性协议获取独占权限),而后续的Store 目标地址的Cache行已经在本核心中处于M状态(可以立即提交)。在严格的TSO模型下,必须等待提交后才能提交,即使已经准备就绪。
这种头部阻塞的影响可以量化:假设Store 的Cache行需要从远端核心获取(延迟约40周期),而之后还有5个Store等待提交。在这40个周期内,整个Store Buffer的提交被停顿,相当于浪费了5个Store的提交机会。
x86处理器通过以下机制缓解头部阻塞问题:
提前获取权限(Prefetch for Write):当Store进入Store Buffer时,立即向L1 Cache发送写权限请求(RFO请求),而不等到Store到达头部再请求。这使得大多数Store在到达头部时已经拥有了写权限。
Store Buffer提交流水线化:Store从Buffer头部提交到Cache的过程本身被流水线化——权限检查、数据写入、Buffer释放在不同的周期中完成。每周期可以启动一个提交操作,即使前一个提交尚未完成。
写合并缓冲区(WCB):对于非临时存储(non-temporal stores,使用
MOVNT指令),写入通过写合并缓冲区直接发送到内存,绕过Cache层次结构。WCB中的写入不受TSO FIFO约束(因为NT Store的内存类型是Write-Combining,有独立的排序规则)。
性能分析 3 — Store Buffer头部阻塞的性能影响
在SPEC CPU2017的整数基准测试中,Store Buffer头部阻塞导致的流水线停顿约占总停顿周期的8%15%。其中:
mcf(内存密集型指针追踪工作负载):头部阻塞占总停顿的约18%,因为大量Store目标地址的Cache行需要从LLC或内存获取。
xalancbmk(XML处理):头部阻塞占总停顿的约12%。
gcc(编译器):头部阻塞仅占约5%——因为编译器工作负载的Store大多访问栈帧局部变量,Cache命中率很高。
这些数据说明了为什么ARM和RISC-V选择弱内存模型——允许非FIFO Store Buffer可以消除头部阻塞问题,在内存密集型工作负载中带来显著的性能提升。
Load序的保证
TSO模型要求同一核心的Load按程序顺序完成(Load-Load序)。在乱序处理器中,Load指令可以乱序发射和执行(即乱序发送地址到Cache并获取数据),但它们的完成(即返回值被后续指令看到的时刻)必须遵循程序顺序。
这里需要明确"乱序执行"和"乱序完成"的区别。在TSO模型下,Load可以乱序执行——即多个Load可以并行地向Cache发送请求并获取数据。但Load的完成——即其返回值被"承诺"为最终结果——必须遵循程序顺序。换言之,如果Load 在程序顺序中先于Load ,且先返回了数据,的结果必须被暂存直到完成后才能被确认。如果在完成之前,读取的Cache行被其他核心修改了,就可能需要重新执行。
这个约束的硬件实现依赖于第 36.0 章中讨论的Load Queue和Load违规检测机制。其核心逻辑如下:
Load指令在发射后立即执行——向Cache发送地址请求并获取数据。此时Load可能乱序执行。
当一个Load完成(获取数据)后,它的结果被暂存在Load Queue中,但尚未"提交"——即该值尚未被确认为正确的。
在Load退休之前,硬件检查是否有任何失效(invalidation)事件可能影响该Load的正确性。具体来说,如果在Load读取Cache之后、Load退休之前,另一个核心的Store使得该Cache行失效(通过一致性协议的invalidation消息),则该Load可能读取了过期的值。
如果检测到上述情况(称为Load顺序违规,Load Order Violation),处理器必须进行流水线冲刷(pipeline flush),从该Load指令开始重新执行。
设计提示
Load序的保证是TSO实现中成本最高的部分之一。Load Queue需要在每次收到一致性协议的snoop请求时,搜索所有尚未退休的Load条目,检查是否有地址匹配。这个搜索操作(snoop-induced Load Queue search)与Store-to-Load转发的CAM查找类似,但方向相反——转发是Load查找Store Buffer,而这里是外部snoop查找Load Queue。在高带宽的多核系统中,snoop请求的频率很高(每周期可能有多次),因此Load Queue必须具备足够的搜索带宽。
Intel处理器的Load序保证机制还有一个重要的优化:推测Load执行(speculative load execution)。处理器允许Load乱序执行并将结果直接转发给依赖的后续指令,而不等待Load顺序检查完成。如果后续检测到Load顺序违规,整个依赖链都会被冲刷并重新执行。这种"先执行、后验证"的策略在大多数情况下(99%以上的Load不会违规)提供了显著的性能优势,只有极少数情况下会付出冲刷的代价。
Load顺序违规的检测机制可以进一步细化为两种情况:
外部失效导致的违规:核心的Load 乱序执行(在程序顺序更早的Load 之前完成),随后访问的Cache行被其他核心的Store失效。如果此时尚未完成,则和的完成顺序可能不符合TSO的Load-Load序要求——另一个核心可能观察到看到的是新值而看到的是旧值,但在程序顺序中先于。
Store-to-Load转发导致的违规:核心的Load 旁路了Store Buffer中的一个Store 而直接从Cache读取,但随后的值被更新(例如被撤销并重新执行)。这种情况较为罕见,但在推测执行中可能发生。
为了检测这些违规,硬件在Load Queue中为每个未退休的Load维护一个地址标签和时间戳。当收到snoop请求时,Load Queue执行地址匹配——如果发现一个尚未退休的Load的地址与snoop地址匹配,且该Load已经在该snoop对应的Store之后完成(通过时间戳判断),则标记该Load为违规,触发流水线冲刷。
Load顺序违规检测的硬件代价主要体现在CAM搜索带宽上。在一个64条目的Load Queue中,每次snoop请求都需要对所有未退休的Load条目执行地址匹配。假设系统中有8个核心,每个核心每周期平均产生0.5次snoop请求,则Load Queue每周期需要处理约4次地址匹配搜索。加上Store-to-Load转发本身需要的搜索,Load Queue的CAM端口成为功耗和面积的热点。
为了降低CAM搜索的功耗,现代处理器采用Bloom Filter预过滤技术:在Load Queue前面放置一个小型的Bloom Filter,记录所有未退休Load的地址指纹。当snoop请求到来时,先查询Bloom Filter。如果Bloom Filter报告"不匹配"(确定性的否定),则跳过CAM搜索,节省功耗。只有Bloom Filter报告"可能匹配"时,才执行完整的CAM搜索。在典型工作负载中,90%以上的snoop请求可以被Bloom Filter过滤掉。
MFENCE的实现
MFENCE(Memory Fence)是x86架构中最强的内存屏障指令。它的语义是:MFENCE之前的所有Load和Store必须在MFENCE之后的所有Load和Store之前完成。在TSO模型中,MFENCE的主要作用是恢复被TSO放宽的Store-Load排序——即确保MFENCE之前的Store已经被写入Cache(对所有核心可见),然后才允许MFENCE之后的Load执行。
值得注意的是,在TSO模型下MFENCE的存在意义可能不那么直观——既然TSO已经保证了Load-Load、Store-Store和Load-Store序,为什么还需要MFENCE?原因在于TSO允许的Store-Load重排序在某些算法中会导致错误的结果。Dekker互斥算法、Peterson互斥算法以及基于标志变量的同步机制都可能受到Store-Load重排序的影响。MFENCE(或等效的lock前缀指令)用于在这些关键位置恢复Store-Load序。
MFENCE的硬件实现通常采用以下策略:
排空Store Buffer:MFENCE指令进入流水线后,停止所有后续内存操作的发射。然后等待Store Buffer中所有先于MFENCE的Store条目被提交到Cache。
等待所有Load完成:同时确保所有先于MFENCE的Load已经完成并退休。
恢复发射:当Store Buffer中先于MFENCE的所有条目都已提交,且所有先前Load都已退休后,MFENCE本身退休,后续内存操作才被允许发射。
图 图 37.4展示了MFENCE排空Store Buffer的时间线。
MFENCE与lock前缀的性能对比
在x86处理器上,提供全屏障语义有两种选择:MFENCE指令和lock前缀指令。虽然两者都保证Store-Load序,但它们的微架构行为有重要差异:
MFENCE的序列化效果:在Intel Skylake之前的微架构上(如Haswell),MFENCE不仅序列化内存操作,还序列化所有指令——MFENCE之后的任何指令(包括纯计算指令)都不能在MFENCE之前执行。这种过度序列化是一个已知的性能问题。从Skylake开始,MFENCE的行为被修正为仅序列化内存操作。
lock前缀的局部性:
lock前缀指令(如lock add [mem], 0)的序列化效果仅限于该指令访问的Cache行。lock指令在执行期间锁定一个Cache行并完成RMW操作,提供了全屏障语义。但与MFENCE不同,lock指令不会序列化不相关的内存操作——其他Cache行的Load和Store仍然可以正常进行(只要不与lock的目标行冲突)。
这就是为什么Linux内核在x86上使用lock; addl $0, (%rsp)而非MFENCE作为全屏障——lock add访问的是栈顶的Cache行(通常在L1 Cache中处于M状态,延迟极低),且不会过度序列化非内存指令。在微基准测试中,lock add比MFENCE快约23倍。
性能分析 4 — MFENCE的性能影响
MFENCE是一条代价高昂的指令。其延迟取决于当前Store Buffer中待提交条目的数量和Cache访问延迟:
最佳情况:Store Buffer为空,MFENCE可以立即完成,延迟约46个周期(流水线排空的固定开销)。
典型情况:Store Buffer中有1020个待提交条目,MFENCE延迟约3050个周期。
最坏情况:Store Buffer中有大量Cache缺失的Store,MFENCE延迟可达100200个周期。
在Linux内核中,MFENCE的使用非常谨慎。内核的smp_mb()宏在x86上通常使用lock; addl $0, (%rsp)而非MFENCE——因为带lock前缀的指令在某些微架构上比MFENCE更快(Intel在Skylake之前的微架构上MFENCE会序列化所有指令,而lock前缀仅序列化内存操作)。
Dekker互斥算法在TSO下的分析
Dekker互斥算法是理解TSO模型局限性的经典案例。该算法是最早的正确互斥算法之一,在SC模型下可以保证互斥性,但在TSO模型下会因Store-Load重排序而失效。
Dekker算法的核心思想是:每个线程先声明自己的意图(设置自己的标志变量),然后检查对方的标志变量。如果对方也声明了意图,则通过一个仲裁变量(turn)来决定谁先进入临界区。算法的关键片段如下:
; 核心0 ; 核心1
mov [flag0], 1 ; 声明意图 mov [flag1], 1 ; 声明意图
mov eax, [flag1] ; 检查对方 mov ebx, [flag0] ; 检查对方
test eax, eax test ebx, ebx
jnz contention jnz contention
; 进入临界区 ; 进入临界区在TSO模型下,核心0的可能仍在Store Buffer中,而后续的绕过Store Buffer直接从Cache读取。如果此时核心1的也在Store Buffer中,核心0和核心1都可能读到对方的标志为0,从而同时进入临界区——互斥性被破坏。
这个问题的根源是:TSO允许Store-Load重排序,而Dekker算法的正确性依赖于每个核心的Store(声明意图)在Load(检查对方)之前对对方可见。
修正方法是在Store和Load之间插入MFENCE指令:
; 核心0 ; 核心1
mov [flag0], 1 ; 声明意图 mov [flag1], 1 ; 声明意图
mfence ; 排空Store Buffer mfence ; 排空Store Buffer
mov eax, [flag1] ; 检查对方 mov ebx, [flag0] ; 检查对方
test eax, eax test ebx, ebx
jnz contention jnz contention
; 进入临界区 ; 进入临界区MFENCE确保从Store Buffer提交到Cache(对所有核心可见)之后,才执行。这恢复了SC的Store-Load排序,使Dekker算法重新获得正确性。
从硬件角度分析MFENCE在这里的作用:
核心0执行,该Store进入Store Buffer。
核心0遇到MFENCE,流水线停止发射后续内存操作。
Store Buffer将提交到L1 Cache。由于MESI协议,如果核心1持有的Cache行(S或E状态),会收到invalidation消息。
MFENCE确认Store Buffer中所有先前条目已提交,MFENCE退休。
核心0执行。此时,如果核心1也已经执行了并提交到Cache,核心0会读到并进入竞争处理逻辑。
硬件描述 4 — TSO下需要fence的经典算法
以下经典的并发算法在TSO模型下需要显式的fence(MFENCE或lock前缀指令)才能保证正确性:
Dekker互斥算法:在Store标志和Load对方标志之间需要fence。
Peterson互斥算法:类似Dekker,在Store和后续Load之间需要fence。
Lamport面包店算法:在Store票号和Load其他线程票号之间需要fence。
Seqlock的读侧:在读取序列号和读取数据之间不需要额外fence(TSO的Load-Load序已保证),但写侧在修改数据后递增序列号之前不需要Store-Store fence(TSO已保证),唯一需要fence的地方是确保序列号的递增Store在后续Load之前对读者可见。
在实践中,大多数系统程序使用lock前缀的原子指令(如lock xchg、lock cmpxchg)来实现互斥,这些指令隐含了全屏障语义,自然避免了Store-Load重排序问题。直接使用标志变量的无锁算法较为少见,但在性能极端敏感的场景(如Linux内核的RCU、Seqlock)中仍有应用。
Store Buffer如何自然产生TSO语义
TSO模型与Store Buffer之间存在一种深刻的自然对应关系:一个FIFO Store Buffer加上Store-to-Load转发机制,恰好产生TSO语义。这不是巧合——TSO模型正是对"带有Store Buffer的处理器"的行为的形式化描述。
理解这一点的关键是分析Store Buffer的每一个属性如何对应到TSO的排序规则:
Store Buffer是FIFO的 Store-Store序:Store按程序顺序进入Store Buffer尾部,按FIFO顺序从头部提交到Cache。这意味着对于同一核心的两个Store 和(先于),一定在之前从Store Buffer提交到Cache,因此一定在之前对其他核心可见。
Load不经过Store Buffer Load-Load序:在TSO实现中,Load直接从Cache读取数据(除非Store Buffer中有匹配的转发)。由于Cache中的数据对所有核心一致(由MESI协议保证),Load的完成顺序不影响其他核心的观察。TSO要求Load-Load序的保证通过Load Queue的顺序管理来实现。
Load在Store进入Store Buffer后即可执行 Store-Load重排序:Store写入Store Buffer后,后续Load无需等待Store提交到Cache即可执行。从其他核心的视角看,该Store尚未对它们可见(因为还在本核心的Store Buffer中),但Load已经完成——这就是Store-Load重排序。
Store-to-Load转发 单核一致性:本核心的Load首先查找Store Buffer,如果找到匹配的Store,直接转发其数据。这保证了单核内的"读自己的写"语义——虽然Store可能尚未提交到Cache,但本核心的Load总能看到最新的Store值。
这种自然对应关系有一个深刻的含义:TSO不是一个人为设计的抽象模型,而是对FIFO Store Buffer这种具体硬件结构行为的忠实描述。x86的TSO模型可以说是"先有硬件,后有模型"——Intel和AMD在设计处理器时自然地采用了FIFO Store Buffer,然后将其行为形式化为TSO内存模型。这也解释了为什么TSO模型如此"恰到好处"——它精确地允许了Store Buffer带来的性能优化(Store-Load重排序),同时保留了FIFO Store Buffer天然提供的所有排序保证。
除了MFENCE之外,x86还提供SFENCE(Store Fence)和LFENCE(Load Fence)两条较弱的fence指令。在TSO模型下,SFENCE保证Store-Store顺序(TSO本身已经保证),LFENCE保证Load-Load顺序(TSO本身也已经保证),因此这两条指令在普通内存操作中几乎不需要使用。它们的主要用途是在非临时存储(non-temporal stores)和从不可缓存(uncacheable)内存区域读取时提供排序保证。
案例研究 1 — Intel Skylake的Store Buffer与内存序
Intel Skylake微架构的Store Buffer有56个条目,支持以下TSO相关特性:
FIFO提交:Store严格按照程序顺序从Store Buffer头部提交到L1 Cache。每周期最多提交1个Store。
Store-to-Load转发:Load在执行时搜索Store Buffer的所有条目,查找地址匹配。如果找到完全覆盖的匹配(同一地址,且Store的数据宽度Load的数据宽度),直接转发Store的数据。部分匹配的情况(Store覆盖Load数据的一部分)需要合并Store Buffer数据和Cache数据。
MFENCE处理:MFENCE进入ROB后,后续内存微操作被标记为"等待fence"。当MFENCE到达ROB头部时,等待Store Buffer排空后退休。
Snoop处理:当收到来自其他核心的snoop(一致性探测)请求时,同时搜索Load Queue和Store Buffer。如果Store Buffer中有匹配的条目,返回Store Buffer中的最新值(snoop-hit-store-buffer)。
弱内存模型的硬件实现
ARM和RISC-V架构采用的弱内存模型给硬件设计者提供了比TSO更大的自由度。本节分别讨论ARM内存模型和RISC-V RVWMO模型的硬件实现,以及fence指令在流水线中的实现方式。
ARM内存模型的硬件支持
ARMv8架构采用一种称为多副本原子(Multi-Copy Atomic)的弱内存模型。在ARMv8.4之前,ARM的模型是非多副本原子的(Other-Multi-Copy Atomic之前的版本),这意味着一个核心的Store可能先对同一Cluster中的某些核心可见,然后才对其他核心可见——这是因为在某些实现中,同一Cluster共享的L2 Cache可能在Store完全传播之前就响应Cluster内其他核心的请求。
ARM内存模型允许以下重排序(在没有fence指令的情况下):
Load-Load重排序:不同地址的Load可以乱序完成。这使得Cache缺失的Load不会阻塞后续Cache命中的Load。
Load-Store重排序:Load可以在后续Store提交之后才完成,反之亦然。
Store-Store重排序:不同地址的Store可以乱序提交到Cache。
Store-Load重排序:与TSO相同,Store可以在后续Load之后才对其他核心可见。
从硬件设计角度看,这些重排序自由度允许ARM处理器采用以下优化策略:
硬件描述 5 — ARM弱内存模型的硬件优化
非FIFO Store Buffer:Store Buffer不需要是FIFO的。Store可以按任意顺序提交到Cache,只要不违反同一地址的数据依赖关系。当前方的Store等待Cache行权限时,后方的Store(如果已经拿到权限)可以提前提交。这大大减轻了Store Buffer的头部阻塞问题。
Load乱序完成:不同地址的Load可以以任意顺序完成。Load Queue不需要像TSO那样在每次snoop时进行全搜索——只有标记了acquire语义的Load才需要特殊的顺序保证。
Store Buffer合并:由于不需要保证Store-Store顺序,多个写入不同地址但位于同一Cache行的Store可以被自由合并,减少Cache写端口的占用。
读取其他核心Store Buffer:在某些ARM实现(如共享L2 Cluster的设计)中,一个核心可以通过内部转发网络直接读取同Cluster其他核心Store Buffer中的值,而无需等待Store写入L1 Cache。
多副本原子性(Multi-Copy Atomicity)的硬件含义
ARMv8.4引入了多副本原子性(Multi-Copy Atomicity,MCA)的保证,这对硬件设计产生了重要影响。MCA的含义是:一个核心的Store操作一旦对任何一个其他核心可见,就必须同时对所有其他核心可见。换言之,不存在"Store先对核心A可见,然后才对核心B可见"的情况。
在MCA之前的ARM处理器中(如Cortex-A57的早期版本),多副本原子性不被保证。考虑以下硬件场景:
核心0和核心1位于同一个Cluster,共享L2 Cache。
核心2位于另一个Cluster。
核心0执行。该Store首先从核心0的Store Buffer提交到L1 Cache,然后被L2 Cache的一致性逻辑处理。
在Store完全传播到外部一致性域之前,核心1(同Cluster)可能通过L2 Cache的内部转发看到。
此时核心2(不同Cluster)可能仍看到——因为invalidation消息尚未到达核心2的Cluster。
这种非MCA行为使得IRIW石蕊测试可能产生异常结果——两个观察者对两个独立写入的顺序产生分歧。MCA的保证消除了这种可能性。
实现MCA的硬件方法是确保Store从L1 Cache提交后,在对同Cluster其他核心可见之前,先完成对外部一致性域的invalidation广播。这可以通过以下机制实现:
L2 Cache的写入排序:当L2 Cache收到来自核心L1的写入时,在将写入的数据对Cluster内其他核心可见之前,先向外部一致性域发送invalidation请求,并等待确认(或至少将invalidation请求入队到发送缓冲区,保证顺序)。
Snoop Filter的全局可见性:使用全局的Snoop Filter来跟踪每个Cache行的所有副本。Store操作必须通过Snoop Filter的全局仲裁后,才能对任何核心可见。
MCA的硬件代价是Store的可见延迟增加——因为Store不能在Cluster内"提前"可见。在某些工作负载中,这可能导致2%5%的性能下降。但MCA极大地简化了软件的推理——程序员不需要考虑Store对不同核心的非同步可见性。
ARM提供了三种不同强度的屏障指令来恢复排序:
DMB(Data Memory Barrier):保证DMB之前的指定类型的内存操作(Load/Store)在DMB之后的指定类型的内存操作之前完成。DMB有多种变体:
DMB ISH(内部共享域的全屏障)、DMB ISHLD(Load-Load和Load-Store屏障)、DMB ISHST(Store-Store屏障)。DSB(Data Synchronization Barrier):比DMB更强。DSB不仅保证内存操作的顺序,还保证DSB之前的所有Cache维护操作和TLB失效操作在DSB之后的任何指令执行之前完成。
ISB(Instruction Synchronization Barrier):最强的屏障。ISB要求处理器冲刷流水线中所有ISB之后取指的指令,并从指令Cache或内存重新取指。ISB主要用于上下文切换后确保指令流的一致性。
DMB、DSB和ISB在ARM处理器中的硬件实现代价差异显著,理解它们的区别对于编写高效的ARM多线程代码至关重要。
DMB的语义与硬件实现
DMB(Data Memory Barrier)是ARM中最常用的屏障指令。它只约束数据内存操作的顺序,不影响指令执行的流水线行为。DMB有多种变体,通过域(domain)和操作类型(access type)参数来细化其语义:
| 指令 | 域/类型 | 排序保证 |
|---|---|---|
DMB SY | 全系统/全操作 | 所有内存操作的全屏障 |
DMB ISH | 内部共享域/全操作 | 同一共享域内的全屏障 |
DMB ISHLD | 内部共享域/Load | LoadLoad, LoadStore |
DMB ISHST | 内部共享域/Store | StoreStore |
DMB OSH | 外部共享域/全操作 | 跨Cluster的全屏障 |
DMB NSH | 非共享 | 仅本核心可见的排序 |
ARM DMB指令的常用变体
DMB的硬件实现不需要排空Store Buffer或冲刷流水线。在微架构中,DMB通常通过在Load Queue和Store Buffer中插入屏障标记(barrier marker)来实现。屏障标记将队列逻辑上分为"屏障前"和"屏障后"两个分区,后分区的操作只有在前分区的所有操作完成后才能被提交/完成。DMB本身不占用功能单元,也不需要排空Store Buffer——它只是在队列中插入了一个排序约束点。
DMB的域参数对硬件实现有重要影响:DMB ISH只需要保证同一内部共享域(通常是同一个Cluster内的核心)内的排序,不需要等待跨Cluster的一致性确认。而DMB SY需要保证全系统范围的排序,可能需要等待外部一致性域的确认消息。在一个4核Cluster设计中,DMB ISH的延迟通常为510个周期,而DMB SY可能需要2050个周期(取决于系统互联的延迟)。
DSB的语义与硬件实现
DSB(Data Synchronization Barrier)比DMB更强。DSB不仅保证数据内存操作的顺序,还保证以下系统级操作在DSB之前完成:
Cache维护操作:如
DC CIVAC(清除并失效Cache行到PoC)、DC CVAC(清除Cache行到PoC)、IC IALLU(失效所有指令Cache)。TLB失效操作:如
TLBI VMALLE1IS(失效所有TLB条目)。分支预测器维护:如BP ALLIS(清除所有分支预测器条目)。
DSB的硬件实现需要追踪上述系统级操作的完成状态。一种常见的实现是使用完成计数器(completion counter):每发出一个Cache维护请求或TLB失效请求,计数器加1;每收到一个完成确认,计数器减1。DSB等待计数器归零后才退休。
DSB的一个关键使用场景是代码修改(self-modifying code):当一个核心修改了内存中的指令后,必须使用DSB确保修改后的数据从Store Buffer写入Cache,然后使用ISB确保流水线从修改后的地址重新取指。典型的序列是:
STR x1, [x0] ; 写入新指令到内存
DC CVAC, x0 ; 清除数据Cache到PoC
DSB ISH ; 等待Cache维护完成
IC IVAU, x0 ; 失效指令Cache
DSB ISH ; 等待指令Cache失效完成
ISB ; 冲刷流水线,重新取指ISB的语义与硬件实现
ISB(Instruction Synchronization Barrier)是ARM中代价最高的屏障指令。ISB的语义是:冲刷流水线中所有ISB之后取指的指令,从ISB之后的PC地址重新取指。这意味着ISB之后的指令是在ISB之前所有系统状态变化生效后重新从指令Cache取回的"干净"指令。
ISB的硬件实现需要:
丢弃取指缓冲区(fetch buffer)中ISB之后的所有指令。
丢弃译码队列(decode queue)中ISB之后的所有微操作。
丢弃发射队列(issue queue)中ISB之后的所有条目。
等待ISB之前的所有指令退休。
从ISB之后的PC地址重新开始取指。
ISB的延迟至少等于前端流水线深度。在一个12级流水线的ARM处理器中,ISB的延迟约为1220个周期(包括重新填充流水线的时间)。
性能分析 5 — ARM三种屏障指令的延迟对比
在一个典型的ARM Cortex-X系列高性能核心上,三种屏障指令的延迟差异如下:
DMB ISH:512个周期。主要延迟来自等待Store Buffer中先前条目的提交和Load Queue中先前Load的完成。在Store Buffer接近空的情况下,DMB可以在5个周期内完成。
DSB ISH:1550个周期。除了DMB的延迟外,还需要等待所有未完成的Cache维护和TLB失效操作返回完成确认。延迟的变化范围大,取决于是否有未完成的系统级操作。
ISB:1525个周期。流水线冲刷的固定开销(约等于前端流水线深度)加上重新填充流水线的时间。ISB的延迟相对稳定,不像DMB/DSB那样依赖于Store Buffer的当前状态。
软件优化建议:在仅需要数据内存排序的场景中,始终使用DMB而非DSB或ISB。在可以确定访问范围仅限于同一共享域内时,使用DMB ISH而非DMB SY。在只需要特定方向的排序时,使用DMB ISHLD或DMB ISHST而非完整的DMB ISH。Linux内核的ARM64实现中,smp_rmb()映射为DMB ISHLD,smp_wmb()映射为DMB ISHST,smp_mb()映射为DMB ISH——精确匹配了每种屏障的最小需求。
设计提示
在ARM处理器中,DMB的实现代价远低于MFENCE。因为ARM的DMB只需要保证特定类型的内存操作顺序,而不需要像MFENCE那样完全排空Store Buffer。一个典型的DMB ISHLD(Load屏障)的实现只需要:(1)等待DMB之前的所有Load完成;(2)标记DMB之后的Load和Store为"等待DMB"。当DMB之前的所有Load完成后,后续操作可以立即开始执行。Store Buffer中的Store不受DMB ISHLD影响,因此不需要排空。
ARM还提供了acquire/release语义的Load和Store指令——LDAR(Load-Acquire Register)和STLR(Store-Release Register)。这些指令将同步语义直接嵌入数据访问操作,避免了单独fence指令的开销。从硬件角度看:
LDAR的实现:Load正常执行,但在其退休之前,后续所有内存操作不能对外可见。在微架构中,这通常通过在Load Queue中设置一个"屏障位"来实现——当这个位被设置时,后续操作的提交被暂停。STLR的实现:Store正常写入Store Buffer,但在从Store Buffer提交到Cache时,必须等待所有先前的内存操作完成。在微架构中,这通过在Store Buffer条目中设置一个"release位"来实现——当这个Store到达Store Buffer头部时,检查所有先前Load和Store是否已完成。
RISC-V RVWMO的硬件支持
RISC-V的内存一致性模型称为RVWMO(RISC-V Weak Memory Ordering),定义在RISC-V特权架构规范中。RVWMO是一个精心设计的弱内存模型,它的排序规则基于保留程序顺序(Preserved Program Order,PPO)的概念——只有满足特定条件的内存操作对才需要保持程序顺序,其他操作对可以自由重排序。
RVWMO的PPO规则包括以下主要类别:
地址/数据/控制依赖:如果操作依赖于操作的结果(地址依赖、数据依赖或控制依赖),则必须在之前完成。
同地址排序:对同一地址的操作必须保持程序顺序(避免同一地址上的数据竞争)。
Acquire/Release排序:acquire操作之后的操作不能重排到acquire之前;release操作之前的操作不能重排到release之后。
Fence指令:fence指令规定了其前后指定类型操作的排序关系。
RVWMO的fence指令具有独特的位域设计。fence指令的格式包含两个4位的位域——predecessor(前驱操作集合)和successor(后继操作集合),每个位域中的4位分别代表:
I(Input):设备输入操作
O(Output):设备输出操作
R(Read):内存读操作(Load)
W(Write):内存写操作(Store)
例如,fence rw, rw保证前面所有的Load和Store在后面所有的Load和Store之前完成——这等价于一个全屏障。fence r, r只保证前面的Load在后面的Load之前完成——这是一个Load屏障。fence w, w是Store屏障。fence rw, w保证前面的Load和Store在后面的Store之前完成。
表表 37.15总结了RISC-V fence指令的常见变体及其等价含义。
| 指令 | 排序保证 | 等价于 |
|---|---|---|
fence rw, rw | Load/Store Load/Store | 全屏障 |
fence r, r | Load Load | Load屏障 |
fence w, w | Store Store | Store屏障 |
fence r, rw | Load Load/Store | Acquire-like |
fence rw, w | Load/Store Store | Release-like |
fence w, r | Store Load | 恢复Store-Load序 |
fence.tso | rr, rw, ww | TSO语义 |
RISC-V fence指令的常见变体
这种细粒度的fence设计使得编译器可以根据具体的同步需求选择最弱的fence变体,从而最小化排序约束带来的性能损失。例如,一个只需要保证Store-Store顺序的场景(如顺序写入日志缓冲区)只需要fence w,w,而不需要使用代价更高的全屏障。
fence指令编码格式的深入分析
RISC-V fence指令在32位编码中占据I-type格式,其中imm[11:0]字段的bit[7:4]编码predecessor集合,bit[3:0]编码successor集合。每4位中从高到低依次表示I(Input)、O(Output)、R(Read)、W(Write)。这意味着fence指令最多可以表示种排序组合,虽然实际有意义的组合远少于此。
这种位域设计的灵活性体现在以下几个方面:
方向性控制:可以只约束StoreLoad(
fence w,r),而不影响其他方向的操作。这比ARM的DMB更细粒度——ARM的DMB ISHLD约束了LoadLoad和LoadStore两个方向,而RISC-V可以单独约束其中任何一个。I/O操作独立控制:predecessor和successor的I(Input)和O(Output)位用于MMIO操作的排序。在与设备通信时,可以使用
fence iorw,iorw来保证I/O操作和内存操作的顺序,而普通的内存同步只需fence rw,rw。非对称排序:可以构造"前面所有操作在后面Store之前完成"这种非对称约束(
fence rw,w),这是Release语义的直接表达,而无需使用专门的Release指令。
然而,这种灵活性也给硬件实现带来了相当的复杂度。
硬件描述 6 — RVWMO fence指令的硬件解码
从硬件实现角度看,fence指令的predecessor/successor位域被解码为一组排序约束位(ordering constraint bits),这些位被传播到Load Queue和Store Buffer的控制逻辑中:
fence r,r:在Load Queue中插入一个"Load屏障标记"。屏障之后的Load不能在屏障之前的Load完成之前完成。实现方式:记录屏障点之前最后一条Load的序列号,后续Load在完成前检查该序列号对应的Load是否已完成。
fence w,w:在Store Buffer中插入一个"Store屏障标记"。屏障之后的Store不能在屏障之前的Store提交之前提交。在非FIFO Store Buffer中,这个标记定义了一个"提交点"——标记之后的Store在标记之前的所有Store提交完毕后才能开始提交。
fence r,w:需要跨Load Queue和Store Buffer的协调。屏障之后的Store不能在屏障之前的Load完成之前提交。实现方式:记录最后一条Load的序列号,Store Buffer在提交时检查该序列号。
fence w,r:需要Store Buffer和Load Queue的协调。屏障之后的Load不能在屏障之前的Store提交之前完成。这是代价最高的fence类型,类似于x86的MFENCE。
fence rw,rw:全屏障,组合以上所有约束。
性能分析 6 — RISC-V fence位域的实现复杂度分析
RISC-V fence的16种predecessor16种successor的组合空间给硬件验证带来了显著的复杂度。在实际实现中,处理器设计者通常采用以下简化策略:
分组处理:将256种组合归类为少数几个等价类。例如,
fence r,r和fence r,rw在只有R类型的后续操作时行为相同,可以共享同一个硬件路径。一个典型的高性能RISC-V核心实际只需要区分68种fence行为类别。保守实现:某些实现选择将所有fence统一视为全屏障(
fence rw,rw)来处理。这牺牲了细粒度fence的性能优势,但大幅简化了硬件设计和验证。这种策略常见于RISC-V嵌入式核心(如某些RV32IMC实现)。I/O分离:在大多数实现中,I/O操作(I和O位)走独立的MMIO通道,其排序由I/O子系统单独管理。核心内部的fence逻辑只需要处理R和W位的组合(种),复杂度降低一个数量级。
从验证角度看,RISC-V的fence设计需要针对每种有意义的位域组合编写石蕊测试,确保硬件实现正确地执行了对应的排序约束。RISC-V国际组织提供的riscv-litmus-tests测试套件包含数百个针对不同fence组合的测试用例。
RVWMO还定义了一种特殊的fence——fence.tso。这条指令的语义精确等价于TSO模型的排序规则:禁止Load-Load、Load-Store和Store-Store重排序,但允许Store-Load重排序。fence.tso的硬件实现比全fence简单——它不需要排空Store Buffer(因为允许Store-Load重排),只需要保证Load-Load、Load-Store和Store-Store的顺序。
设计提示
RISC-V的fence.tso指令是为可移植性而设计的。许多为x86/TSO编写的并发算法可以直接通过在适当位置插入fence.tso来移植到RISC-V上——这比使用完整的fence rw,rw有更好的性能,因为fence.tso不需要排空Store Buffer。对于那些从TSO架构向RISC-V迁移的软件生态系统,fence.tso提供了一个性能友好的过渡方案。
fence.tso的硬件实现特别简洁:它等价于fence r,rw加上fence w,w的组合。在一个支持非FIFO Store Buffer的RISC-V核心中,fence.tso需要:(1)将Store Buffer临时切换为FIFO模式(保证Store-Store序);(2)阻止后续Load在先前Store完成之前执行(保证Load-Store序和Load-Load序);但不需要排空Store Buffer(允许Store-Load重排序)。如果核心本身就使用FIFO Store Buffer(如某些简单的顺序核心),fence.tso的代价接近于零——因为FIFO Store Buffer已经天然提供了TSO的排序保证。
Fence指令的实现
Fence指令在乱序处理器流水线中的实现是一个微妙的工程问题。与普通指令不同,fence不产生数据结果,也不消耗功能单元——它的唯一作用是在内存操作序列中插入一个排序约束。图图 37.7展示了fence指令在流水线中的典型实现。
Fence指令在流水线中的处理有两种主要策略:
策略一:阻塞式实现(Stalling Implementation)
最简单的实现方式是将fence视为一条流水线"断点"——fence进入发射队列后,停止所有后续内存操作的发射,等待fence之前的所有相关内存操作完成后,fence退休,然后恢复后续操作的发射。
这种实现的优点是简单可靠,缺点是性能代价大——fence会在流水线中产生一个"气泡"(bubble),气泡的宽度等于fence之前最慢的内存操作的延迟。
策略二:非阻塞式实现(Non-Stalling Implementation)
更先进的实现允许fence之后的内存操作在fence退休之前就进入流水线并推测性执行——但这些操作的结果不会对外可见(不会提交到Cache),直到fence退休。如果fence退休时发现先序操作已经完成,后续操作可以直接提交;如果先序操作尚未完成(例如遇到Cache缺失),后续操作需要等待。
非阻塞式实现的核心挑战是推测Load的处理——fence后的Load可能推测性地从Cache读取了一个值,但在fence退休时发现该值已经被fence前的Store修改。这种情况需要Load违规检测机制来发现和恢复。
设计权衡 2 — 阻塞式vs非阻塞式fence实现
阻塞式实现:
硬件简单,无需推测恢复逻辑
fence延迟直接暴露为流水线气泡
适合简单核心和低功耗设计(如ARM Cortex-A55)
非阻塞式实现:
允许fence后的操作推测执行,隐藏部分fence延迟
需要Load违规检测和流水线冲刷机制
适合高性能乱序核心(如ARM Cortex-X系列、Apple M系列)
在高性能处理器中,非阻塞式实现可以将fence的有效延迟从3050周期降低到515周期(在大多数情况下fence前的操作在fence到达退休阶段时已经完成)。
还有第三种值得关注的实现策略——基于计数器的fence跟踪(counter-based fence tracking)。这种方法不使用简单的"阻塞/放行"机制,而是维护细粒度的完成计数器。当fence进入流水线时,记录当前未完成的Load数量和未提交的Store数量。fence之后的内存操作可以正常进入流水线,但在提交前检查对应计数器是否已归零。这种方法的优势是不需要显式的推测执行和回滚机制——后续操作只是延迟提交,而不是推测执行后可能需要冲刷。
在实际处理器中,fence的实现还需要考虑与其他微架构机制的交互:
Fence与ROB的交互:fence在ROB中占据一个条目。在宽发射处理器中,频繁的fence指令会消耗ROB容量,缩小有效的乱序执行窗口。
Fence与分支预测的交互:如果fence位于一个预测错误的分支路径上,整个fence及其排序效果都会被撤销。这意味着fence不应该在分支预测错误恢复之前影响Store Buffer的提交行为。
多个fence的合并:如果流水线中有多个连续的fence指令,它们可以被合并为一个等效的fence,其约束是所有单独fence约束的并集。这种优化减少了fence对ROB容量的消耗。
Fence优化技术
在实际软件中,fence指令的使用频率可能很高——特别是在弱内存模型架构上的多线程程序中。因此,减少fence的性能影响是处理器设计的重要优化方向。
Fence消除(Fence Elision)
某些fence指令在特定的微架构状态下是冗余的——即使不执行该fence,内存操作的顺序也不会违反一致性模型的要求。例如:
如果fence之前没有未完成的内存操作(Store Buffer为空且所有Load已退休),则该fence不产生任何排序效果,可以安全消除。
如果两条fence之间没有任何内存操作,后一条fence与前一条等效,可以消除后者。
在单核场景下(系统检测到只有一个活跃核心),所有fence都可以被消除——因为单核不存在跨核心的可见性问题。
Fence延迟隐藏(Fence Latency Hiding)
即使fence不能被消除,其延迟可以通过以下技术部分隐藏:
提前开始排空:当译码器看到一条fence指令时,立即通知Store Buffer开始加速提交(例如提高Store提交的优先级),而不等到fence到达发射阶段。这样当fence实际需要检查Store Buffer状态时,大部分Store可能已经提交完毕。
与非内存指令并行:fence只约束内存操作,不约束寄存器-寄存器计算指令。fence之后的ALU指令可以在fence等待期间正常执行,不受fence影响。
fence推测执行:在非阻塞式实现中,fence之后的Load推测性执行,在大多数情况下推测结果有效,fence的有效延迟接近于零。
案例研究 2 — Fence频率对性能的影响
在一个ARMv8处理器上运行不同同步密度的工作负载,测量DMB指令对IPC的影响:
每1000条指令1个DMB(典型的应用级多线程程序):IPC下降1%,fence几乎不可见。
每100条指令1个DMB(内核密集同步路径):IPC下降3%8%,取决于DMB类型(ISHLD vs ISH)。
每10条指令1个DMB(极端情况,如高频原子操作循环):IPC下降20%40%,fence成为主要瓶颈。
结论:对于绝大多数实际工作负载,fence的性能影响可以忽略不计。只有在极端的同步密集路径中(如自旋锁的busy-wait循环),fence优化才具有显著意义。
内存模型的硬件验证
内存一致性模型的正确实现是处理器设计中最困难的验证挑战之一。内存模型的bug通常表现为极其罕见的多核竞态条件——可能在运行数十亿条指令后才触发一次,使得传统的功能仿真几乎无法发现。
验证内存模型正确性的主要方法包括:
石蕊测试(Litmus Testing):运行预定义的小型多线程测试程序(石蕊测试),检查是否出现了一致性模型禁止的执行结果。例如,在TSO处理器上运行Store Buffer石蕊测试,确认Load-Load重排序不会发生。RISC-V社区维护的
riscv-litmus-tests和ARM的herd7工具集提供了数百个石蕊测试用例。形式化验证:使用数学方法证明微架构实现满足ISA定义的内存模型。例如,使用TLA+或Isabelle/HOL对Store Buffer、Load Queue和一致性协议的交互进行形式化建模,证明所有可能的执行路径都符合目标内存模型。
随机压力测试:使用多线程随机指令生成器在多个核心上同时执行随机的Load/Store/fence序列,检查结果是否违反一致性模型。这种方法虽然不能证明正确性,但可以暴露大量的边界条件bug。
硬件性能计数器监控:在实际芯片上使用性能计数器监控Load顺序违规的频率、Store Buffer排空延迟等指标,检测是否存在异常行为。
设计提示
内存模型的bug在处理器发布后极难修复(通常需要微码更新或硬件回避措施),且可能导致严重的安全问题。Intel在2018年披露的部分Spectre变种与内存模型的推测执行行为直接相关。因此,内存模型的验证在流片前的验证工作中应获得最高优先级。经验法则是:至少运行10,000个石蕊测试用例,每个用例在至少4个不同的核心配置和缓存状态初始条件下执行。
原子操作的实现
原子操作(Atomic Operation)是多核处理器中实现同步原语(如锁、信号量、无锁数据结构)的基础。原子操作保证一个读-修改-写(Read-Modify-Write,RMW)序列在执行过程中不会被其他核心的内存操作打断——整个RMW序列要么全部完成,要么全部不发生。
从硬件角度看,原子操作的核心挑战是:如何在多核共享的Cache层次结构中保证RMW序列的不可分割性?不同的ISA和不同的微架构采用了不同的策略来解决这个问题——从x86的Cache行锁定到ARM/RISC-V的LL/SC机制,再到近年来的近缓存原子操作。这些策略在原子性保证的强度、硬件复杂度、高竞争场景下的性能以及对一致性总线影响等方面各有权衡。
原子操作在软件层面的重要性不言而喻:操作系统内核中的自旋锁(spinlock)、引用计数(reference counting)、无锁队列(lock-free queue)、读-拷贝-更新(RCU)等核心机制都依赖原子操作。在高并发服务器工作负载中,原子操作的性能往往是影响整体吞吐量的关键因素——Linux内核的性能分析显示,在高负载下,原子操作可以占据总CPU时间的5%15%。
从硬件设计的角度看,原子操作的性能由以下因素决定:
Cache一致性延迟:原子操作需要获取目标Cache行的独占权限(M状态),这涉及一致性协议的invalidation/ack往返。在跨NUMA节点的场景下,这个延迟可达200400周期。
锁定/重试开销:CAS的Cache行锁定和LL/SC的重试循环都带来了额外的开销。在高竞争下,这些开销可能数倍于无竞争时的基准延迟。
排序约束的代价:带有acquire/release语义的原子操作需要额外的排序保证,限制了流水线的乱序执行自由度。
一致性总线带宽:频繁的原子操作产生大量的一致性协议消息(invalidation、ack、data transfer),可能饱和一致性互联的带宽。
表 表 37.16列出了不同场景下原子操作的典型延迟范围。
| 场景 | 延迟(周期) | 主要瓶颈 |
|---|---|---|
| L1命中,无竞争 | 420 | RMW执行 + 排序约束 |
| 同Cluster核心竞争 | 3080 | L2一致性仲裁 |
| 跨Cluster核心竞争 | 80200 | 片上网络延迟 |
| 跨NUMA节点竞争 | 200500 | 互联延迟(QPI/UPI) |
| 跨CXL设备 | 5002000 | CXL链路延迟 |
原子操作在不同场景下的典型延迟
本节讨论四种主要的原子操作实现方式。
LL/SC(Load-Linked/Store-Conditional)
LL/SC(Load-Linked/Store-Conditional,也称Load-Reserved/Store-Conditional,LR/SC)是RISC架构(MIPS、ARM、RISC-V、PowerPC)普遍采用的原子操作原语。其基本思想是将RMW操作拆分为两条指令:
LL/LR(Load-Linked / Load-Reserved):从指定地址加载数据,并在该地址上设置一个保留标记(reservation)。
SC(Store-Conditional):向指定地址写入数据,但只有在保留标记仍然有效时才成功。如果保留标记在LL和SC之间被清除(例如因为其他核心写入了该地址),SC失败并返回一个失败标志。
软件通过检查SC的返回值来决定是否需要重试整个LL/SC序列。一个典型的原子加法操作的实现如下(RISC-V汇编):
retry:
lr.w a0, (a1) # Load-Reserved: a0 = *a1, 设置reservation
add a0, a0, a2 # a0 = a0 + a2
sc.w a3, a0, (a1) # Store-Conditional: 若reservation有效,
# *a1 = a0, a3 = 0 (成功)
# 否则 a3 = 1 (失败)
bnez a3, retry # 若SC失败,重试图图 37.8展示了LL/SC机制在微架构中的实现细节。
LL/SC的硬件实现核心是每个核心的Reservation监视器(Reservation Monitor):
硬件描述 7 — Reservation Monitor的硬件结构
Reservation Monitor是一个小型的硬件结构,包含以下组件:
保留地址寄存器(Reserved Address Register):存储LR指令保留的物理地址。通常存储的是Cache行粒度的地址(即忽略行内偏移),因为一致性协议以Cache行为单位进行失效通知。
有效位(Valid Bit):指示当前是否有活跃的reservation。LR指令置位有效位,以下事件清除有效位:
收到来自一致性协议的invalidation/snoop请求,且地址与保留地址匹配
SC指令执行成功(SC成功后reservation自动清除)
上下文切换或中断发生(在大多数实现中)
另一条LR指令执行(替换旧的reservation)
粒度(Granularity):reservation的粒度通常是一个Cache行(64字节)。RISC-V规范要求reservation set的大小不小于自然对齐的字长,但不大于一个Cache行。较大的粒度可能导致伪失败(spurious failure)——SC因为同一Cache行上不相关的Store而失败,即使保留的特定字节没有被修改。
Reservation Register的详细实现
Reservation Register的硬件实现看似简单(一个地址寄存器加一个有效位),但在乱序处理器中,其与Cache一致性协议的交互需要精心设计。
地址匹配粒度的选择是一个关键的设计决策。Reservation的地址匹配可以在不同粒度上进行:
字粒度(4或8字节):最精确的匹配——只有对LL指定的精确字地址的写入才会清除reservation。这避免了false sharing导致的虚假失败,但硬件实现复杂:一致性协议通常以Cache行为单位发送invalidation消息,需要额外的逻辑从Cache行地址中提取字级别的失效信息。
Cache行粒度(通常64字节):将reservation地址截断到Cache行边界。任何对同一Cache行的写入(即使写入的是行内不同的字)都会清除reservation。硬件实现简单——直接用一致性协议的invalidation消息来清除reservation。但可能导致false sharing:同一Cache行中不相关的变量被其他核心修改时,会导致SC虚假失败。
折中方案:某些实现使用半行粒度(32字节),在精确度和实现复杂度之间取得平衡。
RISC-V规范使用Reservation Set的概念来描述粒度:reservation set是一组地址,LR指令设置的reservation覆盖该组中的所有地址。规范要求reservation set的大小至少包含LR/SC操作数的自然对齐字长,但最大不超过一个Cache行。这给了硬件实现者灵活的选择空间。
SC失败条件的完整分析
SC指令失败(返回非零值)的条件可以分为两大类:必要失败(因为原子性被破坏而必须失败)和虚假失败(因为硬件实现的保守性而不必要地失败)。
硬件描述 8 — SC失败条件的分类
必要失败条件:
其他核心写入reservation地址:最基本的失败条件。另一个核心对reservation set内的地址执行了Store操作,通过一致性协议发送invalidation消息,清除了本核心的reservation。
DMA写入reservation地址:I/O设备通过DMA写入了reservation set内的地址。这通常通过一致性域的snoop机制检测。
虚假失败条件:
Cache行替换:reservation地址所在的Cache行被LRU等替换策略逐出,导致一致性状态丢失。即使没有其他核心写入该地址,Cache行的替换也会清除reservation(因为替换后该行不再有本地副本,无法监控外部写入)。
中断或异常:在LR和SC之间发生了中断或异常。大多数实现在上下文切换时清除所有reservation,因为中断处理程序可能修改内存状态。RISC-V规范允许但不要求在中断时清除reservation。
同一Cache行上的无关写入(false sharing):当reservation粒度为Cache行时,另一个核心写入了同一Cache行中的不同变量,通过invalidation清除了reservation,但LL/SC操作的目标变量实际上未被修改。
TLB缺失导致的替换:在某些实现中,TLB缺失处理可能导致Cache行的状态变化,间接清除reservation。
另一条LR指令覆盖:同一核心执行了另一条LR指令,新的reservation覆盖了旧的reservation。大多数实现只支持一个活跃的reservation。
虚假失败的存在要求软件始终在循环中使用LL/SC。RISC-V规范明确要求:SC指令必须在LR/SC循环中使用,且不能假设SC一定成功。
LL/SC天然免疫ABA问题
ABA问题是CAS操作的一个著名缺陷,而LL/SC天然免疫此问题。为了说明这一点,考虑以下场景:
一个无锁栈(lock-free stack)使用CAS实现pop操作。栈顶指针指向节点ABC。线程1执行pop操作,读取栈顶为A,准备用CAS将栈顶更新为B。但在CAS执行之前,线程2连续执行了两次pop(弹出A和B)和一次push(压入A),此时栈为AC。线程1的CAS检查栈顶仍为A(值匹配),成功将栈顶更新为B——但B已经被弹出,栈的结构被破坏。这就是ABA问题。
如果使用LL/SC实现同样的pop操作:
retry:
lr.d a0, (sp_top) # Load-Reserved: a0 = 栈顶节点地址
ld a1, 0(a0) # a1 = 栈顶节点的next指针
sc.d a2, a1, (sp_top) # Store-Conditional: 将栈顶更新为next
bnez a2, retry # SC失败则重试
# a0 = 弹出的节点在上述ABA场景中,即使线程2将A重新压入栈,线程1的SC仍会失败——因为线程2的push操作写入了栈顶指针(与LR保留的地址相同),通过一致性协议的invalidation清除了线程1的reservation。LL/SC的reservation机制检测的是"是否有任何写入发生",而不是"值是否改变",因此天然免疫ABA问题。
LL/SC机制相比CAS还有以下特性值得注意:
可能虚假失败:SC可能在没有其他核心写入的情况下失败(如上述分析的虚假失败条件)。软件必须在循环中使用LL/SC以处理虚假失败。
LR/SC之间的指令限制:为了保证SC的成功率和避免死锁,ISA通常对LR和SC之间可以执行的指令数量和类型施加限制。RISC-V规范要求LR和SC之间的代码序列不超过16条指令,不包含系统调用、fence指令或backward分支。
前向进展保证:RISC-V规范要求:如果软件遵守上述指令限制,且LR和SC操作同一地址,则在没有其他核心竞争的情况下,SC最终一定会成功。这个前向进展保证防止了活锁(livelock)——如果所有核心都因为虚假失败而不断重试,系统可能永远无法取得进展。硬件实现前向进展保证的常见方法是:当检测到LR/SC重试次数超过阈值时,暂时禁止其他核心对该Cache行的访问(类似于短暂的Cache行锁定)。
性能分析 7 — LL/SC的性能特征
LL/SC的性能取决于两个关键因素:
SC成功率:在低竞争场景下,SC的成功率接近100%,LL/SC的总延迟接近一次Load加一次Store的延迟(约815周期)。在高竞争场景下(多个核心同时尝试原子操作同一地址),SC成功率可能降至50%以下,导致频繁重试。
Reservation粒度的影响:Cache行粒度的reservation可能导致伪失败。假设一个64字节Cache行中有16个4字节变量,当两个核心分别原子操作其中不同的变量时,它们的reservation会相互失效(false sharing),导致大量不必要的重试。这种情况在密集的原子计数器数组中尤为严重。
优化建议:在高竞争场景下,使用指数退避(exponential backoff)策略在SC失败后等待随机时间再重试,可以显著提高整体吞吐量。RISC-V规范建议硬件实现提供前向进展保证(forward progress guarantee)——在没有其他核心竞争的情况下,LL/SC序列最终一定会成功。
在乱序处理器中,LL/SC的实现还面临一个微妙的问题:LL/SC与推测执行的交互。考虑以下场景:一条LR指令被推测性地发射(例如在一个未解析的分支之后),它设置了reservation。如果分支预测错误,该LR指令应当被撤销,但reservation是否也应当被撤销?
不同的实现采用不同的策略:
保守策略:LR指令在提交阶段(退休时)才设置reservation。这保证了只有确定执行的LR才会设置reservation,但增加了LR到SC之间的延迟(因为reservation的设置被推迟了)。
激进策略:LR指令在执行阶段就设置reservation,但如果LR被撤销(分支预测错误或异常),reservation被显式清除。这种策略的延迟更低,但需要在分支恢复逻辑中增加reservation清除的处理。
折中策略:LR指令在执行阶段设置reservation,但reservation包含一个"推测位"。只有当LR退休时,推测位才被清除,reservation正式生效。如果LR被撤销,推测位保持置位,后续SC检查推测位时会判定reservation无效。
硬件描述 9 — LL/SC在乱序流水线中的完整执行流程
以RISC-V的lr.w/sc.w在一个6级流水线乱序处理器中的执行为例:
LR指令的执行:
译码:LR被识别为Load类指令,附带reservation设置标志。分配Load Queue条目。
发射:LR像普通Load一样被发射到内存执行端口。
地址计算:AGU计算目标物理地址PA。
Cache访问:向L1 Cache发送Load请求,同时通过一致性协议确保该Cache行至少处于Shared(S)或Exclusive(E)状态。读取旧值。
设置Reservation:将PA写入Reservation Register,设置有效位。如果使用推测策略,同时设置推测位。
退休:LR退休时,如果使用推测策略,清除推测位——reservation正式生效。如果使用保守策略,此时才设置reservation。
SC指令的执行:
译码:SC被识别为条件Store指令。分配Store Buffer条目和目标寄存器(用于返回成功/失败标志)。
发射:SC被发射到内存执行端口。
Reservation检查:检查Reservation Register——有效位是否置位?地址是否与SC目标地址在同一reservation set内?推测位是否已清除?
成功路径:如果reservation有效,通过一致性协议将Cache行升级到M状态(如果还不是),将数据写入Cache,返回成功标志(0),清除reservation。
失败路径:如果reservation无效,不执行任何Cache写入,返回失败标志(1)。SC的Store Buffer条目被丢弃。
退休:SC退休,如果成功,Store数据已提交到Cache;如果失败,没有任何副作用。
注意:SC成功时需要获取Cache行的独占权限(M状态),这个过程与普通Store完全相同——通过一致性协议发送invalidation请求。但SC增加了一个额外的条件检查(reservation有效性),如果检查失败,可以跳过整个写入过程,节省一致性协议的开销。
LL/SC的多核竞争与退避策略
当多个核心同时对同一地址执行LL/SC操作时,竞争导致SC失败率急剧上升。在核同时竞争的情况下,理论上每次SC成功的概率约为(假设各核心的时序均匀分布)。这意味着平均需要次尝试才能成功一次。
性能分析 8 — LL/SC在不同竞争程度下的性能
在一个8核RISC-V处理器上,对同一地址执行原子递增操作的性能测量:
1核(无竞争):SC成功率100%,平均延迟8周期,吞吐量500M ops/s。
2核竞争:SC成功率50%,平均延迟25周期,吞吐量160M ops/s。
4核竞争:SC成功率25%,平均延迟60周期,吞吐量130M ops/s。
8核竞争:SC成功率12.5%,平均延迟150周期,吞吐量85M ops/s。
可以看到,随着竞争核心数的增加,LL/SC的吞吐量急剧下降。在8核竞争时,吞吐量仅为无竞争时的17%——大量的CPU时间被浪费在失败重试上。
为了缓解高竞争下的性能问题,软件可以使用指数退避(exponential backoff)策略:SC失败后,线程等待一个随机时间再重试,等待时间随着连续失败次数指数增长。这减少了多个核心同时重试的概率,提高了整体的SC成功率。
RISC-V规范还要求硬件提供最终前向进展保证:在合理的条件下(遵守LR/SC之间的指令限制),即使在竞争下,每个LL/SC循环最终一定会成功。硬件实现这一保证的常见方法包括:
仲裁优先级轮转:当多个核心同时尝试SC时,一致性协议的仲裁器使用轮转优先级(round-robin),确保每个核心最终都能获得写权限。
短暂的Cache行保留:当检测到某个核心的SC连续失败超过阈值时,一致性协议暂时"保护"该核心的Cache行——在一个短时间窗口内拒绝其他核心的invalidation请求,确保该核心的SC成功。
重试计数器:每个核心维护一个LR/SC重试计数器。当计数器超过阈值时,向一致性协议发送"高优先级"请求,获得更高的仲裁优先级。
CAS(Compare-And-Swap)
CAS(Compare-And-Swap)是x86架构采用的原子操作原语,通过CMPXCHG(Compare and Exchange)指令实现。CAS是一条单指令RMW操作:它比较内存中的当前值与一个期望值(expected value),如果相等,则将新值写入内存;否则不修改内存,并返回内存中的当前值。
CAS的硬件实现比LL/SC更直接,因为整个RMW操作封装在一条指令中:
硬件描述 10 — CAS的硬件实现——Cache行锁定
x86的CMPXCHG指令(以及所有带lock前缀的指令)通过Cache行锁定(Cache Line Locking)来保证原子性:
获取独占权限:指令执行前,处理器通过一致性协议将目标Cache行的状态升级为Modified(M)状态。在MESI协议中,这意味着发出Invalidation请求,使所有其他核心的该Cache行副本失效。
锁定Cache行:在RMW操作执行期间,禁止对该Cache行的任何外部snoop请求。具体实现中,L1 Cache控制器在锁定期间对来自一致性总线的snoop请求返回"重试"(retry)或"延迟"(defer)响应。
执行读-修改-写:在Cache行被锁定的情况下,处理器执行完整的RMW操作——读取旧值、比较、条件写入——整个过程在L1 Cache控制器内完成。
释放锁定:RMW操作完成后,解除Cache行的锁定,恢复正常的snoop响应。
Cache行锁定的持续时间通常只有几个周期(RMW操作的执行延迟),但在此期间,任何其他核心对该Cache行的访问都会被延迟。
Cache行锁定的微架构细节
Cache行锁定的核心机制是在RMW操作期间,L1 Cache控制器暂时拒绝对目标Cache行的外部snoop请求。具体的实现涉及以下步骤和硬件结构:
获取独占权限(Acquire Exclusive):在执行
lock前缀指令之前,处理器通过一致性协议发出RFO(Read For Ownership)请求,将目标Cache行升级到MESI的M状态。如果该Cache行当前在其他核心中处于S或E状态,这些核心会收到invalidation消息并失效其副本。RFO请求的延迟取决于当前Cache行的状态和所在位置——如果已在本核心L1中处于M/E状态,延迟为0周期;如果在其他核心L1中,延迟约为2040周期(核间一致性延迟);如果在LLC或内存中,延迟可达100200周期。设置锁定标志:Cache控制器在目标Cache行的Tag中设置一个临时的锁定位(lock bit)。当锁定位被设置时,该Cache行对外部snoop请求返回"retry"(NACK)响应——请求者被告知稍后重试。
执行RMW操作:在锁定保护下,L1 Cache控制器完成读-修改-写序列。对于
CMPXCHG,序列是:读取旧值与期望值比较如果相等则写入新值。整个序列通常在23个周期内完成。清除锁定标志:RMW操作完成后,清除锁定位,恢复正常的snoop响应。之前被NACK的请求者会在稍后重新发起snoop请求。
锁定期间NACK其他核心snoop请求的机制有一个重要的性能含义:如果多个核心频繁竞争同一个原子变量,NACK会导致大量的snoop重试,增加一致性总线的流量和延迟。在极端情况下,这可能导致一致性总线饱和——一致性总线的大部分带宽被NACK和重试消耗,影响系统中其他不相关的Cache一致性操作。
性能分析 9 — x86 lock前缀指令的延迟分析
以Intel Skylake微架构上的lock cmpxchg [mem], reg为例,其延迟可以分解为:
无竞争、Cache命中(L1 M/E状态):约1820个周期。主要延迟来自流水线内部的串行化——lock前缀指令需要等待Store Buffer中所有先前的Store提交完毕(隐含全屏障),然后执行RMW,再等待写入完成。
无竞争、Cache未命中(需要RFO):约4080个周期。额外延迟来自RFO请求获取Cache行独占权限。
有竞争(多核同时执行lock指令):约100300个周期。延迟高度不确定,取决于竞争的核心数量和一致性协议的仲裁策略。每次lock操作需要先获取独占权限(可能被其他核心的lock操作NACK),执行RMW,释放锁定后其他核心才能获取权限。
一个重要的优化是:如果lock cmpxchg的比较失败(期望值不匹配),某些实现会跳过写入操作——Cache行不需要升级到M状态(E或S状态即可满足读取需求)。这意味着CAS失败的延迟可能低于CAS成功的延迟。软件可以利用这一点:在高竞争场景下,先用普通Load检查值是否匹配("test-and-test-and-set"模式),只有在匹配时才执行CAS,避免不必要的RFO请求。
在现代x86处理器中,lock前缀的指令不仅提供原子性保证,还隐含了全内存屏障(full memory barrier)的语义——即lock前缀指令之前的所有内存操作在其之后的所有内存操作之前完成。这就是为什么Linux内核的smp_mb()在x86上经常使用lock; addl $0, (%rsp)——一条语义上无操作的lock前缀加法,但提供了全屏障语义。
lock前缀指令的全屏障语义意味着:在x86上,每条lock前缀指令执行时都会(1)等待Store Buffer中所有先前的Store提交到Cache;(2)执行原子RMW操作;(3)确保RMW的结果对所有核心可见后才允许后续操作继续。这三步合在一起提供了比MFENCE更强的排序保证——不仅保证了Store-Load序,还保证了原子性。
CAS相比LL/SC的主要优势是不会虚假失败——CAS要么成功(值匹配且写入完成),要么失败(值不匹配),不存在LL/SC那样的因无关事件导致的虚假失败。但CAS也有一个著名的缺陷:ABA问题。当一个值从A变为B再变回A时,CAS无法检测到这个中间变化,会错误地认为值未被修改。解决ABA问题通常需要软件层面的技术,如使用带版本号的指针(tagged pointer)。
CMPXCHG16B与ABA问题的硬件解决
x86提供了CMPXCHG8B(8字节CAS)和CMPXCHG16B(16字节CAS,也称double-width CAS)指令。CMPXCHG16B可以原子地比较和交换一个128位值,常用于实现带版本号的指针以解决ABA问题——将64位指针和64位版本号打包在一起,通过CMPXCHG16B原子地更新。
从硬件角度看,CMPXCHG16B需要原子地读取和写入两个相邻的64位内存位置,这要求目标数据不能跨越Cache行边界(否则需要锁定两个Cache行,复杂度大幅增加)。x86要求CMPXCHG16B的操作数必须16字节对齐,保证数据位于同一Cache行内。
使用CMPXCHG16B解决ABA问题的方案如下:
; 无锁栈pop操作,使用版本号防止ABA
; [stack_top] = {ptr: 64位指针, ver: 64位版本号}
retry:
mov rax, [stack_top] ; 读取当前指针
mov rdx, [stack_top + 8] ; 读取当前版本号
mov rbx, [rax] ; rbx = next = top->next
lea rcx, [rdx + 1] ; rcx = 新版本号 = ver + 1
lock cmpxchg16b [stack_top] ; 原子比较并交换128位
; if (top==rax:rdx) then
; top = rbx:rcx; ZF=1
; else rax:rdx = top; ZF=0
jnz retry ; CAS失败则重试由于版本号在每次修改时都递增,即使指针值回到原来的A,版本号已经改变(从变为),CMPXCHG16B的128位比较会发现不匹配,从而正确地检测到ABA变化。
设计提示
从微架构角度看,CAS(Cache行锁定)和LL/SC(Reservation Monitor)的硬件代价有本质区别。CAS的代价主要在一致性协议层面——锁定期间抑制snoop响应增加了一致性总线的延迟。LL/SC的代价主要在核心内部——需要维护Reservation Monitor并在snoop时检查reservation匹配。对于高竞争场景,CAS的锁定机制可能导致一致性总线的拥塞;而LL/SC的重试机制虽然增加了指令数,但不会锁定一致性总线。
ARM LSE原子指令
ARMv8.1引入了大系统扩展(Large System Extensions,LSE),增加了一组单指令原子操作——包括LDADD(原子加法)、LDCLR(原子位清除)、LDSET(原子位设置)、LDEOR(原子异或)、SWP(原子交换)和CAS(比较并交换)。这些指令将RMW操作封装在单条指令中,避免了LL/SC的重试开销和虚假失败问题。
ARM LSE原子指令的一个重要创新是其近缓存执行(near-cache execution)的实现方式:
硬件描述 11 — ARM LSE的近L1 Cache执行
在高性能ARM处理器(如Cortex-X2及后续设计)中,LSE原子指令的RMW操作直接在L1 Cache控制器中执行,而非在核心流水线的ALU中执行:
指令发射:流水线将LSE原子指令发射到内存执行端口(AGU/LSU)。
地址计算:计算目标地址并发送到L1 Cache控制器。
Cache控制器执行RMW:L1 Cache控制器内部集成了一个简单的ALU(支持加法、逻辑运算、比较-交换),直接在Cache SRAM的读端口输出上执行运算,然后将结果写回同一Cache行。整个RMW过程在Cache控制器内完成,不需要将数据送回核心流水线。
返回旧值:如果指令需要返回旧值(如
LDADD返回加法前的旧值),旧值通过Load数据路径送回核心。
这种近缓存执行的优势:
减少流水线占用:RMW操作不占用核心的ALU和旁路网络资源。
降低延迟:数据不需要在核心流水线和Cache之间往返传输,减少了12周期的延迟。
天然原子性:Cache控制器在执行RMW期间独占Cache行的访问权,天然保证了原子性,不需要额外的锁定机制。
LSE原子指令还支持acquire/release语义的变体。例如:
LDADD:无序原子加法——RMW操作不提供任何排序保证。LDADDA:具有acquire语义的原子加法——RMW之后的操作不能重排到RMW之前。LDADDL:具有release语义的原子加法——RMW之前的操作不能重排到RMW之后。LDADDAL:同时具有acquire和release语义的原子加法(最强排序),等效于SC语义的原子操作。
这种细粒度的排序控制允许软件在不需要全屏障的场景下使用更弱的排序,从而获得更好的性能。
LSE近缓存ALU的设计细节
L1 Cache控制器中集成的ALU需要支持LSE定义的所有运算类型。表 表 37.17列出了LSE定义的原子操作及其ALU需求。
| 指令 | 操作 | ALU运算 | 返回值 |
|---|---|---|---|
LDADD | 原子加法 | 加法器 | 旧值 |
LDCLR | 原子位清除 | AND NOT | 旧值 |
LDSET | 原子位设置 | OR | 旧值 |
LDEOR | 原子异或 | XOR | 旧值 |
LDMAX/LDMIN | 原子最大/最小 | 比较+选择 | 旧值 |
SWP | 原子交换 | 直通(bypass) | 旧值 |
CAS | 比较并交换 | 比较器 | 旧值 |
ARM LSE原子操作及其ALU需求
这个ALU的硬件面积相当小——大部分运算只需要基本的逻辑门和一个32/64位加法器。关键的设计考量是ALU的端口与Cache SRAM读写端口的集成:
在普通Load/Store操作中,Cache SRAM的读端口连接到Load数据路径,写端口连接到Store Buffer的提交路径。
在LSE原子操作中,需要在同一个周期内完成"读ALU运算写回"的流程。这要求Cache SRAM提供一个专用的读-修改-写端口(RMW port),或者通过时间复用(time-multiplexing)在连续的周期中完成读和写操作。
高性能实现通常选择增加一个RMW端口,使得原子操作可以在单个Cache访问周期内完成。面积开销约增加Cache SRAM的5%10%(主要是额外的读/写驱动器和多路选择器)。
LSE与LL/SC的共存策略
ARMv8.1引入LSE后,ARM处理器需要同时支持旧的LL/SC(LDXR/STXR)和新的LSE指令。在同一个处理器中,两种机制的硬件实现需要和平共存:
当核心A使用
LDADD执行原子加法时,如果核心B同时对同一地址执行LDXR(LL),核心B的reservation必须被正确地通过一致性协议失效——即使核心A的操作是在Cache控制器内完成的。LSE指令的Cache行锁定期间,必须正确地向一致性总线发送invalidation消息,以清除其他核心的LL/SC reservation。
编译器和运行时可以根据CPU能力标志(CPUID/HWCAP)动态选择使用LSE还是LL/SC。glibc在ARM64上会检测
HWCAP_ATOMICS标志,如果支持则使用LSE指令。
案例研究 3 — LL/SC vs LSE在高竞争场景下的性能
在一个8核ARM处理器上,对同一个原子计数器进行递增操作的基准测试显示:
LL/SC实现(
ldxr/stxr循环):当8个核心同时竞争时,SC成功率降至约12.5%(平均每8次尝试成功1次),总吞吐量约为80M ops/s。LSE实现(
LDADD指令):由于不存在重试循环,总吞吐量约为200M ops/s——相比LL/SC提升约2.5倍。延迟分布:LL/SC的延迟高度不均匀(长尾效应显著,最坏情况下可能需要数十次重试);LSE的延迟相对稳定(主要由Cache一致性延迟决定)。
结论:在高竞争场景下,LSE的单指令原子操作由于消除了LL/SC的重试开销,在吞吐量和延迟可预测性方面都有显著优势。这也是ARMv8.1引入LSE的主要动机之一——大型服务器系统中,高竞争的原子操作是常见的性能瓶颈。
RISC-V AMO指令
RISC-V的A扩展(Atomic Extension)定义了一组原子内存操作(Atomic Memory Operation,AMO)指令,包括amoswap(原子交换)、amoadd(原子加法)、amoand(原子与)、amoor(原子或)、amoxor(原子异或)、amomin/amomax(原子最小/最大值)等。这些AMO指令与ARM LSE类似,将RMW操作封装在单条指令中。
RISC-V AMO指令的编码中包含两个重要的修饰位:
.aq(acquire位):当置位时,AMO指令具有acquire语义——AMO之后的内存操作不能重排到AMO之前。
.rl(release位):当置位时,AMO指令具有release语义——AMO之前的内存操作不能重排到AMO之后。
.aq.rl(同时置位):提供顺序一致性(SC)语义——AMO既是acquire又是release,形成一个完整的双向屏障。
RISC-V AMO的硬件实现有两种主要策略,选择哪种策略取决于核心的性能目标和面积预算。
策略一:Cache控制器内执行
与ARM LSE类似,在L1 Cache控制器中集成一个简单ALU,直接在Cache SRAM上执行RMW操作。这种方式延迟最低,但需要修改L1 Cache控制器的设计。Cache控制器内执行的优势在于:
天然原子性:Cache控制器在执行RMW期间独占Cache行的访问权,不需要额外的锁定协议。外部snoop请求在RMW期间被延迟响应。
不占用核心流水线资源:AMO操作在Cache控制器中独立完成,核心的ALU和旁路网络可以同时执行其他指令。
确定性延迟:Cache命中时,AMO操作的延迟固定(通常46周期),不受SC重试的影响。
策略二:LR/SC微操作分解
AMO指令被拆分为微操作序列:LR(Load-Reserved) ALU操作 SC(Store-Conditional)。这种方式不需要修改Cache控制器,但占用更多的流水线资源,且可能需要重试(如果SC失败)。
这种策略的实现流程如下:
译码阶段将
amoadd拆分为3条微操作:LR、ADD、SC。LR执行Load-Reserved操作,设置reservation并将旧值读入临时寄存器。
ADD在核心ALU中执行加法运算。
SC执行Store-Conditional,将结果写回内存。
如果SC失败,硬件从LR开始重新执行整个序列。
两种策略的权衡见表 表 37.18。
| 特性 | Cache控制器内执行 | LR/SC微操作分解 |
|---|---|---|
| Cache控制器改动 | 需要增加ALU | 无需修改 |
| 核心流水线占用 | 不占用ALU | 占用13个ALU周期 |
| 延迟(无竞争) | 46周期 | 610周期 |
| 延迟(有竞争) | 4080周期 | 不确定(取决于重试) |
| 虚假失败 | 无 | 有(继承LR/SC特性) |
| 面积开销 | Cache控制器增加5% | 无额外面积 |
| 适用核心 | 高性能核心 | 面积敏感核心 |
RISC-V AMO两种实现策略的对比
硬件描述 12 — RISC-V AMO在Cache控制器中的执行流程
以amoadd.w a0, a1, (a2)为例(将地址a2处的值与a1相加,结果写回a2,旧值返回a0):
周期1:地址计算:AGU计算目标物理地址
PA = translate(a2)。周期2:Cache查找:L1 Cache控制器查找目标地址,获取Cache行状态。如果是M/E状态,直接进入步骤3;否则通过一致性协议获取独占权限(可能需要多个周期)。
周期3:读取旧值:从Cache SRAM读取目标地址的当前值。
周期4:ALU运算:Cache控制器内的ALU执行加法运算
new_val = old_val + a1。周期5:写回Cache:将
new_val写入Cache SRAM,同时将old_val通过Load数据路径返回核心。周期6:完成:指令完成,
a0 = old_val。如果有.aq位,标记后续操作的排序约束;如果有.rl位,确保先前操作已完成。
整个过程中Cache行保持锁定状态(不响应外部snoop),保证了原子性。
RISC-V的LR/SC与AMO指令之间存在一个重要的功能区别:LR/SC可以实现任意的RMW操作(因为在LR和SC之间可以执行任意计算),而AMO只支持预定义的操作(加法、逻辑运算等)。因此,对于复杂的原子操作(如原子的双重比较-交换、原子的链表插入),仍然需要使用LR/SC。
AMO的.aq/.rl修饰位的硬件实现
AMO指令的.aq和.rl修饰位在Cache控制器内执行时需要特殊处理:
无修饰(
amoadd.w):AMO操作不提供任何排序保证。Cache控制器执行RMW后,后续Load可以立即执行,先前的Store不需要先提交。这是性能最高的变体。.aq修饰(
amoadd.w.aq):AMO操作具有acquire语义。Cache控制器完成RMW后,在Load Queue中插入一个屏障标记——后续Load不能在此AMO之前完成,后续Store不能在此AMO之前提交。但AMO之前的操作不受约束,可以在AMO之后重排。.rl修饰(
amoadd.w.rl):AMO操作具有release语义。Cache控制器在开始执行RMW之前,必须等待所有先前的Load完成和所有先前的Store提交。但AMO之后的操作不受约束。.aq.rl修饰(
amoadd.w.aqrl):同时具有acquire和release语义,等效于SC原子操作。这是代价最高的变体——需要等待先前操作完成(release),执行RMW,然后保证后续操作在RMW之后执行(acquire)。
硬件描述 13 — AMO .aq.rl的流水线交互
以amoadd.w.aqrl a0, a1, (a2)为例,该指令在乱序流水线中的执行涉及以下排序约束:
Release约束检查:指令到达发射队列后,检查所有程序顺序中在其之前的Load和Store是否已完成/已提交。如果未完成,该指令等待。
执行RMW:所有先前操作完成后,Cache控制器执行原子RMW操作。
Acquire约束标记:RMW完成后,在Load Queue和Store Buffer中标记后续操作的排序约束——后续内存操作不能在此AMO完成之前对外可见。
退休:AMO退休后,后续操作才被允许对外提交/完成。
在高性能实现中,acquire约束可以通过推测执行来部分隐藏——后续Load推测性地从Cache读取数据,但在AMO退休之前不"提交"其结果。如果AMO退休时后续Load的推测结果仍然有效(Cache行未被invalidate),则推测成功,无需重新执行。
RISC-V AMO指令集的完整列表
表 表 37.19列出了RISC-V A扩展定义的所有AMO指令及其典型应用场景。
| 指令 | 操作 | 语义 | 典型应用 |
|---|---|---|---|
amoswap | 原子交换 | *addr rs2 | 自旋锁 |
amoadd | 原子加法 | *addr += rs2 | 引用计数 |
amoand | 原子与 | *addr &= rs2 | 位图清除 |
amoor | 原子或 | `*addr | = rs2` |
amoxor | 原子异或 | *addr ^= rs2 | 位图翻转 |
amomin | 有符号最小值 | *addr = min(*addr, rs2) | 全局最小值统计 |
amomax | 有符号最大值 | *addr = max(*addr, rs2) | 全局最大值统计 |
amominu | 无符号最小值 | *addr = minu(*addr, rs2) | 无符号统计 |
amomaxu | 无符号最大值 | *addr = maxu(*addr, rs2) | 无符号统计 |
RISC-V A扩展AMO指令集
RISC-V A扩展还包含一个值得注意的设计决策:AMO指令不支持双宽度(double-width)原子操作。与x86的CMPXCHG16B不同,RISC-V的AMO在RV64上最大只支持64位(amoadd.d)操作。如果需要更宽的原子操作,必须通过LR/SC序列实现。这一设计选择简化了Cache控制器中ALU的实现——不需要支持128位运算。
AMO在Linux内核中的应用
RISC-V Linux内核大量使用AMO指令来实现核心的同步原语。以下是几个关键的映射关系:
atomic_add()amoadd.w:无序原子加法,用于不需要排序保证的引用计数递增。atomic_add_return()amoadd.w.aqrl:带返回值的原子加法,需要SC语义以保证返回值与后续操作的排序。atomic_fetch_or()amoor.w.aqrl:原子位设置,用于标志位的原子修改。xchg()amoswap.w.aqrl:原子交换,用于自旋锁的获取。cmpxchg()LR/SC序列:RISC-V没有单指令CAS,需要使用LR/SC循环实现。
设计提示
RISC-V没有提供单指令CAS(compare-and-swap)这一设计选择是有争议的。在Linux内核中,cmpxchg()是一个核心原语——RCU、futex、内存分配器等关键子系统都频繁使用CAS。在RISC-V上,CAS必须通过LR/SC循环实现,这带来了两个问题:(1)代码体积更大(需要循环和重试逻辑);(2)在高竞争场景下可能产生虚假失败和重试开销。RISC-V社区已经讨论过添加CAS指令的提案(Zacas扩展),但截至目前尚未成为正式标准的一部分。
设计提示
在RISC-V处理器设计中,选择AMO的实现策略时需要考虑核心的目标市场。对于高性能核心(如服务器处理器),Cache控制器内执行的策略是首选——它提供更低的延迟和更好的竞争性能。对于面积和功耗敏感的嵌入式核心,使用LR/SC微操作序列的策略更合适——它不需要在Cache控制器中增加ALU硬件,代价是每条AMO指令需要多条微操作。一种折中方案是在L1 Cache控制器中只支持最常用的AMO操作(如amoadd和amoswap),而将较少使用的操作(如amomin/amomax)通过LR/SC微操作序列实现。
近缓存原子操作
传统的原子操作在L1 Cache或核心流水线中执行,这意味着每次原子操作都需要将目标Cache行搬移到执行操作的核心的L1 Cache中。在高竞争场景下,同一个Cache行在多个核心之间频繁迁移("乒乓效应"),导致大量的一致性流量和延迟。
近缓存原子操作(Near-Cache Atomics / Near-Memory Atomics)的核心思想是:将原子操作的执行位置从核心移到数据所在的Cache层级——不移动数据到核心,而是将操作发送到数据所在的地方执行。
近缓存原子操作的主要实现层级有:
L2 Cache级别的原子操作
对于在多个核心之间频繁竞争的原子变量,将RMW操作放在共享的L2 Cache控制器中执行是最有效的。L2 Cache控制器集成一个简单的ALU,当收到核心发来的原子操作请求时,直接在L2 SRAM上执行RMW操作,无需将数据搬移到任何核心的L1 Cache。
这种方式的优势在高竞争场景下尤为显著:
消除Cache行迁移:原子变量始终驻留在L2 Cache中,不需要在核心间的L1 Cache之间来回迁移。这消除了一致性协议的invalidation/ack延迟。
序列化竞争访问:L2控制器自然地将来自不同核心的原子操作请求序列化,避免了一致性协议的竞争开销。
减少总线流量:原子操作请求(操作码 + 操作数)的数据量远小于完整Cache行(64字节),减少了互联网络的带宽消耗。
LLC(Last Level Cache)级别的原子操作
对于跨Cluster或跨NUMA节点的原子操作,可以在LLC控制器中执行。这在CXL(Compute Express Link)互联的异构系统中尤为重要——CXL 3.0规范已经支持近内存原子操作(Back-Invalidate Atomics),允许主机处理器将原子操作请求发送到CXL设备端的内存控制器执行。
LLC级别原子操作的实现比L2级别更复杂,因为LLC通常采用分布式设计(如Intel的Mesh互联中的分布式LLC slice)。原子操作请求首先需要通过地址哈希确定目标LLC slice,然后在该slice的控制器中执行RMW操作。整个过程涉及以下步骤:
核心发出原子操作请求,经过L1和L2(如果L1/L2中没有该Cache行的修改权限,或者系统判定应在LLC执行)。
请求经过片上网络(NoC)路由到拥有该地址的LLC slice。
LLC slice控制器将所有核心中该Cache行的副本失效(如果有的话)。
LLC slice中的ALU执行RMW操作。
结果通过NoC返回给请求核心,同时更新LLC中的数据。
LLC级别执行的延迟比L2级别更高(因为NoC传输延迟),但在跨Cluster竞争的场景下,避免了Cache行在不同Cluster之间的迁移,总体性能可能更优。
动态执行层级选择
一个先进的优化是动态执行层级选择(dynamic execution level selection)——硬件根据运行时的竞争程度自动选择原子操作的执行层级:
低竞争(单核或极少竞争):在L1 Cache中执行,延迟最低(46周期)。
中等竞争(同一Cluster内多核竞争):在共享L2 Cache中执行,避免L1之间的Cache行乒乓。
高竞争(跨Cluster竞争):在LLC中执行,避免Cluster间的一致性开销。
竞争程度的检测可以通过硬件计数器实现:为每个频繁访问的原子变量维护一个竞争计数器。当SC失败次数或Cache行invalidation频率超过阈值时,将该变量的原子操作"升级"到更高的Cache层级执行。当竞争降低时,再"降级"回L1执行。这种自适应机制在工作负载的竞争程度动态变化时(如服务器工作负载中的突发并发)特别有效。
性能分析 10 — 近缓存原子操作的性能收益
在一个4核Cluster共享L2 Cache的ARM处理器上,对同一原子计数器的递增操作:
L1 Cache级别执行(传统方式):Cache行在4个核心间迁移,每次原子操作平均延迟约40周期(包含一致性协议的invalidation/ack往返),吞吐量约100M ops/s。
L2 Cache级别执行(近缓存方式):操作请求发送到L2控制器,每次原子操作平均延迟约12周期(L2访问延迟 + ALU执行),吞吐量约330M ops/s。
加速比:近缓存方式相比传统方式,吞吐量提升约3.3倍,延迟降低约70%。
注意:近缓存原子操作的收益与竞争程度密切相关。在单核或低竞争场景下,L1 Cache级别执行可能更快(因为L1的访问延迟低于L2)。因此,有些实现会根据运行时的竞争程度动态选择执行层级——低竞争时在L1执行,高竞争时自动迁移到L2执行。
图 图 37.10展示了不同执行层级在不同竞争程度下的延迟对比。
近缓存原子操作的硬件实现还需要解决以下技术挑战:
一致性协议集成:近缓存原子操作需要与现有的Cache一致性协议无缝集成。当L2控制器执行原子操作时,它需要先失效所有核心L1 Cache中该Cache行的副本,执行RMW操作,然后在核心需要该数据时重新提供。
排序保证:近缓存原子操作必须遵守ISA定义的内存模型约束。例如,如果一个AMO指令带有
.aq.rl修饰,L2控制器在执行该操作前必须确保发起核心的所有先前内存操作已完成,执行后必须确保结果对所有核心可见才允许后续操作继续。异常处理:如果原子操作的目标地址导致页面错误或访问权限违规,L2控制器需要将异常报告回发起核心的流水线进行处理。
调试支持:近缓存执行的原子操作在硬件调试器中可能不易观察(因为操作不经过核心流水线)。处理器需要提供专门的性能计数器和跟踪机制来监控近缓存原子操作的行为。
表表 37.20从硬件实现的角度对比了四种主要的原子操作机制。
| 特性 | CAS | LL/SC | ARM LSE | RISC-V AMO |
|---|---|---|---|---|
| 指令数 | 1 | 35 | 1 | 1 |
| 虚假失败 | 无 | 有 | 无 | 无 |
| ABA问题 | 有 | 无 | 有 | 无 |
| 锁定机制 | Cache行锁 | Reservation | Cache控制器 | 可选 |
| 隐含屏障 | 是(x86) | 可选 | 可选 | 可选 |
| 近缓存支持 | 困难 | 不适用 | 支持 | 支持 |
原子操作机制的硬件实现对比
案例研究 4 — 原子操作技术的演进
原子操作的硬件实现经历了几个关键阶段:
总线锁定时代(1990年代):早期x86处理器(如Pentium)通过锁定整个内存总线来实现原子操作。在总线锁定期间,没有任何其他核心可以访问内存。这种方式简单但代价极高——锁定总线的延迟可达数百个周期。
Cache行锁定时代(2000年代):现代x86处理器将锁定范围从总线缩小到单个Cache行。这大幅减少了原子操作对其他核心的影响——只有访问同一Cache行的操作才会被延迟。
LL/SC与LSE时代(2010年代):ARM和RISC-V采用LL/SC机制,完全避免了锁定。ARMv8.1的LSE进一步提供了单指令原子操作,在高竞争场景下性能优于LL/SC。
近缓存原子操作时代(2020年代):将原子操作的执行位置从核心移到数据所在的Cache层级,消除了Cache行迁移开销。这一趋势在CXL等新型互联技术中得到加速——CXL 3.0的Back-Invalidate Atomics允许跨设备的近内存原子操作。
这一演进过程的核心主线是:不断缩小原子操作的锁定/影响范围——从整个内存总线单个Cache行无锁(LL/SC)近数据执行。每一步都在减少原子操作对系统其他部分的干扰,提高整体并发性能。
展望未来,原子操作的硬件支持仍在持续演进。新兴的研究方向包括:
近内存计算(PIM)原子操作:在HBM/GDDR内存控制器中集成原子操作单元。当数据位于内存中而非Cache中时,传统的原子操作需要先将数据加载到Cache,再执行RMW操作。PIM原子操作直接在内存侧执行RMW,消除了CacheMemoryCache的数据搬移。Samsung的HBM-PIM和UPMEM的PIM技术已经展示了这种方案的可行性。
CXL跨设备原子操作:CXL 3.0的Back-Invalidate Atomics允许主机处理器将原子操作请求发送到CXL设备端的内存控制器执行。这对于异构计算场景(如GPU与CPU共享内存)特别重要——传统上跨设备的同步需要昂贵的PCIe事务,而CXL原子操作将延迟降低了一个数量级。
硬件事务内存(HTM)的演进:Intel的TSX(Transactional Synchronization Extensions)允许软件将任意代码段声明为原子执行。虽然Intel在最新处理器中弃用了TSX的部分功能(因为安全漏洞),HTM的核心理念——通过硬件监控一组内存地址的冲突来实现乐观并发——仍然影响着下一代同步原语的设计。ARM的TME(Transactional Memory Extension)正在探索类似的方向。
Persistent Memory(持久内存)原子操作:Intel Optane等持久内存技术要求原子操作不仅保证多核间的可见性,还保证数据在掉电后的持久性。这引入了新的指令——如
CLWB(Cache Line Write Back)和CLFLUSHOPT(优化的Cache行刷回)——以及新的fence语义——如SFENCE在持久内存场景下保证Store的持久化顺序。
编程语言内存模型到硬件的映射
内存一致性模型的设计不仅影响硬件微架构,还深刻影响了编程语言和编译器的设计。理解从编程语言到硬件的映射链路,对于处理器设计者来说至关重要——它决定了哪些硬件特性在实际软件中被频繁使用,从而影响硬件设计的优先级。
C++11内存序到硬件指令的映射
C++11/C11标准定义了六种内存序(memory order),从最弱到最强依次是:memory_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_release、memory_order_acq_rel、memory_order_seq_cst。编译器负责将这些高级语义映射到目标架构的具体指令。
表 表 37.21展示了C++11原子操作在三大架构上的典型映射。
| C++11语义 | x86 | ARMv8 | RISC-V |
|---|---|---|---|
relaxed Store | MOV | STR | SW |
release Store | MOV | STLR | fence rw,w; SW |
seq_cst Store | MOV; MFENCE | STLR | fence rw,w; SW |
relaxed Load | MOV | LDR | LW |
acquire Load | MOV | LDAR | LW; fence r,rw |
seq_cst Load | MOV | LDAR | fence rw,rw; LW; fence r,rw |
C++11原子Store/Load在不同架构上的映射
几个关键的观察:
x86上的免费排序:由于TSO已经保证了Load-Load、Store-Store和Load-Store序,x86上的
acquireLoad和releaseStore不需要任何额外的fence指令——普通的MOV即可。只有seq_cstStore需要额外的MFENCE来恢复Store-Load序。这是x86的TSO模型在软件移植方面的重大优势。ARM上的LDAR/STLR:ARM的acquire/release直接映射到硬件的
LDAR/STLR指令,避免了使用独立的DMB指令。LDAR和STLR的延迟通常低于独立的Load/Store加DMB的组合,因为它们的排序约束可以直接在Load Queue/Store Buffer中通过位标记实现。RISC-V上的fence开销:RISC-V缺少专门的acquire Load和release Store指令(这些通过LR/SC的.aq/.rl位提供,但不适用于普通Load/Store)。因此,C++11的acquire/release语义需要通过独立的
fence指令实现,带来额外的指令数和排序开销。RISC-V社区已经讨论了添加Load-Acquire和Store-Release指令的提案(Zilsd/Zalasr扩展)。
性能分析 11 — 不同内存序在不同架构上的性能影响
在一个典型的生产者-消费者工作负载中,C++11内存序的选择对性能的影响:
x86:
relaxed到release/acquire之间几乎无性能差异(都映射到普通MOV)。从acquire到seq_cst有5%15%的性能下降(因为seq_cst Store需要MFENCE)。ARMv8:
relaxed到release/acquire有3%8%的性能下降(LDAR/STLR的排序约束限制了重排序优化)。从acquire到seq_cst有额外2%5%的下降。RISC-V:
relaxed到release/acquire有8%15%的性能下降(额外的fence指令占用流水线资源并限制重排序)。这也是RISC-V社区推动Zalasr扩展的重要动机。
C++11 atomic_compare_exchange在不同ISA上的映射
std::atomic::compare_exchange_strong()是C++11中最常用的原子操作之一,但它在不同ISA上的映射方式截然不同,反映了各架构原子操作机制的差异。
x86:直接映射到
lock cmpxchg指令。单指令完成,不需要循环。ARMv8(有LSE):直接映射到
CAS/CASA/CASAL指令(取决于内存序)。单指令完成。ARMv8(无LSE):映射到
LDXR/STXR循环。compare_exchange_strong需要在外层循环中处理STXR的虚假失败——只有当比较失败(值不匹配)时才真正返回失败,虚假失败时需要重试。RISC-V:映射到
LR/SC循环,处理逻辑类似于无LSE的ARMv8。
注意C++11还定义了compare_exchange_weak(),它允许虚假失败——在LL/SC架构上,weak版本可以省去处理虚假失败的外层循环,直接将LL/SC的失败(包括虚假失败)作为CAS失败返回。这在需要在循环中使用CAS的场景下(如CAS循环实现原子递增)不影响正确性,但可以减少代码体积和分支数。在x86上,weak和strong版本生成完全相同的代码——因为CAS不存在虚假失败。
设计提示
在设计新的RISC-V处理器时,是否支持Zacas扩展(单指令CAS)是一个重要的决策点。支持Zacas的好处是:(1)CAS操作的延迟降低(不需要LR/SC循环);(2)代码体积减小;(3)在高竞争场景下性能更好(没有虚假失败)。代价是:(1)Cache控制器需要增加比较逻辑(比加法器更复杂);(2)指令编码空间的消耗;(3)需要支持两种原子操作范式(LR/SC和CAS)的验证工作量增加。对于面向服务器市场的RISC-V核心,支持Zacas几乎是必要的——CAS是服务器工作负载中最频繁使用的原子操作。
编译器屏障与硬件屏障的区别
在讨论内存一致性时,必须区分两个层面的"重排序":编译器重排序和硬件重排序。
编译器在优化代码时可能改变内存操作的顺序。例如,编译器可能将一个Store移到后续的Load之后(如果它认为两者之间没有数据依赖)。编译器屏障(compiler barrier)防止编译器进行这种重排序,但不影响硬件——硬件仍可能在运行时重排序。
硬件屏障(hardware barrier,即fence指令)防止处理器微架构在运行时重排序内存操作。完整的内存排序保证需要同时使用编译器屏障和硬件屏障。
在C/C++中,
volatile关键字提供编译器屏障(阻止编译器优化掉内存访问或重排序),但不提供硬件屏障——它不插入fence指令。C++11的
std::atomic同时提供编译器屏障和硬件屏障——编译器不会重排序atomic操作,且会根据指定的memory_order插入适当的fence指令。Linux内核的
READ_ONCE()和WRITE_ONCE()提供编译器屏障(确保编译器生成真正的内存访问指令),但不插入fence。smp_rmb()、smp_wmb()、smp_mb()则同时提供编译器屏障和硬件屏障。
从处理器设计者的角度看,编译器屏障的存在意味着:软件实际使用fence指令的频率可能远低于理论上需要的频率。在TSO架构上,大量的编译器屏障(如READ_ONCE())不会产生任何fence指令——硬件已经提供了足够的排序保证。这也意味着TSO处理器上fence指令(MFENCE)的执行频率相对较低,为其"昂贵但稀少"的设计策略提供了合理性。
表 表 37.22总结了不同类型屏障在三大架构上的映射关系。
| 屏障类型 | x86 | ARMv8 | RISC-V |
|---|---|---|---|
| 编译器屏障 | barrier() | barrier() | barrier() |
| Load屏障 | 无需(TSO) | DMB ISHLD | fence r,r |
| Store屏障 | 无需(TSO) | DMB ISHST | fence w,w |
| 全屏障 | lock add/MFENCE | DMB ISH | fence rw,rw |
| Acquire | 无需(TSO) | LDAR | fence r,rw/.aq |
| Release | 无需(TSO) | STLR | fence rw,w/.rl |
| 指令屏障 | 无直接等价 | ISB | fence.i |
不同类型屏障在三大架构上的映射
表中最显著的观察是x86列中大量的"无需"——TSO模型自动提供了大部分排序保证,只有全屏障需要显式指令。这也解释了为什么从x86移植多线程代码到ARM/RISC-V时容易出现内存排序bug——x86上"碰巧正确"的代码在弱内存模型上可能失败。
实际内存序bug案例分析
内存排序bug是多核编程中最难以调试的问题之一。以下是一个在实际系统中发现的经典案例。
Linux内核中的seqlock排序bug
Linux内核的seqlock(顺序锁)是一种读-写同步机制。读者通过读取序列号来检测是否有并发写入。一个简化的seqlock读侧实现如下:
do {
seq = READ_ONCE(lock->sequence); // Load A: 读序列号
/* 临界区:读取受保护的数据 */
data = READ_ONCE(shared->value); // Load B: 读数据
} while (READ_ONCE(lock->sequence) != seq || (seq & 1));
// Load C: 重读序列号正确性要求:Load B(读数据)必须在Load A(读序列号)之后执行,且在Load C(重读序列号)之前执行。否则,读者可能在写者修改数据的过程中读到不一致的值,而序列号检查却误认为数据一致。
在x86(TSO)上,Load-Load序由硬件保证,上述代码天然正确。但在ARM上,Load B可能被硬件重排到Load A之前执行——读者先读了数据,再读序列号,导致即使序列号匹配,数据也可能是不一致的。
修正方法是在Load A和Load B之间使用smp_rmb()(在ARM上映射为DMB ISHLD),保证Load-Load顺序。Linux内核的seqlock实现中的read_seqcount_begin()函数已经包含了这个屏障。
这个案例说明了一个重要原则:在弱内存模型上编程,不能依赖"直觉上的顺序"——必须显式地通过fence或acquire/release语义来保证所需的排序。
内存模型与微架构安全
2018年披露的Spectre和Meltdown攻击揭示了内存一致性模型与微架构安全之间的深刻联系。推测执行——这一在TSO和弱内存模型实现中广泛使用的技术——成为了侧信道攻击的载体。
推测执行与内存模型的安全交互
在TSO模型的推测性实现中,Load可以在分支解析之前推测性地执行。如果分支预测错误,推测Load的架构结果会被撤销(流水线冲刷),但其微架构副作用(如Cache行的加载)不会被撤销。攻击者可以利用这一点:
通过训练分支预测器,使受害者代码在推测路径上执行越界Load。
推测Load将秘密数据从内存加载到寄存器(架构上不可见,但微架构上发生了)。
推测路径上的后续Load以秘密数据为索引访问一个探测数组,将秘密数据编码到Cache状态中。
推测执行被回滚,但Cache状态变化保留。
攻击者通过Cache侧信道(如Flush+Reload)探测Cache状态,恢复秘密数据。
这种攻击与内存模型的关系在于:推测Load绕过了内存模型的排序约束——在推测路径上,Load可能在"不应该执行"的条件下执行,访问了内存模型本应禁止访问的地址。
处理器厂商针对此类攻击引入了多种硬件防护措施:
LFENCE序列化:Intel重新定义了LFENCE的行为——在Spectre缓解模式下,LFENCE不仅序列化Load操作,还序列化推测执行——LFENCE之后的指令不能在LFENCE之前推测执行。这使得LFENCE可以作为推测屏障使用。
SSBD(Speculative Store Bypass Disable):禁止Load推测性地绕过地址未知的Store(Spectre V4的缓解)。这直接限制了Store-to-Load转发的推测行为。
分支预测器隔离:在上下文切换时清除分支预测器状态(IBRS/STIBP),防止攻击者训练受害者的分支预测器。
性能分析 12 — Spectre缓解对内存性能的影响
Spectre缓解措施对内存子系统性能的影响是显著的:
LFENCE屏障:在关键的间接跳转前插入LFENCE,平均增加5%10%的执行时间。
SSBD:禁用推测性Store旁路后,Store-to-Load转发的命中率下降,内存密集型工作负载IPC下降3%8%。
Retpoline(软件缓解):将间接跳转替换为"返回蹦床"(return trampoline),避免分支预测器被训练。对间接跳转密集的工作负载(如虚拟机管理器、解释器)影响最大,IPC下降10%25%。
这些性能影响促使处理器厂商在新一代处理器中集成更精细的硬件缓解机制——如Intel的eIBRS(Enhanced IBRS),在硬件层面隔离分支预测器状态,无需软件层面的Retpoline开销。
从处理器设计的角度看,Spectre等攻击表明:内存一致性模型的"正确性"不仅包括功能正确性(程序看到的值是否符合模型规定),还包括安全性(推测执行是否泄漏了不应被观察到的信息)。未来的内存模型设计需要在性能优化、编程模型简洁性和安全性三者之间找到平衡。
Spectre V1与Load值注入
Spectre V1(Bounds Check Bypass)直接利用了内存模型中Load推测执行的特性。考虑以下场景:
if (x < array1_size) { // 分支条件
y = array2[array1[x] * 256]; // 推测执行路径
}如果攻击者能够控制x的值并训练分支预测器使条件跳转被预测为"taken",则处理器会在分支解析之前推测性地执行越界Load(array1[x]读取任意内存),然后用读取的秘密值作为索引访问array2,将秘密编码到Cache状态中。
从内存模型的角度看,这个攻击的核心在于:推测性Load在架构层面不应该发生(因为分支条件为假),但在微架构层面确实发生了。内存一致性模型只定义了"架构可见"的行为——推测执行的Load在架构上是不可见的(分支回滚后没有任何副作用),因此不违反内存模型。但Cache状态的变化是一个微架构侧信道,超出了传统内存模型的关注范围。
这促使学术界提出了扩展内存模型的概念——在传统的内存排序规则之上,增加关于推测执行微架构副作用的约束。例如,"非推测性内存模型"(non-speculative memory model)要求所有Load的微架构副作用必须等到Load确认为非推测性时才生效——这实质上禁止了Cache侧信道,但代价是Load延迟的显著增加。目前,这仍然是一个活跃的研究领域。
本章小结
本章系统地讨论了内存一致性模型的概念体系、TSO和弱内存模型的硬件实现以及原子操作的微架构设计。内存一致性模型是多核处理器设计中连接硬件优化自由度与软件编程模型的关键桥梁。
从SC到TSO再到ARM/RISC-V的弱内存模型,每一级宽松化都打开了新的硬件优化空间:
SC TSO:允许Store-Load重排序,使得FIFO Store Buffer可以缓冲Store而不阻塞后续Load。这是性价比最高的单步宽松化——仅放宽一种排序约束就带来了10%30%的IPC提升。
TSO 弱模型:进一步允许Load-Load、Store-Store和Load-Store重排序,使得处理器可以采用非FIFO Store Buffer、自由的Load乱序完成和Store合并。额外带来5%15%的IPC提升,但代价是软件需要显式使用fence和acquire/release语义。
原子操作的硬件实现从总线锁定演进到近缓存执行,反映了"减少锁定范围、将操作移向数据"的设计哲学。表 表 37.23总结了内存一致性和原子操作技术的演进主线。
| 时期 | 一致性模型 | 原子操作技术 |
|---|---|---|
| 1990年代 | SC为主 | 总线锁定 |
| 2000年代 | TSO(x86) | Cache行锁定 |
| 2010年代 | TSO + 弱模型(ARM) | LL/SC + LSE |
| 2020年代 | 弱模型(ARM/RISC-V) | 近缓存原子 + CXL原子 |
内存一致性与原子操作技术的演进
"语言级内存模型 ISA级内存模型 微架构实现"的多层映射是现代并发编程生态系统的基石。处理器设计者在选择内存模型时,不仅需要考虑硬件的优化自由度,还需要考虑目标软件生态系统的需求——一个过于宽松的模型可能带来显著的硬件优化空间,但如果编译器和程序员不能有效地利用它(或者因为过于复杂而经常出错),那么这些优化空间就只是纸面上的优势。
本章的核心知识点可以总结为以下几条设计原则:
Store Buffer是TSO的硬件基础:FIFO Store Buffer加Store-to-Load转发自然产生TSO语义。理解这一对应关系是理解x86内存模型的钥匙。
弱模型的核心优化是非FIFO Store Buffer:允许Store乱序提交消除了头部阻塞问题,在内存密集型工作负载中带来5%15%的IPC提升。
Fence的性能代价由实现策略决定:阻塞式fence代价高但简单,非阻塞式fence通过推测执行降低了有效延迟。选择策略取决于核心的性能/面积目标。
LL/SC和CAS是两种根本不同的原子操作范式:LL/SC使用Reservation Monitor,免疫ABA但可能虚假失败;CAS使用Cache行锁定,不虚假失败但有ABA问题。选择取决于ISA传统和软件生态需求。
近缓存原子操作是高竞争场景的解法:将操作移向数据消除了Cache行迁移的乒乓效应,在高竞争场景下可提供3倍以上的吞吐量提升。
安全性是内存模型设计的新维度:推测执行带来的侧信道攻击表明,内存模型的设计必须同时考虑功能正确性、性能和安全性三个维度。
语言内存模型决定了硬件特性的使用效率:C++11的
memory_order_consume未能被编译器有效实现的事实说明,硬件特性(数据依赖保序)如果不能被编程语言和编译器有效利用,其价值就大打折扣。处理器设计者需要与编译器团队紧密合作,确保新的硬件内存模型特性能够被软件工具链有效利用。验证是内存模型实现中最昂贵的环节:内存模型的bug极难发现(可能需要特定的核心数量、Cache状态和时序条件才能触发)且修复代价极高(可能需要微码更新或硬件回避)。投入足够的验证资源——包括形式化验证、石蕊测试和随机压力测试——是成功实现内存模型的前提。
表 表 37.24提供了一个内存模型实现的设计检查清单。
| 检查项 | 描述 |
|---|---|
| Store Buffer FIFO/非FIFO | 确认Store Buffer的排序策略是否满足目标模型要求 |
| Load Queue snoop搜索 | 确认snoop请求能正确搜索所有未退休的Load |
| Fence指令解码 | 确认所有fence变体的排序约束被正确实现 |
| Acquire/Release标记 | 确认Load Queue和Store Buffer中的排序标记正确传播 |
| LR/SC reservation | 确认reservation的设置、清除和粒度符合ISA规范 |
| 原子操作锁定 | 确认Cache行锁定期间snoop被正确延迟/NACK |
| 推测执行回滚 | 确认推测Load的结果在分支回滚时被正确丢弃 |
| 多副本原子性 | 确认Store对所有核心同时可见(如果模型要求) |
| 石蕊测试覆盖 | 至少覆盖SB/MP/LB/IRIW等核心石蕊测试 |
内存一致性模型实现的设计检查清单
最后值得强调的是,内存一致性模型不是一个静态的设计选择——它随着处理器架构、软件生态和安全需求的演进而不断变化。 ARM从早期的非多副本原子模型演进到ARMv8.4的多副本原子模型,RISC-V在基础的RVWMO之上不断添加新的扩展(Ztso、Zacas、Zalasr),x86在Spectre之后重新定义了LFENCE的行为——这些都说明,内存模型的设计是一个持续迭代的过程,需要处理器设计者对硬件实现、软件需求和安全威胁保持深入的理解。
代码清单 lst:ch37-sb-fifo给出了一个支持TSO语义的Store Buffer FIFO的简化SystemVerilog实现,包含入队(enqueue)、出队(dequeue/drain)和Store-to-Load forwarding查找三个核心操作。这个实现展示了Store Buffer如何通过FIFO顺序出队天然地产生TSO的Store-Store排序保证,以及forwarding查找如何通过地址匹配找到最新的Store数据。
module store_buffer_fifo #(
parameter DEPTH = 64, // Store Buffer深度
parameter ADDR_W = 48, // 物理地址宽度
parameter DATA_W = 64 // 数据宽度
)(
input logic clk, rst_n,
// 入队接口(来自提交阶段)
input logic enq_valid,
input logic [ADDR_W-1:0] enq_addr,
input logic [DATA_W-1:0] enq_data,
input logic [7:0] enq_byte_en, // 字节使能
output logic enq_ready, // Store Buffer未满
// 出队/排出接口(写入L1 Cache)
output logic drain_valid,
output logic [ADDR_W-1:0] drain_addr,
output logic [DATA_W-1:0] drain_data,
output logic [7:0] drain_byte_en,
input logic drain_ack, // L1确认写入
// Store-to-Load forwarding查找接口
input logic fwd_req,
input logic [ADDR_W-1:0] fwd_addr,
output logic fwd_hit, // 找到匹配的Store
output logic [DATA_W-1:0] fwd_data,
output logic fwd_partial // 仅部分字节匹配
);
// --- 存储阵列 ---
logic [ADDR_W-1:0] sb_addr [DEPTH];
logic [DATA_W-1:0] sb_data [DEPTH];
logic [7:0] sb_ben [DEPTH];
logic sb_valid [DEPTH];
// --- FIFO指针 ---
logic [$clog2(DEPTH)-1:0] head, tail;
logic [$clog2(DEPTH):0] count;
assign enq_ready = (count < DEPTH);
assign drain_valid = (count > 0) && sb_valid[head];
// --- 入队:Store提交时写入队尾 ---
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
tail <= '0;
count <= '0;
end else if (enq_valid && enq_ready) begin
sb_addr[tail] <= enq_addr;
sb_data[tail] <= enq_data;
sb_ben[tail] <= enq_byte_en;
sb_valid[tail] <= 1'b1;
tail <= tail + 1;
count <= count + 1 - (drain_ack ? 1 : 0);
end else if (drain_ack) begin
count <= count - 1;
end
end
// --- 出队:FIFO顺序排出到L1 Cache(保证Store-Store序)---
assign drain_addr = sb_addr[head];
assign drain_data = sb_data[head];
assign drain_byte_en = sb_ben[head];
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
head <= '0;
end else if (drain_ack) begin
sb_valid[head] <= 1'b0;
head <= head + 1; // FIFO: 严格按序出队
end
end
// --- Store-to-Load Forwarding: 找最新(最近tail)的地址匹配 ---
always_comb begin
fwd_hit = 1'b0;
fwd_data = '0;
fwd_partial = 1'b0;
if (fwd_req) begin
// 从tail向head反向搜索,找到最新的匹配Store
for (int i = 0; i < DEPTH; i++) begin
automatic logic [$clog2(DEPTH)-1:0] idx;
idx = tail - 1 - i[$clog2(DEPTH)-1:0];
if (sb_valid[idx] && sb_addr[idx] == fwd_addr) begin
fwd_hit = 1'b1;
fwd_data = sb_data[idx];
// 检查字节覆盖是否完整
fwd_partial = (sb_ben[idx] != 8'hFF);
break; // 最新匹配优先
end
end
end
end
endmodule设计提示
上述实现展示了三个关键设计点:(1)FIFO出队保证了TSO的Store-Store排序——Store严格按程序顺序提交到L1 Cache;(2)反向搜索(从tail向head)保证了forwarding时返回最新的Store数据——当多条Store写入同一地址时,消费者Load必须看到程序顺序中最后一条Store的值;(3)部分匹配检测(fwd_partial)用于处理字节级覆盖不完整的情况——此时需要将Store Buffer中的部分字节与Cache中的其余字节拼合,这是实现中容易出错的部分。在弱序模型(如ARM/RISC-V)下,出队逻辑可以改为非FIFO,允许已获得Cache写权限的Store优先排出,从而减少头部阻塞。
内存一致性模型规定了Store何时以及以何种顺序对其他核心可见。但在此之前,还有一个更基本的问题:指令的结果何时才算是"最终确定"的?在乱序处理器中,指令可以提前执行,但其结果必须等到按程序顺序提交(commit)后才能修改体系结构状态。第 38.0 章将深入讨论实现这一机制的核心硬件——重排序缓冲区(ROB)。ROB不仅是精确异常的基础,也是Store Buffer正确性的前提:只有已提交的Store才能进入Store Buffer等待排出到Cache。
在下一章中,我们将转向处理器设计中另一个核心主题——分支预测的高级技术,讨论如何通过更精确的分支预测来减少流水线冲刷的性能损失。