怎样优化Pentium系列处理器的代码

Copyright © 1996, 2000 by Agner Fog. Last modified 2000-07-03.


方便面 (fangbianmian) 译 86.7%


云风 (Cloud Wu) 译 http://www.codingnow.com/ 13.3%

目录

  1. 简介
  2. 文献
  3. 高级语言中调用汇编函数
  4. 调试及校验
  5. 内存模式
  6. 对齐
  7. Cache
  8. 第一次 vs 重复运行
  9. 地址生成互锁(AGI) (PPlain 及 PMMX)
  10. 配对整数指令 (PPlain 及 PMMX)
    1. 完美的配对
    2. 有缺陷配对
  11. 将复杂指令集分割为简单指令 (PPlain 及 PMMX)
  12. 前缀 (PPlain 及 PMMX)
  13. PPro, PII 及 PIII 流水线综述
  14. 指令解码 (PPro, PII 及 PIII)
  15. 取指令 (PPro, PII 及 PIII)
  16. 寄存器重命名 (PPro, PII 及 PIII)
    1. 消除依赖性
    2. 寄存器读延迟
  17. 乱序执行 (PPro, PII 及 PIII)
  18. 引退 (PPro, PII 及 PIII)
  19. 部分(Partial)延迟 (PPro, PII 及 PIII)
    1. 部分寄存器延迟
    2. 部分标志延迟
    3. 移位和旋转后的标记延迟
    4. 部分内存延迟
  20. 依赖环 (PPro, PII 及 PIII)
  21. 寻找瓶颈 (PPro, PII 及 PIII)
  22. 分支和跳转 (所有的处理器)
    1. PPlain 的分支预测
    2. PMMX, PPro, PII 及 PIII的分支预测
    3. 避免跳转 (所有的处理器)
    4. 避免使用标记的条件跳转 (所有的处理器)
    5. 将条件跳转替换成条件赋值 (PPro, PII 及 PIII)
  23. 减少代码长度 (所有的处理器)
  24. 规划浮点代码 (PPlain 及 PMMX)
  25. 循环优化 (所有的处理器)
    1. PPlain 及 PMMX 中的循环
    2. PPro, PII 及 PIII 中的循环
  26. 有问题的指令
    1. XCHG (所有的处理器)
    2. 大循环移位 (所有的处理器)
    3. 串操作指令 (所有的处理器)
    4. 位测试 (所有的处理器)
    5. 整数乘法 (所有的处理器)
    6. WAIT 指令 (所有的处理器)
    7. FCOM + FSTSW AX (所有的处理器)
    8. FPREM (所有的处理器)
    9. FRNDINT (所有的处理器)
    10. FSCALE 及指数函数 (所有的处理器)
    11. FPTAN (所有的处理器)
    12. FSQRT (PIII)
    13. MOV [MEM], ACCUM (PPlain 及 PMMX)
    14. TEST 指令 (PPlain 及 PMMX)
    15. 位扫描 (PPlain 及 PMMX)
    16. FLDCW (PPro, PII 及 PIII)
  27. 特别主题
    1. LEA 指令 (所有的处理器)
    2. 除法 (所有的处理器)
    3. 释放浮点寄存器 (所有的处理器)
    4. 浮点指令与MMX 指令的转换 (PMMX, PII 及 PIII)
    5. 浮点转换为整数 (所有的处理器)
    6. 使用整数指令做浮点运算 (所有的处理器)
    7. 使用浮点指令做整数运算 (PPlain 及 PMMX)
    8. 数据块的移动 (所有的处理器)
    9. 自修改代码 (所有的处理器)
    10. 检测处理器类型 (所有的处理器)
  28. 指令速度列表 (PPlain 及 PMMX)
    1. 整数指令集
    2. 浮点指令集
    3. MMX 指令集 (PMMX)
  29. 指令速度及微操作失败列表(PPro, PII 及 PIII)
    1. 整数指令集
    2. 浮点指令集
    3. MMX 指令集 (PII 及 PIII)
    4. XMM 指令集 (PIII)
  30. 速度测试
  31. 不同的微处理器间的比较


1. 简介

这本手册细致的描述了怎样写出高度优化的汇编代码,着重于讲解Pentium系列的微处理器。

这儿所有的信息都基于我的研究。 很多人为这本手册提供了有用的信息和错误矫正, 而我在获得任何新的重要信息后都更新它。 因此这本手册比其它类似的信息来源都更准确,详尽,精确和便于理解, 而且它还包含了许多其它地方找不到的细节描述。 这些信息使你能够用多种方法精确统计一小段代码花掉的时钟周期数。 但是,我不能保证手册里所有的信息都是精确的: 一些时间测试等是很难或者不可能精确测量的, 我看不到 Intel 手册作者拥有的内部技术文档资料。

这本手册讨论了 Pentium 处理器的下列版本:

缩写 名字
PPlain plain 老式 Pentium (没有 MMX)
PMMX 有MMX的Pentium
PPro Pentium Pro
PII Pentium II (包括 Celeron 和 Xeon)
PIII Pentium III (包括一些相当的CPU)

这本手册中使用了MASM 5.10的汇编语法。 X86 汇编语言没有什么官方标准, 但MASM 5.10的汇编语法最接近事实上的标准。 因为几乎所有的汇编器都有 MASM 5.10 兼容模式 (然而我不推荐使用MASM的5.10版本,因为它在 32 位模式下有严重的 Bug。最好是使用 TASM 或者 MASM 的后续版本)。

手册里的一些评论好象是对Intel的批评。 但这并不是说其它的产品会好一些。 与众多与之竞争的商品相比,Pentium系列的微处理器是属于比较好的,它有更好的文档,和更多可测试的特性。由于这些原因,不会有我或者其他人做同类商品的比较测试。

汇编语言编程比用高级语言要复杂的多。 制造 Bug 是很容易的,但是找到 Bug 却很难。 现在已经提醒你了! 我假定读者已经有汇编编程的经验。 没有的话,请在做复杂的优化前读一些汇编的书并且写些代码获得些汇编的经验。

PPlain 和 PMMX 芯片的硬件设计中有许多特性是为一些常用指令或指令对作专门优化的,而不是使用那些普通的优化方法。因为有这些设计,所以优化软件的规则很复杂,且有很多的例外,但是这样做可能获得实质性的好处。PPro, PII 和 PIII 处理器有非常不同的设计,它们会利用乱序执行来做许多的优化工作,但是处理器的这些复杂设计带来了许多潜在的瓶颈,因此为这些处理器进行手工优化将得到许多的好处。Pentium 4 处理器也用了另外一种设计,奔腾4 的优化指导路线和前面的版本非常的不同了。这个手册没有禳括奔腾4 - 读者请自己查阅 Intel 的手册。

在把你的代码转为汇编的之前,确认你的算法是足够优化的。 通常你可以通过优化算法来将代码效率提高的比转成汇编获得的效率多的多。

第二,你必须找到你的程序里最关键的部分。 通常 99% 的 CPU 时间花在程序最里面的循环中。 在这种情况下,你只要优化这个循环并把其它的所有东西都用高级语言写。 一些汇编程序员将大量的精力花在了他们程序的错误的部分上,他们努力得到的唯一结果就是程序变的更加难以调试和维护了。

如果你的程序的关键部分并不那么明显,你可以用profil来找。如果发现瓶颈在磁盘操作,然后你就可以试着修改程序使磁盘操作集中连续,提高磁盘缓冲的命中率,而不是用汇编来写代码。 如果瓶颈在图象输出,那么你就可以尝试找到一种方法来减少调用图象函数的次数。

一些高级语言编译器对于指定的处理器提供了相对好的优化,但是深入的手工优化将做的更好。

请不要将你的编程问题寄给我。我不会帮你做家庭作业的!

祝你在后面的阅读中好运!

2. 文献


在 Intel 的 www 站上,打印的文本或者 CD-ROM 上都有很多有用的文献和指南。 建议你研究一下这些文档来对微处理器的结构有些认识。 然而,Intel 的文档也不总是对的——尤其是那些指南有很多错误(显然,Intel的那些人没有测试他们的例子)。

这里我不给出 URL,因为文件的位置经常的改变。 你可以利用 developer.intel.com 或者www.agner.org/assem 链接上的搜索工具找到你要的文档。

一些文档是.PDF格式的。如果你没有显示或者打印PDF的工具,可以去http://www.adobe.com/下载Acrobat文件阅读器。

使用 MMX 和 XMM (SIMD) 指令优化专门的程序在几本使用手册里都有描述。 各种手册和教程都描述了它的指令集。

VTUNE 是 Intel 用来优化代码的软件工具我没有测试它,因此这里不予评价。

还有很多站点比 Intel 有更多的有用信息。 在新闻组 comp.land.asm.x86 的 FAQ 里列出了这些资源。其它的 internet 上的资源在 www.agner.org/assem 上也有链接。

3. 在高级语言里调用汇编函数

你可以使用在线汇编或者用汇编写整个子程序然后再连接到你的工程中。 如果你选择后者,建议你选择可以将高级语言直接编译成汇编的编译器。 这样你可以得到正确的函数调用原型。 所有的 C++ 编译器都能做这个工作。

传递参数的方法取决于调用形式:

 调用方式   参数在堆栈里的次序   参数由谁来移去 
 _cdecl   第一个参数在低位地址   调用者 
 _stdcall   第一个参数在低位地址   子程序 
 _fastcall   编译器指定   子程序 
 _pascal   第一个参数在高位地址   子程序 

函数调用原型和被编译器命名的函数名可能非常的复杂。 有很多不同的调用转换规则, 不同的编译器也互不兼容。 如果你从C++里调用汇编语言的子程序,最好的方法是将你的函数用 extern "C" 和 _cdecl 定义来做到兼容性和一致性。 汇编代码的函数名前面必须带一个下划线 (_) 并且在外面编译时加上大小写敏感的选项 (选项 -mx)。 例如:

; extern "C" int _cdecl square (int x);
_square PROC NEAR ; 整型平方函数
PUBLIC _square
MOV EAX, [ESP+4]
IMUL EAX
RET
_square ENDP

如果你需要重载函数,重载操作符,方法,和其它 C++ 专有的东西,就必须先用 C++ 写好代码再用编译器编译成汇编代码以获得正确的连接信息和调用原型。这些细节随着编译器的不同而不同而且很少列出文档。 如果你希望汇编函数用其它的调用原型而不是 extern "C" 及 _cdecl,又可以被不同的编译器调用,那么你需要为每个编译器写一个名字。 例如重载一个 square 函数:

; int square (int x);
SQUARE_I PROC NEAR ; 整型平方函数
@square$qi LABEL NEAR ; Borland 编译器的连接名字
?square@@YAHH@Z LABEL NEAR ; Microsoft 编译器的连接名字
_square__Fi LABEL NEAR ; Gnu 编译器的连接名字
PUBLIC @square$qi, ?square@@YAHH@Z, _square__Fi
MOV EAX, [ESP+4]
IMUL EAX
RET
SQUARE_I ENDP


; double square (double x);
SQUARE_D PROC NEAR ; 双精度浮点平方函数
@square$qd LABEL NEAR ; Borland 编译器的连接名字
?square@@YANN@Z LABEL NEAR ; Microsoft 编译器的连接名字
_square__Fd LABEL NEAR ; Gnu 编译器的连接名字
PUBLIC @square$qd, ?square@@YANN@Z, _square__Fd
FLD QWORD PTR [ESP+4]
FMUL ST(0), ST(0)
RET
SQUARE_D ENDP

这个方法能够工作是因为所有这些编译器对重载的函数都缺省使用_cdecl 调用。 然而对于不同的编译器,甚至对方法(成员函数)的调用方式都不一样 (Borland 和 Gnu 编译器使用 _cdecl 方式,'this' 指针是第一个参数;而 Microsoft 使用 _stdcall 方式,'this' 指针放在 ecx 里)。

通常来说,当你使用了下列东西时,不要指望不同的编译器在目标文件级别可以兼容: long double,成员指针,虚机制,new,delete,异常,系统函数调用,以及标准库函数。

16 位模式DOS或Windows, C/C++ 的寄存器使用:
AX是16位返回值,DX:AX是32 位返回值,ST(0)是浮点返回值。寄存器 AX, BX, CX, DX, ES 和算术标志可以被过程改变; 其它的寄存器必须保存和恢复。 一个过程要不改变SI, DI, BP, DS 和 SS 的前提下才不会影响另一个过程。

32位模式Windows, C++ 和其它编程语言下的寄存器使用:
整型返回值放在 EAX, 浮点返回值放在 ST(0)。 寄存器 EAX, ECX, EDX (没有 EBX) 可以被过程修改; 其它的寄存器必须保留和恢复。段寄存器不能被改变,甚至不能被临时改变。 CS, DS, ES 和 SS 都指向平坦模式的段。 FS 被操作系统使用, GS 没有使用,但是被保留。 标记位可以在下面的限制下被过程改变: 方向标志缺省是0。方向标志可以暂时的修改, 但是必须在任何的调用或者返回前清除。中断标志不能被清除。 浮点寄存器堆栈在过程入口处是空的,返回时也应该是空的,除了 ST(0) 被用于返回值的情况。 MMX 寄存器可以被改变但是在返回前或者在调用可能使用浮点运算的过程前必须用 EMMS 清一下。 所有的 XMM 寄存器都可以被过程修改。 在XMM 寄存器里的传递参数和返回值的描述在 Intel 的应用文档 AP 589。 一个过程可以在不改变EBX, ESI, EDI, EBP 和所有的段寄存器的前提下被另一个过程调用。

4. 调试和校验

正如你已经发现的,调试汇编代码非常的困难和容易受到挫折。 我建议你先把你需要优化的小段代码用高级语言写成一个子程序。 然后写个小的测试程序可以充分测试你的这个子程序。 确认测试程序可以测试到所有的分支和边界条件。

当高级语言的子程序可以工作了,你再把它翻译成汇编代码。

现在你可以开始优化了。 每次你做了点修改都应该运行测试程序看看能不能正确工作。 将你所有的版本都标上号并保存起来,这样在发现测试程序检查不到的错误 (比如写到错误的地址)时可以回头来重新测试。

第30章里提到的所有方法或者用测试程序测试最你的程序中最关键的部分。 如果代码比你期望的速度慢的太多,最可能的原因是: cache 失效 (第7章),未对齐操作(第6章),第一次运行消耗(第8章),分支预测失败(第22章) ,取指令问题(第15章),寄存器读延迟(第16章),或者是过长的依赖环(第20章)。

高度优化的代码将变得对其他人非常难读懂,甚至对你日后再读也有困难。 为了使维护代码变为可能,将代码组织为一个个小的逻辑段(过程或者宏)且每段都具有好的接口和清楚地注释就非常重要。 代码越复杂艰涩,写下好的文档就越重要。

5. 内存模式

Pentium 主要为 32 位代码设计,16位代码的性能很差。 将你的代码和数据分段也会明显的降低性能,因此通常你应当使用32位平坦模式,并且使用支持这种模式的操作系统。 如果不特别注明,这本手册里所有的例子都使用32位平坦内存模式。

 

6. 对齐

内存里的所有数据都必须按照下表将地址对齐到可以被 2,4,8 或 16 整除的位置:

 
对齐
 操作数据长度   PPlain 及 PMMX   PPro, PII 及 PIII 
 1 (byte)  1 1
 2 (word)  2 2
 4 (dword)  4 4
 6 (fword)  4 8
 8 (qword)  8 8
 10 (tbyte)  8 16
 16 (oword)  n.a. 16

在 PPlain 和 PMMX 上,在4字节边界线交错的时候,访问未对齐数据将至少有 3 个时钟周期的额外消耗。 当cache边界线被交错的时候损耗更大。

在 PPro,PII 和 PIII 上,当cache边界线交错时,未对齐数据将消耗掉 6-12 个时钟周期。 尺寸小于 16 字节的未对齐操作数,没有在 32 字节边界上交错时将没有额外的损耗。

在 dword 堆栈上以 8 或 16 对齐数据可能会有问题。常用的方法是设置对齐的结构指针。对齐本地数据的函数可以是这样:

_FuncWithAlign PROC NEAR
PUSH EBP ; 前续代码
MOV EBP, ESP
AND EBP, -8 ; 以 8 来对齐帧指针
FLD DWORD PTR [ESP+8] ; 函数参数
SUB ESP, LocalSpace + 4 ; 分配本地空间
FSTP QWORD PTR [EBP-LocalSpace] ; 在对齐了的空间保存一些东西
。。。
ADD ESP, LocalSpace + 4 ; 结束代码。 恢复 ESP
POP EBP ; (PPlain/PMMX 上有 AGI 延迟)
RET
_FuncWithAlign ENDP

虽然对齐数据永远是重要的,但是在 PPlain 和 PMMX 上对齐代码却没有必要。 PPro,PII 及 PIII 上对齐代码的原则在第15章阐述。

7. Cache

PPlain和PPro带有片内cache(一级cache)其中8kb 代码cache,8kb 数据cache。 PMMX,PII 和 PIII 则有 16 kb 代码cache 和 16 kb 数据 cache。 一级 cache 里的数据可以在1个时钟周期内读写,cache 未命中时将损失很多时钟周期。 理解 cache 是怎样工作的非常重要,这样才能更有效的使用它。

数据cache 由 256 或 512 行组成,每行 32 字节。 每次你读数据未命中,处理器将从内存读出一整条 cache 行。 cache 行总是在物理地址的 32 字节对齐。 当你从一个可以被 32 整除的地址读出一个字节, 下 31 字节的读写就不会有多余的消耗。 因此在内存中,你可以把相关数据项放在对齐的32字节块里(集中访问)来获得好处。 例如,如果你有一个循环要操作两个数组,你就可以将两个数组穿插成一个结构数组, 让一起使用的数据的物理位置也在一起。

如果数组或者其它数据结构的尺寸是 32 字节的倍数,你最好将其按 32 字节对齐。

cache 是组相联映像的。 这就是说一个cache 行不能随心所欲地指向任意内存地址。 每个 cache 行有一个 7-bit 的组值,它匹配物理地址的 5 到 11 位 ( 0-4 位指定32字节cache行的行内地址)。 PPlain 和 PPro 可以有 2 条 cache 行对应 128 个组值中的一个值(即每组 2 行),因此对任何RAM地址,可能有两条 cache 行指向它。 PMMX,PII 和 PIII 则是 4 条。

