标签:exec table tool png comm 比较 back floating split
大多数情况下,编写程序都不会使用汇编语言而是使用高级语言,原因大致有以下几点:
尽管汇编语言不是开发的常用语言,不过它也有很多的应用场景,如系统最底层的开发、程序的反汇编调试等。不过本篇文章主要目的是用汇编语言对程序的运行速度进行优化。
首先要说明的是,汇编优化不是软件优化的唯一手段。软件的运行速度会受到很多方面的影响,比如说软件常常会在加载中耗费大量时间:加载模块、加载资源文件、加载数据库、加载底层框架(如C# framework),又比如说软件可能会由于网络状况不好而出现长时间的等待,这都是可以优化的方向。要对软件进行优化,首先需要找出软件的瓶颈,针对这个瓶颈采用相对应的优化方法。
这里讨论的汇编优化,是当瓶颈出现在CPU运算是采用的优化措施。在实际应用中,汇编优化经常会被用在音频与图像处理、加密、排序、数据压缩以及复杂的数学运算当中。上述的这些程序有一个特点,就是程序中的绝大部分都是在利用CPU进行运算,这种程序称为CPU-Intensive Program。
一般来说,CPU-Intensive Program处理的数据都是一连串连续的序列,也就是说会循环对数据进行处理。对这种程序来说,循环的内部是程序的主体,在循环内部耗费的时间会占到总运行时间的99%以上。因此为了避免在没必要优化的代码上浪费时间,我们进行优化之前需要对程序有个总体的了解,知道哪部分的代码是最常跑的、急需优化的(critical),哪部分的代码是次要的(less critical)。如果确实很难分辨程序的主次部分,可以在不同的代码加上计数器来区分。
在讨论指令优化之前,我们首先需要了解一条指令在CPU中是如何被执行的。
一条指令的执行从开始到结束,我们称之为指令周期(Instruction cycle),又可以称为fetch-decode-execute cycle。指令周期可以细分为以下几个部分:
除了指令解码之外,其余的部分主要由micro-operations(μops)组成,也就是传输、运算等基本操作(关于μops后面有较详细的解释)。我们这节的目的就是在了解指令周期的各部分的基础上,讨论各部分对指令运行效率的影响。
其中涉及到的部件有以下几个:
指令获取,顾名思义,目的就是把指令从内存传输进CPU以便后续执行。
指令获取可以分解成以下几个μops:
一般来说,大多数CPU会在一个时钟周期内获取16字节的指令,并且获取方式是16字节对齐,因此对于某些重要的、规模较小的循环代码,还是有必要尽量把代码的大小压缩在16字节内并保证这部分代码16字节对齐。这涉及到指令的大小优化以及对齐,以后有机会再讨论。
在很多处理器中,跳转指令(jumps)会导致指令的获取延迟,因此在重要的代码区有必要减少跳转指令的数目。不过如果是条件跳转指令,但是的跳转的条件不满足,走非跳转分支的话,是不会导致指令的获取延迟的。因此,对于条件跳转指令,尽量使得指令多走不命中的分支(非跳转分支)。
指令是二进制串,其中包括前缀、操作码、操作数等各种信息,为了弄清一段二进制串的具体意图,需要对其进行解码。解码过程我们关注的有两部分:一是前面讨论过的复杂指令转换成μops,二是指令的前缀码。
在复杂指令转换成μops时,各CPU都有各自的最优的解码模式,如:PM处理器的最优解码模式为4-1-1,Core2的最优解码模式为4-1-1-1。以PM处理器为例,其中第一个数字4代表复杂指令,即可以被分解成4、3或者2个μops的指令,后面的-1-1代表两个复杂指令之间至少要有两个简单指令(μops)。而AMD处理器应该尽量避免使用复杂指令。
有些CPU会对前缀嘛的个数有要求,一旦前缀的个数超过某个值,指令的解码速度将会变慢。有些Intel 32位处理器为1,P4E为2,AMD的处理器为3,Core2则没有限制。因此应尽量使用少前缀码的指令。如在32位处理器中,mov ax, 2 比 mov eax, 2 多一个前缀,因此我们倾向于用后者。关于前缀的更多信息请查看Agner Optimize 2. Optimizing subroutines in assembly language: An optimization guide for x86 platforms中的3.4/3.5节。
汇编语言的寻址方式有很多种,其中有一直叫做直接内存寻址(Direct Memory Addressing),如 mov ax, addr ,其中第二个操作数addr就是一个内存地址,这句指令的目的是把addr指向的内存中的数据传输到ax。与之相对应的是间接内存寻址(Indirect Memory Addressing),如 mov ax, [addr] ,其中addr是一个指针的所在地址,这句指令的目的就是把该指针所指向的内存中的数据传输到ax。
对于间接内存寻址,由于第二个操作数addr所指向的内存并非我们实际的所需数据,只是一个指针,为了得到所需数据的内存地址,就有了这一部分的工作。
获取有效地址可以分解成以下的μops:
这步指的是执行具体的指令,实现指令的目的。由于指令多种多样,我们这里就以 add ax, mem 为例,把它分解成μops:
这一步的优化涉及的篇幅较多,我们会在下面散序处理用较多的篇幅展开讨论。
只有采用散序处理的CPU才会出现这个步骤,散序处理在下一节有描述(最好先看一下)。我们后面在讨论散序处理的时候会说到在执行指令的最后会把指令运行的结果,按照指令原本的顺序写回到用户可见的寄存器/内存。
在P4处理器上,一个时钟周期内可以执行3次μop的Instruction retirement,现在的处理器可能可以执行更多的次数。虽然这已经非常高效,不过并不意味着Instruction retirement就不会称为指令执行的瓶颈,原因如下:
散序处理(Out of Order Execution)是现代CPU非常重要特性,x86、arm的新架构基本都支持这种特性,要进行指令优化必须要对散序处理有个基本的了解。
与散序处理相对应的就是顺序处理(In Order Execution),两者在指令的处理步骤上存在明显区别:
顺序处理 In Order Execution :
散序处理 Out of Order Execution :
散序处理的第6步,也就是最后一个步骤,被称为Instruction retirement,具体实现的部分被称为Retirement unit。程序指令原来的执行顺序叫做program order,不过在散序处理的CPU中,指令的处理顺序是基于数据的,当指令在其所依赖的数据就绪后即可开始执行,这种执行顺序叫做data order。不过对于用户来说,为了便于调试与维护,还是有必要在用户可见的范围内维持程序原有的顺序,retirement unit做的就是这个工作。Retirement unit把指令的执行结果按照program order写回用户可见的寄存器,使得用户以为程序是顺序执行的,实际上在CPU内部,指令是散序执行的。
散序处理总体上可以按照上面的描述进行理解,细分开来则涉及到较多的CPU特性。
有如下的例子:
; Example 9.1a, Out-of-order execution mov eax, [mem1] imul eax, 6 mov [mem2], eax mov ebx, [mem3] add ebx, 2 mov [mem4], ebx
上面的代码分别做了两项完全不相干的工作:
CPU散序处理的逻辑如下:
CPU的散序处理的目的就是CPU会尽量使得自己忙起来,这需要CPU具有判断指令间是否具有依赖性的能力。
支撑CPU散序处理的另一个重要特性就是寄存器重命名(register renaming)。
汇编代码中的寄存器都是逻辑寄存器,在CPU的实际处理时会转换成物理寄存器,这就叫做寄存器重命名。如下面的例子:
; Example 9.1b, Out-of-order execution with register renaming mov eax, [mem1] imul eax, 6 mov [mem2], eax mov eax, [mem3] add eax, 2 mov [mem4], eax
这段代码由前一小节的代码演变过来,只是把寄存器ebx改成了eax。这两段代码实现的功能并没有改变,运行时间也是完全一样。因为每次对逻辑寄存器eax进行写入的时候,都会为其分配一个新的物理寄存器。这也意味着上面这段代码中,逻辑寄存器eax共用到了4个物理寄存器,分别为:从[mem1]读取数据、存放乘法运算结果、从[mem2]读取数据、存放加法运算结果。
对同一个逻辑寄存器采用多个物理寄存器的做法使得上述这段指令的前三句与后三句相互独立,能更有效地进行散序处理。这种机制要求CPU有大量的物理寄存器,不过这不是我们关心的问题,一般来说物理寄存器都会多到足以使得这种机制能有效运作。
我们知道寄存器可以8位、16位、32位、64位进行访问,如:ah/al、ax、eax、rax。对于32位寄存器来说,小于等于16位的寄存器被称为partial register。由于是同一个逻辑寄存器,所以在对寄存器进行操作时需要多加注意,以防出现假依赖(false dependence)的情况。
如下是一个假依赖的例子:
; Example 9.1c, False dependence of partial register mov eax, [mem1] ; 32 bit memory operand imul eax, 6 mov [mem2], eax mov ax, [mem3] ; 16 bit memory operand add ax, 2 mov [mem4], ax
代码原本的目的是做两项完全不相关的工作,但是第四条指令 mov ax, [mem3] 只改变了寄存器eax的低16位,高16位仍然是前面乘法保留下来的结果。对于Intel、AMD等公司的CPU来说,它们不会对partial register进行重命名(register renaming),也就是说第四条指令与前面的指令用的是同一个物理寄存器,这使得 mov ax, [mem3] 依赖于指令 imul eax, 6。
另外有些CPU会对partial register进行重命名,使得 mov ax, [mem3] 不依赖于指令 imul eax, 6,但是最后还是要把 imul eax, 6 中eax的高16位与 mov ax, [mem3] 中的ax进行组合,这又会耗费一段时间。
总之,寄存器假依赖会降低指令的执行效率。假依赖可以通过以下方法来避免:把 mov ax, [mem3] 替换成 movzx eax, [mem3] 。movzx会对寄存器的高位进行补零,如此一来就消除了依赖关系。而64位寄存器与32位寄存器之间就不用担心依赖关系,因为在对32位寄存器进行写入的时候,其对应的64为寄存器的高位是自动补零的,即 movzx eax, [mem3] 与movzx rax, [mem3] 是完全相同的效果。
另外,有些指令会对标志寄存器(flag register)进行修改,这也可能会导致出现假依赖。如:INC与DEC这两个指令只修改了标志寄存器的zero flag与sign flag,不会修改carry flag。
Micro-operations(缩写为μops或uops)就是CPU最基本的一些操作,分为四类:
某些复杂的指令在处理时可以分成多个μops。如下面的例子:
; Example 9.2, Splitting instructions into uops push eax call SomeFunction
其中 push eax 会把栈顶下移,然后把eax移入栈内,在分解成μops会把这两步分开,得到如 sub esp, 4 与 mov [esp], eax 的μops集。call指令依赖于栈顶esp。假设这两行指令的前面需要进行大量计算才能得到eax,那么如果push指令不分解成μops的话,那么call指令就跟push指令形成依赖关系,必须先得到eax的计算结果再执行push,最后才能执行call。在分解成μops后,call指令依赖的只有 sub esp 4 ,因此可以在得到eax结果之前就开始执行。
现代CPU一般都会有多个处理单元使得Out of Order Execution可以更高效的运行。如大多数CPU都有两个以上的ALU(Arithmetic Logic Unit),因此在一个时钟周期内可以同时进行两项或者更多的整数运算;CPU通常会有一个浮点加法与一个浮点乘法处理单元,因此浮点数的乘法与浮点数的加法可以同时进行;CPU可能也会分别有一个内存读单元与内存写单元,因此内存的读与写可以同时进行;CPU也可以同时分别执行整数运算、浮点运算、读写内存等。各个处理单元相互独立,使得CPU可以在一个时钟周期之内同时处理多条指令。
浮点数的运算相比整数运算需要更长的执行时间,一般都超过一个时钟周期,不过浮点数运算可以细分成更小的处理单位,各个单位组成流水线。例如:不用等前一条浮点加法指令执行完毕就可以开始执行下一条浮点加法指令。当然,不止有浮点数指令,还有其它的指令也可以进行流水线处理,不过不同的芯片、不同的指令的执行周期不同,指令的相关资料可以去芯片商的官网查找或者Agner Optimize的The microarchitecture of Intel, AMD and VIA CPUs以及Instruction tables。
以Core2处理器为例,其浮点加法的latency为3个时钟周期,throughput为1。这意味着在一条依赖链内,处理器需要用3个时钟周期来执行浮点加法,然后才能去执行该依赖链内的下一条指令;对于不在这条依赖链内的指令,如果同样是是浮点加法指令,只需在1个时钟周期之后即可开始执行。
如下是一些指令的典型的延时与吞吐量表格,为了更好地对比,列出的是1/throughput,指的是一条指令在开始执行之后,间隔多久(平均值)才能开始执行另一条同类型并且不在同一依赖链的指令。
Instruction | latency | 1/throughput |
Interger move | 1 | 0.33-0.5 |
Interger addition | 1 | 0.33-0.5 |
Interger boolean | 1 | 0.33-1 |
Interger shift | 1 | 0.33-1 |
Interger multiplication | 3-10 | 1-2 |
Interger division | 20-80 | 20-40 |
Floating point addition | 3-6 | 1 |
Floating point multiplication | 4-8 | 1-2 |
Floating point division | 20-45 | 20-45 |
Interger vector addition (XMM) | 1-2 | 0.5-2 |
Interger vector multiplication (XMM) | 3-7 | 1-2 |
Floating point vector addition (XMM) | 3-5 | 1-2 |
Floating point vector multiplication (XMM) | 4-7 | 1-4 |
Floating point vector division (XMM) | 20-60 | 20-60 |
Memory read (cache) | 3-4 | 0.5-1 |
Memory write (cache) | 3-4 | 1 |
Jump or call | 0 | 1-2 |
各CPU更具体的latency与throughput可以去查看Agner Optimize的Manual 4: "Instruction tables"。
标签:exec table tool png comm 比较 back floating split
原文地址:http://www.cnblogs.com/TaigaCon/p/7455443.html