15. 取指令(PPro,PII 和 PIII)
指令从指令cache的16字节对齐的块中取出,放置在大小是块的两倍的缓存内。
然后指令从"两倍缓存"取出,放在一个通常是16字节,但不需要16对齐的块中传递给解码器。 我们称这些块是"ifetch"(指令携带块)。
如果一个ifetch是跨 16 字节边界的,那么它需要从"两倍缓存"的两个块中读出。
因此"两倍缓存"被设计成有两个块,目的就是为了能跨越16字节边界取指令。
"两倍缓存"能在一个时钟周期内取一个16字节的块,并能在一个时钟周期内产生一个ifetch块。
一般ifetch块长16字节,但块中有被预测到的转移时,可能短于16字节(关于分支预测,见22章)。
不幸的是,"两倍缓存"还没有大到能够无延迟地取出跳转指令周围的指令(要包括不发生转移的代码和发生转移后的目的代码)。
如果一个穿越16字节边界的ifetch块包括了跳转指令,为了产生这个ifetch块,"两倍缓存"需要存储两个连续的16字节对齐的代码块;如果转移指令之后的第一条指令穿越了16字节边界,那么为了产生一个正确的ifetch块,"两倍缓存"需要载入两块新的16-字节代码块。
这意味着在最坏情况下,转移指令之后第一条指令的解码可能要被延迟两个周期。
因为穿越16边界的ifetch块包含了跳转指令,你要付出代价;转移指令后的第一条指令穿过16边界,也得付出代价。
但如果在ifetch中你有多于一个解码组包含跳转指令?,那么你能得到奖赏。
因为有跨越16边界的转移指令后的第一条指令的存在,使得"两倍缓存"能有额外的时间预先获取1~2块16-字节的代码块。 按照下表,该奖赏能补偿损失。
如果"两倍缓存"只获取了转移指令后的代码的一个16-字节块,那么产生的第一个ifetch块与该块相同,也就是16字节对齐。
换句话说,转移指令后的第一个ifetch块将不会从第一条指令开始,而是从能被16整除的、与先前地址最接近的地址开始。
如果"两倍缓存"有时间读取两块16-字节块,那么新的ifetch块可能穿过16字节边界,并且从转移指令后第一条指令开始。 下表概述了这个规律:
ifetch块中包含跳转的解码组的个数 | 该ifetch块中是否有16字节边界 | 跳转后的第一条指令中有无16字节线 | 解码延迟 | 转移指令后第一个ifetch块的对齐方式 |
1 | 0 | 0 | 0 | 16字节对齐 |
1 | 0 | 1 | 1 | 从第一条指令开始 |
1 | 1 | 0 | 1 | 16字节对齐 |
1 | 1 | 1 | 2 | 从第一条指令开始 |
2 | 0 | 0 | 0 | 从第一条指令开始 |
2 | 0 | 1 | 0 | 从第一条指令开始 |
2 | 1 | 0 | 0 | 16字节对齐 |
2 | 1 | 1 | 1 | 从第一条指令开始 |
3 or more | 0 | 0 | 0 | 从第一条指令开始 |
3 or more | 0 | 1 | 0 | 从第一条指令开始 |
3 or more | 1 | 0 | 0 | 从第一条指令开始 |
3 or more | 1 | 1 | 0 | 从第一条指令开始 |
跳转使取指令发生延迟,因此使得一个循环的每次叠代总是花至少两个多周期,这比循环中的16字节边界线的数目要多。
取指令机制的另一个问题是在前一个ifetch耗尽之前,一个新的ifetch块不会产生。
每个ifetch块可能包含几个解码组。 如果一个16字节ifetch块的结束位置在一条指令中间,那么下一个ifetch块会从该指令的开始处开始。
可能的话,ifetch块中的第一条指令总是进入D0解码器,后两条指令进入D1和D2。 这使得D1和D2没有被最佳利用。
比如代码按照推荐的4-1-1模式组织,计划要进入D1或D2的指令正好是某个ifetch块的第一条指令,那么该指令不得不进入D0,一个时钟周期就这样浪费了。
这可能是一个硬件设计的缺陷,至少是个不完美的设计。 它导致了解码开销很大程度上取决于第一个ifetch块开始的位置。
如果解码速度要求苛刻,你想避免这些问题,那么你必须知道每个ifetch块开始的位置。 这是个相当乏味的工作。
首先,为了知道16字节边界线的位置,你需要将代码段按节对齐(按 16 对齐)。
然后查看汇编码输出列表,知道每一条指令的长度(推荐你学习一下指令的编码方式,这样就可预知指令的长度)。
当你得到了一个ifetch块开始的位置后,可以通过这个方法得到下个ifetch块开始的位置:计该块为16字节,如果它的结束位置在指令的边界线上,那么下个ifetch块就从这个位置开始;如果它的结束位置在某条指令的中间,那么下个ifetch块从该指令的起始处开始(本方法只关心指令的长度,不关心指令产生多少微码和它们做什么)。
对所有代码用此方法,你可以标出每个ifetch块开始的位置。
现在唯一的问题是如何知道起始的位置,因为知道了一个ifetch块开始的位置,就可以知道所有后续ifetch,因此必须知道第一个ifetch的开始位置。
以下是指导方针:
1、按照前面的表,jump,call或return后的第一个ifetch块既可能从第一条指令开始,也可能从与先前地址最接近的16字节边界线开始。
但如果第一条指令是对齐的——使它从16字节边界线开始,那么你就能保证第一个ifetch块从这里开始。 因此,你应该使重要的子过程入口和循环入口按 16
对齐。
2、如果存在两条连续的指令它们的长度和大于16,那么你能保证第二条指令无法与第一条指令放进同一个ifetch块,结果就是总能有一个ifetch块从第二条指令开始。
以此ifetch块为基础,你就能找出后续ifetch块的开始位置。
3、分支预测失败后的第一个ifetch块总是从16字节边界线开始。 按照22.2节的理论,一个重复次数大于5次的循环当它退出时总有一次预测失败。
因此这种循环后的第一个ifetch块从与先前地址最接近的16字节边界线开始。
4、其它序列化事件(不可并行事件)也会使得下一个ifetch块从16字节边界线开始。
类似事件包括中断,异常,自修改代码和CPUID,IN,OUT等序列化指令。
你现在一定需要一个实例了吧:
地址 | 指令 | 长度 | 微码数 | 估计要进入的解码器 |
1000h | MOV ECX, 1000 | 5 | 1 | D0 |
1005h | LL: MOV [ESI], EAX | 2 | 2 | D0 |
1007h | MOV [MEM], 0 | 10 | 2 | D0 |
1011h | LEA EBX, [EAX+200] | 6 | 1 | D1 |
1017h | MOV BYTE PTR [ESI], 0 | 3 | 2 | D0 |
101Ah | BSR EDX, EAX | 3 | 2 | D0 |
101Dh | MOV BYTE PTR [ESI+1],0 | 4 | 2 | D0 |
1021h | DEC ECX | 1 | 1 | D1 |
1022h | JNZ LL | 2 | 1 | D2 |
我们假定第一个ifetch块从1000h地址开始到1010h结束。结束位置在MOV[MEM],0指令中间,因此下个ifetch块从1007h开始到1017h结束。
结束位置在指令边界上,因此第三个ifetch块从1017h开始,覆盖了剩余的循环。 解码花去的时钟周期等于D0指令的数目,在LL循环中是每次叠代5个周期。
最后一个ifetch块包括了三个解码组,覆盖了最后五条指令,而且穿越了16字节边界(1020h)。
根据这些条件查看前面的表,我们知道跳转后的第一个ifetch块将从跳转后的第一条指令开始,它是在1005h的LL标签,到1015h结束。
结束位置在LEA指令中间,因此下个ifetch块从1011h开始到1021h结束,最后一个ifetch块从1021h开始,覆盖了剩下的指令。
现在LEA指令和DEC指令都不幸地在ifetch块的开头,迫使它们进入D0解码器。 所以在第二次叠代中我们有7条指令在D0,解码将花去7个周期。
最后一个ifetch块只包含一个解码组且不含16字节边界线。 查看表格,转移后的下个ifetch块将从16字节边界开始,它是1000h。
这就与第一次叠代的情况相同了。你可以看到该循环解码花去的时钟周期在5和7之间交替。 因为没有其它的瓶颈,所以整个循环叠代1000次的话,花6000时钟解码。
如果开始地址有所不同,使得你在循环的第一条或最后一条指令中有16字节边界线,那么要花8000时钟。
如果重新组织循环,使得没有D1或D2指令处于ifetch块的开头,那么可以只花5000时钟。
上述实例是特意构造的,使得取指令和指令解码是唯一的瓶颈。
避免这种瓶颈的最简单的方法是组织你的代码使得每个时钟周期产生3条以上微码,这样一来尽管有这里描述的种种惩罚,但解码已不再是瓶颈了。
对于小循环这种方法不适用,你必须对取指令和指令解码找出优化的办法。
为了避免16字节边界线在不希望的地方出现,方法之一就是改变程序的开始地址。
记住使你的代码段按节对齐(按16对齐),这样你可以知道16字节边界的位置。
如果你在循环前面插入一条ALIGN 16命令,那么汇编器会用NOP或者其它指令填充到最近的16字节边界。
大多数汇编器用XCHG EBX,EBX指令作为2-字节填充指令(被称作"2-字节NOP")。
这个方案不好,因为在大多数处理器上该指令的花费时间比两条NOP指令多!
如果循环执行很多次,那么在循环外的任何指令对速度都是不重要的,你不必在意这个不太好的填充指令。 但如果填充指令花去的时间是重要的,那么你可以手工选择填充指令。
最好选择那些有意义的填充指令,比如刷新寄存器——为了避免寄存器读延迟(见16.2章)。
比如你用EBP寄存器寻址,但很少对它写回,那么你可以用MOV EBP,EBP或者ADD EBP,0作为填充,减少寄存器读延迟的可能性。
如果你没有有用的指令,而且ST(0)中有一个合法的浮点数值,那么你可以用FXCH ST(0)作为好的填充,因为它不给执行端口增加任何负担。
还有的措施就是重新组织你的指令,使ifetch边界不出现在有害的位置。 这是个难题,不是一直能得到满意的结果的。
还有能做的是控制指令的长度。
有时你能替换一条指令为另一条长度不同的指令。 许多指令可以用不同方法编码,得到不同的长度。
汇编器一般是选择最短版本的指令,但有时候需要硬性地得到较长版本的指令。 比如,DEC ECX长度是一个字节,SUB ECX,1是3个字节。
你用了以下技巧,还可得到一个6字节版本的带长整型立即操作数的指令:
SUB ECX, 9999 ORG $-4 DD 1
带内存操作数的指令可以用SIB字节加长一个字节,但最容易的使指令加长一个字节的方法是加一个DS:段前缀(字节是3Eh)。 处理器通常接受多余的无意义的前缀(除了LOCK),只要指令长度不超过15字节。 甚至没有内存操作数的
指令也能有段前缀。因此如果你想使 DEC ECX 指令变成2个字节,写作:
DB 3Eh DEC ECX
记住:如果指令有多于一个前缀,解码时你会付出代价。 可能这种带有无意义前缀的指令(尤其是重复前缀和锁前缀)最好在以后被未来的处理器用来作为新的指令,因为那时候的不会再有无用的指令代码产生。 但我认为不管怎么说,对任何指令用段前缀都是安全的。
用了这些方法,应该能够使ifetch块的边界出现在你希望的位置了。 当然这是一个乏味的难题。