其结果是 cache 保存的地址 5-11 位相同(即具有相同组值)的不同数据块的数目不能超过 2 个(PPlain 和 PPro)或 4 个(PMMX, PII 和 PIII)。 你可以用以下方法检测两个地址是否有相同的组值:截掉地址的低 5 位得到可以被 32 整除的“截断地址”(即令 低5位=0)。 如果两个截断地址之差是 4096 (=1000H) 的倍数, 这两个地址就有相同的组值。

让我用下面的一小段代码来说明一下, 这里 ESI 放置了一个可以被 32 整除的地址:

AGAIN:  MOV EAX, [ESI]
    MOV EBX, [ESI + 13*4096 + 4]
    MOV ECX, [ESI + 20*4096 + 28]
    DEC EDX 
    JNZ AGAIN

这3个地址都有相同的组值, 因为截断地址的差都是 4096 的倍数。 这个循环在 PPlain 和 PPro 上将运行的相当慢。 当你读 ECX 的时候, 没有空闲的 cache 行有想要的组值, 因此处理器用最近最少使用算法替换的两个cache 行中的一个, 这就是 EAX 使用的那个。 然后从 [ESI+20*4096] 到 [ESI+20*4096+31] 读出数据来填充该cache行并完成写ECX的操作。 下一次再 读EAX时, 你将发现为EAX保存数据的cache行已经被丢弃了, 所以又要替换最近最少使用的 cache 行, 那就是保存 EBX 数值的那个了,如此颠簸... 这将会产生大量的 cache 失效, 这个循环大概开销 60 个时钟周期。 如果第3行改成:

MOV ECX, [ESI + 20*4096 + 32]

这样我们就会在 32 字节边界上交错, 因此和前两行的组值不同了。 这样为这三个地址分别指定cache行就没有什么问题了。 这个循环仅仅消耗3个时钟周期(除了第一次运行) —— 一个相当大的提高! 如刚才提到的,PMMX, PII 和 PIII 每组有 4 路cache行,因此你可以有4个相同组值的cache行(一些Intel文档错误的说 PII的cache是2路)。

检测你的数据地址是否有相同的组值可能非常困难,尤其是它们分散在不同的段里。 要避免这种问题的最好能做的就是将关键部分使用的所有数据都放在一个不超过cache大小的连续数据块里,或者放在两个不超过cache一半大小连续数据块 (例如一个静态数据块,一个堆栈数据块) 这样你的cache行就一定会高效使用。

如果你的代码的关键部分要操作很大的数据结构或者随机数据地址,你可能会想保存所有常用的变量(计数器,指针,控制变量等) 在一个单独的最大为 4k 的连续块里面, 这样你就有一个完整的空闲cache行集来访问随机数据。 因为你通常总是需要栈空间来为子程序保存参数和返回地址, 最好的做法是复制所有的常用静态数据到堆栈(把它们复制成动态变量),如果它们被改变,就在关键循环外再复制回去。

读一个不在一级缓存里的数据将导致从二级缓存读入整个cache行,这大约要消耗 200ns (在 100MHz 系统上是 20 时钟周期, 或是 200MHz 上的 40 个周期),但是你最先需要的数据将在 50-100 ns后准备好。 如果数据也不在二级缓存,你将会碰到 200-300 ns 的延迟。 如果数据在 DRAM 页边界交错,延迟时间会更长一些。 (4/8 MB ,72引脚内存芯片的 DRAM 页大小是 1Kb,16/32 Mb 的是 2kb)。

当从内存读入大块的数据, 速度限制在于填充 cache 行。 有时你可以以非连续的次序读取数据来提高速度: 在你读完一个cache行之前就开始读下一个cache行的第一个数据。 在PPlain和PMMX上读主存或二级cache,以及在PPro,PII,PII上读二级cache,用这个方法可以提高读入速度20 - 40%。 这个方法的不利地方在于使程序代码变的非常的笨拙和难于理解。 关于这些技巧的更多信息请参考http://www.intelligentfirm.com/

当你写向一个不在 一级cache 的地址,在 PPlain和PMMX上这个数值将直接写到 二级cache 或者是 RAM(这取决于2级cache如何设置)。 这大约消耗 100 ns。 如果你向同一个32字节的内存块反复写8次或8次以上但没有从里面读,而且这个块不在一级缓存, 那么较好的做法是先对该块作一个“哑读”使其进入cache行,如此一来随后所有向这个块的写操作就会被定向到cache里,每次只消耗一个时钟周期。 在 PPlain 和 PMMX 上,有时会因为重复写向一个地址而在其间没有读它而带来小小的惩罚。

