7.11. 连接到端口0与1的执行单元
如上面提到的,有些执行单元是双倍的。例如,两个整形向量加法μop可以同时执行,分别在通过端口0与1的ALU上。
其他一些执行单元可通过端口0与1访问,但不是双倍的。例如,一个浮点加法μop可以通过端口0或端口1,但仅有一个浮点加法器,因此同时执行两个浮点加法μop是不可能的。
实现这个机制让浮点加法μop通过任一空闲的端口,无疑是为了提升性能。但不幸的是,这个机制坏处比好处多。大多数μop是浮点加法,或其他可以通过任一端口的代码需要比预期更长的时间执行,通常是多50%。
这个现象最可能的解释是,调度器在同一时钟周期里发布两个浮点加法μop,每个端口一个。但这两个μop不能同时执行,因为它们需要相同的执行单元。结果,其中一个端口暂停,一个时钟周期无事可做。
这适用于以下指令:
任何包含许多这些指令的代码执行的时间很可能超过所需。指令都是上述的相同类型还是混合类型,没有区别。
使用仅能通过其中一个端口的μop,不会发生这个问题,即MULPS,COMISS,PMULUDQ。使用能通过两个端口,ALU是双倍的μop也不会发生这个问题。
一个实际意义不那么重要,但揭示了一点硬件设计的进一步复杂性是,像PMULLW与ADDPS重要的指令不能同时执行,甚至它们在使用不同的执行单元。
包含许多上述类型指令代码差劲的性能是一个不良设计的结果。这个机制无疑是实现来提升性能的。当然,找出这个机制确实提升了性能的例子是可能的,设计者可能在脑子里有一个特定的例子。但仅在只有少量上述类型指令,多数指令占据端口1的代码中看到性能提升。这样的例子很少见,好处很有限,因为它仅适用于只有少量这些指令的代码。当许多指令是上述类型,但不是所有时,性能损失最大。在浮点代码中,这样的情形很常见。在浮点代码中关键的最里层循环可能有许多加法。因此浮点代码的性能很可能因为这个不良设计受到不小的影响。
程序员对这个问题几乎无能为力,因为不能在CPU流水线中控制μop的重排。使用整数替代浮点数可行性很低。一个只做浮点加法的循环可以通过展开来改进,因为这降低了受限于被阻塞端口的循环开销指令的相对代价。有时可以通过重排指令,使得没有两个FADD μop在同一时钟周期里发布给端口,改进仅少数指令是浮点加法的循环。这要求许多经验,而且结果对循环前的代码很敏感。
上面对包含许多浮点加法代码差劲性能的解释,基于系统化的实验,但没有确凿的证据。我的理论基于以下的观察:
通过两个嵌套循环进行测试,其中内层循环包含许多浮点加法,外层循环测量内层循环使用的时钟周期数。内层循环的时钟周期数不总是恒定,通常依照一个周期模式变化。周期依赖于代码里的小细节。已经观察到高达5与9的周期。这些周期不能被我能提出的其他任何理论所解释。
当然所有的测试例子受到端口执行吞吐率的限制,而不是执行时延。
关于哪个μop去往哪个端口与执行单元的信息,是通过饱和一个特定端口或执行单元的实验来获取的。
7.12. 回收
在PM上的回收站像PPro,P2及P3上那样工作。每时钟周期,回收站可以处理3个融合μop。被采用的跳转仅能在回收站3个工位中的第一个里回收。如果一个被采用的跳转恰好进入其他工位,回收站将暂停一个时钟周期。因此,小循环中融合μop数最好能被3整除。细节参考第71页。
7.13. 寄存器的部分访问
PM可以将寄存器的不同部分保存在不同的临时寄存器中,以消除虚假的依赖性,例如:
; Example 7.6. Partial registers
mov al, [esi]
inc ah
这里,第二条指令不需要等待第一条指令完成,因为AL与AH可以使用不同的临时寄存器。在这些μop被回收时,AL与AH被保存到永久EAX寄存器中它们各自的部分。
在写入一个寄存器部分,后接一个从整个寄存器读的时候,出现一个问题:
; Example 7.7. Partial register problem
mov al, 1
mov ebx, eax
在PM型号9上,从整个寄存器(EAX)读必须等待,直到部分寄存器(AL)被回收,且AL中的值与永久寄存器EAX的余下部分合并完成。这称为部分寄存器暂停。在PM型号9上,部分寄存器暂停与PPro上的相同。细节参考第71页。
在更新的PM型号D上,通过插入将寄存器各部分整合起来的额外μop,解决了这个问题。我假设这些额外的μop在ROB-读阶段产生。在上面的例子中,ROB-读将生成一个在MOV EBX, EAX指令前,将AL与EAX余下部分合并到一个临时寄存器的额外μop。在ROB-读阶段,这需要1或2个额外时钟周期,不过这小于之前处理器上部分寄存器暂停5-6时钟周期的代价。
在PM型号M上生成额外μop的情形与较早处理器上产生部分寄存器暂停的情形相同。写入AH,BH,CH,DH的高8位产生两个额外μop,而写入一个寄存器的低8位或16位部分,产生一个额外μop。例如:
; Example 7.8a. Partial register access
mov al, [esi]
inc ax ; 1 extra uop for read ax after write al
mov ah, 2
mov bx, ax ; 2 extra uops for read ax after write ah
inc ebx ; 1 extra uop for read ebx after write ax
在ROB-读中防止额外μop与暂停的最好方式是避免混用寄存器大小。上面例子可以这样改进:
; Example 7.8b. Partial register problem avoided
movzx eax, byte ptr [esi]
inc eax
and eax, 0ffff00ffh
or eax, 000000200h
mov ebx, eax
inc ebx
避免这个问题的另一个方式是通过XOR自己将整个寄存器清零:
; Example 7.8c. Partial register problem avoided
xor eax, eax
mov al, [esi]
inc eax ; No extra uop
处理器知道寄存器XOR自己是清零。寄存器中一个特殊的标记记住寄存器的高位是0,因此EAX = AL。即使在循环中,这个标记也被记住:
; Example 7.9. Partial register problem avoided in loop
xor eax, eax
mov ecx, 100
LL: mov al, [esi]
mov [edi], eax ; No extra uop
inc esi
add edi, 4
dec ecx
jnz LL
通过寄存器清零来防止额外μop的规则与之前处理器上防止部分寄存器暂停的规则相同。细节参考第71页。
(文献:Performance and Power Consumption for Mobile Platform Components Under Common Usage Models. Intel Technology Journal, vol. 9, no. 1, 2005)。
不幸,PM不会产生额外的μop来防止标记寄存器上的暂停。因此,在一条修改标记寄存器部分的指令后,读标记寄存器有一个4-6时钟周期的暂停。例如:
; Example 7.10. Partial flags stalls
inc eax ; Modifies zero flag and sign flag, but not carry flag
jz L1 ; No stall, reads only modified part
jc L2 ; Stall, reads unmodified part
lahf ; Stall, reads both modified and unmodified bits
pushfd ; Stall, reads both modified and unmodified bits
可以通过ADD EAX, 1(修改所有标记)或LEA EAX, [EAX+1](不修改标记)替换INC EAX,避免上面的暂停。避免依赖INC或DEC不改变进位标记事实的代码。
在一条偏移数不是1的偏移指令后读标记时,也会有一个部分标记暂停:
; Example 7.11. Partial flags stalls after shift
shr eax, 1
jc l1 ; No stall after shift by 1
shr eax, 2
jc l2 ; Stall after shift by 2
test eax, eax
jz l3 ; No stall because flags have been rewritten
部分标记暂停的细节参考第74页。
7.14. 写转发暂停
在PM中,写转发暂停与之前处理器相同。细节参考第74页。
; Example 7.12. Store forwarding stall
mov byte ptr [esi], al
mov ebx, dword ptr [esi] ; Stall
7.15. PM中的瓶颈
在优化一段代码时,找出控制执行速度的限制因素是重要的。调整错误的因素很可能没有什么效果。在下面段落中,我将解释每个可能的限制因素。你必须依次考虑每个因素来确定哪一个是最窄的瓶颈,集中精力在这个因素上,直到它不再是最窄的瓶颈。正如以前解释的那样,你必须专注于程序最关键的部分——通常是最里层循环。
如果程序正在访问大量的数据,如果数据分散在内存的各处,那么将有许多数据缓存不命中。访问非缓存数据是如此地耗时,所有其他优化考虑都不重要。缓存被组织为64字节对齐行。如果一个64字节对齐块中的一个字节被访问,可以确定所有64字节将被载入1级数据缓存,且可以没有额外代价地访问。为了提升缓存,建议用在程序相同部分的数据保存在一起。可以把大数组与数据结构边界对齐到64字节。如果没有足够的寄存器,在栈上保存局部变量。PM有四个写端口。超过四个紧挨的写会降低处理几个时钟周期,特别是如果同时还有内存读。在PM上内存的非临时写是高效的。如果不预期很快读同一缓存行,可以使用MOVNTI,MOVNTQ与MOVNTPS用于分散内存写。
Core Solo/Duo有一个改进的,预测未来内存读的数据预取机制。
如果致力于每时钟周期3个μop,应该根据4-1-1规则组织指令。记住4-1-1模式在IFETCH边界被破坏。避免带有超过一个前缀的指令。避免在32位模式下带有16位立即数的指令,参考第78页。
μop融与栈引擎使得每个μop获取更多信息成为可能。如果每时钟周期3个μop或解码限制是瓶颈,这是一个优势。浮点寄存器允许读-修改指令的μop融合,但XMM寄存器不允许。如果可以利用μop融合,对浮点操作使用浮点寄存器,而不是XMM寄存器。
如果一段代码有超过3个经常读但很少写的寄存器时,小心寄存器读暂停。参考第84页。
在使用指针与绝对地址间有一个权衡。面向对象代码通常通过栈框指针以及this指针访问大多数数据。指针寄存器是寄存器读暂停的可能来源,因为它们被经常读,但很少写。不过,使用绝对地址而不是指针,有其他劣势。它使代码变长,降低缓存的效率,而且IFETCH边界问题变多。
非融合μop应该均匀地在5个执行端口分发。在有少数内存操作的代码里,端口0与1很可能是瓶颈。如果不会导致缓存不命中,通过将寄存器到寄存器移动以及寄存器与立即数间移动的指令,替换为寄存器与内存间移动的指令,可以将某些负荷从端口0及1转移到端口2。
在PM上,使用64位MMX寄存器的指令不比使用32位整数寄存器的指令低效。如果整数寄存器用完,对整数计算可以使用MMX寄存器。XMM寄存器稍微低效一些,因为对读-修改指令它们不使用μop融合。MMX与XMM指令比其他指令稍长。如果解码是瓶颈,这可能增大了IFETCH边界的问题。记住在同一个代码中,你不能同时使用浮点寄存器与MMX寄存器。
由于第85页讨论的设计问题,有许多浮点加法的代码很可能在端口0与1暂停。
栈同步μop去往端口0或1。有时可以通过PUSH及POP指令替换相对于栈指针的MOV指令,减少这样μop的数量。细节参考第81页。
部分寄存器访问产生额外的μop,参考第87页。
在PM上,执行单元有适度地的时延,许多操作比P4上要快。
当代码有带有慢指令的长依赖链时,性能很可能受到执行时延的限制。
避免长依赖链以及在依赖链中避免内存立即数。依赖链不会被寄存器与自身的XOR或PXOR打破。
避免混合使用寄存器大小,避免使用AH,BH,CH,DH的高8位。在修改某些标记位的指令或者偏移与旋转后,小心部分标记暂停。参考第89页。
比其他处理器,PM里的分支预测更加先进。可以完美预测重复最多64次的循环。目标不停变换的间接跳转与调用也可以被预测,只要它们遵循一个规则的模式,或者与前面的分支有一一对应的关系。不过分支目标缓冲(BTB)比其他处理器小得多。因此,应该避免不必要的跳转,以减少BTB上的负荷。如果大多数处理器时间花在一小块具有相对少分支的代码上,分支预测将是良好的。但如果处理器时间分散在具有许多分支且没有特别的热点的代码上时,分支预测将是不良的。参考第18页。
回收
在具有许多分支的小循环中,已采用分支的回收会是瓶颈。参考第71页。