Skip to content

多端口Cache与超标量取指

Intel Golden Cove核心每周期可以发起3个Load和2个Store操作,合计5个并发访存请求。要用传统的真正5端口SRAM来实现L1 D-Cache,面积将膨胀到单端口方案的4倍以上,频率降低30%——这在工程上完全不可接受。解决方案是Multi-banking:将SRAM阵列切分为8\sim16个独立的Bank,让不同的请求访问不同的Bank,以标准的单端口SRAM实现多端口的效果。这是一种巧妙的"空间并行"——通过空间上的隔离来避免时间上的冲突。

设计提示

统一视角。Multi-banking是Cache设计中并行的直接体现。正如第 2.0 章讨论的超标量宽度是指令级并行的挖掘,多Bank Cache是访存级并行的挖掘。而Bank冲突(两个请求碰巧访问同一Bank)则是并行的代价——类似于超标量处理器中的资源冲突。回顾第 5.0 章中Cache的基本组织方式,本章将展示如何将Data SRAM从单一的整体结构拆分为多个独立Bank,并解决由此带来的仲裁和冲突处理问题。

超标量处理器的性能在很大程度上取决于能否每周期向流水线供给足够多的指令和数据。一个6-wide的超标量处理器在峰值状态下每周期需要从I-Cache中取出6条指令(甚至更多,以应对分支预测和流水线气泡),同时数据Cache需要支持2\sim3个并发的Load/Store操作。然而,传统的单端口SRAM每周期只能服务一次读或写请求——这远远无法满足超标量处理器的带宽需求。

如何让Cache在一个时钟周期内同时服务多个访存请求,是超标量处理器设计中的一个核心挑战。本章将系统地讨论多端口Cache的三种主要实现方案——真正多端口SRAM、Cache复制和多bank设计,分析它们各自的面积、功耗、复杂度权衡,并以AMD Opteron为例展示实际工业设计中的选择。在此基础上,本章进一步讨论超标量处理器取指阶段的关键问题:取指带宽需求、跨Cache行取指、取指缓冲区以及取指与分支预测的配合。最后,本章介绍非阻塞Cache(Non-blocking Cache)的MSHR机制和关键字优先/提前开始等减少缺失代价的技术。

多端口Cache的设计

在一个典型的超标量处理器中,执行单元每周期可以发起多个访存操作。例如,Intel Golden Cove核心每周期最多可以执行3个Load和2个Store操作,AMD Zen 4每周期可以执行3个Load和2个Store操作,Apple Everest核心每周期可以执行4个Load和2个Store操作。这意味着L1 D-Cache每周期需要同时服务5\sim6个访存请求。

这个需求产生了一个根本性的矛盾:SRAM的读写端口数越多,单元面积和功耗就越大,频率也越低;但超标量处理器需要的并发访存端口数又在不断增加。如何在这一矛盾中找到合理的折中点?工程师们发展出了三种截然不同的策略:直接在SRAM层面增加端口(真正多端口)、在Cache层面复制整个存储体(Cache复制)、以及将存储体切分为独立的bank让不同请求访问不同bank(多bank设计)。每种方案都有其独特的优势和代价,理解这些方案及其权衡,是设计高性能存储子系统的基础。

True Multi-port

真正多端口(True Multi-port)SRAM是最直接的方案:在SRAM单元级别就提供多个独立的读写端口,使得多个访问可以在同一周期内完全并行地进行,无需任何仲裁或冲突处理。

SRAM单元的结构

一个标准的单端口SRAM单元(6T-SRAM)由6个晶体管组成:4个晶体管构成一个交叉耦合的锁存器用于存储1位数据,2个晶体管作为访问管连接到一对位线(Bit Line, BL和BL\overline{\text{BL}})。读和写操作都通过这对位线进行。

要增加一个读写端口,需要为每个SRAM单元额外增加2个访问管和1对位线。因此,一个PP端口的SRAM单元需要4+2P4 + 2P个晶体管和PP对位线。表表 8.1列出了不同端口数量的开销。

端口数晶体管数位线对数面积倍数功耗倍数速度影响
1(6T)611×1\times1×1\times基准
2(8T)822×\sim 2\times1.8×\sim 1.8\times约10%降速
3(10T)1033.2×\sim 3.2\times2.8×\sim 2.8\times约20%降速
4(12T)1244.5×\sim 4.5\times3.8×\sim 3.8\times约30%降速

多端口SRAM单元的硬件开销

面积和布线的爆炸增长

多端口SRAM的面积开销远不止是晶体管数量的线性增长。更严重的问题在于布线。每增加一个端口,就需要增加一对位线(垂直方向)和一条字线(水平方向),这些金属线需要在SRAM阵列中穿过每一个单元。在先进工艺(5 nm及以下)中,金属层的间距已经接近物理极限,额外的布线层带来的面积开销是灾难性的。

为什么布线面积的增长比晶体管面积的增长更严重?在先进工艺中,晶体管(尤其是FinFET或GAA FET)的尺寸缩减很快,但金属互联线的电阻随宽度缩小而急剧增加(因为电子的表面散射效应和晶界散射效应在纳米尺度下变得显著),这迫使设计者使用更宽的金属线或更多的金属层来维持信号完整性。对于多端口SRAM,每增加一个端口就需要额外的位线对穿过整个阵列,这些位线的间距、屏蔽和驱动电路都需要面积,导致实际面积增长远超晶体管数量的线性比例。

图 8.1定性地展示了不同端口数SRAM单元的面积对比。

不同端口数SRAM单元的面积对比(定性示意)
不同端口数SRAM单元的面积对比(定性示意)

功耗开销

多端口SRAM的功耗开销同样巨大。每个端口在被激活时都需要对位线进行预充电和放电,多个端口同时工作意味着更多的位线切换活动,动态功耗近似线性增加。更严重的是,更多的位线和更大的单元面积增加了寄生电容,进一步推高了动态和静态功耗。

在5 nm工艺下,一个32 KB的2端口L1 D-Cache SRAM阵列的功耗约为单端口版本的1.8×1.8\times,面积约为2×2\times。如果将端口数增加到4个(以支持4个并发Load操作),功耗将达到3.5×4×3.5\times\sim 4\times,面积达到4×5×4\times\sim 5\times——这在热密度和芯片面积预算上都是不可接受的。

功耗分析可以更精细地展开。SRAM的功耗由三个分量组成:

(a)位线预充电功耗。每次读操作前,位线对需要被预充电到VDDV_{DD}。预充电功耗Ppre=fCBLVDD2P_{\text{pre}} = f \cdot C_{\text{BL}} \cdot V_{DD}^2,其中CBLC_{\text{BL}}为位线电容。对于PP端口的SRAM,有PP对位线需要预充电,因此预充电功耗线性增加。

(b)位线放电功耗。读操作时,存储单元通过访问管对一条位线放电,产生差分信号。放电功耗取决于位线电容和放电幅度,与端口数无关(每次读操作只在一对位线上产生放电)。但多端口同时读取时,总放电功耗随活跃端口数线性增加。

(c)字线功耗。每个端口有独立的字线,激活字线需要对其寄生电容充放电。PP端口同时访问时,字线功耗为单端口的PP倍。

综合这三个分量,多端口SRAM的动态功耗近似为:PdynamicP(Ppre+Pdischarge+PWL)P_{\text{dynamic}} \approx P \cdot (P_{\text{pre}} + P_{\text{discharge}} + P_{\text{WL}})。在5 nm工艺下,一个32 KB单端口SRAM的读取功耗约为15 mW(在3 GHz频率下),4端口版本的功耗增加到约55 mW——仅SRAM阵列的功耗就接近整个处理器核心功耗预算的5%。

速度(频率)的影响

多端口SRAM不仅增加面积和功耗,还会降低访问速度。速度下降的主要原因有:

(1)位线负载增加。更多的访问晶体管连接在位线上,增加了位线的寄生电容CBLC_{\text{BL}}。对于NN行的SRAM阵列,每增加一个端口,位线电容增加约NCaccessN \cdot C_{\text{access}}CaccessC_{\text{access}}为一个访问管的漏极电容)。更大的位线电容使得位线上的差分信号建立更慢,增加了灵敏放大器的等待时间。

(2)字线负载增加。更多的位线穿过SRAM阵列,增加了字线与位线之间的耦合电容。字线驱动器需要更大的驱动能力来维持字线的上升时间,而更大的驱动器又增加了输入电容,延长了地址解码器的延迟。

(3)布局密度降低。更多的访问管和位线使得SRAM单元的物理布局更加稀疏,导致信号传播距离增加,线延迟增大。

在实测中,2端口SRAM相比单端口SRAM的访问延迟增加约10%\sim15%,4端口SRAM的延迟增加约25%\sim35%。对于L1 D-Cache来说,访问延迟是其最关键的性能参数之一(通常需要在3\sim4个时钟周期内完成),任何延迟增加都可能迫使设计者降低频率或增加流水线级数——这两者都会降低处理器性能。

读端口与读写端口的区别

在讨论多端口SRAM时,有一个重要的区分:只读端口读写端口的硬件开销是不同的。一个只读端口只需要1个访问晶体管和1条位线(单端读取),而一个读写端口需要2个访问晶体管和1对差分位线。因此,如果多端口需求中大部分是读操作,可以用“多读端口+少量写端口”的不对称设计来降低开销。

例如,8T SRAM单元是一种常见的不对称设计:在标准6T单元的基础上增加2个晶体管构成一个独立的只读端口。写操作仍然通过原来的位线对进行,读操作可以通过新增的只读端口独立进行,且不会干扰存储单元的状态。8T单元的面积约为6T单元的1.3×1.5×1.3\times\sim1.5\times(远小于对称2端口的2×2\times),同时提供了1读1写的并发能力。这种设计在L1 D-Cache的Tag SRAM中特别有用——Tag需要频繁读取(每次Load/Store都需要Tag比较),但写入较少(仅在Cache行替换或失效时)。

实际应用

由于上述严峻的面积和功耗开销,真正多端口SRAM在现代高性能处理器中极少用于L1 D-Cache的Data SRAM。然而,真正多端口SRAM在某些较小的存储结构中仍然被广泛使用:

  • 寄存器文件。超标量处理器的物理寄存器文件需要多个读端口和多个写端口(例如6读3写),其容量通常只有几KB(如192项×\times64位\approx1.5 KB),使用多端口SRAM是可行的。即便如此,高端口数的寄存器文件仍然是处理器中面积密度最高的结构之一——在5 nm工艺下,一个192项×\times64位、10读6写的物理寄存器文件的面积约占整个处理器核心面积的3%\sim5%。

  • TLB。L1 TLB的项数通常只有32\sim128项,且需要多端口以支持并发的地址翻译,通常使用多端口CAM实现。L1 DTLB通常需要与L1 D-Cache的Load端口数相匹配——如果L1D支持3个Load/周期,则L1 DTLB也需要3个读端口。

  • 小型队列。Store Queue、Load Queue等通常容量较小(32\sim96项),且需要多端口支持并发的分配、搜索和释放操作。Store Queue的CAM搜索端口数等于每周期Load操作数(用于Store-to-Load Forwarding),是面积和功耗的重要来源。

设计提示

真正多端口SRAM适用于容量小(几KB以内)但端口数量要求高的存储结构。对于容量较大的Cache(32 KB及以上),真正多端口SRAM的面积和功耗开销过于巨大,应当考虑Cache复制或多bank设计。在寄存器文件设计中,如果读端口数量过多(如超过10个),也可以考虑使用banked寄存器文件或operand collector等替代方案。一个实用的经验法则是:当“容量(位)×\times端口数”的乘积超过某个阈值时,就应该考虑替代方案。

Multiple Cache Copies

既然直接在SRAM层面增加端口代价太高,能否换一个思路?如果我们需要两个读端口,不妨把整个Cache复制一份——这样每个副本都是一个标准的单端口Cache,两个读操作分别访问不同的副本,天然没有任何冲突。这就是Cache复制(Multiple Cache Copies)方案的基本思想。

然而,这个看似简单的思路立刻带来一个核心问题:两个副本的数据必须始终保持一致。当一个Store操作修改了副本A中的某个Cache行,如何保证副本B中的对应行也被更新?这个写同步问题是Cache复制方案的核心难题。

基本结构

图 8.2展示了一个2端口Cache复制设计的基本结构。

Cache复制方案的基本结构
Cache复制方案的基本结构

写同步问题

Cache复制方案的核心难题是写同步(Write Synchronization)。当一个Store操作通过Port 0写入Copy 0时,必须将相同的修改同步到Copy 1。如果不进行同步,后续通过Port 1的Load操作可能从Copy 1中读取到过时的数据。

写同步有两种主要策略:

(1)同步写入(Synchronous Write)。当一个写操作发生时,同时写入所有副本。这要求所有副本的写端口在同一周期内都可用。如果多个端口在同一周期内都有写操作,就会出现多个副本需要同时处理多次写入的情况——这实际上要求每个副本至少有一个额外的写端口用于同步,部分抵消了Cache复制的优势。

同步写入的一个具体问题是写端口争用。假设有两个副本,Port 0和Port 1都分配了一个副本用于读取。如果在同一个周期内,Port 0发起了一个Store操作,那么这个Store需要同时写入Copy 0和Copy 1。但如果同一周期Port 1也在从Copy 1读取,就产生了读写端口争用——Copy 1需要在同一周期内既服务一个读操作又服务一个来自Port 0的写同步操作。解决这一争用的方法是将每个副本设计为1读1写的双端口SRAM(使用前述的8T单元),或者在时钟的不同半周期分别进行读和写操作(时分复用,类似于某些寄存器文件的设计)。

(2)延迟同步(Deferred Write)。写操作先写入一个副本,然后在后续的空闲周期将修改同步到其他副本。这种方式下,每个副本仍然只需要一个端口(读写共享),但引入了一个一致性窗口——在写入和同步完成之间的短暂时间内,各副本的数据不一致。为了保证正确性,需要一个比较器在读取时检查其他副本的写缓冲区,或者使用转发逻辑将未同步的写数据直接转发给需要的读操作。

延迟同步的实现通常使用一个写同步队列(Write Sync Queue)。Store操作的地址和数据先写入队列,然后在后续的空闲周期依次同步到各副本。问题是:如果在同步完成之前,另一个端口的Load操作读取了尚未同步的行,就会读到旧数据。为避免这种情况,Load操作需要同时查询写同步队列(类似于Store Buffer的转发机制),如果队列中有匹配的待同步写数据,则直接转发给Load。这增加了关键路径上的延迟——Load操作需要在Tag比较的同时检查写同步队列,增加了约1\sim2个gate的延迟。

面积和功耗

Cache复制方案的面积开销与端口数量成正比——PP个端口需要PP个完整的Cache副本,总面积约为单端口Cache的PP倍。这与真正多端口SRAM在面积上相当(对于2端口的情况),但优势在于每个副本仍然是标准的单端口SRAM,可以使用高密度的6T SRAM单元,且设计验证更简单。

在功耗方面,每次读操作只需要激活一个副本,因此读功耗与单端口Cache相同。但每次写操作需要更新所有副本,写功耗与副本数成正比。对于读多写少的工作负载(这是L1 D-Cache的典型情况——Load指令的频率约为Store指令的2\sim3倍),Cache复制方案的平均功耗低于真正多端口SRAM。

实际应用

Alpha 21264处理器(1998年)的L1 D-Cache就采用了Cache复制方案。它的L1 D-Cache为64 KB/2-way,通过维护两个完整的副本来提供2个读端口。每个副本都是一个标准的单端口Cache,Load操作分发到两个副本中的一个,Store操作则同时写入两个副本。这一设计在当时的工艺条件下是一个合理的折中——真正2端口的64 KB SRAM在0.35 μm工艺下面积和功耗都不可接受,而多bank方案的bank冲突率在当时的设计中较难处理。

Alpha 21264的设计团队做出这一选择有一个有趣的背景:在0.35 μm工艺下,64 KB的6T SRAM面积约为14mm214\text{mm}^2,两个副本共28mm228\text{mm}^2,约占芯片总面积(302mm2302\text{mm}^2)的9%。虽然这个比例不小,但考虑到6T SRAM的密度远高于8T或10T单元,Cache复制方案的实际面积仍然是三种方案中最经济的。

Cache复制与Cache一致性

在多核处理器中,Cache复制方案还需要与缓存一致性协议交互。当其他核心通过一致性协议发起探听(Snoop)请求时,需要同时检查和更新所有副本。这增加了一致性协议的实现复杂度。相比之下,Multi-banking方案只有一份数据,探听请求只需要检查和操作一次,一致性处理更加简单。

具体而言,一个Snoop请求(如来自其他核心的读无效操作)需要:(a)在所有副本的Tag中搜索匹配行;(b)如果命中,修改所有副本中该行的状态(例如从Modified变为Invalid);(c)如果被Snoop的行正在被本地读取或写入,还需要处理冲突。这些操作在Cache复制方案下需要对所有副本串行或并行执行,增加了Snoop处理的延迟和端口争用。

读写不对称的优化

在实际工作负载中,Load指令的频率通常是Store指令的2\sim3倍。一种优化是使用不对称的Cache复制——提供多个只读副本和一个读写副本。只读副本不需要写端口,可以使用更小的单端口SRAM(只有读端口)。写操作只在读写副本上进行,然后将修改广播到所有只读副本。这种不对称设计在面积上优于完全复制,同时为Load操作提供了足够的带宽。

这种思路的一个推广是“分离式Data SRAM”——将L1 D-Cache的Data SRAM分为一个读写副本和多个只读副本,Tag SRAM则统一复制(因为Tag SRAM很小,复制开销可忽略)。只读Data副本可以使用更紧凑的SRAM布局(省略写电路),面积只有读写副本的约70%\sim80%。

Multi-banking

真正多端口SRAM的代价太高,Cache复制方案虽然每个副本可以使用标准SRAM,但总面积仍然随端口数线性增加。有没有一种方案既不增加SRAM单元的复杂度,又不需要完整复制Cache,就能支持多个并发访问?

关键观察是:多个并发访问请求不一定需要访问同一个地址。如果我们把Cache分成多个独立的"bank",每个bank存储不同地址范围的数据,那么只要多个请求访问的数据恰好在不同的bank中,它们就可以完全并行——每个bank仍然只需要是标准的单端口SRAM。

这就是Multi-banking(多bank设计)的核心思想,也是现代高性能处理器L1 D-Cache最常用的多端口实现方案。当然,这个方案有一个显而易见的代价:如果两个请求恰好需要访问同一个bank(bank冲突),其中一个就必须等待。方案的成功取决于bank冲突的频率是否足够低——这需要精心选择bank的数量和映射方式。

基本原理

在Multi-banking设计中,Cache行的地址用于决定数据存储在哪个bank中。最简单的bank映射方式是使用地址的低位(Cache行地址的低log2Bcount\log_2 B_{\text{count}}位,其中BcountB_{\text{count}}为bank数量)来选择bank。例如,在8个bank的设计中,Cache行地址的低3位决定该行属于哪个bank。

图 8.3展示了一个4-bank L1 D-Cache的结构。

4-bank L1 D-Cache的结构
4-bank L1 D-Cache的结构

bank数量的选择

bank的数量决定了Cache能够同时服务的最大访存请求数。在理想情况下(所有并发访问落在不同bank),BcountB_{\text{count}}个bank可以支持BcountB_{\text{count}}个并发访问。然而,由于bank冲突(Bank Conflict)的存在,实际的并发能力通常低于bank数量。

那么,应该配置多少个bank?bank数量越多,冲突越少,但Crossbar越大、路由延迟越高、面积和功耗开销也越大。一个自然的起点是让bank数量等于并发端口数,但这在随机访问模式下仍然有相当高的冲突率(例如5个端口、5个bank时冲突概率高达78.6%)。实际设计中,bank数量通常是并发端口数的2\sim4倍。

