深入理解Intel Core Microarchitecture
Core 2的2级Cache
1级Cache分为 32KB L1i Cache和32KB L1d Cache,都是8路组相联write back buffer,64bytes per line,每个core拥有独立的L1 Cache,共享L2 Cache和总线接口, L2 Cache为16路组相联,64bytes per line,与L1 Cache之间的数据带宽为256bit。两个Core的L1d Cache之间可以互相传输数据,L1 Cache拥有几个数据和指令硬件预取器,L2 Cache的预取器是基于L1 Cache预取器的访问模式和密集程度来工作的,使用了一个改进的Round-Robin算法动态的在两个处理器之间分配带宽。前端总线接口也采用了类似的方式以确保平衡。L1 Cache和L2 Cache采用了独立访问设计,也就是说Core可以直接从L2 L1或Main Memory中直接取数据,无须逐级上升。intel cache使用了mainly inclusive设计,相比AMD使用的是exclusive设计。
数据和指令地址对齐的重要性
现代微处理器架构在Load和Store内存时,如果访问地址为N的整数倍时,则可以一次操作N个字节(N = 2^M),但如果操作的N个字节处于的地址对N取余不为0,则需要2个或以上个时钟周期,特别是在地址跨越了Cache Line后,性能影响更为严重。比如读取4 BYTE的int变量,如果此变量所在内存物理地址(注意是物理地址,不是线性地址)正好为4的整数倍,则能高效的完成操作(处于同样的BUFFER中时)。而且数据按其自然长度对齐还有一个好处就是将数据跨越Cache line的几率降到了最低(后面我们会详细讲到Core微处理器架构的Cache工作原理)。既然对齐那么重要,那我们如何让数据位于对齐的地址上呢?
对于全局变量我们可以使用__declspec(align(N))来达到目的,当然也可以使用INTEL相关的一些类型比如__m128,比如:
typedef __declspec(align(16)) int A16INT;
A16INT i;
现在这个变量i的地址就是16字节对齐的了。
对于位于堆栈中的变量特别复杂,当然我们同样可以使用__declspec(align(N))来对齐变量地址,但需要提醒的是,microsoft visual c++ compiler 的默认堆栈对齐是4BYTE,但官方文档称编译器会智能的判断数据组织,进行必要对齐转换,堆栈默认对齐相当诡异,我测试过跟编译优化选项有关系,实际上变量在堆栈中的排列顺序与定义顺序通常是不一致的,而且也不敢保证struct的字节对齐,double在8字节对齐除非手动加上__declspec(align(N)),在优化条件为/O2时,测试中显示堆栈都是自然长度对齐,仅仅限于我的个人测试,我没有找到相关文档明确说明/O2条件下堆栈一定是数据自然对齐的,所以如果在明确要求数据对齐的地方,还是要加上对齐标志。
动态内存分配的对齐
参考_aligned_malloc、_mm_malloc,如果用malloc分配的内存大小大于8,则内存是8字节对齐的,否则为最小2的多次方对齐,比如如果分配7字节,则4字节对齐。
Pentium Pro, 2 and 3 pipeline
取指令
取指单元将指令代码从代码缓冲的16字节对齐地址处一次取16bytes到另外一个32bytes缓冲中,取到一个2倍缓冲的主要目的就是随后可以解码跨越16byte边界的指令。随后代码会从这个32字节缓冲中传送到解码器,而传送的代码块我们给它取个名字叫 IFETCH块 ,这个IFETCH块最多为16字节。大多数情况下,取指单元会让会让IFETCH块从一个指令边界开始,而不是16字节,但要做到这点,取指单元就需要知道指令的边界在哪里,如果此时不知道,它就让IFETCH块从16字节对齐边界开始。
32字节缓冲没有足够的大小来缓冲JUMP后的代码块,所以JUMP都有延迟,如果IFETCH块中包含的JUMP指令跨越了中间的16字节边界,那么32字节边界就必须保存2个CHUNK来满足JUMP的解码,这样在这个时钟周期中就不能取一个代码块到32字节缓冲中,如果JUMP后的代码块的第一个指令又是跨越了内存的16字节边界,那么完了,又要多等1个周期,因为要取2个块后才能进行第一个指令的解码(所谓延迟就是因为本单元阻塞了整个流水线)。取指单元每个时钟周期能取16字节,如果解码一个IFETCH块大于一个时钟周期的话,这样在多余的时钟周期中可以进行额外的取代码操作,这样可能抵消前面产生的延迟.如果在指令跳转之后32字节缓冲只有时间取16字节,那么第一个IFETCH块就是16字节对齐的那块,而不是从真实指令开始的地方,如果有时间取2个16字节的代码,则实际取到32字节缓冲的是代码块就是真实从JUMP后第一个指令开始的.
32字节缓冲只有时间在跳转后从16字节对齐的CACHE地址取16字节出来,因为跳转后没有指令长度信息可用,所以JUMP后的第一个IFETCH块就是这个块了,很显然JUMP后的第一条指令并不开始于这个IFETCH块的首端,但游戏JUMP跨越了本16字节,还有更高地址的16字节,完整的IFETCH快必须等待,32字节缓冲填充满32字节后才能产生.
指令长度包含了从1到15字节的范围,因此,我们无法确定,16字节的IFETCH块包含了完整的指令数目,很可能最前或最后的指令就跨越了16字节的边界,延伸到前16字节代码块或后16字节代码快,那么取指单元必须知道16字节块中的最后一条指令在哪里结束,这样取指单元就能正确的生成下一个IFETCH块了,而前一个IFETCH块中最后一个不完整指令将不会被解码,所以不占用时钟周期.指令的长度信息可以由指令长度解码器来完成,这个单元处于IFU2处,也就是第2个取指单元.每个时钟周期,它能获得3条指令长度信息,比如,如果一个IFETCH块包含了10条指令,那么在产生下一个IFETCH块之前必须等待3个时钟周期.
译码单元
指令长度解码器是一个高度串行的单元,因为在知道后一条指令的开始之前,你必须依次知道前面所有指令的结束,这严重降低了整个系统的并行度,我们总是尽力做到一次取多条指令,一次解码多条指令,一次执行多条微指令,来获得尽可能快的速度.简单的指令长度解码器一次(一个时钟周期)只能知道一条指令的长度,但PPRO微处理器架构可以一次确定3条指令的长度,甚至同时可以将结果传回给取指单元,让取指单元在同一时刻来正确的从32字节缓冲中产生IFETCH块,这是个相当振奋的功能,我相信它是通过并行同时解析所有的16个可能开始的地方做到这点的.
当IFETCH块产生后被送到译码单元,在这里指令会被翻译为微指令,这里有3个译码单元并行工作,这样一个时钟周期最多能有3条指令被解码,同时被界码的几条条指令(最多3条)被称为一个解码组,3个解码单元被分别成为D0,D1,D2,D0能力最强,它可以在一个时钟周期解码能分解成4个微指令的指令,D1D2相对较弱,只能解码产生一个UOP的指令,并且指令长度不得大于8字节,IFETCH块中的第一条指令总是被派发到D0,余下的指令如果D1D2可以搞定,则派发到D1或D2,否则必须等待D0完成手头工作,再来解码余下指令.如下:
mov [esi], eax ;2 uops
add ebx, [edi] ;2 uops
sub eax, 1 ;1 uop
cmp ebx, ecx ;1 uop
je L1 ;1 uop
第一条指令被派发到D0,而第二条指令发现没地方解码,因为他产生2条必须在D0,所以从它开始必须延迟一个周期,等D0完成第一条指令解码后再开始,第二个时钟周期,I2 I3 I4分别被派发到D0 D1 D2,第3个时钟周期I5到D0.所以在PPRO中,最好的指令组合是1条2~4 uops的指令跟2条1 uop的指令.大于4uop的指令,必须由D0来解码,并且需要2个或2个以上时钟周期来完成解码,并且在这个过程中,其他任何指令无法同时并行解码.块的边界问题,最复杂的就是IFETCH块中的第一条指令总是进入D0,如果指令长度是411模式,并且不幸的是总是1uop指令在第1个,那么4-1-1模式被破坏,解码141这样的组合总是要花费2个时钟周期,如果所有的指令都是141 141 141模式,那么完蛋了,每次都要延迟1周期,但很显然,只要简单的将模式后移一个指令就OK了,但取指单元是绝对无法知道哪个指令产生多少条uop,相信这个结果要在在接下来2个阶段后才能知道.
这个问题非常难处理,因为要知道指令边界很困难,最好的方法就是好好组织代码,让代码平均在一个时钟周期能产生3条uops,流水线的RAT和RRF阶段一个时钟周期最多只能处理3条UOPS,如果我们按411模式来组织,这样我们就可能有如下情况
114 3UOPS/C 411 6UOPS/C 141 3UOPS/C
这样平均下来我们可以达到4UOPS/C,这样我们就能补偿可能在IFETCH边界1个时钟的延迟,这样维持解码器3UOPS/C的效率.
另外一个解决方法就是让一个IFETCH块中的指令尽可能的多,这样在16字节边界被打断的次数也会少很多.比如用指针来代替绝对地址可以缩短指令长度.
; Example 5.2. Instruction fetch blocks
address instruction length uops expected decoder
---------------------------------------------------------------------
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 edx 1 1 D1
1022h jnz LL 2 1 D2
我们来上面这个例子
假设IFETCH块从1000H开始到1010H结束,那么在这个IFETCH块中就存在一个不完整的"mov [mem], 0"指令,那么第二个IFETCH块就要从1007H开始在1017H结束,结果如下:
IFETCH1 -------> 1000H ~ 100FH = 16 bytes
IFETCH2 -------> 1007H ~ 1016H = 16 bytes
很显然第2个IFETCH块正好在lea指令末结束,那么第三个FETCH块为:
IFETCH3 -------> 1017H ~ 1022H = 11 bytes
所有的指令都被FETCH了,现在我们来看看第一个循环需要多少个时钟周期来解码,跳转目标在1005h处,很显然没有跨越16字节边界,如果分支预测正确,IFETCH块的产生将不会发生延迟,IFETCH块1为1005H到1014H,显然末尾包含了一个不完整的LEA指令,第一个FETCH块解码需要耗费2个时钟周期,因为都是2UOPS的指令,都需要等待D0解码,而第2个IFETCH块从1011H到1020H,正好包含4条指令,需要花费4个时钟周期来解码,太痛苦了,lea指令因为是IFETCH块的开始,被安排到了D0解码,第3个IFETCH块为1021H到1022H,需要1个解码周期,分别在D0和D1解码.第一轮循环的解码周期总共需要7.
指令前缀
指令前缀同样可能给指令解码单元带来延迟.
1.如果指令中包含16或32位立即数,操作数前缀可能会带来一些时钟延迟,因为这种情况下,前缀会改变操作数的表示长度,从而让指令长度改变.
2.地址长度属性修改前缀会给解码带来延迟,因为前缀改变了指令的R/M位的解释方式.但带隐式内存操作数的指令,比如字符串操作指令则不会有延迟.
3.段修饰前缀不会给解码带来性能损失.
4.重复修饰前缀和LOCK前缀不会给解码带来延迟.
5.如果一个指令带多个前缀,那么通常会给解码部分带来延迟,一般每多一个产生一个时钟延迟.
Register renaming
寄存器别名表(RAT)控制寄存器换名,解码生成的UOPS通过一个队列来到RAT,随后就到了ROB阶段,最后是RS,RAT一个时钟周期可以处理3个UOPS,也就是说整个系统的吞吐量平均不会操作3UOPS/C,被换名的寄存器数量基本没有限制,通常一个时钟周期能将3个寄存器换名,甚至能将一个寄存器换名3次.
寄存器换名的原理很简单,虽然我们程序中能直接用的寄存器数量非常有限,但在微处理器内部确有大量的隐藏寄存器,CPU能用它们替换程序中出现的逻辑寄存器,这样能让UOPS能最大限度的并行运行,每次程序修改一个逻辑寄存器,为处理器就为这个逻辑寄存器分配一个隐藏寄存器.同时这个阶段还计算相对跳转地址分支,将计算机结果返回到BTB0阶段使用.所谓隐藏寄存器可能就是ROB ENTRY
reorder buffer read
当隐藏寄存器被换名为通用寄存器之后,就需要将通用寄存器的值写到已换名的隐藏寄存器中,如果这些值可用,则他们就存储在ROB ENTRY中,每个ROB ENTRY最多可拥有2个输入寄存器和2个输出寄存器,对于输入寄存器中的值有如下几种情况.
1.通用寄存器中的值可用,即永久积存器文件.那么直接READ值到ROB ENTRY中
2.值被修改过了,但修改的UOP还没有RETIRED,也就是没有写回PERMANENT REGISTER FILE,那么直接从 not-yet-retired rob ENTRY中读值到响应的ROB ENTRY中
3.值还不能用,因为有依赖关系的UOP还没有执行,只能先等待,当值可用后,会马上写入到ROB ENTRY.
1看起来好象是最简单的也是最没问题的,但奇特的是1情况是唯一能在ROB-READ阶段引起延迟的,原因是PRF只有2个读端口,而ROB-READ阶段每个CLOCK却能从RAT阶段收个3个UOPS,而每个UOPS有2个输入积存器,这样1个时钟周期就有6个积存器等待输入,ROB-READ阶段不得不用3个时钟周期来完成输入任务,而当RAT与解码器之间的队列满了后,解码器和取指单元也不得不停下来,而RAT与解码器之间的队列只有大概10个单元,所以马上队列就会被装满.
反而第2 3种情况在ROB-READ阶段不会有延迟,如果积存器还没有通过ROB-WRITEBACK阶段,则ROB-READ读这个积存器就不会有延迟,积存器至少需要3个时钟周期来完成 换名 读ROB 执行 和 ROB写回,所以如果在一个TRIPLET中的UOP中写过一个积存器,则在之后3个TRIPLET中读这个积存器都不会有延迟,如果写回阶段因为REORDERING 慢指令执行 依赖链 CACHE MISS或其他情况所延迟,那么就有更多的时间来读这个积存器了.
不要将解码组和UOPTRIPLET弄混了,一个解码组可以产生1~6条UOPS,即使解码组被解码出来有3个UOPS,也不敢保证这3个UOPS是一起传送到RAT的.而且解码器和RAT之间的队列缓冲非常的短,我们无法假设积存器读延迟不会阻塞解码器,或则解码器的UOP流量波动不会阻塞RAT.除非解码器和RAT之间的队列是空的,否则很难预测哪些UOPS同时经过RAT阶段,分支预测失败后队列应该是空的.同一条指令产生的UOPS也不一定要一起传送到RAT,而RAT取指令也是简单的从队列去取出来,一次3条,跳转预测不会打断队列.只有预测失败后,队列中的UOP才会被丢弃掉,然后从头开始取指译指,这时3个连续的UOPS才一起进入RAT阶段.
一个读寄存器延迟可以通过监视0A2H计数器数,不幸的是不能将这种阻塞情况与其他情况分开.如果3条连续的UOPS读取超过2个不同寄存器,你当然不希望他们一起进入RAT阶段,他们一起进入的可能性为1/3,否则将延迟1个周期,
乱序执行
ROB可以保存40UOPS以及40个临时寄存器,而RESERVATION STATION则可以保存20条UOPS,UOP待到操作数都准备好,并且有空闲的执行单元后,就被执行了.写内存之间不能乱序执行,参阅投机执行.
PM的管线
PM是PENTIUM M,CORE单核和CORE双核的缩写,但不包括CORE2.PM的的架构与PPRO P2 P3差不多,主要的流水线处理阶段为:分支预测,取指,译码,寄存器换名,填写重组缓冲,UOPS缓冲站,乱序执行,结果写回重组缓冲,CPU状态蜕变.PM的管线INTEL并没有公布详细情况,只是简单的说比PPRO的长,所以以下结论都是AGNER FOG的个人测试猜测.
PM的整个流水线长度,基于分支预测失败后的延迟,猜测大概比PPRO要多3~4阶段,PM的分之预测好象比PPRO要复杂,大概使用了3个阶段,比PPRO的多一个,取指单元也明显复杂了,因为在JUMP过程中16字节边界问题不再会有任何延迟,所以估计IFU需要3~4个阶段.
还有新的堆栈引擎阶段,很可能加在了解码阶段之后,因为在只能产生1条UOP的D1~D2之后,不耗费任何多余的时间,还会有一条堆栈同步UOP产生.
还有一个就是UOP融合操作,好象也不用耗费额外的阶段来分离这些UOPS,猜测可能他们公用同一个ROB ENTRY,这个ENTRY可以被注册到2个不同的执行PORT.所以在执行后也可能没有必要在RETIREMENT站之前来融合分开的UOPS.
CORE2的强悍登场
取指令和预解码
CORE2在分之预测与指令预取之间加入了一个队列,这个队列主要用来解决被预测分支中的各种延迟.指令解码单元被分割为预解码单元和解码单元,预解码单元主要是来检测每条指令从IFETCH块的哪里开始,还能分辨指令前缀和其他一些成分,预解码器的带宽为16字节或者6条指令每时钟周期,取较小者.管线的其他单元通常是4条指令每周期,或者5条如果有MACRO-FUSION的话.很显然如何16BYTE中的指令数少于4条的话,预解码器就是瓶颈了.还有个更糟糕的情况,在预解码器没有完成之前16BYTE字节代码的处理之前,它是不会FETCH新的16字节的,比如,如果前16字节中有7条指令,那么在第一个周期完成6条指令的处理,在第2个周期完成剩下一条指令的处理,全部完成后才来处理接下来的新16字节.任何跨越16字节边界的指令将被留到下一个16字节做处理.
循环代码缓冲
在CORE2中解码器的代码队列可以当作尺寸为64字节的循环代码缓冲来用,缓冲中被预解码的循环代码可以被解码器重复使用.所以如果一个循环的代码都包含在64BYTE内,当然就一直用不着寓解码了,64字节缓冲可以当L0 CACHE使用,被组织为4行16字节.
解码器
CORE2拥有4个解码器,第一个解码器最多可以在一个周期内解码一条产生4条UOPS的指令,其他的都只能解码产生一条UOP的指令,如果指令产生4条以上UOPS,则D0需要借助微码ROM花费多个周期来解码.解码器可以在一个时钟周期内从64字节缓冲中读2个16字节.因此32字节能在一次全部解码完毕,但因为预解码器的吞吐量最多只有16字节/C,只有当解码器在上周期处理的字节数少于16字节时,在下周期才可能处理多余16字节的代码,因为上周期有积余,而高达32字节/C只能在小的循环缓冲中得到,因为这时候的代码的预解码信息可以复用.
INTEL早期的处理器在一个周期内能解码的指令有前缀数量的要求,但在CORE2中这个限制消除了,唯一的限制就是指令长度加上指令前缀长度不得超过15字节,除此之外,任何一个解码器都能在一个周期内解码任意多前缀的指令(当然对响应UOPS数量有要求),当然没有哪个指令需要多达14个前缀,但多余前缀可以用NOP操作代替,用来作为循环入口16字节对齐.
微码熔合
有些指令会被解码为2条UOPS,在使用微码熔合技术,可以上2条UOPS熔合为1条,这样可以降低内部带宽使用,但微码派发器会将熔合微码当作2条UOPS分别发送不到不同的执行单元中去,但之后它依然是单独的一条微码指令.微码熔合有两种情况,一种是读-修改操作熔合,一种是写熔合.比如ADD EAX, [MEM],这条指令包含2条微码,一条读内存,一条加操作,这2条微码可以被熔合成1条,MOV [ESI+EDI], EAX, 着条写操作指令也包含2条微码,一条计算写的物理地址,一条写操作,它们也可以被熔合.CORE2比PM能更多的熔合微码,比如一条读-操作-写指令可以同时使用2种熔合,大部分XMM指令都能熔合,1条熔合的UOP有3个输入依赖,而普通的UOP只有2个,写操作有2条UOPS,而读操作只有1条?为什么?
对于#pragma pack(n)和__declspec(align(n))的一些看法,其他pack和align本来在概念上就完全不一样,align是用来指定数据的对齐的位置,而pack指定的是为了达到对齐目的,所允许编译器填充的最大空白数目,注意pack只对结构性数据有影响.
看如下例子:
#pragma pack(4)
struct A
{
char c;
double d1;
short s;
}
首先我们来看MSDN上的一局话:
Unless overridden with __declspec(align(#)), the alignment of a scalar structure member is the minimum of its size and the current packing.
如何没有声明__declspec(align(#)), 结构体中的数据成员对齐地址为其本身长度和pack长度中较小的那个.
Unless overridden with __declspec(align(#)), the alignment of a structure is the maximum of the individual alignments of its member(s).
如果没有声明__declspec(align(#)), 结构体本身的对齐地址为起所有成员中对齐长度最大的那个.
A structure member is placed at an offset from the beginning of its parent structure which is the smallest multiple of its alignment greater than or equal to the offset of the end of the previous member.
一个结构体类型的成员被放置在 上父结构体一个成员之后的地址 这个地址是结构体本身对齐长度的整数倍.
The size of a structure is the smallest multiple of its alignment larger greater than or equal the offset of the end of its last member.
结构体尺寸大于或等于其最后一个成员的偏移,为其对齐的最小倍数
如果我没理解错的话,这个A结构的对齐地址应该是8的倍数,而与pack(n)无关.在来看看他的成员c的地址是8的倍数,d1按道理应该也是8的倍数,这样在d1之前必须填充7个空白,但是我们指定了最多只能填4个空白,没办法,编译填了3个空白,让位置对齐地址为4的整数倍.
参考:
http://msdn.microsoft.com/en-us/library/aa290049.aspx
http://msdn.microsoft.com/en-us/library/83ythb65(VS.80).aspx#vclrfalignexamples