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

38

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

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

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

可以看到两者已经相当接近了。

但是我却在 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 倍。

我们都知道如果对一个有序数组进行搜索,我们可以二分查找算法获得更好的性能。不过二分查找和普通查找是两个截然不同的算法,因此性能有差距是正常的。但是这个测试用例不同,两者的算法完全一模一样,因为都是同一个函数。两者生成的二进制机器码也一样。为什么还有这么大的性能差距呢?

于是我以 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 分支的代码慢。

最后做个总结吧。

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


如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的

10 条评论
二狗蛋丶 · 7月11日

不明觉厉

+1 回复

769344359 · 7月13日

+1 回复

0

"...于是我以 fast array sorted 为关键字在 Google 搜索了一下,果然找到了 stackoverflow 的结果,问题和答案都获得了 2 万多赞,应该值得一看..."

justjavac 作者 · 7月13日
0

哦哦没有仔细阅读全文

769344359 · 7月13日
吃瓜群众 · 7月11日

不明觉厉,所以是什么意思呢?在有序的情况下,还是用if更快吗?用位运算可以更稳定,适合在无序的场景?

回复

飞龙 · 7月18日

如果是转Number再乘呢?

回复

0

什么意思?

justjavac 作者 · 7月19日
0

@justjavac data[i] * Number(data[i] >= 128),这个在具有整数的语言中是明显比if快的。

飞龙 · 7月20日
0

@飞龙 我的测试结果是,这个要慢一些

justjavac 作者 · 7月20日
载入中...