6.8. 部分寄存器暂停
部分寄存器暂停发生在我们写入一个32位寄存器部分,随后读整个寄存器或更大的部分时。例如:
mov al, byte ptr [mem8]
mov ebx, eax ; Partial register stall
这给出了5-6个时钟周期的时延。原因是临时寄存器已经被分配给AL,使它与AH无关。执行单元必须等待,直到对AL的写被回收,才可能将AL的值合并到EAX余下部分的值。可以修改代码,避免这个暂停:
; Example 6.10b. Partial register stall removed
movzx ebx, byte ptr [mem8]
and eax, 0ffffff00h
or ebx, eax
当然,通过将其他指令放在部分寄存器写之后,使得你在读整个寄存器之前,有时间回收,也可以避免这个部分暂停。
你应该知道只要混合不同的数据大小(8,16及32位),部分暂停就会发生:
; Example 6.11. Partial register stalls
mov bh, 0
add bx, ax ; Stall
inc ebx ; Stall
在写整个寄存器或更大的部分之后读部分寄存器,暂停不会发生:
; Example 6.12. Partial register stalls
mov eax, [mem32]
add bl, al ; No stall
add bh, ah ; No stall
mov cx, ax ; No stall
mov dx, bx ; Stall
避免部分寄存器暂停最简单的方法是,总是使用完整的寄存器,在读更小的内存操作数时,使用MOVZX或MOVSX。在PPro,P2与P3上,这些指令是快的,但在更早的处理器上是慢的。因此,在你希望代码在所有的处理器上都有合理的性能时,需要进行折衷。像这样替换MOVZX EAX, BYTE PTR [MEM8]:
; Example 6.13. Replacement for movzx
xor eax, eax
mov al, byte ptr [mem8]
对这个组合,PPro,P2与P3处理器有一个特殊情形,在后面从EAX读的时候,避免部分寄存器暂停。这个技巧是,一个寄存器在与自己XOR时被标记为空。处理器记住EAX的高24位是0,因此可以避免部分寄存器暂停。这个机制仅在特定的组合中奏效:
; Example 6.14. Removing partial register stalls with xor
xor eax, eax
mov al, 3
mov ebx, eax ; No stall
xor ah, ah
mov al, 3
mov bx, ax ; No stall
xor eax, eax
mov ah, 3
mov ebx, eax ; Stall
sub ebx, ebx
mov bl, dl
mov ecx, ebx ; No stall
mov ebx, 0
mov bl, dl
mov ecx, ebx ; Stall
mov bl, dl
xor ebx, ebx ; No stall
通过减去自己把寄存器置为0,与XOR效果相同,但使用MOV指令把它置为0不能防止暂停。
我们可以在一个循环外设置XOR:
; Example 6.15. Removing partial register stalls with xor outside loop
xor eax, eax
mov ecx, 100
LL: mov al, [esi]
mov [edi], eax ; no stall
inc esi
add edi, 4
dec ecx
jnz LL
处理器记住EAX的高24位是0,只要你没有得到一个中断、误预测或其他串行事件。
在调用可能压栈整个寄存器的例程之前,你应该记住将所有部分寄存器置0(neutralize):
; Example 6.16. Removing partial register stall before call
add bl, al
mov [mem8], bl
xor ebx, ebx ; neutralize bl
call _highLevelFunction
许多高级语言例程在一开始就将EBX压栈,在上面的例子中,如果你没有将BL置0,这会产生一个部分寄存器暂停。
在PPro,P2,P3与PM上,使用XOR方法将一个寄存器置0,不会打破其对更早指令的依赖(但在P4上会)。例如:
; Example 6.17. Remove partial register stalls and break dependence
div ebx
mov [mem], eax
mov eax, 0 ; Break dependence
xor eax, eax ; Prevent partial register stall
mov al, cl
add ebx, eax
这里将EAX置0两次看起来是多余的,但没有MOV EAX, 0,最后的指令将不得不等待慢的DIV完成,而没有XOR EAX, EAX,将有一个部分寄存器暂停。
指令FNSTSW AX是特殊的:在32位模式中,其行为就像写入整个EAX。事实上,在32位模式里,它所做的类似这样:
; Example 6.18. Equivalence model for fnstsw ax
and eax, 0ffff0000h
fnstsw temp
or eax, temp
因此,在32位模式中,在这条指令后读EAX时,不会有一个部分寄存器暂停:
; Example 6.19. Partial register stalls with fnstsw ax
fnstsw ax / mov ebx,eax ; Stall only if 16 bit mode
mov ax,0 / fnstsw ax ; Stall only if 32 bit mode
部分标记暂停
标记寄存器也会导致部分寄存器暂停:
; Example 6.20. Partial flags stall
cmp eax, ebx
inc ecx
jbe xx ; Partial flags stall
指令JBE读进位标记与零标记。因为INC指令改变零标记,但不改变进位标记,JBE指令必须等前两条指令回收后,才能合并来自CMP指令的进位标记以及来自INC指令的零标记。在汇编代码中,这个情形很可能是一个bug,而不是一个有目的的标记组合,要改正它,将INC ECX改为ADD ECX, 1。一个导致部分标记暂停的类似的bug是SAHF / JL XX。JL指令测试符号标记以及溢出标记,但SAHF不改变溢出标记。要改正它,将JL XX改为JS XX。
出乎意料地(与Intel手册所说的相反),在一条修改了某些标记位的指令后,在读未修改标记位时,我们也遇到了部分标记暂停:
; Example 6.21. Partial flags stall when reading unmodified flag bits
cmp eax, ebx
inc ecx
jc xx ; Partial flags stall
但在读修改标记时不会:
; Example 6.22. No partial flags stall when reading modified bits
cmp eax, ebx
inc ecx
jz xx ; No stall
部分标记暂停有可能发生在读许多或所有标记位的指令上,比如LAHF,PUSHF,PUSHFD。以下指令在后接LAHF或PUSH(D)时,导致部分标记暂停:INC,DEC,TEST,比特测试,比特扫描,CLC,STC,CMC,CLD,STD,CLI,STI,MUL,IMUL,以及所有的偏移与旋转。以下指令不会导致部分标记暂停:AND,OR,XOR,ADD,ADC,SUB,SBB,CMP,NEG。很奇怪TEST与AND行为不同,根据定义,它们对标记做同样的事。为了避免暂停,你可以使用SETcc指令替代LAHF或PUSHF(D),来保存一个标记的值。
例如:
; Example 6.23. Partial flags stalls
inc eax / pushfd ; Stall
add eax,1 / pushfd ; No stall
shr eax,1 / pushfd ; Stall
shr eax,1 / or eax,eax / pushfd ; No stall
test ebx,ebx / lahf ; Stall
and ebx,ebx / lahf ; No stall
test ebx,ebx / setz al ; No stall
clc / setz al ; Stall
cld / setz al ; No stall
部分标记暂停的代价大约是4个时钟周期。
偏移与旋转后的标记暂停
在一次偏移或旋转后,会得到一个相似的部分标记暂停,除了偏移或旋转一位(短形式):
; Example 6.24. Partial flags stalls after shift and rotate
shr eax,1 / jz xx ; No stall
shr eax,2 / jz xx ; Stall
shr eax,2 / or eax,eax / jz xx ; No stall
shr eax,5 / jc xx ; Stall
shr eax,4 / shr eax,1 / jc xx ; No stall
shr eax,cl / jz xx ; Stall, even if cl = 1
shrd eax,ebx,1 / jz xx ; Stall
rol ebx,8 / jc xx ; Stall
这些暂停的代价大约是4个时钟周期。
6.9. 写转发暂停
写转发暂停有点类似于部分寄存器暂停。在对同一内存地址混合使用不同数据大小时发生:
; Example 6.25. Store-to-load forwarding stall
mov byte ptr [esi], al
mov ebx, dword ptr [esi] ; Stall. Big read after small write
一个小的写之后大的读阻碍写到读转发,这个代价是大约7 – 8个时钟周期。
不像部分寄存器暂停,在向内存写一个操作数,然后读它的一部分,如果这个较小的部分不是在同一个地址开始,你也会有写转发暂停:
; Example 6.26. Store-to-load forwarding stall
mov dword ptr [esi], eax
mov bl, byte ptr [esi] ; No stall
mov bh, byte ptr [esi+1] ; Stall. Not same start address
通过把最后一行改为MOV BH, AH,可以避免这个暂停,但这样一个解决方案在像这样的情形中是不可能的:
; Example 6.27. Store-to-load forwarding stall
fistp qword ptr [edi]
mov eax, dword ptr [edi]
mov edx, dword ptr [edi+4] ; Stall. Not same start address
有趣的,在读写不同的地址时,如果它们恰好在不同的缓存库(cache bank)中有相同的组值,会得到一个伪写转发暂停:
; Example 6.28. Bogus store-to-load forwarding stall
mov byte ptr [esi], al
mov ebx, dword ptr [esi+4092] ; No stall
mov ecx, dword ptr [esi+4096] ; Bogus stall
6.10. PPro,P2,P3中的瓶颈
在为这些处理器优化代码时,分析瓶颈在哪是重要的。如果一个瓶颈更窄,花时间优化掉另一个瓶颈是不合理的。
如果预期代码缓存不命中,你应该重构代码将代码大多数使用的部分维系一起。
如果预期许多数据缓存不命中,那么忘掉其他一切,专注如何重构代码,减少缓存不命中的数量(第2页),在一次数据读缓存不命中后,避免长的依赖链。
如果有许多除法,那么尝试如手册1“优化C++代码”与手册2“优化汇编例程”里描述的那样减少它们,确保在除法期间处理器有事可做。
依赖链倾向于阻碍乱序执行。尝试打破长的依赖链,特别是如果它们包含慢的指令,比如乘法、除法以及浮点指令。参考手册1“优化C++代码” 与手册2“优化汇编例程”。
如果有许多跳转、调用或返回,特别是如果跳转的可预测性很差,尝试避免其中一些。如果不会增加依赖性的话,使用条件移动替换可预测性差的条件跳转。内联小的例程(参考手册2“优化汇编例程”)。
如果混合使用不同的数据大小(8,16及32位整数),小心部分暂停。如果使用PUSHF或LAHF指令,小心部分标记暂停。在偏移或旋转超过1时,避免测试标记(第71页)。
如果致力于每时钟周期3个μop,小心在指令获取及解码中可能的时延(第62页),特别是在小循环中。在这些处理器里,指令解码通常是最窄的瓶颈,而且不幸的是,这个因素使得优化相当的复杂。如果在代码开头做了修改来改进它,这个改进可能会有移动后续代码的IFETCH块及16字节边界的效果。边界的改变在总体时钟周期数会有不可预知的影响,掩盖这个改进的效果。
每时钟周期两个永久寄存器读的限制会将吞吐率降到低于每时钟周期3个μop(第66页)。如果通常在最后一次修改,超过4个时钟周期后,读寄存器,这可能发生。例如,如果经常使用指针对数据取址,但很少修改这些指针,这就可能发生。
每时钟3个μop的吞吐率要求执行端口得到的μop超过不能三分之一(第70页)。
回收站每时钟周期可以处理3个μop,但对被采用的跳转稍微不那么有效(第70页)。