8.6. 栈引擎
Core2与Nehalem有一个与PM工作方式相同的专用栈引擎,如第81页所述,它对更大的流水线做了必要的调整。
PUSH,POP,CALL与RET指令对栈的修改由一个特殊的栈引擎完成,在流水线中,它紧跟着解码步骤,在乱序核之前。这把流水线从修改栈指针的μop负担中解放出来。这个机制节省了栈指针的两次拷贝:一次在栈引擎,另一次在寄存器文件与乱序核。这两个栈指针可能需要同步,如果PUSH,POP,CALL与RET指令序列后跟一条直接读或修改栈指针的指令,比如ADD ESP, 4或者MOV EAX, [ESP+8]。栈引擎在需要同步两个栈指针的情形里插入一个额外的栈同步μop。更详细解释,参考第81页解释。
通过不混用在栈引擎修改栈指针的指令与在乱序执行单元中访问栈指针的指令,栈同步μop有时可以避免。仅包含这两个类别之一指令的序列不需要同步μop,但混用这两个类别的序列需要这些额外的μop。例如,在一个函数调用后的ADD ESP, 4指令被POP ECX替换是有好处的,如果之前的指令是RET且下一条触碰栈指针的指令是PUSH或CALL。
在一个关键函数中完全避免栈同步μop是可能的,如果所有的函数参数都在寄存器中传递,且所有的局部变量都保存在寄存器中,或者被PUSH及POP。在64位Linux调用惯例中,这最实用。这时栈任何必要的对齐都可以通过假的PUSH指令来完成。
8.7. 寄存器重命名
所有的整形、浮点、MMX、XMM、标记与段寄存器都可以重命名。浮点控制字也可以。
寄存器重命名由图6.1中显示的寄存器别名表(RAT)以及重排缓冲(ROB)控制。来自解码器与栈引擎的μop通过一个队列去往RAT,然后到达ROB-读与保留站。RAT每时钟周期可以处理4个μop。RAT每时钟周期可以重命名4个寄存器,在一个时钟周期里,它甚至可以重命名同一个寄存器四次。
8.8. 寄存器读暂停
Core2与Nehalem,与PM及更早的处理器一样,受相同的寄存器读暂停的影响,如第65页所述。在Core2与Nehalem上,永久寄存器文件有三个读端口用于读指令操作数。
ROB-读阶段每时钟周期可以从永久寄存器文件读不超过3个不同的寄存器。这适用于所有通用寄存器,栈指针,标记寄存器,浮点控制寄存器,MMX寄存器与XMM寄存器。在Core上,一个XMM寄存器算作一个,而在之前的处理器上,算作两个64位寄存器。
三个寄存器读端口的前两个可以读用于指令操作数的寄存器、基址寄存器及索引寄存器。第三个读端口在Core2上仅能读用作索引指针的寄存器。在Core Nehalem上,所有这三个读端口都可用于任何操作数。在同一个时钟周期里,可以无限次读同一个寄存器,不会导致暂停。
最近被写过的寄存器可以直接从ROB读出,如果它们还没经过ROB-回写阶段。可以直接从ROB读的寄存器不需要寄存器文件上的读端口。我的测量显示一个μop从ROB-读阶段到ROB-回写阶段大约需要5个时钟周期。这意味着在被修改后的5个时钟周期内,读一个寄存器没有问题。考虑到每时钟周期4个μop吞吐率,可以假定,在被修改后的20个μop内,除非与此同时流水线因任何原因暂停,可以无需使用寄存器读端口来读一个寄存器。
一个非融合μop可以包含最多2个寄存器读,而一个融合μop可以包含最多3个寄存器读。例如,指令ADD EAX, [EBX+ECX]读寄存器EAX,EBX与ECX,然后写寄存器EAX。在一个时钟周期里,解码器向ROB-读步骤最多可以发送4个融合μop。因此,在一个μop四件套中,最大寄存器读数目是12。在所有寄存器都在永久寄存器文件中的最坏情形里,ROB-读阶段可能需要4个时钟周期来读这12个寄存器。
; Example 8.1a. Register read stall on Core2
L: mov eax, [esi+ecx]
mov [edi+ecx], ebx
add ecx, 4
js L
这个循环有一个寄存器读暂停,因为在循环里读三个寄存器,但是不写:ESI,EDI与EBX。ECX不需要读端口,因为它最近被修改了。有三个寄存器读端口,但在Core2上,第三个读端口仅能用于索引寄存器,这三个只读寄存器都没用作索引寄存器。注意指令代码的SIB字节区分基址与索引寄存器。在例子8.1a中ESI与EDI是基址寄存器,而ECX是索引寄存器。索引寄存器可以有一个比例因子,而基址寄存器不行。
稍微修改一下代码,可以使EDI成为一个索引寄存器,这样就可以使用第三个读端口:
; Example 8.1b. Register read stall removed
L: mov eax, [ecx+esi*1]
mov [ecx+edi*1], ebx
add ecx, 4
js L
这里,对ESI及EDI应用比例因子*1,确保汇编器把ESI与EDI用作索引寄存器,而不是ECX。现在可由第三个寄存器读端口读ESI或EDI,因此暂停消除了。在Core2上,例子8.1b每迭代需要1个时钟周期,而例子8.1a每迭代需要2个时钟周期。
预测哪些μop一起进入ROB-读阶段是困难的。μop依次到来,但你不知道每个四件套在哪里开始,除非解码器已经暂停了。因此,如果任何4个连续μop读超过2个或3个寄存器,且这些寄存器最近都没有被写入,会发生寄存器读暂停。
消除寄存器读暂停通常要求某些试验。Core2与Nehalem有可用来检测寄存器读暂停的性能监控计数器。
在像MOV EAX, [EBX]这样的指令中将一个基址寄存器变为索引寄存器是可能的。但仅当试验结果表明可以防止一次寄存器读暂停时,才应该这样做,因为指令MOV EAX, [EBX*1]比MOV EAX, [EBX]长5个字节(4字节用于基址0,1个SIB字节)。
消除寄存器读暂停的其他方法是尽量减少经常读、但很少写入的寄存器数目,用常量或内存操作数替换只读寄存器,组织代码限制同一个寄存器写入与后续读之间的距离。栈指针,栈帧指针及this指针是经常读但很少修改的寄存器的常见例子。
8.9. 执行单元
Core2的执行单元在之前处理器基础上扩展了许多。有6个执行端口。端口0,1与5用于算术与逻辑操作(ALU),端口2用于内存读,端口3用于写地址计算,端口4用于内存写数据。这给出了每时钟周期最多6个非融合μop的吞吐率。
所有执行端口都支持128位向量。大多数ALU操作有1时钟周期的时延。不同的单元在下面的表8.1中列出。所有三个ALU端口可以处理128位移动及布尔操作。所有三个端口可以处理通用寄存器上的加法。端口0与5还可以处理整数向量加法。
对整数乘法与浮点乘法有独立的单元。在端口1上的整数乘法器是完全流水线化的,时延为3,吞吐率为每时钟周期一个完整向量操作。在端口0上的浮点乘法器,对单精度时延为4,对双精度与长双精度的时延为5。在Core2上,除了长双精度,浮点乘法器的吞吐率是每时钟周期1个操作,浮点加法器连接到端口1。它的时延是3且完全流水线化。
整数除法使用浮点除法单元,这是仅有的没有流水线化的单元。端口5上的跳转单元处理所有的跳转与分支操作,包括宏融合的比较-分支操作。
连接到端口0与1的浮点单元处理所有浮点栈寄存器上的操作,以及XMM寄存器上的大多数浮点计算。Core2不区分整数与浮点操作,而Core Nehalem区分。例如,在Core2上,MOVAPS,MOAVPD与MOVDQA是相同的,都可由整数单元执行。在Core Nehalem上,MOVAPS与MOVAPD不同于MOVDQA,仅能在端口5上执行。在Core2上浮点XMM移动,布尔及大多数浮点混排操作由整数单元完成,但在Core Nehalem上使用端口5上的一个专用单元。
算术/逻辑执行单元被良好地分布在端口0,1与5之间。这使得每时钟周期执行3个向量指令成为可能,例如端口0上的浮点向量乘法,端口1上的浮点向量加法,及端口5上的浮点移动。
执行端口 |
执行单元 |
子单元 |
最大数据大小,比特 |
时延,时钟周期 |
处理器 |
0 |
int |
move |
128 |
1 |
|
1 |
int |
move |
128 |
1 |
|
5 |
int |
move |
128 |
1 |
|
0 |
int |
add |
128 |
1 |
|
1 |
int |
add |
64 |
1 |
仅Core 2 |
5 |
int |
add |
128 |
1 |
|
0 |
int |
Boolean |
128 |
1 |
|
1 |
int |
Boolean |
128 |
1 |
|
5 |
int |
Boolean |
128 |
1 |
|
1 |
int |
multiply |
128 |
3 |
|
0 |
int |
shift |
128 |
1 |
仅Core 2 |
1 |
int |
shift |
128 |
1 |
仅Nehalem |
5 |
int |
shift |
64 |
1 |
仅Core 2 |
0 |
int |
pack |
128 |
1 |
|
1 |
int |
pack |
64 |
1 |
仅Nehalem |
5 |
int |
pack |
128 |
1 |
仅Nehalem |
1 |
int |
shuffle |
128 |
1 |
仅Nehalem |
5 |
int |
shuffle |
128 |
1 |
|
5 |
int |
jump |
64 |
1 |
|
0 |
float |
fp stack move |
80 |
1 |
|
1 |
float |
fp add |
128 |
3 |
|
0 |
float |
fp mul |
128 |
4-5 |
|
0 |
float |
fp div and sqrt |
128 |
> 5 |
|
0 |
float |
fp convert |
128 |
1 |
|
1 |
float |
fp convert |
128 |
3 |
|
5 |
float |
fp mov, shuffle |
128 |
1 |
仅Nehalem |
5 |
float |
fp boolean |
128 |
1 |
仅Nehalem |
2 |
int |
memory read |
128 |
2 |
|
3 |
store |
store address |
64 |
1 |
|
4 |
store |
store data |
128 |
3 |
|
表8.1. Core2与Nehalem中的执行单元
整形向量操作的时延与通用寄存器中的操作相同。这使得在耗尽通用寄存器时,对简单的整数操作使用MMX寄存器或XMM寄存器,很方便。虽然,支持向量操作的执行单元要少些。
在Core2上,当整数单元的一个输出μop用作浮点单元的输入时,有一个时钟周期的额外时延,反之亦然。这展示在下面例子中。
; Example 8.2a. Bypass delays in Core 2
.data
align 16
signbits label xmmword ; Used for changing sign
dq 2 dup (8000000000000000H) ; Two qwords with sign bit set
.code
movaps xmm0, [a] ; Unit = int, Latency = 2
mulpd xmm0, xmm1 ; Unit = float, Latency = 5 + 1
xorps xmm0, [signbits] ; Unit = int, Latency = 1 + 1
addpd xmm0, xmm2 ; Unit = float, Latency = 3 + 1
在例子8.2a中,在整数与浮点单元间来回移动数据有3个额外时延。这个代码可以通过重排指令,减少整数与浮点单元间切换次数,来改进:
; Example 8.2b. Bypass delays in Core 2
.code
movaps xmm0, [a] ; Unit = int, Latency = 2
xorps xmm0, [signbits] ; Unit = int, Latency = 1
mulpd xmm0, xmm1 ; Unit = float, Latency = 5 + 1
addpd xmm0, xmm2 ; Unit = float, Latency = 3
在例子8.2b中,在乘上XMM1前,我们改变了XMM0的符号。这将整数与浮点单元间的迁移次数从3次降为1次,总时延降低了2个时钟周期。(我们使用MOVAPS与XORPS,而不是MOVAPD与XORPD,因为前者功能相同,但更短)。
读/写单元与整数单元紧密连接,因此在整数单元与读/写单元间传输数据没有额外的时延。当从内存(读单元)向浮点单元传输数据时有1个时钟周期的时延,但在从浮点单元向内存(写单元)传输数据时,没有额外的时延。在手册4“指令表”中,在合适的地方列出了执行单元。
在Nehalem上,执行单元被分为5个“域(domain)”:
当一个域操作的输出被用作另一个域的输入时,有一个1或2个时钟周期的额外时延。这些称为旁路时延,列在表8.2中。
|
目标域 |
|||
来源域 |
integer |
integer vector |
FP |
store |
integer |
0 |
1 |
2 |
0 |
integer vector |
1 |
0 |
2 |
1 |
FP |
2 |
2 |
0 |
1 |
load |
0 |
1 |
2 |
0 |
表8.2. Nehalem中的旁路时延
对整数向量域及FP域,几条XMM指令分别有几个版本。例如,对寄存器-寄存器移动,在整数向量域中有MOVDQA,在FP域中有MOVAPS与MOVAPD。使用错误域中的指令,额外时延相当可观,如下例所示。
; Example 8.3a. Bypass delays in Nehalem
movaps xmm0, [a] ; Load domain, Latency = 2
mulps xmm0, xmm1 ; FP domain, Latency = 4 + 2
pshufd xmm2, xmm0, 0 ; int vec. dom., Latency = 1 + 2
addps xmm2, xmm3 ; FP domain, Latency = 3 + 2
pxor xmm2, xmm4 ; int vec. dom., Latency = 1 + 2
movdqa [b], xmm1 ; Store domain, Latency = 3 + 1
在这个例子中,通过尽可能仅使用同一个域中的指令,可以降低旁路时延:
; Example 8.3b. Bypass delays in Nehalem
movaps xmm0, [a] ; Load domain, Latency = 2
mulps xmm0, xmm1 ; FP domain, Latency = 4 + 2
movaps xmm2, xmm0 ; FP domain, Latency = 1 + 0
shufps xmm2, xmm2, 0 ; FP domain, Latency = 1 + 0
addps xmm2, xmm3 ; FP domain, Latency = 3 + 0
xorps xmm2, xmm4 ; FP domain, Latency = 1 + 0
movaps [b], xmm1 ; Store domain, Latency = 3 + 1
在例子8.3b中,以SHUFPS替换PSHUFD,要求额外一个时延为1的MOVAPS(如果XMM0中值在后面需要),但它节省了4个时钟周期的旁路时延。以XORPS替换PXOR是显而易见的,因为这两条指令功能相同。以MOVAPS替换MOVDQA不改变时延,但在将来的处理器上可能会。
这里重要的结论是,在Nehalem上,使用错误类型的XMM指令会有时延形式的惩罚。在之前的Intel处理器上,对预定类型以外的操作数使用移动或混排指令没有惩罚。
在时延是瓶颈的长时延链中,旁路时延是重要的,但在吞吐率比时延更受关注的地方不是。整数向量版本的移动及布尔指令的吞吐率为每时钟周期3条,而FP版本的移动与布尔指令仅有1时钟周期1条的吞吐率,使用整数向量版本事实上会提升吞吐率。
在错误的数据类型上使用读、写指令仍然没有额外的旁路时延。例如,在整数数据上使用MOVHPS读或写一个XMM寄存器的高半部是方便的。
当不同时延的μop被发布到相同的执行端口时,会有问题。例如:
; Example 8.4. Mixing uops with different latencies on port 0
mulpd xmm1,xmm2 ; Double precision multiply has latency 5
mulps xmm3,xmm4 ; Single precision multiply has latency 4
假定时延为5的双精度乘法在时刻T开始,在时刻T+5结束。如果我们尝试在时刻T+1启动时延为4的单精度乘法,那么这也将在时刻T+5结束。这两条指令都使用端口0。每个执行端口仅有一个回写端口,一次仅能处理一个结果。因此,不可能在同一时间结束两条指令。调度器将预测并通过将后来的指令推迟到时刻T+2,因而在时刻T+6结束,来避免这个回写冲突。代价是一个浪费的时钟周期。
当两个或更多被发布到相同执行端口的μop有不同的时延时,会出现这种冲突。在每个端口,每时钟周期一个μop的最大吞吐率,仅在所有去往相同端口的μop有相同的时延时,才能获得。在浮点乘法的例子中,可以通过对所有浮点计算使用相同的精度,或者分开不同精度的浮点乘法,而不是混用它们,使吞吐量最大。
设计者尝试通过使μop时延标准化来弱化这个问题。端口0仅可以处理时延1或≥ 4的μop。端口1仅可以处理时延1或3的μop。端口5仅可以处理时延1的μop。端口2,3及4处理内存操作,几乎没有别的。在任何执行单元中,没有使用2个时钟周期的μop。(像MOVD EAX, XMM0这样的指令有时延2,但是执行单元中1时钟周期,以及单元间旁路时延的1个额外周期)。
混用时延的问题也会出现在端口1,但不那么频繁:
; Example 8.5. Mixing uops with different latency on port 1 (Nehalem)
imul eax, 10 ; Port 1. Latency 3
lea ebx, [mem1] ; Port 1. Latency 1
lea ecx, [mem2] ; Port 1. Latency 1
在例子8.5中,在IMUL μop两个时钟周期后,我们不能向端口1发布最后的LEA μop,因为在Nehalem上,它们将在相同时间结束(Core2在端口0上有LEA)。在端口1上这个问题是罕见的,因为大多数可以去往端口1的单周期μop也可以去往端口0或5。
8.10. 回收
回收站看起来比PM高效。在Core2或Nehalem上,我没有检测到任何由于回收站瓶颈导致的时延。