汇编效率优化:指令处理机制

大多数情况下,编写程序都不会使用汇编语言而是使用高级语言,原因大致有以下几点:

  1. 花费更多时间。高级语言的一行相当于汇编语言的几行、几十行甚至更多。
  2. 不够安全。比如说在进行函数调用时PUSH与POP必须成对出现,高级语言中的函数调用会自动为你执行PUSH与POP的操作,但是汇编语言中就必须由程序员自己保证PUSH与POP一致,否则会导致栈错乱,使得程序出现不可预知的错误。
  3. 难以调试。相对于高级语言来说,汇编语言除了语法之外,还要对指令有一定的了解,比如说有些指令的操作数只能是常数,有些指令只能有一个操作数为寄存器等。如果因不了解指令的这些限制而出错,单单从汇编器给出的错误信息是比较难发现出错在什么地方的。
  4. 难以维护。就可读性来说,汇编语言不像高级语言那样直观,缺少注释的话,比较难看出一段指令的具体意图。
  5. 可移植性差。不同平台(如x86、arm)采用的是不同指令集,如果采用汇编语言开发的话则需要分别为各个平台编写程序。
  6. intrinsic function。intrinsic function以类似内联函数(inline function)的方式封装了汇编指令,使得用户只需了解函数的功能,而不用关注具体的实现。这种方式更多地应用在SIMD上,程序员只需调用SIMD的intrinsic function即可达到SIMD的优化效果(见x86、arm)。
  7. 编译器优化。编译器经过这么多年的发展,已经有了相当不错的的优化效果,而且也有很多可选的优化特性,如inline function、intrinsic function等。

尽管汇编语言不是开发的常用语言,不过它也有很多的应用场景,如系统最底层的开发、程序的反汇编调试等。不过本篇文章主要目的是用汇编语言对程序的运行速度进行优化。文中所用到的汇编语言为IA-32(NASM)。

优化要有针对性

