我要啦免费统计

为什么处理一个排序数组要比处理一个未排序的数组更快?

以前无意看到了stackoverflow上面的一个帖子

为什么这段代码在数据排序后的运行时间要比排序前快6倍?


如果没有这行std::sort(data, data + arraySize);, 代码一共运行 11.54 秒.

而如果数据先被排序,那么只需要运行 1.93 秒.

一开始我以为只是语言或者编译器的问题,所以我又用java试了一遍:

结果还是刚才那样,只不过差距缩小了一些。

之后我又想会不会是因为排序算法把数据存到了cache中,但是我马上意识到这种想法是多么愚蠢,因为数组也是刚刚才生成的。

到底是为什么呢?为什么排序好的数组运行的要比没有排过序的更快一些?这个程序只是把一些独立的项相加,数组顺序应该没关系啊?

之后的答案才是全文的亮点,具体原文我就不翻译了,感兴趣的同学可以看看Coolshell的《代码执行的效率》一文中的翻译。简单的说,就是当处理器执行到某一步时,有两种分支指令的可能,此时处理器就需要选择一条来继续执行。如果这时猜对了,那么就可以继续执行下面的语句,但是如果猜错了,就需要在发现错误后及时终止当前指令的执行,返回分叉处。SO站的答案还以火车岔路口的扳道工做类比来形象的说明这个问题。但是就在我们以为自己已经懂了的时候,稍微细想就会发现这种处理方式似乎与我们原有的对于处理器和程序的印象不太符合。任何一个程序经过编译不都是会形成汇编指令吗?处理器的处理过程不就是在机械的按步骤在执行那些指令吗?为什么会有这种所谓的“猜测”存在呢?这还要从编译器的工作方法说起。

汇编是如何实现循环的?

要想搞清楚计算机在运行上面那段代码时究竟做了什么事,首先需要在汇编的层面上看看处理器是怎么真正的执行这段程序的。

一下面一段C语言代码为例:(本段代码摘自CSAPP)

下面我们只来关注50-52行,其他代码是为了和CSAPP保持统一并且能运行而写的:

我们用gcc进行编译,gdb调试显示汇编代码:

之后再对照监视变量:
disassembly

通过%ebp(栈帧寄存器)以及偏移量就可以知道这个局部栈的构造:

还有一点需要注意:因为栈是从高地址向低地址实现分配的,但是系统写数据时仍然是从低地址向高地址写,所以要想访问栈顶元素($ebp)需要将指针指向$ebp-4(即28fef4),而不是28fef8

接下来我们就看看这段汇编代码是怎么样实现循环和循环边界的判断的:

通过分析可以看到,汇编语言基本是在C语言的基础上加了中间的寄存器操作来实现赋值以及比较。这时,原本连贯的一句赋值语句 a = b + c 会被分解为两条指令,分别是 register = b + c 和 a = register。而循环边界的判断也被分解成若干条语句,其中循环的实现是由单独的一条指令来完成的。也就是说,如果当处理器单独执行最后一条语句时,处理器并没有可参照的数据,从而不知是否应该执行。

但是这毕竟一段完整的程序,虽然零散但是严密,如果处理器却是严格按照流程来执行,也不会发生任何歧义的解释。那么又为什么会出现处理器“猜测”的情况出现呢?

指令流水线(Instruction Pipeline)

通过上文我们已经知道,当程序被编译为汇编程序后,整段程序会被拆分得很零散,并且被拆分成了几种基本的指令:赋值,累加,乘法,比较,跳转。而处理器也只能一次执行一条指令。但是再去深入观察处理器执行的流程时,我们会发现执行每条汇编级别的指令都被拆分成了更基本的步骤,包括从内存中读指令到寄存器,对指令进行解析,执行指令,将结果写入寄存器,写入内存等。而这时,处理器并非只能处理其中的一个步骤,而是能够同时处理不同种类的一个步骤。也就是说,处理器可以同时对某条指令进行解析,也可以同时对将某个运算结果写入寄存器,因为这些步骤在电路级别是相互独立互不影响的。