现代高性能处理器的L1 D-Cache通常配置8\sim16个bank:

  • Intel Golden Cove:L1D 48 KB,12-way,16个bank,每bank 3 KB,支持每周期3 Load + 2 Store。

  • AMD Zen 4:L1D 32 KB,8-way,8个bank,每bank 4 KB,支持每周期3 Load + 2 Store。

  • Apple Everest(M4 P-core):L1D 64 KB,8-way,16个bank,每bank 4 KB,支持每周期4 Load + 2 Store。

bank映射方式

bank映射需要在不同访问模式下尽可能减少bank冲突。常见的映射方式有:

(1)简单低位映射。使用Cache行地址的最低log2Bcount\log_2 B_{\text{count}}位作为bank编号。这种方式在顺序访问模式下效果最好——连续的Cache行分布在不同的bank中。但对于以特定步长(stride)访问数组的模式,如果步长是bank数量的倍数,则所有访问都会命中同一个bank,产生严重的bank冲突。

这种“步长冲突”在科学计算中尤为常见。例如,矩阵乘法中按列访问一个行主序矩阵时,如果矩阵的行宽恰好是bank数量的倍数(如一个1024×10241024\times1024的double矩阵,行宽为8 KB,在8-bank设计中步长恰好是8行),则按列访问时每次Load都会命中同一个bank。

(2)XOR映射。将地址的不同位域进行异或来计算bank编号:bank=addr[b+log2B:b]addr[b+2log2B:b+log2B]\text{bank} = \text{addr}[b{+}\log_2 B : b] \oplus \text{addr}[b{+}2\log_2 B : b{+}\log_2 B],其中bb为Offset位数。XOR映射可以将具有简单步长关系的地址分散到不同的bank中,有效降低常见访问模式下的bank冲突率。

XOR映射的有效性可以直觉地理解:对于简单低位映射,两个地址AAA+kBcountlinesizeA + k \cdot B_{\text{count}} \cdot \text{linesize}总是映射到同一个bank(因为它们的低位相同)。但在XOR映射中,这两个地址的bank编号取决于它们高位的差异——只要高位不同,XOR结果就可能不同,从而分散到不同的bank中。

(3)素数取模映射。使用素数作为bank数量(如17个bank用于16端口),通过取模运算来确定bank编号。这种方式可以进一步分散各种步长的访问,但取模运算的硬件开销较大,且bank数量为非2次幂时造成容量浪费。

Tag SRAM的多端口设计

在Multi-banking方案中,Data SRAM被分bank,但Tag SRAM的多端口需求需要单独解决。由于多个并发请求可能访问I-Cache/D-Cache的同一组(但不同路),它们都需要读取同一组的Tag来进行命中判断。Tag SRAM的多端口设计通常采用以下方式之一:

(1)Tag复制。如前述AMD Opteron的做法,将Tag SRAM复制为多份,每个访存端口使用一份独立的Tag副本。Tag SRAM的字宽很窄(每路只有30\sim40位的Tag值加上Valid和Dirty等状态位),复制的面积开销很小。以32 KB/8-way、48位地址的L1D为例,每组的Tag数据为8×(36+2)=3048 \times (36 + 2) = 304位,64组的Tag SRAM总容量为64×304=1945664 \times 304 = 19456\approx2.4 KB。即使复制3份(支持3个Load端口),Tag SRAM的总面积也只有\sim7.2 KB,不到Cache数据阵列的四分之一。

(2)多端口Tag SRAM。对于只有2\sim3个读端口的需求,直接使用多端口SRAM作为Tag SRAM是可行的。因为Tag SRAM的容量很小,多端口SRAM的面积和功耗开销在绝对数值上可以接受。

(3)Tag SRAM分bank。一种较少见但有效的方式是将Tag SRAM也按组号分bank——由于不同的访存请求通常访问不同的组,Tag SRAM的bank冲突率通常低于Data SRAM。这种方式在Tag SRAM特别大(如L2 Cache的Tag)时有意义。

面积和功耗优势

Multi-banking的最大优势在于面积和功耗。每个bank仍然使用标准的6T单端口SRAM,具有最高的存储密度。与真正多端口SRAM相比,Multi-banking方案的SRAM面积约为1×1\times(不增加单元面积),仅需要增加bank仲裁器和crossbar的逻辑面积(通常只占总Cache面积的5%\sim10%)。在功耗方面,每次访问只激活一个bank的SRAM阵列,未被访问的bank可以进入低功耗模式,因此Multi-banking方案的功耗通常低于Cache复制和真正多端口SRAM。

更进一步,Multi-banking天然支持bank级功耗门控(Bank-level Power Gating)。在一个16-bank的设计中,如果某个周期只有2个Load和1个Store操作,只需要激活3个bank,其余13个bank可以保持休眠。这使得Cache的动态功耗与实际的并发访问数成正比,而非与bank总数成正比——在低负载时尤其节能。相比之下,Cache复制方案中每个副本要么完全激活(被读取时),要么完全关闭,无法实现这种细粒度的功耗控制。

Crossbar的设计

在Multi-banking方案中,Crossbar(交叉开关)负责将NN个访存端口的请求路由到BB个bank,并将bank返回的数据路由回对应的端口。一个N×BN \times B的完全Crossbar由N×BN \times B个交叉点组成,每个交叉点是一个受控的传输门。

对于典型的配置(N=5N = 5个端口,B=16B = 16个bank),Crossbar有5×16=805 \times 16 = 80个交叉点。数据宽度通常为8 B\sim16 B(一次Load操作最多读取一个双字或向量宽度),因此Crossbar的总线宽度为6412864 \sim 128位。整个Crossbar的面积和延迟在先进工艺下都是可控的——其延迟通常在1个gate延迟以内,不会成为关键路径。

实际设计中,通常不需要完全Crossbar。由于bank冲突检测已经确保同一周期内不会有两个请求访问同一bank,Crossbar的路由冲突不会发生。因此可以使用简化的Crossbar——例如,只需要在每个bank的输入端放置一个NN:1的MUX来选择哪个端口的请求被服务,以及在每个端口的输出端放置一个BB:1的MUX来选择哪个bank的数据被返回。

Crossbar的实际实现中还需要考虑数据对齐。不同的Load操作可能请求不同大小的数据(1/2/4/8字节)且可能不在自然对齐的边界上。每个bank输出的是整个bank字宽的数据(通常8 B),需要一个对齐旋转器(Alignment Rotator)从中提取出Load操作实际请求的字节。这个旋转器通常集成在Crossbar的输出端,其延迟约为1\sim2个gate,是Load流水线关键路径的一部分。

L1 D-Cache的访问流水线

理解多端口Cache的设计,必须将其放在L1 D-Cache的完整访问流水线中考察。一个典型的3-cycle L1D访问流水线由以下阶段组成:

第1周期:地址计算与Bank选择

  • AGU(Address Generation Unit)计算虚拟地址:VA=Base Register+Offset\text{VA} = \text{Base Register} + \text{Offset}

  • 从VA中提取bank编号(使用VA的特定位域)。

  • 进行bank冲突检测(比较所有并发请求的bank编号)。

  • 将请求通过Crossbar路由到对应的bank。

  • 使用VA的Index位开始SRAM字线解码。

第2周期:SRAM读取与TLB翻译

  • Data SRAM:被选中bank的字线激活,位线上出现差分信号,灵敏放大器放大并锁存数据。

  • Tag SRAM:并行读出该组所有路的Tag值。

  • DTLB:使用VA的页号部分查找TLB,获得物理地址(PA)。这一步与SRAM读取并行进行(VIPT设计的关键优势)。

第3周期:Tag比较与数据选择

  • 将TLB输出的物理页号与各路Tag进行比较,确定命中/缺失以及命中路号。

  • 使用命中路号选择正确的数据路输出。

  • 通过对齐旋转器从选中的数据中提取出Load请求的字节。

  • 如果缺失,启动MSHR分配流程。

在这三个周期中,多端口设计影响第1周期(bank冲突检测和Crossbar路由)和第2周期(多个bank并行读取)。bank冲突检测的延迟直接增加在第1周期的关键路径上——如果检测逻辑的延迟过大,可能需要将第1周期拆分为两个周期,使L1D延迟从3-cycle增加到4-cycle。这就是为什么bank冲突检测需要使用简单且快速的比较器结构(如只比较3\sim4位的bank编号,而非完整的30\sim40位地址)。

硬件描述 1 — L1D流水线时序的关键路径分析

在3-cycle L1D设计中,最紧张的关键路径通常在第3周期:TLB查找(2-cycle TLB的第2周期输出)\to Tag比较(多路比较器)\to 路选择MUX \to 数据对齐。这条路径的总延迟约为:

TLB输出建立时间\sim2个FO4
Tag比较器(4-8路)\sim4个FO4
Way-select MUX\sim2个FO4
数据对齐旋转器\sim3个FO4
总计\sim11个FO4

在5 nm工艺下,1个FO4延迟约为3 ps,11个FO4 \approx 33 ps。对于3.5 GHz的处理器(周期\approx 286 ps),这只占一个周期的11.5%,留下了足够的余量用于锁存器建立时间、时钟偏斜和工艺/电压/温度(PVT)变化。但对于5 GHz+的处理器(周期\approx 200 ps),时序余量变得很小,可能需要使用更快的比较器电路或将Tag比较和Way选择进一步流水线化。

多端口设计增加的额外延迟主要在第1周期(bank冲突检测,约2\sim3个FO4)和第2周期(Crossbar路由,约1\sim2个FO4)。这些额外延迟在3.5 GHz设计中是完全可以容忍的。

设计权衡 1 — 三种多端口Cache方案的对比

表 8.2从面积、功耗、设计复杂度和性能等维度对比了三种多端口Cache方案。

特性True Multi-portCache CopiesMulti-banking
SRAM面积P×\sim P\times(每端口)P×P\timesPP份副本)1×\sim 1\times(6T单元)
额外逻辑面积写同步逻辑仲裁器+Crossbar
读功耗高(多端口)低(单端口读)低(单bank激活)
写功耗高(写所有副本)低(写单bank)
设计复杂度极高(定制SRAM)中(写同步)中(bank仲裁)
性能影响无冲突无冲突有bank冲突
适用范围小型结构中等CacheL1D(主流方案)

Bank冲突的处理

当两个或多个并发访问请求落在同一个bank时,就会发生bank冲突(Bank Conflict)。由于每个bank每周期只能服务一个请求,bank冲突将导致部分请求被延迟,从而降低Cache的有效带宽。

Bank冲突是Multi-banking方案的核心代价——如果bank冲突率太高,Cache的有效带宽将严重退化,Multi-banking的优势将被抵消。因此,理解bank冲突的产生模式、准确地分析冲突率、并设计有效的缓解策略,是Multi-banking设计的关键。

bank冲突的检测

bank冲突检测逻辑需要比较所有并发请求的bank编号,找出需要访问同一bank的请求。对于NN个并发请求和BB个bank,检测逻辑需要(N2)=N(N1)/2\binom{N}{2} = N(N-1)/2个比较器。例如,对于5个并发请求(3 Load + 2 Store),需要5×4/2=105 \times 4 / 2 = 10个比较器,每个比较器的宽度为log2B\log_2 B位(通常3\sim4位),硬件开销是可接受的。

检测逻辑的时序位于关键路径上——它必须在请求到达bank之前完成,以便在冲突发生时阻止第二个请求。在3-cycle的L1D流水线中,bank冲突检测通常在第1周期(地址计算完成后)并行完成,其结果在第2周期用于gate bank的字线使能信号。

bank冲突的仲裁

当检测到bank冲突后,需要从冲突的多个请求中选择一个请求进行服务,其余请求被延迟到下一周期重试。仲裁策略通常遵循以下优先级:

(1)Store优先于后续Load。Store指令在超标量处理器中通常位于Store Buffer中等待提交,延迟一个Store操作可能导致Store Buffer填满,进而阻塞指令提交(Commit),影响整个流水线的吞吐量。

(2)老指令优先。在ROB(Reorder Buffer)中更早的指令优先获得bank访问权。这有助于尽快释放流水线资源(如ROB项和物理寄存器),避免流水线停滞。

(3)非推测性操作优先。已确定不会被取消的访存操作优先于推测性的操作。这避免了将bank带宽浪费在最终会被丢弃的推测操作上。

被延迟的请求通常被放入一个replay队列(Replay Queue)中,在下一周期重新尝试。如果连续多个周期都遇到bank冲突,该请求可能会被多次延迟,造成严重的性能损失。在极端情况下(如多个访问以bank数量为步长遍历数组),bank冲突率可以接近100%,此时Cache的有效带宽退化到单端口水平。

下面的SystemVerilog代码实现了一个简化的4请求4Bank仲裁器,体现了上述的优先级规则——Store优先于Load,同类型操作按年龄(ROB顺序)排序。

verilog
module bank_arbiter #(
    parameter N_REQ  = 4,   // 并发请求数
    parameter N_BANK = 4,   // Bank数
    parameter AGE_W  = 8    // ROB年龄标签位宽
)(
    input  logic [N_REQ-1:0]              req_valid,   // 请求有效
    input  logic [N_REQ-1:0]              req_is_store,// 1=Store, 0=Load
    input  logic [N_REQ-1:0][AGE_W-1:0]  req_age,     // ROB年龄(越小越老)
    input  logic [N_REQ-1:0][1:0]         req_bank,    // 目标Bank编号

    output logic [N_REQ-1:0]              grant,       // 本周期授权
    output logic [N_REQ-1:0]              conflict     // 被延迟(需replay)
);
    // 每个Bank选出一个获胜者
    logic [N_BANK-1:0][N_REQ-1:0] bank_req;   // 请求该Bank的掩码
    logic [N_BANK-1:0][N_REQ-1:0] bank_grant;  // 每Bank的授权

    // Step 1: 按Bank分组请求
    always_comb begin
        bank_req = '0;
        for (int r = 0; r < N_REQ; r++)
            if (req_valid[r])
                bank_req[req_bank[r]][r] = 1'b1;
    end

    // Step 2: 每个Bank独立仲裁(固定优先级)
    always_comb begin
        bank_grant = '0;
        for (int b = 0; b < N_BANK; b++) begin
            // 从请求该Bank的候选中选出优先级最高的
            logic found;
            logic [AGE_W-1:0] best_age;
            int best_r;
            found    = 1'b0;
            best_age = '1;  // 最大值=最年轻
            best_r   = 0;

            for (int r = 0; r < N_REQ; r++) begin
                if (bank_req[b][r]) begin
                    // 优先级: (1)Store > Load (2)同类型按年龄
                    logic cur_better;
                    if (!found) begin
                        cur_better = 1'b1;
                    end else if (req_is_store[r] && !req_is_store[best_r]) begin
                        cur_better = 1'b1;  // Store > Load
                    end else if (!req_is_store[r] && req_is_store[best_r]) begin
                        cur_better = 1'b0;  // Load < Store
                    end else begin
                        cur_better = (req_age[r] < best_age);  // 更老优先
                    end

                    if (cur_better) begin
                        best_r   = r;
                        best_age = req_age[r];
                        found    = 1'b1;
                    end
                end
            end
            if (found)
                bank_grant[b][best_r] = 1'b1;
        end
    end

    // Step 3: 汇总grant和conflict
    always_comb begin
        for (int r = 0; r < N_REQ; r++) begin
            grant[r]    = 1'b0;
            conflict[r] = 1'b0;
            for (int b = 0; b < N_BANK; b++)
                if (bank_grant[b][r]) grant[r] = 1'b1;
            // 有效请求但未被授权 = 冲突
            if (req_valid[r] && !grant[r])
                conflict[r] = 1'b1;
        end
    end
endmodule

在实际设计中,仲裁器的关键路径——从请求有效到授权输出——必须在第1周期内完成(约50\sim80 ps的时序预算)。4请求4Bank的仲裁逻辑约需6\sim8个FO4延迟(约18\sim24 ps),可以满足要求。但如果扩展到6请求16Bank,仲裁逻辑的扇入和比较器数量将显著增加,可能需要流水线化处理。

bank冲突率的分析

bank冲突率取决于bank数量、并发请求数量和访问模式。对于随机均匀分布的访问模式,NN个请求在BB个bank中全部不冲突的概率为:

Pno conflict=B!(BN)!BN=i=0N1BiB P_{\text{no conflict}} = \frac{B!}{(B-N)! \cdot B^N} = \prod_{i=0}^{N-1}\frac{B-i}{B}

表 8.3列出了不同配置下的理论bank冲突概率。

并发请求数 NN4 banks8 banks16 banks32 banks
225.0%12.5%6.3%3.1%
359.4%24.2%11.7%5.7%
484.4%38.7%17.8%8.5%
553.0%24.2%11.3%

随机访问模式下的bank冲突概率

从表表 8.3可以看出,要支持5个并发请求(3 Load + 2 Store),至少需要16个bank才能将冲突概率降低到25%以下。在实际的程序中,由于空间局部性,连续的Load操作往往访问相邻的Cache行,这些行通常位于不同的bank中(使用简单低位映射时),因此实际的bank冲突率往往低于理论值。

然而,某些特定的访问模式会导致远高于理论值的冲突率。考虑以下代码模式:

asm
# 以步长stride遍历数组,stride = 8 cache lines = 512B
  # 在8-bank设计中,所有访问都映射到同一bank
  ld    a0, 0(s0)        # 行地址 = 0, bank = 0
  ld    a1, 512(s0)      # 行地址 = 8, bank = 0 (8 mod 8 = 0)
  ld    a2, 1024(s0)     # 行地址 = 16, bank = 0 (16 mod 8 = 0)

这种步长冲突在科学计算、矩阵运算和某些数据结构遍历中很常见。XOR映射可以有效缓解这种情况。

XOR映射降低步长冲突的具体分析

为了更直观地理解XOR映射如何缓解步长冲突,考虑一个具体的例子。假设8-bank设计,bank编号由行地址的低3位决定(简单低位映射)或由低3位与接下来3位的XOR决定(XOR映射)。

考虑以步长8个Cache行(512 B)遍历数组的情况:

行地址低3位简单映射bankXOR映射bank
00000000000=0000 \oplus 000 = 0
80000000001=1000 \oplus 001 = 1
160000000010=2000 \oplus 010 = 2
240000000011=3000 \oplus 011 = 3
320000000100=4000 \oplus 100 = 4
400000000101=5000 \oplus 101 = 5
480000000110=6000 \oplus 110 = 6
560000000111=7000 \oplus 111 = 7

在简单低位映射下,所有8个地址都映射到bank 0——100%的bank冲突率。在XOR映射下,8个地址均匀分布到8个bank中——0%的冲突率。这个例子清楚地展示了XOR映射对步长冲突的化解能力。

但XOR映射并非万能。如果步长恰好为64个Cache行(行地址每次增加64),则:低3位都是000,接下来3位每次增加8(即000、000、000...重复),XOR结果仍然全部相同。XOR映射只能化解步长值“恰好等于bank数量”的情况,对于步长为bank数量平方的情况仍然无能为力——不过这种极端情况在实际程序中极为罕见。

行级banking vs. 字级banking的权衡

在讨论降低bank冲突率的技术之前,有必要先明确bank划分的两种粒度——行级banking和字级banking——的根本区别及其各自的权衡。

行级banking中,同一Cache行的所有数据存储在同一个bank中。这意味着一次8 B的Load操作只访问一个bank,bank选择逻辑只需要看行地址的低位。但如果两个Load操作恰好访问同一行(如结构体的不同字段),它们必然冲突。

字级banking中,同一Cache行的不同字分散在不同的bank中。例如,64 B的行按8 B分到8个bank:行内偏移[0:7]在bank 0,偏移[8:15]在bank 1,以此类推。这种设计下,同一行内不同字的访问不会冲突。但字级banking带来一个新问题:如果一个Load操作需要读取跨越word边界的数据(如偏移6处的一个8字节Load,横跨bank 0和bank 1),则需要同时访问两个bank,增加了对齐处理的复杂度。

现代处理器通常采用混合方案:bank选择由行地址的低位和行内偏移的高位共同决定。例如,在一个16-bank设计中,bank编号 = 行地址低2位(2位)\parallel行内word偏移(2位),总共4位选择16个bank。这种方案既在行级有4个bank来分散不同行的访问,又在字级有4个bank来分散同一行内不同word的访问。

