17. AMD K8与K10流水线
17.1. AMD K8与K10处理器中的流水线
与Intel桌面处理器的原理相同,AMD微处理器基于乱序执行以及寄存器重命名。
在流水线中,指令被尽可能晚、尽可能小地分解。在执行阶段,每个读-修改宏指令被分解为一个读及一个修改微指令,并在回收前合并为这个宏操作。在AMD术语里,宏操作有点类似于Intel术语里的融合微操作。K8微架构没有比64或80比特大的执行单元,而K10微架构在浮点流水线里有128位执行单元,因此在单个宏指令里可以处理128位XMM指令。
与Intel微架构最重要的区别是,AMD微架构包含3条并行流水线。就在获取阶段后,指令在3个流水线间分发。在简单的情形里,指令待在自己的流水线里直到回收。
流水线的实际长度未知,但基于测得的分支误预测惩罚为12时钟周期,可以推断出,它大约有12个阶段。
下面列出的阶段基于AMD公开的资料,以及由Chip Architect公布的一个独立分析。
整数流水线:
浮点流水线:
浮点流水线比整数流水线长,因为栈映射、寄存器重命名与寄存器读的额外步骤。因为额外的寄存器读步骤,浮点指令测得的最小时延是2时钟周期。浮点指令的最大时延是4时钟周期。
浮点流水线的长度难以测量,因为分支误预测惩罚仅能测量整数流水线的长度。
你可能将流水线的结构想象为一个顺序前端、一个乱序执行核,以及一个顺序回收单元。不过,上面描绘的流水线线性形象有些误导,因为某些过程并行进行,或多或少彼此独立。地址生成需要几个时钟周期,可能在ALU操作之前开始。读-修改与读-修改-写宏操作被分解为去往不同单元的微操作,在不同的时间在乱序核里。顺序前端、分支预测单元、地址生成单元、读写单元、整数算术逻辑单元以及浮点单元都是有自己流水线的独立结构。整数ALU单元与浮点单元可乱序执行操作。读写单元顺序执行所有的内存读,顺序执行所有的内存写,可以在一个后续写之前执行读。其他单元顺序执行所有的操作。
根据我的测试,如果段基址是0,计算地址并在1级缓存中读这个地址需要3时钟周期,如果段基址不是0,需要4时钟周期。现代操作系统使用分页而不是分段来组织内存。因此,在32位与64位操作系统中,可以假设段基址是0(除了通过FS或GS访问的线程信息块)。在16位系统的保护模式以及实模式中,段基址几乎总是0。
要求超过两个宏操作的复杂指令是所谓的向量路径指令。在一条解码线路、一条重排缓冲线路等里,这些指令独占使用所有3个工位,因此其他指令不能并行进行。宏操作在流水线的3 ~ 5阶段从微代码ROM生成。
K7处理器没有双指令(double Instruction)。对所有要求多个宏操作,它使用向量路径过程。否则,K7处理器的微架构非常类似于K8与K10的64位架构,如上面概括的。更早期的AMD处理器有不同的微架构,这里不讨论。
文献:
AMD Athlon™ Processor x86 Code Optimization Guide, Feb. 2002.
AMD Software Optimization Guide for AMD64 Processors, 2005, 2008.
Fred Weber: AMD’s Next Generation Microprocessor Architecture. Oct. 2001.
Hans de Vries: Understanding the detailed Architecture of AMD's 64 bit Core, 2003. www.chip-architect.com。
Yury Malich: AMD K10 Micro-Architecture. 2007. www.xbitlabs.com。
AMD流水线的研究,在Andreas Kaiser与Xucheng Tang帮助下进行。
17.2. 指令获取
在K10上,指令获取器每时钟周期可以从1级代码缓存获取32字节代码。在K7与K8上,每时钟周期可以获取16字节代码到一个32字节缓冲。因此,在更旧的处理器上,如果代码包含许多长指令或跳转,指令获取会是一个瓶颈。2级缓存的代码提交带宽,对K10,测得平均4.22字节每时钟,对K8是2.56字节每时钟。
在K10上,获取的包对齐32字节,在K7与K8上对齐16字节。这有代码对齐的隐含要求。关键例程项与循环项不应该在一个16字节块末尾附近开始。可以对齐关键项到16字节,或至少确保在一个关键标签后前3条指令中没有16字节边界。
分支信息保存在代码缓存中,分支目标缓冲用于获取被预测分支后的代码。跳转与被采用分支的吞吐率是每2时钟周期一个跳转。我视之为获取缓冲仅能包含连续代码的一个迹象。它不能跨越一个被预测的分支。
17.3. 预解码与指令长度解码
指令长度从1到15字节。在代码缓存中,指令边界被标记并拷贝到2级缓存。因此,指令长度解码很少成为瓶颈,即使指令长度解码器每时钟周期仅能处理一条指令。1级代码缓存包含可观的预解码信息。这包括每条指令在哪里结束,opcode字节在哪里,单、双与向量路径指令间的区别,以及跳转与调用识别的信息。这些信息中的一些,但不是所有,被拷贝到2级缓存。来自2级缓存指令的低带宽,可能是由于添加更多预解码信息的过程。
我的实验表明,在K8上,在一个时钟周期里解码3条指令是可能的,即使第三条指令在第一条指令后超出16字节处开始,只要32字节缓冲里留有足够的字节。
微处理器的吞吐率是每时钟周期3条指令,即使对包含被预测跳转的指令流。我们知道一个跳转导致指令获取过程中的一个时延空泡,但在获取与解码间有一个缓冲,使它在这个时延后能赶上。在AMD’s Optimization Guide的某些版本里一个建议是,将每3条指令对齐到8字节,可以改进解码。通过插入伪前缀,使每3条指令8字节长。根据我的实验,这个建议已经过时。解码器总是可以每时钟周期处理3条相对短的指令,不管是否对齐。使指令变长没有好处。我观察到使指令变长,让指令组长度成为8字节倍数(不管是否对齐),仅在很少的情形里会有改进。但使指令变长,更可能有负面影响。
每个指令解码器每时钟周期可以处理3个前缀。这意味着在同一个时钟周期里,可以解码带有3个前缀的3条指令。带有4 ~ 6个前缀的指令需要一个额外时钟周期来解码。
17.4. 单、双与向量路径指令
每条指令产生的宏操作数在手册4“指令表”中列出。
使用一条双指令还是两条单指令没有区别,除了减小代码大小。吞吐率仍然限制在每时钟周期3个宏操作,不是3条指令。这个瓶颈的来源最有可能是回收阶段。如果瓶颈在调度器中,那么不能预期浮点调度器中的一条双指令会限制整数调度器的吞吐率,反之亦然。
向量路径指令效率比单或双指令低,因为它们要求独占解码器与流水线,且不总是重排最优。例如:
; Example 17.1. AMD instruction breakdown
xchg eax, ebx ; Vector path, 3 ops
nop ; Direct path, 1 op
xchg ecx, edx ; Vector path, 3 ops
nop ; Direct path, 1 op
这个序列需要4时钟周期解码,因为向量路径指令必须单独解码。
大多数读-修改与读-修改-写指令仅产生一个宏操作。因此,这些指令比使用单独的读与修改指令更高效。从内存操作数到读-修改指令结果的时延,与读的时延加上算术操作的时延相同。例如,在32位模式里,指令ADD EAX, [EBX]有从EAX输入到EAX输出的1时钟周期时延,以及从EBX到EAX输出的4时钟周期时延。8位或16位读的行为类似于读-修改指令。例如,MOV AX, [EBX]比MOV EAX, [EBX]多1个时钟周期。
宏操作可有任意数量的输入依赖。这意味着带有超过2个输入依赖的指令,比如MOV [EAX+EBX], ECX,ADC EAX, EBX及CMOVBE EAX, EBX,仅产生一个宏操作,而在Intel处理器上,它们要求两个微操作。
17.5. 栈引擎
K10有一个非常类似于Intel处理器的栈引擎(第82页)。这使得栈操作(PUSH,POP,CALL,RET)在K10上比之前的处理器更高效。
17.6. 整数执行流水线
3个整数流水线都有自己的ALU(算术逻辑单元)与AGU(地址生成单元)。每个ALU都可以处理任意整数操作,除了乘法。这意味着在同一时钟周期执行3个单整数指令是可能的,如果它们是无关的。AGU用于内存读、写以及LEA指令的复杂版本。在同一时钟周期进行两个内存操作与一条LEA是可能的。进行3个内存操作是不可能的,因为到数据缓存仅有两个端口。
K10可以在ALU里执行不超过两个操作数的LEA指令,即使它有一个SIB字节。带有比例因子,或者同时带有基址寄存器,索引寄存器及加数的LEA指令,在AGU中执行。未知带有RIP相对地址的LEA是否在AGU执行。在AGU中执行的LEA有2时钟周期的时延。如果AGU与ALU在流水线的同一个阶段,如模型暗示的,假设这两个单元间没有快速的数据转发通道,可能解释了额外的时延。
整数乘法仅在ALU0中进行。一个32位整数乘法需要3时钟周期,且完全流水线化,使新乘法可以在每时钟周期开始。带有累计对象作为隐含操作数及一个显式操作数的整数乘法指令,在DX:AX,EDX:EAX或RDX:RAX中,产生一个双精度大小的结果。对结果的高半部,这些指令使用ALU1。建议使用不产生双精度大小结果的乘法指令,以释放ALU1与EDX。例如,如果结果可以放入32位,使用IMUL EAX, EBX替换MUL EBX。
17.7. 浮点执行流水线
浮点流水线中的3个执行单元被称为FADD,FMUL与FMISC。FADD可以处理浮点加法。FMUL可以处理浮点乘法与除法。FMISC可以处理内存写与类型转换。所有3个单元都可以处理内存读。浮点单元有自己的寄存器文件以及80位数据总线。
浮点加法与乘法的时延是4时钟周期。这些单元完全流水线化,因此新操作可以在每个时钟周期开始。除法需要11个时钟周期,且没有完全流水线化。移动与比较操作的时延是2时钟周期。
3DNow指令的时延与XMM指令相同。使用3DNow指令,而不是XMM指令,几乎没有好处,除非你需要近似倒数指令,3DNow版本比XMM版本更准确、高效。3DNow指令集已经过时,在更新的微处理器上不可用。
在MMX与XMM寄存器里的SIMD整数操作在浮点流水线中处理,而不是整数流水线。FADD与FMUL流水线都有可以处理加法、布尔与偏移操作的整数ALU。整数乘法仅由FMUL处理。
浮点单元的最小时延是2时钟周期。这个时延由流水线设计导致,而不是低时钟频率或慢的加法。大多数SIMD整数ALU操作有2时钟周期的时延。整数乘法有3时钟周期的时延。这些调用完全流水线化,因此新操作可以在每个时钟周期开始。
根据去往的执行单元,浮点单元的宏操作可以分为5类。我将如下命名这些类别:
宏操作类别 |
处理单元 |
||
|
FADD |
FMUL |
FMISC |
FADD |
X |
|
|
FMUL |
|
X |
|
FMISC |
|
|
X |
FA/M |
X |
X |
|
FANY |
X |
X |
X |
表17.1. AMD浮点宏操作类别
浮点调度器将每个宏操作发送给可以处理它的单元。FA/M类别的宏操作可以去往FADD或FMUL单元。FANY类别的宏操作可以去往任意单元。在手册4“指令表”中,所有浮点指令的类别在“AMD指令时序与μop分解”下。
不幸,调度器不能最优地在3个单元间发布宏操作。FA/M类别的宏操作根据可能最简单的算法调度:FA/M宏操作交替去往FADD与FMUL。这个算法确保同一时钟周期提交的两个FA/M宏操作不会进入同一个单元。一个状态比特记录上一次使用的单元,这个比特不会失效。我找不到重置它的方法。
FANY类别宏操作的调度算法仅是稍复杂。FANY类别的宏操作优先去往由它恰好来自的流水线确定的单元。在一系列整数宏操作后的第一个FANY宏操作去往FMISC。在同一时钟周期里,第二条FANY宏操作去往FMUL,可能的第三个FANY宏操作去往FADD。如果在同一时钟周期提交的其他宏操作需要一个特定的浮点单元,那么FANY宏操作可以被重定向到另一个单元。
在确定将一个FA/M或FANY类别宏操作发送到哪里时,浮点调度器不检查一个特定单元是否空闲或者有长的队列。例如,如果一个指令流产生了10个FADD类别的宏操作,然后一个FA/M类别宏操作,有50%的可能性FA/M宏操作将前往FADD单元,尽管发送到FMUL将节约1个时钟周期。
宏操作的这个次优的调度会显著拖慢带有许多浮点指令代码的执行。通过为每个浮点单元设置的性能监控计数器测试一小段关键代码,可以分析这个问题。不过,这个问题难以解决。有时,改变指令次序、使用不同的指令或插入NOP,可能改进宏操作的分布。不过没有通用及可靠的方式解决这个问题。
另一个后果更严重的调度问题在下一段解释。
17.8. 混用不同时延的指令
在混用不同时延的指令时,有调度问题。浮点执行单元被流水线化,因此如果一个4时延的宏操作在时刻0开始,在时刻3结束,同一类型的第二个宏操作可以在时刻1开始,时刻4结束。不过,如果第二个宏操作时延是3,那么它不可以在时刻1开始,因为它将在时刻3结束,与前面的宏操作同时。每个执行单元仅有一个结果总线,这阻止了宏操作同时提交它们的结果。调度器通过不把宏操作派遣到一个执行单元,防止冲突,如果它可以预测在这个宏操作完成时,结果总线不是空闲的。它不能将宏操作重定向到另一个执行单元。
这个问题由下面的例子说明:
; Example 17.2. AMD mixing instruction with different latencies (K8)
; Unit time op 1 time op 2
mulpd xmm0, xmm1 ; FMUL 0-3 1-4
mulpd xmm0, xmm2 ; FMUL 4-7 5-8
movapd xmm3, xmm4 ; FADD/FMUL 0-1 8-9
addpd xmm3, xmm5 ; FADD 2-5 10-13
addpd xmm3, xmm6 ; FADD 6-9 14-17
在这个例子中的每条指令产生两个宏操作,128位寄存器的每个64位部分一个。头两个宏操作是时延为4的乘法。它们分别在时刻0与1开始,在时刻3与4结束。后两个乘法宏操作需要前面宏操作的结果。因此,它们分别直到时刻4与5才能开始,在时刻7与8结束。到目前还好。MOVAPD指令产生时延为2的两个FA/M类别宏操作。其中一个去往空闲的FADD流水线,因此这个宏操作可以立即开始。MOVAPD的另一个宏操作去往FMUL流水线,因为FA/M类别的宏操作交替使用这两个流水线。在时刻2,FMUL流水线准备开始执行新的宏操作,但MOVAPD宏操作不能在时刻2开始,因为这样它将在MULPD第一个宏操作结束的时刻3结束。它不能在时刻3开始,因为这样它将在MULPD第二个宏操作结束的时刻4结束。它不能在时刻4或5开始,因为后两个MULPD宏操作在那里开始。它不能在时刻6或7开始,因为这样结果与后两个MULPD宏操作结果冲突。因此,对这个宏操作,时刻8是是第一个可能的开始时刻。后果是,后续依赖MOVAPD的加法,将被推迟7个时钟周期,即使FADD单元的空闲的。
有两个方法避免上面例子中的问题。第一种可能性是重排指令,将MOVAPD移到两条MULPD指令的前面。这将使MOVAPD的两个宏操作都在FADD与FMUL单元,在时刻0开始。后面的乘法与加法将运行在两个流水线里,彼此没有干扰。
第二个可能的解决方案是用一个内存操作数替换XMM4。MOVAPD XMM3, [MEM]指令产生两个使用FMISC单元的宏操作,在这个例子里它是空闲的。不管时延是否相同,在不同执行流水线中的宏操作间没有冲突。
当然,在K10上的吞吐率比K8上高,但对所有使用浮点寄存器、MMX寄存器或XMM寄存器的指令,死锁问题仍然存在。作为一个一般性指引,可以这样说,在一个2时延的宏操作跟在至少两个时延更长、调度到相同浮点执行单元的宏操作后面,且这些宏操作不需要等待彼此的结果时,会出现死锁。可以将短时延的指令放到前面,或使用去往不同执行单元的指令,避免死锁。
大多数宏操作的时延与执行单元列出如下。完整的列表可以在手册4“指令表”中找到。记住在K10上,一条128位指令通常产生一个宏操作,在K8上是两个宏操作。
宏操作类型 |
时延 |
执行单元 |
寄存器到寄存器移动 |
2 |
FADD/FMUL交替 |
寄存器到内存移动 |
2 |
FMISC |
内存到寄存器移动,64位 |
4 |
任意 |
内存到寄存器移动,128位 |
4 |
FMISC |
整数加法 |
2 |
FADD/FMUL交替 |
整数布尔 |
2 |
FADD/FMUL交替 |
偏移,封装,拆包,混排 |
2 |
FADD/FMUL交替 |
整数乘法 |
3 |
FMUL |
浮点加法 |
4 |
FADD |
浮点乘法 |
4 |
FMUL |
浮点除法 |
11 |
FMUL (not pipelined) |
浮点比较 |
2 |
FADD |
浮点max/min |
2 |
FADD |
浮点倒数 |
3 |
FMUL |
浮点布尔 |
2 |
FMUL |
类型转换 |
2-4 |
FMISC |
表17.2. AMD中的执行单元
17.9. 64位与128位指令
在K10上使用128位指令是一个大优势,但在K8上不是,因为K8上每条128位指令被分解为两个64位宏操作。
在K10上,128位内存写指令被处理为两个64位宏操作,而128位内存读由一个宏操作完成(K8上是2)。
在K8上,128位内存读指令仅使用FMISC单元,K10上是所有3个单元。因此,在K8上使用XMM寄存器将数据块从一个内存位置移动到另一个没有优势,但在K10上有。
根据预定的操作数类型,XMM指令有3个不同的类型:
POR,ORPS与ORPD这3条指令实际上做相同的事情。它们可以互换,但在一条整数指令的输出用作一条浮点指令的输入时,有时延,反之亦然。对这个时延,有两种可能的解释:
解释1:XMM寄存器有一些用于记录浮点值是否为规范、次规范或零的标记位。在一条整数指令的输出用作一条单精度或双精度浮点指令的输入时,必须设置这些标记位。这导致了所谓的重新格式化时延。
解释2:在整数与浮点SIMD单元之间没有快速数据转发通道。这导致了类似于P4上执行单元间的时延。
单精度与双精度浮点指令间没有时延,但浮点到整数指令有时延的事实,支持解释2。
在从内存读且不进行计算的指令后,时延没有差别。在写内存且不进行计算的指令前,没有时延。因此,对于内存读写,可以使用MOVAPS指令,而不是多1字节的MOVAPD或MOVDQA。
在一条内存读指令的输出(不管类型)用作一条浮点指令的输入时,通常有一个2时钟周期的时延。这支持解释1。
对算术操作,使用错误类型的指令是不合理的,但对仅移动数据或执行布尔操作的指令,只要不导致时延,使用错误类型可能是有好处的。名字以PS结尾的指令比其他等效指令要短1字节。
17.10. 寄存器的部分访问
处理器总是将一个整数寄存器的不同部分放在一起。因此,AL与AH不被乱序执行机制视为无关。这会在写寄存器一部分的代码中导致假的依赖性。例如:
; Example 17.3. AMD partial register access
imul ax, bx
mov [mem1], ax
mov ax, [mem2]
在这个情形里,第三条指令有对第一条指令的一个假依赖,由EAX的高半部导致。第三条指令写入EAX(或RAX)的低16位,在EAX新的值可以被写入前,这16位必须与EAX的余下部分合并。结果到AX的移动必须等待前面的乘法完成,因为不能分开EAX的不同部分。通过在到AX的移动前插入一条XOR EAX, EAX指令,或以MOVZX EAX, [MEM2]替代MOV AX, [MEM2],可以消除这个假依赖。
不管是在16位模式、32位模式,抑或64位模式中执行,上面例子的行为都相同。在64位模式中,要消除这个假依赖,EAX清零就足够了。不需要清零整个RAX,因为写入一个64位寄存器的低32位总是重置该64位寄存器的高半部。但写入一个寄存器的低8位或低16位,不会重置该寄存器余下的部分。
这个规则不适用于K8上的XMM寄存器,在K8上,每个128位寄存器被保存为两个无关的64位寄存器。
17.12. 标记寄存器的部分访问
处理器将算术标记分为至少以下组:
这意味着一条仅修改进位标记的指令没有对零标记的假依赖,一条仅修改零标记的指令有对符号标记的假依赖。例如:
; Example 17.4. AMD partial flags access
add eax, 1 ; Modifies all arithmetic flags
inc ebx ; Modifies all except carry flag. No false dependence
jc L ; No false dependence on EBX
bsr ecx, edx ; Modifies only zero flag. False depend. on sign flag
sahf ; Modifies all except overflow flag
seto al ; No false dependence on AH
17.13. 写转发暂停
在写入一个内存位置后,立即从该位置读,如果读比写大,会有一个惩罚,因为在这个情形下,写转发机制不能工作。例如:
; Example 17.5. AMD store forwarding
mov [esi], eax ; Write 32 bits
mov bx, [esi] ; Read 16 bits. No stall
movq mm0, [esi] ; Read 64 bits. Stall
movq [esi], mm1 ; Write after read. No stall
如果读没有在与写相同的地址开始,也有惩罚:
; Example 17.6. Store forwarding stall
mov [esi], eax ; Write 32 bits
mov bl, [esi] ; Read part of data from same address. No stall
mov cl, [esi+1] ; Read part of data from different address. Stall
如果写源自AH,BH,CH或DH,也有惩罚:
; Example 17.7. Store forwarding stall for AH
mov [esi], al ; Write 8 bits
mov bl, [esi] ; Read 8 bits. No stall
mov [edi], ah ; Write from high 8-bit register
mov cl, [edi] ; Read from same address. Stall
17.14. 循环
AMD K8与K10的分支预测机制在第23页描述。
在AMD上,小循环的速度通常受指令获取限制。不超过6个宏操作的小循环可以在2个时钟周期里执行每次迭代,如果它包含不超过1个跳转,且在K10上不包含32字节边界,在K8上不包含16字节边界。如果在K10上,代码中有一个32字节边界,每迭代将需要一个额外时钟周期,因为它需要获取一个额外的32字节块,或者在K7或K8上包含16字节边界。
最大获取速度可由以下规则概括:
一个循环的每迭代最小执行时间,在K10上大致上等于代码中32字节边界数,或者在K8上16字节边界, 加上被采用分支及跳转数的2倍。
例如:
; Example 17.8. AMD branch inside loop
mov ecx,1000
L1: test bl,1
jz L2
add eax,1000
L2: dec ecx
jnz L1
假定在JNZ L1指令处是32字节边界。那么如果JZ L2不跳转,该循环将需要3时钟周期,如果JZ跳转,需要5时钟周期。在这个情形里,我们可以通过在L1前插入一个NOP,使得32字节边界移动到L2,改进代码。那么循环将分别需要3与4时钟周期。在JZ L2跳转的地方,我们节省了1时钟周期,因为32字节边界被移到我们绕过的代码。
如果指令获取是瓶颈,这些考虑才是重要的。如果在循环中别的东西,比计算的获取时间,需要更多时间,没有原因优化指令获取。
17.15. 缓存
1级代码缓存与1级数据缓存都是64K字节,2路组相联,每行64字节。数据缓存有两个端口可用于读或写。这意味着在同一时钟周期,它可以进行两次读或两次写,或者一次读与一次写。在K10上,每个读端口是128位,K8上是64位。在K8与K10上,写端口都是64位。这意味着一个128位写操作要求两个宏操作。
在代码缓存行中,每个64字节行被分为4块,每块16字节。在数据缓存中,每个64字节行被分为8个每个8字节的库(bank)。在同一时钟周期,数据缓存不能执行两个内存操作,如果它们使用相同的库,除了相同缓存行的两个读:
; Example 17.9. AMD cache bank conflicts
mov eax, [esi] ; Assume ESI is divisible by 40H
mov ebx, [esi+40h] ; Same cache bank as EAX. Delayed 1 clock
mov ecx, [esi+48h] ; Different cache bank
mov eax, [esi] ; Assume ESI is divisible by 40H
mov ebx, [esi+4h] ; Read from same cache line as EAX. No delay
mov [esi], eax ; Assume ESI is divisible by 40H
mov [esi+4h], ebx ; Write to same cache line as EAX. Delay
(See Hans de Vries: Understanding the detailed Architecture of AMD's 64 bit Core, Chip Architect, Sept. 21, 2003. www.chip-architect.com. Dmitry Besedin: Platform Benchmarking with Right Mark Memory Analyzer, Part 1: AMD K7/K8 Platforms. www.digit-life.com.)
带有内存访问操作的执行使用3个有各自流水线的不同单元:(1)一个算术逻辑单元(ALU)或其中一个浮点单元,(2)地址生成单元(AGU),(3)一个读写单元(LSU)。ALU用于读-修改与读-修改-写指令,但不用于仅读或写的指令。AGU与LSU用于所有的内存指令。对读-修改-写指令,LSU使用两次。ALU与AGU微操作可以乱序执行,而LSU微操作在大多数情形里顺序处理。就我所知,这些规则如下:
建议在代码中尽早读或计算指针与索引寄存器的值,以避免后续内存操作的时延。内存操作必须等待所有之前内存操作的地址已知的事实,会导致假的依赖,例如:
; Example 17.10. AMD memory operation delayed by prior memory operation
imul eax, ebx ; Multiplication takes 3 clocks
mov ecx, [esi+eax] ; Must wait for EAX
mov edx, [edi] ; Read must wait for above
可以通过在ECX之前读EDX,使得读EDX无需等待慢的乘法,来改进这个代码。
如果数据跨了一个8字节边界,对非对齐内存引用有1时钟周期的暂停。非对齐还阻止了写到读的转发。例如:
; Example 17.11. AMD misaligned memory access
mov ds:[10001h], eax ; No penalty for misalignment
mov ds:[10005h], ebx ; 1 clock penalty when crossing 8-byte boundary
mov ecx, ds:[10005h] ; 9 clock penalty for store-to-load forwarding
2级缓存有512KB或更多,16路组相联,每行64字节以及一条16字节宽的总线。行由一个伪LRU方案逐出。
可以正或负步长自动预取数据流。数据仅被预取到2级缓存,不到1级缓存。
用于数据时,2级缓存包括自动纠错比特,但用于代码时没有。代码是只读的,因此在校验错误时可以从RAM重新读入。节省下来的比特用于保存来自1级缓存的指令边界与分支预测信息。
K10有2MB的3级缓存。有别的3级缓存大小的版本也可能会出现。3级缓存在所有的核之间共享,而每个核有自己的1级与2级缓存。
17.16. AMD K8与K10中的瓶颈
在优化一段代码上,找出控制执行速度的限制因素是重要的。调整错误的因素不太可能有任何益处。在下面的段落中,我将解释AMD微架构中每个可能的限制因素。
在K8及更早的处理器上,指令获取被限制为每时钟周期16字节代码。在流水线其他部分每时钟周期可以处理3条指令时,这会是一个瓶颈。在K10上,指令获取不太可能是瓶颈。
被采用跳转的吞吐率是每2个时钟周期一个。在一个跳转后的指令获取被进一步推迟,如果在该跳转后的头3条指令中有16字节边界。建议把最关键例程入口与循环入口对齐到16字节,或至少确保关键的跳转目标不是在对齐的16字节块末尾附近。小循环中跳转与16字节边界数应该尽可能少。参考上面第174页。
最大重排深度是24个整数宏操作加上36个浮点宏操作。内存操作不能乱序调度。
执行单元有比可能的使用率大得多的能力。据称,9个执行单元可以同时执行9个宏操作,但几乎不可能通过实验验证这个声明,因为回收限制在每时钟周期3个宏操作。所有3条整数流水线都可以处理所有整数操作,除了乘法。因此,整数执行单元不会是瓶颈,除了乘法极多的代码。
在没有执行单元收到超过三分之一宏操作时,可以得到每时钟周期3个宏操作的吞吐率。至于浮点代码,很难在3个浮点单元间获得宏操作完美的均匀分布。因此,建议混用浮点指令与整数指令。
浮点调度器不能在浮点执行单元间最优地分配宏操作。一个宏操作可能去往一个有长队列的单元,而另一个单元则是空闲的。参考第169页。
所有的浮点单元都流水线化,吞吐率是每时钟周期一个宏操作,除了除法以及其他几条复杂的指令。
混用不同时延的宏操作,对同一个浮点单元进行调度会严重妨碍乱序执行。参考第169页。
避免长依赖链以及在依赖链中避免内存立即数。可以通过写一个寄存器或者在寄存器自身上执行以下指令,来破坏一个假依赖:XOR,SUB,SBB,PXOR,XORPS,XORPD。例如,XOR EAX, EAX,PXOR XMM0, XMM0,但不是XOR AX, AX,PANDN XMM0, XMM0,PSUBD XMM0, XMMO或比较指令。注意SBB有对进位标记的依赖。
访问一个寄存器的部分导致对该寄存器余下部分的一个假依赖,参考第172页。访问标记寄存器的部分不会导致一个假依赖,除了罕见情形,参考第173页。
跳转与分支的吞吐率是每2时钟周期一个被采用的分支。如果紧跟着跳转目标是16字节边界,吞吐率会更低。参考第174页。
分支预测机制允许每16字节的对齐代码不超过3个被采用的分支。如果遵守这个规则,总是去往相同地方的跳转被良好预测。参考第23页。
动态分支预测基于一个仅有8或12比特的历史。另外,模式识别通常出于未知原因失败。总是去往相同地方的分支不会污染分支历史寄存器。
回收过程限制为每时钟周期3个宏操作。如果有产生多个宏操作的指令,这很可能成为瓶颈。