第六章: 基准分析与调优
如何最准确和最可靠地测试 JS 性能?
基准分析(Benchmarking)
好了,是时候开始消除一些误解了。我敢打赌,广大的 JS 开发者们,如果被问到如何测量一个特定操作的速度(执行时间),将会一头扎进这样的东西:
var start = new Date().getTime(); // 或者`Date.now()`
// 做一些操作
var end = new Date().getTime();
console.log("Duration:", end - start);
复制代码
这种测量到底告诉了你什么?对于当前的操作的执行时间来说,理解它告诉了你什么和没告诉你什么是学习如何正确测量 JavaScript 的性能的关键。
如果持续的时间报告为0
,你也许会试图认为它花的时间少于 1 毫秒。但是这不是非常准确。一些平台不能精确到毫秒,反而是在更大的时间单位上更新计时器。举个例子,老版本的 windows(IE 也是如此)只有 15 毫秒的精确度,这意味着要得到与0
不同的报告,操作就必须至少要花这么长时间!
要是持续的时间报告为4
呢?你确信它花了大概 4 毫秒?不,它可能没花那么长时间,而且在取得start
或end
时间戳时会有一些其他的延迟。
更麻烦的是,你也不知道这个操作测试所在的环境是不是过于优化了。这样的情况是有可能的:JS 引擎找到了一个办法来优化你的测试用例,但是在更真实的程序中这样的优化将会被稀释或者根本不可能,如此这个操作将会比你测试时运行的慢。
那么...我们知道什么?不幸的是,在这种状态下,我们几乎什么都不知道。 可信度如此低的东西甚至不够你建立自己的判断。你的“基准分析”基本没用。更糟的是,它隐含的这种不成立的可信度很危险,不仅是对你,而且对其他人也一样:认为导致这些结果的条件不重要。
重复
“好的,”你说,“在它周围放一个循环,让整个测试需要的时间长一些。”如果你重复一个操作 100 次,而整个循环在报告上说总共花了 137ms,那么你可以除以 100 并得到每次操作平均持续时间 1.37ms,对吧?
其实,不确切。
对于你打算在你的整个应用程序范围内推广的操作的性能,仅靠一个直白的数据上的平均做出判断绝对是不够的。在一百次迭代中,即使是几个极端值(或高或低)就可以歪曲平均值,而后当你反复实施这个结论时,你就更进一步扩大了这种歪曲。
与仅仅运行固定次数的迭代不同,你可以选择将测试的循环运行一个特定长的时间。那可能更可靠,但是你如何决定运行多长时间?你可能会猜它应该是你的操作运行一次所需时间的倍数。错。
实际上,循环持续的时间应当基于你使用的计时器的精度,具体地将不精确的 ·可能性最小化。你的计时器精度越低,你就需要运行更长时间来确保你将错误的概率最小化了。一个 15ms 的计时器对于精确的基准分析来说太差劲儿了;为了把它的不确定性(也就是“错误率”)最小化到低于 1%,你需要将测试的迭代循环运行 750ms。一个 1ms 的计时器只需要一个循环运行 50ms 就可以得到相同的可信度。
但,这只是一个样本。为了确信你排除了歪曲结果的因素,你将会想要许多样本来求平均值。你还会想要明白最差的样本有多慢,最佳的样本有多快,最差与最佳的情况相差多少等等。你想知道的不仅是一个数字告诉你某个东西跑的多块,而且还需要一个关于这个数字有多可信的量化表达。
另外,你可能想要组合这些不同的技术(还有其他的),以便于你可以在所有这些可能的方式中找到最佳的平衡。
这一切只不过是开始所需的最低限度的认识。如果你曾经使用比我刚才几句话带过的东西更不严谨的方式进行基准分析,那么...“你不懂:正确的基准分析”。
Benchmark.js
何用 Benchmark.js 来运行一个快速的性能测试:
function foo() {
// 需要测试的操作
}
var bench = new Benchmark(
"foo test", // 测试的名称
foo, // 要测试的函数(仅仅是内容)
{
// .. // 额外的选项(参见文档)
}
);
bench.hz; // 每秒钟执行的操作数
bench.stats.moe; // 误差边际
bench.stats.variance; // 所有样本上的方差
// ..
复制代码
比起我在这里的窥豹一斑,关于使用 Benchmark.js 还有 许多 需要学习的东西。不过重点是,为了给一段给定的 JavaScript 代码建立一个公平,可靠,并且合法的性能基准分析,Benchmark.js 包揽了所有的复杂性。如果你想要试着对你的代码进行测试和基准分析,这个库应当是你第一个想到的地方。
我们在这里展示的是测试一个单独操作 X 的用法,但是相当常见的情况是你想要用 X 和 Y 进行比较。这可以通过简单地在一个“Suite”(一个 Benchmark.js 的组织特性)中建立两个测试来很容易做到。然后,你对照地运行它们,然后比较统计结果来对为什么 X 或 Y 更快做出论断。
Benchmark.js 理所当然地可以被用于在浏览器中测试 JavaScript(参见本章稍后的“jsPerf.com”一节),但它也可以运行在非浏览器环境中(Node.js 等等)。
一个很大程度上没有触及的 Benchmark.js 的潜在用例是,在你的 Dev 或 QA 环境中针对你的应用程序的 JavaScript 的关键路径运行自动化的性能回归测试。与在部署之前你可能运行单元测试的方式相似,你也可以将性能与前一次基准分析进行比较,来观测你是否改进或恶化了应用程序性能。
Setup/Teardown
在前一个代码段中,我们略过了“额外选项(extra options)”{ .. }
对象。但是这里有两个我们应当讨论的选项setup
和teardown
。
这两个选项让你定义在你的测试用例开始运行前和运行后被调用的函数。
一个需要理解的极其重要的事情是,你的setup
和teardown
代码 不会为每一次测试迭代而运行。考虑它的最佳方式是,存在一个外部循环(重复的轮回),和一个内部循环(重复的测试迭代)。setup
和teardown
会在每个 外部 循环(也就是轮回)迭代的开始和末尾运行,但不是在内部循环。
为什么这很重要?让我们想象你有一个看起来像这样的测试用例:
a = a + "w";
b = a.charAt(1);
复制代码
然后,你这样建立你的测试setup
:
var a = "x";
复制代码
你的意图可能是相信对每一次测试迭代a
都以值"x"
开始。
但它不是!它使a
在每一次测试轮回中以"x"
开始,而后你的反复的+ "w"
连接将使a
的值越来越大,即便你永远唯一访问的是位于位置1
的字符"w"
。
当你想利用副作用来改变某些东西比如 DOM,向它追加一个子元素时,这种意外经常会咬到你。你可能认为的父元素每次都被设置为空,但他实际上被追加了许多元素,而这可能会显著地歪曲你的测试结果。
上下文为王
不要忘了检查一个指定的性能基准分析的上下文环境,特别是在 X 与 Y 之间进行比较时。仅仅因为你的测试显示 X 比 Y 速度快,并不意味着“X 比 Y 快”这个结论是实际上有意义的。
举个例子,让我们假定一个性能测试显示出 X 每秒可以运行 1 千万次操作,而 Y 每秒运行 8 百万次。你可以声称 Y 比 X 慢 20%,而且在数学上你是对的,但是你的断言并不向像你认为的那么有用。
让我们更加苛刻地考虑这个测试结果:每秒 1 千万次操作就是每毫秒 1 万次操作,就是每微秒 10 次操作。换句话说,一次操作要花 0.1 毫秒,或者 100 纳秒。很难体会 100 纳秒到底有多小,可以这样比较一下,通常认为人类的眼睛一般不能分辨小于 100 毫秒的变化,而这要比 X 操作的 100 纳秒的速度慢 100 万倍。
即便最近的科学研究显示,大脑可能的最快处理速度是 13 毫秒(比先前的论断快大约 8 倍),这意味着 X 的运行速度依然要比人类大脑可以感知事情的发生要快 12 万 5 千倍。X 运行的非常,非常快。
但更重要的是,让我们来谈谈 X 与 Y 之间的不同,每秒 2 百万次的差。如果 X 花 100 纳秒,而 Y 花 80 纳秒,差就是 20 纳秒,也就是人类大脑可以感知的间隔的 65 万分之一。
我要说什么?这种性能上的差别根本就一点儿都不重要!
但是等一下,如果这种操作将要一个接一个地发生许多次呢?那么差异就会累加起来,对吧?
好的,那么我们就要问,操作 X 有多大可能性将要一次又一次,一个接一个地运行,而且为了人类大脑能够感知的一线希望而不得不发生 65 万次。而且,它不得不在一个紧凑的循环中发生 5 百万到 1 千万次,才能接近于有意义。
虽然你们之中的计算机科学家会反对说这是可能的,但是你们之中的现实主义者们应当对这究竟有多大可能性进行可行性检查。即使在极其稀少的偶然中这有实际意义,但是在绝大多数情况下它没有。
你们大量的针对微小操作的基准分析结果——比如++x
对x++
的神话——完全是伪命题,只不过是用来支持在性能的基准上 X 应当取代 Y 的结论。
引擎优化
你根本无法可靠地这样推断:如果在你的独立测试中 X 要比 Y 快 10 微秒,这意味着 X 总是比 Y 快所以应当总是被使用。这不是性能的工作方式。它要复杂太多了。
举个例子,让我们想象(纯粹地假想)你在测试某些行为的微观性能,比如比较:
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(..)
做了什么,你可能会在直觉上认为parseInt(..)
潜在地有“更多工作”要做,特别是在foo
的测试用例下。或者你可能在直觉上认为在foo
的测试用例下它们应当有同样多的工作要做,因为它们俩应当能够在第一个字符"f"
处停下。
哪一种直觉正确?老实说我不知道。但是我会制造一个与你的直觉无关的测试用例。当你测试它的时候结果会是什么?我又一次在这里制造一个纯粹的假想,我们没实际上尝试过,我也不关心。
让我们假装X
与Y
的测试结果在统计上是相同的。那么你关于"f"
字符上发生的事情的直觉得到确认了吗?没有。
在我们的假想中可能发生这样的事情:引擎可能会识别出变量twelve
和foo
在每个测试中仅被使用了一次,因此它可能会决定要内联这些值。然后它可能发现Number("12")
可以替换为12
。而且也许在parseInt(..)
上得到相同的结论,也许不会。
或者一个引擎的死代码移除启发式算法会搅和进来,而且它发现变量X
和Y
都没有被使用,所以声明它们是没有意义的,所以最终在任一个测试中都不做任何事情。
而且所有这些都只是关于一个单独测试运行的假设而言的。比我们在这里用直觉想象的,现代的引擎复杂得更加难以置信。它们会使用所有的招数,比如追踪并记录一段代码在一段很短的时间内的行为,或者使用一组特别限定的输入。
如果引擎由于固定的输入而用特定的方法进行了优化,但是在你的真实的程序中你给出了更多种类的输入,以至于优化机制决定使用不同的方式呢(或者根本不优化!)?或者如果因为引擎看到代码被基准分析工具运行了成千上万次而进行了优化,但在你的真实程序中它将仅会运行大约 100 次,而在这些条件下引擎认定优化不值得呢?
所有这些我们刚刚假想的优化措施可能会发生在我们的被限定的测试中,但在更复杂的程序中引擎可能不会那么做(由于种种原因)。或者正相反——引擎可能不会优化这样不起眼的代码,但是可能会更倾向于在系统已经被一个更精巧的程序消耗后更加积极地优化。
我想要说的是,你不能确切地知道这背后究竟发生了什么。你能搜罗的所有猜测和假想几乎不会提炼成任何坚实的依据。
难道这意味着你不能真正地做有用的测试了吗?绝对不是!
这可以归结为测试 不真实 的代码会给你 不真实 的结果。在尽可能的情况下,你应当测试真实的,有意义的代码段,并且在最接近你实际能够期望的真实条件下进行。只有这样你得到的结果才有机会模拟现实。
像++x
和x++
这样的微观基准分析简直和伪命题一模一样,我们也许应该直接认为它就是。
编写好的测试
来看看我能否清晰地表达我想在这里申明的更重要的事情。
好的测试作者需要细心地分析性地思考两个测试用例之间存在什么样的差别,和它们之间的差别是否是 有意的 或 无意的。
有意的差别当然是正常的,但是产生歪曲结果的无意的差异实在太容易了。你不得不非常非常小心地回避这种歪曲。另外,你可能预期一个差异,但是你的意图是什么对于你的测试的其他读者来讲不那么明显,所以他们可能会错误地怀疑(或者相信!)你的测试。你如何搞定这个呢?
编写更好,更清晰的测试。 另外,花些时间用文档确切地记录下你的测试意图是什么(使用 jsPerf.com 的“Description”字段,或/和代码注释),即使是微小的细节。明确地表示有意的差别,这将帮助其他人和未来的你自己更好地找出那些可能歪曲测试结果的无意的差别。
将与你的测试无关的东西隔离开来,通过在页面或测试的 setup 设置中预先声明它们,使它们位于测试计时部分的外面。
与将你的真实代码限制在很小的一块,并脱离上下文环境来进行基准分析相比,测试与基准分析在它们包含更大的上下文环境(但仍然有意义)时表现更好。这些测试将会趋向于运行得更慢,这意味着你发现的任何差别都在上下文环境中更有意义。
微观性能
好了,直至现在我们一直围绕着微观性能的问题跳舞,并且一般上不赞成痴迷于它们。我想花一点儿时间直接解决它们。
当你考虑对你的代码进行性能基准分析时,第一件需要习惯的事情就是你写的代码不总是引擎实际运行的代码。我们在第一章中讨论编译器的语句重排时简单地看过这个话题,但是这里我们将要说明编译器能有时决定运行与你编写的不同的代码,不仅是不同的顺序,而是不同的替代品。
让我们考虑这段代码:
var foo = 41;
(function() {
(function() {
(function(baz) {
var bar = foo + baz;
// ..
})(1);
})();
})();
复制代码
你也许会认为在最里面的函数的foo
引用需要做一个三层作用域查询。我们在这个系列丛书的 作用域与闭包 一卷中涵盖了词法作用域如何工作,而事实上编译器通常缓存这样的查询,以至于从不同的作用域引用foo
不会实质上“花费”任何额外的东西。
但是这里有些更深刻的东西需要思考。如果编译器认识到foo
除了这一个位置外没有被任何其他地方引用,进而注意到它的值除了这里的41
外没有任何变化会怎么样呢?
JS 编译器能够决定干脆完全移除foo
变量,并 内联 它的值是可能和可接受的,比如这样:
(function() {
(function() {
(function(baz) {
var bar = 41 + baz;
// ..
})(1);
})();
})();
复制代码
注意: 当然,编译器可能也会对这里的baz
变量进行相似的分析和重写。
但你开始将你的 JS 代码作为一种告诉引擎去做什么的提示或建议来考虑,而不是一种字面上的需求,你就会理解许多对零碎的语法细节的痴迷几乎是毫无根据的。
另一个例子:
function factorial(n) {
if (n < 2) return 1;
return n * factorial(n - 1);
}
factorial(5); // 120
复制代码
啊,一个老式的“阶乘”算法!你可能会认为 JS 引擎将会原封不动地运行这段代码。老实说,它可能会——但我不是很确定。
但作为一段轶事,用 C 语言表达的同样的代码并使用先进的优化处理进行编译时,将会导致编译器认为factorial(5)
调用可以被替换为常数值120
,完全消除这个函数以及调用!
另外,一些引擎有一种称为“递归展开(unrolling recursion)”的行为,它会意识到你表达的递归实际上可以用循环“更容易”(也就是更优化地)地完成。前面的代码可能会被 JS 引擎 重写 为:
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
复制代码
现在,让我们想象在前一个片段中你曾经担心n * factorial(n-1)
或n *= factorial(--n)
哪一个运行的更快。也许你甚至做了性能基准分析来试着找出哪个更好。但是你忽略了一个事实,就是在更大的上下文环境中,引擎也许不会运行任何一行代码,因为它可能展开了递归!
说到--
,--n
与n--
的对比,经常被认为可以通过选择--n
的版本进行优化,因为理论上在汇编语言层面的处理上,它要做的努力少一些。
在现代的 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
开始来计算。而第一个与第二个选择之间的区别实际上无关紧要。
这样的事情是完全有可能的:JS 引擎也许看到一个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
所花的开销。
如果你围绕x.length
的用法进行性能基准分析,与将它缓存在变量len
中的用法进行比较,你会发现虽然理论听起来不错,但是在实践中任何测量出的差异都是在统计学上完全没有意义的。
尾部调用优化 (TCO)
正如我们早前简单提到的,ES6 包含了一个冒险进入性能世界的具体需求。它是关于在函数调用时可能会发生的一种具体的优化形式:尾部调用优化(TCO)。
简单地说,一个“尾部调用”是一个出现在另一个函数“尾部”的函数调用,于是在这个调用完成后,就没有其他的事情要做了(除了也许要返回结果值)。
例如,这是一个带有尾部调用的非递归形式:
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)
不是 一个尾部调用,因为在它完成后,在baz()
能返回它的结果前,这个结果必须被加 1。
不过于深入本质细节而简单地说,调用一个新函数需要保留额外的内存来管理调用栈,它称为一个“栈帧(stack frame)”。所以前面的代码段通常需要同时为baz()
,bar(..)
,和foo(..)
都准备一个栈帧。
然而,如果一个支持 TCO 的引擎可以认识到foo(y+1)
调用位于 尾部位置 意味着bar(..)
基本上完成了,那么当调用foo(..)
时,它就并没有必要创建一个新的栈帧,而是可以重复利用既存的bar(..)
的栈帧。这不仅更快,而且也更节省内存。
在一个简单的代码段中,这种优化机制没什么大不了的,但是当对付递归,特别是当递归会造成成百上千的栈帧时,它就变成了 相当有用的技术。引擎可以使用 TCO 在一个栈帧内完成所有调用!
在 JS 中递归是一个令人不安的话题,因为没有 TCO,引擎就不得不实现一个随意的(而且各不相同的)限制,规定它们允许递归栈能有多深,来防止内存耗尽。使用 TCO,带有 尾部位置 调用的递归函数实质上可以没有边界地运行,因为从没有额外的内存使用!
考虑前面的递归factorial(..)
,但是将它重写为对 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(..)
调用都在 尾部位置。
注意: 一个需要注意的重点是,TCO 尽在尾部调用实际存在时才会实施。如果你没用尾部调用编写递归函数,性能机制将仍然退回到普通的栈帧分配,而且引擎对于这样的递归的调用栈限制依然有效。许多递归函数可以像我们刚刚展示的factorial(..)
那样重写,但是要小心处理细节。
ES6 要求各个引擎实现 TCO 而不是留给它们自行考虑的原因之一是,由于对调用栈限制的恐惧,缺少 TCO 实际上趋向于减少特定的算法在 JS 中使用递归实现的机会。
如果无论什么情况下引擎缺少 TCO 只是安静地退化到性能差一些的方式上,那么它可能不会是 ES6 需要 要求 的东西。但是因为缺乏 TCO 可能会实际上使特定的程序不现实,所以与其说它只是一种隐藏的实现细节,不如说它是一个重要的语言特性更合适。
ES6 保证,从现在开始,JS 开发者们能够在所有兼容 ES6+的浏览器上信赖这种优化机制。这是 JS 性能的一个胜利!
复习
有效地对一段代码进行性能基准分析,特别是将它与同样代码的另一种写法相比较来看哪一种方式更快,需要小心地关注细节。
与其运行你自己的统计学上合法的基准分析逻辑,不如使用 Benchmark.js 库,它会为你搞定。但要小心你如何编写测试,因为太容易构建一个看起来合法但实际上有漏洞的测试了——即使是一个微小的区别也会使结果歪曲到完全不可靠。
尽可能多地从不同的环境中得到尽可能多的测试结果来消除硬件/设备偏差很重要。jsPerf.com 是一个用于大众外包性能基准分析测试的神奇网站。
许多常见的性能测试不幸地痴迷于无关紧要的微观性能细节,比如比较x++
和++x
。编写好的测试意味着理解如何聚焦大局上关注的问题,比如在关键路径上优化,和避免落入不同 JS 引擎的实现细节的陷阱。
尾部调用优化(TCO)是一个 ES6 要求的优化机制,它会使一些以前在 JS 中不可能的递归模式变得可能。TCO 允许一个位于另一个函数的 尾部位置 的函数调用不需要额外的资源就可以执行,这意味着引擎不再需要对递归算法的调用栈深度设置一个随意的限制了。