降低bank冲突率的技术

除了增加bank数量外,还有以下技术可以降低bank冲突率:

(1)字级bank(Word-level Banking)。前述的讨论中,bank的划分粒度是Cache行级别的——同一行的所有数据存储在同一个bank中。另一种更细粒度的方式是按来划分bank:一个Cache行的不同字存储在不同的bank中。例如,64 B的Cache行按8 B双字划分为8个bank,每个bank存储行内的一个双字。在这种设计下,两个访问即使位于同一Cache行内,只要访问的双字不同,就不会冲突。这对于结构体字段访问等空间局部性模式非常有利。

字级banking的一个具体例子:假设一个Cache行存储了一个包含多个字段的结构体,两条Load指令分别读取offset 0处的‘x‘字段和offset 16处的‘y‘字段。在行级banking中,两个Load访问同一行,必然冲突;在字级banking中,‘x‘和‘y‘位于不同的word bank,可以并行访问。

(2)流水线化bank。将每个bank的访问流水线化为2个阶段,使得每个bank可以每周期接受一个新请求(虽然每个请求需要2个周期才能完成)。这在逻辑上将每个bank的吞吐量翻倍,等效于将bank数量翻倍。代价是增加了1个周期的访问延迟。

(3)Smart调度。在指令调度阶段(Scheduler)就考虑bank冲突——如果检测到两个Load操作将访问同一bank,则延迟其中一个到下一周期发射。这将bank冲突从Cache层面提升到调度层面处理,避免了replay的开销。Intel的Skylake及后续架构被认为采用了这种bank冲突感知的调度策略。

Smart调度的实现需要Scheduler能够提前知道每个Load操作将访问哪个bank。在L1D使用简单低位映射的情况下,bank编号可以直接从Load操作的基址寄存器和偏移量中提取(偏移量的特定位域即bank编号)。但如果使用XOR映射,则bank编号依赖于完整的虚拟地址,只有在地址计算(AGU,Address Generation Unit)完成后才能确定,此时可能已经来不及在调度阶段做出冲突规避决策。

(4)冲突缓冲。在bank输入端设置一个小的缓冲队列(1\sim2项),当冲突发生时,将被阻塞的请求暂存在缓冲中,在下一个空闲周期自动重试。这避免了将冲突信号传播回Scheduler或触发replay,简化了控制逻辑。代价是增加了1个周期的延迟(对于被缓冲的请求),以及少量的面积用于缓冲存储。

bank冲突与replay机制

在具有replay机制的处理器中(如Intel的P6系列及后续架构),bank冲突的处理与指令replay紧密相关。当一条Load指令因bank冲突而无法在预期的周期内获得数据时,所有依赖该Load结果的后续指令都需要被replay(重新执行)——因为这些指令已经在Scheduler中被投机地调度并开始执行,它们的源操作数被假定在特定的周期到达,但bank冲突使得这一假设失效。

Replay机制的代价不仅仅是一次bank冲突的1个周期延迟。考虑以下指令序列:

asm
lw    a0, 0(s0)      # Load A: 访问bank 3(冲突)
  lw    a1, 8(s1)      # Load B: 也访问bank 3
  add   a2, a0, a1     # 依赖Load A和Load B
  sw    a2, 16(s2)     # 依赖add

如果Load A和Load B发生bank冲突,Load B被延迟1个周期。add指令已经被投机调度在Load A和Load B预期完成后1个周期执行,但Load B的延迟导致add的输入a1不可用,add需要replay。sw依赖add的结果,也需要replay。一次bank冲突可能引发3\sim4条指令的replay,消耗额外的功耗和执行端口带宽。

replay的功耗代价值得特别关注。每条被replay的指令需要重新读取源操作数、重新通过执行单元、重新写回结果——这些操作的功耗与正常执行一条新指令相当。如果bank冲突导致每次平均replay 3条指令,且冲突率为15%,那么replay造成的额外功耗约为0.15×3=45%0.15 \times 3 = 45\%的正常Load执行功耗。这是bank冲突的一个常被忽视的隐性代价。

性能分析 1 — bank冲突对IPC的影响

bank冲突直接影响超标量处理器的IPC。假设一个4-wide乱序处理器每周期平均发起2.5个Load操作(Load指令占比约30%),使用8-bank L1 D-Cache。在随机访问模式下,2个请求在8 bank中冲突的概率为12.5%,3个请求冲突的概率为24.2%。加权后,每周期平均有约15%的概率发生至少一次bank冲突,每次冲突导致一个Load操作延迟1\sim2个周期。

考虑到乱序执行的容错能力(延迟的Load操作可以在后续周期被调度),bank冲突造成的IPC损失通常在2%\sim5%的范围内。增加到16个bank可以将IPC损失降低到1%以下,这就是现代高性能处理器普遍采用16个bank的原因。

然而,在特定工作负载上,IPC损失可能远高于平均值。在SPEC CPU 2017的lbm(Lattice-Boltzmann方法流体力学模拟)测试中,由于规则的步长访问模式,8-bank设计的bank冲突率可以达到30%\sim40%,造成10%以上的IPC损失。这种工作负载是推动处理器厂商从8-bank升级到16-bank的重要动因之一。

向量/SIMD操作对多端口Cache的影响

随着处理器对SIMD/向量操作的支持越来越广泛(如AVX-512、ARM SVE、RISC-V V扩展),L1 D-Cache面临着新的带宽和对齐挑战。

宽Load/Store操作

一条向量Load操作可能一次加载16 B(128位SSE/NEON)、32 B(256位AVX2)甚至64 B(512位AVX-512)的数据。对于Multi-banking设计,一次64 B的向量Load可能跨越多个bank:

  • 在8-bank字级banking(每bank 8 B)下,一次64 B的Load需要访问所有8个bank——这实际上独占了整个Cache的带宽,该周期内其他Load无法执行。

  • 在16-bank行级banking下,一次64 B的Load只访问1个bank(因为64 B恰好等于一个Cache行),不会与其他行的Load冲突。但如果向量Load的地址不是行对齐的(例如从行偏移32处加载64 B),则需要跨越两个Cache行,涉及两个bank的读取。

为了高效支持宽向量操作,现代处理器通常在Cache输出端设置一个宽数据通路——即使每个bank的输出只有8 B,也可以通过并行读取多个bank并拼接,在一个周期内提供完整的32 B或64 B向量数据。这需要一个更宽的Crossbar和数据拼接逻辑。

Gather/Scatter操作

向量编程中的一个常见模式是Gather(收集)和Scatter(散播)操作。Gather操作从内存中多个不连续的地址各取一个标量值,组装成一个向量寄存器;Scatter操作将一个向量寄存器的各个元素分别写入不同的内存地址。

Gather操作等效于多个独立的标量Load操作,对Multi-banking设计来说是最具挑战性的工作负载模式——因为各个元素的地址完全不可预测,bank冲突率接近随机理论值(甚至更高,因为某些数据结构的访问模式会产生系统性的bank冲突)。

在Intel Skylake及后续架构中,一条AVX-512 Gather指令(如VPGATHERDD,一次收集16个32位整数)被微架构分解为多个μ\muop,每个μ\muop执行一个标量Load。这些μ\muop在乱序执行中可以并行发射到多个Load端口,但它们之间的bank冲突仍然会导致replay。在最坏情况下(16个元素全部映射到同一bank),Gather操作需要16个周期才能完成——远低于理想的2\sim3个周期。

设计提示

向量Load/Store操作的性能对bank数量和bank映射方式非常敏感。在设计面向高性能计算(HPC)或AI推理的处理器时,需要特别关注Gather/Scatter工作负载下的bank冲突率。增加bank数量、使用XOR映射以及支持宽数据通路(64 B+)是应对向量工作负载的关键设计选择。RISC-V V扩展的“unit-stride”向量Load/Store在行级banking下冲突率很低(因为访问连续地址),但“indexed”向量Load(Gather)的冲突率取决于索引值的分布,可能很高。

真实的例子:AMD Opteron的多端口Cache

AMD Opteron(K8架构,2003年)的L1 D-Cache设计是一个经典的多端口Cache案例,值得详细分析。

案例研究 1 — AMD Opteron K8的L1 D-Cache

AMD Opteron K8的L1 D-Cache参数如下:

  • 容量:64 KB

  • 相联度:2-way组相联

  • Cache行大小:64 B

  • 每周期支持:2个Load + 1个Store

  • 访问延迟:3个时钟周期(流水线化)

K8的L1 D-Cache采用了Multi-banking与Cache复制相结合的混合方案:

(1)Data SRAM采用8-bank设计,每个bank的容量为64KB/8=8KB64\,\text{KB} / 8 = 8\,\text{KB}。8个bank使得2个Load操作可以并行进行(只要它们不访问同一bank),bank冲突率在典型工作负载下约为12%\sim15%。

(2)Tag SRAM被复制为两份,每份都是完整的Tag阵列。两个Load端口各自使用一份Tag副本进行命中判断,从而避免了Tag SRAM成为性能瓶颈。Tag SRAM的写同步相对简单——当Cache行被替换或失效时,两份Tag同时更新。

(3)Store操作使用Store Buffer进行缓冲,在空闲的bank周期中进行实际写入。Store操作与Load操作的bank冲突通过优先级仲裁解决——Store操作的优先级低于Load操作,当发生冲突时Store操作被推迟。

这一设计的关键洞察是:Tag SRAM和Data SRAM的多端口需求不同。Tag SRAM的字宽很窄(Tag位数约20\sim30位),复制其面积开销很小;Data SRAM的字宽很宽(64 B = 512位/行),复制的面积开销巨大,因此使用Multi-banking来代替复制。这种Tag复制 + Data分bank的混合设计成为了后来许多处理器L1 D-Cache的设计范式。

后续架构的演进

在K8之后,AMD的后续架构进一步演进了多端口设计:

  • Barcelona/K10(2007年):L1D仍为64 KB/2-way,但bank数增加到16个,降低了bank冲突率;同时支持2 Load + 1 Store每周期。

  • Zen(2017年):L1D改为32 KB/8-way,减小了容量但提高了相联度,bank数为8个。Load/Store带宽提升到2 Load + 1 Store每周期。

  • Zen 4(2022年):L1D保持32 KB/8-way,但带宽进一步提升到3 Load + 2 Store每周期,bank数量和仲裁逻辑都进行了相应增强。

Intel方面也采用了类似的Tag复制 + Data分bank策略。Golden Cove的L1D (48 KB/12-way) 使用了16个bank来支持3 Load + 2 Store/周期的带宽需求。值得注意的是,Intel选择了48 KB的非2次幂容量配合12-way相联——这是VIPT约束(C/W页大小C / W \le \text{页大小},即48KB/12=4KB=页大小48\text{KB}/12 = 4\text{KB} = \text{页大小})的直接结果。

VIPT与Multi-banking的交互

在理解Multi-banking设计时,有一个容易被忽略但非常重要的细节:bank编号从地址的哪些位中提取?在VIPT(Virtually Indexed, Physically Tagged)Cache中,用于索引的位(Index位和Offset位)来自虚拟地址,而Tag位来自物理地址。bank编号位属于Index/Offset范围内的哪个位域,直接影响bank映射是基于虚拟地址还是物理地址。

对于一个32 KB、8-way的L1D Cache,行大小64 B,共32768/(8×64)=6432768 / (8 \times 64) = 64组,组号需要6位。虚拟地址的结构如下:

  • 位[5:0]:行内偏移(Offset),6位。

  • 位[11:6]:组号(Index),6位。

  • 位[47:12]:Tag位(物理页号)。

在VIPT设计中,位[11:0]的虚拟地址与物理地址相同(因为页大小为4 KB = 2122^{12}字节,低12位不经过翻译)。bank编号如果从位[8:6]提取(行地址的低3位),则这些位在虚拟地址和物理地址中是相同的,bank映射是确定性的。

但如果Cache容量增大到64 KB/8-way(128组,需要7位Index),则组号需要位[12:6],其中位[12]已经跨入了虚拟页号的范围——这一位在虚拟地址和物理地址中可能不同(取决于页表映射)。如果bank编号使用了位[12],则在TLB翻译完成之前,bank编号是不确定的,这会延迟bank选择和仲裁,增加Cache访问延迟。

这一约束是现代处理器选择L1D Cache参数时的重要考量之一。Intel Golden Cove选择48 KB/12-way而非更自然的64 KB/8-way,很大程度上就是为了满足VIPT约束(48KB/12=4KB48\text{KB}/12 = 4\text{KB} \le页大小),确保所有Index位和bank位都落在页内偏移范围内。

超标量处理器的取指令

取指令(Instruction Fetch)是超标量处理器流水线的第一个阶段,它的任务是每周期从I-Cache中取出足够多的指令供后续的译码和发射阶段消化。取指阶段的带宽直接决定了超标量处理器的最大吞吐量——如果每周期只能取出4条指令,那么即使后端的执行单元可以每周期执行8条指令,处理器的峰值IPC也被限制在4。

取指看似简单——只是从I-Cache中读取一块指令字节而已——但在超标量处理器中,取指阶段面临多个相互交织的挑战。第一,取指带宽必须足够高,需要每周期提供WW条甚至更多的指令。第二,程序中频繁出现的分支指令会打断顺序取指流,降低有效取指效率。第三,取指块可能跨越Cache行边界,需要特殊的硬件处理。第四,对于x86等变长指令集,取出的字节流需要经过预译码才能确定指令边界。本节将逐一讨论这些问题及其解决方案。

取指带宽的需求

一个WW-wide的超标量处理器(即每周期最多发射WW条指令)在理论上需要每周期取出至少WW条指令。然而,实际的取指带宽需求往往高于WW,原因如下:

(1)分支指令的存在。程序中每5\sim7条指令就有一条分支指令。如果一条分支指令出现在取指块的中间位置,那么取指块中分支指令之后的指令可能不会被执行(如果分支被预测为taken),从而浪费了取指带宽。因此,平均每次取指只能获得有效指令Weff<WW_{\text{eff}} < W条。

为什么分支指令对取指效率的影响如此显著?考虑一个8-wide处理器每周期取出8条指令的取指块。如果分支密度为每6条指令一条,则平均每个取指块包含8/61.338/6 \approx 1.33条分支指令。假设分支中约25%是taken的(条件分支约20%加上无条件跳转和调用/返回约5%),则第一条taken分支平均出现在取指块的第6/0.25=246/0.25 = 24条指令处——但这远超8条的取指块宽度。更精确的分析表明,对于8条指令的取指块,取指块内第一条taken分支出现在位置kk的概率为(1pt)k1pt(1-p_t)^{k-1} \cdot p_t,其中pt0.25/64.2%p_t \approx 0.25/6 \approx 4.2\%为每条指令是taken分支的概率。取指块中有效指令的期望数为k=18k(1pt)k1pt+8(1pt)86.5\sum_{k=1}^{8} k \cdot (1-p_t)^{k-1} \cdot p_t + 8 \cdot (1-p_t)^8 \approx 6.5条。

(2)对齐问题。I-Cache通常以Cache行对齐的方式组织。如果PC(程序计数器)的值不在Cache行的起始位置(这在分支目标地址处很常见),那么取指块中PC之前的字节无法使用,进一步减少了有效取指带宽。

(3)流水线气泡。分支预测错误、I-Cache缺失、TLB缺失等事件都会在取指阶段产生气泡(bubble),使得某些周期没有指令被取出。为了弥补这些损失,需要在正常周期中取出更多的指令。

为了满足这些需求,现代高性能处理器的I-Cache取指带宽通常为32 B\sim64 B/周期:

  • Intel Golden Cove:每周期取32 B(最多8条x86指令,使用预译码标记指令边界)。

  • AMD Zen 4:每周期取32 B(通过Op Cache每周期可以提供9个μ\muops,绕过传统取指路径)。

  • Apple Everest:每周期取32 B(最多8条ARM指令)。

  • RISC-V处理器(如SiFive P870):每周期取32 B(最多8条32位指令或更多16位压缩指令)。

对于定长指令集(如ARM AArch64的32位固定长度指令),32 B的取指带宽恰好对应8条指令;对于变长指令集(如x86),32 B中可以包含的指令数取决于指令的平均长度(x86指令平均长度约3.5 B\sim4.5 B,因此32 B平均包含7\sim9条指令)。

有效取指带宽的分析

取指阶段的有效取指带宽(Effective Fetch Bandwidth)受到多个因素的限制。设每周期取指的原始带宽为FF条指令(理想情况),取指有效率为ηfetch\eta_{\text{fetch}},则有效取指带宽为FηfetchF \cdot \eta_{\text{fetch}}。影响ηfetch\eta_{\text{fetch}}的主要因素包括:

  1. I-Cache缺失率mIm_I。每次I-Cache缺失导致约LI-missL_{\text{I-miss}}个周期的取指中断(LI-miss1020L_{\text{I-miss}} \approx 10\sim 20周期,取决于L2延迟)。

  2. 分支预测错误率mBm_B。每次预测错误导致流水线前端刷新,约Lpenalty1220L_{\text{penalty}} \approx 12\sim 20个周期的气泡。

  3. 取指块中的有效指令比例rvalidr_{\text{valid}}。由于分支指令的存在,取指块中分支之后的指令可能无效,平均每个取指块中只有60%\sim80%的指令是有效的。

综合来看,有效取指带宽可以估算为:

有效取指带宽=Frvalid(1mILI-miss/Ttotal)(1mBLpenalty/Ttotal) \text{有效取指带宽} = F \cdot r_{\text{valid}} \cdot (1 - m_I \cdot L_{\text{I-miss}} / T_{\text{total}}) \cdot (1 - m_B \cdot L_{\text{penalty}} / T_{\text{total}})

其中TtotalT_{\text{total}}为总执行周期数。在典型的SPEC CPU工作负载上,rvalid0.7r_{\text{valid}} \approx 0.7,I-Cache缺失和分支预测错误约各造成5%\sim10%的取指损失,因此有效取指带宽约为原始带宽的55%\sim65%。这意味着一个8-wide处理器需要每周期取指8条以上的原始指令,才能保证后端每周期平均获得5\sim6条有效指令。

性能分析 2 — 取指效率的瓶颈分解

为了更具体地理解各因素的影响,考虑一个典型的8-wide处理器运行SPEC CPU 2017的gcc测试。假设:

  • 原始取指带宽 F=8F = 8条/周期

  • I-Cache缺失率 mI=0.3%m_I = 0.3\%(每1000条指令约3次缺失),L2延迟 LI-miss=12L_{\text{I-miss}} = 12周期

  • 分支预测错误率 mB=3%m_B = 3\%(MPKI \approx 5),错误惩罚 Lpenalty=15L_{\text{penalty}} = 15周期

  • 取指块有效指令比例 rvalid=0.65r_{\text{valid}} = 0.65(gcc中分支非常频繁)

则有效取指带宽8×0.65×(10.003×12×8/100)×(10.03×15/6.5)8×0.65×0.97×0.933.7\approx 8 \times 0.65 \times (1 - 0.003 \times 12 \times 8 / 100) \times (1 - 0.03 \times 15 / 6.5) \approx 8 \times 0.65 \times 0.97 \times 0.93 \approx 3.7条/周期。

这个分析揭示了一个令人吃惊的事实:即使是8-wide的处理器,在分支密集的代码上,有效取指带宽可能不到原始带宽的一半。取指块有效指令比例(rvalid=0.65r_{\text{valid}} = 0.65)是最大的瓶颈因素。

这也解释了为什么μ\muop Cache对x86处理器如此重要:μ\muop Cache可以绕过取指和译码的所有瓶颈,直接以μ\muop粒度供给后端,且其组织方式可以按基本块边界对齐,减少了分支造成的带宽浪费。在μ\muop Cache命中的情况下,有效μ\muop供给带宽可以接近原始带宽的90%以上。

跨Cache行的取指

I-Cache的基本寻址单位是Cache行(通常64 B)。当PC指向一个Cache行的后半部分时,一次32 B的取指操作可能跨越两个相邻的Cache行边界。例如,如果PC = Cache行起始地址 + 48 B,那么从PC开始的32 B将跨越到下一个Cache行的前16 B。

