怎样优化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那样的循环计数器来避免。 然而,这将使循环体的指令数变为奇数,从而产生一个未配对的指令,叠代仍然花5个周期。 如果转移指令是不配对的,那么可以在最后一个分支预测失败的操作(即退出循环时)后节省1个时钟,但我们得花额外的时钟进行预处理——设定指向数组尾部的指针并计算-N。 因此两种方法一样的速度,这里提供的方法是最简单、最短的。

下个例子是通过找第一个0字节得到以0结尾的字符串的长度。它比用REP SCASB快:

示例1.10:

STRLEN PROC NEAR
    MOV EAX,[ESP+4] ; 得到串起始指针
    MOV EDX,7
    ADD EDX,EAX ; 起始指针加7的值,最后要用
    PUSH EBX
    MOV EBX,[EAX] ; 读开始的4字节
    ADD EAX,4 ; 移动指针
L1:   LEA ECX,[EBX-01010101H] ; 各个字节减1
    XOR EBX,-1 ; 各个字节取补
    AND ECX,EBX ; 把两个结果与
    MOV EBX,[EAX] ; 读下4个字节
    ADD EAX,4 ; 移动指针
    AND ECX,80808080H ; 测ECX各个字节的符号位(最高位)
    JZ L1 ; 如果没找到0字节,继续找
    TEST ECX,00008080H ; 检查低2个字节
    JNZ SHORT L2
    SHR ECX,16 ; 0字节不在低2个字节中 ADD EAX,2
L2:   SHL CL,1 ; 利用CF避免了一个分支
    POP EBX
    SBB EAX,EDX ; 得到长度,存入EAX
    RET
STRLEN ENDP

在此,我们又把一次叠代的尾部放到下一次操作中做,这样可以增加配对机会。 我没有把循环展开,因为叠代的次数可能不会很多。 串当然最好按4对齐(即起始地址能被4整除)。 代码的访问总会超过串尾,因此串不能放在段尾。

循环体的指令数目是奇数,有1条指令未配对。 令分支指令而不是其它指令不配对是有好处的——当分支预测失败的时候可以节省1个时钟。

TEST ECX,00008080H指令是不能配对的。 你可以用可配对的指令OR CH,CL取代它,但你必须插入一条NOP或其它指令以避免连续分支带来的惩罚。 OR CH,CL的另一个问题是在PPro,PII和PIII上会引起部分寄存器延迟。 因此我仍然用不可配对的指令TEST ECX,00008080H。

一次处理4个字节可能是相当困难的。 上面代码利用了这样一个规律:当且仅当碰到0字节的时候,才会计算产生一个非0的值。 这使得一次叠代测试4个字节变为了可能。 算法包括了从各个字节的减1操作(LEA指令)。 示例中,在减1操作之前我并没有用掩码保护每个字节的最高位,因为只有碰上0字节,减法才可能会向高字节借位。而这种情况下我们恰恰不用关心高字节是什么,因为我们是在向前寻找第一个0字节。 如果我们是在向后寻找,那么我们必须在侦测到0字节以后重新载入整个dword,并测全这4个字节,找到最后一个0。 用BSWAP指令将字节的顺序逆转也可以。  

如果你想找第一个非0值,那么要先把4个字节与你想找的值XOR,然后仍旧用上面的方法找0值。

*带MMX指令的循环(PMMX)

在一个寄存器中处理多个操作数早在MMX处理器上出现过。 因为它有专门的指令和专门的64位寄存器用于这个目的。

回到把2加到数组中所有字节的例子,我们使用MMX指令:

示例1.11:

.data
ALIGN 8
ADDENTS DQ 0202020202020202h ; 为加8次定制字节
A DD ? ; 字节数组的地址
N DD ? ; 叠代的次数

.code
    MOV ESI, [A]
    MOV ECX, [N]
    MOVQ MM2, [ADDENTS]
    JMP SHORT L2
    ; 循环头
L1:   MOVQ [ESI-8], MM0 ; 存储结果
L2:   MOVQ MM0, MM2 ; 载入ADDENTS
    PADDB MM0, [ESI] ; 一条指令完成8次加法
    ADD ESI, 8
    DEC ECX
    JNZ L1
    MOVQ [ESI-8], MM0 ; 存储最后一次结果
    EMMS

存储指令被移到循环控制指令之后,这是为了避免存储延迟。

每次叠代需要4个周期,因为PADDB指令无法与ADD ESI,8配对(访问内存的MMX指令不能与非MMX指令配对,也不能与另一条访问内存的MMX指令配对)。 但如果用ECX作为变址而不用ADD ESI,8的话,又会产生AGI延迟。

因为叠代的开销比较大,我们可以考虑展开循环:

示例1.12:

.data
ALIGN 8
ADDENTS DQ 0202020202020202h ; 为加8次定制字节
A DD ? ; 字节数组的地址
N DD ? ; 叠代的次数

.code
    MOVQ MM2, [ADDENTS]
    MOV ESI, [A]
    MOV ECX, [N]
    MOVQ MM0, MM2
    MOVQ MM1, MM2
L3:   PADDB MM0, [ESI]
    PADDB MM1, [ESI+8]
    MOVQ [ESI], MM0
    MOVQ MM0, MM2
    MOVQ [ESI+8], MM1
    MOVQ MM1, MM2
    ADD ESI, 16
    DEC ECX
    JNZ L3
    EMMS

展开后的循环每次叠代做16次加法,花6个时钟周期。PADDB指令不是配对的。两个线程交错开,避免存储延迟。

如果在用MMX指令后不久就要用浮点指令,那么付出的代价很大。在这种情形下,可能应该像示例1。9那样仍然使用32位寄存器。

*带浮点指令的循环(PPlain和PMMX)

优化浮点循环的方法基本类似整型循环——尽管通常浮点指令之间是重叠而不是配对。
看一段C代码:

  int i, n; double * X; double * Y; double DA;
  for (i=0; i<n; i++) Y[i] = Y[i] - DA * X[i];

这片代码(被称作DAXPY)被广泛研究,因为这是解线性方程的关键。

示例1.13:

    DSIZE = 8 ; 数据尺寸
    MOV EAX, [N] ; 元素个数
    MOV ESI, [X] ; X的指针
    MOV EDI, [Y] ; Y的指针
    XOR ECX, ECX
    LEA ESI, [ESI+DSIZE*EAX] ; 指向X数组尾的指针
    SUB ECX, EAX ; -N
    LEA EDI, [EDI+DSIZE*EAX] ; 指向Y数组尾的指针
    JZ SHORT L3 ; 看是否N = 0
    FLD DSIZE PTR [DA]
    FMUL DSIZE PTR [ESI+DSIZE*ECX] ; DA * X[0]
    JMP SHORT L2 ; 跳入循环
L1:   FLD DSIZE PTR [DA]
    FMUL DSIZE PTR [ESI+DSIZE*ECX] ; DA * X[i]
    FXCH ; 得到旧的结果
    FSTP DSIZE PTR [EDI+DSIZE*ECX-DSIZE] ; 存Y[i]
L2:   FSUBR DSIZE PTR [EDI+DSIZE*ECX] ; 从Y[i]中减
    INC ECX ; 增加索引值
    JNZ L1 ; 循环
    FSTP DSIZE PTR [EDI+DSIZE*ECX-DSIZE] ; 存最后一个结果
L3:

这里我们用了像示例1.6中的方法:用循环计数器充当比例变址寄存器,从负值加到0。 每次操作的末尾放到下一次操作的头部去做。

交错型的浮点操作在这里工作得很完美:在FMUL和FSUBR之间2个时钟的延迟被前面结果的FSTP指令填掉了。 FSUBR和FSTP之间3个时钟的延迟被循环控制的开销和下一次操作的开头两条指令填掉了。 在计数器被增加后的第一个时钟周期里,我们读的是与计数器无关的DA参数,从而又避免了AGI延迟。

结果是每次操作花6个时钟周期,这比Intel的展开循环的解决方案好!

*展开浮点循环(PPlain和PMMX)

DAXPY循环的3-展开是非常复杂的:

示例1.14:

DSIZE = 8 ; 数据尺寸
IF DSIZE EQ 4
SHIFTCOUNT = 2
ELSE
SHIFTCOUNT = 3
ENDIF

    MOV EAX, [N] ; 数据元素个数
    MOV ECX, 3*DSIZE ; 计数器步进值是3*DSIZE
    SHL EAX, SHIFTCOUNT ; DSIZE*N
    JZ L4 ; N = 0
    MOV ESI, [X] ; 指向X的指针
    SUB ECX, EAX ; 令ECX初值=(3-N)*DSIZE
    MOV EDI, [Y] ; 指向Y的指针
    SUB ESI, ECX ; 指针指向(数组末尾-步进值)的地方
    SUB EDI, ECX
    TEST ECX, ECX
    FLD DSIZE PTR [ESI+ECX] ; X数组的第一个元素
    JNS SHORT L2 ; 总共的操作次数少于4
L1:   ; 主循环
    FMUL DSIZE PTR [DA]
    FLD DSIZE PTR [ESI+ECX+DSIZE]
    FMUL DSIZE PTR [DA]
    FXCH
    FSUBR DSIZE PTR [EDI+ECX]
    FXCH
    FLD DSIZE PTR [ESI+ECX+2*DSIZE]
    FMUL DSIZE PTR [DA]
    FXCH
    FSUBR DSIZE PTR [EDI+ECX+DSIZE]
    FXCH ST(2)
    FSTP DSIZE PTR [EDI+ECX]
    FSUBR DSIZE PTR [EDI+ECX+2*DSIZE]
    FXCH
    FSTP DSIZE PTR [EDI+ECX+DSIZE]
    FLD DSIZE PTR [ESI+ECX+3*DSIZE]
    FXCH
    FSTP DSIZE PTR [EDI+ECX+2*DSIZE]
    ADD ECX, 3*DSIZE
    JS L1 ; 循环
L2:   FMUL DSIZE PTR [DA] ; 完成剩余的操作
    FSUBR DSIZE PTR [EDI+ECX]
    SUB ECX, 2*DSIZE ; 移动指针
    JZ SHORT L3 ; 完成
    FLD DSIZE PTR [DA] ; 开始下一个操作
    FMUL DSIZE PTR [ESI+ECX+3*DSIZE]
    FXCH
    FSTP DSIZE PTR [EDI+ECX+2*DSIZE]
    FSUBR DSIZE PTR [EDI+ECX+3*DSIZE]
    ADD ECX, 1*DSIZE
    JZ SHORT L3 ; 完成
    FLD DSIZE PTR [DA]
    FMUL DSIZE PTR [ESI+ECX+3*DSIZE]
    FXCH
    FSTP DSIZE PTR [EDI+ECX+2*DSIZE]
    FSUBR DSIZE PTR [EDI+ECX+3*DSIZE]
    ADD ECX, 1*DSIZE
L3:   FSTP DSIZE PTR [EDI+ECX+2*DSIZE]
L4:

这里展示了把循环进行3-展开的过程,其目的不是推荐这么做,而是让你看看它有多么复杂! 做这种事之前,先要做好花大量时间调试、检测代码正确性的心理准备。还要考虑到这些问题:多数情况下,你不可能把一个展开度小于4的浮点循环的所有延迟全部克服——除非你将循环“环绕”(也就是每次叠代都有一些未完成的操作留待下次完成)。 上面的代码中,主循环的最后一个FLD是下次叠代的第一个操作的开始。 如果像示例1.9和1.10那样,读取数据直到越过数组边界,然后废弃尾部多余的数据——这样的方法似乎很好,但不推荐对浮点循环采用这种做法。 因为当数组后面的内存位置含有不合法的“浮点数”时,读取这些额外的数据会抛出常规的操作数异常。为了避免这种情况,我们必须在主循环后做至少1次额外的操作。

在一个展开循环的外部做的操作次数一般是N%R,其中N是总操作次数,R是展开因子。 可如果是“盘旋式”循环,基于上述的理由,我们要多做一次,也就是 (N-1)%R + 1 次。

一般来讲,我们更倾向在主循环前做额外的操作,但上述例子中我们必须在后面做,有两条理由:一是考虑到“盘旋式”循环带来的剩余操作;二是计算多余操作次数需要做除法(如果R不是2的幂次的话),除法是耗时的,而在循环后做那些多余的操作则避免了除法。

再一个问题就是确定循环计数器的步进值,使计数器以符号改变作为退出循环的标志,且在初始化中,根据步进值相应地设定基址指针的位置。 最后,你得保证对于任何N值,“盘旋式”循环带来的剩余操作都能被正确处理。

做1-3个剩余操作的收尾代码也可做一个单独的循环来实现,但这会多导致一次分支预测失败,因此上面的方法更快。

看了3-展开的困难示例,你可能会感到头大。 接下来你会看到4-展开要容易得多了:

示例1.15:

DSIZE = 8 ; 数据尺寸
    MOV EAX, [N] ; 数据元素个数
    MOV ESI, [X] ; 指向X的指针
    MOV EDI, [Y] ; 指向Y的指针
    XOR ECX, ECX
    LEA ESI, [ESI+DSIZE*EAX] ; 指向X数组尾部的指针
    SUB ECX, EAX ; -N
    LEA EDI, [EDI+DSIZE*EAX] ; 指向Y数组尾部的指针
    TEST AL,1 ; 看N是否是奇数
    JZ SHORT L1
    FLD DSIZE PTR [DA] ; 做掉奇数的操作
    FMUL DSIZE PTR [ESI+DSIZE*ECX]
    FSUBR DSIZE PTR [EDI+DSIZE*ECX]
    INC ECX ; 调整计数器
    FSTP DSIZE PTR [EDI+DSIZE*ECX-DSIZE]
L1:   TEST AL,2 ; 看是否要多做2次操作
    JZ L2
    FLD DSIZE PTR [DA] ; N % 4 = 2 或 3。 再做2次操作
    FMUL DSIZE PTR [ESI+DSIZE*ECX]
    FLD DSIZE PTR [DA]
    FMUL DSIZE PTR [ESI+DSIZE*ECX+DSIZE]
    FXCH
    FSUBR DSIZE PTR [EDI+DSIZE*ECX]
    FXCH
    FSUBR DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
    FXCH
    FSTP DSIZE PTR [EDI+DSIZE*ECX]
    FSTP DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
    ADD ECX, 2 ; 现在计数器值能被4整除了
L2:   TEST ECX, ECX
    JZ L4 ; 不需要再做更多操作了
L3:   ; 主循环:
    FLD DSIZE PTR [DA]
    FLD DSIZE PTR [ESI+DSIZE*ECX]
    FMUL ST,ST(1)
    FLD DSIZE PTR [ESI+DSIZE*ECX+DSIZE]
    FMUL ST,ST(2)
    FLD DSIZE PTR [ESI+DSIZE*ECX+2*DSIZE]
    FMUL ST,ST(3)
    FXCH ST(2)
    FSUBR DSIZE PTR [EDI+DSIZE*ECX]
    FXCH ST(3)
    FMUL DSIZE PTR [ESI+DSIZE*ECX+3*DSIZE]
    FXCH
    FSUBR DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
    FXCH ST(2)
    FSUBR DSIZE PTR [EDI+DSIZE*ECX+2*DSIZE]
    FXCH
    FSUBR DSIZE PTR [EDI+DSIZE*ECX+3*DSIZE]
    FXCH ST(3)
    FSTP DSIZE PTR [EDI+DSIZE*ECX]
    FSTP DSIZE PTR [EDI+DSIZE*ECX+2*DSIZE]
    FSTP DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
    FSTP DSIZE PTR [EDI+DSIZE*ECX+3*DSIZE]
    ADD ECX, 4 ; 索引值加4
    JNZ L3 ; 循环
