# YDKJS-性能测试与调优
如果被问到如何测试某个运算的速度(执行时间),绝大多数 JavaScript 开发者都会从类似下面的代码开始:
var start = new Date().getTime() // 或者 Date.now()
// 进行一些操作
var end = new Date().getTime()
console.log('Duration:', end - start)
这种测量方式首先报告的执行时间并不准确,早期的 IE 精度只有 15ms,这就意味着这个运算的运行时间至少需要这么长才不会被报告为 0。而且就算可以拿到时间,但是这个时间也并不总是可信的,因为它很可能受制于执行环境。
那么,能从中知道的是什么呢?很遗憾,我们几乎一无所知。
# 重复
用一个循环把它包起来,这样整个测试的运行时间就会更长一些了。如果重复一个运算 100 次,然后整个循环报告共消耗了 137ms,那你就可以把它除以 100,得到每次运算的平均用时为 1.37ms,是这样吗?
并不完全是这样,简单的数学平均值绝对不足以对你要外推到整个应用范围的性能作出判断。迭代中即使只有几个(过高或过低的)的异常值也可以影响整个平均值,然后在重复应用这个结论的时候,你还会扩散这个误差,产生更大的欺骗性。
你也可以不以固定次数执行运算,转而循环运行测试,直到达到某个固定的时间。这可能会更可靠一些,但如何确定要执行多长时间呢?
重复执行的时间长度应该根据使用的定时器的精度而定,专门用来最小化不精确性。定时器的精度越低,你需要运行的时间就越长,这样才能确保错误率最小化。
不仅仅是时间问题,确保把异常因素排除,你需要大量的样本来平均化。你还会想要知道最差样本有多慢,最好的样本有多快,以及最好和最差情况之间的偏离度有多大,等等。
你需要知道的不仅仅是一个告诉你某个东西跑得有多快的数字,还需要得到某个可以计量的测量值告诉你这个数字的可信度有多高。
还有,你可能会想要把不同的技术(以及其他方面)组合起来,以得到所有可能方法的最佳平衡。
# Benchmark.js
任何有意义且可靠的性能测试都应该基于统计学上合理的实践。
如果对于标准差、方差、误差幅度这样的术语还不熟悉的话,实际上就还不够资格编写自己的性能测试逻辑。
幸运的是,像 John-David Dalton 和 Mathias Bynens 这样的聪明人了解这些概念,并编写了一个统计学上有效的性能测试工具,名为 Benchmark.js(http://benchmarkjs.com/ (opens new window))。
下面介绍应该如何使用 Benchmark.js 来运行一个快速的性能测试:
function foo() {
// 要测试的运算
}
var bench = new Benchmark(
'foo test', // 测试名称
foo, // 要测试的函数(也即内容)
{
// .. // 可选的额外选项(参见文档)
},
)
bench.hz // 每秒运算数
bench.stats.moe // 出错边界
bench.stats.variance // 样本方差
// ..
除了这里我们介绍的一点内容,关于 Benchmark.js 的使用还有很多要学的。但是,关键在于它处理了为给定的一段 JavaScript 代码建立公平、可靠、有效的性能测试的所有复杂性。
# 环境为王
对特定的性能测试来说,不要忘了检查测试环境,特别是比较任务 X 和 Y 这样的比对测试。仅仅因为你的测试显示 X 比 Y 快,并不能说明结论 X 比 Y 快就有实际的意义。
举例来说,假定你的性能测试表明 X 运算每秒可以运行 10,000,000 次,而 Y 每秒运行 8,000,000 次。你可以说 Y 比 X 慢了 20%。数学上这是正确的,但这个断言并不像你想象的那么有意义。
每秒 10,000,000 次运算就是每毫秒 10,000 次运算,每微妙 10 次。换句话说,单次运算需要 0.1μs,也就是 100ns。很难理解 100ns 到底有多么短。
作为对比,据说人类的眼睛通常无法分辨 100ms 以下的事件,这要比 X 运算速度的 100ns 慢一百万倍了。
即使最近的科学研究表明可能大脑可以处理的最快速度是 13ms(大约是以前结论的 8 倍),这意味着 X 的运算速度仍然是人类大脑捕获一个独立的事件发生速度的 125,000 倍。X 真的非常非常快。
不过更重要的是,我们来讨论一下 X 和 Y 的区别,即每秒 2,000,000 次运算差距的区别。如果 X 需要 100ns,而 Y 需要 80ns,那么差别就是 20ns,这在最好情况下也只是人类大脑所能感知到的最小间隙的 65 万分之一。
我要说的是什么呢?这些性能差别无所谓,完全无所谓!
但是稍等,如果这些运算将要连续运行很多次呢?那么这个差别就会累加起来,对不对?好吧,那我们要问的就是,这个运算 X 要一个接一个地反复运行多次的可能性有多大呢,得运行 650,000 次才能有一点希望让人类感知到。更可能的情况是,它得在一个紧密循环里运行 5,000,000~10,000,000 次才有意义。
你脑子里的计算机科学家可能抗议说,这是可能的;但你脑子里那个现实的的你会更大声说还是应该检查一下这个可能性到底有多大。即使在很少见的情况下是有意义的,但在绝大数情况下它却是无关紧要的。
# 引擎优化
你无法可靠地推断,如果在你的独立测试中 X 比 Y 要快上 10μs,就意味着 X 总是比 Y 要快,就应该总是使用 X。性能并不是这样发挥效力的。它要比这复杂得多。
var twelve = '12'
var foo = 'foo'
// 测试1
var X1 = parseInt(twelve)
var X2 = parseInt(foo)
// 测试2
var Y1 = Number(twelve)
var Y2 = Number(foo)
如果理解与 Number(..)
相比 parseInt(..)
做了些什么,你可能会凭直觉以为后者做的工作可能更多,特别是在 foo
用例下。或者你可能会直觉认为它们的工作量在 foo
用例下应该相同,两个都应该能够在第一个字符 f
处停止。
哪种直觉是正确的呢?在这个例子中,哪个判断正确并不重要。主要是测试结果可能是什么呢?
让我们假装测试结果返回的是从统计上来说完全相同的 X 和 Y。那么你能够确定你关于 f
字符的直觉判断是否正确吗?不能。
在我们假设的情况下,引擎可能会识别出变量 twelve
和 foo
在每个测试中只被使用了一次,因此它可能会决定把这些值在线化。那么它就能识别出 Number( "12" )
可以直接替换为 12。对于 parseInt(..)
,它可能会得出同样的结论,也可能不会。
也有可能引擎的死代码启发式去除算法可能会参与进来,它可能意识到变量 X 和 Y 并没有被使用,因此将其标识为无关紧要的,故而在整个测试中实际上什么事情都没有做。
所有这些都只是根据单个测试所做的假设的思路。现代引擎要比我们凭直觉进行的推导复杂得多。它们会实现各种技巧,比如跟踪记录代码在一小段时期内或针对特别有限的输入集的行为。
如果引擎由于固定输入进行了某种优化,而在真实程序中的输入更加多样化,对优化决策影响很大(甚至完全没有)呢?或者如果引擎看到测试由性能工具运行了数万次而进行优化,但是在真实程序中只会运行数百次,而这种情况下引擎认为完全不值得优化呢?
我们设想的所有这些优化可能性在受限的测试中都有可能发生,而且在更复杂的程序中(出于各种各样的原因),引擎可能不会进行这样的优化。也可能恰恰相反,引擎可能不会优化这样无关紧要的代码,但是在系统已经在运行更复杂的程序时可能会倾向于激进的优化。
这里我要说明的就是,你真的不能精确知道底下到底发生了什么。你能进行的所有猜想和假设对于这样的决策不会有任何实际的影响。
这是不是意味着无法真正进行任何有用的测试呢?绝对不是。
这可以归结为一点,测试不真实的代码只能得出不真实的结论。如果有实际可能的话,你应该测试实际的而非无关紧要的代码,测试条件与你期望的真实情况越接近越好。只有这样得出的结果才有可能接近事实。
像 ++x
对比 x++
这样的微观性能测试结果为虚假的可能性相当高,可能我们最好就假定它们是假的。
# jsPerf.com
尽管在所有的 JavaScript 运行环境下,Benchmark.js 都可用于测试代码的性能,但有一点一定要强调,如果你想要得到可靠的测试结论的话,就需要在很多不同的环境(桌面浏览器、移动设备,等等)中测试汇集测试结果。
比如,针对同样的测试高端桌面机器的性能很可能和智能手机上 Chrome 移动设备完全不同。而电量充足的智能手机上的结果可能也和同一个智能手机但电量只有 2% 时完全不同,因为这时候设备将会开始关闭无线模块和处理器。
如果想要在不止一个环境下得出像“X 比 Y 快”这样的有意义的结论成立,那你需要在尽可能多的真实环境下进行实际测试。仅仅因为在 Chrome 上某个 X 运算比 Y 快并不意味着这在所有的浏览器中都成立。当然你可能还想要交叉引用多个浏览器上的测试运行结果,并有用户的图形展示。
有一个很棒的网站正是因这样的需求而诞生的,名为 jsPerf(http://jsperf.com (opens new window))。它使用我们前面介绍的 Benchmark.js 库来运行统计上精确可靠的测试,并把测试结果放在一个公开可得的 URL 上,你可以把这个 URL 转发给别人。
每次测试运行的时候,测试结果就会被收集并持久化,累积的测试结果会被图形化,并展示到一个页面上以供查看。
# 完整性检查
出于本章之前列出的多种原因,公开发布的测试中有大量是有缺陷或无意义的。
// 用例1
var x = []
for (var i = 0; i < 10; i++) {
x[i] = 'x'
}
// 用例2
var x = []
for (var i = 0; i < 10; i++) {
x[x.length] = 'x'
}
// 用例3
var x = []
for (var i = 0; i < 10; i++) {
x.push('x')
}
在上面的示例中:
- 对开发者来说,极常见的情况是:把自己的循环放入测试用例,却忘了 Benchmark.js 已经实现了你所需的全部重复。非常有可能这些情况下的
for
循环完全是不必要的噪音。 - 每个测试用例中
x
的声明和初始化可能是不必要的。 - 目的是否是找出
x.length
或x.push(..)
对向数组x
添加内容的操作的性能的影响有多大?好吧,这可能是有效的测试目标。但话说回来,push(..)
是一个函数调用,所以它当然要比[..]
访问慢。
以下是另一个例子,展示了典型的不同类型对比的缺陷:
// 用例1
var x = ['John', 'Albert', 'Sue', 'Frank', 'Bob']
x.sort()
// 用例2
var x = ['John', 'Albert', 'Sue', 'Frank', 'Bob']
x.sort(function mySort(a, b) {
if (a < b) return -1
if (a > b) return 1
return 0
})
这里,很明显测试目标是找出自定义的比较函数 mySort(..)
比内建默认比较函数慢多少。
但是,通过把函数 mySort(..)
指定为在线函数表达式,你已经创建了一个不公平/虚假的测试。这里,第二个用例中测试的不只是用户自定义 JavaScript 函数,它还在每个迭代中创建了一个新的函数表达式。
// 用例1
var x = [12, -14, 0, 3, 18, 0, 2.9]
x.sort()
// 用例2
var x = [12, -14, 0, 3, 18, 0, 2.9]
x.sort(function mySort(a, b) {
return a - b
})
这里的 mySort(..)
可以工作。因为你给它提供的是数字,但如果是字符串的话就会失败。第一个用例不会抛出错误,因为内建的比较函数 sort(..)
实际上做了 mySort()
没有做的额外工作,包括内建的那个把比较值强制类型转化为字符串并进行字典序比较。
所以,这个测试是不公平的,因为对于不同的用例,它并没有做完全相同的事情。你得到的任何结果都是虚假的。
// 用例1
var x = false
var y = x ? 1 : 2
// 用例2
var x
var y = x ? 1 : 2
这里有一个更不易擦觉的陷阱,在第一个用例中设定了 x
的值,而在另一个中则没有设定,所以实际上你在第一个用例中做了在第二个用例中没有做的事。
# 写好测试
要写好测试,需要认真分析和思考两个测试用例之间有什么区别,以及这些区别是有意还 是无意的。
有意的区别当然是正常的,没有问题,可我们太容易造成会扭曲结果的无意的区别。你需要非常小心才能避免这样的扭曲。
还有,你可能有意造成某个区别,但是,对于这个试的其他人来说,你的这个意图可能不是那么明显,所以他们可能会错误地怀疑(或信任!)你的测试。如何解决这样的问题呢?
编写更好更清晰的测试。但还有,花一些时间来编写文档(使用 jsPerf.com 上的 Description 字段和 / 或代码注释)精确表达你的测试目的,甚至对于那些微小的细节也要如此。找出那些有意的区别,这会帮助别人和未来的你更好地识别出那些可能扭曲测试结果的无意区别。
通过在页面或测试 setup
设置中预先声明把与测试无关的事情独立出来,使它们移出测试计时的部分。
不要试图窄化到真实代码的微小片段,以及脱离上下文而只测量这一小部分的性能,因为包含更大(仍然有意义的)上下文时功能测试和性能测试才会更好。这些测试可能也会运行得慢一点,这意味着环境中发现的任何差异都更有意义。
# 微性能
到目前为止,我们一直在围绕各种微性能问题讨论,并始终认为沉迷于此是不可取的。
在考虑对代码进行性能测试时,你应该习惯的第一件事情就是你所写的代码并不总是引擎真正运行的代码。
有时候编译器可能会决定执行与你所写的不同的代码,不只是顺序不同,实际内容也会不同。
function factorial(n) {
if (n < 2) return 1
return n * factorial(n - 1)
}
factorial(5) // 120
有些引擎会进行名为递归展开的动作,在这里,它能够意识到你表达的递归其实可以用循环更简单地实现(即优化)。JavaScript 引擎有可能会把前面的代码重写如下来运行:
function factorial(n) {
if (n < 2) return 1
var res = 1
for (var i = n; i > 1; i--) {
res *= i
}
return res
}
factorial(5) // 120
有趣的事,同样的代码用 C 编写并用高级优化编译的结果是,编译器意识到调用 factorial(5)
可以直接用常量值 120 来代替,完全消除了函数的调用。
对现代 JavaScript 来说,这一类执迷基本上毫无意义。这就属于你应该让引擎来关心的那一类问题。你应该编写意义最明确的代码。比较下面的三个 for
循环:
// 选择1
for (var i = 0; i < 10; i++) {
console.log(i)
}
// 选择2
for (var i = 0; i < 10; ++i) {
console.log(i)
}
// 选择3
for (var i = -1; ++i < 10; ) {
console.log(i)
}
即使你认为理论上第二个或第三个选择要比第一个选择性能高那么一点点,这也是值得怀疑的。第三个循环更令人迷惑,因为使用了 ++i
先递增运算,你就不得不把 i
从 -1 开始计算。而第一个和第二个选择之间的区别实际上完全无关紧要。
完全有可能一个 JavaScript 引擎看到了一个使用 i++
的位置,并意识到它可能将其安全地替换为等价的 ++i
,这意味着你花费在决定采用哪一种方案上的时间完全被浪费了,而且产出还毫无意义。
var x = [ .. ];
// 选择1
for (var i=0; i < x.length; i++) {
// ..
}
// 选择2
for (var i=0, len = x.length; i < len; i++) {
// ..
}
理论上说,这里应该在变量 len
中缓存 x
数组的长度,因为表面上它不会改变,来避免在每个循环迭代中计算 x.length
的代价。
实际上,在某些像 v8 这样的引擎中,可以看到 http://mrale.ph/blog/2014/12/24/arraylength-caching.html
,预先缓存长度而不是让引擎为你做这件事情,会使性能稍微下降一点。
# 不是所有的引擎都类似
各种浏览器中的不同 JavaScript 引擎可以都是“符合规范的”,但其处理代码的方法却完全不同。JavaScript 规范并没有任何性能相关的要求,除了 ES6 的“尾调用优化”。
引擎可以自由决定一个运算是否需要优化,可能进行权衡,替换掉运算次要性能。对一个运算来说,很难找到一种方法使其在所有浏览器中都运行得较快。
在一些 JavaScript 开发社区有一场运动,目的是要分析 v8 JavaScript 引擎的特定内部实现细节,决定编写裁剪过的 JavaScript 代 码来最大程度地利用 v8 的工作模式。
如下是 v8 的一些经常提到的例子(https://github.com/petkaantonov/bluebird/wiki/Optimizationkillers (opens new window))。
- 不要从一个函数到另外一个函数传递
arguments
变量,因为这样的泄漏会降低函数实现速度。 - 把
try..catch
分离到单独的函数里。浏览器对任何有try..catch
的函数实行优化都有一些困难,所以把这部分移到独立的函数中意味着你控制了反优化的害处,并让其包含的代码可以优化。
不过,确实要编写只需在一个 JavaScript 引擎上运行的代码吗?如果以后采用的并非 v8 引擎,或则 v8 决定在某些方面修改其运算的工作方式,过去运行很快的方式现在很慢,或者相反,那又该怎么办?
过去把多个字符串值放在一个数组中,然后在数组上调用 join("")
来连接这些值比直接用 +
连接这些值要快。但随着时间的发展,JavaScript 引擎改变了内部管理字符串的方法,特别对 +
连接进行了优化。
一旦新的处理字符串和连接的方法确定下来,很遗憾,所有那些使用数组 join(..)
来连接字符串的代码就成次优的了。
# 大局
我们应该关注优化的大局,而不是担心这些微观性能的细微差别。
怎么知道什么是大局呢?首先要了解你的代码是否运行在关键路径上。如果不在关键路径上,你的优化就很可能得不到很大的收益。
如果你的代码在关键路径上,比如是一段将要反复运行多次的“热”代码,或者在用户会注意到的 UX 关键位置上,如动画循环或 CSS 风格更新,那你就不应该吝惜精力去采用有意义的、可测量的有效优化。
请记住,沉迷于 +x
与 x | 0
的对比在绝大多数情况下都是浪费时间。这是一个微观性能问题,是一个你不应该让其影响程序可读性的问题。
尽管程序关键路径上的性能非常重要,但这并不是唯一要考虑的因素。在性能方面大体相似的几个选择中,可读性应该是另外一个重要的考量因素。
# 尾调用优化
简单地说,尾调用就是一个出现在另一个函数“结尾”处的函数调用。这个调用结束后就没有其余事情要做了(除了可能要返回结果值)。
function foo(x) {
return x
}
function bar(y) {
return foo(y + 1) // 尾调用
}
function baz() {
return 1 + bar(40) // 非尾调用
}
baz() // 42
foo(y+1)
是 bar(..)
中的尾调用,因为在 foo(..)
完成后,bar(..)
也完成了,并且只需要返回 foo(..)
调用的结果。然而,bar(40)
不是尾调用,因为在它完成后,它的结果需要加上 1 才能由 baz()
返回。
不详细谈那么多本质细节的话,调用一个新的函数需要额外的一块预留内存来管理调用栈,称为栈帧。所以前面的代码一般会同时需要为每个 baz()
、bar(..)
和 foo(..)
保留一个栈帧。
然而,如果支持 TCO 的引擎能够意识到 foo(y+1)
调用位于尾部,这意味着 bar(..)
基本上已经完成了,那么在调用 foo(..)
时,它就不需要创建一个新的栈帧,而是可以重用已有的 bar(..)
的栈帧。这样不仅速度更快,也更节省内存。
在简单的代码片段中,这类优化算不了什么,但是在处理递归时,这就解决了大问题,特别是如果递归可能会导致成百上千个栈帧的时候。有了 TCO,引擎可以用同一个栈帧执行所有这类调用
function factorial(n) {
function fact(n, res) {
if (n < 2) return res
return fact(n - 1, n * res)
}
return fact(n, 1)
}
factorial(5) // 120
这个版本的 factorial(..)
仍然是递归的,但它也是可以 TCO 优化的,因为内部的两次 fact(..)
调用的位置都在结尾处。