跨行取指的问题

跨行取指带来两个问题:

(1)需要同时访问两个Cache行。单端口I-Cache每周期只能读取一个Cache行。如果取指块跨越了Cache行边界,就需要在一个周期内从I-Cache读取两个不同的行——这实际上要求I-Cache具有双端口能力。

(2)两个行可能不在同一组。两个相邻的Cache行可能映射到I-Cache的不同组(Set),需要两组独立的Tag比较和Data读取逻辑。更极端的情况是,两个行中的一个可能命中而另一个缺失,需要分别处理。

跨行取指发生的频率取决于程序中taken分支的目标地址分布。对于64 B的Cache行和32 B的取指宽度,如果分支目标地址在行内均匀分布,则每次taken分支后跨行取指的概率为32/64=50%32/64 = 50\%。考虑到taken分支约占所有已执行指令的12%\sim15%,跨行取指的频率约为0.13×0.56.5%0.13 \times 0.5 \approx 6.5\%。虽然比例不高,但如果每次跨行取指都导致1个周期的气泡,对于8-wide处理器,这将造成约5%的IPC损失。

解决方案

处理跨行取指的常见方案有以下几种:

(1)对齐取指。最简单的方案是强制取指块与Cache行对齐——每次取指从Cache行边界开始,取指宽度不超过Cache行大小的一半。例如,对于64 B的Cache行和32 B的取指宽度,将每个Cache行分为前32 B和后32 B两个"取指窗口",PC落在哪个窗口就取哪个窗口的内容。这种方案最简单,但当分支目标地址恰好落在Cache行后半部分时,第一次取指只能获得少量指令(如只有16 B有效),降低了取指效率。

对齐取指的低效在分支密集的代码中尤为明显。假设一个分支目标地址落在Cache行偏移52处(后32 B窗口内),则第一次取指只能获得6452=1264 - 52 = 12字节的有效指令(约3条ARM指令或约3条x86指令)。对于8-wide处理器,这意味着第一个周期只能供给3条指令,远低于8条的峰值。在连续多次taken分支的场景下(如函数调用链),这种对齐损失会累积并严重影响前端吞吐量。

(2)双端口I-Cache或两个bank。将I-Cache的Data SRAM设计为2个bank——偶数行在Bank 0,奇数行在Bank 1。当取指块跨越行边界时,从两个bank中分别读取相邻的两行,然后通过一个旋转器(Rotator)拼接出所需的32 B取指块。这种方案可以支持任意对齐的取指,但增加了I-Cache的硬件复杂度。

(3)预取下一行。使用一个行缓冲器(Line Buffer),预取下一个Cache行到缓冲器中。当取指块跨行时,从当前Cache行读取前半部分,从行缓冲器读取后半部分,拼接成完整的取指块。这种方案不需要修改I-Cache本身的结构,但行缓冲器需要提前预取下一行,在跨行的第一个周期可能产生1个周期的延迟。

在实际处理器中,行缓冲器方案最为常见,因为大多数取指操作是顺序的——当处理器正在取指Cache行NN时,下一次取指大概率会访问行NN或行N+1N{+}1。因此,行缓冲器可以在当前行被取指时就投机地预取下一行,准确率通常在90%以上。Intel的P6系列和ARM Cortex-A系列处理器都采用了类似的I-Cache下一行预取机制。

对于RISC-V的C扩展(16位压缩指令),跨行取指的问题更为复杂。由于指令长度可以是16位或32位,一条32位指令可能跨越两个Cache行——指令的前16位在一个行的末尾,后16位在下一个行的开头。这种情况在对齐取指方案下可能导致一条指令需要两个周期才能完成取指,需要特殊的处理逻辑来检测和处理跨行指令。

图 8.4展示了双bank方案的跨行取指过程。

双bank I-Cache的跨行取指
双bank I-Cache的跨行取指

指令对齐网络

从I-Cache取出的32 B指令字节块中,指令的起始位置可能在字节块内的任意位置。为了将这些指令送入多个并行的译码器,需要一个指令对齐网络(Instruction Alignment Network)来从字节流中提取出独立的指令并分配给各个译码器。

定长指令集的对齐

对于ARM AArch64等定长指令集(每条指令4 B),指令对齐相对简单。取指块中的指令自然地按4字节边界排列。一个32 B的取指块最多包含8条指令。取指时,PC的低位(位[4:2])指示了取指块内第一条有效指令的位置。对齐网络只需要根据起始位置进行一次旋转(Barrel Shift),将第一条有效指令对齐到Slot 0,后续指令依次对齐到Slot 1\sim7。

当取指块内存在taken分支时,分支之后的指令槽需要被标记为无效(Invalid)。FTQ提供的“取指块结束位置”信息用于设置这一掩码。

变长指令集的对齐

对于x86等变长指令集,指令对齐是一个远比定长指令集复杂的问题。x86指令的长度从1字节到15字节不等,只有解析完一条指令的前缀、操作码和编码字段后,才能确定该指令的长度和下一条指令的起始位置。这意味着指令边界的确定是一个串行过程——必须从取指块的第一条指令开始,依次确定每条指令的长度,才能知道后续指令的起始位置。

这种串行依赖严重限制了x86处理器的译码带宽。假设每条指令的长度确定需要1个gate延迟,一个32 B的取指块中最多有32条指令(如果全是1字节指令),串行确定所有指令边界需要32个gate延迟——这在高频处理器中是完全不可接受的。

为了打破这种串行依赖,现代x86处理器采用了以下技术:

(1)预译码标记(Pre-decode Marking)。在Cache行被填入I-Cache时,预译码逻辑对每个字节计算并存储1\sim3位的标记信息,指示该字节是否是一条指令的起始字节、指令长度等。后续取指时,这些标记信息与指令字节一起被读出,对齐网络可以直接使用标记信息而无需重新解析指令。

(2)Micro-op Cacheμ\muop Cache)。完全绕过字节级的取指和译码,直接提供已译码的μ\muop。这是x86处理器解决译码瓶颈的终极方案,将在下一小节讨论。

RISC-V压缩指令的对齐挑战

RISC-V的C扩展引入了16位压缩指令,使得指令集变成了“半变长”——指令长度只有两种可能:16位或32位。虽然比x86的完全变长简单得多,但仍然带来了定长指令集所没有的对齐问题。

最核心的问题是:一条32位指令可能跨越16位边界。在32 B的取指块中,第一条有效指令的起始位置可能是任何2字节对齐的地址。如果取指块从偏移2处开始有效(例如,分支目标地址为Cache行地址+2\text{Cache行地址} + 2),那么偏移0处的2字节被浪费,偏移2处开始的字节需要被检查:如果这是一条16位指令,则下一条指令从偏移4开始;如果这是一条32位指令,则下一条指令从偏移6开始。

RISC-V指令的长度可以通过检查指令最低2位来快速确定:如果最低2位不是11,则指令为16位;如果最低2位是11(且位[4:2]不全为1),则指令为32位。这一判断只需要2\sim3个gate延迟,比x86快得多。因此,RISC-V的指令边界确定可以在一个短的串行链中快速完成——对于32 B的取指块(最多16条16位指令),串行确定所有边界只需要16×34816 \times 3 \approx 48个gate延迟,通过适当的并行化(如4路并行检测),可以在1\sim2个周期内完成。

一种常用的并行化方法是分段对齐:将取指块分为4个8 B的段,每段内独立并行地确定指令边界。段间的依赖(一条指令可能跨越段边界)通过段间的“carry”信号来传递——如果前一段的最后一个half-word是32位指令的前半部分,则carry信号告知后一段的第一个half-word是指令的后半部分而非新指令的开始。

案例研究 2 — I-Cache的设计参数选择

I-Cache的设计参数(容量、相联度、行大小、bank结构)与D-Cache有所不同,反映了指令访问模式的特殊性。

容量。I-Cache的容量通常与L1 D-Cache相当或略小:Intel Golden Cove为32 KB,AMD Zen 4为32 KB,Apple Everest为192 KB(异常地大)。I-Cache的容量主要受指令足迹(Instruction Footprint)约束——对于像数据库查询引擎、Web浏览器JavaScript JIT等代码足迹大的工作负载,更大的I-Cache可以显著降低缺失率。Apple选择192 KB的I-Cache很可能是针对Safari浏览器和iOS应用的大代码足迹优化。

相联度。I-Cache的相联度通常为4-way或8-way,低于D-Cache的8\sim12-way。这是因为I-Cache的冲突缺失率相对较低——指令代码的空间局部性通常好于数据访问。

行大小。I-Cache的行大小通常与D-Cache相同(64 B),因为两者共享L2 Cache,使用相同的行大小简化了L2的管理。

多端口需求。与D-Cache不同,I-Cache通常只需要一个读端口——因为处理器每周期只从一个PC地址开始取指(即使可以跨行,也可以通过前述的双bank或行缓冲器方案解决)。I-Cache不需要写端口用于正常操作(指令是只读的),但需要一个写端口用于Cache行填充和一致性操作(如在自修改代码或JIT编译场景下)。这使得I-Cache的多端口需求远低于D-Cache,设计相对简单。

取指缓冲区

取指缓冲区(Fetch Buffer,也称Instruction Buffer或Fetch Queue)是取指阶段和译码阶段之间的一个FIFO缓冲队列,用于解耦取指和译码的速率差异。

取指缓冲区(Fetch Buffer,也称Instruction Buffer或Fetch Queue)是取指阶段和译码阶段之间的一个FIFO缓冲队列,用于解耦取指和译码的速率差异。

取指缓冲区的必要性

取指阶段和译码阶段的速率在每个周期可能不同:

(1)取指带宽的波动。I-Cache缺失、TLB缺失、分支预测错误等事件会导致取指阶段在某些周期无法提供任何指令。而在正常周期中,取指阶段每周期可以取出超过WW条指令的原始字节(因为取指宽度通常大于等于WW条指令的字节总和)。

(2)译码带宽的波动。对于变长指令集(如x86),译码阶段需要先确定指令边界才能进行译码,复杂指令可能需要多个μ\muops来表示。在某些周期中,译码器可能消耗较多的原始字节(短指令序列),在另一些周期中消耗较少(长指令或复杂指令)。

取指缓冲区的作用是吸收这些波动,使得取指阶段和译码阶段可以以各自的速率独立工作。当取指速率暂时高于译码速率时,多余的字节被存入缓冲区;当取指速率暂时低于译码速率时(如I-Cache缺失期间),译码阶段从缓冲区中消耗已经取到的字节。

取指缓冲区的结构

取指缓冲区的基本参数包括:

(1)宽度。缓冲区的宽度等于每周期的取指带宽,通常为32 B\sim64 B。

(2)深度。缓冲区的深度(项数)决定了它能吸收多少周期的取指/译码速率波动。典型的深度为4\sim8项,总容量128 B\sim256 B。更深的缓冲区可以更好地容忍短期的取指中断,但也增加了面积和分支预测错误时需要刷新的延迟。

取指缓冲区的深度选择需要考虑两个矛盾的目标:

  • 更深的缓冲区可以在I-Cache缺失(通常8\sim14周期延迟)期间为译码阶段持续供给指令,减少前端停顿。如果缓冲区有8项、每项32 B,则总共可以缓存256 B\approx64条ARM指令或约56\sim73条x86指令,足以支撑10\sim12个周期的连续译码(6-wide处理器每周期消耗约6条指令)。

  • 更浅的缓冲区在分支预测错误时恢复更快——当预测错误被检测到时,取指缓冲区中所有基于错误路径的指令必须被丢弃。缓冲区越深,丢弃的指令越多,浪费的功耗越大。

(3)控制信息。缓冲区中的每一项除了存储指令字节外,还需要附加控制信息:该取指块对应的PC值、该取指块中有效指令的起始和结束位置、分支预测信息(如预测方向、预测目标地址)等。

x86的预译码

对于x86这样的变长指令集,取指缓冲区中的原始字节流需要经过预译码(Pre-decode)才能确定指令边界。预译码逻辑扫描取指块中的字节,识别每条指令的起始位置和长度,并在取指缓冲区中标记指令边界。这些标记信息随后被译码器使用来分割指令。

在现代x86处理器中,I-Cache通常存储预译码位(Pre-decode Bits)——当Cache行被填入I-Cache时,同时进行预译码并将结果存储在I-Cache的额外位域中。这样,后续的取指操作可以直接获得指令边界信息,而无需每次都重新进行预译码。Intel从P6架构开始就在I-Cache中存储预译码标记,每个字节附加1\sim3位的预译码信息。

预译码位的存储有一个容易被忽略的面积开销。对于32 KB的I-Cache,如果每个字节附加2位预译码信息,则预译码存储需要32768×2=6553632768 \times 2 = 65536位 = 8 KB。这意味着I-Cache的实际SRAM面积是标称容量的1.25×1.25\times(32 KB数据 + 8 KB预译码),且预译码位需要与数据字节同时被读出,增加了SRAM的读出宽度。

Micro-op Cache(μ\muop Cache)

为了进一步绕过取指和译码的瓶颈,现代x86处理器引入了Micro-op Cacheμ\muop Cache,也称为Decoded Stream Buffer, DSB)。μ\muop Cache缓存已译码的μ\muop序列,使得后续执行相同代码时可以直接从μ\muop Cache读取已译码的μ\muop,完全跳过取指和译码阶段。

μ\muop Cache的设计动机可以从译码的能耗角度理解。x86的译码器是处理器前端中最耗电的组件之一——它需要处理前缀、操作码、ModR/M、SIB、位移量和立即数等复杂的编码格式,涉及大量的多路选择器和查找表。在5 nm工艺下,一个4-wide的x86译码器的功耗可以占到整个处理器核心功耗的5%\sim8%。μ\muop Cache通过缓存译码结果来避免重复译码,不仅提高了前端吞吐量,还显著降低了功耗。

μ\muop Cache的组织结构与传统的I-Cache有所不同。传统I-Cache以字节地址为索引,而μ\muop Cache以指令PC为索引——每个μ\muop Cache行对应一个连续的PC区域(通常32\sim64字节的x86指令地址范围),存储该区域中所有x86指令译码后的μ\muop序列。一个μ\muop Cache行通常可以存储6\sim8个μ\muop。

μ\muop Cache的一个设计挑战是一对多映射:一条复杂的x86指令可能被译码为2\sim4个μ\muop(如ADD [mem], reg被译码为Load + Add + Store三个μ\muop)。这意味着μ\muop Cache行中的μ\muop数量不等于原始x86指令的数量——一条x86指令可能占用μ\muop Cache行中的多个槽位。当一条指令的μ\muop跨越了μ\muop Cache行的边界时,需要特殊处理(通常是将该指令的μ\muop全部放在下一行,空出上一行的剩余槽位)。

Intel Sandy Bridge的μ\muop Cache有1536个μ\muop的容量,组织为32组×\times8路×\times每行6个μ\muop。到Golden Cove,μ\muop Cache的容量增长到约4096个μ\muop。μ\muop Cache的面积约为同容量I-Cache的2\sim3倍(因为每个μ\muop比原始指令字节宽得多——一个μ\muop通常需要50\sim70位来编码操作码、源/目标寄存器、立即数等),但考虑到它同时替代了I-Cache访问和译码器的工作,整体的面积-性能比是有利的。

AMD Zen系列的Op Cache每周期可以提供9个μ\muops,远高于传统取指+译码路径的4个μ\muops/周期吞吐量。Intel的μ\muop Cache(自Sandy Bridge以来)同样可以每周期提供6个μ\muops。μ\muop Cache的命中率在热代码上通常超过80%,使得传统的I-Cache取指路径成为"冷路径"(Cold Path),只在μ\muop Cache缺失时才被使用。

μ\muop Cache的引入虽然大幅提升了x86的前端吞吐量,但并未消除I-Cache和取指逻辑的需要——μ\muop Cache的容量有限(通常2048\sim4096项μ\muops),对于大型代码足迹的工作负载(如数据库、编译器、Web浏览器),μ\muop Cache的缺失率可能很高,此时传统取指路径仍然是必需的。

Loop Buffer(循环缓冲器)

μ\muop Cache的基础上,许多处理器还实现了循环缓冲器(Loop Buffer,也称Loop Stream Detector, LSD)。循环缓冲器检测短循环(循环体内的μ\muop数量小于缓冲器容量),一旦检测到,就将整个循环体锁定在缓冲器中,后续的循环迭代直接从缓冲器中读取μ\muop,不再访问I-Cache或μ\muop Cache。

循环缓冲器的主要优势是功耗节省。在循环执行期间,I-Cache、μ\muop Cache和分支预测器的大部分逻辑都可以被关闭(clock-gated),因为所有需要的μ\muop都已经在缓冲器中。对于计算密集型的内层循环(如向量化的数组运算),循环缓冲器可以节省10%\sim20%的前端功耗。

Intel从Nehalem开始引入Loop Stream Detector,容量约28\sim64个μ\muops。然而,在Skylake及后续架构中,Intel因为一个与循环缓冲器相关的硬件错误(erratum)而在微码更新中禁用了LSD。ARM的Cortex-X系列也实现了类似的循环缓冲器,容量通常为64\sim128个μ\muops。

设计提示

循环缓冲器的设计需要小心处理以下边界情况:(1)循环体跨越μ\muop Cache行边界;(2)循环中包含分支预测错误时的恢复;(3)循环体长度恰好等于缓冲器容量时的边界条件;(4)中断发生在循环执行期间时的状态保存。Intel在Skylake上遇到的LSD erratum据信就与上述边界情况之一有关,这提醒我们看似简单的优化在实现时可能暗藏复杂性。

取指与分支预测的配合

在超标量处理器中,取指阶段与分支预测器需要紧密配合。分支预测器预测下一个取指地址,取指单元使用这个地址从I-Cache中取出指令。这两者之间的交互通过FTQ(Fetch Target Queue,取指目标队列)来协调。

基本取指循环

取指阶段的基本工作循环如下:

(1)分支预测器根据当前PC预测下一个取指目标地址(Next Fetch Address)。

(2)使用当前PC访问I-Cache,取出一个取指块。

(3)同时,I-TLB将当前PC翻译为物理地址,用于I-Cache的Tag比较。

(4)取出的指令块被送入取指缓冲区。

(5)PC更新为分支预测器预测的下一个取指目标地址。

在这个循环中,分支预测器位于关键路径上——它必须在I-Cache访问的同一周期或之前产生下一个取指地址,否则取指流水线将产生气泡。这要求分支预测器具有极低的延迟(通常1个周期)。

这一要求产生了一个有趣的悖论:更精确的分支预测器(如TAGE)通常需要更长的延迟(3\sim4个周期),但取指循环要求预测在1个周期内完成。解决方案是使用两级预测——一个快速但不太精确的“前端预测器”(如简单的BTB + bimodal)在1个周期内产生初步预测,同时一个慢速但精确的“后端预测器”(如TAGE-SC-L)在3\sim4个周期后产生更精确的预测。如果后端预测器的结果与前端预测器不同,则覆盖前端预测并重定向取指。FTQ在这种两级预测方案中起到了关键的协调作用。

FTQ的结构与作用

在实际的处理器设计中,分支预测和I-Cache访问的延迟可能不同。如果分支预测器可以在1个周期内产生多个取指目标(通过预测多个连续的分支),而I-Cache访问需要3\sim4个周期,那么预测速率会快于取指速率。FTQ的作用就是缓存分支预测器产生的取指目标地址,使得预测和取指可以以不同的速率工作。

FTQ的每一项包含以下信息:

  • 取指目标PC。分支预测器产生的取指块起始地址。

  • 取指块结束地址。由分支预测器预测的下一个taken分支的位置决定。在取指块内如果预测有一个taken分支,那么这个分支之后的指令不应被取出(因为控制流将跳转到分支目标地址)。

  • 分支预测信息。包括预测方向(taken/not-taken)、目标地址、使用的预测器状态(用于在预测错误时恢复)。

图 8.5展示了FTQ在取指流水线中的位置。

FTQ在取指流水线中的位置
FTQ在取指流水线中的位置

FTQ的深度

