Skip to content

Cache一致性协议

2005年,AMD的Barcelona原型芯片调试团队遭遇了一个噩梦级别的bug:当三个核心同时对同一Cache行执行特定的读-写-无效化序列时,该Cache行会进入一个协议规范中未定义的状态,导致数据损坏。这个bug花了三个月时间才被追踪定位——因为它只在三核心以特定时序窗口并发操作时才会触发,复现概率极低。这个案例深刻地揭示了Cache一致性的本质:它是多核处理器中最复杂、最难验证的子系统,任何一个状态转移的遗漏或错误都可能导致静默的数据损坏。

设计提示

统一视角。一致性协议是多核并行的协调机制——多个核心并行执行时,一致性协议确保它们看到一致的数据视图。协调的开销就是并行的代价:每一次跨核心的Cache行共享都可能涉及数十到数百周期的一致性事务延迟(回顾第 3.0 章讨论的总线和互连带宽的物理约束)。一致性协议的设计目标是在保证正确性的前提下最小化这些协调开销。回顾第 5.0 章中Cache的基本结构,一致性协议为每个Cache行增加了状态位(M/E/S/I等),将Cache从一个纯粹的"数据缓存"升级为一个"分布式共享数据管理系统"。

在多核处理器中,每个核心通常拥有私有的L1/L2 Cache,而所有核心共享最后一级Cache(LLC)和主存。当两个核心同时缓存了同一内存地址的数据,并且其中一个核心对该数据进行了写操作时,另一个核心的Cache中仍然保留着旧的数据——此时系统中同一地址存在两个不同的值,程序的正确性受到了威胁。Cache一致性协议(Cache Coherence Protocol)正是为了解决这个问题而引入的关键机制:它确保在多核系统中,所有核心对共享内存的访问在逻辑上看起来就像在访问一个统一的、最新的数据副本。

Cache一致性是多核处理器设计中最为复杂的子系统之一。一个典型的一致性协议需要处理数十种消息类型、上百种瞬态状态,以及各种竞争条件(Race Condition)和死锁/活锁场景。Intel在Skylake中的一致性协议包含超过30种瞬态状态;ARM的AMBA CHI协议规范超过400页;AMD在Zen系列中使用的MOESI协议变体涉及复杂的三级目录层次。本章将从基本原理出发,系统地阐述一致性协议的核心概念、主流协议族的状态转移机制,以及面向2030年代chiplet架构的协议演进趋势。

一致性问题的产生

多核系统的共享存储

现代多核处理器采用共享存储(Shared Memory)编程模型:所有核心看到的是同一个地址空间,任何核心都可以读写任何地址。这一模型极大地简化了并行程序的编写——程序员不需要显式地在核心之间传递消息,只需要通过共享变量进行通信。然而,共享存储模型要求硬件保证:当一个核心写入某个地址后,其他核心在随后读取同一地址时必须能够看到这个最新的值。

图 9.1展示了一个典型的多核系统结构。每个核心拥有私有的L1 Cache(通常分为L1I和L1D),可能还有私有的L2 Cache。所有核心通过互连网络(Interconnect)连接到共享的LLC(L3 Cache)和内存控制器。

典型多核处理器的存储层次结构
典型多核处理器的存储层次结构

考虑以下场景来理解一致性问题的本质。假设Core 0和Core 1都在各自的L1 Cache中缓存了变量XX(初始值为0)。当Core 0执行X = 42时,新值42被写入Core 0的L1 Cache。如果不采取任何措施,Core 1的L1 Cache中仍然保留着XX的旧值0。当Core 1随后读取XX时,它将读到过期的值0,而非最新的值42——这就破坏了共享存储的语义。

c
// 初始状态:X = 0,Core 0 和 Core 1 都缓存了 X
// Core 0 执行:
X = 42;       // 新值写入 Core 0 的 L1 Cache

// Core 1 执行:
r1 = X;       // 如果没有一致性协议,r1 可能读到 0(过期值)
assert(r1 == 42);  // 断言失败!

更为严重的情况是,如果两个核心同时对同一地址写入不同的值,最终内存中应该保留哪个值?不同核心Cache中的值如何统一?这些问题都需要一致性协议来解决。

一致性缺失的性能影响

一致性协议的运作不可避免地引入了一种新类型的Cache缺失——一致性缺失(Coherence Miss),有时也称为通信缺失(Communication Miss)或第四类C缺失。一致性缺失发生在以下情况:一个核心的Cache行由于另一个核心的写操作而被无效化,当该核心再次访问这个地址时发生缺失。

一致性缺失与传统的3C缺失(强制缺失、容量缺失、冲突缺失)有本质区别:它不能通过增大Cache容量或提高相联度来减少,因为它是由其他核心的行为导致的。减少一致性缺失的途径包括:(1)改善数据的共享模式(减少虚假共享);(2)使用更大的Cache行来摊薄一致性开销(但可能增加虚假共享);(3)使用更高效的一致性协议来减少不必要的无效化。

在实际的多线程工作负载中,一致性缺失的占比差异很大。对于几乎不共享数据的工作负载(如SPEC CPU的单线程任务在多核上并行运行),一致性缺失几乎可以忽略。但对于高度共享的并行工作负载(如数据库、Web服务器),一致性缺失可能占L1 D-Cache总缺失的20%\sim40%,成为显著的性能瓶颈。

一致性缺失的延迟特性也与传统缺失不同。传统的容量缺失或冲突缺失的延迟取决于下一级Cache或主存的访问延迟(相对固定);而一致性缺失的延迟取决于持有最新数据的核心的位置和当前负载——在同一chiplet内的核心间传输可能只需10\sim20个周期,但在跨Socket的NUMA系统中可能需要100\sim200个周期。更复杂的是,一致性缺失的延迟可能因为网络拥塞或Home Agent排队而产生不确定的波动,使得性能分析和优化变得更加困难。

现代处理器通过预取来部分缓解一致性缺失的影响。一些处理器实现了一致性感知的预取策略:当检测到某个核心频繁地因为一致性缺失而访问某个地址范围时,预取器可以预测性地提前发起一致性请求。但这种预取的效果有限——因为一致性缺失往往与程序的控制流和同步操作紧密相关,难以用简单的地址模式来预测。

一致性缺失中有一种特别隐蔽且有害的变体——虚假共享(False Sharing)导致的缺失。虚假共享发生在两个核心访问同一Cache行中不同字节的情况下。尽管它们在逻辑上没有数据共享关系,但由于一致性协议以Cache行为粒度操作,一个核心的写操作会无效化另一个核心持有的整个Cache行。下面的代码展示了一个典型的虚假共享场景:

c
// 两个计数器紧邻存放,很可能位于同一Cache行
struct {
    int counter0;  // Core 0 独占使用
    int counter1;  // Core 1 独占使用
} shared_counters;  // sizeof = 8字节,远小于 64字节的Cache行

// Core 0:
for (int i = 0; i < 1000000; i++)
    shared_counters.counter0++;  // 每次写入都无效化 Core 1 的缓存

// Core 1:
for (int i = 0; i < 1000000; i++)
    shared_counters.counter1++;  // 每次写入都无效化 Core 0 的缓存

在上述代码中,counter0counter1位于同一个64字节的Cache行中。Core 0每次写入counter0时,一致性协议会将Core 1缓存中包含counter1的整个Cache行无效化;反之亦然。这导致两个核心在每次迭代中都发生一致性缺失,性能可能比单核执行还要差。解决方案是通过填充(Padding)将两个计数器分隔到不同的Cache行中:

c
struct {
    int counter0;
    char padding[60];  // 填充到 64 字节边界
    int counter1;      // 现在位于不同的 Cache 行
} shared_counters;