L4:

通常,找出4-展开的没有延迟的解决方案是很容易的,也不需要“盘旋式”循环。 在主循环外的多余操作次数是N%4,它的计算十分简单不需要做除法,只要测试N的低2位就可以了。 多余的操作将在主循环前完成,使循环计数器的处理更简单。

循环展开的副作用是循环外的多余操作比较慢,这是由于不完全的重叠和可能的分支预测失败的缘故;而且因为代码变长了,首次运行的代价更高了。

一般来说,对于重要的循环如果N很大,或者只用“盘旋式”循环而不进行展开的话已经无法很好地避免延迟,那么对于整型循环推荐展开度为2,是浮点型推荐展开度为4。

25.2  PPro,PII和PIII上的循环

在前面的章节(25.1)中我已经解释了在PPlain和PMMX上,怎样用“盘旋”技术以及展开循环来改进指令配对的机会。 在PPro,PII和PIII上由于乱序执行机制,我们不需要这么做。 但也有其它的难题需要关注,最重要的当数ifetch块的边界问题和寄存器读延迟。

就像前面一样,我选择了和25.1节相同的示例: 一个过程从数组中读出整数,改变每个整数的符号,再把结果存入另一个数组。

这个过程的C语言版本如下:
void ChangeSign (int * A, int * B, int N) {
int i;
for (i=0; i<N; i++) B[i] = -A[i];}

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

示例2.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
_ChangeSign ENDP

看上去解决得不错,实际上它并没优化过:用了没有优化的指令LOOP,LODSD和STOSD,这些指令会产生很多微码。 在所有数据全在1级cache中的前提下,每次叠代要花6-7个时钟周期。 避免这些指令,我们写成:

示例2.2:

    MOV ECX, [N]
    JECXZ L2
    MOV ESI, [A]
    MOV EDI, [B]
ALIGN 16
L1:   MOV EAX, [ESI] ; len=2, p2rESIwEAX
    ADD ESI, 4 ; len=3, p01rwESIwF
    NEG EAX ; len=2, p01rwEAXwF
    MOV [EDI], EAX ; len=2, p4rEAX, p3rEDI
    ADD EDI, 4 ; len=3, p01rwEDIwF
    DEC ECX ; len=1, p01rwECXwF
    JNZ L1 ; len=2, p1rF
L2:

先对批注做个解释:比如MOV EAX,[ESI]指令,长度是2个字节,产生一条微码使用端口2读ESI,并写入EAX(被重命名)。 批注的信息是用来分析可能潜在的瓶颈的。

先分析指令解码(第14章):这些指令中,有一条MOV [EDI],EAX指令要产生2条微码,这条指令必须进入D0。循环体包括的3个解码组,因此解码花3个周期。

然后分析取指令的情况(第15章):如果ifetch边界线使得前3条指令无法一起解码,那么最后一个ifetch块会有3个解码组,这样下一次叠代时ifetch块就会从我们希望的那条指令开始了,我们仅仅在第一次叠代的时候有延迟。 较坏的一种情形是有16-字节边界线和ifetch边界线在最后3条指令中,那么根据前面有关ifetch的表格,会产生1个时钟的延迟并引起下一次叠代的第一个ifetch块从16-字节边界开始,因此每次叠代都会出现问题。 结果就是每次叠代的取指令时间是4个时钟而不是3个。有两种方法避免这个问题:一是在第一次叠代时控制ifetch块的位置;二是控制16-字节边界线的位置。 后者的实现是最容易的。 就像上面代码所示,既然整个循环的代码长度只有15个字节,你可以通过将循环入口按16-对齐来避免循环中出现16-字节边界。 这样就使整个循环简单地放进了一个ifetch块中,不需要再对取指令作更进一步的分析了。

第三个问题是察看是否有寄存器读延迟(第16章)。 在此循环中,不存在对那些至少已有几个周期没被写过的寄存器的读取操作,故没有寄存器读延迟。

第四个要分析的是指令的执行(第17章)。 统计各个端口的微码数:
端口0 或 1: 4条
只在端口1的微码: 1条
端口2: 1条
端口3: 1条
端口4: 1条
假定那些既能进0端口又能进1端口的微码的分配是最优的,那么每次叠代的执行时间是2。5个时钟。

最后分析引退(第18章)。 因为循环体的微码数目不能被3整除,而跳转操作是必须在第一个引退槽中引退的,故引退槽没有被最佳利用。 引退需要的时钟数是|微码数目/3|(向上取整)。 所以这里引退需要3个时钟。

结论是,如果循环入口按16对齐的话,上述循环的执行需要3个时钟。 在此,我作了“除退出循环的那次,条件跳转每次都能被正确地预测(第22.2章)”这样一个假设。

*用同一个寄存器既做计数器又做变址寄存器,并让计数器在0结束(PPro,PII和PIII)

示例2.3:

    MOV ECX, [N]
    MOV ESI, [A]
    MOV EDI, [B]
    LEA ESI, [ESI+4*ECX] ; 指向A数组的尾部
    LEA EDI, [EDI+4*ECX] ; 指向B数组的尾部
    NEG ECX ; -N
    JZ SHORT L2
ALIGN 16
L1:   MOV EAX, [ESI+4*ECX] ; len=3, p2rESIrECXwEAX
    NEG EAX ; len=2, p01rwEAXwF
    MOV [EDI+4*ECX], EAX ; len=3, p4rEAX, p3rEDIrECX
    INC ECX ; len=1, p01rwECXwF
    JNZ L1 ; len=2, p1rF
L2:

用同一个寄存器既做计数器又做变址寄存器,我们将微码数减为6条。 基址寄存器指向数组的尾部,这样变址寄存器就可以从负值增加到0了。

解码:循环体中包括2个解码组,因此解码时间是2个时钟。

取指令:循环花在取指令上的周期数总是至少比它含有的16-字节块的数目多1?。因为循环代码只有11字节,可能会放进一个ifetch块。 通过把循环入口按16对齐后,我们可以保证得到的16-字节块的数目不会超过1个,因此取指令花2个时钟周期。

寄存器读延迟:在循环内,ESI、EDI寄存器被读,但没有被写,因此这被看作是读永久寄存器,但不在同一个三元组中。 EAX、ECX和标志寄存器在循环内部被修改,且读操作发生在它们被写回之前,因此不会引起永久寄存器读。 结论是没有寄存器读延迟。

执行:
端口0或1: 2条
端口1: 1条
端口2: 1条
端口3: 1条
端口4: 1条
执行时间: 1。5个时钟。

引退:
6条微码 = 2个时钟。

结论: 这段循环的每次叠代只花2个时钟。

如果你用了绝对地址,而不用ESI,EDI,那么循环将花3个周期,因为它不能被放进一个16-字节块内。

*展开循环(PPro,PII和PIII)

每次叠代做一次以上操作,做相对少的叠代被称为循环展开。 早期的处理器展开循环的目的是为了提高配对机会达到并行执行(25.1节)。 在PPro,PII和PIII中配对是不需要的,因为乱序执行机制已经在这方面做得很好了。 也不需要用两个不同的寄存器,因为寄存器重命名机制会解决这个问题。 这里展开循环的目的是减少每次叠代的循环开销。

下面的例子与示例2.2相同,只是展开因子为2,这意味着每次叠代做2次操作,叠代次数减半。

示例2.4:

    MOV ECX, [N]
    MOV ESI, [A]
    MOV EDI, [B]
    SHR ECX, 1 ; N/2
    JNC SHORT L1 ; 看N是否是奇数
    MOV EAX, [ESI] ; 先做掉奇数的一次
    ADD ESI, 4
    NEG EAX
    MOV [EDI], EAX
    ADD EDI, 4
L1:   JECXZ L3

ALIGN 16
L2:   MOV EAX, [ESI] ; len=2, p2rESIwEAX
    NEG EAX ; len=2, p01rwEAXwF
    MOV [EDI], EAX ; len=2, p4rEAX, p3rEDI
    MOV EAX, [ESI+4] ; len=3, p2rESIwEAX
    NEG EAX ; len=2, p01rwEAXwF
    MOV [EDI+4], EAX ; len=3, p4rEAX, p3rEDI
    ADD ESI, 8 ; len=3, p01rwESIwF
    ADD EDI, 8 ; len=3, p01rwEDIwF
    DEC ECX ; len=1, p01rwECXwF
    JNZ L2 ; len=2, p1rF
L3:

示例2.2中循环所需要的开销(也就是调整指针和计数器,跳回)是4条微码且“真正的工作”是4条微码。 2-展开循环后相当于一次叠代做两次“真正的工作”,总共是12条微码。 循环开销的微码数占总数的比例从50%下降到33%。 由于展开的循环只能做偶数次操作,因此必须先检查N是否是奇数,是的话在循环外做掉一次操作。

分析循环的取指令的情况,我们发现一个新的ifetch块从ADD ESI,8开始,迫使它进入D0解码器。 这使得循环的解码花5个周期,而不是我们希望的4个。 通过硬性地把前一条指令改得更长,解决这个问题。 把MOV [EDI+4],EAX改为:

MOV [EDI+9999],EAX ; 使指令变长 displacement
ORG $-4
DD 4 ; 将偏移量改回4

这样强迫一个新的ifetch块从变长的MOV [EDI+4],EAX指令开始,解码时间减为4个周期。 流水线的其它阶段可以在1个时钟周期处理3条微码,因此估计执行时间是每次叠代4个时钟(即每次操作2个时钟)。

真正测试这段代码发现花的时间比预期多一点。 我的测量结果显示大约是每个叠代4。5个时钟。 可能是因为微码的乱序执行并没有完全优化,ROB并没有找到最优的微码执行顺序,只提交了一个次优的顺序。 这个问题是无法预知的,只有实际的测试才能暴露它。 我们可以通过手工做一些乱序工作来“帮助”ROB:

示例2.5:

ALIGN 16
L2:   MOV EAX, [ESI] ; len=2, p2rESIwEAX
    MOV EBX, [ESI+4] ; len=3, p2rESIwEBX
    NEG EAX ; len=2, p01rwEAXwF
    MOV [EDI], EAX ; len=2, p4rEAX, p3rEDI
    ADD ESI, 8 ; len=3, p01rwESIwF
    NEG EBX ; len=2, p01rwEBXwF
    MOV [EDI+4], EBX ; len=3, p4rEBX, p3rEDI
    ADD EDI, 8 ; len=3, p01rwEDIwF
    DEC ECX ; len=1, p01rwECXwF
    JNZ L2 ; len=2, p1rF
L3:

现在循环的每次叠代是4个周期了。 这样的做法同样解决了ifetch块的取指令问题。 额外的付出就是因为不利用寄存器重命名,我们要多用一个寄存器。

*展开2次以上

当循环本身的开销在总开销中占的比例很大时,推荐循环展开。 示例2.3循环本身的开销只有2条微码,因此展开循环获利很小,但这里还是把它展开了,权且作为一种练习吧:

“真正的工作”是4条微码,循环开销是2条微码。 进行2-展开后循环体有2*4+2=10条微码。 引退时间将是10/3,向上取整后是4个时钟周期。 计算表明进行2-展开并没有获利。 进行4-展开:

示例2.6:

    MOV ECX, [N]
    SHL ECX, 2 ; 将要处理的字节数
    MOV ESI, [A]
    MOV EDI, [B]
    ADD ESI, ECX ; 指向A数组的尾部
    ADD EDI, ECX ; 指向B数组的尾部
    NEG ECX ; -4*N
    TEST ECX, 4 ; 看N是否是奇数
    JZ SHORT L1
    MOV EAX, [ESI+ECX] ; N是奇数则先做掉一次
    NEG EAX
    MOV [EDI+ECX], EAX
    ADD ECX, 4
L1:   TEST ECX, 8 ; 看N/2是否是奇数
    JZ SHORT L2
    MOV EAX, [ESI+ECX] ; N/2是奇数,做掉多出的2次
    NEG EAX
    MOV [EDI+ECX], EAX
    MOV EAX, [ESI+ECX+4]
    NEG EAX
    MOV [EDI+ECX+4], EAX
    ADD ECX, 8
L2:   JECXZ SHORT L4

ALIGN 16
L3:   MOV EAX, [ESI+ECX] ; len=3, p2rESIrECXwEAX
    NEG EAX ; len=2, p01rwEAXwF
    MOV [EDI+ECX], EAX ; len=3, p4rEAX, p3rEDIrECX
    MOV EAX, [ESI+ECX+4] ; len=4, p2rESIrECXwEAX
    NEG EAX ; len=2, p01rwEAXwF
    MOV [EDI+ECX+4], EAX ; len=4, p4rEAX, p3rEDIrECX
    MOV EAX, [ESI+ECX+8] ; len=4, p2rESIrECXwEAX
    MOV EBX, [ESI+ECX+12] ; len=4, p2rESIrECXwEAX
    NEG EAX ; len=2, p01rwEAXwF
    MOV [EDI+ECX+8], EAX ; len=4, p4rEAX, p3rEDIrECX
    NEG EBX ; len=2, p01rwEAXwF
    MOV [EDI+ECX+12], EBX ; len=4, p4rEAX, p3rEDIrECX
    ADD ECX, 16 ; len=3, p01rwECXwF
    JS L3 ; len=2, p1rF
L4:

ifetch块正好从我们希望的位置开始。 解码时间是6个时钟。

在这里,寄存器读延迟是一个问题。 因为在循环的尾部附近,ECX已经引退了,而我们需要读ESI,EDI和ECX。 为了避免在循环的尾部附近读ESI,已经调整过代码的次序了,从而避免了一个寄存器读延迟。 换句话说,重组代码并多用一个寄存器(EBX)的原因与前面的例子是不同的。

有12条微码且循环的每次叠代花6个时钟(即每次操作 1.5 个时钟)。

展开因子更大以获得最快的速度似乎很诱人。 但因为大多数情况下循环本身的开销每次叠代才1个周期,因此相对于2-展开,把循环进行4-展开只在每次叠代节省了1/4时钟周期,这几乎不值得尝试。 只有相比循环的其它部分而言,循环开销占了很大比例且N很大的时候,你可以考虑4-展开。 展开因子大于4是没什么意义的。

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

循环因子不是2的幂次使得N%R的计算更困难,一般不推荐,除非已知N能被R整除。 示例1.14展示了如何进行3-展开。

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

有时候,可以在同一个32位寄存器中一次处理4个字节。下面的例子是把2加到字节数组的所有元素之上:

示例2.7:

    MOV ESI, [A] ; 字节数组的地址
    MOV ECX, [N] ; 字节数组的元素个数
    JECXZ L2
ALIGN 16
    DB 7 DUP (90H) ; 7 NOP's for controlling alignment

