16. 寄存器重命名(PPro,PII和PIII)
16.1 消除依赖
寄存器重命名是这些微处理器采用的高级技术,为了消除不同部分代码的之间的依赖。比如:
MOV EAX, [MEM1] IMUL EAX, 6 MOV [MEM2],EAX MOV EAX, [MEM3] INC EAX MOV [MEM4],EAX
这里的最后三条指令是独立于开始的三条指令的,因为它们不需要前面三条指令的结果。
为了优化它,在早期的处理器中,你必须在后三条指令中不用EAX寄存器,并且调整指令的顺序使得六条指令两两配对。
而 PPro,PII,PIII 处理器已经自动为你做好了这一切。 在每次你写EAX寄存器的时候,它分派一个新的临时寄存器。 因此,MOVEAX,[MEM3]相对前面的指令独立了。 在乱序执行之后,有可能在较慢的指令IMUL完成之前,MOV [MEM4],EAX已经完成了。
寄存器重命名是完全自动的。
每当一条指令写一个永久性的寄存器时,一个新的临时寄存器被当作它的化身般分派。 一条对一个寄存器既读又写的指令也将引发寄存器重命名。 比如前面的INCEAX指令,用了一个临时寄存器来读,另一个临时寄存器来写。 这并不能减少依赖,当然,这对并发的寄存器读有些意义,稍后解释。
所有的通用寄存器,堆栈指针esp,标志寄存器,浮点寄存器,MMX寄存器,XMM寄存器和段寄存器能被重命名。
控制字,浮点状态字不能被重命名,这是因为这些寄存器用起来很慢。 共有40个通用的临时寄存器,因此你不可能用完。
一般将一个寄存器清0的方法是XOR EAX,EAX 或 SUB EAX,EAX。 这些指令不认为依赖于寄存器的原值。 但如果你想消除前面的慢指令造成的依赖,你就用 MOV EAX,0。
寄存器重命名由寄存器化名表(RAT)和乱序缓存(ROB)控制。
从解码器出来的微码经过一个队列进入RAT,然后进入ROB和保留站。
在一个时钟周期RAT只能处理3条微码。这意味着微处理器平均一个周期的总吞吐量不能超过3条微码。
重命名的个数没有特殊限制。
RAT在一个时钟周期内可以重命名三个寄存器,甚至在一个时钟周期内它可以重命名同一个寄存器三次。
16.2 寄存器读延迟
但有一个限制非常严重,那就是在一个时钟周期内你只能读两个不同的永久寄存器名。除了指令中只用于写的寄存器外,对指令中其它用到的寄存器都有这个限制。比如:
MOV [EDI + ESI], EAX MOV EBX, [ESP + EBP]
第一条指令产生两条微码:一个读EAX,一个读EDI和ESI。 第二条指令产生一个微码:读esp和ebp。
ebx不算作读,因为在这个指令中它只被写。 让我们假定这三个微码一起经过RAT。 将用三个字长(WORD)来保存这三个一起经过RAT的连续微码。
因为ROB一个时钟周期只能处理两个永久性寄存器的读,而我们有五个寄存器读,所以在我们的三元组到达保留站之前,被额外地延迟了两个时钟周期。
再比如,三元组里有3或4个寄存器读,那么会有一个周期的延迟。
但在一个三元组内,同一个寄存器被读多次不被计算在内。比如上述代码改为:
MOV [EDI + ESI], EDI MOV EBX, [EDI + EDI]
其实只需要两个寄存器读(ESI,EDI),三元组不会被延迟。
若一个寄存器将被一个尚未知的微指令写,那么为了无代价地获得这个寄存器,它将一直被保存在ROB内直到被写回。
这将消耗3个时钟周期,甚至更多。 写回是最后一个可访问值的执行阶段。 换句话说,在寄存器的值尚不能被执行单元访问的时候,你可以没有延迟地读出RAT中任何寄存器。
这是因为当一个值一旦能被访问,它将被快速地、直接地写到任何需要它的后续的ROB表项。
但如果在需要它的后续微指令进入RAT之前,该值已经被写回临时|永久寄存器,那么这个值只能从只有两个读端口的寄存器文件中读。
从RAT到执行单元有三个流水阶段,因此你可以确定,在一个微码三元组中被写过的寄存器至少能被下个三元组无代价地读。
如果写回动作因为乱序,慢指令,依赖链,cache失效,或其它缘故发生延迟,那么寄存器就能被更靠后的指令流无代价地读。
比如:
MOV EAX, EBX SUB ECX, EAX INC EBX MOV EDX, [EAX] ADD ESI, EBX ADD EDI, ECX
这六条指令各产生一条微指令。让我们假定前三条微指令一起进入RAT。这三条指令读EBX,ECX,EAX。但因为我们对EAX读之前正在对它写,因此这个读是没有代价的,即我们没有延迟。
下三条微指令读 EAX, ESI, EBX, EDI 和 ECX。
虽然在前面的三元组中EAX,EBX,ECX都被改过,但在它们能被无代价访问之前还没被写回,因此只关系到ESI和EDI,我们也没有延迟。
如果第一个三元组的指令SUB ECX,EAX改为CMP ECX,EAX,那么由于ECX没有被写,我们将因为在第二个三元组中读ESI,EDI和ECX发生延迟。
同样地,如果INC EBX改为NOP,或其它指令,那么我们将因为在第二个三元组中读ESI,EBX,EDI而发生延迟。
没有一种微指令能够读两个以上寄存器。因此,任何读两个以上寄存器的指令将被拆分至两条或两条以上微指令。
为了给寄存器读进行记数,你必须统计指令中所有关联的寄存器,包括整形寄存器,标志寄存器,栈指针寄存器,浮点寄存器和MMX寄存器。XMM寄存器看作两个寄存器,除了它们被部分使用,比如ADDSS和MOVHLPS。
段寄存器和指令指针寄存器ip不计在内。 比如,在指令SETZ AL中你应该只计标志寄存器,而不是AL。 ADD
EBX,ECX中,EBX和ECX都要计,但标志寄存器不计,因为它们只被写。 PUSH EAX指令读EAX和ESP,然后写ESP。
FXCH指令是个例外。
它仅仅是重命名,但没有读任何值,因此在寄存器读延迟的规则里它不被计。 FXCH指令的行为好比一个微指令,它既没读,又没写任何牵涉读延迟的规则的寄存器。
不要把微指令三元组和解码组搞混。
一个解码组可以产生1到6条微指令,即使解码组有三条指令,且正好产生三条微指令,也不能保证这三条微指令一起进入RAT。
解码器和RAT之间的队列相当短(只有10条微指令),因此你不能认为寄存器读延迟不会影响解码,也不能认为解码器吞吐量的波动起伏不会对RAT造成延迟。
除非队列为空,否则很难预知哪些微指令一起进入RAT,但对于优化的代码来说,队列只有在分支预测失败后才为空。
同一条指令产生的微指令不一定一起经过RAT,微指令只是在队列中连续地进入,三个一组。
如果是一个预测到的跳转,序列不被打断:在跳转之前和之后的微指令可以一起经过RAT。
只有当一个预测失败的跳转,队列才被清洗,重新开始,因此下三条微指令一定是一起进入RAT。
如果三条连续的微指令读了两个以上的寄存器,你一定情愿它们不要一起进入RAT。
而它们一起进入的可能性是1/3。在同一个微码三元组中读 3 或 4 个已经写回的寄存器,其代价是延迟一个时钟周期。
你可以把这一个时钟的延迟等价地看成在RAT中同时加载三条以上微指令。 由于这三条微指令一起进入RAT的概率是1/3,因此平均代价是3/3=1条微指令。
计算一段代码经过RAT的平均时间的方法是:寄存器读延迟的个数加上微指令的个数,再除以3。 你会发现为了去除延迟,加入一条额外的指令的方法是无济于事的,除非你确实知道哪些微指令一起进入RAT,或者你能通过这条额外的指令阻止超过一个的,潜在的寄存器读延迟的发生。
为了达到一个时钟周期3条微指令的吞吐量的目的,一个时钟里只能读两个永久性寄存器的限制可能是需要处理的一个瓶颈。去除寄存器读延迟的方法如下:
- 将读同一个寄存器的微指令尽量放在一起,使它们进入同一个三元组的概率提高。
- 将读不同寄存器的微指令尽量隔开,使它们无法进入同一个三元组。
- 如果一条指令写或修改某个寄存器,那么在这条指令之后不要放超过3-4个读这个寄存器的三元组微码。
这是为了保证在这个寄存器被读以前尚没有被写回(其中有跳转没关系,只要它被预测到)。
如果你有理由估计到寄存器的写回将被延迟,那么你还能够在更靠后的指令流中安全地(无代价地)读寄存器。
- 用绝对地址代替指针,这样能减少寄存器读的数量。
- 在不会引起延迟的某个三元组中,你可以重命名一个寄存器,这样能在以后的一个或多个三元组中阻止该寄存器的读延迟。
比如:MOV ESP,ESP / ... / MOV EAX,[ESP+8]。
这个方法多了一条额外的微指令,因此它不太值得,除非估计这样做能防止的平均读延迟数大于1/3。
对于产生一条以上微指令的指令,你要知道该指令产生的微指令的顺序,这样就能精确分析寄存器读延迟的可能性。
我在下面列出了大多数普遍的情况。
写内存
内存的写产生两条微指令。第一条( 端口4 )是一个存储操作——读寄存器值并记下。 第二条( 端口3
)计算内存地址,读指针寄存器。比如:
MOV [EDI], EAX
第一条微指令读EAX, 第二条读EDI。
FSTP QWORD PTR [EBX+8*ECX]
第一条读ST(0), 第二条读EBX和ECX。
读\写
一个读内存操作数,通过算术运算或逻辑元算修改寄存器的指令产生两条微指令。第一条( 端口2)是一个读指针寄存器并且读内存指令,第二条是一个算术指令( 端口0或1 ),它读\写目的寄存器,可能写标志寄存器。 比如:
ADD EAX, [ESI+20]
第一条微码读ESI,第二条读EAX,写EAX和标志寄存器。
读\修改\写
读\修改\写指令产生四条微指令。 第一条( 端口2 )读指针寄存器,第二条( 端口0或1)读\写所有的源寄存器,可能写标志寄存器。第三条( 端口4 )只读不计在这里的临时结果。 第四条( 端口3 )再一次读指针寄存器。
因为第一条和第四条微指令不能一起进入RAT,因此你无法利用它们读的是同一个指针寄存器这个事实。 比如:
OR [ESI+EDI], EAX
第一条微指令读ESI和EDI,第二条微指令读EAX,写EAX和标志, 第三条只读临时结果,第四条再次读ESI和EDI。
不管这些微码如何进入RAT,你能保证读EAX的微码与其中一个读ESI和EDI的微码是一起进入的。所以对于这条指令,寄存器读延迟不可避免地发生了,除非这三个寄存器中的一个最近刚被修改过。
寄存器压栈
一条寄存器压栈指令产生三条微指令。第一条( 端口4)是读出寄存器值后记下,第二条读堆栈指针寄存器ESP,产生内存地址,第三条( 端口0或1 )读并修改ESP,减去一个字的大小( 或一个双字 )。
寄存器出栈
寄存器出栈指令产生两条微码。第一条( 端口2)读出ESP,读出内存值,写入寄存器,第二条读并修改栈顶指针,调整栈顶指针。
调用
近调用产生4条微码( 分别进入端口 1, 4, 3, 01)。前两条只读ip,这不能被计,因为它不能被重命名。 第三条读堆栈指针。 第四条读并修改堆栈指针。
返回
近返回产生4条微码( 分别进入端口 2, 01, 01, 1)。第一条读堆栈指针。 第三条读并修改堆栈指针。
如何避免寄存器读延迟的例子在 实例2.6 中给出