FTQ的深度决定了分支预测器可以"超前"取指单元多少个周期。如果I-Cache的访问延迟为LL个周期,FTQ的深度为DD项,则分支预测器可以容忍DD个周期的取指延迟而不会停滞。

在实际设计中,FTQ的深度通常为8\sim16项。考虑到I-Cache的访问延迟为3\sim4个周期,8项的FTQ可以让分支预测器超前取指单元4\sim5个取指块,提供足够的缓冲来应对短暂的I-Cache缺失或TLB缺失。

FTQ深度的设计考量与取指缓冲区类似——更深的FTQ提供了更好的容错能力,但分支预测错误时需要丢弃更多的FTQ项,增加了恢复延迟。此外,FTQ中每一项都包含分支预测器的状态快照(用于错误恢复),更多的项意味着更大的状态存储面积。

解耦预测与取指

FTQ实现了分支预测与取指的解耦(Decoupling)。这种解耦有以下好处:

(1)容忍I-Cache延迟。当I-Cache发生缺失时,取指单元停滞,但分支预测器可以继续预测后续的取指目标地址并存入FTQ。当I-Cache缺失被解决后,FTQ中已经有了多个预测好的地址,取指单元可以背靠背(back-to-back)地发起多次I-Cache访问,迅速恢复取指带宽。

这种解耦机制的效果可以用一个具体例子来说明。假设I-Cache访问延迟3个周期,在周期TT发生I-Cache缺失,L2延迟12个周期。在没有FTQ的设计中,取指和预测都停滞12个周期,在缺失解决后恢复正常取指。在有8项FTQ的设计中,预测器在停滞期间继续向FTQ填入预测结果;当缺失在周期T+12T+12解决后,FTQ中已经有8个预测好的取指地址,取指单元可以连续8个周期发起I-Cache访问(假设这些访问都命中),实现了比正常状态更高的取指吞吐量,“追赶”了停滞期间的损失。

(2)容忍预测延迟。如果分支预测器需要多个周期才能产生预测结果(例如使用复杂的TAGE预测器),FTQ中已缓存的目标地址可以让取指单元在等待期间继续取指,不会立即停滞。在某些高端设计中,分支预测器本身也被流水线化——一个快速的基本预测器(如BTB)在1个周期内产生粗略预测,一个慢速的精确预测器(如TAGE)在3\sim4个周期后产生精确预测。FTQ可以先使用快速预测的结果启动取指,然后在精确预测结果到达后进行修正。

(3)支持多种预测粒度。分支预测器可以以不同的粒度产生预测:按基本块粒度(一次预测一个基本块的结束位置和下一个基本块的起始地址)或按取指块粒度。FTQ可以将不同粒度的预测结果转换为统一的取指请求。

FTQ与分支预测错误恢复

当后端检测到分支预测错误时,FTQ需要参与恢复过程:

(1)刷新FTQ。FTQ中在预测错误分支之后的所有表项都是基于错误预测路径产生的,必须被全部丢弃。

(2)更新预测器状态。FTQ中记录的分支预测信息(如使用的预测器表项、预测方向等)被用于更新分支预测器——告知预测器此次预测是错误的,以便后续进行修正训练。

(3)重新开始预测。分支预测器从正确的目标地址重新开始预测,产生新的FTQ表项。

在这个过程中,FTQ起到了检查点(Checkpoint)的作用——它保存了分支预测器在每个预测点的状态快照,使得预测器可以精确地恢复到错误分支之前的状态。这对于使用全局历史(Global History)的预测器尤其重要,因为全局历史需要被恢复到正确的值。

FTQ中保存的预测器状态通常包括:

  • 全局分支历史(Global History Register, GHR)的快照。GHR记录了最近NN条分支的方向(taken/not-taken),是TAGE等预测器索引的关键输入。当预测错误发生时,GHR需要被恢复到该分支发生时的值。

  • RAS栈顶指针(Return Address Stack Top Pointer)。RAS用于预测函数返回地址。如果预测错误导致了错误的函数调用/返回路径,RAS的栈顶指针需要被恢复。FTQ中保存RAS指针的快照可以实现快速恢复。

  • 路径历史(Path History)。一些预测器使用分支指令的地址序列(而非方向序列)作为索引输入。FTQ需要保存路径历史的快照。

每个FTQ项保存的状态量决定了FTQ的面积。假设GHR宽度为64位,RAS指针为5位,路径历史为32位,加上取指PC(48位)和其他控制位(16位),每个FTQ项约需64+5+32+48+16=16564 + 5 + 32 + 48 + 16 = 165位。一个16项的FTQ总共需要16×165=264016 \times 165 = 2640\approx330 B。这个面积在L1 I-Cache(32 KB)面前可以忽略不计,但FTQ的读写端口和控制逻辑(特别是分支错误恢复时的快速刷新和指针重置)增加了非不可忽略的设计复杂度。

性能分析 3 — FTQ解耦的量化收益

FTQ解耦对性能的收益可以通过以下模型估算。假设:

  • I-Cache缺失率为0.5%(每200条指令约1次缺失)

  • I-Cache缺失延迟为12个周期

  • 处理器宽度为8-wide,正常IPC约5

  • FTQ深度为8项

无FTQ时,每次I-Cache缺失造成12个周期的取指完全停滞,平均每200条指令损失12个周期。200条指令在正常IPC 5下需要40个周期,缺失开销使其变为52个周期,IPC降低到200/523.85200/52 \approx 3.85

有8项FTQ时,I-Cache缺失期间分支预测器继续工作,FTQ在12个缺失周期中填入8个预测地址。缺失恢复后,取指单元可以连续处理这8个预测地址(假设I-Cache全部命中),有效地将12周期缺失的损失“追赶”回来8个周期,实际损失仅约128=412 - 8 = 4个周期。IPC提升到200/444.55200/44 \approx 4.55

FTQ带来了约4.55/3.85118%4.55/3.85 - 1 \approx 18\%的IPC提升,仅凭简单的解耦缓冲就实现了——这是FTQ成为现代处理器标配的原因。

基于取指块的预测器(Fetch-Block Based Predictor)

现代高性能处理器的分支预测器通常以取指块而非单条分支指令为粒度进行预测。一个取指块预测器每周期预测一个完整取指块中的分支行为,包括:

  • 取指块中是否包含分支指令。

  • 如果包含,第一条taken分支的位置(块内偏移)。

  • taken分支的目标地址。

这种块级预测方式可以直接产生FTQ所需的信息——取指块的起始地址和有效结束位置,使得FTQ和预测器之间的接口更加高效。Intel的P6到Golden Cove架构系列、ARM的Cortex-X系列都采用了这种块级预测方式。

Macro-op Fusion与取指的交互

Macro-op Fusion(宏操作融合)是现代处理器中的一项优化技术:将相邻的两条指令融合为一条内部μ\muop,减少后续流水线需要处理的μ\muop数量。最常见的融合模式是将一条比较/测试指令与紧随其后的条件分支指令融合为一条“比较并跳转”的μ\muop。

Macro-op Fusion对取指阶段有一个微妙的影响:被融合的两条指令必须在同一个取指块中。如果比较指令位于一个取指块的末尾而分支指令位于下一个取指块的开头,则无法融合。这意味着取指块的边界位置直接影响Macro-op Fusion的成功率。

在ARM AArch64中,Macro-op Fusion的一个常见模式是将cmpb.eq融合。在RISC-V中,由于比较和分支已经被合并为一条指令(如beq),Macro-op Fusion的需求不如x86和ARM那么强烈,但RISC-V仍然可以融合auipc+jalr(长距离跳转)或lui+addi(加载大立即数)等序列。

设计提示

Macro-op Fusion的检测逻辑通常位于译码阶段而非取指阶段。但取指阶段需要确保可融合的指令对尽可能落在同一个取指块中。一种方法是在检测到可融合的指令对跨越取指块边界时,自动调整下一次取指的起始位置,使其包含被拆分的第二条指令。然而,这种调整增加了取指逻辑的复杂度,在实践中多数处理器选择简单地放弃跨块融合,接受偶尔的融合失败。

非阻塞Cache

在简单的Cache设计中,当一次访存操作发生Cache缺失时,整个Cache会被"锁定"——后续的所有访存操作都必须等待缺失处理完成才能继续。这种阻塞Cache(Blocking Cache)在标量处理器中尚可接受(因为标量处理器一次只处理一条指令),但在乱序超标量处理器中会造成严重的性能损失。

为什么阻塞Cache在乱序处理器中如此有害?考虑一个320-entry ROB的6-wide处理器:在任一时刻,ROB中可能有数十条Load指令处于不同的执行阶段。如果其中一条Load的Cache缺失导致整个Cache被锁定,那么后续所有的Load和Store操作——包括那些本可以在Cache中命中的操作——都将被阻塞。这不仅浪费了Cache的带宽,还会迅速填满Load Queue和Store Queue,导致流水线停滞。

非阻塞Cache(Non-blocking Cache,也称Lockup-free Cache)由Kroft在1981年首次提出,其核心思想是允许Cache在处理缺失的同时继续服务后续的访存请求。非阻塞Cache使用一种称为MSHR(Miss Status Holding Register,缺失状态保持寄存器)的硬件结构来跟踪未完成的缺失请求。

为了量化阻塞Cache的性能影响,考虑这样一个场景:L1D缺失率约为5%(每100次访存发生5次缺失),L2延迟为12个周期。如果使用阻塞Cache,每次L1D缺失都会锁住Cache约12个周期,期间所有Load/Store操作停滞。粗略估算,在一个6-wide处理器中约30%的指令为Load/Store,即每100条指令约30次访存、1.5次缺失,消耗1.5×12=181.5 \times 12 = 18个额外周期。如果100条指令在无缺失时需要约20个周期(IPC \approx 5),则阻塞Cache将执行时间增加到38个周期,IPC降低到约2.6——性能损失接近50%。非阻塞Cache通过允许hit-under-miss和miss-under-miss,可以将这一损失大幅降低。

非阻塞Cache的"非阻塞"特性有两个层面:

(1)Hit under miss:在一个缺失正在处理时,后续的Cache命中操作仍然可以正常完成。这是最基本的非阻塞能力,几乎所有现代处理器的L1 D-Cache都支持。

(2)Miss under miss:在一个缺失正在处理时,后续的Cache缺失也可以被接受并开始处理,只要有空闲的MSHR表项。这需要多个MSHR来同时跟踪多个未完成的缺失。支持miss-under-miss是实现MLP的前提条件。

一个处理器的非阻塞能力可以用“Hit under NN miss”来量化——即Cache可以在NN个未完成缺失同时存在的情况下仍然服务命中请求。NN的值等于MSHR的数量。最早的非阻塞Cache设计(如MIPS R10000, 1996年)只支持“Hit under 1 miss”(1个MSHR),后来逐步增加到2、4、8、16个MSHR。现代处理器的L1D通常支持“Hit under 10\sim16 miss”。

显式MSHR与隐式MSHR

MSHR的实现有两种根本不同的方式:显式MSHR(Explicit MSHR)和隐式MSHR(Implicit MSHR, 也称In-Cache MSHR)。前面讨论的MSHR结构属于显式MSHR——使用一个独立于Cache的专用硬件表来跟踪缺失状态。隐式MSHR则将缺失信息直接嵌入Cache本身的数据结构中。

显式MSHR

显式MSHR的优势在于结构清晰、与Cache的SRAM完全解耦。MSHR表是一个独立的CAM/RAM结构,其大小、字段宽度和搜索逻辑可以独立于Cache进行优化。显式MSHR的缺点是它需要额外的面积和端口——特别是CAM搜索端口,需要在Cache缺失检测的关键路径上完成。

隐式MSHR

隐式MSHR的思想是:当Cache行缺失时,将缺失信息直接写入Cache行本身的Tag和Data字段中。具体来说,当一个缺失发生时:

  1. 选定一个替换行(即将被驱逐的行)。

  2. 将缺失的行地址写入该行的Tag字段,并设置一个特殊的“Transient”状态位,标记该行正在填充中(尚未有效)。

  3. 将缺失请求的目标信息(Offset、Size、Dest等)写入该行的Data字段的一部分(因为此时Data字段反正不包含有效数据)。

后续的访存请求在进行Tag比较时,如果匹配到一个Transient状态的行,就知道该行正在被填充,此次访问是对一个“in-flight miss”的合并请求,将其信息追加到Data字段中的Target List部分。

隐式MSHR的优势是不需要额外的硬件表——MSHR搜索复用了Cache的Tag比较逻辑,完全没有额外的CAM面积和功耗。缺点是它占用了Cache的一路,在高缺失率场景下可能导致相联度的有效降低。例如,一个8-way L1D如果有4个MSHR在使用中(4个行处于Transient状态),则有效相联度降低到84=48 - 4 = 4-way,增加了冲突缺失的概率。

在实践中,大多数高性能处理器使用显式MSHR,因为它不会降低Cache的有效相联度。隐式MSHR在一些低功耗、面积受限的设计中偶尔被使用。

MSHR的结构

MSHR是非阻塞Cache的核心数据结构。每个MSHR表项跟踪一个正在进行中的Cache缺失请求的完整状态。要理解MSHR的设计,首先需要理解它要回答的核心问题:当一个新的缺失发生时,它是针对一个全新的Cache行(需要分配新的MSHR),还是针对一个已经在处理中的Cache行(可以合并到已有的MSHR中)?这个问题的答案决定了Cache是否需要向下一级存储发送新的请求——而这个决策必须在Cache访问流水线的关键路径上快速完成。

MSHR表项的字段

一个典型的MSHR表项包含以下字段(如图图 8.6所示):

MSHR表项的结构
MSHR表项的结构

各字段的含义如下:

  • V(Valid位):该MSHR表项是否有效(正在使用中)。

  • Block Address:产生缺失的Cache行的地址(去掉Offset后的行地址)。这是MSHR表项的关键字段——所有针对同一Cache行的缺失请求都应合并到同一个MSHR表项中。

  • State:该缺失请求的当前处理状态,如"等待发送到下级"、"请求已发送,等待响应"、"数据已返回,等待填入Cache"等。

  • Issued:指示该缺失请求是否已经被发送到下一级存储(L2 Cache或主存)。

  • Fill Way:当数据返回时,应当填入Cache的哪一路(由替换策略在分配MSHR时确定)。

  • Target List(目标列表):记录所有等待该Cache行数据的处理器端请求。每个目标项包含:

    • Offset:请求的字节在Cache行内的偏移。

    • Size:请求的字节数(1/2/4/8字节)。

    • Dest:目标寄存器编号(用于在数据返回后唤醒等待的指令)。

    • Type:请求类型(Load/Store/预取等)。

MSHR的操作流程

MSHR的操作分为三个阶段:分配(Allocate)、合并(Merge)和释放(Deallocate)。

阶段一:分配

当一个访存操作(Load或Store)在Cache中缺失,且当前没有任何MSHR表项正在跟踪同一Cache行的缺失时,需要分配一个新的MSHR表项。分配过程如下:

  1. 从空闲的MSHR表项中选取一个(通常取编号最小的空闲项)。

  2. 设置Valid位为1。

  3. 记录缺失的Cache行地址到Block Address字段。

  4. 将发起缺失的请求信息写入Target List的第一项。

  5. 通过替换策略确定该行应当填入Cache的哪一路,记录到Fill Way字段。

  6. 如果被替换的行是Dirty的,发起写回请求将旧行写回下一级存储。

  7. 向下一级存储发送读请求,将State设置为"已发送"。

如果所有MSHR表项都已被占用(即没有空闲MSHR可用),则该缺失操作无法被处理,处理器必须stall——这是非阻塞Cache的一个重要限制。MSHR的数量直接决定了Cache可以同时跟踪的未完成缺失数量(即缺失并行度,Miss-Level Parallelism, MLP)。

MSHR耗尽导致的stall被称为结构性阻塞(Structural Stall)。在存储密集型工作负载中,这种stall可能非常频繁。例如,在SPEC CPU 2017的mcf测试(图论,不规则指针追踪访问模式)中,L1D缺失率可以达到20%\sim30%,如果只有10个MSHR,在L2延迟12周期的情况下,几乎每个周期都有MSHR被占用,MSHR耗尽的概率很高。

MSHR分配的时序约束

MSHR的分配操作位于Cache访问流水线的关键路径上。在一个3-cycle L1D Cache中,MSHR分配通常发生在第3周期(即Tag比较完成、确认缺失之后)。分配操作需要完成以下步骤:

  1. 搜索所有有效MSHR,检查是否有匹配的Block Address(用于缺失合并)。

  2. 如果没有匹配项,查找第一个空闲的MSHR。

  3. 写入Block Address和Target List的第一项。

  4. 确定替换路(查询替换策略状态)。

步骤1的CAM搜索和步骤2的优先编码器通常可以并行执行,合计延迟约1个周期。在高频设计中,MSHR的分配可能需要被进一步流水线化——将搜索和写入分到两个周期,这意味着在分配期间有一个1-cycle的"漏洞"窗口,期间新的缺失可能无法被立即处理。

阶段二:合并

当一个访存操作在Cache中缺失,但已经有一个MSHR表项正在跟踪同一Cache行的缺失时,不需要分配新的MSHR表项,只需要将当前请求的信息追加到已有MSHR表项的Target List中。这就是缺失合并(Miss Merging)操作,它避免了向下一级存储重复发送相同的请求。

合并操作需要首先搜索所有有效的MSHR表项,检查它们的Block Address字段是否与当前缺失的行地址匹配。如果匹配,则将当前请求追加到匹配项的Target List中。这个搜索通常使用CAM(内容寻址存储器)来实现,以便在一个周期内完成。

合并操作有一个重要的时序竞争问题:在同一个周期内,可能有一个新的缺失请求需要进行MSHR搜索(合并或分配),同时有一个L2返回的数据正在释放一个MSHR表项。如果释放和搜索在同一周期发生,需要仔细处理以避免竞争条件——例如,一个请求可能匹配到一个正在被释放的MSHR,此时应该分配新的MSHR而非合并。实现中通常通过“释放优先”策略来解决这一问题:在每个周期的前半段处理释放操作,后半段处理搜索和分配操作。

阶段三:释放

当下一级存储返回了请求的Cache行数据时,MSHR进入释放阶段:

  1. 将返回的数据写入Cache的指定位置(由Block Address确定组号,Fill Way确定路号)。

  2. 遍历Target List中的所有目标项,将请求的数据转发给对应的目标寄存器或Store Buffer,并唤醒等待该数据的指令。

  3. 清除Valid位,将该MSHR表项标记为空闲。

数据转发的顺序通常遵循Target List中的排列顺序(先来先服务),但在乱序处理器中,Target List中最早的请求(即最先发生缺失的请求)通常优先被服务,因为它最有可能位于关键路径上。

Target List中多个目标的唤醒通常需要多个周期——每个周期只能唤醒一个目标(因为唤醒操作需要写回寄存器文件和通知Scheduler,这些资源每周期的带宽有限)。对于一个有4个目标的MSHR释放,完成所有目标的唤醒需要4个周期。在此期间,MSHR表项仍然被占用(因为Target List中仍有未处理的目标),这实际上延长了MSHR的占用时间,降低了MSHR的有效利用率。

一种优化是分离释放(Split Deallocation):在第一个目标被唤醒后立即将MSHR标记为“部分释放”状态——新的缺失请求不会再尝试与之合并,但MSHR项仍然存在以跟踪剩余目标的唤醒。当所有目标都被唤醒后,MSHR被完全释放。这种方式的好处是减少了MSHR的“逻辑占用时间”——从新缺失请求的视角看,MSHR在第一个唤醒后就已经不可合并了,等效于更早被释放。

另一种更激进的优化是提前唤醒(Early Wake-up)。在L2返回的数据通过Crossbar到达L1D Cache的同时,将第一个Target的数据直接旁路到Load结果总线上,不经过Cache的读出路径。这样,第一个Target可以在数据到达的同一周期就被唤醒,而无需等待数据先写入Cache再读出。提前唤醒可以将“从L2数据到达到第一个Load完成”的延迟从2个周期(写入Cache + 从Cache读出)减少到0个周期(直接旁路),对于关键路径上的Load性能提升显著。