L1:   MOV EAX, [ESI] ; 一次读4个字节
    MOV EBX, EAX ; 拷贝到EBX
    AND EAX, 7F7F7F7FH ; 在EAX中得到每个字节的低7位
    XOR EBX, EAX ; 在EBX中得到每个字节的最高位
    ADD EAX, 02020202H ; 把值加到4个字节上
    XOR EBX, EAX ; 结合最高位
    MOV [ESI], EBX ; 存结果
    ADD ESI, 4 ; 移动指针
    SUB ECX, 4 ; 减少计数器值
    JA L1 ; 循环
L2:

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

理论上,该循环的每次叠代花4个周期,但实际上因为有依赖链使得乱序执行比较困难,花的时间要多一点。 对于此类事情,在PPro,PII和PIII上,你可以用MMX寄存器获得更高的效率。

下个例子是通过找第一个0字节得到以0结尾的字符串的长度。 它比用REP SCASB快:

示例2.8:

_strlen PROC NEAR
    PUSH EBX
    MOV EAX,[ESP+8] ; 得到串指针
    LEA EDX,[EAX+3] ; 串首址加3的值,最后要用
L1:   MOV EBX,[EAX] ; 读开头4个字节
    ADD EAX,4 ; 移动指针
    LEA ECX,[EBX-01010101H] ; 各个字节减1
    NOT EBX ; 所有位取补
    AND ECX,EBX ; 把两个结果与
    AND ECX,80808080H ; 测符号位(最高位)
    JZ L1 ; 未找到0字节,继续循环
    MOV EBX,ECX
    SHR EBX,16
    TEST ECX,00008080H ; 看0字节是否在低2个字节中
    CMOVZ ECX,EBX ; 如果不在低2个字节,则右移
    LEA EBX,[EAX+2]
    CMOVZ EAX,EBX
    SHL CL,1 ; 用CF,从而避免了一个分支
    SBB EAX,EDX ; 得到长度
    POP EBX
    RET
_strlen ENDP

该循环每次叠代测试4个字节,花3个时钟。 串当然最好是按4对齐。 代码的访问总会超过串尾,因此串不能放在段尾。

并行处理4个字节可能是相当困难的。 上面代码利用了这样一个规律:当且仅当碰到0字节的时候,才会计算产生一个非0的值。 这使得一次叠代测试4个字节变为了可能。 算法包括了从各个字节的减1操作(LEA指令)。 示例中,在减1操作之前我并没有用掩码保护每个字节的最高位,因为只有碰上0字节,减法才可能会向高字节借位。而这种情况下我们恰恰不用关心高字节是什么,因为我们是在向前寻找第一个0字节。 如果我们是在向后寻找,那么我们必须在侦测到0字节以后重新载入整个dword,并测全这4个字节,找到最后一个0。 用BSWAP指令将字节的顺序逆转也可以。 如果你想找第一个非0值,那么要先把4个字节与你想找的值XOR,然后仍旧用上面的方法找0值。

*带MMX指令的循环(PII和PIII)

用了MMX指令,我们可以一次操作进行8个字节的比较:

示例2.9:

_strlen PROC NEAR
    PUSH EBX
    MOV EAX,[ESP+8]
    LEA EDX,[EAX+7]
    PXOR MM0,MM0
L1:   MOVQ MM1,[EAX] ; len=3 p2rEAXwMM1
    ADD EAX,8 ; len=3 p01rEAX
    PCMPEQB MM1,MM0 ; len=3 p01rMM0rMM1
    MOVD EBX,MM1 ; len=3 p01rMM1wEBX
    PSRLQ MM1,32 ; len=4 p1rMM1
    MOVD ECX,MM1 ; len=3 p01rMM1wECX
    OR ECX,EBX ; len=2 p01rECXrEBXwF
    JZ L1 ; len=2 p1rF
    MOVD ECX,MM1
    TEST EBX,EBX
    CMOVZ EBX,ECX
    LEA ECX,[EAX+4]
    CMOVZ EAX,ECX
    MOV ECX,EBX
    SHR ECX,16
    TEST BX,BX
    CMOVZ EBX,ECX
    LEA ECX,[EAX+2]
    CMOVZ EAX,ECX
    SHR BL,1
    SBB EAX,EDX
    EMMS
    POP EBX
    RET
_strlen ENDP

循环中有7条微码在0端口和1端口,平均执行时间是每次叠代3.5个时钟。实际测试的时间是3.8个时钟,这表明ROB对于这种情况的处理还是很合理的——尽管有长达6条微码的依赖链存在。 能在少于4个时钟下测试8个字节,已经比REPNE SCASB快得令人难以相信了。

*带浮点指令的循环(PPro,PII和PIII)

优化浮点循环的方法基本上和优化整型循环差不多,但要更注意依赖链,因为浮点指令的执行时间较长。

看一段C代码:
int i, n; double * X; double * Y; double DA;
for (i=0; i<n; i++) Y[i] = Y[i] - DA * X[i];

这片代码(被称作DAXPY)被广泛研究,因为这是解线性方程的关键。

示例2.10:

DSIZE = 8 ; 数据尺寸 (4 or 8)
    MOV ECX, [N] ; 数据元素个数
    MOV ESI, [X] ; 指向X的指针
    MOV EDI, [Y] ; 指向Y的指针
    JECXZ L2 ; 看是否N = 0
    FLD DSIZE PTR [DA] ; 在循环外载入DA数值
ALIGN 16
    DB 2 DUP (90H) ; 2个NOP指令用于对齐代码
L1:   FLD DSIZE PTR [ESI] ; len=3 p2rESIwST0
    ADD ESI,DSIZE ; len=3 p01rESI
    FMUL ST,ST(1) ; len=2 p0rST0rST1
    FSUBR DSIZE PTR [EDI] ; len=3 p2rEDI, p0rST0
    FSTP DSIZE PTR [EDI] ; len=3 p4rST0, p3rEDI
    ADD EDI,DSIZE ; len=3 p01rEDI
    DEC ECX ; len=1 p01rECXwF
    JNZ L1 ; len=2 p1rF
    FSTP ST ; 废弃DA
L2:

依赖链长达10个时钟,但每次叠代只花4个时钟。 因为在前一次操作尚未完成时后一次操作已经开始了。 将代码对齐的目的是避免16-字节边界线进入最后一个ifetch块。

示例2.11:

DSIZE = 8 ; 数据尺寸(4 or 8)
    MOV ECX, [N] ; 数据元素个数
    MOV ESI, [X] ; 指向X的指针
    MOV EDI, [Y] ; 指向Y的指针
    LEA ESI, [ESI+DSIZE*ECX] ; 指向X数组尾部的指针
    LEA EDI, [EDI+DSIZE*ECX] ; 指向Y数组尾部的指针
    NEG ECX ; -N
    JZ SHORT L2 ; 看是否N = 0
    FLD DSIZE PTR [DA] ; 在循环外载入DA数值
ALIGN 16
L1:   FLD DSIZE PTR [ESI+DSIZE*ECX] ; len=3 p2rESIrECXwST0
    FMUL ST,ST(1) ; len=2 p0rST0rST1
    FSUBR DSIZE PTR [EDI+DSIZE*ECX] ; len=3 p2rEDIrECX, p0rST0
    FSTP DSIZE PTR [EDI+DSIZE*ECX] ; len=3 p4rST0, p3rEDIrECX
    INC ECX ; len=1 p01rECXwF
    JNZ L1 ; len=2 p1rF
    FSTP ST ; 废弃DA
L2:

这里我们用了如同 示例2.3 一样的技巧。 理论上,每次叠代花3个周期,实际上大约要3.5个周期——由于过长的依赖链的缘故。 即使展开循环也不会节省很多。

*带XMM指令的循环(PIII)

PIII上XMM指令使你可以并行操作4个单精度浮点数。 操作数应当按16对齐。
用XMM指令完成DAXPY算法不合适,因为精度太低了,还可能操作数不是按16对齐的。 如果操作次数不是4的倍数的话,你需要写一些额外的代码。 尽管如此,还是展示了代码,其目的只是为了给出一个带XMM指令的循环实例:

示例2.12:

    MOV ECX, [N] ; 数据元素的个数
    MOV ESI, [X] ; 指向X的指针
    MOV EDI, [Y] ; 指向Y的指针
    SHL ECX, 2
    ADD ESI, ECX ; 指向X数组尾部
    ADD EDI, ECX ; 指向Y数组尾部
    NEG ECX ; -4*N
    MOV EAX, [DA] ; 在循环外载入DA参数
    XOR EAX, 80000000H ; 改变DA的符号
    PUSH EAX
    MOVSS XMM1, [ESP] ; -DA
    ADD ESP, 4
    SHUFPS XMM1, XMM1, 0 ; 把-DA拷贝到XMM1的4个位置
    CMP ECX, -16
    JG L2
L1:   MOVAPS XMM0, [ESI+ECX] ; len=4 2*p2rESIrECXwXMM0
    ADD ECX, 16 ; len=3 p01rwECXwF
    MULPS XMM0, XMM1 ; len=3 2*p0rXMM0rXMM1
    CMP ECX, -16 ; len=3 p01rECXwF
    ADDPS XMM0, [EDI+ECX-16] ; len=5 2*p2rEDIrECX, 2*p1rXMM0
    MOVAPS [EDI+ECX-16], XMM0 ; len=5 2*p4rXMM0, 2*p3rEDIrECX
    JNG L1 ; len=2 p1rF
L2:   JECXZ L4 ; 检查是否应该结束了
    MOVAPS XMM0, [ESI+ECX] ; 少做1-3个操作,再多做4个
    MULPS XMM0, XMM1
    ADDPS XMM0, [EDI+ECX]
    CMP ECX, -8
    JG L3
    MOVLPS [EDI+ECX], XMM0 ; 多存2个结果
    ADD ECX, 8
    MOVHLPS XMM0, XMM0
L3:   JECXZ L4
    MOVSS [EDI+ECX], XMM0 ; 多存1个结果
L4:

L1循环的每次叠代做4个操作,花5-6个时钟。 用在RAT中的ESI或EDI同时读XMM1寄存器的两个部分将导致寄存器读端口的延迟,这通过MULPS XMM0,XMM1指令的前后各放一条有关ECX的指令来避免。 在L2之后的额外代码是考虑到N不能被4整除的情况。 注意,这段代码可能会对X和Y数组进行越界访问,如果越界的内存位置含有不正常的浮点数,那么最后的操作会被延迟。 如果可能的话,在数组后面插入一些额外的浮点数(哑元)使操作次数能够被4整除,这样就不需要L2之后的额外代码了。

26.有问题的指令

26.1  XCHG (所有处理器)

XCHG register,[memory]指令是危险的。 缺省情况下,该指令隐含一个LOCK前缀阻止自己使用cache,因此该指令十分耗时,应该尽量避免。


26.2  大循环移位(所有处理器)

RCR和RCL指令,如果移位位数大于1的话是很慢的,应该避免。


26.3  串操作指令(所有处理器)

不带重复前缀的串指令是非常慢的,应该被简单的指令取代。 同样地,任何处理器上的LOOP指令,PPlain和PMMX上的JECXZ指令也很慢。

倘若重复次数不小的话,REP MOVSD和REP STOSD是相当快的。 尽可能地这些指令的DWORD版本,且保证源地址和目的地址都按8对齐。

在特定条件下,还存在一些更快的搬动数据的方法。 详情看27.8章

注意,当REP MOVS指令向目的地址写入一个字的时候,在同一个时钟周期它会从源地址读出下一个字。 如果这两个地址的2-4位相同的话,会有cache行冲突。 换句话说,如果ESI+字长-EDI能被32整除的话,每次叠代都会有1个时钟的损失。 最简单的避免cache行冲突的方法是用DWORD版本的REP MOVS(即REP MOVSD)且使得源地址和目的地址都按8对齐。 想写出优化的代码的话,就不要用MOVSB和MOVSW,甚至在16位模式下也不要用它们。

在PPro,PII和PIII上,如果是一次性搬动整个cache行的话,REP MOVS和REP STOS运行得很快。 但还必须满足以下条件才会发生这样的好事:
*源地址和目的地址都按8对齐
*增量是向前的(清方向标志)
*计数器(ECX)大于等于64
*EDI和ESI的差在数值上大于等于32
*源内存和目的内存必须是写回或组合写模式(一般你可以假定是这样)

在这些条件下,REP MOVSD产生的微码大约是215+2*ECX条,REP STOSD产生的微码大约是185+1。 5*ECX条,它们的速度大约是5字节/时钟。 如果上面的条件不是都满足的,速度将减为1/3。

在“快速模式”下(即上述条件全部满足),byte版本和word版本的REP MOVS/STOS指令同样受益,但效率不如dword版本。

REP STOSD的优化条件与REP MOVSD相同。

机器对于REP LOADS, REP SCAS, 和REP CMPS没有优化过,最好用循环取代它们。 如何取代REPNE SCASB,参考示例 1.102.82.9。 如果ESI和EDI的2-4位相同的话,REP CMPS可能会遭遇cache行冲突。

26.4  位测试(所有处理器)

在PPlain和PMMX上,BT, BTC, BTR, 和BTS最好被TEST, AND, OR, XOR或移位取代;在PPro,PII和PIII上应该避免对内存操作数的位测试。

26.5  整型乘法(所有处理器)

在PPlain和PMMX上,整型乘法大约花9个周期;在PPro,PII和PIII上大约是3个周期。 因此最好用一个常量与其它指令(诸如SHL,ADD,SUB和LEA)结合的形式取代整型乘法,比如:
IMUL EAX,10
可以被替换为:
MOV EBX,EAX / ADD EAX,EAX / SHL EBX,3 / ADD EAX,EBX

LEA EAX,[EAX+4*EAX] / ADD EAX,EAX

在PPlain和PMMX上,浮点乘法比整型乘法快,但花在整数转为浮点,计算结果再转回整数的时间往往比浮点乘法节省的时间要多,除非转化的次数相对乘法的次数而言很少。 MMX乘法很快,但只有16位操作数能用。

26.6  WAIT指令(所有处理器)

经常地,你可以通过省略WAIT指令来提高速度。 WAIT指令有3个功能:

a. 老式的8087处理器每次在浮点指令(伪指令)前都自动插入一个WAIT,以保证协处理器准备好接受它。

b. 被用来在浮点运算单元(FPU)和整型运算单元(IU)之间协调内存的访问(只有先浮点后整型情况下才有):

b.1. FISTP [mem32]
WAIT ; 在用IU读结果之前,先要等FPU写完
MOV EAX,[mem32] ;

b.2. FILD [mem32]
WAIT ; 在用IU覆盖数据之前,先等FPU读完原来的数据
MOV [mem32],EAX ;

b.3. FLD QWORD PTR [ESP]
WAIT ; 在堆栈上覆盖数据时阻止意外中断的发生
ADD ESP,8 ;

c. WAIT还用来检测异常。 如果浮点指令设置的浮点状态字中有未屏蔽的异常位时,会产生一个中断。

关于a:
除了8087外,a功能再也不需要了。通过在程序中申明更高级的CPU,可以告诉汇编器不要插入WAIT,除非你想使代码兼容8087。8087的浮点伪指令经过汇编后也会插入WAIT指令,因此你得告诉汇编器不要插入伪指令,除非你需要它。

