分支预测:为何有序数组比无序数组快?

最近几天在搜集一些关于 JavaScript 函数式编程的性能测试用例,还有内存占用状况分析。算法

我在一年前(2017年1月) 曾写过一篇文章《JavaScript 函数式编程存在性能问题么?》,在文中我对数组高阶函数以及 for-loop 进行了基准测试,获得的结果是 map`reduce` 这些函数比原生的 for-loop 大概有 20 倍的性能差距。编程

不过一年半过去了,V8 引擎也有了很大的改进,其中就包括了对数组高阶函数的性能改进,并取得了很好的效果。数组

能够看到二者已经至关接近了。jsp

可是我却在 jsperf 发现了一个颇有意思的测试用例: https://jsperf.com/sorted-loop/2函数式编程

// 对整形数组 data 进行累加求和
function perf(data) {
    var sum = 0;
    for (var i = 0; i < len; i++) {
        if (data[i] >= 128) {
            sum += data[i];
        }
    }
    return sum;
}
复制代码

两个 test case 都使用这个函数,惟一不一样的是数组(参数):一个是有序的,另外一个是无序的。结果二者的性能差了 4 倍。函数

咱们都知道若是对一个有序数组进行搜索,咱们能够二分查找算法得到更好的性能。不过二分查找和普通查找是两个大相径庭的算法,所以性能有差距是正常的。可是这个测试用例不一样,二者的算法彻底如出一辙,由于都是同一个函数。二者生成的二进制机器码也同样。为何还有这么大的性能差距呢?oop

因而我以 fast array sorted 为关键字在 Google 搜索了一下,果真找到了 stackoverflow 的结果,问题和答案都得到了 2 万多赞,应该值得一看。虽然原文使用 C++ 和 Java 写的,可是应该有共通性。性能

原来二者的代码虽然如出一辙,可是当 CPU 执行时却不同,缘由就在于 CPU 的一个优化特性:Branch Prediction(分支预测)。测试

为了便于理解,答者用了一个比喻:优化

考虑一个铁轨的分叉路口:

(图片做者 Mecanismo,来源 Wikimedia,受权许可 CC-By-SA 3.0)

假设咱们是在 19 世纪,而你负责为火车选择一个方向,那时连电话和手机尚未普及,当火车开来时,你不知道火车往哪一个方向开。因而你的作法(算法)是:叫停火车,此时火车停下来,你去问司机,而后你肯定了火车往哪一个方向开,并把铁轨扳到了对应的轨道。

还有一个须要注意的地方是,火车的惯性是很是大的,因此司机必须在很远的地方就开始减速。当你把铁轨扳正确方向后,火车从启动到加速又要通过很长的时间。

那么是否有更好的方式能够减小火车的等待时间呢?

有一个很是简单的方式,你提早把轨道扳到某一个方向。那么到底要扳到哪一个方向呢,你使用的手段是——“瞎蒙”:

  • 若是蒙对了,火车直接经过,耗时为 0。
  • 若是蒙错了,火车中止,而后倒回去,你将铁轨扳至反方向,火车从新启动,加速,行驶。

若是你很幸运,每次都蒙对了,火车将从不停车,一直前行!(你能够去买彩票了)

若是不幸你蒙错了,那么将浪费很长的时间。

那如今咱们思考一个 if 语句。if 语句会产生一个“分支”,相似前面的铁轨:

有不少人以为,CPU 怎么会像火车同样呢?CPU 也须要减速和后退吗?难道不是遇到中断就直接跳转了吗?

现代化的 CPU 芯片是很是复杂的,为了提高性能大部分芯片使用了指令流水线(instruction pipeline)技术,一般有几个主要步骤:

读取指令(IF) -> 解码(ID) -> 执行(EX) -> 存储器访问(MEM) -> 写回寄存器(WB)
复制代码

这样就大大提高了指令的经过速度(单位时间内被运行的指令数量)。当第一条指令执行完成后,第二条指令已经完成解码了,而且能够当即执行。

那么 CPU 如何作分支预测呢?一个最简单的方式就是根据历史。若是过去 99% 的次数都是在某个分支执行,那么 CPU 就会猜想:下一次可能还会在此分支执行,所以能够提早将这个分支的代码装载到流水线上。若是预测失败,则须要清空流水线并从新加载,可能会损失 20 个左右的时钟周期时间。

若是数组是按某个顺序排列的,那么 CPU 的预测会很是准确,就像咱们前面的代码,data[i] >= 128,不论数组是升序的仍是降序的,在 128 这个分隔点以前和以后,CPU 的分支预测都能获得正确的结果。若是数组是乱序的,那么 CPU 流水线将会不停的预测失败并从新加载指令。

那么咱们若是已经知道了咱们的数组是乱序的,并有很大可能使分支预测失败,那么能不能进行代码优化,避免 CPU 的分支预测?

答案是确定的。咱们能够把分支语句去掉,这样 CPU 就能够直接在指令流水线上装载指令,而无需依赖分支预测功能。在此使用一个位运算的技巧。咱们观察以前的代码:

if (data[i] >= 128) {
    sum += data[i];
}
复制代码

把全部大于 128 的数累加。

由于位运算只对 32 位的整数有效,所以咱们可使用右移来判断一个数。

对于有符号数:

  • 非负数右移 31 位为必定为 0
  • 负数右移 31 位为必定为 -1,也就是 0xffff

由于 -1 的二进制表示是全部位都是 1,既:

0b1111_1111_1111_......_1111_1111_1111
// 32个
复制代码

所以,-1 与任何数进行运算其值不变。

-1 & x === x
复制代码

0-1 正好相反,32 位所有为 0:

0b0000_0000_0000_......_0000_0000_0000
// 32个
复制代码

能够看到,对应数字 0-1,每一个 bit 位都是相反的,因而咱们能够按位取反

~ -1 === 0
~ 0 === -1
复制代码

如此一来咱们能够分析前面的代码,“若是大于 128 则累加”,咱们拆解一下:

  • 咱们把这个数减去 128,那么只有 2 种结果:正数(0)和负数
  • 右移 31 位,获得 0-1

咱们须要把全部的结果为 0(大于128) 的值相加:

  • 按位取反,把大于 128 的数变为 -1,小于 128 的变为 0
  • 与原数进行与运算

代码为:

const t = (data[i] - 128) >> 31
sum += ~t & data[i];
复制代码

这样就能够避免分支预测失败的状况。性能测试:

能够看到二者有几乎相同的性能,并且性能明显高于以前使用 if 分支的乱序数组。可是咱们也看到了二者的性能和有序数组的 if 分支代码相比,性能要差了很多,是否是由于位运算没有使用分支预测,于是比有序数组的分支预测代码性能要差一些呢?并非。

即便有序数组的分支预测成功率很是高,可是在经历 128 这个分支临界点时,CPU 依然会预测失败,并损失很长的时钟周期时间。除非数组里面全部的数组都是大于 128 或者都是小于 128 的。而使用位运算则彻底不须要 CPU 停顿。

位运算比 if 分支要慢,这也和不少开发者的心理预期不同,不少人以为位运算理所应当是最快的。其实我很早以前就写过一篇文章:

上面代码之因此位运算比 if 分支要慢,是由于位运算实现这个功能比较繁琐,生成的二进制机器码也比较长,所以须要更长得指令周期才能执行完,所以要比 if 分支的代码慢。

最后作个总结吧。

位运算因为消除了分支,所以性能更加稳定,可是可读性也更差。甚至有人说:“全部在业务代码里面使用位运算的行为都是装逼”、“代码是写给人看的,位运算是写给机器看的”。

相关文章
相关标签/搜索