MSHR与下级存储的接口

MSHR向下一级存储(通常是L2 Cache)发送请求时,需要通过一个请求队列(Request Queue)来管理请求的发送顺序和优先级。请求队列的设计需要考虑:

(1)优先级排序。需求请求(来自处理器Load/Store)的优先级高于预取请求。在需求请求内部,“老”的请求(更早进入MSHR的)优先级通常高于“新”的请求——这是因为老的请求更可能位于关键路径上,尽早完成它可以释放更多的流水线资源。

(2)带宽限制。L1D到L2的请求总线每周期只能发送有限数量的请求(通常1\sim2个)。如果多个MSHR同时需要发送请求(例如在一个缺失突发burst期间),请求队列需要进行仲裁,选择最高优先级的请求先发送。

(3)响应匹配。当L2返回数据时,需要将返回的数据与MSHR中的请求匹配。这通常通过在请求中携带MSHR编号(作为Transaction ID)来实现——L2在返回数据时附带这个编号,L1D控制器可以直接索引到对应的MSHR项,无需CAM搜索。

释放与二次缺失

在MSHR释放过程中可能发生一种特殊情况:当新的Cache行被写入Cache时,它可能驱逐一个正在被其他指令使用的行(如果该行是Dirty的,还需要写回)。这一驱逐操作可能导致其他正在进行的Load/Store操作发现自己的数据"消失了"——即发生了所谓的二次缺失(Secondary Miss)。

为了处理二次缺失,Cache控制器在写入新行之前需要检查被驱逐的行是否有其他MSHR正在等待。如果有,则需要延迟写入或选择其他路进行驱逐。这一检查增加了MSHR释放路径的复杂度,但对于正确性是必要的。

图 8.7展示了MSHR的完整操作流程。

MSHR的操作流程
MSHR的操作流程

缺失合并

缺失合并(Miss Merging,也称Miss Coalescing)是MSHR机制的一个关键特性:当多个访存请求对同一个Cache行产生缺失时,它们共享一个MSHR表项,只向下一级存储发送一次请求。

缺失合并的价值

缺失合并的价值体现在两个方面:

(1)减少下级存储的带宽需求。如果没有缺失合并,NN个对同一行的缺失请求会产生NN次下级存储访问,浪费大量的带宽。在乱序处理器中,由于多条指令可以并行执行,同一Cache行上可能短时间内累积多个缺失请求。通过合并,只需要一次访问即可满足所有请求。

(2)节省MSHR资源。每个合并的请求不需要占用新的MSHR表项,只需要在现有MSHR的Target List中增加一项。这等效于用Target List的容量来扩展MSHR的有效数量。

缺失合并在实际工作负载中的发生频率取决于访问模式的空间局部性。在遍历数组的代码中,同一Cache行内的多个元素可能被连续的Load指令访问。例如,一个循环每次迭代访问一个8 B的double元素,在64 B的Cache行中有8个元素。如果乱序窗口足够大(能覆盖多个循环迭代),则同一行的多次访问可能在第一次缺失被处理之前就已经发出,从而被合并。在SPEC CPU 2017的bwaves测试中,缺失合并率可以达到50%以上——即一半以上的L1D缺失通过合并避免了额外的L2访问。

Target List的深度

Target List的深度决定了每个MSHR表项可以合并的最大请求数。如果Target List满了,新的对同一行的缺失请求只能等待MSHR释放或stall。

典型的Target List深度为4\sim8项。这一深度基于以下观察:在乱序处理器中,同时对同一Cache行产生缺失的Load/Store操作数量受到ROB大小和指令窗口的限制,通常不会超过4\sim8个。

半字(Half-word)合并与字节合并

在合并过程中,多个请求可能访问同一Cache行中的不同字节。例如,一个Load操作请求行内偏移0处的8字节,另一个Load操作请求行内偏移16处的4字节。这些请求可以合并到同一个MSHR中,因为它们都需要同一Cache行的数据。当数据返回时,每个请求根据自己的Offset和Size从返回的Cache行中提取所需的字节。

更复杂的情况是一个Store操作和一个Load操作对同一行的不同字节产生缺失。这种情况下,当行数据返回后,需要先将行写入Cache,然后执行Store操作修改对应的字节,最后将修改后的数据转发给Load操作。MSHR的Target List需要记录每个请求的类型(Load或Store)以正确处理这种情况。

考虑一个8-bank、12个MSHR的L1 D-Cache。假设以下Load操作序列在两个时钟周期内发生(每周期3个Load):

周期操作地址(行地址:偏移)
周期 ttLoad A0x1000:0(缺失)
周期 ttLoad B0x2000:8(缺失)
周期 ttLoad C0x3000:16(命中)
周期 t+1t{+}1Load D0x1000:24(缺失,同行)
周期 t+1t{+}1Load E0x4000:0(缺失)
周期 t+1t{+}1Load F0x2000:32(缺失,同行)

MSHR的操作过程为:

  1. 周期tt:Load A缺失,分配MSHR#0(行地址0x1000),Target List#0 = {Offset:0, Size:8, Dest:r1, Type:Load}。向L2发送读请求。

  2. 周期tt:Load B缺失,分配MSHR#1(行地址0x2000),Target List#0 = {Offset:8, Size:8, Dest:r2, Type:Load}。向L2发送读请求。

  3. 周期tt:Load C命中,正常返回数据,不涉及MSHR。

  4. 周期t+1t{+}1:Load D缺失,搜索MSHR发现#0的行地址匹配0x1000,执行缺失合并:在MSHR#0的Target List中追加{Offset:24, Size:8, Dest:r4, Type:Load}。不向L2发送请求。

  5. 周期t+1t{+}1:Load E缺失,分配MSHR#2(行地址0x4000)。

  6. 周期t+1t{+}1:Load F缺失,搜索MSHR发现#1的行地址匹配0x2000,执行缺失合并。

结果:6个Load缺失只产生了3个MSHR表项和3次L2请求,而非6次。当L2返回0x1000行的数据时,MSHR#0同时唤醒Load A和Load D,分别提取偏移0和偏移24处的8字节数据。

MSHR深度的选择

MSHR的深度(即MSHR表项的数量)是非阻塞Cache设计中的一个关键参数。它决定了Cache可以同时追踪的未完成缺失数量——即MLP(Miss-Level Parallelism,缺失级并行度)的上限。

MLP的重要性

在乱序处理器中,多个Load指令可以被同时发射到存储单元并并行地处理缺失。如果这些Load指令的缺失是独立的(即它们的地址没有依赖关系),那么它们的缺失延迟可以重叠,实际的性能惩罚只等于一次缺失延迟而非NN次。

例如,如果处理器可以同时发出4个独立的Load操作,每个Load的缺失延迟为100个周期,那么:

  • 如果只有1个MSHR(即MLP = 1),4个缺失串行处理,总延迟 = 4×100=4004 \times 100 = 400周期。

  • 如果有4个MSHR(即MLP = 4),4个缺失并行处理,总延迟 100\approx 100周期。

  • 加速比 = 400/100=4×400 / 100 = 4\times

这说明MSHR深度对性能的影响可以与缺失延迟本身一样大。

MLP的概念由Chou、Sendag和Patt在2004年首次系统化提出。他们指出,传统的“平均缺失代价”模型(假设每次缺失的代价独立)会高估存储密集型工作负载的实际性能损失,因为乱序执行允许多个缺失重叠。准确的性能模型需要考虑“MLP-aware”的缺失代价——即将同时发生的多个缺失作为一个“缺失簇”来计算代价。

MLP-aware的缺失代价模型

传统模型将Cache缺失的性能代价估算为:

缺失代价=缺失次数×平均缺失延迟 \text{缺失代价} = \text{缺失次数} \times \text{平均缺失延迟}

这个模型假设每次缺失都是“串行”的——即每次缺失都会造成一次完整的延迟惩罚。

MLP-aware模型则考虑了缺失的重叠:

缺失代价=缺失簇数×平均缺失延迟 \text{缺失代价} = \text{缺失簇数} \times \text{平均缺失延迟}

其中“缺失簇”是指一组同时在处理中的缺失——这组缺失共享一个延迟惩罚(因为它们的延迟重叠)。一个缺失簇中有kk个缺失,则这kk个缺失的总代价只等于1次缺失延迟,而非kk次。

MLP的影响可以通过一个具体的例子来说明。考虑两个程序,都有100次L1D缺失、L2延迟100周期:

程序A(指针追踪,低MLP):每次Load的地址依赖于前一次Load的结果(链表遍历模式)。这意味着缺失是严格串行的——当前Load缺失必须等待前一次Load返回结果后才能计算地址。100次缺失形成100个缺失簇,总代价 = 100×100=10000100 \times 100 = 10000周期。

程序B(数组遍历,高MLP):所有Load的地址都是独立计算的(如base+i×8\text{base} + i \times 8)。乱序处理器可以同时发出多个Load,假设MLP = 10,则100次缺失形成约100/10=10100/10 = 10个缺失簇,总代价 = 10×100=100010 \times 100 = 1000周期。

两个程序的缺失次数相同,但程序B的实际性能损失只有程序A的1/101/10。这个例子解释了为什么在Cache优化中,不仅要关注“降低缺失率”,还要关注“提高MLP”——对于高MLP的工作负载,缺失率的影响被MLP稀释了。

这一观察也影响了缓存替换策略的设计。传统的LRU替换策略只关注最小化缺失率,但Qureshi和Patt在2006年提出了MLP-aware的替换策略:优先保留那些缺失代价高(低MLP,如指针追踪模式)的行,允许驱逐那些缺失代价低(高MLP,如数组遍历模式)的行,即使后者的访问频率可能更高。

现代处理器的MSHR配置

表 8.5列出了现代高性能处理器各级Cache的MSHR配置。

处理器L1D MSHRL2 MSHRROB大小Load Queue
Intel Golden Cove1232512192
AMD Zen 4104832088
Apple Everest (M4)1648\sim600\sim160
ARM Cortex-X41232320128

现代高性能处理器的MSHR配置(2024–2025年)

L1D MSHR深度

L1D的MSHR深度通常为10\sim16个。这一数字的选择基于以下考虑:

(1)可用的MLP。L1D缺失后的数据从L2 Cache返回,延迟约为8\sim14个周期。在这段时间内,乱序处理器可以继续执行指令并发出新的Load操作。一个320项的ROB、88项的Load Queue,在L2延迟12个周期的窗口内大约可以发出6\sim10个独立的Load操作,因此10\sim16个MSHR可以有效地利用L1D到L2之间的MLP。

(2)面积和功耗。每个MSHR表项需要存储Block Address、状态位和Target List,总计约40+4×(6+3+7+2)11040 + 4\times(6+3+7+2) \approx 110位,加上CAM搜索逻辑。12个MSHR的总面积约12×110=132012 \times 110 = 1320\approx165 B,相比32 KB的L1D Cache可以忽略不计。但CAM搜索逻辑(12项×\times30位地址比较)的延迟和功耗需要在Cache访问的关键路径上仔细优化。

L2 MSHR深度

L2的MSHR深度通常为32\sim48个,远多于L1D。这是因为L2缺失后的数据从L3/主存返回,延迟高达30\sim300个周期。在这段漫长的等待时间内,乱序处理器可以发出大量的独立Load操作。更多的L2 MSHR可以充分利用这些MLP,避免因MSHR耗尽而导致处理器过早停滞。

L2 MSHR深度选择的另一个考虑因素是预取请求的竞争。L2通常配置了硬件预取器,预取请求也需要占用MSHR。如果L2只有16个MSHR,其中一半被预取请求占用,则只剩8个MSHR用于真正的缺失请求——这可能不够。因此,L2 MSHR通常配置得比纯缺失需求更多,以容纳预取请求。一些设计将MSHR分为“需求MSHR”和“预取MSHR”两组,确保预取请求不会抢占需求请求的MSHR资源。

MSHR深度的边际收益递减

图 8.8定性地展示了MSHR深度与IPC的关系。

MSHR深度与IPC的关系(定性示意)
MSHR深度与IPC的关系(定性示意)

从图中可以看出,MSHR深度的增加对IPC的提升呈现明显的边际收益递减。对于L1D,从1个MSHR增加到8个带来了显著的IPC提升(约2×2\times),但从8个增加到16个的提升就小得多(约10%);从16个增加到32个几乎没有额外收益。这是因为在L2延迟只有12个周期的情况下,乱序窗口中同时存在的独立L1D缺失通常不超过10\sim12个。

对于L2,由于下级延迟更长(30\sim300个周期),IPC在更大的MSHR深度下仍然有显著提升——从8个到32个MSHR可以带来20%\sim30%的IPC提升。这就是为什么L2 MSHR通常配置为L1D MSHR的2\sim4倍。

MSHR深度与ROB大小的关系

MSHR深度的选择与ROB的大小密切相关。ROB限定了处理器可以"超前"执行的指令窗口——越大的ROB允许处理器在等待缺失时探索更远的指令,从而发现更多的独立缺失。如果MSHR的深度小于ROB中可能同时存在的独立缺失数量,则MSHR将成为MLP的瓶颈。

一条经验性的设计规则是:L1D MSHR深度\approxL2延迟(周期数)×\times每周期L1D缺失率。例如,如果L2延迟为12周期,平均每周期发生0.8次L1D缺失,则约需12×0.81012 \times 0.8 \approx 10个MSHR。对于L2 MSHR,类似的分析给出:L3/内存延迟×\times每周期L2缺失率。L3延迟40周期、每周期0.5次L2缺失\Rightarrow约需40×0.5=2040 \times 0.5 = 20个MSHR。实际配置通常在此基础上增加50%\sim100%的余量,以应对突发缺失场景。

多级Cache层次中的MSHR协同

在多级Cache层次(L1D \to L2 \to L3)中,每一级Cache都有自己的MSHR。当L1D发生缺失时,请求被发送到L2;如果L2也缺失,L2的MSHR跟踪该请求并将其进一步发送到L3。这种级联的MSHR结构形成了一个嵌套的缺失跟踪层次

MSHR的层间流水化

理想情况下,L1D的MSHR释放应该与L2的MSHR分配在时间上重叠。当L2返回数据给L1D时,L1D的MSHR可以立即释放(数据填入L1D Cache,Target List中的请求被唤醒)。但如果L1D释放MSHR的同时又有新的L1D缺失到达,需要同时进行MSHR释放和分配——这两个操作竞争MSHR表的写端口,可能导致冲突。

实际设计中,MSHR表通常采用双端口或时分复用来同时支持分配(写入新的MSHR项)和释放(清除旧的MSHR项)操作,确保两者不会互相阻塞。

L2 MSHR的特殊考虑

L2 MSHR相比L1D MSHR有几个重要的特殊性:

(1)来源多样。L2 MSHR不仅需要处理来自L1D的缺失请求,还需要处理来自L1I的缺失请求和L2预取器的预取请求。在多端口L1D的设计中,L2每周期可能同时接收多个L1D缺失请求(如果L1D有3个Load端口,理论上每周期最多3个L1D缺失)。L2 MSHR需要足够的分配带宽来处理这些并发请求。

(2)一致性协议交互。L2通常是一致性协议的操作点(Point of Coherence)——其他核心的Snoop请求在L2处被响应。L2 MSHR中正在处理的缺失请求可能与来自其他核心的Snoop请求冲突(如L2正在从L3取一个行,同时另一个核心也在请求同一行的独占权)。处理这种冲突需要额外的状态位和仲裁逻辑。

(3)更宽的Target List。一个L2 MSHR的Target List中的每个目标不是一个单独的Load/Store请求,而是一个L1D MSHR的标识——因为L2不直接知道处理器端的Load/Store操作,它只知道L1D发来了一个缺失请求。当L2数据返回时,L2将数据发送给L1D,由L1D的MSHR负责将数据进一步转发给处理器端的Load/Store操作。

MSHR耗尽时的背压传播

当L2的MSHR全部耗尽时,L2无法接受新的缺失请求。这个“背压”(Back-pressure)会向上传播到L1D——L1D发出的新缺失请求被L2拒绝(NACK),L1D需要保持自己的MSHR占用,在后续周期重试。如果L1D的MSHR也因此全部耗尽,背压继续向上传播到处理器的Load/Store单元——新的缺失Load操作只能stall在调度器中,等待MSHR释放。

这种级联的背压传播可以导致严重的性能下降。考虑一个“病态”场景:程序以很高的速率产生L2缺失(如指针追踪访问一个远超L2容量的链表),L3延迟为40个周期。如果L2只有32个MSHR,则在稳态下,32×40=128032 \times 40 = 1280周期的窗口内可以跟踪32个未完成的L2缺失。如果每周期产生超过32/40=0.832/40 = 0.8次L2缺失,则L2 MSHR会持续处于耗尽状态,处理器被迫频繁stall。

这也是为什么L2 MSHR的深度通常远大于L1D MSHR——L2需要足够的MSHR来“吸收”下级存储的长延迟,避免背压过早地传播到处理器核心。

MSHR与预取器的交互

在现代处理器中,L1D和L2 Cache通常配置了硬件预取器(如Stride预取器、流预取器等)。预取器产生的预取请求与处理器的需求请求共享MSHR资源,两者之间的交互需要精心管理。

MSHR资源竞争

预取请求和需求请求都需要MSHR来跟踪。当MSHR资源紧张时,一个激进的预取器可能占用大量MSHR,导致真正的需求缺失无法获得MSHR而被迫stall——这是典型的“预取伤害需求”场景。

为了缓解这一问题,常见的策略包括:

(1)需求优先。当MSHR接近耗尽时(例如只剩最后2\sim3个空闲项),停止接受新的预取请求,保留剩余的MSHR给需求请求。这通过一个简单的“水位线”阈值来实现——当空闲MSHR数量低于阈值时,预取请求被拒绝或延迟。

(2)预取MSHR分组。将MSHR分为两组:一组专门用于需求请求(Demand MSHR),另一组专门或优先用于预取请求(Prefetch MSHR)。需求请求只从Demand MSHR中分配,确保不会被预取请求饿死。当Demand MSHR全部空闲时,预取请求也可以借用Demand MSHR。

(3)预取升级。当一个需求请求到达时,如果发现已有一个预取请求的MSHR正在跟踪同一Cache行,则将该预取MSHR“升级”为需求MSHR——修改其优先级标记,使其在L2/L3的请求队列中获得更高的优先级,加速数据返回。这种升级避免了需求请求的重复分配,同时利用了预取已经“走在前面”的优势。

预取与缺失合并

预取请求也参与缺失合并。如果一个需求请求缺失的Cache行已经有一个预取请求正在处理(MSHR已分配),则需求请求可以合并到预取MSHR的Target List中,无需分配新的MSHR或发送新的下级请求。这是预取的最理想情况——预取正好“猜对”了处理器即将需要的数据,且预取请求提前出发,使得当需求请求到达时数据已经在途(或已经返回)。

预取的及时性(Timeliness)可以通过合并率来间接衡量。如果大部分预取请求在数据返回后才被需求请求命中(即命中了由预取填充的Cache行),说明预取太早或太保守;如果大部分预取请求在数据返回前就被需求请求合并,说明预取太晚或不够提前。理想情况下,预取应该在数据即将被需求时“刚刚好”完成填充。

设计提示

MSHR的设计需要在三个维度之间取得平衡:(1)数量——更多的MSHR支持更高的MLP,但增加了CAM搜索的面积和延迟;(2)Target List深度——更深的Target List支持更多的缺失合并,但增加了每个MSHR的面积和释放延迟;(3)预取与需求的资源分配——需要确保预取不会饿死需求请求,同时充分利用预取的带宽优势。在实际设计中,这三个维度通常需要通过大量的仿真(使用SPEC CPU、PARSEC等基准测试套件)来调优。

写缓冲区与MSHR的协同

Store操作在Cache中缺失时的处理与Load操作有所不同。大多数现代处理器采用Write-allocate策略(也称Fetch-on-write):Store缺失时先从下级取回完整的Cache行,修改行中对应的字节,然后写入Cache。这意味着Store缺失也需要分配MSHR。