关于b:
在8087和80287上需要显式地用WAIT指令协调内存访问,但在Pentium上不需要。在80387和80487上是否需要不大清楚。对于协调内存访问,Intel手册上说除了在FNSTSW和FNSTCW之后,都需要WAIT指令。但我在这些Intel处理器上做了几次实验,发现在32位的Intel处理器上省去WAIT指令并没有出现任何错误。省去用于协调内存访问的WAIT指令不是100%安全的,甚至在写32位代码的时候。因为代码可能会在罕见的80386主处理器和287协处理器组合的机器上运行,而287是需要WAIT协调内存的。我也没有关于非Intel处理器的信息,也没有测试过硬件和软件所有可能的组合,因此可能存在一些其它的需要WAIT的情形。

如果你想保证代码可以在任何32位处理器上工作(包括非Intel处理器),那么如果是为了协调内存访问安全起见,推荐你写WAIT指令。

关于c:
基于c的目的,汇编器会自动在以下指令前面插入一条(F)WAIT:FCLEX, FINIT, FSAVE, FSTCW, FSTENV, FSTSW。你可以通过写成FNCLEX来省略WAIT指令。在80387上,我测试的结果是大多数情况下不需要WAIT指令,因为没有WAIT指令,这些指令(除了FNCLEX和FNINIT)在异常发生时也会产生中断(所不同的是有了WAIT,中断点的IRET是返回到FN。。指令,没有WAIT则是返回到下一条指令)。

对于几乎所有其它的浮点指令,如果前面有指令在浮点状态字上设置了未屏蔽的异常位的话,都会产生一个中断——而异常可能被立刻检测到,也可能在稍后检测到。你可以在最后一条浮点指令后面插入一条WAIT以保证捕获所有异常。

如果想知道异常究竟在哪里以便从异常情形恢复的话,你可能仍然需要一条WAIT。比如考虑上面b。3的代码:如果你想从FLD产生的异常中恢复,那么需要一条WAIT,因为在ADD ESP,8之后的中断会覆盖要加载的值。FNOP可能比WAIT更快,而且能达到相同的目的。

26.7  FCOM + FSTSW AX(所有处理器)

FNSTSW指令在所有处理器上都很慢。 PPro,PII和PIII处理器有FCOMI指令来避免较慢的FNSTSW指令。 用FCOMI取代传统的FCOM/FNSTSW AX/SAHF指令能够节省8个时钟周期。 因此你要尽可能地用FCOMI取代FNSTSW,哪怕在会引起额外代码的情况下。

在没有FCOMI的处理器上,通常用于做浮点比较的方法是:

    FLD [a]
    FCOMP [b]
    FSTSW AX
    SAHF
    JB ASmallerThanB

你可以用FNSTSW AX取代FSTSW AX,直接用test AH取代不可配对的SAHF来改进代码(但 TASM3.0 版本对于FNSTSW AX指令有一个bug):

    FLD [a]
    FCOMP [b]
    FNSTSW AX
    SHR AH,1
    JC ASmallerThanB

测试st(0)是否=0。0:

    FTST
    FNSTSW AX
    AND AH,40H
    JNZ IsZero ; (注意ZF的值与结果相反)

测试a是否大于b:

    FLD [a]
    FCOMP [b]
    FNSTSW AX
    AND AH,41H
    JZ AGreaterThanB

在PPlain和PMMX上,不要用TEST AH,41H因为它不能配对。

在PPlain和PMMX上,FNSTSW指令花2个时钟,但在任何浮点指令之后它都必须先等上4个时钟,因为它要等浮点状态字从流水线中引退。 甚至在FNOP这种不改变状态字的指令后也如此,但在整型指令后没有延迟。 你可以在FCOM和FNSTSW之间插入4个时钟周期的整型指令来弥补这个延迟。 紧跟FCOM之后配对的FXCH指令不会使FNSTSW延迟,哪怕是有缺陷的配对:

    FCOM ; clock 1
    FXCH ; clock 1-2 (有缺陷的配对)
    INC DWORD PTR [EBX] ; clock 3-5
    FNSTSW AX ; clock 6-7

你可能想用FCOM取代FTST,因为FTST是不可配对的。 记住要用FNSTSW,因为FSTSW(没有N)将带有一个WAIT前缀导致以后的延迟。

有时候把整型指令用于比较浮点值,可以更快,如27.6节描述。

26.8  FPREM(所有处理器)

FPREM和FPREM1指令在所有处理器上都很慢。 你可以用以下算法取代它: 把被除数与除数的倒数相乘,乘积减去其整数部分得到其小数部分,小数部分再与除数相乘(见27.5章,如何得到小数部分)。

一些文档上说有时候这2条指令给出的结果是不能完全还原的,因此需要不断重复地做FPREM或FPREM1直到余数能够完全还原。 从老式的8087开始我已经在很多处理器上测试过这件事了,没有发现需要重复做FPREM或FPREM1的情况。


26.9  FRNDINT(所有处理器)

这个指令在所有处理器上都很慢。 将它替换为:

    FISTP QWORD PTR [TEMP]
    FILD QWORD PTR [TEMP]

尽管在写操作完成之前就试图去读[TEMP]会有惩罚,但这段代码仍然比FRNDINT快。 推荐你在中间插入一些其它指令以避免惩罚,见27.5章,如何进行浮点数取整。

26.10  FSCALE和指数函数(所有处理器)

FSCALE指令在所有处理器上都很慢。计算2的整数次幂,可通过向浮点数的阶码部分插入希望的幂次来快速完成。为了计算2^N,这里N是一个带符号的整数,对于不同范围的N,你可以选择以下方法之一:

对于|N| < 2^7-1你可以用单精度浮点数:

    MOV EAX, [N]
    SHL EAX, 23
    ADD EAX, 3F800000H    ;IEEE754标准
    MOV DWORD PTR [TEMP], EAX
    FLD DWORD PTR [TEMP]


对于 |N| < 2^10-1 你可以用双精度浮点数:

    MOV EAX, [N]
    SHL EAX, 20
    ADD EAX, 3FF00000H    ;IEEE754标准
    MOV DWORD PTR [TEMP], 0
    MOV DWORD PTR [TEMP+4], EAX
    FLD QWORD PTR [TEMP]

对于 |N| < 2^14-1 你可以用扩展双精度浮点数:

    MOV EAX, [N]
    ADD EAX, 00003FFFH    ;IEEE754标准
    MOV DWORD PTR [TEMP], 0
    MOV DWORD PTR [TEMP+4], 80000000H
    MOV DWORD PTR [TEMP+8], EAX
    FLD TBYTE PTR [TEMP]

FSCALE 经常用来计算指数函数。下面的例子是一个不用慢指令 FRNDINT 和 FSCALE 的指数函数:

; extern "C" long double _cdecl exp (double x);
_exp PROC NEAR
PUBLIC _exp
    FLDL2E
    FLD QWORD PTR [ESP+4] ; x
    FMUL ; z = x*log2(e)
    FIST DWORD PTR [ESP+4] ; round(z)
    SUB ESP, 12
    MOV DWORD PTR [ESP], 0
    MOV DWORD PTR [ESP+4], 80000000H
    FISUB DWORD PTR [ESP+16] ; z - round(z)
    MOV EAX, [ESP+16]
    ADD EAX,3FFFH
    MOV [ESP+8],EAX
    JLE SHORT UNDERFLOW
    CMP EAX,8000H
    JGE SHORT OVERFLOW
    F2XM1
    FLD1
    FADD ; 2^(z-round(z))
    FLD TBYTE PTR [ESP] ; 2^(round(z))
    ADD ESP,12
    FMUL ; 2^z = e^x
    RET

UNDERFLOW:
    FSTP ST
    FLDZ ; return 0
    ADD ESP,12
    RET

OVERFLOW:
    PUSH 07F800000H ; 正无穷大
    FSTP ST
    FLD DWORD PTR [ESP] ; 返回无穷大
    ADD ESP,16
    RET

_exp ENDP

26.11  FPTAN (所有处理器)

根据手册,FPTAN返回两个结果X和Y,留给程序员用Y/X得到结果。 但事实上,在一般情况下X总是1,因此除法不需要了。 我的实验结果显示所有32位Intel处理器(不管用浮点单元FPU的还是用协处理器的),FPTAN返回的X总是1。 如果想绝对保证你的代码在所有处理器上运行正确,你可以先看一下X是否是1,这比做除法要快得多了。 Y的值可能会很大,但不可能是正负无穷大,因此只要参数是合法的你就用不着测试Y是否是有效的了。

26.12  FSQRT (PIII)

在PIII上,计算x的近似平方根的较快的方法是x乘以其倒数平方根:
SQRT(x) = x * RSQRT(x)
RSQRTSS或RSQRTPS指令得到的倒数平方根的精度是12位。 通过Intel应用手册AP-803描述的牛顿-拉弗森公式,可以得到23位的精度:
x0 = RSQRTSS(a)
x1 = 0.5 * x0 * (3 - (a * x0)) * x0)
x0是a的倒数平方根的首次逼近,x1是更好的逼近。 计算的顺序不能搞错,必须先用这个公式,然后再与a乘得到其平方根。

26.13  MOV [MEM],累加器 (PPlain和PMMX)

MOV [mem],AL/MOV [mem],AX/MOV [mem],EAX指令被对称电路看作如同向累加器(AL/AX/EAX)写一样。 因此下面的指令不能配对:

   MOV [mydata], EAX
   MOV EBX, EAX

只有在短形的MOV指令,没有基址或变址寄存器,原操作数为累加器的情况下才会发生这种问题。 你可以通过用另外的寄存器,或改变指令顺序,或用指针寄存器,或把MOV指令硬性编码为更长形式来避免这个问题。

32位模式下,你可以把MOV [mem],EAX指令写成:

   DB 89H, 05H
   DD OFFSET DS:mem

16位模式下,你可以把MOV [mem],AX指令写成:

   DB 89H, 06H
   DW OFFSET DS:mem

要用AL而不用(E)AX,把88H改成89H即可。

MOV [MEM],累加器 的缺点在PMMX上仍然没被改正。

26.14  TEST指令(PPlain和PMMX)

带立即操作数的TEST指令,只有当目的操作数是AL,AX或EAX时才能配对。

TEST register,register 和 TEST register,memory总是能配对的。

比如:

    TEST ECX,ECX ; 可配对
    TEST [mem],EBX ; 可配对
    TEST EDX,256 ; 不可配对
    TEST DWORD PTR [EBX],8000H ; 不可配对

用了以下方法的任何一种,都能使它配对:

    MOV EAX,[EBX] / TEST EAX,8000H
    MOV EDX,[EBX] / AND EDX,8000H
    MOV AL,[EBX+1] / TEST AL,80H
    MOV AL,[EBX+1] / TEST AL,AL ; (测试符号位SF)

(导致不能配对的原因可能是该2-字节指令的头一个字节与某条不能配对的指令相同,而当决定是否能配对的时候,处理器无法提供对第二个字节的检查)。

26.15  位扫描(PPlain和PMMX)

在PPlain和PMMX上,BSF和BSR指令的优化是很差的,花大约11+2*n个时钟,n是要跳过的0的个数。

以下代码模拟了BSR ECX,EAX的功能:

    TEST EAX,EAX
    JZ SHORT BS1
    MOV DWORD PTR [TEMP],EAX
    MOV DWORD PTR [TEMP+4],0
    FILD QWORD PTR [TEMP]
    FSTP QWORD PTR [TEMP]
    WAIT ; WAIT仅仅是为了和老的8087处理器兼容
    MOV ECX, DWORD PTR [TEMP+4]
    SHR ECX,20 ; 把指数隔离
    SUB ECX,3FFH ; 调整
    TEST EAX,EAX ; 清ZF标志
    BS1:

以下代码模拟了BSF ECX,EAX的功能:

    TEST EAX,EAX
    JZ SHORT BS2
    XOR ECX,ECX
    MOV DWORD PTR [TEMP+4],ECX
    SUB ECX,EAX
    AND EAX,ECX
    MOV DWORD PTR [TEMP],EAX
    FILD QWORD PTR [TEMP]
    FSTP QWORD PTR [TEMP]
    WAIT ; WAIT仅仅是为了和老的8087处理器兼容
    MOV ECX, DWORD PTR [TEMP+4]
    SHR ECX,20
    SUB ECX,3FFH
    TEST EAX,EAX ; 清ZF标志
    BS2:

这些模拟的代码不要在PPro,PII和PIII上用,因为在这些机器上,位扫描指令只用1或2个时钟周期,而上面这些代码有2个部分内存延迟。

26.16  FLDCW (PPro, PII 和 PIII)

在PPro,PII和PIII上,如果在FLDSW指令后面有需要读控制字的浮点指令(几乎所有浮点指令都会读浮点控制字),那么会有严重的延迟。

在编译C/C++代码的时候,如果把浮点数转为整数的截断操作完成的同时其它浮点指令需要进行舍入操作,那么会产生大量FLDCW指令。 在用汇编写代码的时候,可能的话你可以通过使用舍入操作取代截断操作来改进代码,或者在循环内部需要截断操作的时候,把FLDCW指令移到循环外。

27.5章,如何不改变控制字把浮点数转化为整数。

27. 特别主题

27.1  LEA指令(所有处理器)

在很多场合下,LEA指令都派得上用场,因为只靠这1条指令,就能在1个时钟内做1次移位、2个加法和1次数据传输。 比如:
LEA EAX,[EBX+8*ECX-1000]

MOV EAX,ECX / SHL EAX,3 / ADD EAX,EBX / SUB EAX,1000快得多
LEA指令能在不改变标志寄存器的前提下做加法或移位,且源操作数和目的操作数不需要尺寸相同,因此可以考虑用LEA EAX,[BX]取代MOVZX EAX,BX(尽管大多数处理器并没有对这种做法进行过优化)。

然而在PPlain和PMMX上,如果LEA指令使用了在前1个时钟周期被写过的基址或变址寄存器的话,它可能会遭遇AGI延迟。

在PPlain和PMMX上,因为LEA指令在u、v管道都能配对,而移位指令只能在u管道配对,因此如果你希望指令在v管道配对的话,可以用LEA取代移位位数为1、2或3的移位指令。

对于32位处理器,只含比例变址寄存器的寻址方式事实上不存在,因此像LEA EAX,[EAX*2]实际编码的时候会变成带有4字节立即数偏移的指令LEA EAX,[EAX*2+00000000]。 故为了减小代码尺寸,你可以用LEA EAX,[EAX+EAX]或更好的ADD EAX,EAX。 后者在PPlain和PMMX上是不可能AGI延迟的。 如果你正好有一个寄存器的值是0(比如循环结束后的循环计数器),那么你可以用它做基址寄存器来减小代码尺寸:

LEA EAX,[EBX*4] ; 7字节
LEA EAX,[ECX+EBX*4] ; 3字节

27.2  除法(所有处理器)

除法相当耗费时间。 在PPro,PII和PIII上,对于除数是字节、字、双字的整型除法,耗费的时间分别是19,23,39个时钟周期。 在PPlain和PMMX上,无符号整型除法花的时间与上述差不多,带符号的花的时间要多一些。 因此在不会溢出的前提下,最好用小尺寸操作数(哪怕有操作数尺寸前缀的代价),尽可能地用无符号除法。

*除数是常量的整数除法(所有处理器)

除数是2的幂次的整型除法可以由右移实现。 除数是2^N的无符号整型除法:

    SHR EAX, N