由于处理器的这种独特性质,所以一个加速处理器处理的方法就诞生了。既然处理器在一个时钟周期内能够最多同时处理多种操作,那么就应该尽可能的让处理器多同时处理才行。但是由于每条指令的顺序是固定并且有依赖关系的(只有先拿到指令后才能译码,只有读懂指令后才能执行),所以我们在一开始(第一个时钟周期)只能让处理器取第一条指令。但是当处理器进行到第二步时,也就是处理器对第一条指令进行译码时,我们就可以让处理器同时取出第二条指令,因为取指令和译码是两个不同的阶段,彼此独立。以此类推,每当我们的第一条指令执行到第n步时,第二条指令也已经执行到了第n-1步,第三条指令执行到了n-2步……这样就很大程度上提高了处理器运行程序的效率。这种机制就叫做指令流水线(Instruction Pipeline)。

指令流水线
虽然指令流水线确实提高了处理器的处理速度,但是也存在着很严重的问题。因为有些指令不是独立的,而是依赖于上一条指令的执行情况。比如在执行从一个内存地址取出数据进行加法运算时,也许上一条指令已经改变了这个数据的值,但是还没有来的及写入内存,而这句就已经从内存地址取出了一个旧的值,这样就会造成运算错误。而在本例中也一样,因为在最后一条指令执行时,需要先判断两个数的大小才能决定是否跳转,但是由于上一条比较指令的结果还没有没保存,所以跳转指令就面临两难境地。

虽然这个问题是很严重的,它似乎都影响到了计算的准确性,但是因此而把指令流水线机制放弃掉也有因噎废食之嫌。于是计算机做出一个保证准确性的前提之下的尽量保持高效率的解决方案:一旦发现某个参与提前计算的值在之后被修改,那么就立即停止那个提前计算的流水线以及其之后的流水线,废弃掉所有的已经计算出来的结果,重新计算。这样一来,虽然似乎效率又变的和没有使用流水线一样了,但是至少保住了计算的精确性。

而对于跳转呢?跳转也是同一个道理。当某流水线执行到跳转指令而是否跳转还未确定时,通常处理器都会根据某个算法来计算是否要冒险跳转。而这种算法虽然很多,但是大多也都基于之前的跳转结果而来。一旦发现跳转错误,就立即终止跳转之后的所有流水线流程,而这样对效率的损失也是显而易见的。[1]

为什么处理一个排序数组要比处理一个未排序数组更快?

这样,我们也就清楚在排序时到底发生了什么事了。这篇文章一开始的程序是要判断某个数是否大于128,如果大于那么就累加,否则跳过。而在处理器执行跳转语句时,依据便是data[i]与128的大小比较。当这组数据大小随意排列时,显然处理器要“猜测”(其实是确定的算法)错误很多次,因为理想状态下只有0.5的机会猜对。而当这组数据已经排好序时,一开始有可能处理器会猜错,但是当处理器发现之前的几次的跳转结果都相同时,算法会倾向于继续猜测这种状况,所以才对的几率会大很多。只有当遍历的数据超过128时,处理器才会再度猜错,不过这也使一时的。如果处理器采用的是“按照上一次的结果来猜测”的算法的话,也只是有一次流水线会被停掉。所以整段代码几乎是全部按多调流水线执行下来的,当然会快很多。而在上面的问题中,这种效率差距竟然达到了6倍之多。

Note

  1. 其实近代的IA32处理器为了避免跳转错误带来的成本代价,增添了比较跳转指令cmov(即conditional move)。这条指令先执行两个分支的代码并储存结果,之后再判断条件的成立情况直接读取先前计算出的结果(这个过程中并没有跳转指令参与)。虽然多执行了很多代码(连另一种情况也计算了),但是总体上看仍然要比盲目跳转遇到损失的代价要小。

Reference

  1. Computer Systems: A Programmer's Perspective
  2. Branch_predictor from Wikipedia
  3. Instruction_pipeline from Wikipedia
  4. 《代码执行的效率》 from Coolshell

Post Footer automatically generated by wp-posturl plugin for wordpress.

Leave a Reply

Your email address will not be published. Required fields are marked *