然而,Store操作可以被写缓冲区(Write Buffer,也称Store Buffer的L1D端部分)吸收。Store数据先写入Write Buffer,后续在空闲周期批量写入Cache。如果Write Buffer中的多个Store操作地址位于同一Cache行,它们可以被合并(Write Combining),只需一次Cache写入操作。

Write Buffer与MSHR之间的关键交互场景是:当一个Store操作缺失时,它的数据被暂存在Write Buffer中,同时一个MSHR被分配来取回缺失的行。当行数据从L2返回并填入Cache后,Write Buffer中等待的Store数据才能实际写入Cache。在此期间,如果有Load操作需要读取Write Buffer中尚未写入Cache的数据,需要通过Store-to-Load Forwarding机制将数据直接从Write Buffer转发给Load——这避免了Load操作等待Cache行填充完成。

MSHR的详细位宽分析

为了更精确地理解MSHR的硬件开销,我们对一个典型的L1D MSHR表项进行详细的位宽分析。假设处理器使用48位虚拟地址、52位物理地址、64 B Cache行、8-way L1D。

  • Valid:1位。标记表项是否有效。

  • Block Address:物理地址去掉行内偏移(6位)后为46位。但由于MSHR的搜索粒度是Cache行,通常可以利用Index字段隐含在MSHR的组织中,实际存储的Tag宽度为46log2(组数)46 - \log_2(\text{组数})位。对于64组的L1D,Tag宽度为466=4046 - 6 = 40位。

  • State:3位。编码当前状态:IDLE(000), ALLOCATED(001), ISSUED(010), DATA_READY(011), FILLING(100), DRAINING(101)。

  • Issued:1位。标记请求是否已发送到L2。

  • Fill Waylog2W\log_2 W位 = 3位(8-way)。记录数据返回后应填入Cache的哪一路。

  • Coherence State:2位。期望的一致性状态(如Exclusive或Shared,取决于请求类型——Load请求期望Shared,Store请求期望Exclusive)。

  • Prefetch标记:1位。标记该MSHR是由demand请求还是预取请求分配的。用于需求优先级管理。

  • 时间戳:8\sim10位。记录MSHR分配的时间,用于在多个MSHR同时需要服务时选择最老的(优先级最高的)。

Target List中每个目标项的位宽:

  • Offset:6位。Cache行内的字节偏移。

  • Size:3位。请求的数据宽度(1/2/4/8/16/32字节,编码为000\sim101)。

  • Dest:7\sim8位。目标物理寄存器编号(在一个有256个物理寄存器的乱序处理器中为8位)。

  • Type:2位。请求类型:Load(00), Store(01), Prefetch(10), Atomic(11)。

  • ROB ID:8\sim10位。在ROB中的位置编号,用于唤醒时通知Scheduler。

  • Valid:1位。该目标项是否有效。

每个目标项约为6+3+8+2+10+1=306 + 3 + 8 + 2 + 10 + 1 = 30位。以每MSHR 4个目标项计算,Target List占4×30=1204 \times 30 = 120位。

MSHR主体字段约为1+40+3+1+3+2+1+10=611 + 40 + 3 + 1 + 3 + 2 + 1 + 10 = 61位。

每个MSHR表项总计约61+120=18161 + 120 = 181\approx23 B。12个MSHR的总存储为12×181=217212 \times 181 = 2172\approx272 B\approx0.27 KB。

相比32 KB的L1D Cache,MSHR的面积开销仅约0.27/320.84%0.27/32 \approx 0.84\%——面积几乎可以忽略。MSHR的主要硬件成本不在存储面积,而在CAM搜索逻辑——12个MSHR需要12个40位的并行比较器,这些比较器的面积和功耗是MSHR硬件成本的主体。

MLP的利用策略

充分利用MLP需要处理器微架构的多个组件协同配合。MSHR提供了MLP的硬件基础(同时跟踪多个缺失),但能否真正实现高MLP还取决于以下因素:

ROB大小与MLP的关系

ROB(重排序缓冲区)决定了处理器的指令窗口大小——即处理器可以“超前”执行的指令范围。只有当ROB足够大,使得多个独立的缺失Load指令同时存在于ROB中时,MSHR才能被充分利用。

设ROB大小为RR项,指令流中每II条指令有一条产生L1D缺失的Load(I=1/缺失率×1/Load比例I = 1/\text{缺失率} \times 1/\text{Load比例}),则ROB中同时存在的缺失Load数量约为R/IR / I。例如,R=320R = 320、Load比例30%、L1D缺失率5%,则I=1/(0.05×0.30)67I = 1/(0.05 \times 0.30) \approx 67,ROB中缺失Load数约为320/675320/67 \approx 5。这意味着即使L1D有16个MSHR,在此工作负载下平均只有约5个被使用。

要使16个MSHR被充分利用,需要R/I16R / I \geq 16,即R16×IR \geq 16 \times I。对于I=67I = 67,需要R1072R \geq 1072——这远超当前处理器的ROB大小。这解释了为什么在实际工作负载中,L1D MSHR很少全部被占用——处理器的指令窗口通常不够大以发现足够多的独立缺失。

调度器的MLP感知

乱序处理器的调度器(Scheduler)可以通过调整指令发射顺序来最大化MLP。一种策略是优先发射独立的Load指令——当多条指令准备就绪时,优先选择那些地址已经计算完成的Load指令发射到存储单元,即使它们在ROB中不是最老的。这种“Load优先”策略增加了同时发出多个Load的概率,从而提高MLP。

另一种策略是MLP-aware的缺失代价估计。当替换策略需要选择驱逐候选时,传统的策略(如LRU)只考虑重用距离,不考虑缺失的并行性。MLP-aware替换策略则评估每个候选行被驱逐后的缺失代价:如果该行被驱逐后的缺失可以与其他缺失并行(高MLP),则其有效代价较低,可以优先被驱逐;如果该行被驱逐后的缺失是孤立的(低MLP),则其有效代价高,应该被保留。

编译器与MLP的协作

编译器可以通过代码变换来增加程序的可利用MLP。几种常见的编译器优化包括:

(1)循环分裂(Loop Fission):将一个包含多个数组访问的循环分裂为多个只访问一个数组的循环,使得每个循环内的缺失更加规则和可预取。

(2)软件流水线(Software Pipelining):将循环体中的独立操作跨迭代交错排列,使得一个迭代的缺失Load可以与后续迭代的计算操作重叠。

(3)预取指令插入:在检测到低MLP的缺失模式时(如指针追踪),编译器可以插入软件预取指令来人为地增加MLP——通过提前发出预取请求,在当前缺失等待的同时为后续缺失做准备。

关键字优先与提前开始

当Cache发生缺失时,需要从下一级存储中取回整个Cache行(通常64 B)。在传统的设计中,处理器必须等待整个Cache行完全写入Cache后才能恢复执行。然而,处理器实际上只需要Cache行中的特定几个字节(即缺失的Load或Store操作请求的字节),而不需要等待整行都到达。

这里的核心洞察是:Cache行的64 B数据在总线上的传输不是瞬时完成的,而是分多次传输。如果总线宽度为16 B,则一次Cache行填充需要4次传输,耗时4个总线周期。在这4个周期内,如果处理器需要的关键字在最后一次传输中,它就必须无谓地等待前3次传输——这些时间本可以被利用。关键字优先(Critical Word First)和提前开始(Early Restart)就是利用这一观察来减少缺失代价的两种技术。

关键字优先

关键字优先(Critical Word First)的核心思想是:当Cache缺失触发对下一级存储的读请求时,首先返回处理器实际需要的那个字(或双字),而不是按照Cache行的地址顺序从低地址到高地址依次返回。

基本原理

在传统的Cache行填充方式中,下级存储按照地址顺序返回Cache行的各个部分。假设Cache行大小为64 B,总线宽度为16 B(128位),则一次Cache行填充需要64/16=464/16 = 4次总线传输,按照偏移0、16、32、48的顺序依次传输。如果处理器需要的数据位于偏移48处,则需要等待所有4次传输完成后才能获得所需数据,总等待时间为4个总线传输周期。

在关键字优先方式下,总线传输从处理器需要的偏移位置开始,然后以环绕(wrap-around)的方式传输剩余部分。对于上述例子,传输顺序为:偏移48(关键字)、偏移0、偏移16、偏移32。处理器在第一次传输完成后即可获得所需数据,等待时间仅为1个总线传输周期。

数值例子

考虑一个具体的例子来说明关键字优先的收益。假设L1D发生Cache缺失,需要从L2取回一个64 B的行。L1\leftrightarrowL2之间的总线宽度为16 B,因此需要4次传输。假设每次传输需要1个周期,总线请求的初始延迟为8个周期(包括L2 Tag查找和Data读取)。

在传统方式下,处理器发出读请求后,L2经过8个周期开始返回数据,按偏移0\to16\to32\to48的顺序传输。如果处理器需要的数据位于偏移40(第3次传输中),则处理器的总等待时间为8+3=118 + 3 = 11个周期。

使用关键字优先,L2从偏移32开始传输(包含偏移40的数据),传输顺序为32\to48\to0\to16。处理器在第一次传输完成后即可获得数据,总等待时间为8+1=98 + 1 = 9个周期,节省了2个周期。

实现要求

关键字优先的实现需要下级存储(如L2 Cache)和互联总线的配合:

(1)请求端需要在缺失请求中包含关键字的偏移信息(通常log2(行大小/总线宽度)\log_2(行大小/总线宽度)位,如log2(64/16)=2\log_2(64/16) = 2位)。

(2)响应端(L2 Cache)需要能够从任意偏移位置开始读取Cache行数据。这要求L2的Data SRAM支持"旋转读取"(Rotated Read),即可以从行的中间位置开始输出数据。一种实现方式是在L2的Data SRAM输出端增加一个旋转器(Barrel Shifter)。

(3)Cache控制器需要能够在Cache行尚未完全填充时就将第一个返回的数据块转发给处理器。这意味着Cache行填充是增量式的——先写入关键字所在的部分,随后写入其余部分。

性能收益

关键字优先的性能收益取决于Cache行大小和总线宽度的比值。设Cache行需要TT次总线传输才能完成填充,则平均的等待时间减少量为:

Δt=T12×tbus \Delta t = \frac{T-1}{2} \times t_{\text{bus}}

其中tbust_{\text{bus}}为一次总线传输的延迟(通常1\sim2个周期)。对于T=4T = 4,平均节省1.5×tbus21.5 \times t_{\text{bus}} \approx 2个周期。

在L1到L2的路径上(延迟约12个周期),2个周期的节省对应约17%的延迟减少。在L2到L3/主存的路径上(延迟30\sim300个周期),关键字优先的收益相对较小(不到1%\sim7%),因此主要在L1\leftrightarrowL2之间使用。

提前开始

提前开始(Early Restart)是关键字优先的一个简化版本:Cache行仍然按照地址顺序传输(从低地址到高地址),但一旦处理器需要的字到达,就立即将其转发给处理器,而不等待整行填充完成。

与关键字优先的区别

提前开始与关键字优先的关键区别在于传输顺序:

  • 关键字优先:改变了传输顺序,总是先传输关键字,然后传输其余部分。

  • 提前开始:不改变传输顺序(始终从偏移0开始),但允许处理器在传输过程中"提前"使用已到达的数据。

如果处理器需要的数据恰好位于Cache行的偏移0处,两种技术的等待时间相同(都是1次总线传输周期)。如果需要的数据位于行的末尾(如偏移48),则:

  • 关键字优先:等待时间 = 1次传输周期(关键字被最先传输)。

  • 提前开始:等待时间 = TT次传输周期(必须等到关键字按顺序到达),与不使用任何优化相同。

因此,关键字优先在所有情况下都优于或等于提前开始。

提前开始的优势

尽管关键字优先的性能更好,提前开始在实践中仍然被广泛使用,原因是它的实现更简单

(1)不需要改变总线传输顺序,下级存储的读取逻辑不需要支持旋转读取。

(2)Cache行的填充逻辑更简单——始终从偏移0开始按顺序写入。

(3)总线协议不需要携带关键字偏移信息。

在现代处理器中,由于L1\leftrightarrowL2之间通常使用宽总线(如32 B甚至64 B),一个64 B的Cache行只需要2次甚至1次传输即可完成,关键字优先和提前开始的性能差异已经很小。因此,现代处理器通常结合使用这两种技术——在L1\leftrightarrowL2路径上使用关键字优先(因为L2的读取逻辑足够灵活),在更远的层次上使用提前开始。

现代总线宽度对关键字优先收益的影响

随着片上互联带宽的不断增加,关键字优先的收益在逐渐减小。在早期处理器(如1990年代的MIPS R10000)中,L1\leftrightarrowL2总线宽度只有8 B或16 B,一个64 B的行需要8次或4次传输,关键字优先可以平均节省3.5或1.5个总线周期,对性能有显著影响。

在现代处理器中,L1\leftrightarrowL2之间的总线宽度通常为32 B或64 B:

  • 32 B总线:64 B行需要2次传输,关键字优先平均节省0.5个周期。

  • 64 B总线:64 B行只需1次传输,关键字优先无额外收益(因为整行一次性到达)。

这意味着在使用64 B总线的现代处理器中(如Intel Golden Cove的L1D\leftrightarrowL2接口),关键字优先和提前开始的价值已经非常有限——整个Cache行在一个总线周期内就全部到达,无所谓“先传哪个字”。然而,在L2\leftrightarrowL3或L3\leftrightarrow内存控制器的接口上,总线宽度相对于Cache行大小可能仍然不足(例如L3行大小128 B,L3\leftrightarrow内存控制器总线32 B,需要4次传输),关键字优先在这些层次上仍然有价值。

增量式Cache行填充

无论是关键字优先还是提前开始,都需要Cache控制器支持增量式Cache行填充(Incremental Line Fill)——即Cache行可以在只有部分数据到达时就标记为"部分有效",并在后续的传输中逐步补全。

增量式填充的实现需要为每个正在填充中的Cache行维护一个有效位向量(Valid Bit Vector)。对于64 B的行和16 B的总线宽度,需要64/16=464/16 = 4个有效位,每一位对应行中的一个16 B块。当一个块的数据到达时,对应的有效位被置为1。当所有4个有效位都为1时,行填充完成。

在填充过程中,如果后续的Load操作命中了同一行但请求的数据尚未到达(对应的有效位为0),则该Load操作需要等待——它被追加到MSHR的Target List中,等待相应的数据块到达后再被唤醒。如果请求的数据已经到达(对应的有效位为1),则Load可以直接从部分填充的行中读取数据,无需等待。

增量式填充引入了一个微妙的正确性问题:在行填充过程中,如果一个Store操作要修改行中已经有效的部分(有效位为1),而行的其他部分尚未到达(有效位为0),是否应该允许这个Store操作执行?答案是肯定的——Store可以修改已有效的部分,因为行填充的目的是获取缺失数据的副本,而非保护已有数据。但实现中需要确保Store操作不会修改尚未到达的部分(因为这部分数据还不在Cache中,修改可能被后续的填充覆盖)。

Sub-block Cache

增量式填充的思想可以推广到Sub-block Cache(子块Cache)。在Sub-block Cache中,每个Cache行被划分为多个子块(Sub-block),每个子块有独立的有效位和脏位。当Cache缺失时,只需要从下一级存储取回处理器实际需要的子块,而不必取回整个行。这可以减少缺失时从下级存储传输的数据量,降低总线带宽需求。

然而,Sub-block Cache也有代价:每行需要更多的元数据位(每个子块需要独立的Valid位和Dirty位),且替换和一致性管理变得更复杂。在现代处理器中,Sub-block Cache不太常见——因为L1\leftrightarrowL2之间的总线带宽已经很高,传输完整的64 B行的额外开销可以忽略。但在某些对带宽敏感的场景(如片间互联、多芯片封装)中,类似Sub-block的思想仍然有用。

设计权衡 2 — 关键字优先与提前开始的对比

特性关键字优先提前开始
传输顺序从关键字开始环绕固定从偏移0开始
最佳情况延迟1次传输1次传输
最坏情况延迟1次传输TT次传输
平均延迟减少(T1)/2tbus(T{-}1)/2 \cdot t_{\text{bus}}(T1)/4tbus(T{-}1)/4 \cdot t_{\text{bus}}
实现复杂度较高(旋转读取)较低(顺序填充)
总线协议修改需要传递偏移无需修改

硬件描述 2 — 非阻塞Cache + 关键字优先的协同效应

非阻塞Cache和关键字优先/提前开始是两种互补的缺失延迟优化技术。非阻塞Cache通过MLP将多个独立缺失的延迟重叠,关键字优先通过优先传输关键字来缩短每次缺失的有效延迟。两者结合使用时,效果是乘法关系而非加法关系:

设每次L1D缺失的基准延迟为LL周期,关键字优先节省δ\delta周期,有NN个独立缺失同时存在(MSHR深度N\ge N),则:

  • 无优化时总延迟:N×LN \times L(串行处理所有缺失)。

  • 仅有MSHR(MLP):LL(并行处理,延迟为单次)。

  • MSHR + 关键字优先:LδL - \delta(并行处理,每次缺失更短)。

在典型配置下(L=12L=12, δ=2\delta=2, N=4N=4),无优化时总延迟为48个周期,MSHR将其减少到12个周期(4×4\times加速),关键字优先进一步减少到10个周期,总加速比为4.8×4.8\times

写缓冲区与合并写

在前面的讨论中,我们主要关注了Load操作的性能优化——多端口Cache、非阻塞Cache、关键字优先等。但Store操作同样是Cache访问带宽的重要消费者。在典型的程序中,Store指令约占所有指令的10%\sim15%(约占所有访存指令的30%\sim40%)。如何高效地处理Store操作,是Cache子系统设计的另一个关键问题。

Store操作的特殊性

Store操作与Load操作有一个根本性的区别:Store不在关键路径上。Load操作的结果被后续指令依赖,Load的延迟直接影响这些依赖指令的执行时机;而Store操作只是将数据写入存储层次,没有后续指令依赖Store操作的“完成”(除了通过Store-to-Load Forwarding间接影响后续Load)。

这一特性意味着Store操作可以被缓冲——处理器不需要等待Store操作真正写入Cache才能继续执行后续指令。只要Store的地址和数据被记录在一个缓冲区中,处理器就可以认为Store“已完成”并继续前进。实际的Cache写入可以在后续的空闲周期中异步进行。

这个缓冲区就是写缓冲区(Write Buffer),在超标量处理器中通常与Store Buffer或Store Queue紧密集成。Write Buffer吸收了Store操作的延迟,使得Cache写入可以在“最方便”的时候进行——例如在没有Load操作需要Cache带宽的周期中。

合并写

Write Buffer中可能同时存在多个对同一Cache行不同字节的Store操作。例如,一段连续的结构体字段赋值代码可能产生对同一行内不同偏移的多次Store。如果每个Store都独立地写入Cache,将占用多个周期的Cache写入带宽。

合并写(Write Combining)技术将Write Buffer中对同一Cache行的多个Store操作合并为一次Cache写入。实现方式是在Write Buffer中为每个条目维护一个64 B的数据缓冲区和一个64 B的字节使能掩码(Byte Enable Mask)。每当一个新的Store操作到达时,检查Write Buffer中是否已有对应行的条目:

  1. 如果有,将新Store的数据写入对应的字节位置,更新字节使能掩码。

  2. 如果没有,分配一个新的Write Buffer条目。

当一个Write Buffer条目被写入Cache时(例如在空闲周期或条目被驱逐时),只需要一次Cache写入操作即可将所有已合并的Store数据一起写入。

性能分析 4 — 合并写的带宽节省

考虑以下代码模式(初始化一个结构体的多个字段):

sd a0, 0(s0)写offset 0处的8字节
sd a1, 8(s0)写offset 8处的8字节
sd a2, 16(s0)写offset 16处的8字节
sd a3, 24(s0)写offset 24处的8字节

这4个Store操作都位于同一Cache行内(假设s0是Cache行对齐的)。无合并写时,需要4次独立的Cache写入,占用4个周期的Cache写入带宽。使用合并写后,4个Store在Write Buffer中被合并为一个条目,只需要1次Cache写入。在Store密集的代码中(如memset、结构体初始化),合并写可以将Cache写入带宽需求降低2\sim4倍。