除数是2^N的有符号整型除法:

    CDQ
    AND EDX, (1 SHL N) -1 ; 或 SHR EDX, 32-N
    ADD EAX, EDX
    SAR EAX, N

如果N>7的话,上面的SHR指令比AND短。 然而SHR只能进入端口0执行(且只能在u-管道配对),AND能进端口0或1的任何一个(且在u、v管道都能配对)。

除数是常量的除法可以用乘以倒数来做。 为了计算无符号整型除法q=x/d,可以先计算除数的倒数f=2^r/d,这里的r定义了二进制小数点的位置(基点)。 然后把x与f相乘再右移r位即可。 r的最大值是32+b,b是d的有效二进制位的位数-1(即b是满足2^b<=d的最大整数)。 用了r=32+b就可以覆盖被除数x的最大范围。

为了弥补舍入造成的误差,上面的算法还需要做一些细分的操作。 因此下面的方法用了取整操作,得到了无符号整数除法的正确结果,它的结果与DIV指令给出的结果是一致的(感谢 Terje Mathisen 发明了这个方法):

    b = (d的有效位位数) - 1
    r = 32 + b
    f = 2^r / d
    如果f是一个整数,那么可以判断d是2的幂次:case A。
    如果f不是整数,那么看f的小数部分是否 < 0.5 :f的小数部分 < 0.5,case B;f的小数部分> 0.5 ,case C。

    case A: (d = 2^b)
    结果 = x SHR b

    case B: (f的小数部分 < 0.5)
    把f向下取整,然后
    结果 = ((x+1) * f) SHR r

    case C: (f的小数部分 > 0.5)
    把f向上取整,然后
    结果 = (x * f) SHR r

示例:
假定除数是5,
5 = 00000101b。
b = (有效位位数) - 1 = 2
r = 32+2 = 34
f = 2^34 / 5 = 3435973836.8 = 0CCCCCCCC.CCC... (十六进制)
f的小数部分大于0。5,进入case C,f向上取整得到0CCCCCCCDh。

下面的代码计算EAX/5,用EDX返回结果:

    MOV EDX,0CCCCCCCDh
    MUL EDX
    SHR EDX,2

在乘法之后,EDX中的数已经是积右移32位的结果了,因此对于r=34,只要再右移2位就可以了。 如果除数是10,只要把最后一条指令改成SHR EDX,3即可。
如果是B情形,代码为:

    INC EAX
    MOV EDX,f
    MUL EDX
    SHR EDX,b

除了x是0FFFFFFFFH,其它情况下都工作正常。 在EAX是0FFFFFFFFH时,因为INC指令将溢出,得到最终结果是0。 如果程序中x可能是0FFFFFFFFH,把代码改为:

    MOV EDX,f
    ADD EAX,1
    JC DOVERFL
    MUL EDX
    DOVERFL:SHR EDX,b

如果程序中x的取值范围是有限的,你可以将r取得小点,也就是更少的移位位数。 将r取得小基于以下原因:
  
*你可以令r=32,免除最后的SHR EDX,b指令
*你可以令r=16+b,这样乘法指令得到的结果就是32位而不是64位,就可以不用EDX寄存器了:

    IMUL EAX,0CCCDh / SHR EAX,18

*你可以选择一个r值进入C情形而不是b情形,这样可以免除INC EAX指令。

在上述情况下,x的最大值至少是2^(r-b),有时更大。 如果想确切地知道使你的代码可以正确工作的x的最大值,你必须系统性地进行测试。

你还可以利用26.5章的方法,用更快的指令取代较慢的乘法指令。

下面的例子计算EAX/10,结果在EAX中返回。 我选择r=17而不是19,因为这样正好能给出一个较易优化的代码,而且与r=19能够覆盖的x的范围一致:
f = 2^17 / 10 = 3333h, case B: q = (x+1)*3333h:

    LEA EBX,[EAX+2*EAX+3]
    LEA ECX,[EAX+2*EAX+3]
    SHL EBX,4
    MOV EAX,ECX
    SHL ECX,8
    ADD EAX,EBX
    SHL EBX,8
    ADD EAX,ECX
    ADD EAX,EBX
    SHR EAX,17

系统测试表明,对于所有x<10004H,这段代码能正确工作。

*除数是同一个值的多次整数除法(所有处理器)

如果在写汇编代码的时候不知道除数是多少,但是重复地使用同一个除数,那么也可以用上面的办法。 代码必须对A,B,C三种情况分别讨论,在做除法之前先计算f。

下面的代码显示了如何用同一个除数做多次除法(用了取整操作的无符号除法)。 先调用SET_DIVISOR确定除数并求出其倒数,然后对每个值调用DIVIDE_FIXED,做除数相同的除法。

.data

RECIPROCAL_DIVISOR DD ? ; 除数的倒数取整后的值
CORRECTION DD ? ; case A: -1, case B: 1, case C: 0
BSHIFT DD ? ; 除数的有效二进位位数 - 1

.code

SET_DIVISOR PROC NEAR     ; divisor in EAX
    PUSH EBX
    MOV EBX,EAX
    BSR ECX,EAX     ; b = 除数的有效二进位位数 - 1
    MOV EDX,1
    JZ ERROR       ; 错误: 除数是0
    SHL EDX,CL      ; 2^b
    MOV [BSHIFT],ECX    ; 保存b
    CMP EAX,EDX
    MOV EAX,0
    JE SHORT CASE_A ; 除数是2的幂次
    DIV EBX ; 2^(32+b) / d
    SHR EBX,1 ; 除数 / 2
    XOR ECX,ECX
    CMP EDX,EBX ; 把剩余值与divisor/2比较
    SETBE CL ; 如果是case B,则1
     MOV [CORRECTION],ECX ; 矫正取整误差
    XOR ECX,1
    ADD EAX,ECX ; 如果是case C,则加1
    MOV [RECIPROCAL_DIVISOR],EAX ; 除数的倒数取整
    POP EBX
    RET
CASE_A: MOV [CORRECTION],-1 ; 记住我们在case A
    POP EBX
    RET
SET_DIVISOR ENDP

DIVIDE_FIXED PROC NEAR ; 被除数在EAX,返回结果也在EAX
    MOV EDX,[CORRECTION]
    MOV ECX,[BSHIFT]
    TEST EDX,EDX
    JS SHORT DSHIFT ; 除数是2的幂次
    ADD EAX,EDX ; 矫正取整误差
    JC SHORT DOVERFL ; 矫正溢出
    MUL [RECIPROCAL_DIVISOR] ; 与除数的倒数相乘
    MOV EAX,EDX
    DSHIFT: SHR EAX,CL ; 调整位数
    RET
DOVERFL:MOV EAX,[RECIPROCAL_DIVISOR] ; 被除数 = 0FFFFFFFFH
    SHR EAX,CL ; 用位移的方法做除法
    RET
DIVIDE_FIXED ENDP

对于0 <= x < 232, 0 < d < 232的范围,这段代码得出的结果与DIV指令一致。
注意:如果你能保证x<0FFFFFFFFH的话,JC DOVERFL及其跳转的目标代码都是不需要的。

如果除数是2的幂次的可能性很小,那么就不值得为它优化了,你可以省去到DSHIFT的跳转,而在case A下,令CORRECTION = 0做一次乘法。

如果除数经常改变,那么SET_DIVISOR过程还需要优化。 在PPlain和PMMX处理器上,你可以用26.15节给出的代码取代BSR指令。

*浮点除法(所有处理器)

最高精度的浮点除法需要38或39个时钟周期。 你可以通过在浮点控制字中指定低精度来节省时间(在PPlain和PMMX上,只有FDIV和FIDIV在低精度下更快;而在PPro,PII和PIII上,对于FSQRT也同样在低精度下更快。 没有其它指令可以通过降低精度来提高速度)。

*并行做除法(PPlain和PMMX)

在PPlain和PMMX上,可以把一个浮点除法和一个整形除法同时做来节省时间。 而在PPro,PII和PIII上不可能,因为整数除法和浮点除法用了同一条电路。
比如:A = A1 / A2; B = B1 / B2
    
    FILD [B1]
    FILD [B2]
    MOV EAX, [A1]
    MOV EBX, [A2]
    CDQ
    FDIV
    DIV EBX
    FISTP [B]
    MOV [A], EAX

(要保证将浮点控制字的舍入控制位设置成期望的舍入方法)

*用倒数指令更快地做除法(PIII)

在PIII上你可以用快速倒数指令RCPSS或RCPPS求得除数的倒数,然后将被除数与之相乘。然后这样做的精度只有12位。你可以用Intel应用手册AP-803中的牛顿-拉弗森方法把精度提高到32位:
x0 = RCPSS(d)
x1 = x0 * (2 - d * x0) = 2*x0 - d * x0 * x0
x0是直接用倒数指令得到的除数d的倒数的逼近;x1则是更精确的逼近。你必须在把倒数与被除数相乘之前先用这个公式:

    MOVAPS XMM1, [DIVISORS] ; 载入除数
    RCPPS XMM0, XMM1 ; 求得倒数的逼近
    MULPS XMM1, XMM0 ; 牛顿-Raphson公式
    MULPS XMM1, XMM0
    ADDPS XMM0, XMM0
    SUBPS XMM0, XMM1
    MULPS XMM0, [DIVIDENDS] ; 结果在XMM0中

这样,就可以在18个时钟周期内做4个精度为23位的浮点除法。 在浮点寄存器中重复牛顿-拉弗森公式进一步增加精度也是可能的,但不是很有利。

如果想将此方法用于整型除法,那么必须检查舍入误差。 下面的代码在大约42个时钟周期里,用了对整型压缩字的截断操作完成了4个除法。 在0<=被除数 < 7FFFFH,0 < 除数 <= 7FFFFH的范围内能得到正确的结果:

    MOVQ MM1, [DIVISORS] ; 载入4个除数
    MOVQ MM2, [DIVIDENDS] ; 载入4个被除数
    PUNPCKHWD MM4, MM1 ; 把除数解压成双字
    PSRAD MM4, 16
    PUNPCKLWD MM3, MM1
    PSRAD MM3, 16
    CVTPI2PS XMM1, MM4 ; 把高位的两个除数转化成浮点数 MOVLHPS XMM1, XMM1
    CVTPI2PS XMM1, MM3 ; 把低位的两个除数转化成浮点数
    PUNPCKHWD MM4, MM2 ; 把被除数解压成双字
    PSRAD MM4, 16
    PUNPCKLWD MM3, MM2
    PSRAD MM3, 16
    CVTPI2PS XMM2, MM4 ; 把高位的两个被除数转化成浮点数
    MOVLHPS XMM2, XMM2
    CVTPI2PS XMM2, MM3 ; 把低位的两个被除数转化成浮点数
    RCPPS XMM0, XMM1 ; 求得除数倒数的逼近
    MULPS XMM1, XMM0 ; 用牛顿-Raphson方法改进精度
    PCMPEQW MM4, MM4 ; 同时,将MM4的位(4个字长)全置1
    PSRLW MM4, 15
    MULPS XMM1, XMM0
    ADDPS XMM0, XMM0
    SUBPS XMM0, XMM1 ; 精度是23位的除数的倒数
    MULPS XMM0, XMM2 ; 与被除数相乘
    CVTTPS2PI MM0, XMM0 ; 把低位的两个结果截断成整数
    MOVHLPS XMM0, XMM0
    CVTTPS2PI MM3, XMM0 ; 把高位的两个结果截断成整数
    PACKSSDW MM0, MM3 ; 把4个结果压缩到MM0
    MOVQ MM3, MM1 ; 把结果与除数相乘。。。
    PMULLW MM3, MM0 ; 检查舍入误差
    PADDSW MM0, MM4 ; 加1,为后面的减少作补偿
    PADDSW MM3, MM1 ; 加上除数。 除数应该>被除数 PCMPGTW MM3, MM2 ; 看结果是否太小了
    PADDSW MM0, MM3 ; 如果不是太小的话,各个结果减1
    MOVQ [QUOTIENTS], MM0 ; 存储4个结果

代码检查是否结果太小,如果不是太小的话,则做适当矫正。不需要检查结果是否太大。

*避免除法(所有处理器)

显然,你要尽量少做除法。 浮点除法中,除数是常量或者除数是同一个值的重复除法自然可以通过与除数的倒数相乘来完成。 但也有很多其它情况下,你可以减少除法次数。 比如:if(A/B>C)... 在B>0时可以改成if(A>B*C)... ,在B<0时改成if(A<B*C)... 。

    A/B+C/D 可以改成 (A*D + C*B) / (B*D)

如果你用的是整数除法,那么必须意识到在你把公式变形后舍入误差可能会不同。

27.3  释放浮点寄存器(所有处理器)

当你退出子过程的时候,必须释放所有用过的浮点寄存器,除了那些用于存储结果的寄存器。

释放1个寄存器的最快方法是FSTP ST。 在PPlain和PMMX上,释放2个寄存器的最快方法是FCOMPP;在PPro,PII和PIII上用FCOMPP或两次FSTP ST都可以,它们都能够很好地适合解码序列。

建议不要用FFREE。


27.4  在浮点和MMX指令之间的切换(PMMX,PII和PIII)

如果在MMX指令后面可能会有浮点指令的话,你必须在最后一条MMX指令后面加上EMMS指令。

在PMMX上,在浮点和MMX之间切换的代价很大。 在EMMS后面的第一条浮点指令大约要花58个额外的时钟周期,在浮点指令后面的第一条MMX指令大约要花38个额外的时钟周期。

在PII和PIII上没有这么大的代价。 可以在EMMS和第一条浮点指令之间插入一些整形指令来掩盖延迟。



27.5
 把浮点数转化成整数(所有处理器)

浮点数转化为整数总是要通过存储单元才能完成,反之亦然:
    
     FISTP DWORD PTR [TEMP]
     MOV EAX, [TEMP]

在PPro,PII和PIII上,因为FIST指令相对较慢,故这段代码试图在向[TEMP]的写操作尚未完成时从[TEMP]读,可能会有惩罚(参考第17章)。 插入WAIT指令也无济于事(参考26.6节)。 如果可能的话,推荐你在[TEMP]的写操作与[TEMP]的读操作之间插入一些其它指令以避免惩罚。 这个方法对以下所有的例子都有效。

C/C++规范要求浮点数转化成整数用的是截断的方法,而不是舍入。 在转化时,大多数C库在FISTP指令之前先改变浮点控制字使其指示截断操作,过后再把它改回。 在任何处理器上这个方法都非常慢。 在PPro,PII和PIII上,浮点控制字是不能被重命名的,因此后面所有的浮点指令必须等待FLDCW指令引退,无法重叠执行。

每当在C/C++中你想进行浮点转为整数的操作时,应该考虑是否可以用舍入到最接近的整数来取代截断操作。 如果标准库中没有快速舍入函数,那么可以根据需要用下面的示例代码。

如果你在循环内部需要截断操作,则最好把改回控制字的操作放在循环外。 当然前提是循环内部的其余浮点指令可以在截断模式下正确工作。

就像下面的示例那样,你可以用种种技巧做截断操作而不改变控制字。 这些例子都假定控制字被设为默认值——也就是最近舍入(偶)。

最近舍入(偶)

; extern "C" int round (double x);
_round PROC NEAR
PUBLIC _round
    FLD QWORD PTR [ESP+4]
    FISTP DWORD PTR [ESP+4]
    MOV EAX, DWORD PTR [ESP+4]
    RET
