【转】苹果的黑魔法?Apple M1的栈操作消除
苹果的黑魔法?Apple M1的栈操作消除(上)

JamesAslan喜欢画画和摄影的硅工码农(滑稽)Luv Letter、琴梨梨OvO 等

前言
访存是微结构设计永恒的难题,这个领域也有着一个传说:部分处理器能够将栈操作消除。ARM架构并没有专门的栈操作指令,那么应该如何定义栈操作呢?我们不妨将其宽泛定义为:访问同一物理地址的store和load指令对(后文中将用栈操作来指代此类情况),栈操作无疑被包含其中。众所周知,苹果M1处理器有着极高的IPC,采用了许多激进的设计来提高指令并行度,那么它能不能消除栈操作呢?让我们来探究一番。
一点小小的剧透:其实最新的x86阵营处理器在这一方面远比苹果激进,它们的故事我们以后再讲;而苹果的实现方式与它们并不相同,这有可能是ARM ISA本身的特性或是实际需求使然。本文探究了Apple M1栈操作消除的实现方式,并揭示了其ICache以及流水线设计的部分细节:
JamesAslan:苹果的黑魔法?(下)Apple M1的栈操作消除81 赞同 · 3 评论文章
一点题外话:在上一篇文章中我们探究了13900ES预取器的反常行为:
JamesAslan:这L2 Cache有点怪,13900ES的BUG还是FEATURE?162 赞同 · 11 评论文章
后来翻阅Intel官方PPT时发现:

