最近几天在搜集一些关于 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
分支的代码慢。
最后做个总结吧。
位运算由于消除了分支,因此性能更加稳定,但是可读性也更差。甚至有人说:“所有在业务代码里面使用位运算的行为都是装逼”、“代码是写给人看的,位运算是写给机器看的”。