_round ENDP

截断操作(趋向0)

; extern "C" int truncate (double x);
_truncate PROC NEAR
PUBLIC _truncate
    FLD QWORD PTR [ESP+4] ; x
    SUB ESP, 12 ; 为局部变量分配空间
    FIST DWORD PTR [ESP] ; 舍入值
    FST DWORD PTR [ESP+4] ; 原来的浮点值
    FISUB DWORD PTR [ESP] ; 减去舍入值
    FSTP DWORD PTR [ESP+8] ; 存储减去舍入值后得到的差
    POP EAX ; 舍入值
    POP ECX ; 原来的浮点值
    POP EDX ; 减去舍入值后得到的差 (浮点值)
    TEST ECX, ECX ; 看x的符号
    JS SHORT NEGATIVE
    ADD EDX, 7FFFFFFFH ; 如果x-round(x) < -0,则会产生进位
    SBB EAX, 0 ; 如果x-round(x) < -0则减1
    RET
NEGATIVE:
    XOR ECX, ECX
    TEST EDX, EDX
    SETG CL ; 如果x-round(x) > 0,置CL=1
    ADD EAX, ECX ; 如果x-round(x) > 0 则加1
    RET
_truncate ENDP

截断操作(趋向负无穷大)

; extern "C" int ifloor (double x);
_ifloor PROC NEAR
PUBLIC _ifloor
    FLD QWORD PTR [ESP+4] ; x
    SUB ESP, 8 ; 为局部变量分配空间
    FIST DWORD PTR [ESP] ; 舍入值
    FISUB DWORD PTR [ESP] ; 减去舍入值
    FSTP DWORD PTR [ESP+4] ; 存储减去舍入值后得到的差
    POP EAX ; 舍入值
    POP EDX ; 减去舍入值后得到的差 (浮点值)
    ADD EDX, 7FFFFFFFH ; 如果x-round(x) < -0,则会产生进位
    SBB EAX, 0 ; 如果x-round(x) < -0则减1
    RET
_ifloor ENDP

在-2^31< x <2^31-1范围内,这些过程能够正确工作。 注意,它们不检查溢出或NAN。

PIII有单精度浮点数的截断操作指令:CVTTSS2SI 和 CVTTPS2PI。 如果对单精度满意的话,这些指令是相当有用的。 但如果为了使用这些截断指令,你必须把高精度浮点转化成单精度浮点的话,那么因为数值在转化过程中可能会被向上舍入,你会遇到问题。

*有选择地使用FISTP指令(PPlain和PMMX)

一般把浮点数转化成整数是这样做的:

     FISTP DWORD PTR [TEMP]
    MOV EAX, [TEMP]

另一个供选择的方法是:

.DATA
ALIGN 8
TEMP DQ ?
MAGIC DD 59C00000H ; 2^51 + 2^52的魔数

.CODE
    FADD [MAGIC]
    FSTP QWORD PTR [TEMP]
    MOV EAX, DWORD PTR [TEMP]

加上2^51+2^52的魔数,使得任何在-2^31~+2^31范围内整数在被存为双精度浮点数的时候,低32位是对齐的。 结果与你使用FISTP指令进行除了截断(趋向0)外所有舍入操作的结果是相同的。 但如果控制字指定了截断操作或碰到溢出的情况,那么与使用FISTP指令的结果不同。 如果想兼容老式的80287处理器的话,你可能还需要一条WAIT指令,见26.6节

这个方法并不比使用FISTP快,但在PPlain和PMMX上,它给出了更好的调度机会——因为在FADD和FSTP之间有3个时钟的浪费,它可以用其它指令来填补。使用魔数的相反数,将一个数乘以或除以2的幂次的操作与上面相同。 此外,在加一个常量时,你也可以通过把一个加该常量的魔数来完成,当然必须是双精度浮点数。


27.6  用整型指令做浮点操作(所有处理器)

一般地,整型指令比浮点指令快,因此用整型指令做一些简单的浮点操作通常是有利的。 最典型的就是数据传输。 比如:
FLD QWORD PTR [ESI] / FSTP QWORD PTR [EDI]
改为:
MOV EAX,[ESI] / MOV EBX,[ESI+4] / MOV [EDI],EAX / MOV [EDI+4],EBX

*测试是否一个浮点数为0:

一般浮点值0被表示成32或64位0,但有个缺陷:符号位可能被置位!-0也被看作是一个合法的浮点数,比如把0与一个负值相乘,处理器可能会产生一个最高位(符号位)是1的0。 因此想测试浮点数是否是0的话,你不应该测试符号位。 比如:
FLD DWORD PTR [EBX] / FTST / FNSTSW AX / AND AH,40H / JNZ IsZero
用整型指令改写时,需要用ADD EAX,EAX避免符号位可能为1带来的影响:
MOV EAX,[EBX] / ADD EAX,EAX / JZ IsZero
如果是双精度浮点数(QWORD),那么只要测试32-62位就可以了。 如果它们是0,只要是个规格化的浮点数,那么低4字节一定也是0。

*测试是否为负:

对一个浮点数,如果符号位是1且其它位至少有一位为1,那么它是负的。 比如:
MOV EAX,[NumberToTest] / CMP EAX,80000000H / JA IsNegative

*巧妙操纵符号位:

通过简单地操纵符号位,可以改变一个浮点数的符号。 比如:
XOR BYTE PTR [a] + (TYPE a) - 1, 80H

类似地,可以通过AND操作把符号位复位,得到浮点数的绝对值。

*数值比较:

浮点数的存储格式是唯一的,这就使你可以用整型指令比较浮点数,除了符号位之外。 如果保证两个浮点数都是规格化且都为正数,那么可以简单地将它们像整数那样比较,比如:
FLD [a] / FCOMP [b] / FNSTSW AX / AND AH,1 / JNZ ASmallerThanB
改为:
MOV EAX,[a] / MOV EBX,[b] / CMP EAX,EBX / JB ASmallerThanB
该方法只有在两个浮点数的精度相同,且保证没有一个数的符号位是1的前提下才正确。

如果可能有负值,那么必须把负数转化成二进制补码的形式,做带符号数的比较:

    MOV EAX, [a]
    MOV EBX, [b]
    MOV ECX, EAX
    MOV EDX, EBX
    SAR ECX, 31 ; 拷贝符号位
    AND EAX, 7FFFFFFFH ; 使符号位复位(令它=0)
    SAR EDX, 31
    AND EBX, 7FFFFFFFH
    XOR EAX, ECX ; 如果符号位是1,需要转为二进制补码?
    XOR EBX, EDX
    SUB EAX, ECX
    SUB EBX, EDX
    CMP EAX, EBX
    JL ASmallerThanB ; 带符号整数的比较

对于所有规格化浮点数,包括-0,该方法都能正确工作。


27.7  用浮点指令做整型操作(PPlain和PMMX)

*整型乘法(PPlain和PMMX)

在PPlain和PMMX上,浮点乘法比整型乘法快,但把整数因子转为浮点以及把乘法的结果再转为整型的代价很大。 因此,只有在相比乘法次数而言,需要的数据转换次数很少的前提下,用浮点做乘法才有它的优越性(如果用了非规格化的浮点操作数,似乎可以省去一些转化次数,但处理非规格化的浮点数相当慢,因此不是个好办法!)。

在PMMX上,MMX乘法指令比整型乘法快,可以被流水化,吞吐量为每个时钟1条乘法指令。因此在PMMX上,如果你能忍受16位精度的话,用MMX指令做快速的乘法是个不错的办法。

而在PPro,PII和PIII上,整型乘法指令比浮点乘法快。

*整型除法(PPlain和PMMX)

浮点除法并不比整型除法快,但在浮点单元做除法的时候,你可以同时做另一个整型操作(包括整型除法,但不能是整型乘法)(看上文的例子)。

*二进制转为十进制(所有处理器)

用FBSTP指令能简单方便地把二进制转为十进制——虽然不一定是最快的方法。


27.8  数据块的拷贝(所有处理器)

有很多方法可以移动数据块。 最常用的方法是REP MOVSD,但在一定条件下,其它方法更快。

在PPlain和PMMX上,如果目的地址不在cache中的话,用浮点寄存器一次移动8个字节更快:

TOP:  FILD QWORD PTR [ESI]
    FILD QWORD PTR [ESI+8]
    FXCH
    FISTP QWORD PTR [EDI]
    FISTP QWORD PTR [EDI+8]
    ADD ESI, 16
    ADD EDI, 16
    DEC ECX
    JNZ TOP

源数组和目的数组要按8对齐。虽然FILD和FISTP指令较慢,会花些额外的时间,但事实上你做的写操作次数只有原来的一半,因而得到了补偿。 注意,该方法只有在PPlain和PMMX上且目的地址不在1级cache的前提下才有优势。 你不能用FLD和FSTP(不带I)指令,因为位串是任意的,非规格化的“浮点数”处理的速度很慢,而且会造成某些位串在处理过程中被改变。

在PMMX上,如果目的地址不在cache中的话,用MMX指令一次移动8个字节更快。

TOP:  MOVQ MM0,[ESI]
    MOVQ [EDI],MM0
    ADD ESI,8
    ADD EDI,8
    DEC ECX
    JNZ TOP

考虑到可能会cache失效,故不需要把循环展开或做进一步优化,因为这种情况下主存的访问是瓶颈而不是指令的执行。

在PPro,PII和PIII上,如果满足以下条件,REP MOVSD指令特别快(见26.3节):

 *源地址和目的地址都按8对齐
 *增量是向前的(清方向标志)
 *计数器(ECX)大于等于64
 *EDI和ESI的差在数值上大于等于32
 *源内存和目的内存的必须是写回或组合写模式(一般你可以假定是这样)

在PII上,在上面的条件不满足且目的地址可能在1级cache的前提下,用MMX寄存器更快。 可以把循环进行2-展开,源地址和目的地址当然要按8对齐。

在PIII上,在上面的条件不满足或者目的地址在1级或2级cache的前提下,用MOVAPS移动数据最快:

    SUB EDI, ESI
TOP:  MOVAPS XMM0, [ESI]
    MOVAPS [ESI+EDI], XMM0
    ADD ESI, 16
    DEC ECX
    JNZ TOP

不像FLD,MOVAPS可以处理任何位串。 要记住的是源地址和目的地址必须按16对齐。

如果移动的字节数不是16的倍数,那么你可以向上取最接近的能被16整除的数值,在目的缓存的末尾增加一些额外空间以接受多余字节。 如果这样做不可能,那么对于剩余的字节你只能想其它方法来移动了。

在PIII上,你还可以绕过cache,直接向RAM写,用的是MOVNTQ或MOVNTPS指令。 如果你不希望目的数据进入cache的话,这样做是很有效的。MOVNTPS只比MOVNTQ稍微快一点点。


27.9  自修改代码(所有处理器)

在对一块代码修改后立即执行带来的惩罚,在PPlain上大约是19个时钟,PMMX上大约31个时钟,PPro,PII和PIII上大约150-300时钟。 在80486等早期处理器上还需要在被修改代码和修改命令之间加一个jump,目的是为了刷新代码cache。

在受保护的操作系统下,为了得到修改代码的权限你需要进行特殊的系统调用:在16位Windows下,调用ChangeSelector;在32位Windows下,调用 VirtualProtect和FlushInstructionCache(或者把代码放到数据段内)。

不认为自修改代码是一个好的编程习惯,但如果因此在速度上的获利相当大的话也未尝不可。


27.10 检测处理器类型(所有处理器)

对一种处理器而言最优的代码不一定对另一种处理器最优,我想这点应该是很清楚了。 你可以把程序的重要部分写成几个不同的版本,每一个都对专门的处理器是最优的,然后检测程序究竟运行在哪个处理器上,再在运行时选择(也可能是运行前安装)相应版本的代码。 如果你用了不能被所有处理器支持的指令(也就是条件传输,FCOMI,MMX和XMM指令),那么你必须检测将要运行的处理器是否支持这些指令。下面的子过程检测了处理器的类型以及支持的特性。

; 如果汇编器不能识别CPUID指令,那么要事先定义它:
CPUID MACRO
    DB 0FH, 0A2H
ENDM

; C++ 原型:
; extern "C" long int DetectProcessor (void);

; 返回值:
; bits 8-11 = 类型 (PPlain and PMMX是5, PPro, PII和PIII是6)
; bit 0 = 浮点指令支持
; bit 15 = 条件传输和FCOMI指令支持
; bit 23 = MMX指令支持
; bit 25 = XMM指令支持

_DetectProcessor PROC NEAR
PUBLIC _DetectProcessor
    PUSH EBX
    PUSH ESI
    PUSH EDI
    PUSH EBP
    ; 检测处理器是否支持CPUID指令
    PUSHFD
    POP EAX
    MOV EBX, EAX
    XOR EAX, 1 SHL 21 ; 检测CPUID位是否可靠?
    PUSH EAX
    POPFD
    PUSHFD
    POP EAX
    XOR EAX, EBX
    AND EAX, 1 SHL 21
    JZ SHORT DPEND ; 不支持CPUID指令
    XOR EAX, EAX
    CPUID ; 得到CPUID的功能数?
    TEST EAX, EAX
    JZ SHORT DPEND ; 不支持CPUID功能1?
    MOV EAX, 1
    CPUID ; 得到处理器类型和支持的特性
    AND EAX, 000000F00H ; 类型
    AND EDX, 0FFFFF0FFH ; 特征位
    OR EAX, EDX ; 按位组合
DPEND: POP EBP
    POP EDI
    POP ESI
    POP EBX
    RET
_DetectProcessor ENDP

注意,还有些操作系统禁止XMM指令。 关于检测操作系统是否支持XMM的资料在Intel应用手册AP-900找:“处理器和操作系统中,多媒体SIMD扩展支持标志("Identifying support for Streaming SIMD Extensions in the Processor and Operating System")”。 更多关于微处理器标识的文章可在Intel的应用手册AP-485中找到:“英特尔处理器标识和CPUID指令("Intel Processor Identification and the CPUID Instruction")”。

为了在不能识别条件传输,MMX,XMM等指令的汇编器上对这些指令进行编码,可以用 www.agner.org/assem/macros.zip 提供的宏。



28. 指令速度列表(PPlain和PMMX)

28.1 整型指令

注释:
操作数:
r = 寄存器(register), m = 内存(memory), i = 立即数(immediate data), sr = 段寄存器(segment register)
m32 = 32位内存操作数(32 bit memory operand), 等等。

时钟周期:
表上列出的是最小值。 cache失效,未对齐和异常都可能会大幅增加时钟周期。

配对:
u = 可在u管道配对, v = 可在v管道配对, uv = 可在任何管道配对, np = 无法配对(not pairable)。

 
指令 
操作数
 时钟周期   配对情况 