首先要说明的是,汇编优化不是软件优化的唯一手段。软件的运行速度会受到很多方面的影响,比如说软件常常会在加载中耗费大量时间:加载模块、加载资源文件、加载数据库、加载底层框架(如C# framework),又比如说软件可能会由于网络状况不好而出现长时间的等待,这都是可以优化的方向。要对软件进行优化,首先需要找出软件的瓶颈,针对这个瓶颈采用相对应的优化方法。

这里讨论的汇编优化,是当瓶颈出现在CPU运算时采用的优化措施。在实际应用中,汇编优化经常会被用在音频与图像处理、加密、排序、数据压缩以及复杂的数学运算当中。上述的这些程序有一个特点,就是程序中的绝大部分都是在利用CPU进行运算,这种程序称为CPU-Intensive Program。

一般来说,CPU-Intensive Program处理的数据都是一连串连续的序列,也就是说会循环对数据进行处理。对这种程序来说,循环的内部是程序的主体,在循环内部耗费的时间会占到总运行时间的99%以上。因此为了避免在没必要优化的代码上浪费时间,我们进行优化之前需要对程序有个总体的了解,知道哪部分的代码是最常跑的、急需优化的(critical),哪部分的代码是次要的(less critical)。如果确实很难分辨程序的主次部分,可以在不同的代码加上计数器来区分。

指令周期 Instruction cycle

在讨论指令优化之前,我们首先需要了解一条指令在CPU中是如何被执行的。

一条指令的执行从开始到结束,我们称之为指令周期(Instruction cycle),又可以称为fetch-decode-execute cycle。指令周期可以细分为以下几个部分:

  1. 指令获取 Instruction fetch。
  2. 指令解码 Instruction decoding。
  3. 获取有效地址 Effective address reading。(Indirect memory addressing)
  4. 指令执行 Instruction execution。
  5. 指令休止 Instruction retirement。(Out-of-order)

除了指令解码之外,其余的部分主要由micro-operations(μops)组成,也就是传输、运算等基本操作(关于μops后面有较详细的解释)。我们这节的目的就是在了解指令周期的各部分的基础上,讨论各部分对指令运行效率的影响。

bigsec

其中涉及到的部件有以下几个:

  • Program counter(PC),用于存储下一条要执行的指令的地址。
  • Memory address register(MAR),用于存储内存地址,该内存地址会指示CPU从该内存读取或写入数据。
  • Memory data register(MDR),用于存储数据,可以是从内存读取进CPU的数据,也可以是从CPU写入内存的数据。
  • Instruction register(IR),用于存取执行的指令。

指令获取 Instruction fetch

指令获取,顾名思义,目的就是把指令从内存传输进CPU以便后续执行。 指令获取可以分解成以下几个μops:

  1. PC –> MAR 把下一条要执行的指令的地址传到MAR。
  2. Memory –> MDR 读命令从MAR指定的内存地址读取指令,传输到MDR。
  3. MDR –> IR 把MDR中的指令传输到IR。
  4. PC + 1 –> PC PC指向下一条指令。

一般来说,大多数CPU会在一个时钟周期内获取16字节的指令,并且获取方式是16字节对齐,因此对于某些重要的、规模较小的循环代码,还是有必要尽量把代码的大小压缩在16字节内并保证这部分代码16字节对齐。这涉及到指令的大小优化以及对齐,以后有机会再讨论。

在很多处理器中,跳转指令(jumps)会导致指令的获取延迟,因此在重要的代码区有必要减少跳转指令的数目。不过如果是条件跳转指令,但是的跳转的条件不满足,走非跳转分支的话,是不会导致指令的获取延迟的。因此,对于条件跳转指令,尽量使得指令多走不命中的分支(非跳转分支)。

指令解码 Instruction decoding

指令是二进制串,其中包括前缀、操作码、操作数等各种信息,为了弄清一段二进制串的具体意图,需要对其进行解码。解码过程我们关注的有两部分:一是前面讨论过的复杂指令转换成μ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节。

获取有效地址 Effective address reading

汇编语言的寻址方式有很多种,其中有一直叫做直接内存寻址(Direct Memory Addressing),如 mov ax, [addr] ,其中第二个操作数addr就是一个内存地址,这句指令的目的是把addr指向的内存中的数据传输到ax。与之相对应的是间接内存寻址(Indirect Memory Addressing),NASM并不支持这种寻址方式,其中addr是一个指针的所在地址,指令的目的就是把该指针所指向的内存中的数据传输到ax。

对于间接内存寻址,由于第二个操作数addr所指向的内存并非我们实际的所需数据,只是一个指针,为了得到所需数据的内存地址,就有了这一部分的工作。

获取有效地址可以分解成以下的μops:

  1. IR –> MAR 在对指令进行解码后,可以得知指令是否为间接内存寻址,如果是则可以把其中的内存地址传输到MAR。
  2. Memory –> MDR 读命令把MAR所指向的内存数据读取进MDR,也就是把指向所需数据的指针读入MDR。
  3. MDR –> IR 读取的指针就是所需数据的地址,即直接地址,这里用这个地址对原本的指令进行更新。

※不过似乎支持上述间接内存寻址这种模式的汇编语言比较少。

指令执行 Instruction Execution

这步指的是执行具体的指令,实现指令的目的。由于指令多种多样,我们这里就以 add ax, [mem] 为例,把它分解成μops:

  1. IR –> MAR 由于是内存寻址,因此需要从内存把所需数据(mem所指向的数据)读入CPU。
  2. Memory –> MDR 读命令把所需数据从内存读入MDR。
  3. MDR + ax –> ax 加法操作,结果存储到ax。

这一步的优化涉及的篇幅较多,我们会在下面散序处理用较多的篇幅展开讨论。

指令休止 Instruction retirement

只有采用散序处理的CPU才会出现这个步骤,散序处理在下一节有描述(最好先看一下)。我们后面在讨论散序处理的时候会说到在执行指令的最后会把指令运行的结果,按照指令原本的顺序写回到用户可见的寄存器/内存。

在P4处理器上,一个时钟周期内可以执行3次μop的Instruction retirement,现在的处理器可能可以执行更多的次数。虽然这已经非常高效,不过并不意味着Instruction retirement就不会称为指令执行的瓶颈,原因如下:

  1. 虽然程序是散序执行,不过用户所见的是Instruction retirement的执行结果,而Instruction retirement是顺序执行的。
  2. 如后面散序处理介绍时所述,指令在处理完成后会进入重排序队列ROB(re-order buffer),不过在P4处理器上ROB大小为40μops,因此如果是执行某些延时很长的指令,如div,那么在div期间,div之后的指令得不到移除,因此ROB有可能会满队列,影响到后面指令的执行。
  3. 条件JUMP指令跳转必须占用时钟周期的第一个μop,因此,如果有连续两个条件跳转的话,则会浪费时钟周期的第2、3个μop。

散序处理 Out of Order Execution

散序处理(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

上面的代码分别做了两项完全不相干的工作:

[mem2] = [mem1] * 6 [mem4] = [mem3] + 2

CPU散序处理的逻辑如下:

  1. 如果CPU在执行第一条指令 mov eax, [mem1] 时发现[mem1]不在cache内,则会到内存去获取[mem1],这会花费数个时钟周期。在这段时间内,CPU会去寻找下一条可以被处理处理的指令。
  2. 第二条指令 imul eax, 6 依赖第一条指令,而此时第一条指令还没执行完成,因此无法执行。
  3. 第三条指令 mov [mem2], eax 依赖第二条指令,也无法执行。
  4. 第四条指令 mov ebx, [mem3] 是独立于上述指令的因此可以执行。
  5. 如果[mem3]在cache内,第四条指令会比第一条之类早执行完成,此时可以开始执行第五条指令 add ebx, 2 。

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

Micro-operations(缩写为μops或uops)就是CPU最基本的一些操作,分为四类:

  • 数据传输,最常见的就是mov了。

  • 算术运算,如add,mul等。

  • 逻辑运算,如and,or等。

  • 移位,如shl等。 某些复杂的指令在处理时可以分成多个μ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结果之前就开始执行。

多处理单元 Execution unitis

现代CPU一般都会有多个处理单元使得Out of Order Execution可以更高效的运行。如大多数CPU都有两个以上的ALU(Arithmetic Logic Unit),因此在一个时钟周期内可以同时进行两项或者更多的整数运算;CPU通常会有一个浮点加法与一个浮点乘法处理单元,因此浮点数的乘法与浮点数的加法可以同时进行;CPU可能也会分别有一个内存读单元与内存写单元,因此内存的读与写可以同时进行;CPU也可以同时分别执行整数运算、浮点运算、读写内存等。各个处理单元相互独立,使得CPU可以在一个时钟周期之内同时处理多条指令。

流水线指令 Pipelined instructions

浮点数的运算相比整数运算需要更长的执行时间,一般都超过一个时钟周期,不过浮点数运算可以细分成更小的处理单位,各个单位组成流水线。例如:不用等前一条浮点加法指令执行完毕就可以开始执行下一条浮点加法指令。当然,不止有浮点数指令,还有其它的指令也可以进行流水线处理,不过不同的芯片、不同的指令的执行周期不同,指令的相关资料可以去芯片商的官网查找或者Agner Optimize的The microarchitecture of Intel, AMD and VIA CPUs以及Instruction tables。

指令的延时与吞吐量 Instruction latency and throughput

  • 延时(latency)是指一条指令从开始执行到执行结果就绪所花费的时间,以时钟周期为单位。执行一条依赖链(dependency chain)所花的时间是该依赖链内所有指令的延时的总和。
  • 吞吐量(throughput)是指在一个时钟周期之内,同一类指令所能执行的次数。由于CPU在指令的处理上采用了pipeline等各种优化方式,而pipeline的特点就是就算是多条相同的指令也可以同时执行,因此通常有latency < 1/throughput而非相等。

以Core2处理器为例,其浮点加法的latency为3个时钟周期,throughput为1。这意味着在一条依赖链内,处理器需要用3个时钟周期来执行浮点加法,然后才能去执行该依赖链内的下一条指令;对于不在这条依赖链内的指令,如果同样是是浮点加法指令,只需在1个时钟周期之后即可开始执行。

如下是一些指令的典型的延时与吞吐量表格,为了更好地对比,列出的是1/throughput,指的是一条指令在开始执行之后,间隔多久(平均值)才能开始执行另一条同类型并且不在同一依赖链的指令。

bigsec

各CPU更具体的latency与throughput可以去查看Agner Optimize的Manual 4: "Instruction tables"。