许多编程语言和框架已经提供了对齐辅助工具(如C11的_Alignas(64)、Java的@Contended注解、Rust的#[repr(align(64))]),帮助程序员避免虚假共享。

一致性协议的分类框架

在深入各具体协议之前,建立一个系统的协议分类框架有助于理解不同设计选择之间的关系。一致性协议可以从以下几个维度进行分类:

按写策略分类

写无效(Write-Invalidate)协议:当一个核心写入共享数据时,通过发送无效化消息使所有其他核心的副本失效。写者获得独占权限后,后续写操作不产生一致性流量。这是目前所有主流处理器采用的方式。代表协议:MSI、MESI、MOESI、MESIF。

写更新(Write-Update)协议:当一个核心写入共享数据时,将新值广播给所有持有该行副本的核心,让它们同步更新数据。优点是其他核心不会因为无效化而产生缺失(数据始终保持最新);缺点是每次写操作都产生广播流量,在写密集工作负载中带宽消耗极大。代表协议:Dragon、Firefly。

写广播(Write-Broadcast)协议:一种折中方案——将写数据广播到所有核心,但仅更新已经缓存了该行的核心(不将数据推送到没有该行的核心)。这比写更新稍微节省带宽,但仍然不如写无效在大多数工作负载中高效。

按状态获取机制分类

基于监听(Snooping-Based):一致性请求通过广播发送给所有Cache控制器,每个控制器独立决定是否需要响应。优点:延迟低(单次广播),实现简单。缺点:带宽随核心数线性增长。

基于目录(Directory-Based):一致性请求发送给集中的目录(或分布式的Home节点),由目录确定需要通知哪些核心后发送定向消息。优点:消息数量与共享者数量成正比,可扩展性好。缺点:多跳延迟,需要额外的目录存储。

基于令牌(Token-Based):每个Cache行对应固定数量的令牌(Token),持有足够数量令牌的核心获得对应权限(1个令牌=读权限,所有令牌=写权限)。Martin等人在2003年提出的Token Coherence协议结合了监听和目录的优点:用广播来快速传播请求,用令牌来跟踪权限。但其硬件实现复杂度较高,主流商用处理器未采用。

按一致性粒度分类

Cache行粒度(Line-Level):绝大多数协议的默认粒度。状态标记、无效化、数据传输都以完整的Cache行(通常64字节)为单位。

子行粒度(Sub-Line Level):将Cache行进一步划分为子行(如16字节),每个子行独立维护一致性状态。可以减少假共享的影响,但大幅增加了状态存储开销和协议复杂度。在GPU的一致性实现中有部分应用。

页粒度(Page-Level):以内存页(4 KB或更大)为粒度进行一致性管理。通常用于软件一致性方案或跨节点的NUMA一致性中。粒度过粗会导致大量假共享,但协议开销极低。

一致性与一致性模型的区分

在深入讨论协议细节之前,必须明确两个容易混淆但本质不同的概念:Cache一致性(Cache Coherence)与内存一致性模型(Memory Consistency Model)。

Cache一致性关注的是单一地址的多个副本之间的一致性问题。它回答的核心问题是:当多个核心缓存了同一地址的数据时,如何保证所有核心最终能看到这个地址的最新值?一致性协议通过在Cache之间传播写操作(使旧副本无效或更新)来实现这一目标。一致性对程序员来说应当是完全透明的——在一个正确实现一致性的系统中,程序员不需要关心数据在哪个Cache中,也不需要执行任何特殊操作来"刷新"Cache。

内存一致性模型关注的是不同地址的访存操作之间的顺序关系。它回答的核心问题是:当一个核心执行了对地址AA的写操作后又执行了对地址BB的写操作时,其他核心是否一定能以相同的顺序观察到这两个写操作?不同的一致性模型(如顺序一致性SC、总存储序TSO、弱有序模型等)对这个问题给出了不同的回答。内存一致性模型将在第 10.0 章中详细讨论。

可以用一个简洁的方式来区分二者:

  • Coherence:保证对同一地址的写操作最终对所有核心可见(单地址的"传播"问题)。

  • Consistency:规定对不同地址的操作在不同核心看来的顺序约束(多地址的"排序"问题)。

在实现上,一致性协议是一致性模型的底层基础设施。一致性协议负责在硬件层面传播写操作,而一致性模型则在此之上规定这些写操作对其他核心变得可见的时机和顺序。两者共同确保多核系统的存储行为对程序员而言是可预测和可推理的。

一个有助于理解的类比是:一致性协议就像邮政系统(负责将信件从发送者传递到所有接收者),而一致性模型就像邮寄规则(规定多封信件到达不同接收者的顺序约束)。邮政系统保证信件最终会到达(一致性保证写操作最终对所有核心可见),但邮寄规则可能允许或不允许信件以不同顺序到达不同接收者(一致性模型规定不同地址的写操作的可见顺序)。

在实际系统中,一致性协议通常被设计为对一致性模型透明——同一个一致性协议(如MESI)可以与不同的一致性模型(如TSO、弱有序)配合使用。一致性模型的约束通过处理器流水线中的存储缓冲区排序逻辑内存屏障指令来实现,而非在一致性协议中直接编码。但两者之间存在微妙的交互——例如,一致性协议中写操作的"全局可见点"(即写操作何时被认为对所有核心可见)与一致性模型的定义密切相关。在TSO模型下,写操作在离开处理器的存储缓冲区并被一致性协议处理的那一刻就算全局可见;在弱有序模型下,写操作可能被延迟到执行内存屏障时才变得全局可见。这些细节将在第 10.0 章中深入讨论。

SWMR不变量与数据值不变量

一个正确的一致性协议必须满足两个核心不变量:

不变量一:Single-Writer/Multiple-Reader(SWMR)

对于任何给定的内存地址,在任何时间点上,以下两个条件之一必须成立:

  1. 恰好有一个核心对该地址拥有读写权限(Read-Write),此时没有其他核心持有该地址的任何副本;或者

  2. 有零个或多个核心对该地址拥有只读权限(Read-Only),此时没有任何核心拥有读写权限。

用形式化的方式表达:设tt为任意时间点,aa为任意内存地址,RW(t,a)\mathrm{RW}(t,a)为在时刻tt拥有地址aa读写权限的核心数,RO(t,a)\mathrm{RO}(t,a)为在时刻tt拥有地址aa只读权限的核心数,则SWMR不变量要求:

t,a:(RW(t,a)=1RO(t,a)=0)    (RW(t,a)=0RO(t,a)0) \forall t, \forall a: \bigl(\mathrm{RW}(t,a) = 1 \wedge \mathrm{RO}(t,a) = 0\bigr) \;\vee\; \bigl(\mathrm{RW}(t,a) = 0 \wedge \mathrm{RO}(t,a) \geq 0\bigr)

SWMR不变量在本质上类似于读写锁(Reader-Writer Lock):允许多个读者并发访问,但写者必须独占。

不变量二:数据值不变量(Data Value Invariant)

在一个epoch(某个核心独占或共享一个Cache行的时间段)的开始时刻,该Cache行的值必须等于上一个读写epoch结束时刻的值。换言之,一致性协议必须保证数据写入的传播性——一个核心写入的新值必须在随后的读操作中可见。

这两个不变量共同构成了一致性协议正确性的充分条件。SWMR保证了写操作的排他性(不会出现两个核心同时写同一地址的冲突),而数据值不变量保证了写入值的传播性(新值不会"丢失")。所有的一致性协议——无论是基于监听的还是基于目录的——都必须在每一次状态转移中维持这两个不变量。

设计提示

理解SWMR不变量是理解所有一致性协议的关键。在后续分析MSI、MESI等协议的状态转移时,可以用SWMR不变量来验证每个转移是否正确:任何导致两个核心同时拥有同一Cache行读写权限的状态转移都是一个协议bug。

基于监听的协议

基于监听的协议(Snooping Protocol)是最早、也是最直观的一致性协议族。其核心思想简洁而优雅:所有的一致性请求通过一条共享总线(Shared Bus)广播给系统中的所有Cache控制器,每个Cache控制器监听(Snoop)总线上的请求,并根据自身持有的Cache行状态来决定是否需要响应。

监听协议依赖于总线(或类似的有序广播机制)提供两个关键特性:

  1. 广播:每个请求对所有Cache控制器可见。

  2. 全局序:所有请求按照一个全局顺序到达所有Cache控制器——这自然地保证了对同一地址的多个请求不会被不同核心以不同顺序观察到。

总线提供的全局序特性是监听协议正确性的基石。这个特性为什么如此重要?考虑以下场景:Core A和Core B几乎同时对同一地址XX发出BusRdX请求(都想获得独占写权限)。由于总线每次只能仲裁一个请求获得控制权,A和B的请求会按照仲裁结果被序列化——假设A先获得总线,A的BusRdX先被广播。所有核心(包括B)都首先观察到A的请求,B的Cache控制器将XX无效化(如果持有)。然后B的BusRdX被广播——此时A已经持有XX的M状态,A监听到B的BusRdX后提供数据并转为I。最终B获得XX的M状态。

这种序列化是自动的、硬件保证的——总线的物理特性(同一时刻只有一个设备驱动总线)天然地提供了全局序。这就是为什么监听协议的设计如此简洁。相比之下,目录协议中的Home节点需要显式地序列化对同一地址的请求(通过锁定目录条目直到事务完成),增加了复杂度。

监听协议的优点在于设计简单、延迟低(一次总线事务即可完成状态转移)。但其根本缺陷在于总线的可扩展性有限——当核心数量增加时,总线成为带宽瓶颈。对于4\sim8核的系统,监听协议仍然是实用且高效的选择;对于更大规模的系统,则需要引入目录协议(见9.3 节)。

硬件描述 1 — Cache控制器的硬件实现

一致性协议在硬件中由每个核心的Cache控制器(Cache Controller)实现。Cache控制器是一个复杂的有限状态机,它同时处理两个方向的事件:来自处理器的访存请求(PrRd/PrWr)和来自互连网络的一致性消息(Snoop请求、数据响应、确认消息等)。

在硬件实现中,Cache控制器通常包含以下关键组件:

  • MSHR(Miss Status Holding Register):每个未完成的一致性事务占用一个MSHR条目,记录事务的当前状态(瞬态状态)、等待的Ack计数、缓存的数据等。典型的L1 Cache控制器有8\sim16个MSHR条目。

  • Snoop缓冲区:存储来自互连的Snoop请求。Snoop请求的处理通常具有高优先级——Cache控制器必须及时响应Snoop请求以避免协议停顿。

  • 写回缓冲区:存储需要写回到下一级Cache或主存的脏数据。写回操作通常在Cache行被驱逐或从M/O状态被强制降级时发生。

  • 一致性状态位:每个Cache行的Tag中额外存储2\sim3位的一致性状态(M/E/S/I或更多状态),这些状态位与Tag同时被读取和更新。

Cache控制器的状态机必须能够正确处理并发事件——例如,当Cache控制器正在等待一个BusRdX请求的响应时(Cache行处于瞬态状态IMD\mathrm{IM}^D),可能同时收到来自另一个核心对同一地址的Snoop请求。协议必须为每种瞬态状态下的每种可能的Snoop请求定义明确的响应行为——这是协议复杂性的主要来源。

MSI协议

MSI协议是最基本的写无效(Write-Invalidate)一致性协议,使用三种稳定状态:

  • Modified(M):该Cache行已被当前核心修改,且是系统中唯一的有效副本。持有M状态的核心拥有读写权限。该行的数据与主存不一致("脏"数据),在被驱逐时必须写回主存。

  • Shared(S):该Cache行是干净的(与主存一致),可能在多个核心的Cache中同时存在。持有S状态的核心拥有只读权限。

  • Invalid(I):该Cache行无效,不包含有用数据。逻辑上等价于不在Cache中。

MSI协议精确地实现了SWMR不变量:M状态对应"Single Writer"(恰好一个核心拥有读写权限,其他核心的对应行都处于I状态),S状态对应"Multiple Reader"(零个或多个核心拥有只读权限),I状态对应"既不读也不写"。

处理器端事件

每个Cache控制器可能收到来自处理器的两类请求:

  • PrRd(Processor Read):处理器读取一个Cache行。

  • PrWr(Processor Write):处理器写入一个Cache行。

总线端事件与侦听总线事务分类

Cache控制器通过监听总线来观察其他核心的活动。为了精确理解一致性协议的运作机制,必须首先对总线事务的类型和语义进行严格的分类。总线上的事务类型包括:

  • BusRd(Bus Read):某个核心请求读取一个Cache行,用于PrRd缺失(从I状态获取共享副本)。BusRd不要求其他核心无效化自己的副本——发出BusRd的核心只需要只读权限。当一个持有M状态副本的核心监听到BusRd时,它必须提供数据(因为主存中的数据已过期),并将自己的状态从M降级为S(在MSI/MESI中)或O(在MOESI中)。BusRd消息在总线上只携带地址信息,不携带数据,其宽度通常为Tag位数(约30\sim40位)加上少量控制位。

  • BusRdX(Bus Read Exclusive):某个核心请求独占读取一个Cache行,用于PrWr缺失(从I状态直接获取独占权限)。BusRdX同时完成两个操作:(1)获取数据;(2)无效化所有其他核心持有的该行副本。其他核心监听到BusRdX后,必须将对应行无效化(从M/E/S/O转为I)。如果某个核心持有M或O状态的副本,它还需要将脏数据提供给请求者。BusRdX可以理解为"BusRd + BusInv"的原子组合。

  • BusUpgr(Bus Upgrade):某个核心请求将已持有的共享副本升级为独占副本,用于PrWr命中S状态的情况。BusUpgr与BusRdX的关键区别在于:BusUpgr不需要传输数据——发出BusUpgr的核心已经在本地Cache中持有该行的有效数据(S状态),只需要获得独占写权限。因此BusUpgr只需要无效化其他核心的副本,不需要从主存或其他核心获取数据。这使得BusUpgr比BusRdX快得多——在典型的总线实现中,BusUpgr事务需要2\sim3个总线周期(仅传输地址+控制信号并等待确认),而BusRdX需要额外的数据传输周期(一个64字节Cache行在16字节宽的总线上需要4个数据传输周期)。

  • BusWB(Bus Write-Back):某个核心将修改过的数据写回主存,通常在M状态(或O状态)的行被驱逐时发生。BusWB是一个非一致性事务——它不改变任何其他核心的Cache状态,仅将脏数据写入主存以保证持久性。

  • Flush(数据刷出):当一个持有M状态副本的核心监听到BusRd或BusRdX时,它需要将脏数据提供出去。Flush操作将该Cache行的完整数据(64字节)放到总线上。在早期的总线协议中,Flush将数据同时写回主存;在现代处理器中,通常采用Cache-to-Cache Transfer——数据直接从提供核心传输到请求核心,跳过主存。两种实现在功能上等价,但后者减少了一次内存写入操作。

  • FlushOpt(Optimized Flush):FlushOpt是Flush的带宽优化版本,仅将数据提供给请求核心而不写回主存。在MOESI协议中,当M状态核心监听到BusRd时,它可以发出FlushOpt将数据传给请求者,自身转为O状态。由于O状态的核心负责在后续被驱逐时写回主存,FlushOpt避免了此刻的主存写入,节省了内存带宽。AMD的MOESI实现广泛使用FlushOpt来优化Cache-to-Cache传输。

表 9.1总结了各种总线事务的关键属性。

设计提示

BusUpgr事务是MESI/MOESI协议中一个重要但常被忽视的优化。在MSI协议中,S\toM的升级使用BusRdX,需要从主存或其他核心获取数据——但请求核心已经持有该数据的有效副本(S状态的数据与主存一致),重复获取数据纯粹是浪费。BusUpgr通过只发送"请无效化"的请求(不请求数据)来消除这种浪费。在写密集且数据频繁从S升级到M的工作负载中(如数据库的锁管理),BusUpgr相比BusRdX可以节省30%\sim50%的总线带宽。

图 9.2展示了MSI协议中处理器端(即Cache控制器对处理器请求的响应)的状态转移图。图图 9.3展示了总线端(即Cache控制器对其他核心总线请求的响应)的状态转移图。

MSI协议——处理器端状态转移图
MSI协议——处理器端状态转移图
MSI协议——总线端状态转移图(监听其他核心的请求)
MSI协议——总线端状态转移图(监听其他核心的请求)

MSI协议完整状态转换表

为了对MSI协议建立严格的理解,表表 9.2给出了完整的3×63 \times 6状态转换表(3种状态×\times6种事件),逐条解释每一个转换的含义和动作。6种事件包括:处理器端的PrRd、PrWr,以及总线端监听到的BusRd、BusRdX、BusUpgr、Flush。

对上述状态转换表的关键行进行逐条深入分析。这种逐行分析虽然冗长,但对于理解一致性协议的每一个设计决策为什么是正确的、为什么是必要的至关重要——一致性协议中没有多余的状态转换,每一条规则都对应着对SWMR不变量或数据值不变量的维护。

(1)I\toS(PrRd/BusRd):这是最常见的一致性事务之一——冷缺失(Compulsory Miss)或容量缺失后的读取。处理器发出BusRd,总线上的所有Cache控制器检查自己是否持有该行:如果某个核心持有M状态副本,该核心通过Flush提供最新数据,请求核心和提供核心都进入S状态;如果没有核心持有M副本,则由主存(或包含式LLC)提供数据。BusRd事务在总线上需要一个地址传输周期和(如果数据从其他核心提供)一个数据传输阶段。对于64字节Cache行在16字节总线宽度下,数据传输需要4个时钟周期。

为什么目标是S而不是M?因为请求只是PrRd(读操作),不需要写权限。进入S状态满足SWMR不变量的"Multiple Reader"条件——允许多个核心同时持有只读副本。如果直接进入M状态,就需要无效化所有其他副本(因为M要求独占),这对于一个读操作来说是不必要的代价。

(2)I\toM(PrWr/BusRdX):写缺失——处理器需要对一个不在本地Cache中的地址进行写操作。BusRdX同时完成数据获取和无效化两个操作。与BusRd不同,BusRdX会导致所有持有该行副本的核心将其无效化。这个事务的代价最高:除了数据传输外,所有S状态的共享者都需要响应无效化请求,在实现中可能需要额外的确认机制。

(3)S\toM(PrWr/BusRdX):写升级——处理器已持有S状态副本,但需要写权限。在基本的MSI协议中,这个升级仍然使用BusRdX(即请求数据+无效化),尽管请求核心已经持有有效数据。这是MSI协议的一个已知低效之处——数据传输是多余的。MESI协议通过引入E状态和BusUpgr事务来解决这个问题。在MSI中,S\toM的BusRdX事务在一个4核系统中消耗约12\sim25个总线周期,其中约8个周期用于传输本来不需要的数据。

(4)M\toS(监听BusRd/Flush):干预(Intervention)——其他核心读取本核心独占修改的数据。持有M状态的核心必须介入,因为主存中的数据是过期的(Stale)。在早期的总线实现中,Flush操作将数据同时写回主存和提供给请求者;在现代实现中(如MOESI的FlushOpt),数据只传给请求者,不写回主存。M\toS转换后,数据存在于至少两个核心中(请求者和原持有者),满足SWMR的"多读者"条件。

(5)M\toI(监听BusRdX/Flush):强制无效化——其他核心请求写该行。持有M状态的核心必须提供脏数据(否则数据会丢失),然后将自己的副本无效化。这是一致性协议中延迟最长的事务之一:请求核心从发出BusRdX到获得独占权限,需要等待总线仲裁(1\sim2周期)、地址传输(1周期)、Snoop响应(2\sim3周期)和数据传输(4周期),总计约8\sim10个总线周期。

(6)S\toI(监听BusRdX/无效化):被动无效化——其他核心请求写权限,本核心的共享副本被无效化。这个操作本身很快(只需要标记Tag中的状态位从S改为I,1个周期即可完成),但如果该核心在无效化后又需要访问同一地址,则会产生一致性缺失(Coherence Miss),代价为一次完整的BusRd事务。

MSI协议事务举例

为了建立直观理解,下面通过一个具体的事务序列来展示MSI协议的运作过程。假设系统有三个核心(C0、C1、C2),它们对地址XX执行以下操作序列:

在步骤3中,C0已经持有XX的S状态副本,但要写入XX需要将其升级为M状态。C0发出BusRdX请求,该请求广播到总线上,C1监听到后将自己的XX副本从S状态转为I状态。此时只有C0持有XX的有效副本(M状态),满足SWMR不变量的"Single Writer"条件。

在步骤4中,C2请求读取XX。C2发出BusRd请求,C0监听到后将自己的数据提供给C2(同时写回主存),并将自己的状态从M转为S。此时C0和C2都持有XX的S状态副本,满足SWMR不变量的"Multiple Reader"条件。

写无效与写更新

MSI协议属于写无效(Write-Invalidate)协议族:当一个核心要写入共享数据时,它通过发送无效化消息来使其他核心的副本失效,而非将新的值广播给所有持有副本的核心。与之对应的是写更新(Write-Update)协议(如Dragon协议):写入时将新值广播到所有持有副本的核心,让它们同时更新数据。

写无效协议在实际系统中远比写更新协议更为常用,原因有三:

(1)带宽效率。在写无效协议中,无效化消息只需要携带地址(通常几十位),而写更新消息需要携带完整的数据(通常64字节 = 512位)。当一个核心对一个Cache行执行多次写操作时(例如更新一个结构体的多个字段),写无效协议只需要在第一次写时发送一次无效化消息,后续的写操作不产生任何总线流量;而写更新协议在每次写操作时都需要广播数据。

(2)连续写的优化。在写无效协议中,一旦一个核心获得了某Cache行的独占权限(M状态),后续对该行的所有读写操作都在本地完成,不需要任何一致性流量。这对于写密集型的工作负载(如矩阵运算、图像处理)非常有利。

(3)虚假共享的影响。写更新协议在存在虚假共享(False Sharing)时会产生大量无用的更新流量。虚假共享是指两个核心访问同一Cache行中不同的字节——它们在逻辑上并不共享数据,但由于位于同一Cache行中而被一致性协议视为共享。在写更新协议中,每次写操作都会触发对整个Cache行的广播更新;在写无效协议中,虽然第一次写操作会触发无效化,但后续的写操作不产生流量。

目前所有主流商用处理器(Intel、AMD、ARM、Apple、RISC-V)都采用写无效协议。写更新协议仅在学术研究和一些特殊场景(如GPU的纹理Cache一致性)中有所应用。

MSI协议的总线带宽消耗分析

为了量化MSI协议的总线行为,考虑以下分析模型。设一个4核系统运行一个多线程工作负载,每个核心每1000条指令产生rrd=20r_{\text{rd}} = 20次L1 D-Cache读缺失和rwr=15r_{\text{wr}} = 15次写缺失。其中约70%的写缺失发生在私有数据上(仅被当前核心访问),30%发生在共享数据上。

MSI协议下各种总线事务的频率(每1000条指令/每核):

  • BusRdrrd=20r_{\text{rd}} = 20次,每次传输地址(1周期)+数据(4周期,如果数据从主存获取可能更长)。

  • BusRdX(写缺失,I\toM):约rwr×0.5=7.5r_{\text{wr}} \times 0.5 = 7.5次(另外一半写缺失是S\toM升级),每次传输地址+无效化+数据。

  • BusRdX(写升级,S\toM):约rwr×0.5=7.5r_{\text{wr}} \times 0.5 = 7.5次。在MSI中这些升级也使用BusRdX,需要不必要的数据传输。

  • BusWB(M状态驱逐):约5\sim10次,每次传输地址+64字节数据。

总的总线事务数约为20+7.5+7.5+7.5=42.520 + 7.5 + 7.5 + 7.5 = 42.5次/千指令/核心。对于4核系统,总线上的总事务率约为42.5×4=17042.5 \times 4 = 170次/千指令。以4 GHz处理器、IPC=2计算,每秒执行8×1098 \times 10^9条指令,每秒的总线事务数约为170×8×106=1.36×109170 \times 8 \times 10^6 = 1.36 \times 10^9次。如果每次事务平均消耗10个总线周期,则要求总线吞吐量至少为1.36×10101.36 \times 10^{10}周期/秒——这需要一条至少运行在13.6 GHz的总线,显然超出了实际可行的范围。

这个简单的计算说明了为什么纯总线架构的监听协议在4核以上的系统中面临严峻的带宽挑战,也解释了E状态(减少不必要的BusRdX)、O状态(减少BusWB)和BusUpgr事务(减少BusRdX中的数据传输)等优化的必要性。

MSI协议的运作示例:多步推演

为了加深对MSI协议状态机运作的理解,下面通过一个更复杂的多步操作序列,展示各种边界条件下的协议行为。假设系统有4个核心(C0\simC3),初始状态下地址XX不在任何Cache中。

时刻T1:C0读取XX。C0的Cache控制器发现XX处于I状态,发出BusRd。总线仲裁后,BusRd广播到所有Cache控制器。C1\simC3的控制器检查各自的Tag——均未命中,不断言S#信号(但在MSI中没有S#信号机制,所有BusRd的结果都进入S状态)。主存提供数据。结果:C0进入S状态,C1\simC3保持I。

时刻T2:C1读取XX。C1发出BusRd。C0监听到BusRd,发现自己持有XX的S状态副本。在MSI协议中,S状态的核心不需要对BusRd做任何响应(数据由主存提供,因为S状态的数据与主存一致)。结果:C0和C1都处于S状态。

时刻T3:C2读取XX。类似T2,C2发出BusRd,从主存获取数据。结果:C0、C1、C2都处于S状态。

时刻T4:C0写入XX=100。C0需要从S升级到M。C0发出BusRdX。总线上的BusRdX被C1和C2监听到——它们都将自己的XX副本从S状态转为I状态。C3忽略(I状态下对BusRdX无响应)。C0获得独占权限,将XX修改为100。结果:C0为M(值=100),C1、C2、C3为I。注意此时主存中XX仍为初始值(MSI中M\toS需要Flush才更新主存)。

时刻T5:C3读取XX。C3发出BusRd。C0监听到BusRd,发现自己持有M状态副本(脏数据)。C0必须介入——通过Flush将数据(值=100)放到总线上。C3从总线获取最新数据。C0从M降级为S。同时Flush操作将数据写回主存(主存更新为100)。结果:C0和C3都处于S状态(值=100),主存值=100。

时刻T6:C0再次写入XX=200。C0持有S状态,需要再次发出BusRdX来获取独占权限。C3的S副本被无效化。C0获得M状态。结果:C0为M(值=200),主存仍为100(脏数据)。

这个六步序列展示了MSI协议的所有关键转换。特别注意T4和T6中的BusRdX事务——在MSI中,这两次写升级都需要完整的BusRdX事务(包含数据传输),而在MESI协议中,如果C0在T1时进入E状态(因为没有其他共享者),T4的写操作可以通过E\toM静默升级完成,节省一次总线事务。

性能分析 1 — MSI协议的性能缺陷

MSI协议有一个显著的性能问题:当一个核心读取了一个仅被自己使用的Cache行(不被其他核心共享)后,如果接下来要写入该行,仍然需要发出BusRdX请求来从S状态升级到M状态。这次总线事务实际上是不必要的——因为没有其他核心持有该行的副本,不需要发送无效化消息。在许多实际工作负载中,大量数据是私有的(只被一个核心使用),这种不必要的升级事务浪费了宝贵的总线带宽。MESI协议通过引入Exclusive状态来解决这个问题。

MESI协议

MESI协议(也称Illinois协议)在MSI的基础上增加了Exclusive(E)状态,用于区分"独占但干净"的情况和"共享"的情况。MESI的四种状态为:

  • Modified(M):与MSI相同——该行已被修改,是唯一的有效副本,数据与主存不一致。

  • Exclusive(E):该Cache行是干净的(与主存一致),并且是系统中唯一的副本。持有E状态的核心拥有读权限,并且可以静默地升级到M状态——即在写入时不需要发出任何总线事务。

  • Shared(S):与MSI相同——该行是干净的,可能在多个核心中存在。

  • Invalid(I):与MSI相同——无效。

E状态的引入解决了MSI中"私有数据的不必要升级事务"问题。当一个核心读取一个Cache行时,如果该行不在任何其他核心的Cache中,它将进入E状态而非S状态。后续的写操作可以直接将E状态转为M状态,无需发出任何总线请求。

E状态的正确性可以用SWMR不变量来验证:E状态表示只有一个核心持有该行的有效副本,且该核心尚未写入——这与M状态一样满足"Single Writer"的前提条件(没有其他核心持有副本),因此升级到M不需要发送无效化消息。

E状态引入的量化动机分析

E状态的引入动机来自对实际工作负载中写行为的深入分析。研究表明,在典型的单线程和多线程工作负载中,大量的写操作发生在私有数据上——即写入的Cache行只被一个核心独占使用,不被其他核心共享。在SPEC CPU基准测试中,约60%\sim80%的L1 D-Cache有效行在任一时刻只被一个核心持有。

在MSI协议中,这些私有数据经历的状态转换路径为I\toS(BusRd)\toM(BusRdX),共需要两次总线事务。第二次BusRdX事务是完全不必要的——因为没有其他核心持有该行的副本,无效化消息无人需要接收,数据也无需重新获取。BusRdX事务在总线上消耗的带宽和延迟如下:

  • 总线仲裁:1\sim2个周期(竞争总线控制权)。

  • 地址传输:1个周期(发送BusRdX地址和控制信号)。

  • Snoop窗口:3\sim5个周期(等待所有Cache控制器检查并响应)。

  • 数据传输:4\sim8个周期(传输64字节Cache行数据,实际上是多余的)。

  • 总计约9\sim16个周期——对于一个本可以0周期完成的操作(静默升级)。

在MESI协议中,相同操作的路径为I\toE(BusRd,S#未断言)\toM(静默升级,无总线事务),只需一次总线事务。E\toM的静默升级在本地Cache控制器中只需要修改2位的一致性状态位(从E编码改为M编码),通常可以在Tag写入端口的一个周期内完成。

考虑一个具体的数值估算:假设一个4核处理器运行SPEC CPU 2017的gcc基准测试,每个核心的L1 D-Cache每1000条指令产生约15次写缺失。其中约70%(10.5次)是对私有数据的写入。在MSI协议中,这10.5次写入中的每一次都需要一次额外的BusRdX事务;在MESI协议中,这些事务全部被E\toM静默升级消除。以每次BusRdX消耗12个总线周期估算,MESI每1000条指令节省10.5×12=12610.5 \times 12 = 126个总线周期的带宽。对于一个4 GHz处理器以IPC=2运行,每秒执行8×1098 \times 10^9条指令,MESI每秒节省约8×106×126=1098 \times 10^6 \times 126 = 10^9个总线周期的带宽——相当于250 MHz×\times16字节宽度总线的25%吞吐量

从另一个角度理解,BusUpgr事务(S\toM升级)虽然比BusRdX高效(不需要数据传输),但仍需要总线仲裁和Snoop窗口(约5\sim7个周期)。E状态则将这个开销降为0——因为连BusUpgr都不需要。这是E状态相比"MSI+BusUpgr优化"的额外优势。

E/S状态的判定

当一个核心发出BusRd请求时,如何判断该行应该进入E状态还是S状态?这依赖于总线上的一个额外信号——Shared Line(S#)。当总线上广播BusRd请求时,所有持有该行有效副本的核心将断言S#信号。请求核心根据S#信号来决定:

  • 如果S#被断言(至少一个其他核心持有该行):进入S状态。

  • 如果S#未被断言(没有其他核心持有该行):进入E状态。

图 9.4展示了MESI协议的完整状态转移图(处理器端和总线端合并)。

MESI协议状态转移图(实线:处理器端;虚线:总线端监听)
MESI协议状态转移图(实线:处理器端;虚线:总线端监听)

表 9.4以表格形式完整总结了MESI协议的所有状态转移规则,方便设计者快速查阅。

图 9.5以形式化的有限自动机(Finite Automaton)视角重绘MESI协议的状态转移。处理器端事件(PrRd/PrWr)用实线箭头表示,总线端监听事件用虚线箭头表示。这种自动机表示法与硬件验证中形式化模型检验(Model Checking)使用的状态机描述直接对应——验证工具可以自动枚举所有可达状态并检查SWMR不变量是否被违反。

MESI协议的有限自动机表示。四个稳定状态(M/E/S/I)和所有合法的状态转移。实线箭头为处理器端事件驱动的转移,虚线箭头为总线监听事件驱动的转移。注意E$\to$M的静默升级不产生任何总线事务——这是MESI相对于MSI的核心优化。
MESI协议的有限自动机表示。四个稳定状态(M/E/S/I)和所有合法的状态转移。实线箭头为处理器端事件驱动的转移,虚线箭头为总线监听事件驱动的转移。注意E$\to$M的静默升级不产生任何总线事务——这是MESI相对于MSI的核心优化。

MESI协议事务举例

下面通过一个事务序列来展示E状态的实际效果。对比MSI和MESI在相同操作序列下的总线事务数量。

在上述4步操作中,MESI协议产生了2次总线事务,而MSI协议产生了4次总线事务。在步骤2和步骤4中,MESI的E\toM静默升级各节省了一次总线事务。这在写密集的工作负载中可以显著减少互连带宽消耗。

MESI中Shared Line信号的硬件实现

S#信号的硬件实现有两种主要方式:

(1)开漏(Open-Drain)信号线:在共享总线架构中,S#是一条专用的信号线,所有Cache控制器通过开漏驱动器连接到这条线上。在Snoop窗口期间,持有被请求Cache行的任何核心将其开漏输出拉低(断言S#)。如果没有任何核心拉低,S#保持高电平(未断言)。请求核心在Snoop窗口结束时采样S#的状态来决定进入E还是S。开漏实现简单且无竞争问题,但其电气特性(较大的RC延迟)限制了总线频率。

(2)OR汇聚逻辑:在点对点互连中(如Ring Bus、Mesh),不存在物理共享的S#信号线。取而代之的是,每个被监听的核心在其Snoop响应消息中携带一个"我持有此行"的标志位。互连网络或Home节点收集所有Snoop响应后,对这些标志位进行OR操作,将结果包含在返回给请求核心的数据消息中。这种实现的延迟更高(需要等待所有Snoop响应到达并汇聚),但与点对点互连的架构兼容。

在包含式LLC的实现中(如Intel Haswell之前的架构),S#的判定可以完全由LLC完成:如果LLC的目录信息显示该行在其他核心的私有Cache中存在,则等效于S#被断言;否则等效于S#未断言。这种实现避免了对Snoop响应的等待,将E/S状态的判定延迟降低到LLC的Tag查找延迟(约3\sim5个周期)。

案例研究 1 — Intel处理器中的MESI变体

Intel从Pentium Pro开始就采用MESI协议的变体作为其一致性协议的基础。在现代Intel处理器(如Golden Cove微架构)中,一致性协议基于MESIF(见9.2.4 节),但核心仍然是MESI框架。E状态在实际工作负载中提供了显著的性能提升。在SPEC CPU 2017的整数基准测试中,一个核心的L1 D-Cache中约60%\sim80%的有效Cache行处于E或M状态(即为该核心私有),只有20%\sim40%处于S状态。这意味着,如果没有E状态,大量的写操作将产生不必要的总线事务,严重浪费互连带宽。

从总线事务计数的角度来看,MESI相比MSI在典型服务器工作负载中减少了25%\sim40%的一致性流量。这一优势在核心数较少的桌面处理器中更为突出——桌面应用的数据共享程度更低,大部分数据是核心私有的。

MOESI协议

MOESI协议在MESI的基础上进一步增加了Owned(O)状态。O状态解决了MESI协议中的另一个性能问题:当一个持有M状态Cache行的核心监听到其他核心的BusRd请求时,MESI要求该核心将数据写回主存(Flush),然后转为S状态。这次写回操作会增加延迟并占用内存带宽,尤其在频繁共享的数据场景下是不必要的开销。

O状态的含义是:该Cache行已被修改(与主存不一致),但允许其他核心也持有该行的S状态副本。持有O状态的核心负责在后续提供数据(而非主存),并且在该行最终被驱逐时负责写回主存。

MOESI的五种状态:

  • Modified(M):已修改,唯一有效副本,与主存不一致。

  • Owned(O):已修改,但其他核心可能持有S状态副本。O状态核心是数据的"所有者",负责提供数据和最终的写回。与主存不一致。

  • Exclusive(E):干净,唯一副本,与主存一致。

  • Shared(S):干净或"可能脏"(如果有其他核心持有O状态),可能有多个副本。注意:在MOESI中,S状态的数据不一定与主存一致——如果存在一个O状态的副本,S状态副本的数据与O状态副本一致,但与主存不一致。

  • Invalid(I):无效。

图 9.6展示了MOESI协议的关键状态转移。

MOESI协议状态转移图(核心转移)
MOESI协议状态转移图(核心转移)

MOESI协议完整状态转换表

表 9.6给出了MOESI协议的完整状态转换表。与MSI和MESI相比,MOESI的状态转换表更为复杂,因为O状态引入了新的交互模式——O状态的核心既是数据的"所有者"又允许其他核心持有共享副本,这在状态机中产生了额外的转换路径。

对MOESI转换表中几个关键转换的深入分析:

(1)M\toO(监听BusRd/FlushOpt)——这是MOESI协议与MESI的核心区别。当一个持有M状态副本的核心监听到其他核心的BusRd时,在MESI中它必须转为S并写回主存(因为S状态要求数据与主存一致),而在MOESI中它转为O状态,通过FlushOpt将数据直接传给请求者,不写回主存。O状态的核心承担起"数据所有者"的角色——后续任何核心的BusRd都由O核心响应(而非从主存获取)。这种设计的正确性保证在于:O状态核心在被驱逐或无效化时必须负责将脏数据写回主存,确保数据不会丢失。

(2)O\toO(监听BusRd/FlushOpt)——O状态核心可以反复地为多个请求者提供数据,同时保持O状态不变。每次FlushOpt操作只涉及核心间的数据传输,不涉及主存。这在"一写多读"的工作负载中(如生产者–消费者模式、发布–订阅模式)极为高效。在MESI协议中,每次新的读者加入都需要从主存获取数据(因为之前的M\toS转换已经将数据写回主存,所有核心的S状态副本都从主存获取);而在MOESI中,O核心直接提供数据,延迟更低(核心间传输约20\sim40周期,而主存访问约100\sim200周期)。

(3)O\toM(PrWr/BusUpgr)——O状态核心要重新获得独占写权限。由于O状态核心已经持有最新数据(虽然其他核心可能有S副本),它只需要发出BusUpgr来无效化其他S共享者,不需要重新获取数据。BusUpgr事务只传输地址和控制信号(约3\sim5个总线周期),远快于BusRdX(需要额外的数据传输)。

(4)O\toI(监听BusRdX/Flush)——当其他核心请求独占写入时,O核心必须让出所有权。由于O状态的数据与主存不一致,O核心必须通过Flush将数据提供给请求者(可能同时写回主存),然后将自身无效化。这是O状态核心唯一需要与主存交互的场景——只有在所有权被夺走时,脏数据才最终写回。

(5)S\toM(PrWr/BusUpgr)——在MOESI中,S状态核心的写升级使用BusUpgr而非BusRdX。这一点很重要:如果系统中存在O状态核心,BusUpgr会同时无效化O核心。O核心收到BusUpgr后转为I,其"所有者"职责转移给了新获得M状态的核心。这意味着BusUpgr不仅无效化共享者,还隐式地完成了所有权的转移。

O状态的优势

考虑以下场景:Core 0持有地址XX的M状态副本。随后Core 1请求读取XX。在MESI协议中,Core 0必须将XX的数据写回主存,然后Core 0和Core 1都进入S状态。而在MOESI协议中,Core 0只需要直接将数据提供给Core 1,Core 0转为O状态,Core 1进入S状态——不需要写回主存。这节省了一次内存写操作的延迟和带宽。

如果随后Core 2也请求读取XX,Core 0(O状态)再次提供数据,Core 2也进入S状态。在整个过程中,数据始终没有被写回主存。只有当Core 0最终驱逐了这个O状态的Cache行(或者某个核心请求写入XX),数据才需要写回主存。

AMD使用MOESI协议的详细原因分析

AMD从K8处理器(2003年)开始采用MOESI协议,此后的Athlon 64、Opteron以及整个Zen系列(Zen到Zen 5)都延续了这一选择。AMD选择MOESI而非Intel的MESI/MESIF有深层的架构原因。

(1)AMD的多芯片直连架构。AMD从Opteron开始采用直连互连(HyperTransport,后改为Infinity Fabric),而非Intel当时使用的前端总线(FSB)。在直连架构中,每个处理器芯片有自己的本地内存控制器。当Core A(位于Socket 0)请求读取Core B(位于Socket 1)持有的M状态数据时,在MESI协议中,Core B必须将数据写回主存(可能位于Socket 0或Socket 1的本地DRAM),然后数据从主存传给Core A。这涉及两次内存事务。在MOESI中,Core B直接通过HyperTransport/Infinity Fabric将数据传给Core A,Core B转为O状态——只需一次核心间传输,完全跳过主存。在NUMA系统中,跨Socket的内存访问延迟可达100\sim200ns,而核心间直接传输延迟约为30\sim50ns。O状态使AMD的NUMA系统避免了大量的远程内存写回操作。

(2)带宽节省的量化分析。在一个典型的双Socket AMD EPYC系统中运行数据库工作负载(如MySQL的OLTP),跨Socket的一致性流量占总互连带宽的约15%\sim25%。其中约40%\sim50%是M\toS转换引发的数据传输。如果使用MESI协议,每次M\toS转换都需要一次内存写回(64字节×\times主存写入延迟),这些写回操作消耗内存控制器的写端口带宽。以DDR5内存控制器的典型写带宽(每通道25.6 GB/s)估算,写回操作可能占用高达5%\sim10%的内存写带宽。MOESI的O状态将这部分写回全部延迟或消除,释放的带宽可以服务更多的demand读请求,对延迟敏感的OLTP工作负载带来2%\sim5%的吞吐量提升。

(3)写回合并优化。在MOESI协议中,一个持有O状态的Cache行如果再次被本核心写入(O\toM转换,通过BusRdX获取独占权限),它只需要无效化其他核心的S状态副本,而不需要先写回主存再重新读取。这种"延迟写回"策略的一个推论是:如果一个Cache行在被多个核心依次读取和写入的过程中(如生产者–消费者模式),数据可能始终不经过主存——完全在各核心的Cache之间传递。只有当持有O/M状态的Cache行被LRU替换算法驱逐时,脏数据才最终写回主存。

(4)O状态的硬件代价。O状态在硬件上的额外代价很小:每个Cache行的Tag中需要增加约0.5位的存储(从2位的MSI编码扩展到3位的MOESI编码,实际上M/O/E/S/I五态仍可用3位编码,不增加额外位宽)。Cache控制器的状态机复杂度增加约20%\sim30%(需要额外处理O状态下的PrWr、BusRd、BusRdX和驱逐事件)。考虑到O状态带来的显著带宽和延迟优势,这个硬件代价是非常合理的。

(5)与Intel MESIF的设计对比。Intel选择MESIF而非MOESI,反映了两家公司在一致性优化方向上的不同侧重。Intel的F状态优化的是"多个核心共享同一只读数据"的场景——指定一个转发者避免多个核心同时响应BusRd。AMD的O状态优化的是"数据从写者传播到读者"的场景——避免写回主存。两种优化是互补的,解决的是不同的性能瓶颈。AMD在2020年代的Zen 3/4/5中也引入了类似F状态功能的"指定转发者"机制(集成在L3 Cache的Snoop Filter中),但核心的MOESI框架保持不变。

MOESI与MESI的对比举例

表 9.7量化展示了MOESI中O状态对内存写回操作的节省效果。

在这个生产者–消费者场景中,数据在C0和C1之间反复传递。MESI协议每次M\toS转移都需要将整个Cache行写回主存(步骤2、4、6),共产生3次64字节的内存写操作。MOESI协议通过O状态完全避免了这些写回操作——数据直接在核心之间传递,主存始终不参与。在高频率的生产者–消费者交互中(如线程间的消息队列),MOESI可以节省大量的内存带宽。

设计权衡 1 — MOESI vs MESI的设计权衡

MOESI协议的O状态通过延迟写回来减少内存带宽消耗,这在以下场景中尤为有效:

  • 生产者–消费者模式:一个核心写入数据后,多个核心读取该数据。没有O状态时,每次从M转S都需要写回主存;有O状态时,只需要核心间直接传输数据。

  • 锁的实现:获取锁的核心修改锁变量(M状态),释放锁后其他核心读取锁变量。O状态避免了锁变量的反复写回。

但O状态也增加了协议的复杂性:Cache控制器需要额外的逻辑来跟踪哪个核心是"Owner",并且在O状态被驱逐时必须确保数据被正确写回。AMD从K8处理器开始采用MOESI协议,在其Zen系列中继续使用并进行了优化。

MESIF协议

MESIF协议是Intel在Nehalem架构(2008年)中引入的一致性协议变体,在MESI基础上增加了Forward(F)状态。MESIF主要用于Intel的QPI(QuickPath Interconnect)和后续的UPI(Ultra Path Interconnect)互连中。

F状态的动机来自于MESI协议在多核共享读取场景中的一个效率问题:当多个核心同时持有某个Cache行的S状态副本时,如果又有一个新的核心请求读取该行,谁来提供数据?在MESI中,所有S状态的核心都可以提供数据(或者由主存提供),这可能导致多个核心同时响应同一个BusRd请求,产生冲突。

F状态解决了这个问题:在所有持有某个Cache行共享副本的核心中,恰好有一个核心被指定为F状态(其余为S状态)。F状态的语义是"我是被指定的转发者"——当有新的BusRd请求时,只有持有F状态的核心负责提供数据,其他S状态的核心不响应。

MESIF的五种状态:

  • M、E、S、I:与MESI相同。

  • Forward(F):与S类似(干净的共享副本),但被指定为数据的转发者。在所有共享副本中,最多只有一个处于F状态。当持有F状态的核心驱逐该行时,它需要将F状态转移给另一个持有S状态的核心(或者让新的请求者获得F状态)。

F状态的工作过程

考虑一个4核系统中的具体场景:

(1)Core 0首先读取地址XX,由于没有其他核心持有XX,Core 0进入E状态。 (2)Core 1请求读取XX,Core 0监听到BusRd,Core 0从E转为S,Core 1获得F状态(在MESIF中,最后一个获取共享副本的核心获得F状态)。 (3)Core 2请求读取XX,只有持有F状态的Core 1响应,提供数据给Core 2。Core 1从F转为S,Core 2获得F状态。 (4)Core 3请求读取XX,只有持有F状态的Core 2响应。Core 2从F转为S,Core 3获得F状态。

在步骤(3)和(4)中,如果使用MESI协议(没有F状态),所有S状态的核心都可能同时响应BusRd请求,在基于点对点互连(而非共享总线)的系统中可能导致多个核心同时发送数据给请求者——这是一种带宽浪费。F状态通过精确指定"谁来响应"来避免这种冗余数据传输。

F状态与O状态的本质区别

表面上看,F状态和O状态都解决了"数据传输由谁负责"的问题,但它们在数据一致性的语义上有根本区别:

O状态的数据与主存不一致——O状态核心持有的是被修改过的脏数据。因此O状态核心承担着"最终写回"的责任:当O状态行被驱逐时,必须将数据写回主存(或下一级Cache)。O状态可以理解为"这个数据被修改过,但我允许别人读取它的拷贝"。

F状态的数据与主存一致——F状态核心持有的是干净数据,它只是被"指定"为数据的转发者。当F状态行被驱逐时,不需要写回(因为主存中的数据是最新的)。F状态可以理解为"这个数据是干净的,但大家约定由我来负责响应新的读请求"。

这一区别有重要的性能含义。在MOESI中,O状态行被驱逐的代价较高(需要写回64字节到主存);在MESIF中,F状态行被驱逐几乎没有代价(静默丢弃即可,但F角色需要转移给其他S核心或新的请求者)。因此MESIF在Cache容量压力大(频繁驱逐)的场景下可能比MOESI更高效——驱逐F行不产生写回流量。

F状态的迁移策略

当持有F状态的核心将对应Cache行驱逐时,F的"转发者"角色需要转移。Intel的实现采用以下策略:

(1)新请求者继承F:如果在F被驱逐之前有新的核心请求该行,新请求者自动获得F状态。这是最常见的情况。

(2)F静默丢弃:如果没有新请求者,F行被静默驱逐——此时没有任何核心持有F状态。下次有核心请求该行时,数据从主存获取(因为S状态核心不负责响应),新获取者成为F。

(3)定向通知(在某些实现中):F被驱逐时通知Home Agent,Home Agent可以将F角色重新指定给另一个S核心。但这增加了协议复杂度和驱逐延迟。

表 9.8总结了四种常见协议族中各状态的属性。

状态有效脏(与主存不一致)独占可静默写入
Modified (M)
Owned (O)
Exclusive (E)
Shared (S)
Forward (F)否(但是唯一转发者)
Invalid (I)

一致性协议状态属性总结

设计提示

F状态与O状态虽然都解决了"谁提供数据"的问题,但它们的适用场景不同。O状态用于数据已被修改(与主存不一致)的情况,O状态的核心是数据的Owner,必须在被驱逐时写回主存。F状态用于数据是干净的(与主存一致)的情况,F状态的核心只是被指定的转发者,即使F状态被驱逐也不需要写回(因为主存中的数据是最新的)。因此,MOESI和MESIF是两种不同的优化方向,分别被AMD和Intel采用。

监听过滤器

随着核心数量的增加,基于监听的协议面临严重的可扩展性问题:每个Cache的一致性请求都需要广播到所有其他Cache控制器。对于NN核系统,每个核心发出的BusRd/BusRdX请求都会被其他N1N-1个核心的Cache控制器处理。即使绝大多数监听操作的结果是"未命中"(该行不在被监听的Cache中),监听操作本身仍然消耗能量和Cache端口带宽。

监听过滤器(Snoop Filter)通过在互连网络中维护一个目录结构来减少不必要的监听请求。监听过滤器记录了每个Cache行可能存在于哪些核心的Cache中,当收到一致性请求时,只将监听消息发送给实际可能持有该行的核心,而非广播给所有核心。

监听过滤器的核心思想

监听过滤器的核心思想是用一个有限大小的目录结构替代全广播。在纯监听协议中,每个BusRd/BusRdX请求都被广播到所有NN个Cache控制器,每个控制器都必须进行Tag查找来判断自己是否持有被请求的行。对于一个N=16N=16核的系统,一次一致性请求导致16次Tag查找。实际统计表明,在SPEC CPU基准测试中,约70%\sim90%的Snoop查找结果是"该行不在我的Cache中"——这意味着大部分Snoop操作是无用功,不仅浪费Cache Tag端口的带宽,还消耗约100 fJ\sim1 pJ每次查找的动态功耗。监听过滤器通过提前判断"谁可能持有该行"来过滤掉这些无用的Snoop查找。

从概念上看,监听过滤器是侦听协议和目录协议之间的中间地带:它在广播互连中引入了有限的目录信息,从而在保持侦听协议低延迟优势的同时,减少了不必要的总线流量。监听过滤器的每个条目包含以下字段:

  • Tag:被追踪的Cache行地址(通常取物理地址的高位部分),位宽为log2(M/B)log2(sets)\lceil\log_2(M/B)\rceil - \lceil\log_2(\text{sets})\rceil位。对于4 GB物理地址、64字节行、4096组的过滤器,Tag约为32612=1432 - 6 - 12 = 14位。

  • 存在位向量(Presence Bit Vector):NN位,每位对应一个核心。若第ii位为1,表示核心ii的私有Cache中可能持有该行。

  • 一致性状态(可选,2\sim3位):记录该行的全局一致性状态(如I/S/M),用于辅助一致性决策。

对于N=8N=8核系统,每个过滤器条目的总位宽为14+8+3=2514 + 8 + 3 = 25位。如果过滤器有16K个条目(2142^{14}×\times4路组相联),总存储开销约为16384×4×25/8200KB16384 \times 4 \times 25 / 8 \approx 200\,\text{KB}

包含式监听过滤器

包含式监听过滤器(Inclusive Snoop Filter)为系统中所有私有Cache中的每一行维护一个存在位向量(Presence Bit Vector)。每当一个核心将一个Cache行取入其私有Cache时,监听过滤器中相应的位被设置;每当一个Cache行被驱逐时,相应的位被清除。当一致性请求到达时,监听过滤器查找该地址的存在位向量,只向位被设置的核心发送监听消息。

包含式监听过滤器的结构示意
包含式监听过滤器的结构示意

包含式监听过滤器的存储开销为:对于NN核系统,每个被追踪的Cache行需要NN位的存在位向量加上Tag。如果过滤器需要追踪所有私有Cache中的行,其容量与所有私有Cache的总容量成正比。在实际实现中,监听过滤器通常与LLC集成在一起——LLC的每个Tag条目中额外增加NN位的存在位向量。Intel在Haswell及后续处理器中就采用了这种设计,利用LLC的包含性(Inclusive LLC)来实现高效的监听过滤。

排他式监听过滤器

排他式过滤器不追踪"谁持有什么",而是追踪"最近被监听过但不在任何Cache中的行"。其原理是:如果一个地址最近被监听过并且确认不在任何Cache中,那么短时间内的再次监听可以直接被过滤掉而不需要实际发送。这种设计的存储开销更小,但过滤效果不如包含式。

包含式LLC与监听过滤的耦合

在许多现代处理器中,监听过滤功能与LLC的包含性策略紧密耦合。如果LLC采用包含式策略(Inclusive Policy),即要求私有Cache中的所有行必须在LLC中也存在,那么LLC的Tag本身就构成了一个天然的监听过滤器——只有在LLC中命中的地址才可能在某个私有Cache中存在,LLC中缺失的地址一定不在任何私有Cache中。

这种设计的优势在于无需额外的监听过滤器硬件——LLC的Tag和一致性位向量已经包含了所有需要的信息。但其代价是包含式LLC的有效容量降低:LLC中必须保留私有Cache的全部内容,减少了LLC可以存储独有数据的空间。对于一个16核处理器,每核64 KB L1D + 512 KB L2,私有Cache的总容量为(64+512)×16=9.2MB(64 + 512) \times 16 = 9.2\,\text{MB}。如果LLC总容量为24 MB,那么其中至少9.2 MB被私有Cache副本"占据",LLC的有效独有容量仅为14.8 MB。

Intel从Skylake-SP开始转向非包含式LLC(Non-Inclusive LLC)设计,在LLC中嵌入了独立的Snoop Filter来维护监听过滤信息,从而将LLC的全部容量用于存储有用的数据。这种设计的Snoop Filter条目数通常为LLC条目数的1.5\sim2倍(因为需要追踪所有私有Cache中的行,其总数可能超过LLC容量),需要额外的SRAM面积,但换来了更大的有效LLC容量。

性能分析 2 — 监听过滤器的有效性

在一个8核处理器中运行PARSEC基准测试套件时,包含式监听过滤器可以过滤掉60%\sim90%的监听请求(具体比例取决于工作负载的共享特性)。对于以私有数据访问为主的工作负载(如blackscholes),过滤率可以高达95%以上;对于以共享数据为主的工作负载(如fluidanimate),过滤率较低,约为50%\sim60%。即便如此,监听过滤器仍然是在适度核心数量下维持监听协议可行性的关键技术。

假共享的深入分析

9.1.1 节中已经介绍了假共享(False Sharing)的基本概念。作为一致性协议中最常见的性能陷阱,假共享值得更深入的分析——包括其产生机制、量化影响、检测手段和硬件缓解策略。

乒乓效应的机制分析

假共享的核心问题在于乒乓效应(Ping-Pong Effect):同一个Cache行在两个(或多个)核心之间反复地获取独占权限、被无效化、再重新获取。每次"乒乓"都涉及一次完整的一致性事务。

考虑两个核心C0和C1分别修改同一Cache行内的不同字(字节偏移分别为offset_Aoffset_B)。假设使用MESI协议,乒乓过程如下:

  1. C0写入offset_A:C0发出BusRdX,获取M状态。C1的副本(如果有)被无效化。

  2. C1写入offset_B:C1发出BusRdX,C0的M状态副本被强制Flush到C1,C0变为I,C1获得M状态。

  3. C0再次写入offset_A:C0发出BusRdX,C1的M状态副本被Flush到C0,C1变为I,C0获得M状态。

  4. 步骤2\sim3无限循环——每次写入都触发一次Cache行的跨核传输。

每次乒乓的代价为一次BusRdX事务。在典型的多核处理器中,L1 D-Cache到L1 D-Cache的Cache-to-Cache传输延迟约为20\sim40个处理器周期(取决于互连延迟和协议开销)。如果两个核心以高频率交替写入(如在紧密循环中),乒乓效应可以使每核心的有效写吞吐量降低为原来的1/401/40以下。

假共享的量化性能影响

假设两个核心C0和C1各运行一个循环,每次迭代执行一次写操作,写入同一Cache行内的不同字。设单次写命中的延迟为thit=1t_{\text{hit}} = 1周期(L1命中),一次一致性缺失(乒乓)的延迟为tmiss=30t_{\text{miss}} = 30周期。

无假共享(两个字位于不同Cache行)时,两个核心独立运行,每核心每次迭代耗时thit=1t_{\text{hit}} = 1周期,两个核心的聚合吞吐量为2×fcore2 \times f_{\text{core}}次写操作/秒。

有假共享(两个字位于同一Cache行)时,由于乒乓效应,每核心的每次写操作实际延迟为tmiss=30t_{\text{miss}} = 30周期。两个核心的聚合吞吐量为2×fcore/302 \times f_{\text{core}} / 30次写操作/秒。

性能下降比为:

减速比=tmissthit=301=30× \text{减速比} = \frac{t_{\text{miss}}}{t_{\text{hit}}} = \frac{30}{1} = 30\times

这意味着假共享在最坏情况下可以将性能降低到无假共享时的1/301/30——比单核执行还要慢。在实际工作负载中,假共享的影响通常没有这么极端(因为不是每次迭代都触发乒乓),但5×\times\sim10×\times的性能下降是常见的。

假共享的检测手段

在软件层面,假共享可以通过以下工具和方法检测:

  • 性能计数器分析:现代处理器提供了一致性相关的性能计数器,如Intel的MEM_LOAD_L3_HIT_RETIRED.XSNP_HITM(记录跨核心Snoop命中Modified状态的次数)。如果这个计数器的值异常高,且对应的数据地址聚集在少数Cache行中,很可能存在假共享。

  • Intel VTune Profiler:VTune的"内存访问分析"模块可以自动检测假共享热点,标记出被多个线程频繁修改的Cache行。

  • Linux perf c2c:Linux内核的perf c2c(Cache-to-Cache)工具专门用于检测假共享。它记录所有引发Cache-to-Cache传输的访存操作,并按Cache行地址聚合,高亮显示热点行。

  • 静态分析:编译器或代码审查工具可以分析结构体的内存布局,标记可能被不同线程访问的相邻字段。

假共享的硬件缓解策略

除了软件层面的对齐和填充外,硬件设计也可以在一定程度上缓解假共享的影响:

(1)子行粒度一致性(Sub-line Coherence):将一致性的操作粒度从Cache行级别(64字节)细化到子行级别(如16字节或字级别)。如果一致性协议以16字节为粒度操作,那么两个核心分别写入同一64字节Cache行中不同的16字节子行时,不会触发相互无效化。但子行一致性大幅增加了协议的复杂度——每个Cache行需要维护4个独立的一致性状态(对于16字节子行),Snoop请求和响应也需要指定子行粒度。这种方法在学术研究中有所探索,但由于硬件代价过高,主流商用处理器尚未采用。

(2)动态Cache行大小。一些研究提出根据共享行为动态调整Cache行大小——对于频繁发生假共享的行使用较小的行大小(如32字节),对于空间局部性好的行使用较大的行大小(如128字节)。这种方案可以在空间局部性和假共享之间取得更好的平衡,但需要复杂的硬件支持来管理可变大小的Cache行。

(3)写合并缓冲区(Write Coalescing Buffer)。在Cache控制器中加入一个小型写合并缓冲区,将对同一Cache行的多次写操作合并后再发出一致性请求。如果一个核心在短时间内对一个Cache行执行了多次写操作,写合并缓冲区可以将这些写操作批量化为一次BusRdX事务,减少乒乓频率。Intel的L1 D-Cache中包含一个小型的写合并机制,可以在一定程度上缓解假共享的影响。

(4)适应性共享检测。一些处理器实现了硬件级的假共享检测机制:当检测到某个Cache行在两个核心之间频繁乒乓时,硬件可以采取措施,例如将该行的一致性粒度临时细化到子行级别,或者延迟一致性响应以给写操作更多的合并机会。这种自适应机制是学术研究的活跃领域。

假共享的编程语言级缓解

各主流编程语言和运行时环境都提供了对抗假共享的机制:

C/C++:使用alignas(64)(C11/C++11标准属性)或编译器特定的__attribute__((aligned(64)))来强制结构体成员对齐到Cache行边界。GCC和Clang还支持__attribute__((section(".data.cacheline_aligned")))来将全局变量放在独立的Cache行中。在Linux内核中,__cacheline_aligned_in_smp宏在SMP系统上展开为__attribute__((aligned(SMP_CACHE_BYTES))),在单核系统上展开为空(避免不必要的内存浪费)。

Java:JDK 8引入的@sun.misc.Contended注解(JDK 9后改为@jdk.internal.vm.annotation.Contended)可以标注类或字段。被标注的字段前后会被JVM自动插入128字节的填充。此注解在JDK内部被广泛使用——例如java.lang.Thread类的threadLocalRandomSeed字段就使用了@Contended来避免与其他字段的假共享。

Rust:标准库提供了CachePadded<T>类型(在crossbeam-utils库中),它通过在类型TT的前后填充来确保TT独占一个Cache行。Rust的#[repr(align(64))]属性也可以用于结构体级别的对齐控制。

Go:Go运行时在内部数据结构(如runtime.p的结构体)中使用了手动填充来避免假共享。Go 1.19引入的internal/cpu.CacheLinePad类型提供了标准化的填充机制。

性能分析 3 — 假共享在实际系统中的影响

在Java虚拟机的研究中,Heil和Smith发现JVM的垃圾回收器由于对象头的密集更新而频繁触发假共享。在一个8核系统上运行SPECjbb2005时,假共享导致的一致性流量占总一致性流量的15%\sim25%。Java 8引入的@sun.misc.Contended注解通过在被注解的字段前后插入128字节的填充来避免假共享。在Linux内核中,__cacheline_aligned_in_smp宏被广泛使用来确保关键数据结构(如自旋锁、per-CPU变量)位于独立的Cache行中,避免假共享。一个经典的案例是Linux内核的struct zone结构——在引入Cache行对齐之前,跨NUMA节点的内存分配操作因为假共享导致了高达40%的性能退化。

基于侦听的Cache-to-Cache传输时序分析

理解一致性协议的性能特性,需要深入分析一次典型一致性事务的逐周期时序。本节以一次基于侦听的Cache-to-Cache传输为例,详细分解各个阶段的时序开销。

场景设定

考虑如下场景:Core 0持有地址XX的M状态副本,Core 1请求读取XX。在MESI协议下,这触发一次M\toS/S的Cache-to-Cache传输。假设系统使用共享总线互连,总线宽度为16字节,时钟频率与处理器核心同步。

逐周期时序分解

整个Cache-to-Cache传输事务的时序可以分解为以下阶段:

周期阶段描述耗时
T0Core 1的L1 D-Cache检测到XX缺失,向Cache控制器发出请求1 cyc
T1\simT2Core 1的Cache控制器请求总线仲裁,等待总线可用1\sim3 cyc
T3Core 1在总线上发出BusRd地址(地址阶段)1 cyc
T4总线广播BusRd到所有Cache控制器(传播延迟)1 cyc
T5\simT7各Cache控制器进行Tag查找(Snoop查找阶段)2\sim3 cyc
T8Core 0的Cache控制器发现自己持有M状态,断言HIT#信号1 cyc
T9\simT10Core 0的Cache控制器读取Data SRAM,准备数据1\sim2 cyc
T11\simT14Core 0在总线上传输64字节数据(16B/cyc ×\times 4次)4 cyc
T15Core 1接收数据,写入L1 D-Cache,更新状态为S1 cyc
T16Core 0更新本地状态从M到S,总线释放1 cyc
总计14\sim18 cyc

基于侦听的Cache-to-Cache传输逐周期时序

上述时序分析表明,一次Cache-to-Cache传输在共享总线系统中需要约14\sim18个总线周期

需要注意的是,上述分析假设总线是空闲的(无竞争)。在实际系统中,总线仲裁延迟会随负载增加而增长。当多个核心同时请求总线时,采用固定优先级仲裁的系统中低优先级核心可能等待数十个周期;采用轮转仲裁的系统中平均等待时间约为Ncompetitors/2×ttransactionN_{\text{competitors}} / 2 \times t_{\text{transaction}}。对于4核系统中2个核心同时竞争总线的场景,平均仲裁延迟约为1个事务时长(约15个周期),将Cache-to-Cache传输的实际延迟从18增加到约33个周期。

在现代处理器中,如果总线时钟频率低于核心频率(例如总线运行在核心频率的1/21/21/41/4),则以核心周期计算的延迟更长:

Tc2c=Tbus_cycles×fcorefbus T_{\text{c2c}} = T_{\text{bus\_cycles}} \times \frac{f_{\text{core}}}{f_{\text{bus}}}

例如,如果核心频率为4 GHz、总线频率为2 GHz,则18个总线周期对应36个核心周期。

Ring Bus上的Cache-to-Cache传输

在Intel的Ring Bus互连中(用于Haswell到Ice Lake的桌面/服务器处理器),Cache-to-Cache传输的时序有所不同。Ring Bus不使用共享总线仲裁,而是通过环形网络上的分组传输来完成数据传输。

  • 请求注入:Core 1将BusRd请求注入到Ring上,1\sim2个周期。

  • 请求传播:请求在Ring上传播到Snoop Filter所在的LLC Slice。传播延迟取决于Core 1到目标LLC Slice的Ring跳数。在一个12核Ring中,平均跳数约为N/4=3N/4 = 3跳,每跳约1个周期,共3个周期。

  • Snoop Filter查找:LLC Slice查找Snoop Filter,确定Core 0持有M状态副本,2个周期。

  • Snoop消息转发:Snoop消息从LLC Slice传播到Core 0,平均3跳,3个周期。

  • Core 0响应:Core 0进行Tag查找+数据读取,2\sim3个周期。

  • 数据传输:数据从Core 0通过Ring传输到Core 1。64字节数据在32字节/周期的Ring带宽下需要2个周期传输,加上传播延迟约3个周期,共5个周期。

  • 接收和更新:Core 1接收数据,更新Cache,1个周期。

总延迟约为2+3+2+3+3+5+1=192 + 3 + 2 + 3 + 3 + 5 + 1 = 19个Ring周期。在实际测量中,Intel处理器的L1-to-L1 Cache-to-Cache传输延迟约为30\sim40ns(对应约120\sim160个核心周期),高于理论最小值,原因包括Ring竞争、缓冲延迟、和协议握手开销。

Mesh NoC上的Cache-to-Cache传输

在Intel Skylake-SP及后续处理器的Mesh互连中,Cache-to-Cache传输遵循三跳模型(与目录协议类似):

  1. Core 1向Home Agent(通过地址哈希确定的LLC Slice)发送请求。延迟取决于Core 1到Home的曼哈顿距离,在8×68 \times 6的Mesh中平均约5\sim6跳。

  2. Home Agent查询Snoop Filter,向Core 0发送Forward消息。延迟取决于Home到Core 0的距离,平均约5\sim6跳。

  3. Core 0将数据直接发送给Core 1。延迟取决于Core 0到Core 1的距离,平均约5\sim6跳。

每跳延迟约为1\sim2个Mesh周期。三跳传输的总延迟约为3×6×1.5=273 \times 6 \times 1.5 = 27个Mesh周期,加上各节点的处理延迟(约10个周期),总计约37个Mesh周期。在实际的Xeon Scalable处理器中,跨核L1-to-L1延迟约为40\sim70ns,具体取决于核心之间的拓扑距离。

AMD Zen系列的Cache-to-Cache传输延迟

AMD Zen系列处理器的Cache-to-Cache传输延迟因通信范围而显著不同,反映了其chiplet架构的层次化特性:

(1)同一CCX内(4核共享L3 Slice):核心间通信不需要经过L3控制器的目录查询,而是通过CCX内部的交叉开关(Crossbar)直接完成。L1-to-L1的传输延迟约为15\sim20个核心周期(约4\sim5ns@4.5 GHz)。这种低延迟得益于CCX内部的简单拓扑和短距离物理布线。

(2)同一CCD内、不同CCX(Zen 3之后,CCD内只有一个CCX):Zen 3将整个CCD的8核统一到一个CCX中,消除了这一延迟层级。但在Zen/Zen 2架构中(每CCD两个CCX,每CCX 4核),跨CCX通信需要经过CCD内的L3 Cache控制器——延迟约为25\sim40个周期(约7\sim10ns)。

(3)跨CCD(通过Infinity Fabric):请求从源CCD的L3控制器出发,经过Infinity Fabric传输到IOD(I/O Die)的Data Fabric,再转发到目标CCD的L3控制器。IOD中的Home Agent查询系统级目录来确定数据的位置。整条路径的延迟约为60\sim120个核心周期(约15\sim30ns),其中Infinity Fabric的物理层传输延迟约占一半。

(4)跨Socket:跨Socket通信除了经历上述跨CCD的延迟外,还需要经过Socket间的Infinity Fabric链路(GMI/xGMI)。Socket间的物理链路延迟约为20\sim40ns,加上协议开销,总的Cache-to-Cache延迟约为150\sim300个核心周期(约40\sim80ns)。

通信范围路径延迟(周期)延迟(ns@4.5GHz)
同一CCXL1\toCrossbar\toL115\sim203\sim5
同一CCDL1\toL3 Ctrl\toL125\sim356\sim8
跨CCD(同Socket)L3\toIF\toIOD\toIF\toL360\sim12015\sim30
跨Socket++xGMI链路延迟150\sim30040\sim80

AMD Zen 4架构下不同层次的Cache-to-Cache传输延迟

这种层次化的延迟特性对软件优化有重要启示。在AMD平台上运行的高性能计算应用(如BLAS库、MPI通信库)通常需要进行拓扑感知的线程绑定——将频繁共享数据的线程绑定到同一CCD上,以确保一致性事务在低延迟的CCD内完成。AMD的numactlhwloc工具可以帮助应用识别和利用这种拓扑层次。

设计提示

Cache-to-Cache传输的延迟对多线程应用的性能有直接影响。在锁的实现中(如自旋锁),获取锁的操作本质上是一次对锁变量的写操作——如果锁被另一个核心持有(M状态),获取锁就需要一次完整的Cache-to-Cache传输。在一个8核系统中,如果所有核心竞争同一个自旋锁,且锁持有时间很短(如10个周期),那么大部分时间实际上花在了Cache-to-Cache传输上(30\sim60个周期/次),锁的吞吐量被一致性延迟而非计算所限制。这就是高性能锁实现(如CLH锁、MCS锁)使用每核心本地变量来减少跨核Cache传输的根本原因。

基于目录的协议

当核心数量超过8\sim16个时,即使使用了监听过滤器,基于总线的监听协议仍然面临可扩展性瓶颈。基于目录的协议(Directory-Based Protocol)通过用点对点消息替代广播来解决这个问题:每个内存地址对应一个目录条目(Directory Entry),记录了哪些核心持有该地址的Cache副本以及其状态。当一个核心需要获取或修改某个Cache行时,它向该行所属的宿主节点(Home Node)发送请求,宿主节点查询目录来确定需要通知哪些核心,然后只向相关的核心发送定向消息。

目录协议的优势在于其通信量与实际共享程度成正比,而非与核心总数成正比。对于一个64核系统,如果某个Cache行只被2个核心共享,目录协议只需要发送2条定向消息,而监听协议需要广播到其他63个核心。

表 9.11定量比较了监听协议和目录协议在不同核心数量下的一致性流量特性。

特性监听协议目录协议
每次请求的消息数O(N)O(N)(广播)O(k)O(k)kk为共享者数)
延迟(无竞争)1次总线事务2\sim3跳
互连要求有序广播(总线/环)点对点网络(Mesh)
存储开销每行NN位(全映射)
适用核心数\leq8\sim1616\sim1000+
协议复杂度

监听协议与目录协议的可扩展性对比

从上表可以看出,监听协议和目录协议是两种互补的设计:监听协议在小规模系统中更高效(延迟低、无存储开销),而目录协议在大规模系统中是唯一可行的选择(消息数量不随核心数线性增长)。这也是现代处理器普遍采用混合方案的原因——在片内使用监听或小规模目录,在片间使用目录协议。

目录协议的消息类型

目录协议使用的消息类型比监听协议更加丰富和精细,因为点对点通信需要更明确的消息语义。常见的目录协议消息可分为以下几类:

请求消息(Request Messages,从Cache控制器发给Home Agent):

  • GetS(Get Shared):请求只读副本。对应监听协议中的BusRd。

  • GetM(Get Modified):请求独占写权限。对应BusRdX。

  • PutS(Put Shared):主动释放共享副本(通常在Cache行被驱逐时发送),通知Home更新目录。

  • PutM(Put Modified):主动释放修改副本并写回脏数据。

  • Upgrade:从S升级到M,对应BusUpgr。

转发消息(Forward Messages,从Home Agent发给当前Owner或共享者):

  • Fwd-GetS:Home转发读请求给Owner——"有人要读你持有的数据,请提供"。

  • Fwd-GetM:Home转发写请求给Owner——"有人要写你持有的数据,请让出"。

  • Inv(Invalidate):Home通知共享者无效化自己的副本。

响应消息(Response Messages):

  • Data:携带Cache行数据的响应(可以从Home、Owner或主存发送给请求者)。

  • InvAck:无效化确认——共享者确认已将对应行无效化。

  • PutAck:Home确认收到PutS/PutM消息。

  • FwdAck:Owner确认已响应转发请求。

每条消息都需要携带地址(标识操作的Cache行)、源节点ID、事务ID(用于匹配请求和响应),有些消息还需要携带数据负载(64字节Cache行数据)。控制消息(如Inv、InvAck)的Flit宽度通常约为50\sim80位,而数据消息的Flit宽度为数百位(取决于数据通道宽度)。

全映射目录

全映射目录(Full-Map Directory)是最直接的目录实现方式:为每个内存块(Cache行粒度)维护一个位向量(Bit Vector),其中每一位对应一个核心。如果某一位被设置,表示对应的核心持有该内存块的Cache副本。

全映射目录的设计需要在追踪精度存储开销之间取得平衡。追踪精度越高(能精确标识每个共享者),发送的无效化消息越少(只发给真正的共享者);存储开销越大(位向量越宽),每个目录条目消耗的SRAM面积越多。下面的分析展示了这种权衡的具体数值。

一个全映射目录条目通常包含以下字段:

  • 状态位(2\sim3位):表示该内存块的全局一致性状态。最基本的编码使用2位来表示三种目录状态:I\mathrm{I}(不在任何Cache中)、S\mathrm{S}(被一个或多个核心共享读)、M\mathrm{M}(被一个核心独占修改)。如果支持O和E状态,则需要3位编码。具体的编码方式如表表 9.12所示。

  • 共享者位向量NN位):对于NN核系统,每位对应一个核心。当状态为S\mathrm{S}时,位向量中为1的位表示持有该行共享副本的核心集合;当状态为M\mathrm{M}时,位向量中恰好有1位为1,指示Owner核心。

  • Owner字段(可选,log2N\log_2 N位):记录当前Owner的核心编号(在目录状态为M或O时使用)。在使用位向量的实现中,Owner信息已经隐含在位向量中(唯一为1的那一位),因此Owner字段是冗余的。但在有限指针目录中,显式的Owner字段可以加速Owner的查找。

状态2位编码3位编码含义
I00000不在任何核心的Cache中
S01001一个或多个核心持有只读副本
M10010恰好一个核心持有独占修改副本
O011一个核心持有Owner副本,其他核心可能有S副本
E100恰好一个核心持有独占干净副本

目录状态位的编码方式

一个完整的目录条目的位宽计算如下。对于NN核系统、PP位物理地址、BB字节Cache行大小,目录条目的Tag位宽为Plog2Blog2(sets)P - \log_2 B - \log_2(\text{sets})位。以P=46P=46位(支持64 TB物理内存)、B=64B=64字节、sets=8192\text{sets}=8192为例:

条目位宽=(46613)Tag: 27位+3状态+N位向量=30+N(位) \text{条目位宽} = \underbrace{(46 - 6 - 13)}_{\text{Tag: 27位}} + \underbrace{3}_{\text{状态}} + \underbrace{N}_{\text{位向量}} = 30 + N \text{(位)}

对于N=64N=64核,每个条目为94位(约12字节);对于N=256N=256核,每个条目为286位(约36字节)。位向量部分随核心数线性增长,这是全映射目录可扩展性问题的根源。

目录的物理实现位置

目录条目在物理上有两种常见的实现位置:

(1)嵌入主存DRAM:在早期的NUMA系统(如SGI Origin 2000)中,目录条目与数据一起存储在DRAM中。每个DRAM行(通常512位数据+ECC)额外附加N+3N + 3位的目录信息。这种实现的优点是目录容量不受限制(每个主存Cache行都有对应的目录条目);缺点是每次目录查询都需要一次DRAM访问,增加了延迟(约40\sim60ns)。

(2)嵌入LLC SRAM:在现代片上多核处理器中,目录条目通常嵌入在LLC(L3 Cache)的Tag阵列中。每个LLC Tag条目额外增加NN位的存在位向量和2\sim3位的目录状态位。这种实现的优点是目录查询与LLC Tag查询合并,无需额外的SRAM访问(延迟约3\sim5个周期);缺点是目录容量受限于LLC的容量——只有在LLC中有Tag的Cache行才有目录条目。对于非包含式LLC,这意味着需要一个独立的Snoop Filter结构来追踪LLC中不存在但私有Cache中存在的行。

Intel从Haswell到Coffee Lake采用包含式LLC,目录自然嵌入LLC Tag中。从Skylake-SP开始改为非包含式LLC,在LLC旁边部署了独立的Snoop Filter SRAM来存储目录信息。AMD Zen系列的L3 Cache也将目录信息嵌入L3 Tag中,但由于L3是受害者缓存(非包含式),AMD额外使用了IOD中的系统级目录来追踪跨CCD的共享关系。

目录查询的流水线化

在高性能处理器中,目录查询操作需要被流水线化以支持高吞吐量。Home Agent每个周期可能收到多个一致性请求,每个请求都需要查询目录。一个典型的目录查询流水线包含以下阶段:

  1. 请求接收+地址解码(1周期):从网络接口接收请求消息,解码目标地址。

  2. Tag匹配(1\sim2周期):在目录的Tag SRAM中查找匹配的条目。

  3. 状态读取+位向量读取(1周期):读取匹配条目的一致性状态和共享者位向量。

  4. 协议决策(1周期):根据当前状态和请求类型决定下一步动作(发送Fwd/Inv/Data等)。

  5. 消息生成+状态更新(1\sim2周期):生成响应消息,更新目录条目的状态和位向量。

总流水线深度约为5\sim7个周期,吞吐量为每周期处理1个请求。在高负载下,请求可能在Home Agent的输入队列中排队等待,增加了有效延迟。Home Agent的输入队列深度通常为8\sim16个条目,如果队列满则向请求者发送Retry/NACK消息。

图 9.8展示了全映射目录的结构和一个完整的目录事务流程。

全映射目录的结构(8核系统示例)
全映射目录的结构(8核系统示例)

目录协议的基本操作流程

以一个基于目录的MSI协议为例,说明三种主要操作:

(1)读缺失(目录状态为I):请求核心CrC_r向Home节点发送读请求。Home查询目录,发现该行不在任何Cache中(状态为I)。Home从主存读取数据,将数据发送给CrC_r,并更新目录:状态设为S,CrC_r的位设为1。

(2)读缺失(目录状态为M):请求核心CrC_r向Home节点发送读请求。Home查询目录,发现该行被核心CoC_o独占修改(状态为M)。Home向CoC_o发送干预(Intervention)消息,CoC_o将数据发送给CrC_r(并可能写回Home),CoC_o的状态从M转为S(或I)。Home更新目录:状态设为S,CrC_rCoC_o的位都设为1。

(3)写缺失(目录状态为S):请求核心CrC_r向Home节点发送写请求(或升级请求)。Home查询目录,发现该行被核心C1,C2,,CkC_1, C_2, \ldots, C_k共享(状态为S)。Home向每个共享者CiC_i发送无效化(Invalidation)消息。每个CiC_i收到后将该行无效化并回复确认(Ack)。当CrC_r收到所有Ack后(或Home收集所有Ack后转发给CrC_r),CrC_r获得独占写权限。Home更新目录:状态设为M,只有CrC_r的位设为1。

目录协议中的三跳读事务(目录状态为M)
目录协议中的三跳读事务(目录状态为M)

图 9.10展示了目录协议中写缺失(目录状态为S)的完整消息交互流程——这是目录协议中消息最多的场景,因为需要向所有共享者发送无效化消息并收集确认。

目录协议中的写缺失事务(目录状态为S)
目录协议中的写缺失事务(目录状态为S)

写缺失事务中一个重要的设计决策是:CrC_r需要知道有多少个共享者(即需要等待多少个InvAck)才能判断事务是否完成。这个信息可以由Home在发送数据时一并告知(Data消息中携带NsharersN_{\text{sharers}}字段),也可以由Home收集所有InvAck后统一通知CrC_r(但这会增加延迟)。大多数现代处理器采用前一种方式——Home将共享者数量编码在数据消息中,CrC_r自行计数InvAck。

目录协议完整事务举例

下面通过一个涉及4个核心的详细事务序列来展示目录协议的完整运作过程。假设核心C0\simC3和Home节点HH在Mesh网络中,地址XX初始不在任何Cache中。

事务1:C0发出GetS(XX)XX的目录状态为I。HH从主存获取数据,发送Data给C0,更新目录为{状态=S, 共享者={C0}}。总消息数:2条(GetS + Data)。

事务2:C2发出GetS(XX)HH查询目录,状态为S,共享者为{C0}。HH直接从主存(或LLC)提供数据给C2,更新目录为{状态=S, 共享者={C0, C2}}。总消息数:2条。注意HH不需要向C0发送任何消息——BusRd在目录S状态下不影响现有共享者。

事务3:C1发出GetM(XX)HH查询目录,状态为S,共享者为{C0, C2}。HH需要执行以下操作:(a)向C0发送Inv消息;(b)向C2发送Inv消息;(c)向C1发送Data消息(携带AckCount=2)。C0收到Inv后将XX无效化,发送InvAck给C1。C2同样操作。C1收到Data后暂存在MSHR中(进入IMDA\mathrm{IM}^{DA}瞬态状态),等收齐2个InvAck后转为M状态。HH更新目录为{状态=M, Owner=C1}。总消息数:6条(GetM + 2×\timesInv + Data + 2×\timesInvAck)。

事务4:C3发出GetS(XX)HH查询目录,状态为M,Owner=C1。HH向C1发送Fwd-GetS消息。C1收到后将数据直接传给C3(DCT优化),C1从M降级为S(或O,取决于协议)。C1还可能需要向HH发送一个FwdAck来确认降级完成。HH更新目录为{状态=S, 共享者={C1, C3}}。总消息数:4条(GetS + Fwd-GetS + Data + FwdAck)。这是一个典型的三跳事务:C3\toH\toC1\toC3。

事务5:C1发出PutS(XX)(C1主动驱逐XX的共享副本)。C1向HH发送PutS消息。HH更新目录为{状态=S, 共享者={C3}}。HH回复PutAck给C1。总消息数:2条。PutS是一个轻量级事务——不涉及数据传输(S状态是干净的),只需更新目录。但在某些实现中,如果HH采用包含式Snoop Filter,PutS甚至可以省略——HH通过反向无效化来隐式清除已驱逐的条目。

全映射目录的存储开销

全映射目录的主要缺点是存储开销随核心数量线性增长。对于NN核系统,每个目录条目需要NN位的位向量。设Cache行大小为BB字节,物理内存大小为MM字节,则目录条目总数为M/BM / B,目录的总存储开销为:

目录存储开销=MB×N(位)=M×N8B(字节) \text{目录存储开销} = \frac{M}{B} \times N \text{(位)} = \frac{M \times N}{8B} \text{(字节)}

对于一个64核系统,Cache行大小64字节,目录存储开销占内存大小的比例为:

N8B=648×64=18=12.5%\frac{N}{8B} = \frac{64}{8 \times 64} = \frac{1}{8} = 12.5\%

这意味着每8字节的内存需要1字节的目录存储——对于一个256 GB的系统,目录就需要32 GB的额外存储。当核心数量增加到256或1024时,这个比例将增长到50%甚至更高,显然是不可接受的。

有限指针目录与共享者表示方法

全映射位向量目录的O(N)O(N)存储开销促使研究者和工程师探索更紧凑的共享者表示方法。共享者列表的表示可以分为三大类:位向量(Bit Vector)、有限指针(Limited Pointer)和链表(Linked List),每种方法在存储开销、查找速度和协议复杂度之间有不同的权衡。

三种共享者表示方法的对比

(1)完整位向量(Full Bit Vector):每个核心对应1位,NN位总宽度。优点:查找和更新都是O(1)O(1)操作——检查核心ii是否是共享者只需读取第ii位,添加/删除共享者只需设置/清除对应位。无效化所有共享者时,可以一次性读取整个位向量,并行发送无效化消息。缺点:存储开销为O(N)O(N),当N>64N > 64时变得不可接受。

(2)有限指针(Limited Pointer):维护kk个指针(通常k=28k=2\sim8),每个指针宽度为log2N\lceil\log_2 N\rceil位,指向一个共享者的核心编号。优点:存储开销为O(klogN)O(k \log N),远小于O(N)O(N)。当k=4k=4N=1024N=1024时,每个条目仅需4×10=404 \times 10 = 40位,而位向量需要1024位。缺点:检查某个核心是否是共享者需要遍历所有kk个指针(O(k)O(k)查找);添加共享者需要找到一个空闲指针槽位;当共享者数量超过kk时需要溢出处理。

(3)链表(Linked List / Chained Directory):目录条目中只存储一个指向"头部"共享者的指针,每个共享者的Cache Tag中存储一个"下一个共享者"的指针,形成一条链表。这种方法由Chaiken等人在1990年代提出。优点:目录条目的大小恒定(仅一个log2N\lceil\log_2 N\rceil位的头指针),不随共享者数量增长。缺点:(a)无效化所有共享者需要串行遍历整条链表,延迟为O(k)O(k)跳,其中kk为共享者数量;(b)每个Cache行的Tag中需要额外的log2N\lceil\log_2 N\rceil位空间用于存储链表指针;(c)链表的维护(插入、删除)在并发环境下容易出错,增加了协议验证的复杂度。

表 9.13定量对比了三种表示方法。

有限指针目录的细节

有限指针目录(Limited Pointer Directory)通过将位向量替换为固定数量的指针来大幅减少目录存储开销。其核心观察是:在实际工作负载中,绝大多数Cache行在同一时刻只被少数几个核心(通常1\sim3个)共享。因此,目录条目可以只维护kk个指针(每个指针为log2N\log_2 N位,指向一个共享者),而非NN位的完整位向量。

对于kk指针目录,每个目录条目的大小为k×log2Nk \times \log_2 N位。以k=4k=4N=64N=64为例:每个条目需要4×6=244 \times 6 = 24位,远小于全映射的64位。当核心数NN增大时,有限指针目录的优势更加明显:

核心数NN全映射(位/条目)4指针(位/条目)节省比例
1616160%
64642462.5%
2562563287.5%
102410244096.1%

全映射目录与有限指针目录的存储开销对比

指针溢出的处理

当一个Cache行的共享者数量超过kk时,有限指针目录需要一个溢出策略(Overflow Strategy):

(1)广播策略(Broadcast on Overflow):当指针数量不够用时,将该行标记为"广播"状态,后续的无效化操作对该行使用广播而非定向消息。这种策略简单但可能导致不必要的广播流量。

(2)驱逐策略(Eviction on Overflow):当需要添加第k+1k+1个共享者时,先向其中一个现有共享者发送无效化消息,释放一个指针位置给新的共享者。这种策略避免了广播,但可能导致不必要的Cache缺失。

(3)粗粒度位向量(Coarse-Grained Bit Vector):当指针溢出时,切换为一个粗粒度的位向量——每一位代表一组核心(如4个核心一组),而非单个核心。这是一种精度和存储开销之间的折中。

实际的处理器设计通常根据具体的核心数量和工作负载特性来选择溢出策略。研究表明,在大多数商用多核处理器的工作负载中,k=4k=4的有限指针目录已经能覆盖99%以上的Cache行——指针溢出是极为罕见的事件。

Agarwal等人在1988年的经典研究中分析了多种并行工作负载的共享特性,发现绝大多数Cache行在同一时刻被0\sim2个核心共享。具体来说,在一个16核系统中运行并行科学计算工作负载时:

  • 约50%\sim60%的有效Cache行仅被1个核心持有(M或E状态)。

  • 约25%\sim35%的行被2\sim3个核心共享。

  • 约5%\sim10%的行被4\sim8个核心共享。

  • 不到1%的行被超过8个核心共享。

这种重尾分布(Heavy-Tailed Distribution)是有限指针目录可行性的统计基础——用4个指针可以精确覆盖99%+的情况,仅对不到1%的极端共享情况使用溢出策略。在服务器工作负载中,高度共享的行通常对应于全局锁变量、原子计数器、线程同步原语等——这些行的数量本身就很少。

粗粒度位向量的设计细节

粗粒度位向量(Coarse-Grained Bit Vector)是一种介于完整位向量和有限指针之间的折中方案。其核心思想是将NN个核心分成GG个组(Group),每组包含N/GN/G个核心。位向量的每一位对应一个组而非一个核心,位宽从NN压缩为GG

当位向量中某一位为1时,表示对应组中至少一个核心可能持有该行的副本。发送无效化消息时,需要向该组中的所有核心发送(因为目录无法精确知道组内哪个核心持有副本)。这导致了一些不必要的Snoop操作——目录"高估"了共享者的数量。

粗粒度位向量的精度损失可以通过以下指标量化:设实际共享者数量为kk,这kk个共享者分布在gg个组中(gkg \leq k),则每次无效化操作发送的Snoop消息数为g×(N/G)g \times (N/G)。在最坏情况下(kk个共享者分散在kk个不同组中),Snoop消息数为k×(N/G)k \times (N/G),相比完整位向量的kk条消息多了N/G1N/G - 1倍。

N=64N=64核、G=16G=16组为例(每组4个核心),位向量位宽从64位压缩为16位(节省75%的目录存储),但每次无效化平均多发送41=34 - 1 = 3条不必要的Snoop消息。在实际工作负载中,由于同一组内的核心通常共享同一个L2 Cache或LLC Slice,组内Snoop的代价远小于跨组Snoop,因此粗粒度位向量的性能损失通常在2%\sim5%以内。

混合表示方案

现代处理器设计中最常用的是混合表示方案——将上述方法组合使用。一个典型的设计如下:

  • 目录条目中包含k=2k=2个完整精度指针(每个log2N\lceil\log_2 N\rceil位)和一个GG位的粗粒度位向量(G=N/4G = N/4)。

  • 当共享者数量2\leq 2时,使用精确指针——Snoop消息只发给精确的共享者。

  • 当共享者数量>2> 2时,指针溢出,切换为粗粒度位向量模式——Snoop消息发给被标记组中的所有核心。

  • 总条目大小为2×log2N+N/4+32 \times \lceil\log_2 N\rceil + N/4 + 3位。对于N=64N=64核:2×6+16+3=312 \times 6 + 16 + 3 = 31位,相比完整位向量的64+3=6764 + 3 = 67位节省54%。

这种混合方案在Intel的Mesh互连中有类似的应用——Snoop Filter的每个条目使用少量精确指针处理常见情况(1\sim2个共享者),对于罕见的高度共享场景则退化为广播。

目录压缩

除了有限指针目录外,还有多种技术可以进一步压缩目录的存储开销。

分段目录(Sectored Directory)

分段目录不是为每个Cache行单独维护一个目录条目,而是将连续的多个Cache行组成一个(Sector),为每个段维护一个共享者列表。例如,将4个相邻的Cache行组成一个段,则目录条目数减少为1/41/4,但代价是精度降低——当段中的任一行需要无效化时,同一段中的所有行在所有共享者中都可能被无效化(保守策略),导致一些不必要的无效化操作。

TagLess目录

TagLess目录利用Cache的包含性(Inclusive LLC)来消除目录中Tag的存储需求。如果LLC是包含式的(即私有Cache中的所有行一定在LLC中存在),那么目录信息可以直接附加在LLC的每个条目上,利用LLC的Tag作为目录的索引,不需要额外的Tag存储。这是现代处理器中最常用的目录实现方式——Intel和AMD的多核处理器都在LLC条目中嵌入了目录位向量。

Ackwise目录

Ackwise等学术研究提出了基于布隆过滤器(Bloom Filter)的目录压缩技术。用mm位的布隆过滤器替代NN位的位向量,可以将每个条目的存储开销从NN位压缩到mm位(其中mNm \ll N)。布隆过滤器的假阳性(False Positive)会导致少量不必要的监听消息,但不会影响正确性。

目录存储开销的系统级视角

将上述各种目录表示方法置于系统级视角下分析,可以更清晰地理解存储开销对芯片面积的影响。

对于一个典型的2030年代服务器处理器——128核心、每核64 KB L1D + 1 MB L2、共享96 MB L3(非包含式),物理地址46位、Cache行64字节——计算各种目录方案的存储开销:

所有私有Cache行总数:每核的L1D + L2行数为(64KB+1MB)/64=17408(64\,\text{KB} + 1\,\text{MB}) / 64 = 17\,408行,128核共计2.23×1062.23 \times 10^6行。这就是Snoop Filter需要追踪的最大条目数。

方案一:完整位向量(128位/条目)。每个Snoop Filter条目包含:Tag(约20位)+ 状态(3位)+ 位向量(128位)= 151位。总存储:2.23×106×15142MB2.23 \times 10^6 \times 151 \approx 42\,\text{MB}。这几乎相当于L3 Cache容量的一半——显然不可接受。

方案二:4指针目录(4×7=284 \times 7 = 28位/条目)。每个条目:Tag(20位)+ 状态(3位)+ 4个指针(4×7=284 \times 7 = 28位)+ 溢出标志(1位)= 52位。总存储:2.23×106×5214.5MB2.23 \times 10^6 \times 52 \approx 14.5\,\text{MB}。仍然较大,但已比方案一节省65%。

方案三:2指针+粗粒度位向量混合。每个条目:Tag(20位)+ 状态(3位)+ 2个精确指针(2×7=142 \times 7 = 14位)+ 32位粗粒度位向量(128核分32组,每组4核)+ 模式位(1位)= 70位。总存储:2.23×106×7019.5MB2.23 \times 10^6 \times 70 \approx 19.5\,\text{MB}。这种方案在常见情况(1\sim2个共享者)下使用精确指针,在罕见的高度共享情况下退化为粗粒度位向量,是实践中最常用的设计。

在实际实现中,Snoop Filter通常采用集合相联结构(如16路组相联),条目数通过组数和相联度的乘积确定。当Snoop Filter容量不足以追踪所有私有Cache行时,会发生Snoop Filter的容量冲突——某些条目被驱逐,对应的私有Cache行必须被反向无效化(Back-Invalidation):即Snoop Filter主动向持有该行的核心发送无效化消息,强制其驱逐该行。这确保了Snoop Filter的包含性不变量。反向无效化虽然保证了协议的正确性,但会导致额外的Cache缺失,降低性能。因此Snoop Filter的容量必须足够大,以覆盖所有私有Cache中的行——这也是上述存储开销计算中以"所有私有Cache行总数"作为基准的原因。

面向chiplet架构的层次化目录

在2030年代的chiplet架构中,目录结构可能呈现层次化特征:chiplet内部使用一级目录管理核心间的一致性,chiplet之间使用二级目录管理跨chiplet的一致性。每一级目录的粒度、存储方式和协议复杂度都可以独立优化。例如,chiplet内部(4\sim16核)可以使用完整的位向量目录(存储开销小),而chiplet之间(16\sim64个chiplet)可以使用有限指针或粗粒度位向量目录。

AMD的Zen 4/Zen 5在其IOD(I/O Die)中实现了两级目录结构:CCD内部的L3 Cache包含CCD级目录,IOD中的系统级目录管理跨CCD的一致性。当一个核心的请求可以在CCD内部解决(被请求的数据在同一CCD的L3中),不需要查询系统级目录——这减少了跨chiplet通信的延迟和带宽消耗。只有当CCD内部缺失时,请求才通过Infinity Fabric上升到IOD的系统级目录。

目录协议中的竞争条件处理

目录协议的实现中最复杂的部分之一是处理竞争条件(Race Condition)——多个核心对同一地址几乎同时发起的请求可能以不同的顺序到达Home节点和各个Cache控制器,协议必须在所有可能的消息到达序列下都维持正确性。

经典竞争场景:ReadShared vs GetM

考虑以下场景:Core A发出ReadShared(读共享请求),Core B几乎同时对同一地址发出GetM(写独占请求),且该地址当前处于目录I状态(不在任何Cache中)。

如果ReadShared先到达Home,Home将数据发送给Core A(A进入S状态),然后处理Core B的GetM——Home向Core A发送Inv消息,A无效化后回复InvAck,B获得M状态。最终结果:B持有M状态。

如果GetM先到达Home,Home将数据发送给Core B(B进入M状态),然后处理Core A的ReadShared——Home向B发送Fwd消息,B提供数据给A并降级为S(或O),A获得S状态。最终结果:A持有S,B持有S(或O)。

两种序列都是正确的,但最终状态不同。目录协议的Home节点通过对同一地址的请求进行序列化(Serialization)来确保确定性——Home为每个地址维护一个请求队列,按到达顺序逐个处理,不允许对同一地址的两个请求并行处理。

Intervention竞争:Fwd与新请求

一个更微妙的竞争条件发生在以下场景:Home向Core A(当前Owner)发送Fwd消息(将数据转发给请求者Core B),但在Fwd到达Core A之前,Core A主动发起了对同一地址的写回(例如因为Cache行被LRU策略驱逐)。此时Core A的写回消息和Home的Fwd消息"交叉"在网络中:

  • Core A发送WriteBack数据给Home。

  • Home发送Fwd给Core A。

  • Core A收到Fwd时,该行已经被驱逐(状态为I),无法提供数据。

  • Home收到WriteBack,但它已经发出了Fwd并期望Core A直接将数据传给Core B。

这种竞争的标准处理方式是:Core A收到Fwd但发现该行已为I状态时,发送一个NACK或"数据不可用"响应给Home(或请求者)。Home收到Core A的WriteBack数据后,重新处理Core B的请求——这次可以直接从Home提供数据给Core B(两跳传输)。虽然这增加了事务延迟,但保证了协议的正确性。

在Intel的实现中,这种竞争通过事务ID跟踪来处理:每个一致性事务有一个唯一的事务ID,Home在发出Fwd时记录该事务的状态。当收到意外的消息(如WriteBack在Fwd之后到达)时,Home根据事务状态来判断如何正确处理。

三跳与两跳传输

在基于目录的协议中,一次一致性事务的延迟由消息传输的跳数(Hop Count)决定。最常见的是三跳传输(Three-Hop Transfer):

  1. 第1跳:请求核心CrC_r向Home节点发送请求。

  2. 第2跳:Home节点查询目录,向Owner核心CoC_o(或共享者)发送干预/无效化消息。

  3. 第3跳CoC_o将数据直接发送给CrC_r(而非回传给Home),完成事务。

三跳传输的延迟为三次单跳延迟之和。在一个片上网络(NoC)中,单跳延迟通常为5\sim15个时钟周期,因此三跳传输的延迟约为15\sim45个周期。

两跳优化

在某些情况下,可以将三跳减少为两跳传输(Two-Hop Transfer):

(1)数据来自Home:如果被请求的Cache行恰好就在Home节点的LLC中(且不在任何私有Cache中),Home可以直接在收到请求后返回数据,只需要两跳(请求\toHome,数据\to请求者)。

(2)直接传输(Direct Transfer):如果请求核心CrC_r在发出请求时就知道Owner是CoC_o(例如通过缓存目录信息的猜测),CrC_r可以直接向CoC_o发送请求,CoC_o直接回复数据,只需要两跳。但这需要额外的机制来处理猜测错误的情况。

(3)Speculative forwarding:Home节点在收到请求时,推测该行可能已被修改但仍在某个核心中,同时向Owner发送干预并向主存发起读取。如果Owner确实持有数据,数据从Owner直接传给请求者(等效三跳但部分并行);如果Owner不持有数据,则从主存获取(两跳)。

三跳与两跳传输延迟对比
三跳与两跳传输延迟对比
::: warning 性能分析 4 — 跳数对延迟的影响

在一个采用Mesh NoC互连的64核处理器中(如Intel Xeon Scalable系列),单跳延迟约为2\sim3ns。三跳传输的延迟约为6\sim9ns,而两跳传输约为4\sim6ns。对于延迟敏感的工作负载(如数据库的OLTP事务处理),将频繁的一致性事务从三跳优化为两跳可以带来10%\sim20%的性能提升。现代处理器通常同时支持两跳和三跳传输路径:对于Home节点有数据的情况使用两跳,对于需要从Owner获取数据的情况使用三跳。

:::

目录协议的逐周期时序分析

为了深入理解目录协议的性能特性,本节以一个基于Mesh NoC的64核处理器为例,详细分析两种关键一致性事务的逐周期时序。

场景一:读缺失(目录状态为M)——三跳传输

Core CrC_r(位于Mesh坐标(2,3)(2,3))请求读取地址XX,该地址的Home节点HH位于(5,1)(5,1)(通过地址哈希确定),Owner核心CoC_o位于(7,4)(7,4)。Mesh网络每跳延迟为1.5个周期(包括路由器流水线延迟)。

阶段描述耗时(核心周期)
1CrC_r的Cache控制器生成ReadShared请求,注入NoC2
2请求从Cr(2,3)C_r(2,3)H(5,1)H(5,1):曼哈顿距离$5-2
3HH查询目录:Tag匹配+状态读取+位向量/指针读取3
4HH生成Fwd消息,从H(5,1)H(5,1)Co(7,4)C_o(7,4):$7-5
5CoC_o进行Tag查找+数据SRAM读取+状态更新(M\toS/I)4
6CoC_o发送数据到CrC_rCo(7,4)C_o(7,4)Cr(2,3)C_r(2,3):$7-2
7CrC_r接收数据,写入Cache,更新状态2
总延迟\approx35个周期

三跳读缺失(目录M状态)的逐周期时序分析

以4 GHz核心频率计算,35个周期对应约8.75 ns。这与实际测量值(Intel Xeon Scalable处理器的跨核L2缺失延迟约为40\sim50ns,包含L2 Tag查找和NoC排队延迟)处于同一数量级,但实际延迟更高的原因包括:(1)NoC可能有拥塞(排队延迟);(2)目录查找可能涉及LLC SRAM的访问延迟(约10个周期);(3)CoC_o的数据SRAM读取可能与其他操作竞争端口。

场景二:写缺失(目录状态为S,3个共享者)

Core CrC_r请求写入地址YYYY的Home节点HH发现目录状态为S,共享者为C1,C2,C3C_1, C_2, C_3三个核心。

阶段描述耗时(核心周期)
1CrC_r发送GetM请求到HH2+dCrH×1.52 + d_{C_r \to H} \times 1.5
2HH查询目录,生成3条Inv消息,并行发送到C1,C2,C3C_1, C_2, C_33+max(dHCi)×1.53 + \max(d_{H \to C_i}) \times 1.5
3HH同时发送Data+AckCount=3给CrC_rdHCr×1.5d_{H \to C_r} \times 1.5
4C1,C2,C3C_1, C_2, C_3分别无效化后发送InvAck到CrC_r4+max(dCiCr)×1.54 + \max(d_{C_i \to C_r}) \times 1.5
5CrC_r收齐所有3个InvAck(取最晚到达的)取决于max\max时间
6CrC_r完成事务:将数据写入Cache,状态设为M2

写缺失(目录S状态,3个共享者)的时序分析

写缺失事务的总延迟由关键路径决定——关键路径是阶段1\to2\to4\to6(请求到Home,Home转发Inv到最远共享者,最远共享者回复InvAck到请求者)。注意阶段3的Data传输与阶段2/4的Inv/InvAck传输是并行的——HH在发出Inv消息的同时就发送数据给CrC_rCrC_r在收到数据后缓存在MSHR中,等收齐所有InvAck后才正式完成事务。

假设平均跳数为5跳,则关键路径延迟约为(2+7.5)+(3+7.5)+(4+7.5)+2=34(2+7.5) + (3+7.5) + (4+7.5) + 2 = 34个周期。但如果共享者数量增加到10个或更多(如热点锁变量),且某些共享者位于Mesh的远端,延迟可能增加到50\sim60个周期。

InvAck收集的实现细节

写缺失事务中的InvAck收集机制是目录协议实现中的一个关键细节。CrC_r需要知道自己需要等待多少个InvAck才算事务完成。有两种主要实现方式:

(1)Home告知AckCountHH在Data消息中附带一个AckCount字段(通常4\sim6位),告知CrC_r有多少个共享者被发送了Inv。CrC_r维护一个递减计数器,每收到一个InvAck就递减,计数器到0时事务完成。这种方式的优点是CrC_r自行判断完成条件,不需要HH再发送额外的完成消息。这也是ARM CHI协议采用的方式。

(2)Home收集后通知HH自己收集所有InvAck,确认全部到达后再向CrC_r发送一条CompAck(完成确认)消息。CrC_r只需要等待这一条消息。这种方式简化了CrC_r的逻辑,但增加了延迟(多一跳:CiHC_i \to H的InvAck,再加上HCrH \to C_r的CompAck)。

大多数现代处理器采用方式(1),因为它将事务完成的判断分布到了请求核心,避免了Home成为瓶颈。但方式(1)有一个潜在问题:如果CrC_r在收到Data但尚未收齐所有InvAck时,又有另一个核心CsC_s对同一地址发出请求,CrC_r必须推迟响应CsC_s的请求直到自己的事务完成——否则可能违反SWMR不变量。这正是瞬态状态IMDA\mathrm{IM}^{DA}(已收到数据Data,但Ack尚未全部到达)存在的原因。

混合协议

在实际的商用处理器中,一致性协议往往不是纯粹的监听或纯粹的目录协议,而是根据系统的不同层次采用不同的策略——这就是混合协议(Hybrid Protocol)。例如,在一个多芯片(Multi-Socket)系统中,片内(同一芯片上的多个核心之间)使用监听或基于共享LLC的目录协议,片间(不同芯片之间)使用基于目录的协议。

片内监听与片间目录

现代多核处理器的典型一致性层次结构如下:

(1)片内一致性(Intra-Chip Coherence):同一芯片上的多个核心通过片上互连(如Ring Bus、Mesh NoC)和共享LLC进行一致性管理。由于核心数量有限(4\sim16个,或更多但分成多个Cluster),且片内通信延迟低(几个时钟周期),可以采用高效的监听协议或基于包含式LLC的目录协议。

(2)片间一致性(Inter-Chip Coherence):不同芯片之间通过高速串行互连(如Intel UPI、AMD Infinity Fabric、ARM CCIX/CXL)进行一致性管理。由于芯片间通信延迟高(几十到上百纳秒)且带宽有限,必须采用基于目录的协议来避免广播。

片内监听与片间目录的混合架构
片内监听与片间目录的混合架构

AMD的Infinity Fabric一致性

AMD的Zen系列处理器采用了独特的chiplet架构。每个CCD(Core Complex Die)包含8个核心和一个32 MB的L3 Cache,使用片内的目录协议。多个CCD通过Infinity Fabric互连到I/O Die(IOD),IOD中包含内存控制器和片间一致性引擎。CCD之间的一致性事务通过IOD的Home Agent进行仲裁,采用基于目录的MOESI协议。Zen 4的IOD还支持3D V-Cache技术,为每个CCD叠加额外的64 MB L3 Cache,一致性协议需要将这些扩展Cache纳入管理。

AMD的一致性架构体现了清晰的层次化设计:CCD内部的8个核心通过L3 Cache的Slice进行一致性管理(类似于包含式监听过滤器),延迟极低(10\sim15周期);跨CCD的一致性事务需要经过Infinity Fabric和IOD的Home Agent,延迟较高(50\sim100周期)。这种层次化延迟特性对软件的数据布局有重要影响——将频繁共享的数据分配给同一CCD上的核心可以避免跨CCD的一致性开销。AMD的EPYC服务器处理器支持NUMA感知的线程调度,操作系统可以将需要频繁通信的线程绑定到同一CCD,以最小化一致性延迟。

在Zen 5架构中,AMD进一步优化了CCD间的一致性延迟,引入了直接CCD-to-CCD通信路径——部分一致性事务可以绕过IOD直接在CCD之间完成,将跨CCD的延迟从约100周期降低到约60\sim70周期。这种优化对于高度并行的工作负载(如数据库事务处理、HPC应用)具有显著的性能意义。

AMD MOESI协议在Infinity Fabric上的实现细节

AMD的MOESI协议在Infinity Fabric上的实现涉及两级协议的协调:

CCD内部(L3 Cache级别):CCD内部的8个核心通过L3 Cache的Tag和一致性位向量进行一致性管理。L3 Cache采用受害者缓存(Victim Cache)策略——数据只在被L2驱逐时才进入L3(L3不包含L2的副本)。L3的每个Set中的Tag除了存储地址信息外,还维护一个8位的核心存在位向量(对应CCD内的8个核心),指示哪些核心的L2可能缓存了该行。当核心A的L2缺失但L3命中时,L3直接提供数据(两跳,延迟约10\sim15个周期)。当核心A的L2缺失且L3也缺失时,请求需要通过Infinity Fabric上升到IOD。

CCD之间(IOD级别):IOD(I/O Die,或在Zen 4中称为IOD Chiplet)中的Data Fabric包含系统级的Home Agent和目录结构。当一个CCD的L3缺失导致请求到达IOD时,IOD的Home Agent查询系统级目录来确定:(a)数据是否在另一个CCD的L3中;(b)数据是否被另一个CCD中的某个核心以M/O状态持有;(c)数据是否需要从DRAM获取。

如果目标数据在另一个CCD的L3中,IOD的Home Agent向目标CCD发送转发消息,目标CCD直接将数据通过Infinity Fabric发送给请求CCD——这是MOESI的O状态机制的跨CCD扩展。在整个过程中,如果数据是脏的(M/O状态),它不需要写回DRAM——O状态核心负责将来的写回,从而节省了昂贵的DRAM写操作。

AMD的系统级目录采用有限指针设计——每个目录条目维护2\sim4个CCD级别的指针(每个指针log2NCCD\lceil\log_2 N_{\text{CCD}}\rceil位)。对于最多12个CCD的EPYC处理器,每个指针需要4位,4个指针共16位。这比64位的完整位向量(对应64核以Cache行为粒度的追踪)紧凑得多,且溢出概率极低——在实际工作负载中,超过4个CCD同时共享同一Cache行的情况极为罕见。

设计权衡 2 — chiplet架构下一致性协议的延迟-带宽权衡

chiplet架构(如AMD Zen系列)将处理器分解为多个小芯片(chiplet),通过高速互连(如Infinity Fabric)连接。这种架构在制造成本和良率方面具有显著优势,但对一致性协议提出了新的挑战:

  • 延迟增加:跨chiplet的一致性事务需要经过SerDes(串行化/反串行化)电路和互连布线,延迟比片内通信高2\sim5倍。

  • 带宽受限:chiplet间的互连带宽远低于片内互连。AMD Zen 4的Infinity Fabric提供约32 GB/s/方向的带宽(CCD到IOD),而片内Ring Bus可提供超过200 GB/s。

  • 功耗代价:跨chiplet的数据传输功耗约为片内传输的5\sim10倍(SerDes的功耗是主要来源),这使得减少一致性流量变得更加重要。

AMD通过以下策略缓解chiplet架构的一致性开销:(1)32 MB的大容量L3 Cache作为CCD级别的一致性过滤器——大部分一致性事务在CCD内部解决;(2)MOESI的O状态避免跨chiplet的写回流量;(3)Probe Filter(在IOD中)精确追踪跨CCD的共享关系,避免不必要的跨CCD Snoop。

Intel的Mesh互连一致性

Intel从Skylake-SP开始采用Mesh互连替代Ring Bus。在Mesh架构中,每个核心与一个LLC Slice相邻形成一个Tile,所有Tile通过2D Mesh网络互连。一致性协议基于三代理模型:Caching Agent(核心的Cache控制器)、Home Agent(内存控制器和目录)、Snoop Filter Agent(LLC Slice中的监听过滤器)。当一个Caching Agent发出请求时,它首先通过地址哈希找到对应的Home Agent,Home Agent查询Snoop Filter来确定需要监听哪些核心,然后发送定向监听消息。

Intel Mesh互连的一致性实现有几个值得注意的设计细节:

(1)地址哈希分布。LLC被均匀地分成NslicesN_{\text{slices}}个Slice(通常等于核心数),地址通过一个哈希函数映射到某个Slice。哈希函数的设计目标是使地址在各Slice间均匀分布,避免热点。Intel使用一个基于XOR的哈希函数——将地址的不同位组进行XOR运算来生成Slice编号。这种哈希函数简单、快速(单周期可完成),且能提供良好的分布均匀性。

(2)Snoop模式选择。Intel的Mesh互连支持多种Snoop模式,系统BIOS可以根据工作负载特性选择最优模式:

  • Core Snoop:类似于传统监听——请求直接广播到所有核心,不使用Home Agent。延迟最低但带宽消耗最大,适用于核心数少的配置。

  • Home Snoop:所有请求先发到Home Agent,由Home查询Snoop Filter后发送定向Snoop。带宽效率最高但延迟增加一跳,适用于核心数多的配置。

  • Early Snoop:混合模式——请求同时发给Home Agent和可能的共享者。如果Snoop先命中,可以在Home响应前就完成数据传输(减少延迟);如果Snoop未命中,Home的响应作为后备。这种模式在延迟和带宽之间取得了较好的平衡。

(3)非包含式LLC的一致性维护。从Skylake-SP开始,Intel的LLC采用非包含式策略——私有Cache中的数据不一定在LLC中有副本。这要求在LLC的Tag阵列旁边维护一个独立的Snoop Filter,其条目数通常为LLC条目数的1.5\sim2倍(因为需要追踪的行数等于所有私有Cache的总容量)。Snoop Filter的条目包含地址Tag和核心存在位向量(NN位),当Snoop Filter因容量限制而需要驱逐某个条目时,会触发反向无效化——向持有该行的核心发送无效化消息,强制其驱逐该行。反向无效化确保了Snoop Filter的精确性(不存在Snoop Filter中没有记录但某核心却持有的行),但也引入了额外的Cache缺失。Intel通过设计足够大的Snoop Filter容量(通常覆盖所有L1+L2的总容量)来将反向无效化的频率降到可忽略的水平。

ARM CHI协议

ARM AMBA CHI(Coherent Hub Interface)是ARM公司定义的高性能一致性互连协议,用于连接ARM核心(Cortex-A/X系列)、GPU、NPU和其他加速器。CHI是AMBA ACE协议的继任者,面向大规模多核和chiplet架构设计。

CHI协议定义了三种类型的节点:

  • RN(Request Node):发起一致性请求的节点,通常是处理器核心的Cache控制器。RN又分为RN-F(Fully Coherent,参与完整一致性协议)和RN-I(IO Coherent,发起一致性请求但不被监听)。

  • HN(Home Node):管理一致性的中心节点,负责序列化对同一地址的请求、查询目录、发送监听消息。HN-F包含Snoop Filter或目录。

  • SN(Slave Node):被动响应请求的节点,通常是内存控制器。SN不参与一致性协议的状态管理。

CHI的通道结构是其设计的核心特征之一。CHI定义了五个独立的通道(Channel),每个通道承载不同类型的消息,这种分离设计是避免协议死锁的结构性保障:

CHI通道的死锁避免机制

CHI的五通道分离设计有明确的死锁避免意图。在一致性协议中,死锁通常发生在不同类型的消息共享同一传输资源时形成的环路依赖。CHI通过以下规则结构性地消除死锁:

  • REQ通道不依赖于SNP通道的可用性——一个RN发出的请求不会因为SNP通道忙碌而被阻塞。

  • SNP通道不依赖于REQ通道的可用性——HN发出的Snoop请求不会因为REQ通道忙碌而被阻塞。

  • RSP通道不依赖于DAT通道的可用性——确认消息可以独立于数据传输发送。

  • DAT通道是"终端"通道——数据传输完成后不需要等待其他通道的响应(CompAck除外,但CompAck走RSP通道,与DAT通道独立)。

这种分离保证了:即使某个通道的缓冲区暂时被占满,其他通道的消息仍然可以正常流动,不会形成"通道A等待通道B,通道B等待通道A"的死锁环路。

从形式化分析的角度看,通道分离将协议的消息依赖图分解为一个有向无环图(DAG)——如果将每个通道视为一个节点,通道间的依赖关系视为有向边(例如REQ通道上的请求可能触发SNP通道上的Snoop,形成REQ\toSNP的依赖),那么只要这个依赖图是无环的,就不会出现死锁。CHI和TileLink的通道设计都经过精心安排,确保通道间的依赖关系构成DAG。

五通道架构的设计推导

CHI的五通道设计并非任意选择,而是从死锁自由性的需求出发的结构性解决方案。要理解为什么需要五个通道,首先需要理解一致性协议中消息依赖关系的本质。

在一次典型的写缺失事务中,消息的依赖链如下:

  1. RN发出REQ(ReadUnique)\to HN收到请求。

  2. HN发出SNP(SnpUnique)\to 被监听的RN收到Snoop。

  3. 被监听的RN发出RSP(SnpResp)或DAT(SnpRespData)\to HN和/或请求RN收到响应。

  4. HN或被监听RN发出DAT(CompData)\to 请求RN收到数据。

  5. 请求RN发出RSP(CompAck)\to HN收到确认,事务完成。

如果REQ和SNP共享同一通道,则可能出现以下死锁:RN-A在REQ通道上发出请求,占据了通道缓冲区;HN需要在同一通道上向RN-A发送SNP消息,但通道缓冲区已满(被RN-A的REQ占据);HN无法发送SNP,因此RN-A的事务无法推进,RN-A不会释放REQ缓冲区——死锁。将REQ和SNP分到独立通道后,这种依赖环路被结构性地打破。

类似地,RSP和DAT必须分离:如果RSP(CompAck)和DAT(CompData)共享通道,被监听的RN可能需要先发送DAT(SnpRespData)才能释放内部资源来处理新的REQ,而新的REQ可能需要DAT通道上的CompData才能完成——又形成了环路依赖。

因此,CHI的通道分离遵循一个核心原则:每对具有因果依赖的消息类型必须使用不同的通道。五个通道的设计是满足这一原则的最小通道数配置——用更少的通道无法避免所有可能的死锁场景。

CHI事务流程详解:ReadShared

下面以一个完整的ReadShared事务为例,展示CHI五通道协议的消息交互过程。假设RN-0请求读取地址XX(当前被RN-1以Dirty Exclusive状态持有),Home节点为HN-0。

第1步(REQ通道):RN-0通过REQ通道向HN-0发送ReadShared请求。请求Flit包含目标地址XX、事务ID、请求类型等信息。HN-0的请求队列接收此请求。

第2步(SNP通道):HN-0查询Snoop Filter,发现RN-1持有地址XX的修改副本。HN-0通过SNP通道向RN-1发送SnpSharedFwd消息——"Fwd"后缀表示要求RN-1将数据直接转发给RN-0(DCT优化),而非回传给HN-0。SnpSharedFwd的Flit中包含转发目标RN-0的节点ID。

第3步(DAT通道):RN-1收到SnpSharedFwd后,通过DAT通道将Cache行数据直接发送给RN-0(CompData消息)。同时RN-1更新本地Cache状态为Shared Clean或Owned(取决于实现)。如果Cache行大小为64字节、DAT通道宽度为256位,则需要2个DAT Flit来传输完整数据。

第4步(RSP通道):RN-1还需要通过RSP通道向HN-0发送SnpResp消息,告知HN-0 Snoop操作已完成及RN-1的最终状态。HN-0据此更新Snoop Filter中的记录。

第5步(RSP通道):RN-0收到完整数据后,通过RSP通道向HN-0发送CompAck消息,确认事务完成。HN-0收到CompAck后释放该事务占用的跟踪资源。

上述五步交互涉及四个通道(REQ、SNP、DAT、RSP),整个事务的延迟关键路径为:REQ传播到HN + HN处理 + SNP传播到RN-1 + RN-1处理 + DAT传播到RN-0 = 三跳延迟 + 处理开销。DCT优化使得数据直接从RN-1传给RN-0,避免了数据先回传到HN再转发的额外跳数。

CHI的Credit-Based流控

CHI使用基于信用(Credit-Based)的流控机制来管理每个通道上的消息传输。每个接收者向发送者预先分配若干信用令牌(Credit Token),每发送一个Flit消耗一个信用。当接收者处理完一个Flit并释放了缓冲区空间后,它返回一个信用令牌给发送者。如果发送者的信用为零,则必须等待信用返回后才能继续发送。

Credit-Based流控的关键优势在于它是无损的(Lossless)——消息不会因为缓冲区溢出而被丢弃,因此不需要消息重传机制。但它也引入了一个设计约束:信用的往返延迟(Credit Round-Trip Latency)直接影响了通道的有效吞吐量。如果信用往返延迟为tcreditt_{\text{credit}}个周期,则要维持通道满吞吐量需要至少tcreditt_{\text{credit}}个信用的预分配——这意味着接收者需要tcreditt_{\text{credit}}个缓冲区条目。在大规模Mesh网络中,信用往返延迟可能高达20\sim30个周期,每个通道每对收发节点之间需要预留20\sim30个缓冲区条目,总存储开销不可忽视。

CHI的Flit格式与位宽分析

CHI采用Flit(Flow Control Unit)作为消息传输的基本单元。不同通道的Flit具有不同的格式和位宽:

REQ Flit(约100位)包含以下关键字段:

  • TgtID(目标节点ID,log2Nnodes\lceil\log_2 N_{\text{nodes}}\rceil位):消息的目标节点。

  • SrcID(源节点ID):消息的发送节点。

  • TxnID(事务ID,12位):标识一次一致性事务,用于将响应与请求匹配。

  • Addr(地址,44\sim52位):被请求的Cache行地址。

  • Opcode(操作码,6位):请求类型(如ReadShared=0x01, ReadUnique=0x03等)。

  • Size(数据大小,3位):请求的数据粒度(1字节到64字节)。

  • MemAttr(内存属性,4位):可缓存性、可分配性等属性。

  • Order(排序要求,2位):指定是否需要与之前的请求保持顺序。

DAT Flit(256\sim512位)的大部分位宽被Data字段占据——一个64字节Cache行的数据需要512位。在256位数据总线的实现中,一个64字节的数据传输需要2个Flit。DAT Flit还包含DBID(Data Buffer ID)、Resp(响应类型)和DataID(标识分片中的哪一部分)等控制字段。

CHI的Snoop请求类型分类

CHI定义了超过10种Snoop请求类型,为不同的一致性场景提供精确的操作语义:

  • SnpShared:请求被Snoop的核心将Cache行降级为共享状态(如果持有E/M则降级为S/O,如果持有S则不变)。不要求无效化。

  • SnpClean:类似SnpShared,但如果被Snoop核心持有脏数据(M/O),要求其提供脏数据并清除脏状态。

  • SnpUnique:要求被Snoop的核心无效化其Cache行副本(转为I状态)。这是最强的Snoop类型,用于写操作获取独占权限。

  • SnpCleanInvalid:要求被Snoop的核心写回脏数据(如果有)并无效化。

  • SnpMakeInvalid:要求被Snoop的核心无效化其副本,但不需要提供数据。用于写操作中请求核心已经持有数据但需要获取写权限的场景(类似BusUpgr)。

  • SnpOnce:一次性Snoop——如果被Snoop的核心持有脏数据则提供,但不改变任何核心的Cache状态。用于非缓存操作或调试读取。

  • SnpSharedFwd / SnpUniqueFwd:带转发(Forward)语义的Snoop——被Snoop的核心不仅降级/无效化自己的副本,还直接将数据转发给请求核心(DCT,Direct Cache Transfer),避免数据回传到HN后再转发的额外延迟。

这些丰富的Snoop类型使CHI协议能够在各种场景下选择最高效的操作——例如,当请求核心只需要获取写权限(已持有数据)时使用SnpMakeInvalid而非SnpUnique,避免了不必要的数据传输。

ARM CHI协议的系统拓扑结构
ARM CHI协议的系统拓扑结构

CHI协议支持完整的MOESI状态模型,并定义了丰富的Snoop请求类型以支持不同的一致性操作。例如,SnpShared请求让被监听的核心将Cache行从M/E状态降级为S状态;SnpUnique请求让被监听的核心将Cache行无效化(类似于BusRdX);SnpCleanInvalid请求让被监听的核心写回脏数据并无效化。

CHI协议的一个重要特性是支持DMT(Direct Memory Transfer)和DCT(Direct Cache Transfer):

  • DMT:SN节点直接将数据发送给RN请求者,而非先经过HN。这减少了数据路径的跳数。

  • DCT:被Snoop的RN节点直接将数据转发给请求的RN节点,实现了类似于三跳传输中的第三跳直接传输优化。

CHI的Retry机制

CHI协议包含一个完善的Retry(重试)机制来处理资源不足的情况。当HN的事务缓冲区已满无法接受新的请求时,HN不是简单地丢弃请求,而是向请求的RN发送RetryAck消息——告知"你的请求无法现在处理,请稍后重试"。RN收到RetryAck后将请求保留在本地队列中等待重试。

当HN释放了事务缓冲区空间后,它向之前被拒绝的RN发送PCrdGrant(Protocol Credit Grant)消息——告知"现在有空间了,你可以重新发送请求"。RN收到PCrdGrant后重新发送之前的请求。

这种Retry机制比简单的NACK更加高效:

  • 避免盲重试:RN不会在收到PCrdGrant之前重试,避免了重试风暴。

  • 公平性:HN可以按FIFO或优先级顺序发送PCrdGrant,确保所有被拒绝的请求最终都能被处理。

  • 背压传播:Retry机制实现了从HN到RN的背压(Back-Pressure),当系统负载过高时自然地降低请求发送速率,避免网络拥塞。

CHI的原子操作支持

CHI协议内建了对原子操作(Atomic Operation)的支持——这对于实现无锁数据结构(如Compare-and-Swap、Fetch-and-Add等)至关重要。CHI定义了以下原子操作类型:

  • AtomicStore:对目标地址执行原子的读-修改-写操作(如AtomicAdd、AtomicMax等),不返回旧值。

  • AtomicLoad:对目标地址执行原子的读-修改-写操作,返回旧值给请求者。

  • AtomicSwap:原子地将目标地址的值替换为新值,返回旧值。

  • AtomicCompare:原子的Compare-and-Swap操作。

CHI的原子操作可以在两个位置执行:Near-Data(在HN/SN附近执行,减少数据移动)或Near-Core(在请求核心本地执行,需要先获取独占权限)。Near-Data执行特别适用于高竞争的原子变量——如果多个核心竞争同一个原子计数器,Near-Data方式可以在Home Agent处集中处理所有原子操作,避免Cache行在核心之间反复乒乓。

案例研究 2 — ARM Neoverse V2的CHI实现

ARM Neoverse V2核心(用于AWS Graviton 4等服务器处理器)采用CHI协议连接到ARM CMN-700(Coherent Mesh Network)互连。CMN-700支持最多256个核心的一致性域,使用分布式Snoop Filter(每个HN-F Slice维护一个Snoop Filter分区)。在Neoverse V2的配置中,L3 Cache被分成多个Slice分布在Mesh网络中,每个Slice与一个HN-F节点绑定。一致性请求通过地址哈希定位到对应的HN-F Slice,HN-F查询本地Snoop Filter来确定需要监听哪些核心。CMN-700的Snoop Filter采用了类似于有限指针目录的设计,每个条目维护4\sim8个指针来追踪共享者。

在性能方面,CMN-700的一致性延迟具有以下特征:

  • L1-to-L1 Cache传输(同一cluster):约25\sim35ns。数据通过本地Crossbar直接传输,不经过Mesh。

  • L1-to-L1 Cache传输(跨cluster):约40\sim60ns。经过Mesh网络的三跳传输。

  • L3命中(Home Agent本地):约15\sim25ns。两跳传输(请求\toHome,数据\to请求者)。

  • DRAM访问:约80\sim120ns。三跳传输加上DRAM访问延迟。

CMN-700支持的CHI特性包括DCT(Direct Cache Transfer)、DMT(Direct Memory Transfer)、Stash操作(允许一个核心将数据主动推送到另一个核心的Cache中)以及MPAM(Memory Partitioning and Monitoring,用于QoS管理)。

TileLink是由SiFive定义的开放互连协议,作为RISC-V生态系统的标准一致性互连协议。TileLink的设计目标是提供一个简洁、可扩展、可形式化验证的一致性协议,适用于从微控制器到服务器级处理器的各种RISC-V实现。

TileLink定义了五个通道(Channel A\simE),每个通道承载不同方向和类型的消息:

通道方向类型功能描述
AMaster \to Slave请求获取数据(Get)、写入数据(Put)、获取一致性权限(Acquire)
BSlave \to Master探测探测请求(Probe),要求Master降低权限或提供数据
CMaster \to Slave释放/探测响应主动释放(Release)、探测响应(ProbeAck)、写回数据
DSlave \to Master响应授权(Grant)、访问确认(AccessAck)、提供数据
EMaster \to Slave授权确认授权确认(GrantAck),完成三次握手

TileLink的通道定义

TileLink支持三种一致性级别(Conformance Level),适用于不同复杂度的设计:

  • TL-UL(Uncached Lightweight):最简单的级别,只使用通道A和D,支持不可缓存的Get/Put操作。适用于简单的外设互连。

  • TL-UH(Uncached Heavyweight):在TL-UL基础上增加原子操作(Atomic)和突发传输(Burst)支持。适用于需要原子操作的DMA和外设。

  • TL-C(Cached):完整的一致性协议,使用所有五个通道,支持Acquire/Release/Probe等一致性操作。适用于多核处理器的一致性互连。

图 9.14展示了TileLink中一次完整的Acquire事务(获取独占权限)的消息交互流程。

TileLink使用三种权限级别来描述Cache行的状态:

  • N(Nothing):无权限,等价于Invalid。

  • B(Branch):只读权限,等价于Shared。

  • T(Trunk/Tip):读写权限,等价于Modified/Exclusive。

权限转换通过以下操作实现:

  • Acquire (NtoB):从无权限获取只读权限。

  • Acquire (NtoT):从无权限获取读写权限。

  • Acquire (BtoT):从只读权限升级为读写权限。

  • Probe (toN):降级到无权限(无效化)。

  • Probe (toB):降级到只读权限。

  • Release (TtoN):主动释放读写权限并写回。

  • Release (TtoB):主动从读写降级到只读。

设计提示

TileLink的五通道设计与ARM CHI的四通道设计在本质上是相似的——都是为了将不同类型的消息(请求、探测、数据、响应)分离到独立的通道上,从而避免协议死锁。在流水线化的互连中,如果不同类型的消息共享同一通道,可能出现资源依赖导致的死锁(例如,一个Probe响应等待通道资源,而通道被一个等待Probe响应的请求占据)。通过将消息分到独立的通道,可以保证每种类型的消息有独立的缓冲和仲裁路径,从而结构性地避免死锁。

TileLink与CHI的对比

表 9.19对比了TileLink和ARM CHI这两个面向不同生态系统的一致性协议。

TileLink的极简设计使其特别适合学术研究和新兴的RISC-V芯片初创公司——较少的消息类型和状态意味着更低的验证成本和更短的开发周期。SiFive的RISC-V核心(如U74、P670/P680系列)都基于TileLink构建一致性互连。中国的平头哥半导体(T-Head)在其玄铁系列RISC-V处理器中也采用了TileLink的变体。

ARM CHI的丰富特性集则面向高性能服务器市场——其多样化的Snoop类型和数据传输优化(DMT/DCT)使得协议可以在各种复杂场景下实现最优性能。但这种复杂性也带来了更高的验证成本:ARM的CHI验证IP套件包含数千个定向测试用例和复杂的约束随机测试生成器。

TileLink的三次握手事务模型

TileLink TL-C协议中一个独特的设计决策是Acquire/Grant事务采用三次握手(Three-Way Handshake)模型,而非ARM CHI的两次握手:

  1. 第一次(A通道):Master发送Acquire请求——"我需要获取地址XX的T(独占)权限"。

  2. 第二次(D通道):Slave处理请求后发送Grant响应——"授予你地址XX的T权限,这是数据"。此时Slave已经完成了对其他Master的Probe操作。

  3. 第三次(E通道):Master收到Grant后发送GrantAck确认——"我已经收到了授权"。

为什么需要第三次握手(GrantAck)?这与事务完成的原子性有关。在Slave发出Grant消息后、Master收到之前,Slave可能需要处理对同一地址的新请求。如果Slave不知道Master是否已经收到Grant,它就无法确定当前的Owner是谁。GrantAck消息告诉Slave:"Master已经确认收到授权",此后Slave可以安全地将该地址的Owner记录更新为Master。

这种三次握手模型的代价是增加了一跳延迟(GrantAck的传输延迟),但提供了更强的事务原子性保证——在GrantAck到达之前,Slave可以确定地说"上一个事务尚未完成",从而简化了竞争条件的处理。ARM CHI通过使用事务ID和更复杂的状态跟踪来避免第三次握手,但代价是协议状态机更加复杂。

TileLink的Probe操作详解

TileLink的Probe操作(通过B通道从Slave发送到Master)对应监听协议中的Snoop概念,但其设计更加参数化。Probe消息的关键参数包括:

  • param(目标权限):指定被Probe的Master应将权限降低到什么级别。toN表示降到Nothing(完全无效化),toB表示降到Branch(只读权限)。Slave可以根据请求者的需求精确选择降级程度——例如,如果请求者只需要读权限,Slave只需要将Owner的权限从T降到B(而非完全无效化),从而保留Owner的只读副本。

  • address:被Probe的Cache行地址。

  • size:被Probe的数据范围(TileLink支持子行粒度的Probe,尽管大多数实现以完整Cache行为粒度)。

被Probe的Master通过C通道回复ProbeAck消息,其中包含:释放的权限级别(如TtoB表示从独占降为只读)、如果数据是脏的则附带写回数据(ProbeAckData)。Slave收集所有ProbeAck后才向请求者发送Grant。

这种参数化设计使得TileLink协议在逻辑上非常正交(Orthogonal)——每种操作的行为由参数决定,而非由不同的操作码区分。相比之下,ARM CHI使用超过10种不同的Snoop操作码来覆盖不同的场景(SnpShared、SnpUnique、SnpClean等),每种有独立的语义。TileLink的参数化方法减少了协议规范的体积(仅需定义Probe+param的组合规则),但要求实现者对参数组合的含义有精确的理解。

硬件描述 2 — 面向2030年代的一致性协议趋势:CXL

CXL(Compute Express Link)是面向2030年代异构计算的关键互连标准。CXL 3.0/3.1定义了三个子协议:CXL.io(基于PCIe的设备枚举和配置)、CXL.cache(设备缓存主机内存的一致性协议)、CXL.mem(主机访问设备附属内存的协议)。

CXL.cache协议允许加速器(GPU、NPU、FPGA)缓存主机CPU的内存,并与CPU的Cache保持一致性。这与传统的CPU间一致性不同——CXL.cache是一种非对称的一致性协议:主机CPU是Home Agent,设备是Caching Agent,但设备不参与CPU间的Snoop(设备不会被其他CPU监听)。CXL.cache定义了三种请求类型:D2H Req(设备到主机的请求)、H2D Req(主机到设备的Snoop请求)和H2D Rsp/D2H Rsp(响应消息)。

CXL.mem协议允许主机CPU通过CXL链路访问设备附属的内存(如CXL内存扩展器中的DRAM或持久性内存)。CXL 3.0引入的Back-Invalidate机制允许设备附属内存的Home Agent(位于CXL设备上)对CPU的Cache进行无效化——这使得CXL内存可以参与多主机的一致性域,为disaggregated memory架构提供了硬件基础。

CXL一致性协议的延迟特性与传统CPU间一致性有显著差异。CXL链路的物理层延迟约为50\sim100ns(取决于链路长度和SerDes延迟),加上协议层的开销,一次CXL.cache的一致性事务端到端延迟约为150\sim300ns——这比CPU片内的一致性延迟(10\sim50ns)高一个数量级,但比传统PCIe DMA操作(数微秒级)低一个数量级。CXL 3.0通过引入共享内存池(Shared Memory Pool)和多主机一致性(Multi-Headed Coherence),使得多个CPU可以以硬件一致性的方式共享同一块CXL附属内存——这对于分布式数据库、内存计算等场景具有革命性意义。

在2030年代的chiplet和UCIe(Universal Chiplet Interconnect Express)时代,一致性协议将跨越芯片边界、封装边界乃至机架边界。CXL、CHI和UCIe的融合将使得一致性域从"单芯片上的几十个核心"扩展到"跨多个芯片和设备的数百个计算单元"——这对一致性协议的可扩展性、延迟和验证复杂度提出了前所未有的挑战。

一致性协议的实现考量

前面几节讨论了一致性协议的理论框架和状态转换逻辑。本节转向协议的硬件实现层面,讨论在实际的RTL设计中需要解决的关键工程问题。

MSHR与一致性事务跟踪

每个未完成的一致性事务(从发出请求到收到所有响应)需要占用一个MSHR条目(Miss Status Holding Register)。MSHR在一致性协议中的作用远比在简单Cache中复杂——它不仅需要跟踪缺失地址和请求者信息,还需要维护一致性事务的完整状态。

一个支持目录协议的L1 Cache控制器中,每个MSHR条目通常包含以下字段:

  • 地址标签(约30\sim40位):被请求的Cache行地址。

  • 事务类型(3\sim4位):GetS/GetM/Upgrade/PutM等。

  • 瞬态状态(4\sim5位):当前的瞬态状态(如ISD\mathrm{IS}^DIMDA\mathrm{IM}^{DA}等)。

  • 待接收Ack计数(4\sim6位):写缺失事务中还需等待多少个InvAck。

  • 数据缓冲区指针(4\sim5位):指向存储接收到的数据的临时缓冲区位置。

  • 合并请求队列(每条目\sim4个请求):在同一MSHR条目等待期间,处理器可能对同一地址发出多个读/写请求,这些请求需要被合并(Merge)并在事务完成后批量服务。

  • 时间戳/超时计数器(8\sim10位):用于检测一致性事务的超时——如果一个事务在规定时间内未完成,可能表明存在协议bug或网络拥塞,需要触发重试或错误处理。

典型的L1 Cache控制器有8\sim16个MSHR条目,L2有16\sim32个。MSHR条目数量直接限制了核心的最大未完成一致性事务数(Outstanding Coherence Transactions),进而影响了核心可以容忍的内存级并行度。如果所有MSHR条目都被占用(全部都在等待一致性响应),核心将无法发起新的缺失请求,导致流水线停顿。

设计提示

MSHR的设计是Cache控制器中面积和功耗的主要贡献者之一。一个16条目的MSHR,每条目约100位(不含数据缓冲区),需要约200字节的SRAM。数据缓冲区(每条目64字节×\times16条目=1 KB)的面积更大。在功耗方面,MSHR的CAM(Content-Addressable Memory)查找(每次Snoop请求到达时需要检查是否与某个未完成事务冲突)是一个频繁的高功耗操作。优化MSHR的CAM查找能耗是Cache控制器低功耗设计的重要目标。

写回缓冲区与一致性的交互

Cache控制器中的写回缓冲区(Write-Back Buffer, WBB)用于暂存被驱逐的脏Cache行数据,直到这些数据被成功写回下一级Cache或主存。写回缓冲区与一致性协议的交互涉及几个重要的设计问题:

(1)Snoop对写回缓冲区的检查。当一个Snoop请求到达Cache控制器时,除了检查Cache Tag阵列外,还必须检查写回缓冲区——因为被驱逐的脏数据可能还在WBB中等待写回。如果Snoop的地址与WBB中某个条目匹配,Cache控制器必须从WBB(而非Cache Tag)中提供数据。这要求WBB具有按地址查找的CAM功能。

(2)写回与无效化的竞争。一种常见的竞争条件是:Cache控制器将一个M状态行驱逐到WBB中(Cache Tag中该行变为I),几乎同时另一个核心对同一地址发出写请求。此时Home查询目录发现该行仍在当前核心(因为目录尚未收到WriteBack确认),向当前核心发送Fwd-GetM。核心的Cache Tag中该行已为I,但WBB中仍有脏数据。正确的处理方式是:从WBB中提取数据发送给请求者,并取消WBB中的写回操作(因为数据已被转发)。

(3)WBB容量对一致性延迟的影响。如果WBB满,Cache控制器无法驱逐新的脏行,进而无法为incoming的Snoop请求腾出空间。一些Snoop响应需要Cache控制器将脏数据写回后再响应(如M\toI的转换需要先将数据写到WBB或直接发送给请求者)。如果WBB满,Snoop响应被延迟,可能导致其他核心的一致性事务停顿。典型的L1 WBB有4\sim8个条目、L2有8\sim16个条目。

一致性协议的验证

一致性协议是处理器设计中最容易出错的部分之一。协议中的竞争条件、瞬态状态、消息重排等因素使得穷举测试几乎不可能覆盖所有场景。一个微妙的协议bug可能只在极其特殊的消息到达顺序下才会触发,但一旦触发就可能导致数据损坏——这是最严重的硬件bug类别。历史上多次处理器的重大bug与一致性协议有关:Intel Pentium Pro的一个一致性bug在特定条件下导致数据无效化丢失;AMD Barcelona(2007年)的TLB与一致性协议的交互bug导致了大规模的芯片召回——该bug发生在TLB项被无效化的同时恰好有一个一致性事务正在使用该TLB项的地址翻译结果,导致一致性消息被发送到错误的物理地址。这个bug的根本原因是TLB无效化和一致性协议之间缺乏充分的同步机制,只在高负载下极低概率触发,但一旦触发就可能导致数据损坏。AMD后来通过BIOS微码更新禁用了TLB的部分优化路径来绕过这个bug,代价是约10%的TLB性能下降。

这些真实案例说明了一致性协议验证的极端重要性——一个隐藏在数百万行RTL代码中的微妙竞争条件,可能导致数亿美元的产品召回和品牌损失。

Murphi模型检查

Murphi是最广泛使用的Cache一致性协议验证工具,由斯坦福大学David Dill教授团队开发。Murphi的核心方法是模型检查(Model Checking):将协议建模为一个有限状态机,然后穷举遍历该状态机可达的所有状态,检查是否存在违反不变量的状态。

Murphi使用一种类似Pascal的语言来描述协议模型。以下是MSI协议的Murphi建模框架:

c
const
  NUM_CORES = 3;
  NUM_ADDRS = 2;

type
  CoreID   = 0 .. NUM_CORES - 1;
  AddrType = 0 .. NUM_ADDRS - 1;
  CacheState = enum { I, S, M };
  Value    = 0 .. 3;

var
  cache:  array [CoreID] of array [AddrType] of CacheState;
  cdata:  array [CoreID] of array [AddrType] of Value;
  memory: array [AddrType] of Value;

-- 核心c对地址a的读操作
ruleset c: CoreID; a: AddrType do
  rule "PrRd from Invalid"
    cache[c][a] = I
  ==>
  begin
    -- 检查是否有其他核心持有M状态
    if exists o: CoreID do cache[o][a] = M end then
      -- 从Owner获取数据
      ... -- 状态转移逻辑
    else
      -- 从内存获取数据
      cache[c][a] := S;
      cdata[c][a] := memory[a];
    endif;
  end;
endruleset;

-- SWMR 不变量检查
invariant "SWMR"
  forall a: AddrType do
    forall c1: CoreID; c2: CoreID do
      (c1 != c2) ->
        !(cache[c1][a] = M & cache[c2][a] != I)
    endforall
  endforall;

-- 数据值不变量检查
invariant "Data Value"
  forall a: AddrType do
    forall c: CoreID do
      (cache[c][a] != I) ->
        (cdata[c][a] = memory[a] | cache[c][a] = M)
    endforall
  endforall;

Murphi的工作流程如下:

  1. 从初始状态开始,枚举所有可能的操作(如每个核心对每个地址的读/写)。

  2. 对每个操作,计算目标状态,检查所有不变量。

  3. 如果目标状态是新状态(未曾访问过),将其加入待探索队列。

  4. 重复上述过程,直到所有可达状态都被探索过,或发现违反不变量的状态。

Murphi的主要限制是状态空间爆炸(State Space Explosion)。状态空间大小的估算如下:对于NN个核心、AA个地址、KK种Cache状态、VV种数据值,总的状态空间大小约为:

S(K×V)N×A×VA |\mathcal{S}| \approx (K \times V)^{N \times A} \times V^{A}

其中第一项是各核心Cache的状态组合(每核每地址的Cache状态×\times数据值),第二项是主存中各地址的数据值。

对于一个3核、2地址的MSI协议(K=3K=3种稳定状态,V=4V=4种数据值),S=(3×4)3×2×422.99×106×164.8×107|\mathcal{S}| = (3 \times 4)^{3 \times 2} \times 4^2 \approx 2.99 \times 10^6 \times 16 \approx 4.8 \times 10^7个状态,Murphi可以在数秒内完成遍历。但对于一个8核、4地址的完整MOESI协议(K=5K=5种稳定状态+15种瞬态状态=20=20V=4V=4),S(20×4)32×441058|\mathcal{S}| \approx (20 \times 4)^{32} \times 4^4 \approx 10^{58}——远超任何计算机的处理能力。即使考虑到大部分状态是不可达的(实际可达状态远少于理论上限),8核MOESI的可达状态数仍然轻易超过101510^{15}

面对如此巨大的状态空间,实践中的策略是分层验证:首先用Murphi验证2\sim3核配置的完整正确性,然后用参数化验证(Parameterized Verification)或归纳证明(Inductive Proof)来论证协议在任意核心数NN下的正确性。参数化验证的核心思想是:如果协议在N=kN=k核配置下正确,且协议的行为对称性保证了N=k+1N=k+1核配置的正确性可以从N=kN=k推导出来,那么协议对所有NN都正确。

为了应对状态空间爆炸,Murphi及其后续工具采用了多种优化技术:

  • 对称性约简(Symmetry Reduction):利用核心和地址的对称性来减少需要探索的状态数量。如果核心0\sim7是对称的(它们执行相同的协议),那么"核心0持有M、核心1持有S"与"核心1持有M、核心0持有S"本质上是同一种状态,只需探索其中一种。对称性约简通常可以将状态空间减少N!N!倍(NN为对称组件数量)。

  • 哈希压缩(Hash Compaction):使用哈希函数来压缩状态表示,以更小的内存存储更多的状态。这可能引入少量的哈希碰撞(导致个别状态被跳过),但在实践中碰撞概率极低。

  • 分层验证:先验证简化版的协议(少核、少地址),然后逐步增加复杂度。

案例研究 3 — Intel的协议验证实践

Intel在其处理器设计中大量使用形式化验证来确保一致性协议的正确性。根据Intel公开的论文和报告,Haswell及后续处理器的一致性协议在设计阶段使用了基于Murphi的定制验证工具,共探索了超过101010^{10}个状态。验证过程在数百台服务器组成的验证集群上并行运行,耗时数周。此外,Intel还开发了基于形式化定理证明(如基于ACL2的方法)的互补验证流程,用于证明协议在任意核心数量下的正确性(而非仅验证有限配置)。AMD和ARM也有类似的验证投入,通常占整个一致性子系统设计团队工作量的40%\sim60%。

协议死锁与活锁检测

一致性协议中最难以调试的问题是死锁(Deadlock)和活锁(Livelock)。

死锁

协议死锁发生在两个或多个组件互相等待对方的响应,导致所有组件都无法继续处理。在一致性协议中,死锁通常发生在以下场景:

(1)资源依赖死锁:Core A的一致性请求需要等待Core B的Snoop响应,而Core B的Snoop响应需要等待Core A释放某个缓冲区资源。

(2)消息通道死锁:两个节点在同一通道上互相发送消息,且通道的缓冲区已满。Node A的消息无法发送给Node B(因为B的接收缓冲区满),而B的缓冲区之所以满是因为B正在等待向A发送的消息被A接收。

图 9.15展示了一个经典的消息通道死锁场景。

消息通道死锁示例——两个节点的请求互相阻塞
消息通道死锁示例——两个节点的请求互相阻塞

瞬态状态与死锁预防

一致性协议中的瞬态状态(Transient State)是指Cache行在一次一致性事务过程中的中间状态。例如,在MESI协议中,当一个核心发出BusRdX请求但尚未收到所有无效化确认时,该Cache行处于一种"正在等待升级"的瞬态状态(通常表示为ISD\mathrm{IS}^DIMD\mathrm{IM}^D等)。

瞬态状态是协议复杂性的主要来源。一个完整的MESI协议实现通常需要4种稳定状态和10\sim20种瞬态状态。更复杂的协议(如Intel的Skylake一致性协议)可能有30种以上的瞬态状态。每种瞬态状态都定义了在收到各种消息时的响应行为,这些行为的正确性必须通过形式化验证来保证。

为了理解瞬态状态的必要性,考虑以下竞争条件场景。Core 0处于I状态,对地址XX发出PrWr请求,Cache控制器发出BusRdX并将状态设为瞬态IMD\mathrm{IM}^D(Invalid到Modified,等待Data)。在BusRdX的数据返回之前,Core 1可能也对地址XX发出BusRdX(Core 1也要写入XX)。此时Core 0的状态机面临一个关键的设计决策:

选项A:Core 0在IMD\mathrm{IM}^D状态下收到Core 1的BusRdX监听——但Core 0尚未拥有数据(正在等待),无法提供数据给Core 1。Core 0可以发送一个NACK消息让Core 1稍后重试,或者在IMD\mathrm{IM}^D状态下缓存这个待处理的监听请求,等自己完成I\toM转换后再响应。

选项B:Core 0直接放弃自己的BusRdX事务(取消正在进行的状态转换),响应Core 1的监听请求,然后重新发起自己的请求。但这可能导致活锁——如果Core 1也同时放弃,两个核心可能无限循环地互相取消。

实际的协议实现通常采用选项A的某种变体,结合以下机制:(1)在目录协议中,Home节点负责序列化对同一地址的竞争请求,确保它们按顺序处理;(2)在监听协议中,总线的全局序自然地解决了竞争——先获得总线仲裁权的请求先被处理。

表 9.20列举了目录式MESI协议中的主要瞬态状态及其含义。

每种瞬态状态都需要定义:在该状态下如果收到来自处理器的新请求应如何处理(通常需要阻塞/排队,因为当前地址已有未完成的事务),以及如果收到来自其他核心的监听请求应如何响应(可能需要NACK、延迟响应、或缓存待处理)。一个nn种瞬态状态的协议,其状态转换表大小从原来的(4×k)(4 \times k)扩展为((4+n)×k)((4 + n) \times k)——这就是为什么Intel Skylake的一致性协议验证需要探索超过101010^{10}个状态的原因。

图 9.16展示了包含关键瞬态状态的MESI协议状态转移图。

包含瞬态状态的MESI协议状态转移图
包含瞬态状态的MESI协议状态转移图

死锁预防策略

协议设计者采用多种策略来预防死锁:

(1)通道分离(Channel Separation):将不同类型的消息分配到不同的物理或逻辑通道(如ARM CHI的REQ/SNP/RSP/DAT四通道,TileLink的A\simE五通道)。由于每种消息类型有独立的缓冲和路由资源,不同类型消息之间不会产生资源竞争。通道分离可以结构性地消除一大类死锁——只要保证每个通道内部不存在环路依赖。

(2)缓冲区预留(Buffer Reservation):为高优先级消息(如Snoop响应、无效化确认)预留专用的缓冲区位置,确保这些关键消息在任何情况下都能被接收和处理。如果Snoop响应的缓冲区从不被其他类型的消息占据,那么Snoop响应永远不会因为缓冲区满而被阻塞。

(3)消息排序约束(Message Ordering Constraints):定义严格的消息处理优先级。例如,Snoop请求必须在任何情况下被处理(即使Cache控制器正在处理另一个事务),且Snoop响应不能依赖于新的Snoop请求——这保证了Snoop处理链上不存在环路依赖。

(4)NACK与重试(Negative Acknowledgment and Retry):当一个节点无法处理某个请求时(例如所有事务缓冲区都已被占用),它可以发送NACK消息让请求者在随后重试。NACK机制通过"打破"资源等待链来避免死锁,但引入了活锁的风险——如果两个节点持续互相NACK,可能导致无限重试。

活锁与公平性

活锁是指系统中的组件都在持续运行(没有被阻塞),但由于不断重复无意义的操作而无法取得进展。在一致性协议中,活锁通常发生在以下场景:

(1)持续竞争:两个核心不断竞争同一个Cache行的独占权限,每次一个核心刚获得权限就被另一个核心的无效化请求夺走,导致两个核心都无法完成对该行的写操作。

(2)NACK风暴:大量请求同时到达同一个Home节点,Home的事务缓冲区已满,所有请求都被NACK,然后几乎同时重试,再次被NACK——形成"NACK风暴"。

活锁的预防通常通过以下机制:

  • 指数退避(Exponential Backoff):被NACK的请求者在重试前等待一个随机递增的延迟,减少同时重试的概率。典型的退避序列为{1,2,4,8,16,}\{1, 2, 4, 8, 16, \ldots\}个周期,直到达到上限(如256个周期)后回绕。

  • 优先级仲裁:在竞争场景下,使用固定或轮转的优先级来保证至少有一个请求者能取得进展。Intel的Home Agent实现中,当检测到同一地址的连续请求被NACK超过阈值次数时,会提升该请求的优先级。

  • 锁定机制:当一个核心连续多次竞争失败时,协议可以"锁定"该地址的下一个授权给该核心,防止饥饿。这种机制在ARM CHI协议中通过Excl(Exclusive Access)标志位支持——标记为Exclusive的请求在Home Agent处获得优先处理权。

竞争条件的系统级影响

一致性协议中的竞争条件不仅影响协议正确性,还对系统性能产生可观测的影响。在高度竞争的场景下(如多个核心频繁写入同一自旋锁),以下性能退化模式是常见的:

(1)一致性风暴(Coherence Storm):当多个核心同时对同一Cache行发起写请求时,Home Agent可能收到大量并发的GetM/Upgrade请求。由于同一地址的请求必须被序列化处理,后到的请求必须等待前面的请求完成。在NN个核心同时竞争同一行的极端情况下,最后一个请求的完成时间约为N×TtransactionN \times T_{\text{transaction}}——这是O(N)O(N)的串行化延迟。一致性风暴是高竞争自旋锁性能极差的根本原因。

(2)Snoop响应风暴:当一个被广泛共享的Cache行需要无效化时(如频繁读取的全局计数器突然被某核心写入),Home Agent需要向所有共享者发送Inv消息,并等待所有InvAck。如果共享者数量kk很大(如k=32k = 32),则InvAck的收集时间取决于最慢到达的InvAck——在网络拥塞时这个延迟可能很长。

(3)目录热点:如果某些Cache行被极频繁地访问(如锁变量、原子计数器),它们对应的Home Agent会成为性能瓶颈。该Home Agent的请求队列持续满载,导致新的请求被Retry,进一步增加延迟。这种热点效应在地址哈希不均匀的系统中尤为严重。

缓解上述问题的常见策略包括:(1)锁分散(Lock Striping):将一个全局锁拆分为多个局部锁,每个锁保护数据的一个子集,减少竞争;(2)读-拷贝-更新(Read-Copy-Update, RCU):使用延迟释放机制避免读操作加锁;(3)NUMA感知调度:将竞争同一数据的线程调度到同一NUMA节点,减少跨节点一致性流量。

一致性协议的性能计数器与调试

现代处理器提供了丰富的性能计数器(Performance Counter)来帮助开发者诊断一致性相关的性能问题。理解这些计数器的含义对于优化多线程应用至关重要。

Intel平台的一致性性能计数器

Intel处理器通过perf工具和VTune Profiler暴露了以下关键的一致性相关计数器:

  • MEM_LOAD_L3_HIT_RETIRED.XSNP_MISS:L3命中但跨核心Snoop未命中的load次数。这表明数据在L3中但不在任何其他核心的私有Cache中——通常是独占访问的数据。

  • MEM_LOAD_L3_HIT_RETIRED.XSNP_HIT:L3命中且跨核心Snoop命中(对方Cache中有干净副本)的load次数。这表明数据被多个核心共享读取。

  • MEM_LOAD_L3_HIT_RETIRED.XSNP_HITM:L3命中且跨核心Snoop命中Modified状态的load次数。这是假共享和真共享的关键指标——它表明请求的数据刚被另一个核心修改,需要一次Cache-to-Cache传输。高频率的XSNP_HITM通常指向假共享或热点锁竞争。

  • OFFCORE_RESPONSE.DEMAND_DATA_RD.L3_MISS.SNOOP_HITM:Demand读请求在L3缺失后,通过远端Snoop在其他核心的Cache中找到Modified副本。这比上述L3命中的XSNP_HITM延迟更高(跨Socket的一致性事务)。

AMD平台的一致性性能计数器

AMD Zen系列处理器通过IBS(Instruction-Based Sampling)提供了精细的一致性事件采样:

  • ls_l3_miss_latency:L3缺失的平均延迟,区分本地DRAM和远端DRAM/Cache的来源。

  • ls_any_fills_from_sys:来自同一CCD内其他核心、其他CCD、或远程Socket的Cache填充次数——这些填充中的大部分是一致性事务的结果。

  • ls_misal_loads / stores:跨Cache行边界的load/store次数。虽然不直接是一致性事件,但跨行访问在一致性协议中需要原子地处理两个Cache行,可能产生额外的一致性流量。

Linux perf c2c工具

Linux内核提供的perf c2c(Cache-to-Cache)工具是检测假共享的最强大工具之一。perf c2c的工作原理是:

(1)使用PEBS(Precise Event-Based Sampling,Intel)或IBS(AMD)对缓存相关的硬件事件进行精确采样,记录每次采样的数据地址、指令地址和事件类型。

(2)将采样数据按Cache行地址聚合,统计每个Cache行上来自不同核心/线程的HITM事件频率。

(3)对于HITM频率最高的Cache行,进一步分析其对应的数据结构和代码位置——这些就是假共享(或真共享热点)的位置。

perf c2c的输出包含"Shared Data Cache Line Table",列出HITM最严重的Cache行及其详细信息(物理地址、访问的线程/核心、对应的代码行等),使开发者能够精确定位并修复假共享问题。

设计权衡 3 — 形式化验证 vs 仿真验证的权衡

形式化验证(如Murphi模型检查)的优势在于可以穷举所有可达状态,保证不存在违反不变量的情况。但其局限在于:(1)状态空间爆炸限制了可验证的配置规模;(2)模型与实际RTL之间的一致性无法自动保证——如果模型正确但RTL实现有误,形式化验证无法发现。

仿真验证(如使用SystemVerilog/UVM的约束随机测试)的优势在于直接在RTL上运行,可以发现实现级的bug。但其局限在于覆盖率有限——即使运行数十亿个周期的随机测试,也可能遗漏极低概率的竞争条件。

现代处理器设计通常将两者结合使用:先用Murphi对协议的抽象模型进行形式化验证,确保协议逻辑正确;然后在RTL实现上进行大规模的约束随机仿真,确保实现忠实地反映了协议模型。一些团队还使用形式化等价性检查(Formal Equivalence Checking)来自动验证RTL与协议模型之间的一致性。

本章小结

本章系统地阐述了Cache一致性协议的核心概念和主流实现方案。表表 9.21总结了本章讨论的主要协议的关键特征。

本章的核心要点可以归纳如下:

  1. 一致性的本质:SWMR不变量和数据值不变量是所有一致性协议的正确性基础。理解这两个不变量是分析和设计任何一致性协议的起点。

  2. 监听 vs 目录:监听协议利用广播和全局序来实现简单高效的一致性,适用于小规模系统(\leq8核);目录协议利用点对点消息和目录结构来实现可扩展的一致性,适用于大规模系统(\geq16核)。

  3. 状态的演进:从MSI到MESI(E状态优化私有数据写入)、到MOESI(O状态优化共享读取的写回)、到MESIF(F状态优化共享数据的转发)——每个新状态的引入都是对特定性能瓶颈的有针对性的解决方案。

  4. 工业协议:ARM CHI和RISC-V TileLink代表了现代一致性协议的两种设计哲学——CHI追求完备性和高性能(丰富的消息类型、多种优化路径),TileLink追求简洁性和可验证性(最小化的通道和消息类型)。

  5. 验证的重要性:一致性协议的验证(形式化验证+仿真验证)是确保处理器正确性的关键环节,通常占整个一致性子系统设计团队工作量的一半以上。

  6. 2030年代的趋势:CXL协议和UCIe标准正在将一致性域从单芯片扩展到跨芯片、跨封装乃至跨机架的范围。chiplet架构要求一致性协议在异构互连(片上Mesh + 封装内UCIe + 板间CXL)之间无缝协作。同时,加速器(GPU、NPU)的一致性需求也在推动协议向更灵活的方向演进——例如放松部分一致性约束以换取更高的带宽效率。

面向2030年代的处理器架构师需要深刻理解一致性协议的基本原理,因为无论互连技术和封装方式如何演进,SWMR不变量和数据值不变量作为一致性的正确性基础不会改变。变化的是协议的具体实现方式——从简单的总线监听到复杂的多级目录、从片内Ring Bus到跨芯片CXL链路——但不变的是对"共享数据在多个缓存副本之间保持一致"这一根本需求的满足。

表 9.22总结了不同互连架构下一致性事务的典型延迟,为处理器设计者选择一致性方案提供了量化参考。

从上表可以观察到一个清晰的趋势:一致性延迟随互连距离的增加而显著增长。片内的核心间延迟约为30\sim70个周期(约10\sim20ns),跨chiplet的延迟约为70\sim120个周期(约20\sim40ns),跨Socket的延迟约为100\sim250个周期(约30\sim80ns),而CXL设备间的延迟高达300\sim600个周期(约100\sim200ns)。这种多层次的延迟差异对软件的数据布局和线程调度策略有深远的影响——将频繁通信的线程调度到同一chiplet或同一NUMA节点上,可以将一致性延迟降低3\sim10倍。

设计提示

一致性协议的设计是多核处理器中复杂度最高的子系统之一,但其复杂度在很大程度上是可以被分层管理的。一个清晰的分层策略是:(1)在Cache控制器内部,使用严格的状态机来处理协议逻辑,所有瞬态状态都有明确的转换规则;(2)在互连层,使用通道分离和Credit-Based流控来结构性地避免死锁;(3)在系统层,使用层次化的目录结构来管理不同域之间的一致性。这种分层设计使得每一层的复杂度可控,即使总系统规模很大也能保证可验证性。

设计提示

前向桥接。一致性协议解决了"同一地址在多个Cache中的数据一致性"问题。但多核编程的正确性还依赖另一个维度:当一个核心对不同地址执行多次写操作时,其他核心看到这些写操作的顺序是否与写入顺序一致?这就是内存一致性模型(Memory Consistency Model)的范畴——它规定了不同地址的访存操作在不同核心看来的顺序约束。第 10.0 章将讨论从最强的Sequential Consistency到x86的TSO再到ARM/RISC-V的弱一致性模型,以及这些模型对处理器微架构(特别是Store Buffer和Load队列的设计)的深远影响。

正文与图片:CC BY-NC-SA 4.0 · 本仓库少量站点配置代码:MIT