NOP   1 uv
MOV r/m, r/m/i 1 uv
MOV r/m, sr 1 np
MOV sr , r/m >= 2 b) np
MOV m , 累加器 1 uv h)
XCHG (E)AX, r 2 np
XCHG r , r 3 np
XCHG r , m >15 np
XLAT   4 np
PUSH r/i 1 uv
POP r 1 uv
PUSH m 2 np
POP m 3 np
PUSH sr 1 b) np
POP sr >= 3 b) np
PUSHF   3-5 np
POPF   4-6 np
PUSHA POPA   5-9 i) np
PUSHAD POPAD   5 np
LAHF SAHF   2 np
MOVSX MOVZX r , r/m 3 a) np
LEA r , m 1 uv
LDS LES LFS LGS LSS m 4 c) np
ADD SUB AND OR XOR r , r/i 1 uv
ADD SUB AND OR XOR r , m 2 uv
ADD SUB AND OR XOR m , r/i 3 uv
ADC SBB r , r/i 1 u
ADC SBB r , m 2 u
ADC SBB m , r/i 3 u
CMP r , r/i 1 uv
CMP m , r/i 2 uv
TEST r , r 1 uv
TEST m , r 2 uv
TEST r , i 1 f)
TEST m , i 2 np
INC DEC r 1 uv
INC DEC m 3 uv
NEG NOT r/m 1/3 np
MUL IMUL r8/r16/m8/m16 11 np
MUL IMUL 所有其它版本 9 d) np
DIV r8/m8 17 np
DIV r16/m16 25 np
DIV r32/m32 41 np
IDIV r8/m8 22 np
IDIV r16/m16 30 np
IDIV r32/m32 46 np
CBW CWDE   3 np
CWD CDQ   2 np
SHR SHL SAR SAL r , i 1 u
SHR SHL SAR SAL m , i 3 u
SHR SHL SAR SAL r/m, CL 4/5 np
ROR ROL RCR RCL r/m, 1 1/3 u
ROR ROL r/m, i(><1) 1/3 np
ROR ROL r/m, CL 4/5 np
RCR RCL r/m, i(><1) 8/10 np
RCR RCL r/m, CL 7/9 np
SHLD SHRD r, i/CL 4 a) np
SHLD SHRD m, i/CL 5 a) np
BT r, r/i 4 a) np
BT m, i 4 a) np
BT m, i 9 a) np
BTR BTS BTC r, r/i 7 a) np
BTR BTS BTC m, i 8 a) np
BTR BTS BTC m, r 14 a) np
BSF BSR r , r/m 7-73 a) np
SETcc r/m 1/2 a) np
JMP CALL short/near 1 e) v
JMP CALL far >= 3 e) np
conditional jump short/near 1/4/5/6 e) v
CALL JMP r/m 2/5 e np
RETN   2/5 e np
RETN i 3/6 e) np
RETF   4/7 e) np
RETF i 5/8 e) np
J(E)CXZ short 4-11 e) np
LOOP short 5-10 e) np
BOUND r , m 8 np
CLC STC CMC CLD STD   2 np
CLI STI   6-9 np
LODS   2 np
REP LODS   7+3*n g) np
STOS   3 np
REP STOS   10+n g) np
MOVS   4 np
REP MOVS   12+n g) np
SCAS   4 np
REP(N)E SCAS   9+4*n g) np
CMPS   5 np
REP(N)E CMPS   8+4*n g) np
BSWAP   1 a) np
CPUID   13-16 a) np
RDTSC   6-13 a) j) np

注:
a)在PPlain上,这条带0FH前缀的指令花一个额外的时钟周期解码,除非前面是一条多时钟指令(见第12章)。
b)带FS和GS的版本有一个0FH前缀。见注a。
c)带SS,FS和GS的版本有一个0FH前缀。见注a。
d)带两个操作数且没有立即数的版本有一个0FH前缀。见注a。
e)见第22章
f)只有寄存器是累加器时才能配对。见26.14节
g)对于重复前缀的解码需要一个额外的时钟周期,除非前面是一条多时钟指令(比如CLD。见第12章)。
h)仿佛像是对累加器的写操作那样配对。见26.14节
i) 如果SP能被4整除,那么是9。见10.2节
j)在PPlain上,权限值是6或者实模式,非权限值是11?,在虚拟模式?下发生错误。在PMMX上:分别是8和13个时钟。


28.2 浮点指令

注释:
r = 寄存器(register), m = 内存(memory), m32 = 32位内存操作数(32 bit memory operand), 等等。

时钟周期:
表上列出的是最小值。 cache失效,非规格化的操作数,未对齐和异常都可能会大幅增加时钟周期。

配对:
+ = 可以和FXCH配对, np = 不能和FXCH配对。

i-ov:
可以和整形指令重叠。i-ov = 4,意味着最后的4个时钟可以与后续并发的整形指令重叠。

fp-ov:
可以和浮点指令重叠。 fp-ov = 2 意味着最后的2个时钟可以与后续并发的浮点指令重叠(这里,WAIT也看作是一条浮点指令)。

指令  操作数   时钟周期   配对情况   i-ov   fp-ov 
FLD r/m32/m64 1 + 0 0
FLD m80 3 np 0 0
FBLD m80 48-58 np 0 0
FST(P) r 1 np 0 0
FST(P) m32/m64 2 m) np 0 0
FST(P) m80 3 m) np 0 0
FBSTP m80 148-154 np 0 0
FILD m 3 np 2 2
FIST(P) m 6 np 0 0
FLDZ FLD1   2 np 0 0
FLDPI FLDL2E etc.   5 s) np 2 2
FNSTSW AX/m16 6 q) np 0 0
FLDCW m16 8 np 0 0
FNSTCW m16 2 np 0 0
FADD(P) r/m 3 + 2 2
FSUB(R)(P) r/m 3 + 2 2
FMUL(P) r/m 3 + 2 2 n)
FDIV(R)(P) r/m 19/33/39 p) + 38 o) 2
FCHS FABS   1 + 0 0
FCOM(P)(P) FUCOM r/m 1 + 0 0
FIADD FISUB(R) m 6 np 2 2
FIMUL m 6 np 2 2
FIDIV(R) m 22/36/42 p) np 38 o) 2
FICOM m 4 np 0 0
FTST   1 np 0 0
FXAM   17-21 np 4 0
FPREM   16-64 np 2 2
FPREM1   20-70 np 2 2
FRNDINT   9-20 np 0 0
FSCALE   20-32 np 5 0
FXTRACT   12-66 np 0 0
FSQRT   70 np 69 o) 2
FSIN FCOS   65-100 r) np 2 2
FSINCOS   89-112 r) np 2 2
F2XM1   53-59 r) np 2 2
FYL2X   103 r) np 2 2
FYL2XP1   105 r) np 2 2
FPTAN   120-147 r) np 36 o) 0
FPATAN   112-134 r) np 2 2
FNOP   1 np 0 0
FXCH r 1 np 0 0
FINCSTP FDECSTP   2 np 0 0
FFREE r 2 np 0 0
FNCLEX   6-9 np 0 0
FNINIT   12-22 np 0 0
FNSAVE m 124-300 np 0 0
FRSTOR m 70-95 np 0 0
WAIT   1 np 0 0

注:
m) 待存的值需要提前一个时钟准备好
n) 如果重叠的指令也是FMUL指令,那么是1
o) 不能和整型指令重叠。
p) 对于24位,53位,64位精度,FDIV分别需要19,33,39个时钟周期。FIDIV还要多花3个周期。精度由浮点控制字的8-9位指定。
q) 前4个周期可以与前面的整型指令重叠。见26.7节
r) 给出的时钟数是对典型情况而言。罕见情况下可能更快,极端情况下可能更慢。
s) 如果输出结果为FST,FCHS或FABS所用,那么可能要多用3个周期以上。


28.3  MMX指令(PMMX)

MMX指令的速度列表就不需要了。 因为除了MMX乘法指令需要3个周期外,其它指令都是1个时钟周期。 MMX乘法指令可以被重叠并流水化,从而获得每个时钟1个乘法的吞吐率。

EMMS指令本身只花1个时钟。 但在EMMS之后的第一条浮点指令要额外花去大约58个时钟,在浮点指令后的第一条MMX指令要额外花去大约38个时钟。 在PMMX上,在EMMS之后的MMX指令没有惩罚(但在PII和PIII上有小的惩罚)。

在MMX指令中用内存操作数没有惩罚,因为在流水线中,MMX运算单元比读取单元慢一步。 但当你把一个MMX寄存器的数据存入内存或32位寄存器时可能会有惩罚:数据必须提前一个时钟准备好。这与浮点存数指令类似。

除了EMMS外,所有MMX指令都是在任何管道可配对的。 MMX指令的配对规则在第10章中描述。

29. 指令速度及微操作失败列表(PPro,PII和PIII)

注释:
操作数:
r = 寄存器(register), m = 内存(memory), i = 立即数(immediate data), sr = 段寄存器(segment register), m32 = 32位内存操作数(32 bit memory operand),等等。

微码:
指令对各个执行端口产生的微码数
p0: 端口0: ALU, 等等。
p1: 端口1: ALU, 跳转
p01: 指令既能进入端口0也能进入端口1,就看哪个先有空了。
p2: 端口2: 读数据, 等等。
p3: 端口3: 为存数据计算地址
p4: 端口4: 存数据

延迟:
这里的延迟是指指令在依赖链中产生的延迟(这与花在执行单元上的时间不同。 在不能确切测量的情况下,值可能是错误的,尤其对内存操作数)。 给出的值是最小值。 cache失效,未对齐操作,异常都可能会大幅增加时钟数。 在这里,总是假定浮点操作数是规格化的。 除了XMM数据传输指令,洗牌指令和布尔指令外,非规格化数,NAN(包括发信号的和不发信号的),正负无穷大都会导致延迟再增加50-150时钟。 浮点上溢,下溢,非规格化数,NAN导致的延迟与之相似。

吞吐量:
这里的吞吐量是指若干相同指令的最大吞吐量。 比如,FMUL指令的吞吐量为1/2,意味着每2个时钟周期可以开始一条新的FMUL指令。

29.1 整型指令
指令 操作数 微码的端口分配情况 延迟 吞吐量
    p0 p1 p01 p2 p3 p4    
NOP       1          
MOV r,r/i     1          
MOV r,m       1        
MOV m,r/i         1 1    
MOV r,sr     1          
MOV m,sr     1   1 1    
MOV sr,r 8       5  
MOV sr,m 7 1     8  
MOVSX MOVZX r,r     1          
MOVSX MOVZX r,m       1        
CMOVcc r,r 1   1          
CMOVcc r,m 1   1 1        
XCHG r,r     3          
XCHG r,m     4 1 1 1 很大 b)  
XLAT       1 1        
PUSH r/i     1   1 1    
POP r     1 1        
POP (E)SP     2 1        
PUSH m     1 1 1 1    
POP m     5 1 1 1    
PUSH sr     2   1 1    
POP sr     8 1        
PUSHF(D)   3   11   1 1    
POPF(D)   10   6 1        
PUSHA(D)       2   8 8    
POPA(D)       2 8        
LAHF SAHF       1          
LEA r,m 1           1 c)  
LDS LES LFS LGS LSS m     8 3        
ADD SUB AND OR XOR r,r/i     1          
ADD SUB AND OR XOR r,m     1 1        
ADD SUB AND OR XOR m,r/i     1 1 1 1    
ADC SBB r,r/i     2          
ADC SBB r,m     2 1        
ADC SBB m,r/i     3 1 1 1    
CMP TEST r,r/i     1          
CMP TEST m,r/i     1 1        
INC DEC NEG NOT r     1          
INC DEC NEG NOT m     1 1 1 1    
AAS DAA DAS     1            
AAD   1   2       4  
AAM   1 1 2       15  
MUL IMUL r,(r),(i) 1           4 1/1
MUL IMUL (r),m 1     1     4 1/1
DIV IDIV r8 2   1       19 1/12
DIV IDIV r16 3   1       23 1/21
DIV IDIV r32 3   1       39 1/37
DIV IDIV m8 2   1 1     19 1/12
DIV IDIV m16 2   1 1     23 1/21
DIV IDIV m32 2   1 1     39 1/37
CBW CWDE       1          
CWD CDQ   1              
SHR SHL SAR ROR ROL r,i/CL 1              
SHR SHL SAR ROR ROL m,i/CL 1     1 1 1    
RCR RCL r,1 1   1          
RCR RCL r8,i/CL 4   4          
RCR RCL r16/32,i/CL 3   3          
RCR RCL m,1 1   2 1 1 1    
RCR RCL m8,i/CL 4   3 1 1 1    
RCR RCL m16/32,i/CL 4   2 1 1 1    
SHLD SHRD r,r,i/CL 2              
SHLD SHRD m,r,i/CL 2   1 1 1 1    
BT r,r/i     1          
BT m,r/i 1   6 1        
BTR BTS BTC r,r/i     1          
BTR BTS BTC m,r/i 1   6 1 1 1    
BSF BSR r,r   1 1          
BSF BSR r,m   1 1 1        
SETcc r     1          
SETcc m     1   1 1    
JMP short/near   1           1/2
JMP far 21 1        
JMP r   1           1/2
JMP m(near)   1   1       1/2
JMP m(far) 21 2        
conditional jump short/near   1           1/2
CALL near   1 1   1 1   1/2
CALL far 28 1 2 2    
CALL r   1 2   1 1   1/2
CALL m(near)   1 4 1 1 1   1/2
CALL m (far) 28 2 2 2    
RETN     1 2 1       1/2
RETN i   1 3 1       1/2
RETF   23 3        
RETF i 23 3        
J(E)CXZ short   1 1          
LOOP short 2 1 8          
LOOP(N)E short 2 1 8          
ENTER i,0     12   1 1    
ENTER a,b ca. 18+4b   b-1 2b    
LEAVE       2 1        
BOUND r,m 7   6 2        
CLC STC CMC       1          
CLD STD       4          
CLI   9          
STI   17          
INTO       5          
LODS         2        
REP LODS       10+6n        
STOS         1 1 1    
REP STOS       ca. 5n a)    
MOVS       1 3 1 1    
REP MOVS       ca. 6n a)    
SCAS       1 2        
REP(N)E SCAS       12+7n        
CMPS       4 2        
REP(N)E CMPS       12+9n        
BSWAP   1   1          
CPUID   23-48          
RDTSC   31          
IN   18       >300  
OUT   18       >300  
PREFETCHNTA  d) m        1        
PREFETCHT0  d) m        1        
PREFETCHT1  d) m        1        
PREFETCHT2  d) m        1        
SFENCE  d)            1  1   1/6

注:
a) 在特定条件下更快:见26.3节
b) 见26.1节
c) 如果只有常量,没有基址或变址寄存器的话是3
d) 只有PIII上才能用。

29.2 浮点指令
指令 操作数 微码的端口分配情况 延迟 吞吐量
    p0 p1 p01 p2 p3 p4    