看来有可能是一个FEATURE,能够见证行业巨头在面对取舍时的小小窘迫,还是颇有趣味的。
正文
测试构造
首先,我们需要构造一个Probe用的小程序来反应处理器执行栈操作(访问同一物理地址的store和load指令对,后文中将用栈操作来指代此类情况)的性能。从定义入手其实基本结构十分简单,只要一条store指令和一条load指令(将其称为一个指令块)不断循环往复即可,使用汇编构造如下:
str x11, [x10,#0] // x11为store的(值的)源寄存器;[x10,#0]为store的地址,含义为:地址x10+0ldr x12, [x10,#0] // x12为load的目的寄存器;[x10,#0]为load的地址,含义为:地址x10+0
可以看到,这样的栈操作其实可以被迅速完成而不需要通过Cache,因为load的值显然就是store存入的值;倘若处理器在流水线内就获得了load指令的值,我们就将这一操作称为栈操作消除(没有通过Cache获得结果)。
注意ARM汇编中即便同为ldr,也有包括post index在内的多种寻址方式,需要使用正确的汇编。但是很快我们就会发现问题:
str x11, [x10,#0]ldr x12, [x10,#0]------------------str x11, [x10,#0]ldr x12, [x10,#0]------------------str x11, [x10,#0]ldr x12, [x10,#0]
循环执行这样的多个指令块,指令块之间并没有任何依赖,因此块间可以完美并行,这样是无法反应栈操作的性能的。我们不妨将store指令与load指令的值寄存器设置为同一个:
str x11, [x10,#0]ldr x11, [x10,#0]------------------str x11, [x10,#0]ldr x11, [x10,#0]------------------str x11, [x10,#0]ldr x11, [x10,#0]
此时上一个块的load指令的结果会作为下一个块的store指令的值来源,因此倘若上一个块的load指令无法取回数据,下一个块的store指令就无法完成;整个程序通过访存地址相关和寄存器相关串成了一条巨大的相关链,其执行时长即可反应我们需要的栈操作性能。此类测试的其他构建细节在专栏的其他文章内有详细讲解,简而言之,整个程序分为外循环与内循环,内循环为N个指令块(不仅仅使用1个指令块,否则循环相关的指令占比过大,它们不是我们计时的对象,需要通过通过放置N个指令块稀释),外循环为M次(多次执行,使得执行时长足够长,方便计时),则共会执行N*M次指令块;我们将总执行时长除以N*M,再通过频率信息就可以得出每个指令块执行所需的cycle数,以下均以cycle数标识栈操作性能。
//测试程序示意,实际程序为嵌入汇编:for(int i=0;i<M;i++){
//共N组store和load str x11, [x10,#0]
ldr x11, [x10,#0]
...........
str x11, [x10,#0]
ldr x11, [x10,#0]}
基准获取
现在有请本次测试的背景板:ARM Cortex X1,其作为ARM公版的首个超大核微架构,承载了ARM冲击高性能市场的愿景,不妨看看其表现如何。为了排除取指带宽波动的影响,我们根据处理器的微结构信息,在指令块中插入指定数量的nop指令,使得一个cycle理论上只能取指、重命名一个指令块。对于Cortex X1这样的6发射处理器,调整指令块如下使其正好容纳6条指令:
str x11, [x10,#0]nopnopnopnopldr x11, [x10,#0]

由于我们的主要探究对象并不是Cortex X1,其非整结果我们不做深入探究,不妨将其视为4-5cycle。这样的处理器显然没有进行栈操作消除,符合我们对背景板处理器的性能预期,示意流水线时空图如下:

可见,在没有栈操作优化时由于寄存器相关,执行一个指令块所需的时间约为load指令的load-to-use延迟(视具体流水线设计会有区别,影响因素较多,包括但不限于:1.有无store buffer前递。2.store order violation避免机制的设计。)
栈操作消除
既然ARM Cortex X1没有栈操作消除,那么Apple M1呢?针对8发射的Firestorm,我们调整指令块如下:
str x11, [x10,#0]nopnopnopnopnopnopldr x11, [x10,#0]

仅耗时约1.6 cycle!无疑M1拥有栈操作的优化,不愧是ARM阵营的扛把子选手!这样的优化简直可以称之为消除了,倘若load被实际执行,纵使有store buffer的数据前递,其延迟也难以被压缩至1 -2 cycle。

但是故事显然不能仅此而已,不妨看看M1的栈操作消除面对更加复杂的情况表现如何。我们修改指令块,在我们的栈操作store、load对之间加入一条不相关的store指令(操作了不同的地址):
str x11, [x10,#0]str x11, [x10,#8] //str指令操作位宽为64bit,即8字节;偏移量为8时两条str的地址不会有重叠nopnopnopnopnopldr x11, [x10,#0]

略有波动,但是栈操作消除仍然生效,看来M1不会被如此简单的手法欺骗。我们添加两对相互嵌套的操作:
str x11, [x10,#0]str x12, [x10,#8]nopnopnopnopldr x12, [x10,#8]ldr x11, [x10,#0]

仍然是两对栈操作,但是并不完美嵌套:
str x11, [x10,#0]str x12, [x10,#8]nopnopnopnopldr x11, [x10,#0]ldr x12, [x10,#8]

可以看到虽然消除效果逐渐变差,但是仍然存在明显的栈操作消除现象。我们不再继续增加一个指令块中的栈操作对数,因为M1理论上每周期只能执行2条load指令与2条store指令(其实即便增加到3对,栈操作消除的效果仍然明显存在,但是为了数据的美观性不予展示)。
瑕疵初现
一个机制不可能面面俱到,我们肯定能够通过精心构造指令对使其失效;不过在此之前,我们还得请出老朋友:perf stat工具。如果要在M1平台上使用perf工具,为其安装Arch linux是较为方便的选择;只要学会正确指定核心+事件号,其体验与x86平台基本无异。但是如果不想覆盖原操作系统,MacOS上也是可以使用性能计数器的,只是过程相对曲折;本次我在个人日用的Macbook air上使用MacOS进行测试。苹果显然对于自己的性能计数器信息较为忸怩,我们首先要自己从系统目录中获取性能计数器的描述。通过一些手段,我们可以找到a14.plist、a15.plist两个文件,M1使用的是与a14近乎相同的Firestorm,因此我们参考a14.plist中的信息。实际上这两个文件内容近乎一模一样,可见两代处理器的性能计数器部分并无明显变动(或者说开发者可以访问的性能计数器部分并无明显改动)。从中我们挑选出所需的计数器如下:
Event NameEvent DiscriptionFIXED_CYCLESFIXED_INSTRUCTIONSST_MEMORY_ORDER_VIOLATION_NONSPECRetired stores that triggered memory order violationsFLUSH_RESTART_OTHER_NONSPECPipeline flush and restarts that were not due to branch mispredictions or memory order violationsLD_UNIT_UOPUops that flowed through the Load UnitST_UNIT_UOPUops that flowed through the Store UnitMAP_STALLCycles while the Map Unit was stalled for any reasonMAP_STALL_DISPATCHCycles while the Map Unit was stalled because of Dispatch back pressure
M1中提供6个可配置性能计数器,足够我们的使用了。接着,我们需要一些手段来访问这些计数器。好消息是实际上MacOS提供了类似的接口,即Kperf动态库;坏消息是这个库并不对外开放;好消息是前人通过逆向工程破解了其信息。然后,我们只需要调用Kperf即可读取所需的计数器;这里使用前人大佬ibireme的框架,将我们的测试程序作为profile_func嵌入其中,运行效果如下:

我们终于可以一窥处理器的运行状态,可见store-order-violation predictor工作得很好,运行过程中仅有极少的访存序引起的回滚。重命名堵塞较多且均由发射队列(对于Apple的设计而言,其实是第一级的分派队列)的反压造成;因为我们的指令块平均需要1.6周期才能执行完成,但是处理器前端每周期都能取回1个指令块,因此大量的访存指令堆积在分派队列和发射队列中,最终导致了重命名阶段堵塞。一切都符合预期。
至此,我们终于可以给栈操作消除机制找点麻烦了。栈操作消除可以基于两种较为简单的思路:
基于静态信息,例如操作数的源寄存器号。倘若一对store与load指令用于计算地址的源寄存器和偏移都相同,那我们显然可以推测store与load访问了同一地址,并对它们进行消除。消除的方式多种多样,较为简单的即直接通过重命名机制,将load指令的目的寄存器重命名为store指令的值的源寄存器;但是为了确保这样的消除是正确的,load指令仍然需要被执行,以验证地址的正确性以及它们真的相等,因此只是消除了执行延迟而并非整个指令的执行。
基于历史信息,例如指令PC。倘若一对store与load指令曾经访问过相同的物理地址,那我们将它们标记为可能再次访问同一地址,并对它们进行消除。消除的方式可以与上一点近似,同样其也需要被执行来验证正确性。
在具体实现的过程中,无论是上述哪种方式都会遇到各种各样的细节问题,例如如何记录和存储store指令的信息、使用逻辑寄存器号还是物理寄存器号等,下文中我们再详细讨论。
我们不妨尝试在不改变指令块大小和内容的前提下,改变内部指令的排布,使得store与load指令不再分布于指令块的两端:
str x11, [x10,#0]nopnopnopnopnopldr x11, [x10,#0]nop

有明显的栈操作消除,现象和之前相比似乎并没有什么变化。我们更进一步,再将load指令向上移动一个槽位:
str x11, [x10,#0]nopnopnopnopldr x11, [x10,#0]nopnop

异常出现了,栈操作消除的效果仍然存在,因为其执行时长小于load-to-use延迟,但是又明显高于之前的延迟,这说明栈操作消除并非百分之百成功。不是每一对store-load指令都被消除,而是呈一定概率被消除,进而将load-to-use延迟与栈操作消除的延迟按一定比例混合了;而在这种情况下,栈操作消除的成功率下降了。我们继续将load指令向上移动,直至与store指令紧邻:
str x11, [x10,#0]nopnopnopldr x11, [x10,#0]nopnopnop

str x11, [x10,#0]nopnopldr x11, [x10,#0]nopnopnopnop

str x11, [x10,#0]nopldr x11, [x10,#0]nopnopnopnopnop

str x11, [x10,#0]ldr x11, [x10,#0]nopnopnopnopnopnop

我们可以观察到几个异常现象:
随着store与load指令的间距越来越小,栈操作消除的成功率越来越低。
当store与load指令紧邻时,栈操作消除机制近乎完全失效(不发生)。
load每上移2次,栈操作消除的成功率才变化一次。
这三个现象包含了海量的信息。我们可以由此推测,苹果的栈操作消除是基于静态操作数信息的消除,并未使用历史信息;而且并未优化当store与load指令在同一周期被译码和重命名的情况。
已知随着store与load指令间距缩小栈操作消除的成功率变低,说明栈操作消除的判断机制与指令的槽位有关。
由1推演可知,指令的槽位实际上代表了指令在Cache行内的位置,不同的位置会使得它们进入处理器后位于指令包内的不同槽位。
由于我们构造的指令块总是由nop指令填充至8条(恰好是M1每周期能够取指和译码的指令数量),我们不妨将一个指令块视作处理器流水线内每周期得到的指令包。由于nop指令在译码级才会被消除,因此每个指令包在到达译码级时仍然为8条指令;经过译码级后只剩下store与load指令需要重命名。
由3推演可知,在流水线未被塞满时,每周期我们能够向处理器的后端填入1条store指令和1条load指令(nop指令在译码级被消除,无需被真正执行,直接标记等待提交即可);而即便在理想情况下(1.6 cycles per block)栈操作消除也无法使我们每周期执行一个指令块(1 store + 1 load),因此处理器后端在经过一定时间后会由于访存队列满而产生反压;反压进而导致发射队列和分派队列也被填满产生反压;最终导致流水线停顿(重命名级和更早的流水级阻塞),性能计数器的数值印证了这一点:重命名级经历了相当数量的停顿,约1/3的总cycle数;且基本100%来自分派队列的反压。

5. 由4推演可知,一旦处理器进入阻塞状态,由于每周期只能执行并提交平均1.6条指令(不妨以理想状态推演)即1-2条指令,可以预见重命名级每周期也只能向分派队列发送1-2条指令。同时由3已知重命名级最多只会同时存在2条指令,因为nop指令在进入重命名级前被消除了。
6.已知重命名级在被清空前无法接受下一个指令包,否则维护指令序关系异常困难。
7. 由1、2、5、6推演可知,M1的译码级支持每周期向重命名级送入少于一个指令包的指令。否则,无论指令位于指令包的哪个槽位,对于译码和重命名级都是等价的,不会造成栈操作消除效果的波动。
8. 已知load每上移2次,栈操作消除的成功率才变化一次。
9. 由7和8推演可知,M1的译码级最小支持以2条指令为粒度向重命名级发送指令,槽位0-1、2-3、4-5、6-7可以分别进入后续流水级直至所有指令清空,随后译码级接受下一个指令包。
综上可知,store与load指令的间隔大小与它们同时进入重命名级的概率密切相关,间隔越小同时进入重命名级的概率越高,而同时进入重命名级意味着栈操作消除失败。由于译码级只支持槽位0-1、2-3、4-5、6-7分别进入后续流水级,因此在槽位移动小于2时对处理器而言是等价的,我们不会观察到栈操作消除成功率的变化;同时也意味着一旦store与load指令紧邻,它们近乎一定会同时进入重命名级,即一定会使栈操作消除失效。
至此,我们发现了苹果M1栈操作消除存在的证据;初步推演了M1栈操作消除机制失效的原因。那么为何store与load指令同时进入重命名级会使栈操作消除机制失效呢?我们下篇再见,届时将还原M1栈操作消除的实现细节,并揭示M1 ICache和流水线内设计的冰山一角。

(太长了,字数太多,只好忍痛分成两篇水了)
编辑于 2023-02-04 11:26苹果的黑魔法?(下)Apple M1的栈操作消除

JamesAslan喜欢画画和摄影的硅工码农(滑稽)

回顾
在上篇中我们发现了M1有着激进的栈操作消除机制,但是其并不完美在部分情形下不能生效;我们由此分析和推演了其失效的原因,并猜想了部分实现细节。本篇我们将还原M1栈操作消除的实现细节,并揭示M1 ICache和流水线内设计的冰山一角。
苹果的黑魔法?Apple M1的栈操作消除(上)327 赞同 · 19 评论文章
正文
为何store与load指令同时进入重命名级会使栈操作消除失效呢?这与栈操作消除的实现机制密切相关。
栈操作消除可以基于两种较为简单的思路: 1. 基于静态信息,例如操作数的源寄存器号。倘若一对store与load指令用于计算地址的源寄存器和偏移都相同,那我们显然可以推测store与load访问了同一地址,并对它们进行消除。消除的方式多种多样,较为简单的即直接通过重命名机制,将load指令的目的寄存器重命名为store指令的值的源寄存器;但是为了确保这样的消除是正确的,load指令仍然需要被执行以验证地址的合法性以及它们真的相等,因此只是消除了执行延迟而并非整个指令的执行。 2. 基于历史信息,例如指令PC。倘若一对store与load指令曾经访问过相同的物理地址,那我们将它们标记为可能再次访问同一地址,并对它们进行消除。消除的方式可以与上一点近似,同样其也需要被执行来验证正确性。
无论使用了哪种思路,栈操作消除机制都由两个基本部分组成:消除识别器(以下简称识别器)和信息暂存器(以下简称暂存器)。上述两种思路的区别主要体现在识别器上,基于静态信息的识别器与暂存器高度耦合,其能使用的信息全部来自于暂存器内存储的内容;而基于历史信息的识别器则可以利用其本身存储的执行历史信息,这些执行历史信息可以以PC作为索引,记录曾经访问过相同物理地址的store与load指令,能够提供比静态信息更多的消除机会。
方案对比
对于基于静态信息的栈操作消除机制,同时进入重命名级的store与load指令对会极大考验其设计。因为只有当store指令的信息被存储进入暂存器后,识别器才能尝试将后续load与先前的store信息进行匹配,而存储这个操作是需要一整个时钟周期去完成的。倘若需要消除同一个指令包内的store与load指令对,就要在译码或重命名级进行指令间的两两复杂检查,这种检查的复杂度会随着处理器宽度的增长指数增长。苹果在近些年的确有类似的专利,但是从实测结果看(见上篇的测试)M1并没有实装这些专利。
对于基于历史信息的栈操作消除机制,同时进入重命名级的store与load指令相对容易处理。因为识别器可以根据存储的PC提前识别可能的store与load指令,不需要指令包内指令间的两两检查而是一对一的PC比较。但是从上篇的实测结果来看,M1对于同时进入重命名级的store与load指令近乎毫无招架之力,不像是使用了这一方案,后文我们使用测试来进行排除。
假设M1使用了基于静态信息的栈操作消除机制,暂存器内会存储什么信息呢?我们需要两大类信息:
地址信息,即store指令访问了什么地址。由于不参考历史信息,我们无从得知物理地址信息仅有虚地址信息;而虚地址由base+offset生成,base与offset分别来自寄存器号与立即数。那么结果似乎显而易见了,我们需要存储用于生成store指令访问地址的寄存器号和立即数;但是寄存器号又有物理寄存器号(经过重命名后的硬件内部使用的寄存器)和逻辑寄存器号(代码中可见的ISA规定的寄存器)之分,M1到底使用了什么下文我们测试后才能确定。
值信息,即store指令存储了什么内容。为了在重命名时消除load指令,我们必须将store指令的值来源物理寄存器直接传递给load指令,并将load指令的结果寄存器重命名为该物理寄存器。但是暂存器未必直接存储了物理寄存器号,其仍然可以只存储逻辑寄存器号,并在load指令进入重命名级后让其读取暂存的逻辑寄存器号对应的物理寄存器号;代价就是,一旦store与load指令间有任何其他指令修改过被暂存的逻辑寄存器,那么重命名表的内容就会被更新,load指令就不能通过前述方式获得正确的物理寄存器号,进而导致栈操作消除失败。暂存器存储物理寄存器号需要读取重命名表,因此设计会更加复杂;相应的,能够覆盖的栈操作消除情况也会变多。M1到底使用了什么下文我们测试后才能确定。
有了这么多的猜想和疑惑,我们现在开始一一测试。

同时进入流水线的影响几何?
我们不断提及,同时进入重命名级的store与load指令会导致栈操作消除失败;但是即便是此前测试过的在同一指令包中store与load相距最远的情况(如下),也有相当概率的消除失败(因为cycles per block为1.6,非整数)。
str x11, [x10,#0]
nop
nop
nop
nop
nop
nop
ldr x11, [x10,#0]

我们必须尝试构建一种极端情况:在指令块指令数量不变(8条)的情况下,使得同一指令块内的store指令和load指令不在同一周期进入流水线,即这两条指令不位于流水线内的同一指令包内。我们观察此时的反汇编:
100004500: 0b 01 00 f9 str x11, [x8] //内循环开始
100004504: 1f 20 03 d5 nop
100004508: 1f 20 03 d5 nop
10000450c: 1f 20 03 d5 nop
100004510: 1f 20 03 d5 nop
100004514: 1f 20 03 d5 nop
100004518: 1f 20 03 d5 nop
10000451c: 0b 01 40 f9 ldr x11, [x8]
每一个指令块被放置于32Byte对齐的位置,即每一个指令块的头指令恰好位于cache行的开头或一半;这是十分有利于取指的排布。但是有利于取指也就意味着它们总是作为一个指令包被取入流水线内,这恰恰是我们不希望见到的;我们不妨让其排布不再如此对齐,进而恰好使一个指令块的8条指令无法被同时取入流水线。我们在被测指令前加入一条无用的占位指令,破坏cache行的排布:
100004500: 8a 01 00 91 add x10, x12, #0 //占位指令
100004504: 0b 01 00 f9 str x11, [x8] //内循环开始
100004508: 1f 20 03 d5 nop
10000450c: 1f 20 03 d5 nop
100004510: 1f 20 03 d5 nop
100004514: 1f 20 03 d5 nop
100004518: 1f 20 03 d5 nop
10000451c: 1f 20 03 d5 nop
100004520: 0b 01 40 f9 ldr x11, [x8]

性能大幅提升!可见在这种状态下栈操作消除的成功率近乎完美。这说明了两个问题:
同时进入重命名级的store与load指令确实会导致栈操作消除失败。
M1的取指并不能从ICache行内的任意位置开始。
我们着重说明第二点。倘若M1的取指能够从ICache行内的任意位置开始,那么在外循环第一次回跳到内循环开始处时,无论指令块是否按照Cache行对齐,对于处理器而言都是等价的;因为处理器可以获得在第n行的前7条指令和第n+1行的第8条指令(行内一半位置时同理)。但是测试结果明显体现了不等价性,这里的不等价性也意味着M1的ICache分bank粒度并未到单个4 Byte指令的粒度。那么M1的ICache bank粒度到底是多大呢?我们不妨再插入一条占位指令:
100004500: 8a 01 00 91 add x10, x12, #0 //占位指令
100004504: 8a 01 00 91 add x10, x12, #0 //占位指令
100004508: 0b 01 00 f9 str x11, [x8] //内循环开始
10000450c: 1f 20 03 d5 nop
100004510: 1f 20 03 d5 nop
100004514: 1f 20 03 d5 nop
100004518: 1f 20 03 d5 nop
10000451c: 1f 20 03 d5 nop
100004520: 1f 20 03 d5 nop
100004524: 0b 01 40 f9 ldr x11, [x8]

性能又衰退回到了最初没有插入占位指令的水平。这意味着这种指令排布对于处理器而言与未插入占位指令时是等价的;即处理器在跨行时也可以取到同一指令块的8条指令(在第n行的末尾获得前6条指令,在第n+1行的开头获得第7、8条指令);所以,M1的ICache bank粒度为2条指令即8 Byte。这一粒度对于ICache而言已经十分细腻了,作为参考AMD Zen 4的取指粒度为16 Byte。
基于历史or基于静态信息?
从前文的实测结果来看,M1对于同时进入重命名级的store与load指令近乎毫无招架之力,不具有使用了历史信息来帮助栈操作消除的特征,我们还可以尝试从另一个角度验证这一点。任何记录历史信息的硬件表项都必然是容量有限的;在使用PC进行识别时,如果需要消除的PC不同的store与load指令对过多,栈操作消除就会失效(表项被占满后相互挤兑,导致所有PC都不能被正常识别,因为它们在再次被执行前就会被其他PC替换出表)。在上篇测试构建部分我们提及测试代码分为内循环与外循环,只要我们增加内循环N的数量,即相当于增加了不同PC的store与load指令对。
//测试程序示意,实际程序为嵌入汇编:
for(int i=0;i<M;i++){
//共N组store和load
指令块0
指令块1
...........
指令块N-2
指令块N-1
}
指令块内容方面,我们继续使用M1能够较好消除的间隔最远的模式,并插入一个占位指令(见上一小节)破坏Cache行排布:
str x11, [x10,#0]
nop
nop
nop
nop
nop
nop
ldr x11, [x10,#0]
不断增加内循环数量(即使用的store与load指令对的数量):
//20对
cycles per block: 1.042
//50对
cycles per block: 1.032
//100对
cycles per block: 1.015
//500对
cycles per block: 1.025
//1000对
cycles per block: 1.002
//5000对
cycles per block: 1.003
可见栈操作消除机制完美生效,因此海量的不同PC并没有导致某种硬件表结构溢出,我们可以基本确定M1仅使用了静态信息来进行栈操作消除。
实际上,M1的store order violation predictor也仅有70项左右的容量,存在超过5000项的栈操作消除历史表是与前者矛盾的。(store order violation predictor 并非本篇内容因此这里不做解释)

物理寄存器号or逻辑寄存器号?(值信息)
值信息,即store指令存储了什么内容。为了在重命名时消除load指令,我们必须将store指令的值来源物理寄存器传递给load指令,并将load指令的结果寄存器重命名为该物理寄存器。但是暂存器未必直接存储store的物理寄存器号,其仍然可以只存储逻辑寄存器号,并在load指令进入重命名级后让其自行查找与暂存的逻辑寄存器号对应的物理寄存器号;代价就是一旦store与load指令间有任何其他指令修改过暂存逻辑寄存器,重命名表的内容就会被更新,load指令就不能通过前述方式获得正确的物理寄存器号,进而导致栈操作消除失败。那么我们可以在store与load指令间插入一条修改store指令值来源寄存器的指令(不妨记为指令A),来让栈操作消除失败。为了确保指令A在load指令进入重命名级前就修改完成重命名表,需要让指令A提前于load进入流水线。我们将指令块扩展至16条指令,理论上它需要两个周期才能被取指:
str x11, [x10,#0]
nop
nop
nop
nop
nop
nop
nop
// 以上为第一个周期的取指内容;以下为第二个周期的取指内容
nop
nop
nop
nop
nop
nop
nop
ldr x11, [x10,#0]

栈操作消除完美工作,2周期的执行时长已经等于理论最短执行延迟,load-to-use的延迟完全不可见。此时加入指令A,并使其先于load一个周期进入流水线:
str x11, [x10,#0]
nop
nop
nop
nop
nop
nop
add x11, x10, #0
// 以上为第一个周期的取指内容;以下为第二个周期的取指内容
nop
nop
nop
nop
nop
nop
nop
ldr x11, [x10,#0]

栈操作消除完美工作,因此M1在值信息方面应该使用了物理寄存器号,暂存器内很可能存储了store指令值信息的物理寄存器号;这样的设计十分合理,毕竟栈操作之间程序很可能进行了复杂的行为,需要保证值信息寄存器不被修改十分不合理。这样的设计下暂存器本身很可能被放置在了重命名级,以便利用现有的重命名表读端口。
物理寄存器号or逻辑寄存器号?(地址信息)
地址信息,即store指令访问了什么地址。由于不参考历史信息,我们无从得知物理地址信息,仅有虚地址信息;而虚地址由base+offset生成,base与offset分别来自寄存器号与立即数。暂存器内存储的是物理寄存器号还是逻辑寄存器号呢?我们不妨将store与load指令的base寄存器更换为不同的逻辑寄存器号,但是它们的内容相同,并且在初始化时由同一逻辑寄存器move而来;这样虽然store与load的base逻辑寄存器号不同,但是经过重命名后它们的物理寄存器号是一样的,倘若此时栈操作消除还能正常工作,则M1是通过物理寄存器号识别可以消除的栈操作。首先我们不加入mov指令:
str x11, [x10,#0]
nop
nop
nop
nop
nop
nop
ldr x11, [x8,#0] //注意:x8的值与x10相同,即store与load仍然访问了同一地址

栈操作消除机制不在工作,我们加入mov指令:
mov x8,x10 //注意:x10对应的物理寄存器被同时复制给了x8
str x11, [x10,#0]
nop
nop
nop
nop
nop
nop
ldr x11, [x8,#0] //注意:x8的值与x10相同,即store与load仍然访问了同一地址

栈操作消除机制仍然不在工作,因此M1在地址信息方面使用的是逻辑寄存器号;这一选择也十分合理,使用物理寄存器号进行识别匹配意味着需要在重命名级之后才能进行,M1较短的流水线不便使用类似设计,否则会带来巨大的时序压力(处理器频率无法提高)。
能否对地址进行简单运算?
我们在store与load指令间假意修改用于生成访存地址的base寄存器:
str x11, [x10,#0]
nop
nop
nop
mov x10, x10 // 实际上并没有改变x10的值
nop
nop
ldr x11, [x10,#0]

栈操作消除立即失效,因此M1的栈操作消除机制不能容忍栈操作之间对地址的任何操作。
能否识别虚假的栈操作?
我们在栈操作指令(访问地址A,则其修改的地址空间为A:A+8字节)之间,加入一条store指令(不妨记为指令B)且访问地址A+4(则其修改的地址空间为A+4:A+12字节);这样load指令实际上会受到指令B的影响,其需要从地址A:A+4取回第一条store存入的内容,从地址A+4:A+8取回指令B存入的内容;倘若使用栈操作消除机制直接将第一条store的值传递给load,就会导致错误:
str x11, [x10,#0]
str x12, [x10,#4] // 地址偏移了4字节,与栈操作的地址部分重叠却不相等
nop
nop
nop
nop
nop
ldr x11, [x10,#0]


可以看到不但栈操作消除不在生效,而且出现了大量的流水线刷新,该性能计数器事件排除了store order violation和分支误预测,那么显然就是栈操作消除误判产生的回滚。因此M1不能识别部分不该进行栈操作消除的场景,处理器在验证后会因此刷新流水线。(其进行了栈操作消除,但是在后续验证过程中发现了错误,因此刷新流水线回滚并重新执行load)
后记
M1的栈操作消除机制让人大开眼界,但最新的x86阵营处理器在这一方面远比苹果激进(它们的故事我们以后再讲);不过苹果的实现方式与它们并不相同,这有可能是ARM ISA本身的特性或是实际需求使然。从测试中看M1的相关机制还远算不上完善,仍然有较多的corner case亟待优化。希望苹果早日结束挤牙膏操作,重回业界搅局者的角色,Ultra已来Extreme何在?

编辑于 2023-02-04 11:26