Non-temporal Store与Write-Combining Buffer

在某些场景中,处理器知道Store的数据在短期内不会被再次读取(例如写入显存或DMA缓冲区)。对于这类Store,将数据填入Cache反而有害——它会驱逐Cache中可能更有用的数据(Cache pollution)。

Non-temporal Store(非时间性写入)绕过Cache,直接将数据写入下一级存储或主存。x86提供了MOVNTPSMOVNTDQ等非时间性写入指令,ARM提供了STNP(Store Non-temporal Pair)指令。

Non-temporal Store使用一个专门的Write-Combining Buffer(WCB)来累积多个non-temporal Store的数据。当一个WCB条目的64 B数据被填满后,整行数据以突发传输(burst transfer)的方式写入下一级存储。这种方式最大化了总线利用率(一次完整的行传输比多次小粒度传输效率更高),同时完全避免了Cache pollution。

WCB的容量通常很小(4\sim8个条目),因为non-temporal Store主要用于流式写入场景,不需要大量的缓冲。Intel处理器中WCB条目数通常为4\sim10个。

Write-Allocate vs. No-Write-Allocate

Store缺失时的处理策略有两种:

(1)Write-Allocate(写分配,也称Fetch-on-Write)。Store缺失时,先从下级存储取回缺失的Cache行,写入Cache,然后在行中修改对应的字节。这种策略假设Store的地址在短期内还会被再次访问(空间或时间局部性),因此值得为其分配一个Cache行。

(2)No-Write-Allocate(非写分配,也称Write-Around)。Store缺失时,直接将Store数据写入下一级存储,不在当前Cache中分配行。只有Load缺失才触发行分配。这种策略假设Store的数据不太可能被快速重新访问,避免了用Store数据“污染”Cache。

现代处理器的L1 D-Cache几乎普遍使用Write-Allocate策略。这是因为在超标量乱序处理器中,Store操作后面很可能紧跟着Load操作访问同一行(如读取刚写入的值进行计算),Write-Allocate确保这些后续Load可以在L1D中命中。

Write-Allocate策略带来了一个有趣的副作用:即使程序只进行Store操作(如memset将一个大数组清零),也会产生大量的L1D缺失和L2读取。这些读取获取的数据马上被Store覆盖,读取本身的数据是“无用的”——浪费了L2的读取带宽和MSHR资源。为了解决这个问题,一些处理器实现了全行写(Full-line Write / Zero-line)优化:如果处理器检测到一系列Store操作将覆盖一个完整的Cache行的所有字节(如向量化的memset),则直接分配一个空行(不从L2取数据),标记为Modified,然后写入Store数据。这避免了无用的L2读取。

ARM架构提供了DC ZVA(Data Cache Zero by VA)指令,专门用于将一个Cache行清零而不需要先从下级读取——这本质上就是硬件层面对全行写优化的ISA支持。x86虽然没有直接等价的指令,但某些处理器(如Intel的Nehalem及后续架构)在检测到REP STOSB/STOSQmemset的典型实现)时会自动进行全行写优化。

Store-to-Load Forwarding与Write Buffer的关系

在超标量乱序处理器中,一条Load指令可能在程序顺序上位于一条Store指令之后,但在乱序执行中可能被调度到Store之前或同一周期执行。如果这条Load访问的地址与前面的Store相同或重叠,则Load应该读到Store写入的值——这就是Store-to-Load Forwarding(STF)的需求。

STF由Store Buffer(或更广义地,Write Buffer)来实现。当一条Load执行时,它同时搜索Cache和Store Buffer:

  • 如果Store Buffer中有匹配的Store条目(相同地址且Store的数据完全覆盖Load请求的字节范围),则数据直接从Store Buffer转发给Load,无需等待Store实际写入Cache。

  • 如果Store Buffer中没有匹配条目,则Load从Cache中读取数据。

  • 如果Store Buffer中有部分匹配(Store只覆盖了Load请求字节范围的一部分),则需要将Store Buffer中的数据与Cache中的数据合并后转发给Load——这是最复杂的情况。

STF的时序对处理器性能至关重要。如果STF可以在Load执行的同一周期完成(即Store Buffer搜索延迟\le Cache访问延迟),则STF不增加任何额外的Load延迟——Load无论是从Cache命中还是从Store Buffer转发,延迟都相同。在现代处理器中,Store Buffer搜索通常可以在3个周期内完成(与L1D访问的3个周期并行),因此STF是“零代价”的。

但部分匹配的情况例外。当Load请求的字节范围跨越了Store Buffer中多个Store条目的边界时(例如一个8字节的Load,其中低4字节由Store A覆盖,高4字节由Store B覆盖),合并逻辑需要从两个Store条目中分别提取数据并拼接,通常增加1\sim2个周期的额外延迟。某些处理器(如较早期的Intel Skylake之前的架构)不支持这种跨Store条目的转发,而是让Load重新执行。

设计提示

Store Buffer的大小直接影响STF的覆盖范围。一个更大的Store Buffer可以保持更多的“in-flight” Store操作,增加了后续Load操作在Store Buffer中找到匹配数据的概率。现代处理器的Store Buffer容量通常为56\sim128项(AMD Zen 4: 64项,Intel Golden Cove: 72项,Apple Everest: 约128项)。Store Buffer不仅需要支持地址匹配搜索(CAM),还需要支持按ROB顺序释放(FIFO),且每周期可能需要处理多个Store写入和多个Load搜索——使得Store Buffer成为处理器后端面积和功耗最大的结构之一。

Write-Back Buffer

当一个Dirty的Cache行被替换(驱逐)时,它的数据需要被写回到下一级存储。这个写回操作不在任何指令的关键路径上——它是Cache替换的“副产品”,可以在后台异步完成。为了不阻塞Cache的正常操作,被驱逐的Dirty行首先被放入一个Write-Back Buffer(也称Eviction Buffer或Victim Buffer)中,Cache立刻释放该行用于填入新数据,而实际的写回操作在后续的空闲总线周期中进行。

Write-Back Buffer的典型深度为4\sim8项。每项存储一个完整的Cache行数据(64 B)和行的物理地址。Write-Back Buffer需要在以下情况被搜索:

(1)Load/Store缺失时。新的缺失请求可能恰好命中了Write-Back Buffer中一个刚被驱逐的行——这在Cache颠簸(Thrashing)场景下较为常见。如果命中,数据可以直接从Write-Back Buffer返回,避免了访问L2的延迟。

(2)Snoop请求时。其他核心发来的一致性请求可能匹配Write-Back Buffer中的行。这种情况需要特别处理——如果一个Snoop请求匹配了一个正在等待写回的行,该行的数据可能需要直接转发给请求方。

Write-Back Buffer的搜索通常与MSHR搜索并行进行,不增加Cache访问的关键路径延迟。

Victim Cache

与Write-Back Buffer密切相关的一个概念是Victim Cache(牺牲者Cache),由Jouppi在1990年提出。Victim Cache是一个小型(通常4\sim16项)的全相联Cache,存储最近从L1D中被驱逐(替换)的Cache行。与Write-Back Buffer不同,Victim Cache不仅存储Dirty行,还存储Clean行。

Victim Cache的作用是缓解L1D的冲突缺失(Conflict Miss)。在直接映射或低相联度的Cache中,两个频繁访问的行如果映射到同一组(set),就会相互驱逐(颠簸, thrashing),导致大量的缺失。Victim Cache可以吸收这些被驱逐的行,当处理器再次访问被驱逐的行时,可以直接从Victim Cache中获得数据(1\sim2周期延迟),而无需访问L2(10+周期延迟)。

一个4项的Victim Cache可以将直接映射L1D的冲突缺失率降低20%\sim40%,其效果近似于将L1D从直接映射变为4-way组相联。然而,在现代处理器中,L1D通常已经是8\sim12-way组相联,冲突缺失率已经很低,Victim Cache的边际收益大大降低。因此,现代高性能处理器中独立的Victim Cache已经不太常见——但其思想被融合到了L2 Cache的设计中(L2作为L1的“大型Victim Cache”)。

Intel的某些处理器实现了“Inclusive L2”(包含式L2),L2中包含L1D中的所有行的副本。当一个行从L1D被驱逐时,它在L2中仍然有副本,不会完全丢失。这在功能上等效于一个容量等于L2大小的Victim Cache。AMD的Zen系列则采用了“Victim L3”设计——L3 Cache只存储从L2中被驱逐的行(Exclusive/Victim模式),不与L1D/L2重复,从而最大化了三级Cache的有效总容量。

回顾本章讨论的各项技术,它们都是针对特定的性能瓶颈而发展出来的。面向2030年的处理器设计,存储子系统面临着新的挑战,推动着这些技术的进一步演进。

MSHR设计的前沿发展

随着处理器核心的后端规模持续扩大(ROB 500+项、每周期6\sim8条Load),MSHR设计面临新的挑战和发展方向。

自适应MSHR分区

现代处理器需要在demand请求和预取请求之间动态分配MSHR资源。一种先进的方案是自适应MSHR分区:根据当前的预取精度和MSHR压力动态调整demand/prefetch MSHR的分配比例。

设总MSHR数为MM,当前预取精度为AA。当A>80%A > 80\%时,预取是有效的,可以将更多MSHR分配给预取(如比例为Md:Mp=60%:40%M_d : M_p = 60\% : 40\%);当A<50%A < 50\%时,预取可能有害,将大部分MSHR保留给demand(如Md:Mp=90%:10%M_d : M_p = 90\% : 10\%)。这种自适应分区通过简单的计数器和比较逻辑即可实现。

MSHR压缩

在某些工作负载中,大量的缺失集中在少数几个物理页面内。MSHR压缩(MSHR Compression)利用这一观察,将同一页面内的多个MSHR合并为一个“超级MSHR”——使用页号作为共同的Block Address前缀,只存储各缺失在页面内的偏移量。这可以将有效的MSHR数量增加2\sim4倍而不增加MSHR表的物理大小。

MSHR压缩的实现需要修改MSHR的搜索逻辑:除了精确的行地址匹配(用于正常的缺失合并),还需要页面级的匹配(用于压缩合并)。这增加了CAM比较的复杂度,但在行地址匹配失败时作为回退路径使用,不影响关键路径延迟。

更宽的超标量对多端口Cache的压力

从历史趋势看,超标量处理器的发射宽度在不断增加:从1990年代的4-wide到2010年代的6-wide,再到2020年代的8\sim10-wide。每增加一个Load端口,L1D Cache就需要额外的bank仲裁逻辑和更大的Crossbar。Apple Everest核心已经支持每周期4 Load + 2 Store,据传其后续设计考虑5 Load + 3 Store。在这种趋势下,16个bank可能不再足够——32个bank的设计可能在2028\sim2030年的高端处理器中出现。

但bank数量不能无限增加。每个bank的容量越小,空间局部性对冲突率的缓解效果越弱(因为连续的访问更容易落在同一个小bank中)。当bank容量小到只有几百字节时,单个应用数据结构(如一个cache行大小的结构体)就可能跨越多个bank,增加了bank冲突的可能性。

一种可能的演进方向是分层banking——在行级banking的基础上叠加字级banking,形成二维的bank结构。例如,8个行级bank ×\times 4个字级bank = 32个逻辑bank,但物理上的Crossbar只需要8×\times4的两级结构,比32×\times1的单级结构更紧凑。

另一种方向是部分多端口化——对热点bank使用8T SRAM(提供1读1写的双端口),其他bank使用标准6T SRAM。这种异构banking设计可以在面积开销可控的前提下降低热点bank的冲突率。哪些bank是“热点”可以通过运行时统计来动态确定。

MSHR的功耗与时序优化

MSHR的CAM搜索是其功耗的主要贡献者。每次L1D缺失都需要搜索所有有效的MSHR表项,以确定是否可以进行缺失合并。对于16个MSHR,每次搜索需要16个40位的并行比较,动态功耗约为5 pJ/搜索。在每周期3次L1D访问、5%缺失率的情况下,MSHR搜索的平均功耗为3×0.05×50.75pJ3 \times 0.05 \times 5 \approx 0.75\,\text{pJ}/周期——虽然绝对值不大,但在5 GHz下等效于0.75×5=3.75mW0.75 \times 5 = 3.75\,\text{mW},是L1D Cache总功耗的一个非忽略分量。

降低MSHR搜索功耗的技术包括:

(1)分级搜索(Two-Phase Search):先使用Block Address的低若干位(如低8位)进行粗粒度的预筛选,只对预筛选通过的MSHR表项进行全宽度比较。如果预筛选的假阳性率足够低(如<<10%),大部分搜索只需要激活1\sim2个全宽度比较器而非全部16个。

(2)Bloom Filter预过滤:在MSHR表前放置一个Bloom Filter,用于快速判断一个地址是否“可能”在MSHR中。如果Bloom Filter报告“不在”,则完全跳过MSHR搜索。Bloom Filter的假阳性率可以通过增加哈希函数数量来控制。一个128位、3个哈希函数的Bloom Filter在16个MSHR表项的情况下假阳性率约为3%——意味着97%的L1D命中访问可以完全跳过MSHR搜索,大幅降低平均功耗。

MSHR深度与后端规模的同步增长

随着ROB大小的增长(从256项到512项再到600+项),处理器可以发现的MLP也在增加。这要求MSHR深度同步增长。预计到2030年,L1D MSHR可能达到20\sim24项,L2 MSHR可能达到64\sim96项,以匹配800+项ROB的MLP需求。

MSHR的扩展带来的挑战主要在CAM搜索延迟——24项×\times40位的CAM搜索需要更多的比较器和更深的匹配树,可能需要将MSHR搜索流水线化为2个周期,增加了缺失合并的延迟。一种缓解方案是使用Bloom Filter预筛选——在MSHR入口处放置一个Bloom Filter,对新的缺失地址进行快速预检查:如果Bloom Filter报告“不存在”,则可以跳过CAM搜索直接分配新MSHR;如果报告“可能存在”,才进行完整的CAM搜索。Bloom Filter的延迟只有1\sim2个gate,远低于完整CAM搜索,可以在大多数情况下(新缺失通常不匹配已有MSHR)节省搜索延迟。

前端取指带宽的扩展

随着处理器宽度的增加和代码足迹的持续增长(现代应用的指令足迹可以达到数MB),前端取指带宽也面临压力。可能的演进方向包括:

(1)更大的I-Cache。Apple已经将I-Cache增加到192 KB,Intel和AMD可能在后续架构中跟进。更大的I-Cache降低了缺失率,减少了取指中断。

(2)更大的μ\muop Cache。Intel的Golden Cove有约4096项μ\muop Cache,未来可能增加到8192项以上,进一步提高μ\muop Cache的命中率。

(3)更宽的取指。从32 B/周期增加到64 B/周期(16条ARM指令或更多x86指令),为更宽的后端提供足够的指令供给。

本章小结

本章围绕超标量处理器的访存子系统,讨论了多端口Cache设计、超标量取指和非阻塞Cache三个密切相关的主题。

在多端口Cache方面,分析了三种实现方案的权衡。真正多端口SRAM具有最高的性能(无冲突)但面积和功耗开销巨大,只适用于寄存器文件、TLB等小型存储结构。Cache复制方案通过维护多个副本来提供多端口能力,面积开销与端口数成正比,写同步是其核心难题,Alpha 21264是其经典案例。Multi-banking方案将SRAM划分为多个bank,以bank冲突为代价换取了最优的面积和功耗效率,是现代L1 D-Cache的主流选择——Intel、AMD和Apple的最新处理器均采用8\sim16 bank的设计来支持每周期3\sim4个Load和2个Store的带宽需求。AMD Opteron K8的"Tag复制 + Data分bank"混合设计体现了工程实践中的精妙权衡,这一设计范式沿用至今。VIPT约束进一步影响了bank映射位的选择,是多端口Cache设计中必须考虑的因素。

在超标量取指方面,讨论了取指带宽的需求(每周期32 B\sim64 B)、跨Cache行取指的三种解决方案(对齐取指、双bank I-Cache、行缓冲器预取)、指令对齐网络的设计(定长与变长指令集的差异)、取指缓冲区的结构和深度选择、μ\muop Cache和循环缓冲器对取指带宽瓶颈的缓解,以及FTQ如何实现分支预测与取指的解耦。Macro-op Fusion与取指块边界的交互也是取指阶段需要考虑的实际问题。

在非阻塞Cache方面,详细介绍了MSHR的表项结构(Block Address + State + Target List)、操作流程(分配\to合并\to释放)和缺失合并机制。MSHR深度的选择直接决定了处理器可利用的MLP——L1D通常配置10\sim16个MSHR,L2配置32\sim48个。MSHR与预取器之间的资源竞争需要通过需求优先策略和分组管理来协调。关键字优先和提前开始这两种减少每次缺失有效延迟的技术在早期处理器中价值显著,但随着片上总线宽度的增加,其收益在逐渐减小。最后讨论了写缓冲区和合并写机制对Store操作延迟的吸收和带宽的节省。

回顾本章讨论的各项技术,可以总结出一条贯穿始终的设计原则:在面积、功耗和复杂度的约束下,最大化存储子系统的有效带宽和最小化有效延迟。多端口Cache通过空间并行来提升带宽;非阻塞Cache通过MSHR实现时间并行,将多个缺失的延迟重叠;关键字优先和提前开始则通过优化数据传输顺序来缩短每次缺失的有效延迟;写缓冲区通过解耦Store操作来释放Cache带宽给Load。这些技术并非孤立存在,而是在现代处理器中协同工作——一个典型的高性能L1 D-Cache同时采用了Multi-banking(空间并行)、12\sim16个MSHR(时间并行)、关键字优先(延迟优化)和合并写(写带宽优化),四者共同支撑起超标量处理器每周期5\sim6次访存操作的带宽需求。

面向2030年代的设计,MSHR和非阻塞Cache的演进方向包括:

(1)更深的MSHR。随着ROB大小从512项增长到1024项甚至更大,L1D MSHR可能从当前的12\sim16项增长到24\sim32项,L2 MSHR从32\sim48项增长到64\sim96项。更深的MSHR可以充分利用更大的乱序窗口发现的MLP。然而,更多的MSHR意味着更大的CAM搜索逻辑和更高的搜索延迟,可能需要新的搜索加速技术(如分级搜索或Bloom Filter预过滤)。

(2)MSHR与预取队列的统一。当前的设计中MSHR和预取队列(PQ)是分离的结构。一种可能的演进方向是将两者统一为一个“请求跟踪缓冲区”(Request Tracking Buffer),其中每个表项可以跟踪demand请求或预取请求,并支持请求之间的动态升级(预取升级为demand)和降级。这种统一设计可以减少面积开销并提高资源利用率。

(3)层次化MSHR管理。在chiplet架构中,不同die上的Cache层次需要协调MSHR资源。例如,CCD die上的L2 MSHR耗尽时,可以向IO die上的LLC借用MSHR资源(通过die间互联发送请求)。这种跨die的MSHR共享需要精心设计的协议来管理资源分配和回收。

(4)MSHR压缩与虚拟化。在虚拟机环境中,多个虚拟机共享物理处理器的MSHR资源。MSHR压缩技术可以通过合并同一页面内的缺失请求来增加有效MSHR数量,降低虚拟机之间的资源竞争。

这些技术共同构成了超标量处理器高带宽、低延迟的访存子系统基础。

设计提示

前向桥接。本章解决了单核内Cache的带宽问题。但当多个核心各自拥有私有Cache,并且同时访问同一内存地址时,一个更根本的问题出现了:如何保证所有核心看到的数据是一致的?这就是Cache一致性问题——多核并行的代价中最复杂的一个。第 9.0 章将展示一致性协议如何通过精心设计的状态机和消息传递机制来协调多个私有Cache(回顾第 5.0 章的Cache基本结构),确保共享数据在任何时刻都满足SWMR(Single Writer, Multiple Readers)不变量。一致性协议的验证复杂度之高,使它成为处理器设计中最容易出错、最难调试的子系统。

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