FLD r 1              
FLD m32/64       1     1  
FLD m80 2     2        
FBLD m80 38     2        
FST(P) r 1              
FST(P) m32/m64         1 1 1  
FSTP m80 2       2 2    
FBSTP m80 165       2 2    
FXCH r             0 3/1 f)
FILD m 3     1     5  
FIST(P) m 2       1 1 5  
FLDZ   1              
FLD1 FLDPI FLDL2E etc. 2              
FCMOVcc r 2           2  
FNSTSW AX 3           7  
FNSTSW m16 1       1 1    
FLDCW m16 1   1 1     10  
FNSTCW m16 1       1 1    
FADD(P) FSUB(R)(P) r 1           3 1/1
FADD(P) FSUB(R)(P) m 1     1     3-4 1/1
FMUL(P) r 1           5 1/2 g)
FMUL(P) m 1     1     5-6 1/2 g)
FDIV(R)(P) r 1           38 h) 1/37
FDIV(R)(P) m 1     1     38 h) 1/37
FABS   1              
FCHS   3           2  
FCOM(P) FUCOM r 1           1  
FCOM(P) FUCOM m 1     1     1  
FCOMPP FUCOMPP   1   1       1  
FCOMI(P) FUCOMI(P) r 1           1  
FCOMI(P) FUCOMI(P) m 1     1     1  
FIADD FISUB(R) m 6     1        
FIMUL m 6     1        
FIDIV(R) m 6     1        
FICOM(P) m 6     1        
FTST   1           1  
FXAM   1           2  
FPREM   23              
FPREM1   33              
FRNDINT   30              
FSCALE   56              
FXTRACT   15              
FSQRT   1           69 e,i)
FSIN FCOS   17-97       27-103 e)
FSINCOS   18-110       29-130 e)
F2XM1   17-48       66 e)
FYL2X   36-54       103 e)
FYL2XP1   31-53       98-107 e)
FPTAN   21-102       13-143 e)
FPATAN   25-86       44-143 e)
FNOP   1              
FINCSTP FDECSTP   1              
FFREE r 1              
FFREEP r 2              
FNCLEX       3          
FNINIT   13          
FNSAVE   141          
FRSTOR   72          
WAIT       2          

注:
e) 不能流水化
f) FXCH产生1条微码,但它通过寄存器重命名来完成,不需要进入任何端口。
g) FMUL与整型乘法使用同一条电路。 因此,混用浮点和整型乘法的总体吞吐量是每3个时钟1条FMUL+1条IMUL。
h) FDIV的延迟取决于控制字中指定的精度:64位精度延迟是38,53位精度延迟是32,24位精度延迟是18。 除数是2的幂次的除法花9个时钟。 除法的吞吐量是1/(延迟-1)。
i) 在低精度下更快。

?
29.3  MMX指令 (PII 和 PIII)
指令 操作数 微码的端口分配情况 延迟 吞吐量
    p0 p1 p01 p2 p3 p4    
MOVD MOVQ r,r     1         2/1
MOVD MOVQ r64,m32/64       1       1/1
MOVD MOVQ m32/64,r64         1 1   1/1
PADD PSUB PCMP r64,r64    1         1/1
PADD PSUB PCMP r64,m64     1 1       1/1
PMUL PMADD r64,r64 1           3 1/1
PMUL PMADD r64,m64 1     1     3 1/1
PAND PANDN POR
PXOR
r64,r64     1         2/1
PAND PANDN POR
PXOR
r64,m64     1 1       1/1
PSRA PSRL PSLL r64,r64/i   1           1/1
PSRA PSRL PSLL r64,m64   1   1       1/1
PACK PUNPCK r64,r64   1           1/1
PACK PUNPCK r64,m64   1   1       1/1
EMMS   11       6 k)  
MASKMOVQ  d) r64,r64      1    1  1 2-8 1/30-1/2
PMOVMSKB  d) r32,r64    1          1  1/1
MOVNTQ  d) m64,r64          1  1   1/30-1/1
PSHUFW  d) r64,r64,i    1          1  1/1
PSHUFW  d) r64,m64,i    1    1      2  1/1
PEXTRW  d) r32,r64,i    1  1        2  1/1
PISRW  d) r64,r32,i    1          1  1/1
PISRW  d) r64,m16,i    1    1      2  1/1
PAVGB PAVGW  d) r64,r64      1        1  2/1
PAVGB PAVGW  d) r64,m64      1  1      2  1/1
PMINUB PMAXUB PMINSW PMAXSW  d) r64,r64      1        1  2/1
PMINUB PMAXUB PMINSW PMAXSW  d) r64,m64      1  1      2  1/1
PMULHUW  d) r64,r64  1            3  1/1
PMULHUW  d) r64,m64  1      1      4  1/1
PSADBW  d) r64,r64  2    1        5  1/2
PSADBW  d) r64,m64  2    1  1      6  1/2

注:
d) 只有PIII上才能用。
k) 你可以在EMMS和后续浮点指令之间插入一些其它指令来掩盖延迟。

?
29.4 XMM指令 (PIII)
指令 操作数 微码的端口分配情况 延迟 吞吐量
     p0   p1   p01   p2   p3   p4     
MOVAPS r128,r128     2       1 1/1
MOVAPS r128,m128       2     2 1/2
MOVAPS m128,r128         2 2 3 1/2
MOVUPS r128,m128       4     2 1/4
MOVUPS m128,r128   1     4 4 3 1/4
MOVSS r128,r128     1       1 1/1
MOVSS r128,m32     1 1     1 1/1
MOVSS m32,r128         1 1 1 1/1
MOVHPS MOVLPS r128,m64     1       1 1/1
MOVHPS MOVLPS m64,r128         1 1 1 1/1
MOVLHPS MOVHLPS r128,r128     1       1 1/1
MOVMSKPS r32,r128 1           1 1/1
MOVNTPS m128,r128          2  2   1/15-1/2
CVTPI2PS r128,r64   2         3 1/1
CVTPI2PS r128,m64   2   1     4 1/2
CVTPS2PI CVTTPS2PI r64,r128   2         3 1/1
CVTPS2PI r64,m128   1   2     4 1/1
CVTSI2SS r128,r32   2   1     4 1/2
CVTSI2SS r128,m32   2   2     5 1/2
CVTSS2SI CVTTSS2SI r32,r128   1   1     3 1/1
CVTSS2SI r32,m128   1   2     4 1/2
ADDPS SUBPS r128,r128   2         3 1/2
ADDPS SUBPS r128,m128   2   2     3 1/2
ADDSS SUBSS r128,r128   1         3 1/1
ADDSS SUBSS r128,m32   1   1     3 1/1
MULPS r128,r128 2           4 1/2
MULPS r128,m128 2     2     4 1/2
MULSS r128,r128 1           4 1/1
MULSS r128,m32 1     1     4 1/1
DIVPS r128,r128 2           48 1/34
DIVPS r128,m128 2     2     48 1/34
DIVSS r128,r128 1           18 1/17
DIVSS r128,m32 1     1     18 1/17
ANDPS ANDNPS ORPS XORPS r128,r128   2         2 1/2
ANDPS ANDNPS ORPS XORPS r128,m128   2   2     2 1/2
MAXPS MINPS r128,r128   2         3 1/2
MAXPS MINPS r128,m128   2   2     3 1/2
MAXSS MINSS r128,r128   1         3 1/1
MAXSS MINSS r128,m32   1   1     3 1/1
CMPccPS r128,r128   2         3 1/2
CMPccPS r128,m128   2   2     3 1/2
CMPccSS r128,r128   1         3 1/1
CMPccSS r128,m32   1   1     3 1/1
COMISS UCOMISS r128,r128   1         1 1/1
COMISS UCOMISS r128,m32   1   1     1 1/1
SQRTPS r128,r128 2           56 1/56
SQRTPS r128,m128 2     2     57 1/56
SQRTSS r128,r128 2           30 1/28
SQRTSS r128,m32 2     1     31 1/28
RSQRTPS r128,r128 2           2 1/2
RSQRTPS r128,m128 2     2     3 1/2
RSQRTSS r128,r128 1           1 1/1
RSQRTSS r128,m32 1     1     2 1/1
RCPPS r128,r128 2           2 1/2
RCPPS r128,m128 2     2     3 1/2
RCPSS r128,r128 1           1 1/1
RCPSS r128,m32 1     1     2 1/1
SHUFPS r128,r128,i   2 1       2 1/2
SHUFPS r128,m128,i   2   2     2 1/2
UNPCKHPS UNPCKLPS r128,r128   2 2       3 1/2
UNPCKHPS UNPCKLPS r128,m128   2   2     3 1/2
LDMXCSR m32 11       15 1/15
STMXCSR m32 6       7 1/9
FXSAVE m4096 116       62  
FXRSTOR m4096 89       68  

30. 速度测试

奔腾家族的处理器内部有一个64位的时钟计数器,通过RDTSC(read time stamp counter,读时间戳计数器)指令可以把它的值读到EDX:EAX寄存器中。 这对于确切测试一块代码用掉的时钟数十分有用。

下面的代码对测试一块代码花去的时钟数很有用。 程序执行要测的代码片段,测试10次,以10个时钟为一个单位,保存用掉的单位数。 这段代码可以在PPlain和PMMX上的16位或32位模式下使用:

;************ PPlain和PMMX上的测试程序: ********************

ITER EQU 10 ; 叠代次数
OVERHEAD EQU 15 ; 对于PPlain是15,对于PMMX是17

RDTSC MACRO ; 定义RDTSC指令
    DB 0FH,31H
ENDM
;************ 数据段: ********************
。DATA ; 数据段
ALIGN 4
COUNTER DD 0 ; 循环计数器
TICS DD 0 ; 存储时钟数的临时变量
RESULTLIST DD ITER DUP (0) ; 存储结果的数组
;************ 代码段: ********************
。CODE ; 代码段
BEGIN: MOV [COUNTER],0 ; 循环计数器复位
TESTLOOP: ; 循环测试
;************ 在这里做一些任意的初始化工作: ********************
    FINIT
;************ 初始化结束 ********************
    RDTSC ; 读时钟计数器
    MOV [TICS],EAX ; 保存计数值
    CLD ; 无法配对的填充指令
REPT   8
    NOP ; 放8个NOP避免影响后面要测试的代码
ENDM

;************ 这里放置要测试的代码: ********************
    FLDPI ; 这只是一个例子
    FSQRT
    RCR EBX,10
    FSTP ST
;***************** 要测试的代码结束 *******************

    CLC ; 无法配对的,带“阴影”的填充指令,使RDTSC的前缀在其“阴影”里解码
    RDTSC ; 再次读计数器
    SUB EAX,[TICS] ; 计算两次的差
    SUB EAX,OVERHEAD ; 减去填充指令等用掉的时钟数
    MOV EDX,[COUNTER] ; 循环计数器
    MOV [RESULTLIST][EDX],EAX ; 结果存入数组
    ADD EDX,TYPE RESULTLIST ; 增加计数器
    MOV [COUNTER],EDX ; 保存计数器
    CMP EDX,ITER * (TYPE RESULTLIST)
    JB TESTLOOP ; 重复叠代过程

; 这里还要写一些把值从RESULTLIST中读出的代码

在PPlain上,在被测代码的前面和后面的填充指令是为了每次叠代都能获得一致的结果。 CLD是一条无法配对的指令,插在这里是为了保证第一次叠代时指令的配对情况与后面的叠代相同。 在PPlain上,插入8个NOP的目的是避免被测代码的任何前缀的解码时间与前面指令重叠(避免阴影效应)。 这里使用了单字节指令,使后续叠代的配对情况与第一次叠代时相同。 在PPlain上,在被测代码之后的CLC指令是一条无法配对的指令,带有“阴影”,使RDTSC的0FH前缀的解码时间与CLC的执行时间重叠(在其阴影里解码),这样RDTSC就与被测代码独立,不会在被测代码的阴影中解码了。

在PMMX上,如果希望FIFO(先进先出)的指令缓存为空,你可以在指令前先插入XOR EAX,EAX / CPUID来检测;如果希望FIFO的缓存是满的,那么就插入一些耗时的指令(比如,CLI或AAD)(CPUID没有阴影,即后续指令前缀的解码无法在CPUID执行时重叠进行)。

在PPro,PII和PIII上,在每个RDTSC之前、之后你必须插入XOR EAX,EAX / CPUID 以避免RDTSC与任何指令并行执行,并且不要写填充指令(CPUID是序列化指令,即它在进行前会等待所有未完的操作结束,且清洗流水线。 故将它用于测试目的很合适)。

在PPlain和PMMX上,RDTSC指令不能在虚拟模式下执行。 故倘若想运行DOS程序,则必须在实模式下运行(在重启机器时按F8,选择"safe mode command prompt only" 或 "bypass startup files")。

整个测试程序可以到 www.agner.org/assem/ 下载。

奔腾处理器还带有一些专门用于监控性能的计数器。 可以为诸如cache失效,未对齐操作,各种延迟等事件计数。 性能监控计数器的详细介绍就略过了,可以在“Intel架构下的软件开发者手册”("Intel Architecture Software Developer's Manual"),vol.3,附录A找到。



31.  不同的微处理器间的比较

下面的表格概括了奔腾家族处理器之间的一些重要的区别:

   PPlain   PMMX   PPro   PII   PIII 
代码cache, kb 8 16 8 16 16
数据cache, kb 8 16 8 16 16
具备2级cache, kb 0 0 256 512 *) 512 *)
MMX指令 不支持 支持 不支持 支持 支持
XMM指令 不支持 不支持 不支持 不支持 支持
条件传输指令 不支持 不支持 支持 支持 支持
乱序执行 不支持 不支持 支持 支持 支持
分支预测
分支目标缓存的表项数目(BTB entry) 256 256 512 512 512
返回站缓存的尺寸(RSB size) 0 4 16 16 16
分支预测失败的惩罚 3-4 4-5 10-20 10-20 10-20
部分寄存器延迟 0 0 5 5 5
FMUL的执行时间 3 3 5 5 5
FMUL的吞吐率 1/2 1/2 1/2 1/2 1/2
IMUL的执行时间 9 9 4 4 4
IMUL的吞吐率 1/9 1/9 1/1 1/1 1/1

*)Celeron:0-128,Xeon:512及512以上,和许多其它的变种处理器有。 在一些处理器上,2级cache以一半速度运行。

上表的注释:
如果无法将程序的关键部分控制在较小的内存空间里的话,代码cache的尺寸就很重要了。

如果关键部分处理的数据量不小的话,那么数据cache的尺寸很重要。

MMX和XMM指令对于处理大块并行数据的程序很有用,比如声音和图像处理。 在其它应用下可能用MMX和XMM指令也没多大优势。

条件传输指令很有用,用于避免那些不大好预测的条件跳转。

乱序执行改进了性能,尤其对于未优化的代码而言。 它包括自动进行指令重排和寄存器重命名。

具备好的分支预测方法的处理器能够预知简单的重复模式的转移。 如果分支预测失败的代价很高的话,那么好的分支预测机制就是最重要的。

当子过程在不同的位置被调用时,返回栈缓存(RSB)改进了返回指令的预测。

部分寄存器延迟使得混合处理不同尺寸的数据(8,16,32位)变得更困难。

乘法指令的延迟时间是指在依赖链中花去的时间。 1/2的吞吐率意味着执行可以被流水化,每2个时钟周期可以开始一个新的乘法指令。 这指明了处理并行数据可以达到的速度。

本文档中描述的大多数优化对于其它类型的处理器没什么副作用(要有也是很小的副作用),包括非Intel处理器。 但同时,也要意识到几个问题:

在PPlain和PMMX上规划浮点代码时,经常需要大量的FXCH指令。 在老式的处理器上会减慢执行速度,但在奔腾家族和高级的非Intel处理器上不会。

在PMMX,PII和PIII上利用MMX指令,以及在PPro,PII和PIII上利用条件传输指令,如果想让你的代码兼容早期处理器的话,可能会出现问题。 解决办法是将你的代码写成几个版本,每个分别为特定的处理器优化。 在运行的时候,你的程序要检测处理器类型,然后选择版本适当的代码(27.10节)。