在 PPro,PII 和 PIII上,一次写操作的cache失效通常会导致读入一个cache行,但也有可能使存储区域做不同的操作,例如显存 (见 Pentium Pro 系列开发者手册, vol.3 : 操作系统写作者指南")。

提高内存读写速度的方法在下面的27.8章节讨论。

PPlain 和 PPro 有 2 个写缓存,PMMX, PII 和 PIII 有 4 个。 故在 PMMX, PII 和 PIII 上你最多可以有4个未完成的不命中cache的写操作而不会使后面的指令产生延迟。 每个写缓存可以处理的操作数宽度最多64位。

临时数据可以方便地放在堆栈里,因为堆栈区域非常有可能在cache中。然而,如果你的数据元素大于堆栈字大小时应该注意对齐问题。

如果两个数据结构的生命期不重叠的话,那么它们可能会共享相同的 RAM 区域从而提高cache效率。这与在堆栈中为临时变量分配空间的普遍习惯是一致的。

将临时数据保存在寄存器里将有更高的效率。 既然寄存器是一种稀有资源,你可能想用[ESP]而不是[EBP]来定位堆栈里的数据, 这样就可以释放EBP用于其它用途。 不要忘记了 ESP 在你每次做 PUSH 或者 POP 时都会被改变。 (你不能在 16位 Windows下使用ESP, 因为时钟中断将在你的代码中不可预知的位置修改ESP的高字。)

有一个分开的cache给代码使用, 它和数据cache是类似的。 代码cache 的大小在 PPlain和PPro上是 8 kb, 在 PMMX,PII和PIII上是 16 kb。 能让你的代码的关键部分(最里面的循环)放入代码cache是很重要的。 最常用的代码或者需要一起使用的过程最好是储存在临近的位置。 不常用的分支或者过程应该放远离些,放在代码的下面或者其它的位置。

8. 第一次 vs 重复运行

一片代码往往在第一次运行时比重复运行消耗更多的时间。 原因见下:

1. 从 RAM 读入代码到cache花去了比运行它更多的时间。
2. 代码操作的所有数据都必须加载到cache, 这比执行那些操作更花时间。 当代码重复运行的时候, 数据几乎都在 cache 里。
3. 跳转指令在第一次运行的时候并不在分支目的缓存(branch target buffer,简称BTB)里, 因此一般都不能正确的预测。 见第22章
4. PPlain 上, 代码的解码是个瓶颈。 如果花掉一个时钟周期去检测指令长度, 那么就不可能在一个时钟周期解码两条指令, 因为处理器不知道第二条指令从那里开 始的。 PPlain 通过记住上次运行后保存在cache里的每条指令的长度来解决这个问题。 这样做的结果是, PPlain上第一次执行时,指令如果不是只有1个字节长的话就不会配对执行。 PMMX, PPro, PII 和 PIII 在第一次解码却没有这个问题。

因为这四个原因,在循环内部的一段代码第一次运行通常比随后的运行花去更多的时间。

如果你使用了一个很大的循环而不能放入代码cache,将导致效率下降,因为它们不能在 cache 运行。 因此你应该重新组织一下循环使cache能放下它们。

如果你有非常多的跳转,调用,分支在循环里,就会反复的产生分支目的缓存失败。

同样的,如果循环反复操作一个对数据cache而言太大的数据结构,也会一直得到数据cache不命中的惩罚。

9. 地址生成互锁(AGI) (PPlain and PMMX)

指令操作内存所需要的地址需要一个时钟周期来计算。 通常在前面的指令或指令对执行的时候,它已经在流水线通过一个独立的阶段上计算好了。 但是如果地址的计算倚赖上个时钟周期的运行结果的话,你就需要一个额外时钟周期来等待地址的计算。 这就叫做AGI延迟。 例如:
ADD EBX,4 / MOV EAX,[EBX] ; AGI 延迟
例子里的延迟可以向 ADD EBX,4 and MOV EAX,[EBX] 间增加一些其它的指令或者重新写成 MOV EAX,[EBX+4] / ADD EBX,4 来去掉。
当你隐性的使用ESP寻址, 比如 PUSH, POP, CALL, and RET, 且 ESP 在前个周期被MOV, ADD 或 SUB 等修改,这样也会造成AGI延迟。 PPlain 及 PMMX 有专门的电路来预测栈操作后的ESP值, 因此你在用PUSH, POP, 或 CALL 改变 ESP 后不会遇到AGI延迟。 在 RET 后面, 仅仅在有立即操作数对ESP做加法时才会产生AGI延迟。

例如:

ADD ESP,4 / POP ESI ; AGI 延迟
POP EAX / POP ESI ; 无延迟, 配对
MOV ESP,EBP / RET ; AGI 延迟
CALL L1 / L1: MOV EAX,[ESP+8] ; 无延迟
RET / POP EAX ; 无延迟
RET 8 / POP EAX ; AGI 延迟
当 LEA 指令使用了基寄存器或索引寄存器, 而它们在前面的时钟周期被改变了,同样会发生AGI延迟。 例如:
INC ESI / LEA EAX,[EBX+4*ESI] ; AGI 延迟

PPro, PII 和 PIII 在读内存和LEA上没有 AGI 延迟, 但是在写内存时会有 AGI 延迟。 如果后来的代码不需要等待写操作结束的话这并无大影响。

10. 整数指令配对(PPlain 及 PMMX)

10.1 完美的配对

PPlain 及 PMMX有两条流水线来执行指令, 分别叫做 U-管道和V-管道。 在一定的条件下两条指令可以一个在 U-管道,一个在 V-管道 同时执行。 这可以使速度加倍。 因此将你的指令重新组织一下次序使它们配对是很有利的。
    
下面这些指令可以在任意的管道内配对:
    
    *MOV 寄存器, 内存, 或是立即数到寄存器或内存
    *PUSH 寄存器或立即数, POP 寄存器
    *LEA NOP
    *INCDECADDSUBCMPAND ORXOR
    *还有一些形式的 TEST (见26.14章)

下面的指令只能在 U-管道 配对:
    
   ADC
SBB
    SHRSARSHLSAL 移动立即数位
    RORROLRCRRCL 移动立即数1位

下面的指令可以在任何管道运行,但是只能在 V-管道 配对:
    
    near call

    shortnear jump
    shortnear 条件跳转。
    
除这些指令之外的整型指令都只能在 U-管道 运行, 而且不能配对。

两条连续的指令满足了下面的要求时就可以配对:

1. 第一条指令在 U 管道 中,第二条指令在 V 管道 中, 且它们都是可配对的。

2. 当第一条指令写一个寄存器的时候,第二条指令不去读/写它。
例如:
MOV  EAX, EBX / MOV ECX, EAX ; 写后面跟着读,不能配对
MOV  EAX, 1 / MOV EAX, 2 ; 写后面跟着写,不能配对
MOV  EBX, EAX / MOV EAX, 2 ; 读后面跟着写,可以配对
MOV  EBX, EAX / MOV ECX, EAX ; 读后面跟着读,可以配对
MOV  EBX, EAX / INC EAX ; 读后面跟着读写,可以配对

3. 在第2条规则里面, 寄存器的一部分作为整个寄存器来对待, 例如:

    MOV  AL, BL / MOV AH, 0
    写入相对寄存器的不同部分, 不能配对

4. 当两条指令同时写的是标志寄存器的不同部分时,规则2和3都可以忽略掉。 例如:

    SHR EAX, 4 / INC EBX ; 可以配对

5. 一个写标记寄存器的指令可以和一个条件跳转配对, 而忽略掉规则 2 。 例如:

    CMP EAX, 2 / JA LabelBigger ; 可以配对

6. 下面的指令对,虽然同时修改了栈指针,但是它们依然可以配对:

  PUSH + PUSH, PUSH + CALL, POP + POP

7. 对于有前缀的配对指令有一些限制。 下面列出了几种形式的前缀:

   *用段前缀对非缺省段寻址的指令。
  *在 32 位代码中使用 16 位的数据, 或16位的代码中使用 32 位数据的带操作数尺寸前缀的指令。
  *16位模式中, 使用32位的基址寄存器或变址寄存器的带地址尺寸前缀的指令。
  *带重复前缀的字符串操作指令。
  *带LOCK前缀的锁定指令。
  *很多在 8086 处理器中没有实现的,有两个字节的操作码且其中第一个字节是 0FH的指令。 这个 0FH 字节的行为在
PPlain 上就像一个前缀, 但是后来的版本中就不是。 最常见的带 0FH 前缀的指令有: MOVZX, MOVSX, PUSH FS, POP FS, PUSH GS, POP GS, LFS, LGS, LSS, SETcc, BT, BTC, BTR,   BTS,BSF,BSR, SHLD, SHRD,还有带两个操作数且没有立即数的 IMUL。

在 PPlain 上, 有前缀的指令除了近距离条件跳转外只能在 U 管道中执行。

PMMX 上, 带有操作数尺寸、地址尺寸或0FH前缀的指令可以在任意管道执行, 但是带有段前缀, 重复前缀, 或者锁定前缀的指令只能在 U 管道执行。

8. 既带有偏移量又带有立即操作数的指令在 PPlain 上不能配对, 而在 PMMX 上只能在 U 管道配对:
MOV DWORD PTR DS:[1000], 0 ; 不能配对, 或者只能在 U 管道配对
CMP BYTE PTR [EBX+8], 1 ; 不能配对, 或者只能在 U 管道配对
CMP BYTE PTR [EBX], 1 ; 可以配对
CMP BYTE PTR [EBX+8], AL ; 可以配对
(关于既带有偏移量又带有立即操作数的指令在 PMMX 上配对的另一个问题是:这条指令的长度可能>=7字节, 这意味着, 一个时钟周期只有一条指令能被解码, 这些放在第12章解释。)

9. 两条指令必须已经预读进来且被解码。 这些放在第 8 章解释。

10. PMMX 上, 对于 MMX 指令有特殊的配对规则:
*MMX 移位, pack 和 unpack 指令可以在任意的管道执行,但是不能跟另外一条 MMX 移位,pack 和 unpack 指令配对。
*MMX 乘法指令可以在任意管道运行,但是不能和另外一条 MMX 乘法指令配对。乘法指令需要消耗 3 个时钟周期,其中后两个时钟周期并行执行其它指令,就好象浮点指令那样 (参见第 24 章)。
*一条访问内存或整型寄存器的 MMX 指令只能在 U 管道运行, 而且不能跟非 MMX 指令配对。


10.2 有缺陷配对

有几种情况下, 两条成对指令根本不能并行执行, 或者只是时间上部分重叠。 然而它们依然被当作是成对的, 因为第一条指令在 U 管道执行, 而第二条在 V 管道。而且随后的指令必须要在两条有缺陷配对的指令都完成后才开始运行。

有缺陷配对发生在以下条件下:

1. 如果第二条指令遭遇了一个 AGI 延迟 (见第9章)。

2. 两条指令不能同时访问内存的同一个 DWORD。 下面的例子假定 ESI 可以被 4 整除:
    MOV AL, [ESI] / MOV BL, [ESI+1]
两个操作数是在同一个 DWORD 里, 因此它们不能同时执行。 这对指令需要 2 个时钟周期。
    MOV AL, [ESI+3] / MOV BL, [ESI+4]
这里两个操作数分别处于两个 DWORD 的边界, 因此它们完美地配对, 只需要消耗 1 个时钟周期。

3. 第 2 条款可以扩展到两个地址的 2-4 位相同的情况 (cache行冲突)。 对于 DWORD 地址, 这意味着两个地址差不能被 32 整除。 例如:

    MOV [ESI], EAX / MOV [ESI+32000], EBX ; 有缺陷配对
    MOV [ESI], EAX / MOV [ESI+32004], EBX ; 完美配对

不访问内存的配对整型指令可以在一个时钟周期执行完,但是预测失败的跳转例外。 读/写内存的MOV指令,当数据区在cache里并严格对齐的时候也只需要一个时钟周期,即使用了像比例变址寻址这样复杂的寻址模式,也不会有速度上的惩罚。

一组配对整数指令, 如果需要读内存, 做一些计算后把结果保存在寄存器或标记寄存器中时, 需要消耗两个时钟周期。 (读/修改 指令)。

一组配对整数指令, 如果需要读内存, 做一些计算后把结果回写到内存中, 需要消耗3个时钟周期。 (读/修改/写 指令)。

4. 如果一条 读/修改/写 指令和一条 读/修改 或 读/修改/写指令 配对, 那么它们就是一个有缺陷配对。

下表展示了各种情况下需要的时钟周期数:

第一条指令 第二条指令
   MOV 或者 仅仅是寄存器操作   读/修改   读/修改/写 
 MOV 或仅仅是寄存器操作   1   2   3 
 读/修改   2   2   3 
 读/修改/写   3   4   5 

例如:
ADD [mem1], EAX / ADD EBX, [mem2] ; 4 个时钟周期
ADD EBX, [mem2] / ADD [mem1], EAX ; 3 个时钟周期

5. 当两条配对指令都因为cache失效,没有对齐,或跳转预测失败等情况而需要额外时间时, 一对指令消耗的时间将比其中任何一条需要的时间都长, 但是比两条指令需要的时间之和短。

6. 在可配对浮点指令之后与其配对的FXCH指令, 当下一条指令不是浮点指令时组成一个缺陷配对。

为了避免有缺陷配对,你必须知道哪条指令进入了 U 管道, 哪条进入了 V 管道。 为此,你可以向前看看你的代码,找到那些不能配对的,或者只能在一条管道中配对, 又或因为上面提及的规则而不能配对的指令,这样就可以清楚地知道后面的指令中哪条指令进入了 U 管道, 哪条进入了 V 管道。

有缺陷配对通常可以通过重组你的指令来避免。 例如:

     L1: MOV EAX,[ESI]
      MOV EBX,[ESI]
      INC ECX

这里两条 MOV 指令组成了一个有缺陷配对, 因为它们访问了同一内存地址, 所以这组指令需要消耗 3 个时钟周期。 你可以通过重组指令, 把 INC ECX 跟其中一个 MOV 指令配对。

    L2: MOV EAX,OFFSET A
      XOR EBX,EBX
      INC EBX
      MOV ECX,[EAX]
      JMP L1

INC EBX / MOV ECX,[EAX] 这对指令是一个有缺陷配对, 因为后一条指令发生了 AGI 延迟。 这组指令消耗 4 个时钟周期。 如果你插入一条 NOP 或任意别的指令, 使得MOV ECX,[EAX] 跟 JMP L1 配对, 这样这组指令就只需要消耗 3 个时钟周期了。

下一个例子是 16 位模式下的, 假设 SP 可以被 4 整除:

L3:     PUSH AX
      PUSH BX
      PUSH CX
      PUSH DX
      CALL FUNC

这里 PUSH 指令组成了两个有缺陷配对, 因为各对指令中的两个操作数都放入了内存的同一 DWORD 中。 PUSH BX 可能可以和 PUSH CX 完美配对起来 (因为它们访问的是两个不同的 DWORD) 但是并不是这样, 因为它已经和 PUSH AX 配对了。 这组指令消耗了 5 个时钟周期。 如果你插入一个 NOP 或者其它指令, 让 PUSH BX 跟 PUSH CX 配对, 而 PUSH DX 和 CALL FUNC 配对, 这样这组指令就只需要 3 个时钟周期了。 另一个解决方案是,让SP不被 4 整除。 想知道 SP 是否被 4 整除在 16 位模式下是很困难的, 所以避免这个问题的最佳方案是去使用 32 位模式。

11. 将复杂指令集分割为简单指令 (PPlain 及 PMMX)

你可以把 读/修改 和 读/修改/写 指令切开来提高配对机会。 例如:

    ADD [mem1],EAX / ADD [mem2],EBX ; 5 个时钟周期

这个代码可以切开, 而只需要消耗 3 个时钟周期:

    MOV ECX,[mem1] / MOV EDX,[mem2] / ADD ECX,EAX / ADD EDX,EBX
    MOV [mem1],ECX / MOV [mem2],EDX

同样的你可以把不能配对的指令切开让它们可以配对:

    PUSH [mem1]
    PUSH [mem2] ; 不能配对

切开变成:

    MOV EAX,[mem1]
    MOV EBX,[mem2]
    PUSH EAX
    PUSH EBX ; 所有的都配对了

下面还有另一些例子, 展示了一些不能配对的指令切开后变成简单的可配对指令:

CDQ 切成: MOV EDX,EAX / SAR EDX,31
NOT EAX 改为 XOR EAX,-1
NEG EAX 切成 XOR EAX,-1 / INC EAX
MOVZX EAX,BYTE PTR [mem] 切成 XOR EAX,EAX / MOV AL,BYTE PTR [mem]
JECXZ 切成 TEST ECX,ECX / JZ
LOOP 切成 DEC ECX / JNZ
XLAT 改为 MOV AL,[EBX+EAX]

如果切开指令并不能提高速度, 你应该保持复杂指令或不能配对的指令, 这样可以减小代码的尺寸。

对于 PPro, PII and PIII, 不需要将指令切开, 除非能产生更小的代码。

12. 前缀(PPlain和PMMX)

有前缀的指令可能无法在V-管道执行 (见第10章,第7部分), 并且它的解码时间多于一个周期。

在 PPlain 上,除了条件近跳转的0FH前缀外,每个前缀的解码时间是一个时钟周期。

PMMX 对于0FH前缀没有解码延迟。 段前缀和重复前缀用 1 个时钟周期来解码。 地址尺寸和操作数尺寸前缀用 2 个周期来解码。 在 PMMX 上,如果两条指令中第一条有一个段前缀或重复前缀或没有前缀,第二条没有前缀,那么它能在一个周期内解码这两条指令。 有地址尺寸或操作数尺寸前缀的指令在PMMX上只能被单独解码。 多于一个前缀的指令,每个前缀化1个周期解码。

在32位模式下,地址尺寸前缀可以不用; 用了平坦内存模式后,段前缀也能不用; 如果只用8位和32位整型的话,操作数尺寸前缀也能不用。

在前缀不可避免情况下,如果前面的指令执行时间超过一个时钟周期的话,那么解码延迟可能被掩盖。 PPlain的规则是,任何执行时间(不包括解码)为N个时钟周期的指令可以掩盖下两条(有时是三条)指令或指令对里N-1个前缀的解码延迟。 换句话说,用于执行指令的每个时钟周期,都可以用来解码后续指令的一个前缀。 "阴影效应"对于被正确预测的分支也有效。 任何执行时间超过一个时钟周期的指令,以及任何因为AGI效应、cache不命中、数据没对齐等等理由的延迟(除了解码延迟和分支预测失败),它们都有"阴影效应"。

PMMX也有“阴影效应”,但是机制更先进。 已经解码完毕的指令被存在一个对用户透明的先进先出(FIFO)的缓存里面,该缓存能存4条指令。取缓存中的指令没有延迟。一旦指令解码完毕开始执行,就被抛出缓存。 当指令的执行速度慢于指令的解码速度时,缓存会填满——也就是当你有未配对的指令或多时钟的指令时。 当指令的执行速度大于解码速度时,缓存会空出——也就是当你有因为前缀缘故的解码延迟。 在分支预测失败的时候,缓存被清洗。 在第2条指令没有前缀且没有一条指令的长度超过7个字节的前提下,指令cache在一个时钟周期内可以放入两条指令,U、V两个流水线可以在一个时钟周期内各接受其中的一条指令去执行。

比如:

CLD
REP MOVSD

CLD指令花两个时钟周期,因此掩盖了REP前缀的解码延迟。如果CLD远离REP MOVSD的话,这片代码将花不止一个周期。

CMP DWORD PTR [EBX],0  / MOV EAX,0 / SETNZ AL

由于CMP指令是一条读/写指令,因此花两个周期。在CMP的两个周期中,SETNZ指令的前缀0FH被解码,因此在PPlain机上,解码延迟被掩盖了(在PMMX上没这个问题,因为它对0FH前缀没有解码延迟)。

在PPro,PII,PIII机上的前缀的副作用,在14章描述。

 

13. PPro,PII和PIII流水线综述

PPro,PII和PIII微处理器的制造工艺在Intel的各种指南手册中有很好的解释和插图。为了理解这些处理器是怎么工作的,推荐你学习这些材料。在此,我只对代码优化相关部分作简要描述。

指令代码从指令cache的16字节对齐的块中取到一个两倍大的缓存中,该缓存能够容纳两个16字节的块。从该缓存传递到解码器的指令块我称之为ifetch块(指令携带块)。ifetch块一般是16字节,但没有对齐。两倍缓存的目的就是希望能够对跨越16字节边界的指令也能解码(16字节边界是指能够被16整除的地址)。

指令长度解码器决定了每个指令的开始和结束位置,和紧接的下一条指令,ifetch块就是根据指令长度解码的结果来定位的。 有三个解码器,因此你可以在一个时钟周期内解码三条指令。 在同一个时钟周期内解码的指令(最多三条)被称为一个解码组。

解码器将指令翻译为微操作、小型的微指令。简单的指令只产生一条微码,复杂的指令可能产生几条微码。 比如ADD EAX,[MEM]指令解码为两条微码:一条读内存操作数,一条做加法。 把指令分解为微码的目的是使以后的系统处理更为有效。

三个解码器被称为D0,D1和D2。D0能够处理所有的指令。D1和D2只能处理那些只产生一条微码的简单指令。

从解码器出来的微码经过一个短的队列到达寄存器分配表(RAT)。 微码的执行在临时寄存器中进行,以后再写回到永久寄存器诸如EAX,EBX等等。 RAT的目的是给微码分配临时寄存器,并且使寄存器重命名成为可能(见后续章节)

在RAT之后,微码进入了乱序缓存(reorder buffer,ROB)。 ROB的作用是乱序执行。 在微码需要的操作数不可用之前,它将呆在保留站。 当因为前面的微码产生的结果(作为后面某条微码的操作数)还没完成时,ROB在需要该操作数的微码等待期间,会找另一条后面的微码来执行(前提是逻辑正确),从而节省了时间。

就绪态的微码被送入执行单元。执行单元有五个端口:端口0和1能处理算术运算,跳转等等;端口2负责所有的内存读;端口3计算将要被写的内存地址;端口4进行内存写。

在ROB中,一条被执行过的指令被标记为将要引退。 然后它进入引退站。 在这里,微码用过的临时寄存器的内容要写回永久寄存器。 虽然微码能够被乱序执行,但必须有序引退。

后续章节中,我会详细地描述流水线中每一步的吞吐量如何进行优化。

 

14. 指令解码(PPro,PII和PIII)

在此我先讲指令解码,然后再讲取指令。 因为要理解取指令时发生的延迟,你必须先知道解码器的工作原理。

只有在一些条件满足的情况下,解码器才能在一个时钟周期内解码3条指令。 解码器D0能够处理所有的在一个时钟周期内最多产生4条微码的指令。 解码器D1和D2只能处理那些只产生1条微码的指令,而且那些指令长度不能超过8字节。

概述同一个时钟周期内解码2或3条指令的规则如下:

    *第一条指令(由D0解码)产生的微码不能超过4条
    *第二、三两条指令都只能产生1条微码
    *第二、三两条指令长度都不能超过8个字节
    *这些指令都要在同一个16字节的ifetch块中(见下一章)

在D0中的指令长度没有限制(尽管Intel手册中提到了一些),只要这三条指令能放入一个16字节的ifetch块。

产生4条以上微码的指令需要2个或更多时钟周期来解码,并且在这个过程中没有其它的指令可以并行解码。

根据以上规则,我们得出结论:一个时钟周期内解码器至多产生6条微码(如果第一条指令产生4条微码,后两条指令各产生1条微码);至少产生2条微码(如果所有指令都产生2条微码,这时D1和D2没法用)。

为了达到最大吞吐量,推荐你把代码组织成4-1-1模式:产生2-4条微码的指令可以"免费"附带2条产生1条微码的简单指令,某种意义上不增加解码时间,比如:

MOV EBX, [MEM1] ; 1条微码 (D0)
INC EBX          ; 1条微码 (D1)
ADD EAX, [MEM2] ; 2条微码 (D0)
ADD [MEM3], EAX ; 4条微码 (D0)

解码要花去3个时钟。 重组代码使它们进入两个解码组可以节省一个周期:

ADD EAX, [MEM2] ; 2条微码 (D0)
MOV EBX, [MEM1] ; 1条微码 (D1)
INC EBX          ; 1条微码 (D2)
ADD [MEM3], EAX ; 4条微码 (D0)

现在解码器在2个时钟周期内产生8条微码,应该比较满意了。因为流水线的后续阶段只能在一个时钟周期内处理3条微码,所以大于3条/周期的解码吞吐率你就可以认为解码不是瓶颈了。然而,就像后面的章节描述的那样,取指令机制的复杂性可能会使解码延迟,因此安全起见,你的目标是每个时钟周期的解码吞吐率大于3。

你可以在29章的列表中查出各种指令产生的微码数。

在解码时,前缀也可能带来惩罚。 指令能够有这样一些前缀:

* 操作数尺寸前缀。 当你在32位环境中有一个16位操作数时将用到,反之亦然(除了那些操作数只能有一种尺寸的指令,比如FNSTSW AX)。 当指令有一个16或32位的立即操作数时,操作数尺寸前缀会带来几个周期的惩罚,因为操作数的长度被前缀改变了。 比如:

    ADD BX, 9                  ; 因为立即操作数是8位,故没有惩罚
    MOV WORD PTR [MEM16], 9    ; 因为操作数是16位,有惩罚

后一条指令应该被替换成:

    MOV EAX, 9
    MOV WORD PTR [MEM16], AX   ; 没惩罚,因为没有立即数

* 地址尺寸前缀。当你在16位模式下用32位地址时用到,反之亦然。它很少用到,一般应该避免。每当你有一个显式的内存操作数时(甚至有时没有偏移量),地址尺寸前缀导致一次惩罚。因为指令编码中指明r/m的位被前缀改变了。 只有隐式内存操作数的指令,比如串操作指令,即使有了地址尺寸前缀也没有惩罚。
* 段前缀。 当你需要定位非默认的数据段时需要用到。 在PPro,PII和PIII上没有因段前缀而带来的惩罚。
* 重复前缀和锁前缀在解码时没有惩罚。
* 当你的前缀多于一个时总是有惩罚。 一般惩罚是每个前缀一个周期。

 

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的开始位置。 以下是指导方针:

*按照前面的表,jump,call或return后的第一个ifetch块既可能从第一条指令开始,也可能从与先前地址最接近的16字节边界线开始。 但如果第一条指令是对齐的——使它从16字节边界线开始,那么你就能保证第一个ifetch块从这里开始。 因此,你应该使重要的子过程入口和循环入口按 16 对齐。
*如果存在两条连续的指令它们的长度和大于16,那么你能保证第二条指令无法与第一条指令放进同一个ifetch块,结果就是总能有一个ifetch块从第二条指令开始。 以此ifetch块为基础,你就能找出后续ifetch块的开始位置。
*分支预测失败后的第一个ifetch块总是从16字节边界线开始。 按照22.2节的理论,一个重复次数大于5次的循环当它退出时总有一次预测失败。 因此这种循环后的第一个ifetch块从与先前地址最接近的16字节边界线开始。
*其它序列化事件(不可并行事件)也会使得下一个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块的边界出现在你希望的位置了。 当然这是一个乏味的难题。

16.寄存器重命名(PPro,PII和PIII)

16.1 消除依赖

寄存器重命名是这些微处理器采用的高级技术,为了消除不同部分代码的之间的依赖。比如:
     
      MOV EAX, [MEM1]
     IMUL EAX, 6
     MOV [MEM2], EAX
     MOV EAX, [MEM3]
     INC EAX
     MOV [MEM4], EAX

这里的最后三条指令是独立于开始的三条指令的,因为它们不需要前面三条指令的结果。 为了优化它,在早期的处理器中,你必须在后三条指令中不用EAX寄存器,并且调整指令的顺序使得六条指令两两配对。 而PPro,PII,PIII处理器已经自动为你做好了这一切。 在每次你写EAX寄存器的时候,它分派一个新的临时寄存器。 因此,MOV EAX,[MEM3]相对前面的指令独立了。 在乱序执行之后,有可能在较慢的指令IMUL完成之前,MOV [MEM4],EAX已经完成了。

寄存器重命名是完全自动的。 每当一条指令写一个永久性的寄存器时,一个新的临时寄存器被当作它的化身般分派。 一条对一个寄存器既读又写的指令也将引发寄存器重命名。 比如前面的INC EAX指令,用了一个临时寄存器来读,另一个临时寄存器来写。 这并不能减少依赖,当然,这对并发的寄存器读有些意义,稍后解释。

所有的通用寄存器,堆栈指针esp,标志寄存器,浮点寄存器,MMX寄存器,XMM寄存器和段寄存器能被重命名。 控制字,浮点状态字不能被重命名,这是因为这些寄存器用起来很慢。 共有40个通用的临时寄存器,因此你不可能用完。

一般将一个寄存器清0的方法是XOR EAX,EAX或SUB EAX,EAX。 这些指令不认为依赖于寄存器的原值。 但如果你想消除前面的慢指令造成的依赖,你就用MOV EAX,0。

寄存器重命名由寄存器化名表(RAT)和乱序缓存(ROB)控制。 从解码器出来的微码经过一个队列进入RAT,然后进入ROB和保留站。 在一个时钟周期RAT只能处理3条微码。这意味着微处理器平均一个周期的总吞吐量不能超过3条微码。

重命名的个数没有特殊限制。 RAT在一个时钟周期内可以重命名三个寄存器,甚至在一个时钟周期内它可以重命名同一个寄存器三次。

16.2 寄存器读延迟

但有一个限制非常严重,那就是在一个时钟周期内你只能读两个不同的永久寄存器名。除了指令中只用于写的寄存器外,对指令中其它用到的寄存器都有这个限制。比如:
          
          MOV [EDI + ESI], EAX
          MOV EBX, [ESP + EBP]

第一条指令产生两条微码:一个读EAX,一个读EDI和ESI。 第二条指令产生一个微码:读esp和ebp。 ebx不算作读,因为在这个指令中它只被写。 让我们假定这三个微码一起经过RAT。 将用三个字长(WORD)来保存这三个一起经过RAT的连续微码。 因为ROB一个时钟周期只能处理两个永久性寄存器的读,而我们有五个寄存器读,所以在我们的三元组到达保留站之前,被额外地延迟了两个时钟周期。 再比如,三元组里有3或4个寄存器读,那么会有一个周期的延迟。

但在一个三元组内,同一个寄存器被读多次不被计算在内。比如上述代码改为:

          MOV [EDI + ESI], EDI
          MOV EBX, [EDI + EDI]

其实只需要两个寄存器读(ESI,EDI),三元组不会被延迟。

若一个寄存器将被一个尚未知的微指令写,那么为了无代价地获得这个寄存器,它将一直被保存在ROB内直到被写回。 这将消耗3个时钟周期,甚至更多。 写回是最后一个可访问值的执行阶段。 换句话说,在寄存器的值尚不能被执行单元访问的时候,你可以没有延迟地读出RAT中任何寄存器。 这是因为当一个值一旦能被访问,它将被快速地、直接地写到任何需要它的后续的ROB表项。 但如果在需要它的后续微指令进入RAT之前,该值已经被写回临时|永久寄存器,那么这个值只能从只有两个读端口的寄存器文件中读。 从RAT到执行单元有三个流水阶段,因此你可以确定,在一个微码三元组中被写过的寄存器至少能被下个三元组无代价地读。 如果写回动作因为乱序,慢指令,依赖链,cache失效,或其它缘故发生延迟,那么寄存器就能被更靠后的指令流无代价地读。

比如:
          MOV EAX, EBX
          SUB ECX, EAX
          INC EBX
          MOV EDX, [EAX]
          ADD ESI, EBX
          ADD EDI, ECX 

这六条指令各产生一条微指令。让我们假定前三条微指令一起进入RAT。这三条指令读EBX,ECX,EAX。但因为我们对EAX读之前正在对它写,因此这个读是没有代价的,即我们没有延迟。 下三条微指令读EAX, ESI, EBX, EDI 和 ECX。 虽然在前面的三元组中EAX,EBX,ECX都被改过,但在它们能被无代价访问之前还没被写回,因此只关系到ESI和EDI,我们也没有延迟。 如果第一个三元组的指令SUB ECX,EAX改为CMP ECX,EAX,那么由于ECX没有被写,我们将因为在第二个三元组中读ESI,EDI和ECX发生延迟。 同样地,如果INC EBX改为NOP,或其它指令,那么我们将因为在第二个三元组中读ESI,EBX,EDI而发生延迟。

没有一种微指令能够读两个以上寄存器。因此,任何读两个以上寄存器的指令将被拆分至两条或两条以上微指令。

为了给寄存器读进行记数,你必须统计指令中所有关联的寄存器,包括整形寄存器,标志寄存器,栈指针寄存器,浮点寄存器和MMX寄存器。XMM寄存器看作两个寄存器,除了它们被部分使用,比如ADDSS和MOVHLPS。 段寄存器和指令指针寄存器ip不计在内。 比如,在指令SETZ AL中你应该只计标志寄存器,而不是AL。 ADD EBX,ECX中,EBX和ECX都要计,但标志寄存器不计,因为它们只被写。 PUSH EAX指令读EAX和ESP,然后写ESP。
  
FXCH指令是个例外。 它仅仅是重命名,但没有读任何值,因此在寄存器读延迟的规则里它不被计。 FXCH指令的行为好比一个微指令,它既没读,又没写任何牵涉读延迟的规则的寄存器。
  
不要把微指令三元组和解码组搞混。 一个解码组可以产生1到6条微指令,即使解码组有三条指令,且正好产生三条微指令,也不能保证这三条微指令一起进入RAT。 

解码器和RAT之间的队列相当短(只有10条微指令),因此你不能认为寄存器读延迟不会影响解码,也不能认为解码器吞吐量的波动起伏不会对RAT造成延迟。

除非队列为空,否则很难预知哪些微指令一起进入RAT,但对于优化的代码来说,队列只有在分支预测失败后才为空。 同一条指令产生的微指令不一定一起经过RAT,微指令只是在队列中连续地进入,三个一组。 如果是一个预测到的跳转,序列不被打断:在跳转之前和之后的微指令可以一起经过RAT。 只有当一个预测失败的跳转,队列才被清洗,重新开始,因此下三条微指令一定是一起进入RAT。

如果三条连续的微指令读了两个以上的寄存器,你一定情愿它们不要一起进入RAT。 而它们一起进入的可能性是1/3。在同一个微码三元组中读 3 或 4 个已经写回的寄存器,其代价是延迟一个时钟周期。 你可以把这一个时钟的延迟等价地看成在RAT中同时加载三条以上微指令。 由于这三条微指令一起进入RAT的概率是1/3,因此平均代价是3/3=1条微指令。 计算一段代码经过RAT的平均时间的方法是:寄存器读延迟的个数加上微指令的个数,再除以3。 你会发现为了去除延迟,加入一条额外的指令的方法是无济于事的—— 除非你确实知道哪些微指令一起进入RAT,或者你能通过这条额外的指令阻止超过一个的,潜在的寄存器读延迟的发生。

为了达到一个时钟周期3条微指令的吞吐量的目的,一个时钟里只能读两个永久性寄存器的限制可能是需要处理的一个瓶颈。去除寄存器读延迟的方法如下:

*将读同一个寄存器的微指令尽量放在一起,使它们进入同一个三元组的概率提高。
*将读不同寄存器的微指令尽量隔开,使它们无法进入同一个三元组。
*如果一条指令写或修改某个寄存器,那么在这条指令之后不要放超过3-4个读这个寄存器的三元组微码。 这是为了保证在这个寄存器被读以前尚没有被写回(其中有跳转没关系,只要它被预测到)。 如果你有理由估计到寄存器的写回将被延迟,那么你还能够在更靠后的指令流中安全地(无代价地)读寄存器。
*用绝对地址代替指针,这样能减少寄存器读的数量。
*在不会引起延迟的某个三元组中,你可以重命名一个寄存器,这样能在以后的一个或多个三元组中阻止该寄存器的读延迟。 比如:MOV ESP,ESP / ... / MOV EAX,[ESP+8]。 这个方法多了一条额外的微指令,因此它不太值得,除非估计这样做能防止的平均读延迟数大于1/3。

对于产生一条以上微指令的指令,你要知道该指令产生的微指令的顺序,这样就能精确分析寄存器读延迟的可能性。 我在下面列出了大多数普遍的情况。

*写内存
内存的写产生两条微指令。第一条( 端口4 )是一个存储操作——读寄存器值并记下。 第二条( 端口3 )计算内存地址,读指针寄存器。比如:
MOV [EDI], EAX
第一条微指令读EAX, 第二条读EDI。
FSTP QWORD PTR [EBX+8*ECX]
第一条读ST(0), 第二条读EBX和ECX。

*读\写
一个读内存操作数,通过算术运算或逻辑元算修改寄存器的指令产生两条微指令。第一条( 端口2 )是一个读指针寄存器并且读内存指令,第二条是一个算术指令( 端口0或1 ),它读\写目的寄存器,可能写标志寄存器。 比如:
ADD EAX, [ESI+20]
第一条微码读ESI, 第二条读EAX,写EAX和标志寄存器。

*读\修改\写
读\修改\写指令产生四条微指令。 第一条( 端口2 )读指针寄存器,第二条( 端口0或1 )读\写所有的源寄存器,可能写标志寄存器。第三条( 端口4 )只读不计在这里的临时结果。 第四条( 端口3 )再一次读指针寄存器。 因为第一条和第四条微指令不能一起进入RAT,因此你无法利用它们读的是同一个指针寄存器这个事实。 比如:
        
         OR [ESI+EDI], EAX

第一条微指令读ESI和EDI,第二条微指令读EAX,写EAX和标志, 第三条只读临时结果, 第四条再次读ESI和EDI。 不管这些微码如何进入RAT,你能保证读EAX的微码与其中一个读ESI和EDI的微码是一起进入的。所以对于这条指令,寄存器读延迟不可避免地发生了,除非这三个寄存器中的一个最近刚被修改过。

*寄存器压栈
一条寄存器压栈指令产生三条微指令。第一条( 端口4 )是读出寄存器值后记下,第二条读堆栈指针寄存器ESP,产生内存地址,第三条( 端口0或1 )读并修改ESP,减去一个字的大小( 或一个双字 )。

*寄存器出栈
寄存器出栈指令产生两条微码。第一条( 端口2 )读出ESP,读出内存值,写入寄存器,第二条读并修改栈顶指针,调整栈顶指针。

*调用
近调用产生4条微码( 分别进入端口 1, 4, 3, 01 )。前两条只读ip,这不能被计,因为它不能被重命名。 第三条读堆栈指针。 第四条读并修改堆栈指针。

*返回
近返回产生4条微码( 分别进入端口 2, 01, 01, 1 )。第一条读堆栈指针。 第三条读并修改堆栈指针。

如何避免寄存器读延迟的例子在 实例2.6 中给出

17. 乱序执行(PPro, PII and PIII)

乱序缓存(reorder buffer,简称ROB)可以容纳40条微码。 一条微码呆在ROB中,直到所有它需要的操作数都已就绪并且有一个空的执行单元可用。 这一切使得乱序执行成为可能。 如果一部分代码因为cache不命中被延迟,且之后的代码独立于被延迟的操作,那么后面的代码不会被延迟。

写内存的操作无法乱序执行,其它的写操作都能。 一共有4个写缓存。 因此如果你预计写操作时会有很多cache不命中或者你正在向未命中cache的内存写,那么建议你先一次安排4个写操作,并且保证在下4个写操作之前CPU有其它事情做。 内存读和其它指令一般都能乱序执行,除了IN,OUT和序列化的指令。

如果你的程序向某个内存地址写,不久之后又从同一个地址读,那么读操作会错误地在写操作之前执行,因为乱序执行的时候ROB不能分辨这个内存地址。当写地址被计算的时候错误才被发现,然后读操作(它是被"投机执行"的)必须重做。 惩罚大约是3个时钟周期。 避免它的唯一办法是保证执行单元在写操作和后面的读同一个地址的操作之间有其它事情做。

在五个端口周围有几个成群的执行单元。 端口0和1用于算术运算等。 简单的move,算术和逻辑运算能够进0和1端口的任意一个,就看哪个有空了。 端口0还处理乘法,除法,整型移位和整型循环移位,浮点操作。 端口1还处理跳转和一些MMX,XMM操作。 端口2处理所有的内存读,一些串操作和一些XMM操作。 端口3为内存写计算地址。 端口4执行所有的内存写操作。 在29章你能看到指令产生的微码的完整列表,还指出各个微码进入哪个端口。 注意,内存写操作总是需要两条微码,一条进端口3,一条进端口4。 内存读操作只需要一条微码( 进入端口2 )。

多数情况下,一个端口每个周期接受一条微码。 这意味着一个周期最多可以执行5条微码(如果它们分别进入5个不同的端口)。 然而因为之前的流水线在一个时钟最多产生3条微码,所以不可能平均一个时钟执行3条以上微码。

如果你想维持每个时钟3条微码的吞吐率,那么必须保证没有执行单元接收超过三分之一数量的微码。 用了29章的微码表可以数出进入各个端口的微码数。 如果端口0和1很忙,而端口2空闲,那么你要用MOV register,memory指令取代MOV register,register 或 MOV register,immediate指令来改进代码,这样可以从端口0和1上移出部分负担到端口2。

大多数微码的执行时间是一个时钟周期。 但乘法,除法和许多浮点运算要用更多:

浮点加法和浮点减法用3个周期,但执行单元是完全流水化的,因此在上一个浮点加/减结束之前,它就能在每个时钟周期接受一个新的FADD或FSUB(当然要基于它们是独立的这个假设)。

整型乘法花4个周期,浮点乘法花5个周期,MMX乘法花3个周期。 整型和MMX乘法是流水化的,可在每个时钟接受一条新指令。 浮点乘法是部分流水化的:执行单元可以在前一个浮点乘法的2个时钟之后接受一个新的FMUL,因此最大吞吐量是每两个时钟一条FMUL。 两条FMUL之间的空洞用整型乘法去填充无济于事,因为它们用同一条电路。 XMM加法用3个时钟,XMM乘法用4个时钟,而且都是完全流水化的。 但因为逻辑上的XMM寄存器在物理上是用两个64位寄存器实现的,你需要两条微码整合一个XMM操作,因此吞吐量是每两个时钟一个XMM操作。 XMM加法和XMM乘法可以并行执行,因为它们用的不是同一个执行单元。

整型和浮点型除法需要39个时钟,而且不是流水化的。 这意味着在前一个除法完成之前,执行单元无法开始新的除法。 对开方和一些超越函数同样如此。

jump,call和return指令也不是完全流水化的。 在跳转后的第一个时钟周期内你不能执行一个新的跳转。 因此jump,call和return指令的最大吞吐量是每两个时钟一条指令。

你当然还应该避免那些产生很多微码的指令。比如LOOP XX指令,应该被替换为:DEC ECX/JNZ XX。

如果你有连续的POP指令,那么你应该把它们"打碎"以减少微码数:

POP ECX / POP EBX / POP EAX ; 可以变成:
MOV ECX,[ESP] / MOV EBX,[ESP+4] / MOV EAX,[ESP] / ADD ESP,12

前者产生6条微码,后者只产生4条微码而且解码更快。 对于PUSH指令用同样的方法就不太好了,因为被"打碎"的代码序列可能产生寄存器读延迟,除非你有其它的指令可以插入或者寄存器最近被重命名过。 对于CALL和RET指令用这个方法也不好,会妨碍返回栈缓存(return stack buffer,简称RSB)的预测功能。 还要注意的就是在早期的处理器中,ADD ESP指令也会引起AGI延迟。

 

18. 引退(PPro,PII和PIII)

引退是微码使用的临时寄存器数据拷回永久寄存器(EAX,EBX等)的过程。 在ROB中,一条已经执行过的微码被标记为准备引退。

引退站可以在一个时钟周期处理3条微码。 这不成问题,因为在RAT中吞吐量已经被限制为一个时钟3条微码。 但有两个原因会使引退也成为瓶颈: 第一,指令必须有序引退。 如果一条微码被乱序执行了,那么在它前面的全部微码还没引退之前它不能引退。 第二个限制是被确认为发生的跳转指令必须在引退站的三个槽的第一个槽中引退。 就像一条指令只适合解码器D0,解码器D1、D2只能闲置一样,如果一条要引退的微码是一个被确认为发生的跳转,那么引退站的后两个槽只能闲置。 因此假如你有一个小循环,那么其中的微码数不能被3整除很重要。

任何微码在引退之前都呆在乱序缓存中(ROB)。 ROB可以容纳40条微码。 这就限制了在除法等慢指令的大延迟期间可以执行的指令数。 在除法完成之前,ROB会大量充塞执行过后等待引退的微码,而只有在除法完成并引退后,那些后来并行的微码才开始引退,因为引退是有序进行的。

在预测分支"投机"执行(第22章)的情况下,在确认分支预测正确以前那些"投机"执行的微码不能引退。 如果发现预测结果是错误的,那么"投机"执行过的微码不引退了,全被废弃。

这些指令不能被"投机"执行:内存写,IN,OUT和序列化的指令。

 

19. 部分延迟(PPro,PII和PIII)

19.1 部分寄存器延迟

当你对一个32位寄存器的部分写,不久后读更大的一部分或整个,部分寄存器延迟将发生。 比如:

        MOV AL, BYTE PTR [M8]
        MOV EBX, EAX      ; 部分寄存器延迟

延迟是5-6个时钟。 理由是一个临时寄存器已被分配给AL(使它独立于AH),在执行单元把AL的值与EAX其余部分的值组合起来进行读以前,必须等待AL写操作的引退。 通过改变代码避免延迟:

    MOVZX EBX, BYTE PTR [MEM8]
    AND EAX, 0FFFFFF00h
    OR EBX, EAX

当然也可以在寄存器的部分写操作之后插入一些其它指令来避免这种延迟,这样在你读整个寄存器之前可以有充分时间引退。

只要你在代码中混合使用了不同的数据尺寸(8,16和32位),你就要注意到部分延迟:

     MOV BH, 0
     ADD BX, AX     ; 延迟
    INC EBX       ; 延迟

如果是先写了大的部分或整个寄存器,然后再读部分寄存器则不会有延迟:

    MOV EAX, [MEM32]
    ADD BL, AL     ; 无延迟
    ADD BH, AH     ; 无延迟
    MOV CX, AX     ; 无延迟
    MOV DX, BX     ; 延迟

避免部分寄存器延迟的最简单的方法是一直用整个寄存器——在读小的内存操作数时用MOVZX或MOVSX。 这些指令在PPro,PII和PIII上很快,但在早期的处理器上慢。要在所有处理器上运行快得想个折中的办法。 可以把MOVZX EAX,BYTE PTR [M8]替换成如下指令:

    XOR EAX, EAX
    MOV AL, BYTE PTR [M8]

要知道,为了避免以后读EAX造成的部分寄存器延迟,PPro,PII和PIII专门为此类指令组合做了一件特殊的事,它采取的技巧就是当一个寄存器与自身异或的时候寄存器直接被记为清零。 处理器记住EAX的高24位是零,因此避免了部分延迟。 这个机制只在如下的组合工作:

    XOR EAX, EAX
    MOV AL, 3 
    MOV EBX, EAX ; 无延迟

    XOR AH, AH
    MOV AL, 3
    MOV BX, AX ; 无延迟

    XOR EAX, EAX
    MOV AH, 3
    MOV EBX, EAX ; 延迟

    SUB EBX, EBX
    MOV BL, DL
    MOV ECX, EBX ; 无延迟

    MOV EBX, 0
    MOV BL, DL
    MOV ECX, EBX ; 延迟

    MOV BL, DL
    XOR EBX, EBX   ; 无延迟

通过一个寄存器与它自身相减清零与XOR的工作方式相同,但用MOV指令清零则无法阻止延迟发生。

你可以在循环外写一个XOR指令:

    XOR EAX, EAX
    MOV ECX, 100
 LL:  MOV AL, [ESI]
    MOV [EDI], EAX  ; 无延迟
    INC ESI
    ADD EDI, 4
    DEC ECX
    JNZ LL

只要没有中断,预测失败或其它序列化事件发生,处理器会记住EAX的高24位是0。

在调用一个可能会PUSH整个寄存器的子过程之前,你应该记得“压制”任何前不久用过的部分寄存器:

    ADD BL, AL
    MOV [MEM8], BL
    XOR EBX, EBX    ; 压制BL
    CALL _HighLevelFunction

大多数高级语言会在一个过程开始的地方PUSH EBX,如果像上面这类情况你没有压制BL,会产生部分寄存器延迟。

用XOR清零一个寄存器的方法并不能打破它对前面指令的依赖:

    DIV EBX
    MOV [MEM], EAX
    MOV EAX, 0     ; 打破依赖
    XOR EAX, EAX    ; 阻止部分寄存器延迟
    MOV AL, CL
    ADD EBX, EAX

两次把EAX设为0看上去多余,但要知道如果没有MOV EAX,0,那么后续的指令必须等待除法慢指令结束,没有XOR EAX,EAX则会产生部分寄存器延迟。

FNSTSW AX指令是特殊的:在32位模式下它的行为就和写整个EAX一样。事实上,在32位模式下它做的事情类似于:
   AND EAX,0FFFF0000h / FNSTSW TEMP / OR EAX,TEMP
因此32位模式下,你在该指令后面读EAX不会发生部分寄存器延迟:

    FNSTSW AX / MOV EBX,EAX   ; 只在16位模式下有延迟
    MOV AX,0 / FNSTSW AX    ; 只在32位模式下有延迟

19.2 部分标志延迟

标志寄存器也会引起部分寄存器延迟:
    
     CMP EAX, EBX
    INC ECX
    JBE XX ; 部分标志延迟

JBE指令既读进位标志(CF)又读零标志(ZF)。 因为INC指令修改ZF,不修改CF,在JBE指令结合CF(CMP修改CF)和ZF(INC修改ZF)之前,它必须等待前两条指令的引退。这种情形与其说是故意进行标志的组合,不如说可能是一个bug。 改正bug的方法是用ADD ECX,1取代INC ECX。 类似引起部分标志延迟bug是SAHF / JL XX。 JL指令测试符号标志(SF)和溢出标志(OF),但SAHF指令不改变溢出标志。 改正bug的方法是用JS XX替换JL XX。

出乎意料的是(与Intel手册上说的相反),当你在一条修改了一些标志的指令之后只读一些没有修改过的标志,也会产生部分标志延迟:

    CMP EAX, EBX
    INC ECX
    JC XX     ; 部分标志延迟

但只读修改过标志则没有延迟:

     CMP EAX, EBX
    INC ECX
    JE XX     ; 无延迟

部分标志延迟可能发生在那些读很多标志位的指令上,也就是LAHF,PUSHF,PUSHFD。 以下指令后面若跟LAHF或PUSHF(D)会有部分标志延迟:INC, DEC, TEST, 位 测试, 位扫描, CLC, STC, CMC, CLD, STD, CLI, STI, MUL, IMUL和所有移位、循环移位。 以下指令不会引起部分标志延迟:AND, OR, XOR, ADD, ADC, SUB, SBB, CMP, NEG。 奇怪的是TEST和AND的行为不一样的——尽管根据定义,它们确实对标志寄存器做了同样的事情。 你可以用SETcc指令取代LAHF或PUSHF(D)来储存一个标志,避免延迟。

比如:

    INC EAX / PUSHFD ; 延迟
    ADD EAX,1 / PUSHFD ; 无延迟

    SHR EAX,1 / PUSHFD ; 延迟
    SHR EAX,1 / OR EAX,EAX / PUSHFD ; 无延迟

    TEST EBX,EBX / LAHF ; 延迟
    AND EBX,EBX / LAHF ; 无延迟
    TEST EBX,EBX / SETZ AL ; 无延迟

    CLC / SETZ AL ; 延迟
    CLD / SETZ AL ; 无延迟

部分标志延迟的惩罚大约是4个时钟。

19.3 移位和循环移位后的标志延迟

在移位或循环移位后读任意标志位,会发生类似部分标志延迟的延迟,除了移位和循环移位的位数是1且为简易格式(不用计数器)的情况:

    SHR EAX,1 / JZ XX         ; 无延迟
    SHR EAX,2 / JZ XX         ; 延迟
    SHR EAX,2 / OR EAX,EAX / JZ XX  ; 无延迟

    SHR EAX,5 / JC XX         ; 延迟
    SHR EAX,4 / SHR EAX,1 / JC XX  ; 无延迟

    SHR EAX,CL / JZ XX      ; 哪怕CL=1也有延迟
    SHRD EAX,EBX,1 / JZ XX    ; 延迟
    ROL EBX,8 / JC XX       ; 延迟

这种类型的延迟大约是4个时钟。

19.4 部分内存延迟

部分内存延迟与部分寄存器延迟有些相像。当你对同一个内存地址,混合不同尺寸的数据进行操作时发生:

    MOV BYTE PTR [ESI], AL
    MOV EBX, DWORD PTR [ESI] ; 部分内存延迟

在此,因为处理器必须把AL写回的1个字节和后面3个原来在内存中的字节组合,以得到需要读进EBX的4个字节,所以会发生延迟。 惩罚大约是7-8个时钟。

和部分寄存器延迟不同的是,当你把一个大尺寸的操作数写入内存,然后读其中的一部分且这部分的起始地址与原来不同时,也会有部分内存延迟:

    MOV DWORD PTR [ESI], EAX
    MOV BL, BYTE PTR [ESI]  ; 无延迟
    MOV BH, BYTE PTR [ESI+1] ; 延迟

你可以通过把最后一行改成MOV BH,AH来避免延迟,但这种解决方法在以下情况是做不到的:

    FISTP QWORD PTR [EDI]
    MOV EAX, DWORD PTR [EDI]
    MOV EDX, DWORD PTR [EDI+4] ; 延迟

有趣的是,在写后如果读一个完全不同的地址,只是碰巧与写的地址在不同的cache行有相同组值时,也会有部分内存延迟:

    MOV BYTE PTR [ESI], AL
    MOV EBX, DWORD PTR [ESI+4092] ; 无延迟
    MOV ECX, DWORD PTR [ESI+4096] ; 延迟

 

20.依赖环(PPro,PII和PIII)

一组指令,其中每一条指令依赖于前一条指令的结果,称这组指令是一个依赖环。 长的依赖环应该尽可能避免,因为它阻止了乱序执行和并行执行。

比如:

    MOV EAX, [MEM1]
    ADD EAX, [MEM2]
    ADD EAX, [MEM3]
    ADD EAX, [MEM4]
    MOV [MEM5], EAX

在这个例子中,每条ADD指令产生2条微码,一条从内存中读(端口2),一条做加法(端口0或1)。 读内存的微码可以乱序执行,但各条加法微码都必须等待前面的加法微码完成。 这个依赖环执行的时间并不长,因为每个加法只需要一个时钟。 但如果你有诸如乘法这样的慢指令,甚至更坏的除法形成依赖环,那么明显你应该做一些事情来打破依赖环。 可以用多个累加器实现这个目的:

    MOV EAX, [MEM1] ; 开始第一个环
    MOV EBX, [MEM2] ; 用另一个累加器开始第二个环
    IMUL EAX, [MEM3]
    IMUL EBX, [MEM4]
    IMUL EAX, EBX ; 最后结合两个环
    MOV [MEM5], EAX

这里,第二个乘法指令可以在第一个结束之前开始。 因为乘法指令花费4个周期且是完全流水化的,所以你最多可设4个累加器。

除法是非流水化的,因此你不能为除法依赖环做类似的事情。当然,你可以先将所有的除数相乘,最后再做一个除法。

浮点指令的延迟比整型指令大,因此你应该明确地打破长的浮点依赖环:

    FLD [MEM1] ; 开始第一个环
    FLD [MEM2] ; 用另一个累加器开始第二个环
    FADD [MEM3]
    FXCH
    FADD [MEM4]
    FXCH
    FADD [MEM5]
    FADD ; 最后把环结合
    FSTP [MEM6]

你需要很多FXCH指令,不用担心,它们很“便宜”。 尽管在RAT,ROB和引退站中FXCH指令也被计为1条微码,但在RAT中FXCH指令通过寄存器重命名被“化解”,从而不会给执行端口造成任何负载。

如果依赖环很长,你需要设3个累加器:

    FLD [MEM1] ; 开始第一个环
    FLD [MEM2] ; 开始第二个环
    FLD [MEM3] ; 开始第三个环
    FADD [MEM4] ; 第三个环
    FXCH ST(1)
    FADD [MEM5] ; 第二个环
    FXCH ST(2)
    FADD [MEM6] ; 第一个环
    FXCH ST(1)
    FADD [MEM7] ; 第三个环
    FXCH ST(2)
    FADD [MEM8] ; 第二个环
    FXCH ST(1)
    FADD     ; 结合1、3环
    FADD     ; 与第2个环结合
    FSTP [MEM9]

不要把中间结果存入内存后马上读出:

    MOV [TEMP], EAX
    MOV EBX, [TEMP]

试图在前面的写内存操作完成之前从相同地址读会带来惩罚。 就像上面的例子那样。可以把最后一条指令改成MOV EBX,EAX或在两条指令之间插入一些其它指令。

有一种情形使你不可避免地要把中间结果存入内存,即从一个整型寄存器传输数据到一个浮点寄存器,反之亦然。 比如:

    MOV EAX, [MEM1]
    ADD EAX, [MEM2]
    MOV [TEMP], EAX
    FILD [TEMP]

如果在TEMP的写操作和TEMP的读操作之间你没有其它指令可插入,那么你可以考虑用浮点寄存器取代EAX:

    FILD [MEM1]
    FIADD [MEM2]

连续的跳转、调用和返回也可以看作是依赖环。 对于这些指令,吞吐量是每2个时钟周期1个转移指令。 因此推荐你在这些转移指令中间给处理器一些其它的事情做。



21. 寻找瓶颈(PPro,PII和PIII)

在这些处理器上作代码优化时,分析瓶颈的原因很重要。 当有一个要害的瓶颈存在时,花时间优化不是瓶颈的方面是没有意义的。

如果你估计到指令cache失效,那么你应该重组代码,将用得最多的部分放在一起。

如果你估计有很多次数据cache失效,那么不要再想其它事情了,集中精力于重组数据减少cache失效的次数(第7章),且要避免在读数据cache失效后存在一个长的依赖环(第20章)。

如果你有很多除法,那么试着减少它们(第27.2节),且保证处理器在做除法期间有其它事情做。

依赖环有妨碍乱序执行的倾向(第20章),试着打破长的依赖环,尤其是其中包括像乘法、除法和浮点指令这样的慢指令的时候。

如果你有许多跳转、调用、或返回,尤其是有大量不大好预测的转移指令时,试着避免一些,可能的话用条件传输代替条件跳转,用宏代替小的过程(第22.3节)。

如果你混用了不同尺寸的数据(8,16和32位),那么留意部分延迟。 如果用了PUSHF或LAHF指令,那么留意部分标志延迟。 避免在移位或循环移位数大于1的指令后测试标志位(第19章)。

如果你以一个时钟3条微码的吞吐量为目标,那么要注意取指令和指令解码可能的延迟(第14章第15章),特别是在小循环中。

一个时钟周期最多读2个永久寄存器的限制可能会使一个时钟的微码吞吐量减为3条以下(第16.2节)。 如果你经常在寄存器写操作的4个多时钟以后读该寄存器,这种限制可能会发生。 比如,你经常用指针对数据寻址,但很少修改这些指针。

一个时钟3条微码的吞吐量的必要条件是各个执行端口得到微码数不能超过总数的三分之一(第17章)。

引退站可以在一个时钟处理3条微码,但对于处理发生的跳转指令不是很高效(第18章)。



22. 分支和跳转(所有处理器)

奔腾家族的处理器试图预测跳转的位置,条件跳转是否发生。如果预测正确,通过在跳转发生之前读取后续指令进入管道并解码,能够节省一大笔时间;如果预测失败,管道被清洗,花的代价取决于管道的长度。

预测基于分支目标缓存部件(Branch Target Buffer ,简称 BTB ),它为每一个分支存了历史记录或跳转指令,使预测基于每条指令执行的历史记录。 BTB组织得像一个组相联cache,那里新的表项按照伪随机替换算法分配。

为了优化代码,重要的是要减少预测失败的代价。 这需要很好地理解分支预测的工作机制。

分支预测机制在任何地方都没有很好地描述,包括Intel的手册。 因此我这里做了详细的描述。 这些信息基于我的研究(在 PPlain 的 Karki Jitendra Bahadur 的帮助下)。

接下去,我要用术语"控制转移指令"代替所有能够使ip变化的指令,包括条件\非条件跳转,直接\间接跳转,近\远,跳转,调用,返回。 所有这些指令都要预测。

22.1  PPlain的分支预测

PPlain的分支预测机制比其它三种处理器复杂得多。 关于这个课题,在Intel文档或其它地方的信息直接误导读者,根据这些文档会写出不够理想的代码。

22.1.1  BTB的组织(PPlain)

PPlain有一个分支目标缓存(BTB),它能缓存256条跳转指令。 该BTB像一个4路的组相联cache,每路有64项。 这意味着BTB不能储存4个以上组值相同的项。 和数据cache不同的是,BTB用了伪随机数替换算法,这意味着一个新项不一定替换具有相同组值的最近最少使用的项。 组值的计算方法后面介绍。 每个BTB项存储目标跳转地址和预测状态,可能有四种状态:

状态0:强烈预言不发生
状态1:弱预言不发生
状态2:弱预言发生
状态3:强烈预言发生

在状态2或3,一个跳转指令被预测为将要发生;在状态0或1,被预测为不发生。 状态变化就像一个2位的计数器,在发生后,状态值增加;在不发生后,状态值减少。 计数器是饱和计数而不是环绕计数,因此到了0,它不再减少;到了3,它不再增加。 看来,应该预测得蛮准,因为要使预测结果发生变化(比如以前预言不发生,现在变成预言发生),分支指令可能要两次背离它以前的行为。

然而,该机制的一大弱点是BTB表项处于状态0意味着该BTB表项是没有用的。 因此处于状态0的BTB表项有了也等于没有。 这意义在于,如果一个分支指令没有BTB表项,那么它被预测为不发生。 这改善了BTB的利用,因为一般地,那些很少发生的分支指令不会占用BTB表项。

现在如果有一条件转移指令,它没有BTB表项。那么一个新的项会产生,该项的初值总是被置为状态3。这意味着它不可能从状态0到状态1(除了后面讨论的一种特殊情况)。如果发生,从状态0你只能到状态3?。如果不发生,分支被移出BTB表。

这是个严重的设计缺陷。通过将状态值为0的表项扔出BTB,并且总是将新的项置为状态3,设计者显然只优先考虑把无条件跳转和经常发生的分支指令的第一次运行代价最小化,而不顾这严重破坏了该机制的基本思想,降低了内部小循环的性能。 这会使得一个经常不发生的分支指令必须经过三次预测失败——和一个经常发生的分支指令一样多(显然,Intel工程师们没有注意到这个缺陷,直到我公开了我的发现)。

为了研究这个不对称,你可以组织你的分支指令,使它们发生的概率大于不发生的概率。 看这个if-then-else语句:

    TEST EAX,EAX
    JZ A
    <分支 1>
    JMP E
A:   <分支 2>
E:

如果分支1的概率比分支2大, 分支2很少连续执行2次,那么你可以交换这两个分支,这样JA A指令发生的概率就大于不发生的概率了,使相应的BTB表项进入状态3,这样可以减少预测失败:

    TEST EAX,EAX
    JNZ A
    <分支 2>
    JMP E
A:    <分支 1>
E:

(这与Intel的指南手册中推荐的完全相反)。

有理由将经常执行的分支放在发生的位置,然而:
  
  1. 把很少执行的分支远离你的代码底部却有利于改善指令cache的利用。
  2. 使很少发生的分支指令大多数时间不在BTB内,可能改善BTB的利用。
  3. 如果一条分支指令因为其它分支指令加入而被赶出BTB的话,它会被预测为不发生。
  4. 只有PPlain机上存在不对称的分支预测。

然而循环式思维是有点多虑了,因此我仍然推荐使跳转指令的发生概率大于不发生。 除非分支2使用概率太小了,那么分支预测的失败可以忽略了,那么考虑Intel的推荐。

同样的,你需要完善地组织底部有分支指令的循环,就像下面的例子:

     MOV ECX, [N]
 L:   MOV [EDI],EAX
    ADD EDI,4
    DEC ECX
    JNZ L

如果N很大,那么JNZ指令会大量地发生,不可能有两次连续的不发生。

考虑这样一种情况——每两次发生一次。 转移指令第一次进入BTB项的时候是状态3,然后就会在2和3之间颠簸。 这样它每次都被预测为发生,导致50%的预测失败。 假定现在它背离了规律,有了一次意外的不发生。跳转模式如下:

01010100101010101010101, 0表示不发生, 1表示发生。
       ^

意外的不发生用^表示。 在这个事变之后,BTB表项将在1和2之间颠簸,导致100%的预测失败。 这个不幸的模式将一直继续,直到又一个0101的背离发生。 这是这个分支预测机制的最坏情况了。

22.1.2  BTB记录的是前一个指令对的U管道指令的地址

BTB机制计算的是指令对,而不是单条指令。 因此,为了分析BTB表项存了什么地址,你必须知道指令的配对情况。 对于任何转移指令,BTB表项总是记录前一个指令对的U管道指令的地址(不配对的指令也看作一个"指令对")。 比如:

    SHR EAX,1
    MOV EBX,[ESI]
    CMP EAX,EBX
    JB L

这里SHR指令与MOV指令配对,CMP与JB配对。 因此对于JB L指令,BTB表项记录了SHR EAX,1的地址。 当到达这个BTB表项并且它处于状态2或3,Pentium会读出BTB表项中的目标地址,加载L之后的指令进入流水线。 这一步在分支指令JB L被解码之前就已发生,因此对于分支预测,Pentium只依赖BTB中的信息。

你应该知道,指令在第一次执行的时候很少配对的(见第八章)。 如果上述的指令完全不配对,BTB表项会记录CMP指令的地址,而这对于下一次执行(此时指令是配对的)显然是错的。 然而,多数情况下PPlain足够聪明,它一般不会对从没使用过的"指令对"分配BTB表项。 因此要到第二次执行,才会有BTB表项的分配;推论是要到第三次执行,才会有分支预测 (不过例外的是,如果第二条指令是单字节指令,那么在第一次执行的时候,你就会得到一个BTB表项,而在第二次执行的时候这个表项是无效的。 但既然这回BTB表项记录的指令将发送到V管道,这将被忽略而不会带来惩罚,因为BTB表项只读U管道的指令)。

一个BTB表项的id是它的组号,等于它记录的地址的0-5位。 6-31位也存入BTB做为标志位。 地址差为64字节的倍数的地址将拥有相同的组号。 最多允许有四个BTB表项拥有相同的组号。 如果你想检查是否你的转移指令争用同一个BTB表项,你必须比较它的前一个指令对中U管道指令地址的0-5位。 这很要命的,我从来没有见过有人这么干过。 也没有什么工具可以帮你干这件事。


22.1.3  连续的跳转(PPlain)

当跳转预测失败的时候,流水线被清洗。 如果下一个执行过的指令对也包括转移指令,PPlain不会加载它的跳转目标。 因为在流水线被清洗的时候不能加载新的跳转目标。 结果是不顾第二个转移指令的BTB表项状态,它被预测为不发生。 因此,如果第二个转移是发生的,你又会得到惩罚,哪怕事实上对于第二个跳转指令,BTB表项得到了正确的更新。 如果你有一个很长的转移指令的链,并且第一个转移指令预测失败,那么流水线会不断地刷新,在此其中如果没有不发生的"指令对",你就会每个转移指令都预测失败。 最极端的例子是一个跳转至它本身的循环: 每次叠代都会得到预测失败的惩罚。

连续的转移指令带来的问题还不止这个。 再一个问题是如果你有BTB表项和属于它的转移指令之间的另一转移指令,如果第一个转移指令跳转到其它地方,奇怪的事情发生了。 看这个例子:

    SHR EAX,1
    MOV EBX,[ESI]
    CMP EAX,EBX
    JB L1
    JMP L2

L1:   MOV EAX,EBX
    INC EBX

当JB L1不发生的时候,对于JMP L2,你会得到一个附上CMP EAX,EBX地址的BTB表项。 但如果JB L1发生时情况又如何呢?在这个时候JMP L2的BTB表项已经被读到,但处理器不知道下一个指令对不包括转移指令,因此它把指令对MOV EAX,EBX /INC EBX预测为跳转到L2。 预测非转移指令为跳转的惩罚是3个时钟周期。 JMP L2的BTB表项的状态值减1,因为它被应用于不发生跳转的指令上。 如果我们不断在循环中跳转到L1,JMP L2的BTB表项状态会被减为1和0。 于是问题一直出现,直到某一次JMP L2被执行。

预测非转移指令为跳转的代价仅仅发生在跳转至L1被预测到的情况下。 如果JB L1是预测失败的跳转,那么流水线被清洗,我们不会像前面那样加载L2这个错误目标。所以这回我们不会有预测非转移指令为跳转的惩罚,但JMP L2的BTB表项状态值还是减小。

现在假定我们把INC EBX指令替换为另一个jump指令。 那么这个jump指令会用和JMP L2相同的BTB表项(该表项预测跳转到L2),该表项可能会预测到错误的目标而带来惩罚(除非这个jump指令碰巧也以L2为目标)。

总结一下,连续的转移指令可能导致下面问题:
  
  *如果前一个转移预测失败,流水线被刷新,无法加载下一个转移指令的转移目标(强制预测为不发生)
  *BTB表项可能被错误地用于非转移指令,并且预测它们是跳转的。
  *上述的一个推论是:一个被错误应用的BTB表项的状态值会减小,可能到以后真正属于它的跳转要发生它却预测不发生。 由于这个理由,甚至无条件跳转的指令都会被 预测为不发生。
  *两个很近的jump指令可能会共用一个BTB表项,导致预测到错误的目标。

所有这些混乱会带给你许多惩罚。 因此你要明确避免在一个随机转移(不太好预测的)的指令的后面,和它转移目标的后面紧跟一个包括转移指令的指令对(两条转移指令之间至少要间隔两条其它指令)。

一个实例:

    CALL P
    TEST EAX,EAX
    JZ L2
L1:   MOV [EDI],EBX
    ADD EDI,4
    DEC EAX
    JNZ L1
L2:   CALL P

看上去这似乎是一段"优美的"代码:一个过程调用,当计数不为0的时候来个循环,再调用过程。你能发现多少问题呢?

首先,我们注意到P过程在两个不同的位置被调用。 这意味着P的返回地址一直在变,从而,对P的返回指令总是预测失败。

现在假设EAX是0。而因为P过程返回的预测失败导致的流水线刷新,使得该跳转到L2,却无法加载它的目标地址。 然后,因为JZ L2预测失败而刷新,第二个P调用又无法加载它的目标地址。 我们就进入了这样一个"境界":因为第一个jump预测失败,连续的jump导致流水线不断刷新。 JZ L2的BTB表项存储P的返回指令的地址,这个BTB表项会被错误地应用于第二个P调用后的任何指令。 但这倒不会带来惩罚,因为第二个返回的预测失败流水线被刷新了。

现在我们考虑EAX非0的情况:因为刷新,JZ L2一直被预测为不发生。 第二个P调用的BTB表项记录TEST EAX,EAX的地址。 这个表项会被错误地用于MOV/ADD指令对,预测它跳转到P。 这引起刷新,阻止JNZ L1加载它的目标。 如果我们以前执行过这里,第二个P调用会有另一个附着DEC EAX地址的BTB表项。 在第二、第三次叠代后,这个表项也被错误地应用到MOV/ADD指令对,直到它的状态减为1或0。 这种情况是在第二次叠代的时候没有惩罚,因为JNZ L1的预测失败导致了刷新,阻止了错误目标的加载。 但在第三次叠代的时候有惩罚。 后续的叠代没有惩罚,但因为上述情况的存在,JNZ L1一直预测失败,刷新会阻止P调用加载它的目标地址。 直到P调用的BTB表项因为几次错误的应用而被废除。

我们可以通过插入一些NOP,分开所有连续的转移来改进代码:

    CALL P
    TEST EAX,EAX
    NOP
    JZ L2
L1:   MOV [EDI],EBX
    ADD EDI,4
    DEC EAX
    JNZ L1
L2:   NOP
    NOP
    CALL P

虽然额外的NOP花费2个时钟周期,但节省得更多。 此外,JZ L2这回移入了U管道(因为CALL进入V管道),这样当预测失败的时候,代价就由4个时钟周期减为3个时钟周期。 只剩的问题就是P过程的返回地址一直会预测失败。 要解决的话,只有用内联宏来代替P过程的调用(如果你的CPU指令cache足够大的话)。

通过这个例子,你懂得了应该仔细地寻找连续的转移指令然后考虑是否可以通过插入一些NOP节省时间。 另外,你看到了一些必然预测失败的情况,比如循环退出的时候,当一个被不同位置调用的过程返回的时候。 当然,如果你有一些有用的代码可以插入,那么应该选择有用的代码而不是NOP。

多分支(case: 状态)既可以用树型的大量转移指令实现,也可以用跳转表实现。 如果你选择了树型的转移指令,那么你必须用一些NOP或其它指令隔开连续的转移指令。因此在PPlain上,跳转表可能是一个更好的解决方案。 跳转地址表应该放在数据段,决不能放在代码段!


22.1.4  小循环(PPlain)

在小循环中,你经常以很短的时间间隔重复访问同一个BTB表项。 这不会引起延迟。 因为PPlain不知怎么从流水线上开了一个"后门": 它能在写回BTB之前得到最近一次跳转的状态值,而不是等待BTB表项的更新。 该机制对用户几乎是透明的,但有时它会产生有趣的效果: 如果状态0来不及写回BTB,你会看到分支预测的状态总是从0到1,而不是3。 这在一个循环小于4个指令对时发生。 在只有两个指令对的循环中,你可能会在连续两次叠代中,使用不是来自BTB的状态0;在如此小的循环中,甚至会偶然地用两次以前叠代的状态值,而不用最近的一次来预测。 一般来说,这些有趣的效果在执行时不会带来什么副作用。


22.2  PMMX, PPro, PII and PIII的分支预测

22.2.1  BTB的组织(PMMX,PPro,PII和PIII)

PMMX的分支目标缓存(BTB)有256个表项,组织成16路*16组。 每个表项用属于它的转移指令的最后一个字节的地址的2-31位作为ID。 2-5位作为组值,6-31位存入BTB中作为标记。 两条地址相隔64字节的转移指令将有相同的组值,可能会被踢出BTB表。 但因为每组有16路,因此不会经常发生这种事情。

PPro,PII和PIII的分支目标缓存(BTB)有512个表项,组织成16路*32组。 每个表项用属于它的转移指令的最后一个字节的地址的4-31位作为ID。 4-8位作为组值,并且所有的4-31位都存入BTB作为标记。 两条地址相隔512字节的转移指令将有相同的组值,可能会被踢出BTB表。 但因为每组有16路,因此不会经常发生这种事情。

对于任何控制转移指令,PPro,PII和PIII在它第一次执行的时候就会分配BTB表项。 而在PMMX上,是在转移指令第一次发生的时候分配表项,没发生过的分支指令不进入BTB表,一旦它发生以后,哪怕它以后一直是不发生,它也驻留在BTB表中。

在另一条拥有相同组值的转移指令需要一个BTB表项的时候,原来的表项被踢出BTB。


22.2.2 预测失败的惩罚(PMMX,PPro,PII和PIII)

在PMMX上,U管道上执行的条件jump指令(JXX)预测失败的代价是4个周期,在V管道上是5个周期。 其它的转移指令都是4个周期的惩罚。

在PPro,PII和PIII上,因为管道比较长,预测失败的代价很大,一般需要10-20个时钟周期。 因此在PPro,PII和PIII上的程序要格外留意那些不大好预测的分支。


22.2.3 条件跳转的模式识别(PMMX,PPro,PII和PIII)

这些处理器有先进的模式识别机制,能够正确预测有规律的分支指令,比如每四次叠代来一次发生,其它三次是不发生。 事实上,它们能够预测所有长度不超过5的模式的发生和不发生,和许多更长的模式。

该机制号称"两级自适应分支预测模式",由 T.-Y.Yeh 和 Y.N.Patt 发明。 它以前面描述的PPlain上的2位计数器模式为基础(但没有2位计数器那样不对称的缺陷)。 2 位计数器是在跳转发生时状态值增加,在不发生时减少;在到达3或0时饱和;分支指令在相应的计数器值是2或3时被预测为发生,0和1时预测为不发生。现在通过在每一个BTB表项中有16个这样的计数器获得了大大的改进。 参考分支指令最近4次的执行历史,从16个计数器中选出1个。 比如,分支指令发生一次然后不发生三次,你会得到历史位串1000(1=发生,0=不发生)。 这就使得CPU下一次预测用计数器8(1000b=8),并且以后更新计数器8。

如果1000序列总是发生,计数器8将很快到达它最大状态值3,这样它将一直预测1000序列。 要两次背离这个模式才会改变预测结果。 重复模式100010001000使得计数器8的状态值是3,计数器1,2,4的状态值为0。 另外12个计数器不用。


22.2.4  可完美预测的模式(PMMX,PPro,PII和PIII)

如果某个长度大于5的模式串中任何一个4位的子串都是唯一的,那么这个模式串能被完美地预测。 下面列出了能被完美预测的模式串:

 长度  
可完美预测的模式 
1-5 all
6 000011, 000101, 000111, 001011
7 0000101, 0000111, 0001011
8 00001011, 00001111, 00010011, 00010111, 00101101
9 000010011, 000010111, 000100111, 000101101
10 0000100111, 0000101101, 0000101111, 0000110111, 0001010011, 0001011101
11 00001001111, 00001010011, 00001011101, 00010100111
12 000010100111, 000010111101, 000011010111, 000100110111, 000100111011
13 0000100110111, 0000100111011, 0000101001111
14 00001001101111, 00001001111011, 00010011010111, 00010011101011, 00010110011101, 00010110100111
15 000010011010111, 000010011101011, 000010100110111, 000010100111011, 000010110011101, 000010110100111, 000010111010011, 000011010010111
16 0000100110101111, 0000100111101011, 0000101100111101, 0000101101001111

看了这个表,你应该意识到如果一个模式串能被正确地预测,那么它的回文串(反过来读)和补串也能被正确预测。 比如,我们发现了模式0001011,其回文串是: 1101000,补串是1110100,补串的回文串是0010111。 这四个串都可预测。 小循环左移一位得到:0010110,这不是一个新的模式,仅仅是同一个模式的小循环移位版。 所有由上表中的模式经过回文,取补,小循环移位得到的模式都能识别。 至于明确的理由这里没有给出。

在BTB表项分配之后,模式识别机制学习一个规律化的模式需要两个阶段。 学习过程中预测失败的模式不可再生。 这可能是因为BTB表项包括了一些分配之前的东西。既然BTB表项是随机分配的,在初始化学习过程中很少有机会去预测。


22.2.5  处理背离规律模式的情况(PMMX,PPro,PII和PIII)

分支预测机制还能够完善地处理"几乎有规律"的模式,和背离规律模式的情况。它不仅学习规律化的模式的样子,还学习规律化的模式发生背离的情况。如果背离也有相同的模式,它会记住背离的模式,这样背离的预测失败只发生一次。

比如:

0001110001110001110001011100011100011100010111000
                      ^                   ^

这个序列中,0意味着不发生,1意味着发生。 机器学习到重复模式是000111。 第一次背离是一个0,用^标出。 在这个0以后的三次跳转可能预测不到了,因为它还没学习到0010,0101,1011后会发生什么。 在同样的意外发生1次或2次后,它学习到0010后是个1,0101后是个1,1011后是个1。 这意味着相同类型的意外最多发生两次,经过一次预测失败,它就学会处理这类意外。

对于两个不同的规律化模式的切换,该机制也很有效。 比如,我们已经重复了000111模式(长度为6)很多次,然后重复01模式(长度为2)很多次,然后又回到000111模式的时候,它不需要再学习000111模式,因为000111序列所用的计数器在01期间没有被改过。 在两个模式之间交替的几次后,它还会懂得处理模式的变换,对于每次模式切换,代价只有一次预测失败。


22.2.6  不能完美预测的模式(PMMX,PPro,PII和PIII)

最简单的不能被完美预测的模式是每六次有一次发生。该模式是:

000001000001000001
    ^^    ^^    ^^
    ab    ab    ab

0000序列总是跟在一个0(用a标出)和一个1(用b标出)后面。 这使得计数器0一直在上上下下。 如果计数器0从状态0或1开始,那么它会一直在0和1之间交替,b位置将一直发生预测失败;如果计数器0从状态3开始,那么它会一直在2和3之间交替,a位置将一直发生预测失败。 最坏的情况是它从状态2开始,它会不幸在1和2之间交替,我们在a和b处都会预测失败(这有点像前面说的PPlain的最坏情况)。 我们会从哪个状态开始取决于该转移指令的BTB表项分配之前的历史。 因为随机分配,所以我们无法控制。

为了避免每次循环有两个预测失败的最坏情况,理论上我们可以给CPU一个专门设计的分支序列,使它的计数器值为理想的状态,然而,不推荐这个办法,因为考虑到额外代码的开销,而且不管我们在计数器中放入什么信息,在以后有中断或任务切换的时候仍然会丢失。


22.2.7 完全随机的模式(PMMX,PPro,PII和PIII)

在完全没有规律的序列下,强大的模式识别机制也有小的缺点。

下面的表格列出了在完全随机的跳转发生/不发生序列下的预测失败率:

 
发生/不发生的比例 
 
预测失败的比例 
0.001/0.999
0.001001
0.01/0.99
0.0101
0.05/0.95
0.0525
0.10/0.90
0.110
0.15/0.85
0.171
0.20/0.80
0.235
0.25/0.75
0.300
0.30/0.70
0.362
0.35/0.65
0.418
0.40/0.60
0.462
0.45/0.55
0.490
0.50/0.50
0.500

预测失败率比没有模式识别机制略高。 因为处理器在毫无规律的序列里仍然在试图找重复的模式。


22.2.8 小循环(PMMX)

在小循环下,PMMX的分支预测是不可靠的,因为模式识别机制在又一次碰到分支指令之前来不及更新它的数据。 这意味着通常能够完美预测的简单模式也不能预测了。然而,正常情况下不能识别的模式,能在小循环下完美地预测。 比如,一个总是循环6次,底部有一个分支指令的循环将有111110的模式。 这个模式在正常情况下总得有1到2次预测失败,但在小循环里不会有预测失败。 对于总是循环7次的小循环也是如此。 大多数其它循环次数的循环作为小循环都比正常情况下预测得差。 这意味着叠代6或7次的循环更适合做成小循环,其它情况下不适合。 如果需要把循环做大,你可以将它展开。

判断在PMMX上的一个循环是否是小循环,你可以遵循拇指法则:给循环中的指令计数。 如果小于等于6,该循环是小循环。 如果有多于7条指令,你就能保证模式识别的功能正常了。 相当奇怪的是,各条指令用多少时钟周期,有没有延迟,是否配对都无所谓。 复杂的整形指令不计在内(复杂的整形指令是指不能配对的(NP)整形指令,通常需要多余一个周期来执行),一个可以有许多复杂的整形指令而在行为上仍是一个小循环。 复杂的浮点指令和MMX指令也计为1条指令。 注意,该法则只是启发性的,并非完全可靠。 重要的是实验测试。 在PMMX上,你可以用性能监控器35H为分支预测的失败计数。 测试结果也是不确定的,因为分支预测的结果取决于BTB表项分配之前的历史记录。

在PPro,PII和PIII上小循环的预测是正常的,因为每次叠代至少要花去2个时钟周期用于更新数据。


22.2.9  间接跳转和调用(PMMX,PPro,PII和PIII)

间接跳转和调用没有模式识别机制,对于一个间接跳转,BTB 只能记住一个目标。它只是简单地预测到达与上一次相同的目标。


22.2.10  JECXZ和LOOP(PMMX)

在PMMX上,这两个指令没有模式识别。 它们被简单地预测与最近一次执行的目标相同。 因此PMMX上,对运行时间苛刻的代码要避免这两条指令(在PPro,PII和PIII上对它们有模式识别机制,但是loop指令的效率仍然低于DEC ECX/JNZ)。


22.2.11  返回(PMMX,PPro,PII和PIII)

PMMX,PPro,PII和PIII上有一个返回栈缓存(RSB)用于预测返回指令。 RSB工作时是先进后出。 每当CALL指令被执行时,相应的返回地址被压入RSB。 每次返回指令执行时,一个返回地址弹出RSB用于预测返回地址。 该机制保证了同一个子程序,哪怕在不同的地方被调用,其返回指令也能被正确地预测。

为了保证该机制的正常工作,你必须保证所有的CALL和返回指令匹配。 不要没有返回便从一个子过程跳出,如果速度要求苛刻的话,不要将返回指令当作间接跳转指令使用。

在PMMX上,RSB可有四个表项,在PPro,PII和PIII上可有16个。 在RSB空的情况下,返回指令的预测与间接跳转指令相同,即它被预测为到达与上一次执行同样的位置。

在PMMX上,当子程序嵌套深度大于4时,最内层的4次调用使用RSB,只要没有新的调用,所有外层子程序的返回都用简单的预测机制。 一条利用RSB的返回指令仍然占有一个BTB表项。 PMMX的4个RSB表项听起来不多,但可能已经足够了。 一般来说,子程序嵌套大于4层很正常,但真正影响速度的是最内层的快慢,当然这不包括递归程序。

在PPro,PII和PIII上,当子程序嵌套深度大于16时,最内层的16次调用使用RSB,所有外层子程序的返回都会预测失败。 因此递归过程的深度最好不要超过16层。


22.2.12  PMMX上的静态预测

PMMX上,以前没有执行过的或者BTB表中没有的控制转移指令总是被预测为不发生,而不管它是前行还是后行的。

总是不发生的分支指令不会分配BTB表项。只要它发生一次,它就得到一个BTB表项,以后不管它不发生多少次,它总在BTB表内。只有当其它的转移指令正好要抢占它的表项时,它才被踢出BTB表。

任何跳转的目标地址紧随其后的转移指令不会得到BTB表项。比如:

     JMP SHORT LL
LL:

该指令不可能得到BTB表项,因此总是预测失败。


22.2.13  PPro,PII,PIII上的静态预测

在PPro,PII和PIII上,对于以前没有执行过的或者BTB表中没有的控制转移指令,如果是前行的则被预测为不发生,如果是后行的则被预测为跳转(比如一个循环)。 在这些处理器上,静态的预测花费的时间比动态的预测长。

如果你的代码不太可能放入cache,那么最好把最常用的执行分支做成不发生的,这样是为了改进指令的预取。


22.2.14  非常靠近的转移指令(PMMX)

在PMMX上,如果两条转移指令太过靠近的话,它们有共享一个BTB表项的风险。 明显的后果就是它们总是预测失败。

我们已知转移指令的BTB表项由指令的最后一个字节的地址的2-31位来指定。 如果两条转移指令如此接近以至它们的地址只有0-1位不同,就会发生共享一个BTB表项的问题。 比如:

     CALL P
    JNC SHORT L

如果CALL指令的最后一个字节和JNC指令的最后一个字节在记忆体的同一个双字内,我们就会付出代价。 你必须根据程序的输出汇编窗口,看是否这两个地址已经被双字界线隔开(双字界线是一个能被4整除的地址)。

解决这个问题有很多办法:
1.在记忆体中将代码序列稍微搬动一点,这样你就使得两个地址被双字线隔开。
2.将short jump改为near jump(有四个字节偏移),这样第二条指令的尾部离得远了。如果没有办法控制汇编器,只能靠简单地硬性规定近转移分支,那么你用这个方法。
3.在CALL和JNC之间插入一些指令。如果你因为段不是双字对齐的,或者代码一直在上下移动而不知道双字界线在哪里,那么这是最容易的方法,你更改代码如下:
    
     CALL P
    MOV EAX,EAX  ;填充2个字节就安全了
    JNC SHORT L

如果你还想在PPlain也避免问题,那么插入两个NOP,这样可以避免配对(见前述22。1。3节)。

RET指令对于这个问题特别敏感,因为它只有一个字节长:

    JNZ NEXT
    RET

这里你要用三个字节填充:

    JNZ NEXT
    NOP
    MOV EAX,EAX
    RET


22.2.15  连续的调用或返回(PMMX)

如果在过程调用指向的目标的第一个指令对中包括另外一个CALL指令,或者一个返回紧跟着另一个返回,那么会有惩罚。 比如:
  
  FUNC1 PROC NEAR
    NOP ; 避免过程在被调用之后紧跟call指令
    NOP
    CALL FUNC2
    CALL FUNC3
    NOP ; 避免在FUNC3返回之后紧跟返回指令
    RET
  FUNC1 ENDP

这里需要两个NOP,因为一个NOP会与CALL配对。 对于RET,一个NOP够了,因为RET是不可配对的。 两个CALL指令之间不需要NOP,因为在CALL返回之后再CALL是没有惩罚的(在PPlain上你就需要在这里也加两个NOP了)。

连续CALL的惩罚仅仅发生在同一个子过程在几个不同的位置被调用的时候(可能因为RSB需要更新)。 而连续的返回总是有惩罚。 有时CALL后跟一个jump会有小的延迟,但CALL后跟return、return后跟CALL(如上)、jump后跟jump,call或者return、return后跟jump都没有惩罚。


22.2.16  连续的转移(PPro,PII和PIII)

在前一个jump,call或return后的第一个时钟周期内,后一个jump,call或return无法执行。 因此对于连续的转移,每个转移都要花2个周期,你可能希望处理器在这段时间内并行做一些其它事情。 同样的道理,在这些处理器上,loop指令的每次叠代也至少花两个时钟周期。


22.2.17  设计可预测的分支(PMMX,PPro,PII和PIII)

多路分支(switch/case 状态值)既可由使用跳转表的间接跳转来实现,也可由树型的分支指令来实现。 因为间接跳转很难预测,所以如果有可预测的模式和足够的BTB表项的话,推荐后者。 如果你要使用前者,推荐把跳转地址表放在数据段。

你可能希望重新组织代码,使得不能完美预测的分支模式变成可完美预测的模式。 比如,一个循环总是执行20次,底部的条件转移指令总是19次发生,第20次不发生。 模式是有规律的,但不能被模式识别机制识别,因此那次不发生总是预测失败。 为了使得模式可识别,你可以把每两个循环做成四个或五个,或者将循环展开成4份让它执行5次。 这种复杂的做法增加额外代码,只有在PPo,PII和PIII这些预测失败代价昂贵的机器上才值得。 如果循环次数更多,则没必要为了这一个预测失败而采取任何措施。


22.3  避免跳转(所有处理器)

有很多理由促使你想减少jump,call和return指令的数量:

*转移预测失败的代价很大
*与不同处理器相关的,有各种对于连续的、链式的跳转的惩罚
*因为随机替换算法,转移指令会彼此排挤对方出BTB表
*一个返回指令在PPlain和PMMX上花费2个周期,call和return在PPro PII,PIII上要产生4条微指令。
*在PPro,PII和PIII上,在转移指令之后的指令预取可能会被延迟(第15章),而且对于跳转,引退的效果比其它微指令小得多(第18章)。

调用和返回可以通过用内联宏替换来解决。在许多情况下,可以通过重构你的代码减少大量的jump指令。 比如,一个跳到jump指令的jump,可以用一个跳到最终地址的jump来代替。 有时如果条件是相同的或者是已知的,这甚至对条件跳转也适用。 一个跳到RET指令的jump可以直接替换成RET。 如果你想消除返回之后的返回,你不应该修改堆栈指针,因为这将会干扰RSB的预测机制。 你应该把前一个CALL替换成一个jump。 比如,CALL PRO1/RET可以被替换成JMP PRO1,前提是PRO1过程的返回地址与RET相同。

你还可以通过重复一遍跳转后的指令来消除跳转。 如果你在循环中,或在返回之前有两路分支,这很管用:

A:    CMP [EAX+4*EDX],ECX
    JE B
    CALL X
    JMP C
B:    CALL Y
C:    INC EDX
    JNZ A
    MOV ESP, EBP
    POP EBP
    RET

跳转到C可以通过重复循环的尾部代码来消除:

A:    CMP [EAX+4*EDX],ECX
    JE B
    CALL X
    INC EDX
    JNZ A
    JMP D
B:    CALL Y
C:    INC EDX
    JNZ A
D:    MOV ESP, EBP
    POP EBP
    RET

最经常执行的分支应该优先考虑。 跳转到D已经是出了循环了,因此不是关键。 如果这个jmp经常发生,那么也要优化,方法是替换JMP D为D后面的三条指令。

22.4  利用标志位避免条件跳转(所有处理器)

最需要消除的就是条件跳转,尤其当它们不大好预测的时候。 有时灵活地运用标志位能获得和条件跳转一样的逻辑。 比如你可以不用条件跳转计算一个带符号数的绝对值:
    CDQ
    XOR EAX,EDX
    SUB EAX,EDX

(在PPlain和PMMX上,用MOV EDX,EAX/SAR EDX,31代替CDQ)

对于下列情况,进位标志特别有用:
如果值为0,则置进位标志:CMP [VALUE],1
如果值不为0,则置进位标志:XOR EAX,EAX / CMP EAX,[VALUE]
如果进位了,增加一个数:ADC EAX,0
每当进位标志是1,将某个位置位:RCL EAX,1
如果进位标志是1,产生一个掩码:SBB EAX,EAX
如果满足某个条件,将某个位置位:SETcond AL
如果满足某个条件,将所有位置位:XOR EAX,EAX / SETNcond AL / DEC EAX(记得将上一个例子的条件取反)

找出两个带符号数的小者:if(b<a)a=b;

SUB EBX,EAX
SBB ECX,ECX
AND ECX,EBX
ADD EAX,ECX

两个数选择其一:if(a!=0)a=b;else a=c;

CMP EAX,1
SBB EAX,EAX
XOR ECX,EBX
AND EAX,ECX
XOR EAX,EBX

这些技巧所产生的额外代码是否值得取决于:一个条件跳转的可预测性如何,在树型分支指令之间是否可被利用插入一些其它指令对或程序,跳转后面是否紧跟其它跳转(这将导致连续跳转的惩罚)。


22.5 用条件传输代替条件跳转(PPro,PII和PIII)

PPro,PII和PIII处理器由专门用于避免条件跳转的条件传输指令,因为对于这些机器预测失败的代价太大了。 对于整型和浮点型寄存器都有条件传输指令。 对于只运行在这些机器上的代码,你应该尽可能用条件传输指令代替不大好预测的条件转移指令。 如果想使你的代码在所有机器上都能运行,你对于瓶颈代码就得准备两个版本,一个用于支持条件传输的处理器,一个用于不支持条件传输的处理器(见27.10节,如何侦测条件传输指令是否被支持)。

预测失败的代价很大,因此哪怕有一些额外的指令,用条件传输指令代替条件跳转都是值得的。 但条件传输指令有使得依赖链变长的缺点,哪怕只需要一个,它也要等到两个寄存器操作数都就绪。 一个条件传输指令要等三个操作数处于就绪态: 条件标志和两个传输操作数。 你必须考虑到是否这三个操作数的任何一个可能会因为依赖链或者cache不命中带来延迟。 如果两个操作数就绪速度比条件标志慢得多,那你还是用条件跳转好,因为可能有等待两个操作数的时间,一个条件预测失败已经解决了。在需要漫长等待一个你可能还用不到的操作数的情况下,即使考虑到预测失败,一个条件跳转比条件传输快。 相反的,当两个操作数早已准备好,条件标志延迟的情况下,又考虑到分支预测可能失败,那么条件传输优于条件跳转。

23. 减小代码尺寸(所有处理器)

第七章描述的,指令cache是8k或16k。 如果代码的要害部位无法完全放进指令cache,那么可以考虑减小代码尺寸。

一般32位代码比16位代码大,因为32位代码的地址和数据常量是4个字节,16位代码是2个字节。 然而,16位代码有一些其它的惩罚诸如前缀的惩罚,同时访问邻近的字带来的问题(前述10.2章)。 减小代码尺寸的其它方法在下面讨论。

如果跳转地址,数据地址和数据常量在-128到127范围内,那么表示成一个带符号的字节可以节省空间。

对于跳转地址,近跳转被编码为2个字节。但是超过127字节的跳转,如果是非条件的编码为5个字节,如果是条件的编码为6个字节

同样地,数据地址如果可以被表达成一个指针和一个在-128到127范围的偏移,能节省空间。比如:
MOV EBX,DS:[100000] / ADD EBX,DS:[100004] ; 12字节
减少为:
MOV EAX,100000 / MOV EBX,[EAX] / ADD EBX,[EAX+4] ; 10字节

如果多次这样使用指针,好处就明显体现出来了。 倘若你的数据在指针偏移的-128~127范围内,那么用EBP或ESP在栈中存储数据,较之用静态内存地址和绝对地址可以减小代码长度。 用PUSH和POP读写临时数据甚至可以使代码更短。

如果数据常量在-128~127范围内,也能花费更少的空间。 当立即操作数是一个带符号的字节时,大多数指令有一种短的形式,比如:
  PUSH 200 ; 5字节
  PUSH 100 ; 2字节

  ADD EBX,128 ; 6字节
  SUB EBX,-128 ; 3字节

最重要的MOV指令带立即操作数,却不具备这种短形。
比如:

  MOV EAX, 0 ; 5字节

或许可改为:

  XOR EAX,EAX ; 2字节

还有

  MOV EAX, 1 ; 5字节

或许可改为:

  XOR EAX,EAX / INC EAX ; 3字节

或者:

  PUSH 1 / POP EAX ; 3字节

还有

  MOV EAX, -1 ; 5字节

或许可改为:

  OR EAX, -1 ; 3字节

如果同一个地址常量或数据常量将使用多次,那么最好把它放进寄存器。 一个带有4字节立即操作数的MOV指令有时候可以替换成算术指令——如果在MOV之前目的寄存器的值已知的话。 比如:

    MOV [mem1],200 ; 10字节
    MOV [mem2],200 ; 10字节
    MOV [mem3],201 ; 10字节
    MOV EAX,100 ; 5字节
    MOV EBX,150 ; 5字节

假定mem1和mem3都在mem2的-128~127的范围内,这片代码可以改为:

     MOV EBX, OFFSET mem2 ; 5字节
    MOV EAX,200 ; 5字节
    MOV [EBX+mem1-mem2],EAX ; 3字节
    MOV [EBX],EAX ; 2字节
    INC EAX ; 1字节
    MOV [EBX+mem3-mem2],EAX ; 3字节
    SUB EAX,101 ; 3字节
    LEA EBX,[EAX+50] ; 3字节

但在PPlain和PMMX上,要注意到LEA指令带来的AGI延迟。

应该注意到不同的指令有不同的长度。这些指令只有一个字节,十分“诱人”:PUSH reg, POP reg, INC reg32, DEC reg32。 操作数是8位寄存器的INC\DEC指令是2个字节,因此INC EAX比INC AL短。

XCHG EAX,reg也是一条单字节指令,比MOV EAX。reg短,但比较慢。

有些指令使用累加器可以比使用其它寄存器节省一个字节:
比如:

  MOV EAX,DS:[100000] 比MOV EBX,DS:[100000]短
  ADD EAX,1000 比ADD EBX,1000短

带指针的指令,当它们只有基址指针(不是ESP)和一个偏移时,比带有比例变址寄存器,或基址变址寻址,或ESP作为基址指针的长度短一个字节:
比如:

  MOV EAX,[array][EBX] 比MOV EAX,[array][EBX*4]短
  MOV EAX,[EBP+12] 比MOV EAX,[ESP+12]短

没有偏移,没有变址寄存器的指令,如果用EBP作为基址指针比用其它寄存器长一个字节:

  MOV EAX,[EBX] 比MOV EAX,[EBP]短,但是
  MOV EAX,[EBX+4] 和MOV EAX,[EBP+4]一样长。

不带基址指针,只有一个比例变址指针的指令会被强加一个4字节的偏移,哪怕偏移是0,因此:

  LEA EAX,[EBX+EBX] 比LEA EAX,[2*EBX]短。

24. 规划浮点代码(PPlain和PMMX)

浮点指令无法按整型指令的方法去配对,除了下述规则定义的特殊情况:

  * 第一条指令(在U管道中执行)必须是FLD, FADD, FSUB, FMUL, FDIV, FCOM, FCHS, 或 FABS。
 * 第二条指令(在V管道中执行)必须是FXCH。
  * 跟在FXCH后面的那条指令必须是一条浮点指令,否则FXCH的配对是不完美的,会额外地花去一个时钟。

这种特殊的配对很重要,下面作个简单的解释。

大多数情况下浮点指令无法配对,但很多指令是流水化的,也就是说,一条指令可以在前一条指令尚未完成时就开始。比如:

    FADD ST(1),ST(0) ; 时钟周期1-3
    FADD ST(2),ST(0) ; 时钟周期2-4
    FADD ST(3),ST(0) ; 时钟周期3-5
    FADD ST(4),ST(0) ; 时钟周期4-6

显然,如果后一条指令需要用到前一条指令的结果的话,那么它们在时间上无法重叠。 因为几乎所有的浮点指令都与寄存器堆栈的栈顶ST(0)有关,所以看来要经常做到后一条指令独立于前一条指令的结果很难。 解决这个难题的方法就是寄存器重命名。 像FXCH指令其实并没有真正交换两个寄存器的值,它仅仅交换了它们的名字。 对浮点堆栈进行压栈或出栈操作的指令也是通过寄存器重命名工作的。 浮点寄存器重命名在Pentium上被高度优化过,因此寄存器在使用的时候可能被重命名。 寄存器重命名从来不会引起延迟——甚至可能在1个时钟周期中对寄存器重命名多次,就像示例中让FLD或FCOMPP与FXCH配对那样。

通过使用FXCH指令,你就可以获得很多浮点代码的重叠执行。比如:

    FLD [a1] ; 时钟周期1
    FADD [a2] ; 时钟周期2-4
    FLD [b1] ; 时钟周期3
    FADD [b2] ; 时钟周期4-6
    FLD [c1] ; 时钟周期5
    FADD [c2] ; 时钟周期6-8
    FXCH ST(2) ; 时钟周期6
    FADD [a3] ; 时钟周期7-9
    FXCH ST(1) ; 时钟周期7
    FADD [b3] ; 时钟周期8-10
    FXCH ST(2) ; 时钟周期8
    FADD [c3] ; 时钟周期9-11
    FXCH ST(1) ; 时钟周期9
    FADD [a4] ; 时钟周期10-12
    FXCH ST(2) ; 时钟周期10
    FADD [b4] ; 时钟周期11-13
    FXCH ST(1) ; 时钟周期11
    FADD [c4] ; 时钟周期12-14
    FXCH ST(2) ; 时钟周期12

上面的例子中,我们把3个独立的线程交错。 每个FADD需要3个时钟周期,我们可以在每个时钟周期开始一个新的FADD。 当我们在“a线程”开始一个FADD指令,在回到“a线程”以前,我们还有时间在“b线程”和“c线程”中开始两个新的FADD指令,因此每隔两个的FADD指令属于同一个线程。 就像上面的代码,我们每次用FXCH指令把想操作的线程的寄存器换入ST(0),这产生了一个有规律的模式。 值得注意是FXCH指令的重复周期是2,而线程的重复周期是3。 这很容易搞错,因此要对机器熟悉,知道各个浮点寄存器当前在什么位置。

所有版本的FADD,FSUB,FMUL和FILD指令都花3个周期且可以重叠执行,因此这些指令可以用上面的办法来调度。 如果内存操作数在1级cache中并且完全对齐的话,那么使用内存操作数花的时间不比寄存器操作数多。

你现在一定习惯于带有例外的规则了,上述的重叠规则也有个例外:在一个FMUL指令之后的第一个时钟周期内你不能开始一个新的FMUL,因为FMUL的电路并不是完全流水化的。 推荐你在两条FMUL指令之间插入其它的指令,比如:
    FLD [a1] ; 时钟周期1
    FLD [b1] ; 时钟周期2
    FLD [c1] ; 时钟周期3
    FXCH ST(2) ; 时钟周期3
    FMUL [a2] ; 时钟周期4-6
    FXCH ; 时钟周期4
    FMUL [b2] ; 时钟周期5-7 (延迟)
    FXCH ST(2) ; 时钟周期5
    FMUL [c2] ; 时钟周期7-9 (延迟)
    FXCH ; 时钟周期7
    FSTP [a3] ; 时钟周期8-9
    FXCH ; 时钟周期10 (未配对)
    FSTP [b3] ; 时钟周期11-12
    FSTP [c3] ; 时钟周期13-14
这里,你在FMUL [b2]和FMUL [c2]之前有延迟,因为它们是在前一个FMUL后的第一个时钟周期开始的。你可以在各个FMUL之间插入FLD指令来改进代码:

    FLD [a1] ; 时钟周期1
    FMUL [a2] ; 时钟周期2-4
    FLD [b1] ; 时钟周期3
    FMUL [b2] ; 时钟周期4-6
    FLD [c1] ; 时钟周期5
    FMUL [c2] ; 时钟周期6-8
    FXCH ST(2) ; 时钟周期6
    FSTP [a3] ; 时钟周期7-8
    FSTP [b3] ; 时钟周期9-10
    FSTP [c3] ; 时钟周期11-12

你也可以在FMUL之间插入FADD,FSUB或其它指令来避免延迟。

当然,重叠浮点指令的前提是你有一些彼此独立的线程能够交错。如果你只有一个很大的公式要执行,那么你可以并行计算公式的每个部分,达到重叠的目的。比如要把6个数相加,你可以分2个线程,每个线程有3个数,最后把两个线程的结果相加:

    FLD [a] ; 时钟周期1
    FADD [b] ; 时钟周期2-4
    FLD [c] ; 时钟周期3
    FADD [d] ; 时钟周期4-6
    FXCH ; 时钟周期4
    FADD [e] ; 时钟周期5-7
    FXCH ; 时钟周期5
    FADD [f] ; 时钟周期7-9 (延迟)
    FADD ; 时钟周期10-12 (延迟)

因为要等FADD [d]的结果,我们在FADD [f]之前有1个时钟延迟;又因为等FADD [f]的结果,在最后一个FADD之前有2个时钟延迟。 通过在最后一个FADD之前插入一些整型指令可以掩盖第二个延迟,但对于第一个延迟这么做没用,因为整型指令会使FXCH的配对不完美。

开3个线程而不是2个,可以避免第一个延迟。 但这将多出一个FLD指令,因此并没节约,除非相加的数在大于等于8个。

不是所有的浮点指令都能重叠执行的。 有些浮点指令能够覆盖的整型指令比浮点指令多。 除法FDIV就是一个例子,它花39个周期。 在它之后,除第一个周期外的其它周期都能重叠整型指令,但只有最后两个时钟能够重叠浮点指令。比如:

    FDIV ; 时钟周期1-39 (U流水线)
    FXCH ; 时钟周期1-2 (V流水线,不完美的配对)
    SHR EAX,1 ; 时钟周期3 (U流水线)
    INC EBX ; 时钟周期3 (V流水线)
    CMC ; 时钟周期4-5 (不能配对)
    FADD [x] ; 时钟周期38-40 (U流水线, 当FPU忙时只能等)
    FXCH ; 时钟周期38 (V流水线)
    FMUL [y] ; 时钟周期40-42 (U流水线, 等FDIV的结果)

第一个FXCH与FDIV配对,但要额外花去1个时钟因为后面跟的不是浮点指令。 SHR/INC指令对在FDIV完成前就能开始,但必须等FXCH结束。

FADD指令必须等到第38个时钟才开始,因为新的浮点指令只能在FDIV的最后两个时钟开始执行。 第二个FXCH与FADD是配对的。 FMUL指令必须等FDIV结束,因为它要用到除法的结果。

如果在那种能重叠很多整型指令的浮点指令(比如FDIV或FSQRT)后面你没有什么事可做,那么你可以对后面的程序可能用到的内存地址进行“哑读”,以保证它在1级cache中。比如:

    FDIV QWORD PTR [EBX]
    CMP [ESI],ESI 
    FMUL QWORD PTR [ESI]

在此,当计算除法的时候,我们用整型指令与之重叠,把在[ESI]地址的值预取入cache(我们不关心CMP指令的结果是什么)。

第28章给了一个完整的浮点指令列表,以及它们能与那些指令配对,与那些指令重叠。

在浮点指令中用内存操作数并不花代价,因为在流水线中,运算单元比读取单元慢一步。 但当你把浮点数据存入内存的时候就“不公平”了: 带内存操作数的FST或FSTP指令在执行阶段花两个周期,但要提早1个周期把数据准备好。 如果要存的数据没有提前准备好的话,就会有一个周期的延迟。这与AGI延迟有点像。 比如:

    FLD [a1] ; 时钟周期1
    FADD [a2] ; 时钟周期2-4
    FLD [b1] ; 时钟周期3
    FADD [b2] ; 时钟周期4-6
    FXCH ; 时钟周期4
    FSTP [a3] ; 时钟周期6-7
    FSTP [b3] ; 时钟周期8-9

FSTP [a3]将延迟1个时钟,因为FADD [a2]的结果没有提前一个时钟准备好。 多数情况下,要不是通过把浮点代码规划成4个线程或在其中插入一些整型指令的话,这种延迟是无法掩盖的。 此外,FST(P)执行阶段的2个时钟是无法与后续指令配对或重叠的。

带有整型操作数的指令诸如:FIADD, FISUB, FIMUL, FIDIV, FICOM可以切成简单的操作,以改善重叠。比如:

    FILD [a] ; 时钟周期1-3
    FIMUL [b] ; 时钟周期4-9

切成:

    FILD [a] ; 时钟周期1-3
    FILD [b] ; 时钟周期2-4
    FMUL ; 时钟周期5-7

在示例中,通过重叠两条FILD指令你节省了2个时钟。

 

25. 循环优化(所有处理器)

分析程序你经常会看到大部分时间都花费在最内层的循环上面。 提高速度的方法就是认真地用汇编优化最花时间的循环。 其它的部分仍然用高级语言完成。

下面所有的例子都假定数据全在1级cache内。 如果数据cache失效是瓶颈,那么没有理由去对指令进行优化。 而应该把注意力集中在组织你的数据,尽量减少cache失效次数(第七章)。

25.1  PPlain和PMMX上的循环

循环通常包括一个控制叠代次数的计数器,而且经常是一次叠代读或写一个数组元素。我选了这样一个例子:一个过程从数组中读整数,改变每个整数的符号,把结果存入另一个数组中。

这个过程用C语言可以写成:

void ChangeSign (int * A, int * B, int N) {
  int i;
  for (i=0; i<N; i++) B[i] = -A[i];}

翻译成汇编,我们可以写成:

示例1.1:

_ChangeSign PROC NEAR
    PUSH ESI
    PUSH EDI
A    EQU DWORD PTR [ESP+12]
B    EQU DWORD PTR [ESP+16]
N    EQU DWORD PTR [ESP+20]
    MOV ECX, [N]
    JECXZ L2
    MOV ESI, [A]
    MOV EDI, [B]
    CLD
L1:   LODSD
    NEG EAX
    STOSD
    LOOP L1
L2:  POP EDI
    POP ESI
    RET ; (如果是_cdecl调用规则,那么没有额外的POP指令)
_ChangeSign ENDP

看上去写得很漂亮,但这不是优化的,因为它用了慢的未配对指令。 在所有数据在1级cache的前提下,每次叠代化11个时钟周期。

*只用可配对的指令(PPlain和PMMX)

示例1.2:

    MOV ECX, [N]
    MOV ESI, [A]
    TEST ECX, ECX
    JZ SHORT L2
    MOV EDI, [B]
L1:   MOV EAX, [ESI] ; u
    XOR EBX, EBX ; v (成对)
    ADD ESI, 4 ; u
    SUB EBX, EAX ; v (成对)
    MOV [EDI], EBX ; u
    ADD EDI, 4 ; v (成对)
    DEC ECX ; u
    JNZ L1 ; v (成对)
L2:

在此,我们只用可配对指令,并组织指令使它们都能配对。 现在每次叠代只花4个时钟了。不“切开”NEG指令我们也能获得相同的速度,但另一条无法配对的指令应该“切开”。

*用一个寄存器既当变址寄存器又当计数器

示例1.3:

    MOV ESI, [A]
    MOV EDI, [B]
    MOV ECX, [N]
    XOR EDX, EDX
    TEST ECX, ECX
    JZ SHORT L2
L1:   MOV EAX, [ESI+4*EDX] ; u
    NEG EAX ; u
    MOV [EDI+4*EDX], EAX ; u
    INC EDX ; v (成对)
    CMP EDX, ECX ; u
    JB L1 ; v (成对)
L2:

用同一个寄存器既当变址寄存器又当计数器使循环体的指令数目减少了,但仍然要4个时钟,因为有两条未配对的指令。

*让计数器以0结束(PPlain和PMMX)

我们想摆脱1.3中的CMP指令,就像在1。2中那样,使计数器以0结束,测试ZF标志来判断循环是否结束。 一个办法是先取数组的最后一个元素,再向后执行循环。 然而,数据cache是为向前访问数据优化的,而不是向后。 因此如果可能发生cache失效,好的办法还是使计数器从-N开始,沿着负值加到0。 这样基址指针应该指向数组末尾而不是开头:

示例1.4:

    MOV ESI, [A]
    MOV EAX, [N]
    MOV EDI, [B]
    XOR ECX, ECX
    LEA ESI, [ESI+4*EAX] ; 指向A数组尾
    SUB ECX, EAX ; -N
    LEA EDI, [EDI+4*EAX] ; 指向B数组尾
    JZ SHORT L2
L1:   MOV EAX, [ESI+4*ECX] ; u
    NEG EAX ; u
    MOV [EDI+4*ECX], EAX ; u
    INC ECX ; v (成对)
    JNZ L1 ; u
L2:

现在循环体的指令减为五条了,但因为配对不佳,每次叠代仍是4个时钟(如果数组的地址和尺寸是常量,我们可以通过用A+SIZE A取代ESI,B+SIZE B取取代EDI来节省两个寄存器)。 让我们看看如何改进配对:

*循环中指令的配对(PPlain和PMMX)

我们希望通过循环控制指令的混合计算改进配对情况。 如果想在INC ECX和JNZ L1中插入一些指令,那么这些指令不能影响ZF。 在INC ECX后的MOV [EDI+4*ECX],EBX指令会产生AGI延迟,因此我们必须设计得更精妙:

示例1.5:

    MOV EAX, [N]
    XOR ECX, ECX
    SHL EAX, 2 ; 4 * N
    JZ SHORT L3
    MOV ESI, [A]
    MOV EDI, [B]
    SUB ECX, EAX ; - 4 * N
    ADD ESI, EAX ; 指向A数组尾
    ADD EDI, EAX ; 指向A数组尾
    JMP SHORT L2
L1:   MOV [EDI+ECX-4], EAX ; u
L2:   MOV EAX, [ESI+ECX] ; v (成对)
    XOR EAX, -1 ; u
    ADD ECX, 4 ; v (成对)
    INC EAX ; u
    JNC L1 ; v (成对)
    MOV [EDI+ECX-4], EAX
L3:

在此我们用了一个不同的方法计算EAX的相反数:把所有位取反,再加一。 用这个方法巧妙利用了INC指令:INC指令不改变CF(ADD指令会影响CF)。用ADD而不是INC增加循环计数,测试CF而不是ZF。如此就能把INC EAX指令插入而不影响CF。 你可能想, 我们为何不用LEA EAX,[EAX+1]取代INC EAX,至少它不影响任何标志。 但要知道LEA指令会产生AGI延迟,不是最好的解决之道。 注意,如此利用INC指令不改变CF的性质只有在PPlain和PMMX上有用,在PPro,PII和PIII上会引起部分标志延迟。
我们已经获得了完美的配对,每次叠代只需3个时钟了。 至于循环计数每次加1(示例1.4)还是加4(示例1.5)只是个人喜好,循环花的时间都一样。

*将每次操作的末尾与下一次操作的开头环绕(PPlain和PMMX)

示例1.5用的方法不是通用的,因此我们找寻其它方法改进配对机会。 一个方法就是重组循环,使每次操作的末尾与下一次操作的开头环绕。称它为“盘旋式循环”。一个“盘旋式循环”的每次叠代总有一些未完成的操作留待下一次叠代时完成。 实际上,示例1.5每次叠代的最后一个MOV与下一个叠代的第一个MOV也是相关的,但我们想更深入地研究这个方法:

示例1.6:

    MOV ESI, [A]
    MOV EAX, [N]
    MOV EDI, [B]
    XOR ECX, ECX
    LEA ESI, [ESI+4*EAX] ; 指向A数组末尾
    SUB ECX, EAX ; -N
    LEA EDI, [EDI+4*EAX] ; 指向B数组末尾
    JZ SHORT L3
    XOR EBX, EBX
    MOV EAX, [ESI+4*ECX]
    INC ECX
    JZ SHORT L2
L1:   SUB EBX, EAX ; u
    MOV EAX, [ESI+4*ECX] ; v (成对)
    MOV [EDI+4*ECX-4], EBX ; u
    INC ECX ; v (成对)
    MOV EBX, 0 ; u
    JNZ L1 ; v (成对)
L2:   SUB EBX, EAX
    MOV [EDI+4*ECX-4], EBX
L3:

在此,我们在存储前一个值之前就读出下一个值,这当然改进了配对机会。 插在INC ECX和JNZ L1之间的MOV EBX,0指令不是为了增加配对机会,而是为了避免AGI延迟。

*展开循环(PPlain和PMMX)
增加配对机会的常用的方法是每次叠代做2次操作,叠代次数减半。 这被称为循环展开:

示例 1.7:

    MOV ESI, [A]
    MOV EAX, [N]
    MOV EDI, [B]
    XOR ECX, ECX
    LEA ESI, [ESI+4*EAX] ; 指向A数组的末尾
    SUB ECX, EAX ; -N
    LEA EDI, [EDI+4*EAX] ; 指向B数组的末尾
    JZ SHORT L2
    TEST AL,1 ; 看N是否是奇数
    JZ SHORT L1
    MOV EAX, [ESI+4*ECX] ; N是奇数,则把多余的一个先做掉
    NEG EAX
    MOV [EDI+4*ECX], EAX
    INC ECX ; 使计数器变为偶数
    JZ SHORT L2 ; N = 1
L1:   MOV EAX, [ESI+4*ECX] ; u
    MOV EBX, [ESI+4*ECX+4] ; v (配对)
    NEG EAX ; u
    NEG EBX ; u
    MOV [EDI+4*ECX], EAX ; u
    MOV [EDI+4*ECX+4], EBX ; v (配对)
    ADD ECX, 2 ; u
    JNZ L1 ; v (配对)
L2:

现在我们并行执行2次操作,得到了最好的配对机会。 我们必须先看N是否是奇数,如果是的话在循环外做掉一次操作。 因为循环只能做偶数次操作。

循环的第一条MOV指令有AGI延迟,因为ECX在前一个时钟周期被增加过。 因此循环的每次叠代(包括2次操作)花6个时钟。

*重组循环避免AGI延迟(PPlain和PMMX)

示例 1.8:

    MOV ESI, [A]
    MOV EAX, [N]
    MOV EDI, [B]
    XOR ECX, ECX
    LEA ESI, [ESI+4*EAX] ; 指向A数组的末尾
    SUB ECX, EAX ; -N
    LEA EDI, [EDI+4*EAX] ; 指向B数组的末尾
    JZ SHORT L3
    TEST AL,1 ; 看N是否是奇数
    JZ SHORT L2
    MOV EAX, [ESI+4*ECX] ; N是奇数,则把多余的一个先做掉
    NEG EAX ; 没有配对机会
    MOV [EDI+4*ECX-4], EAX
    INC ECX ; 使计数器变为偶数
    JNZ SHORT L2
    NOP ; JNZ L2不容易预测,因此加入NOP
    NOP
    JMP SHORT L3 ; N = 1
L1:   NEG EAX ; u
    NEG EBX ; u
    MOV [EDI+4*ECX-8], EAX ; u
    MOV [EDI+4*ECX-4], EBX ; v (配对)
L2:   MOV EAX, [ESI+4*ECX] ; u
    MOV EBX, [ESI+4*ECX+4] ; v (配对)
    ADD ECX, 2 ; u
    JNZ L1 ; v (配对)
    NEG EAX
    NEG EBX
    MOV [EDI+4*ECX-8], EAX
    MOV [EDI+4*ECX-4], EBX
L3:

该技巧就是找出那些不用计数器做变址索引的指令,重组循环,使计数器在前3个周期就已经增加过。 现在我们的每2次操作减为5个时钟了,这已接近最好的可能。

如果数据cache是瓶颈,那么你可以通过把A、B数组交错成一个数组来进一步提高速度,因为这样每个B[i]就紧跟在对应的A[i]之后了。 如果该结构数组至少按8对齐的话,B[i]将一直和A[i]在一条cache行内,这样在写B[i]的时候就不可能cache失效。 当然,用了这方法或许会使程序的其它部分很不方便,因此要权衡利弊得失。

*展开2次以上(PPlain和PMMX)

你可能会想每次叠代做2次以上的操作,这样可以减少平均每次操作的循环开销。 但对于大多数情形,循环开销都可以减少到每次叠代只有1个时钟周期,因此相比展开成2次操作而言,展开成4次操作只能在节省1/4时钟/操作,这几乎不值得尝试。 只有在循环开销无法降低到1个时钟且N非常大,你可以考虑展开成4次操作/叠代。

过分的循环展开的缺点有:
1. 你需要计算N%R的值,这里R是展开的次数。然后在主循环的前面或后面做掉N%R次操作,使剩余的操作次数可以被R整除。这会多出很多代码而且其分支不容易预测。而且循环体也变大了。
2. 一片代码的第一次运行会花去很多时间。代码越多首次运行的惩罚越大,尤其是当N很小的时候。
3. 过多的代码使得代码cache更难有效利用。

*在32位寄存器中并行处理多个8或16位的操作数(PPlain和PMMX)

如果需要妥善处理8或16位操作数的数组,那么展开循环还会遇到问题。 因为你将无法使2条访问内存的指令配对。 比如,MOV AL,[ESI]/MOV BL,[ESI+1]不能配对——如果两个操作数在内存的同一个dword中的话。 有一个更妙的方法,也就是在32位寄存器中一次处理4个字节。

以下例子是把2加到字节数组的每个元素上去:

示例 1.9:

    MOV ESI, [A] ; 字节数组的地址
    MOV ECX, [N] ; 字节数组的元素个数
    TEST ECX, ECX ; 看N是否=0
    JZ SHORT L2
    MOV EAX, [ESI] ; 读开始的4个字节
L1:   MOV EBX, EAX ; 拷贝到EBX
    AND EAX, 7F7F7F7FH ; 得到EAX中每个字节的低7位
    XOR EBX, EAX ; 得到每个字节的最高位
    ADD EAX, 02020202H ; 把值同时加到4个字节上
    XOR EBX, EAX ; 再与最高位组合
    MOV EAX, [ESI+4] ; 读下4个字节
    MOV [ESI], EBX ; 存结果
    ADD ESI, 4 ; 增加指针
    SUB ECX, 4 ; 减少循环计数
    JA L1 ; 循环
L2:

该循环中每4个字节的操作花5个时钟。 数组当然应该按4对齐。如果数组元素个数不能被4整除,那么你可以在数组的末尾增加一些字节使其长度能被4整除。 该循环总是会越界访问数组尾,因此你得保证数组没有放置在段的末尾,以避免常规保护性错误。

注意,这里用掩码保护每个字节的最高位,以避免加每个字节时把进位带给下个字节。 再次组合最高位时,我用了XOR而不用ADD是为了避免进位。

ADD ESI,4指令可以通过用一个像示例1.4那样的循环计数器来避免。 然而,这将使循环体的指令数变为奇数,从而产生