17. 乱序执行(PPro, PII and PIII)
乱序缓存(reorder buffer,简称ROB)可以容纳40条微码。
一条微码呆在ROB中,直到所有它需要的操作数都已就绪并且有一个空的执行单元可用。 这一切使得乱序执行成为可能。
如果一部分代码因为cache不命中被延迟,且之后的代码独立于被延迟的操作,那么后面的代码不会被延迟。
写内存的操作无法乱序执行,其它的写操作都能。 一共有4个写缓存。
因此如果你预计写操作时会有很多cache不命中或者你正在向未命中cache的内存写,那么建议你先一次安排4个写操作,并且保证在下4个写操作之前CPU有其它事情做。
内存读和其它指令一般都能乱序执行,除了IN,OUT和序列化的指令。
如果你的程序向某个内存地址写,不久之后又从同一个地址读,那么读操作会错误地在写操作之前执行,因为乱序执行的时候ROB不能分辨这个内存地址。当写地址被计算的时候错误才被发现,然后读操作(它是被"投机执行"的)必须重做。
惩罚大约是3个时钟周期。 避免它的唯一办法是保证执行单元在写操作和后面的读同一个地址的操作之间有其它事情做。
在五个端口周围有几个成群的执行单元。 端口0和1用于算术运算等。
简单的move,算术和逻辑运算能够进0和1端口的任意一个,就看哪个有空了。 端口0还处理乘法,除法,整型移位和整型循环移位,浮点操作。
端口1还处理跳转和一些MMX,XMM操作。 端口2处理所有的内存读,一些串操作和一些XMM操作。 端口3为内存写计算地址。 端口4执行所有的内存写操作。 在29章你能看到指令产生的微码的完整列表,还指出各个微码进入哪个端口。
注意,内存写操作总是需要两条微码,一条进端口3,一条进端口4。 内存读操作只需要一条微码( 进入端口2 )。
多数情况下,一个端口每个周期接受一条微码。
这意味着一个周期最多可以执行5条微码(如果它们分别进入5个不同的端口)。
然而因为之前的流水线在一个时钟最多产生3条微码,所以不可能平均一个时钟执行3条以上微码。
如果你想维持每个时钟3条微码的吞吐率,那么必须保证没有执行单元接收超过三分之一数量的微码。 用了29章的微码表可以数出进入各个端口的微码数。
如果端口0和1很忙,而端口2空闲,那么你要用MOV register,memory指令取代MOV register,register 或 MOV
register,immediate指令来改进代码,这样可以从端口0和1上移出部分负担到端口2。
大多数微码的执行时间是一个时钟周期。 但乘法,除法和许多浮点运算要用更多:
浮点加法和浮点减法用3个周期,但执行单元是完全流水化的,因此在上一个浮点加/减结束之前,它就能在每个时钟周期接受一个新的FADD或FSUB(当然要基于它们是独立的这个假设)。
整型乘法花4个周期,浮点乘法花5个周期,MMX乘法花3个周期。
整型和MMX乘法是流水化的,可在每个时钟接受一条新指令。
浮点乘法是部分流水化的:执行单元可以在前一个浮点乘法的2个时钟之后接受一个新的FMUL,因此最大吞吐量是每两个时钟一条FMUL。
两条FMUL之间的空洞用整型乘法去填充无济于事,因为它们用同一条电路。 XMM加法用3个时钟,XMM乘法用4个时钟,而且都是完全流水化的。
但因为逻辑上的XMM寄存器在物理上是用两个64位寄存器实现的,你需要两条微码整合一个XMM操作,因此吞吐量是每两个时钟一个XMM操作。
XMM加法和XMM乘法可以并行执行,因为它们用的不是同一个执行单元。
整型和浮点型除法需要39个时钟,而且不是流水化的。 这意味着在前一个除法完成之前,执行单元无法开始新的除法。
对开方和一些超越函数同样如此。
jump,call和return指令也不是完全流水化的。 在跳转后的第一个时钟周期内你不能执行一个新的跳转。
因此jump,call和return指令的最大吞吐量是每两个时钟一条指令。
你当然还应该避免那些产生很多微码的指令。比如LOOP XX指令,应该被替换为:DEC ECX/JNZ XX。
如果你有连续的POP指令,那么你应该把它们"打碎"以减少微码数:
POP ECX / POP EBX / POP EAX ;
可以变成:
MOV ECX,[ESP] / MOV EBX,[ESP+4] / MOV EAX,[ESP] / ADD ESP,12
前者产生6条微码,后者只产生4条微码而且解码更快。
对于PUSH指令用同样的方法就不太好了,因为被"打碎"的代码序列可能产生寄存器读延迟,除非你有其它的指令可以插入或者寄存器最近被重命名过。
对于CALL和RET指令用这个方法也不好,会妨碍返回栈缓存(return stack buffer,简称RSB)的预测功能。
还要注意的就是在早期的处理器中,ADD ESP指令也会引起AGI延迟。