基准分析(Benchmarking)

好了,是时候开始消除一些误解了。我敢打赌,广大的JS开发者们,如果被问到如何测量一个特定操作的速度(执行时间),将会一头扎进这样的东西:

  1. var start = (new Date()).getTime(); // 或者`Date.now()`
  2. // 做一些操作
  3. var end = (new Date()).getTime();
  4. console.log( "Duration:", (end - start) );

如果这大致就是你想到的,请举手。是的,我就知道你会这么想。这个方式有许多错误,但是别难过;我们都这么干过。

这种测量到底告诉了你什么?对于当前的操作的执行时间来说,理解它告诉了你什么和没告诉你什么是学习如何正确测量JavaScript的性能的关键。

如果持续的时间报告为0,你也许会试图认为它花的时间少于1毫秒。但是这不是非常准确。一些平台不能精确到毫秒,反而是在更大的时间单位上更新计时器。举个例子,老版本的windows(IE也是如此)只有15毫秒的精确度,这意味着要得到与0不同的报告,操作就必须至少要花这么长时间!

另外,不管被报告的持续时间是多少,你唯一真实知道的是,操作在当前这一次运行中大概花了这么长时间。你几乎没有信心说它将总是以这个速度运行。你不知道引擎或系统是否在就在那个确切的时刻进行了干扰,而在其他的时候这个操作可能会运行的快一些。

要是持续的时间报告为4呢?你确信它花了大概4毫秒?不,它可能没花那么长时间,而且在取得startend时间戳时会有一些其他的延迟。

更麻烦的是,你也不知道这个操作测试所在的环境是不是过于优化了。这样的情况是有可能的:JS引擎找到了一个办法来优化你的测试用例,但是在更真实的程序中这样的优化将会被稀释或者根本不可能,如此这个操作将会比你测试时运行的慢。

那么…我们知道什么?不幸的是,在这种状态下,我们几乎什么都不知道。 可信度如此低的东西甚至不够你建立自己的判断。你的“基准分析”基本没用。更糟的是,它隐含的这种不成立的可信度很危险,不仅是对你,而且对其他人也一样:认为导致这些结果的条件不重要。

重复

“好的,”你说,“在它周围放一个循环,让整个测试需要的时间长一些。”如果你重复一个操作100次,而整个循环在报告上说总共花了137ms,那么你可以除以100并得到每次操作平均持续时间1.37ms,对吧?

其实,不确切。

对于你打算在你的整个应用程序范围内推广的操作的性能,仅靠一个直白的数据上的平均做出判断绝对是不够的。在一百次迭代中,即使是几个极端值(或高或低)就可以歪曲平均值,而后当你反复实施这个结论时,你就更进一步扩大了这种歪曲。

与仅仅运行固定次数的迭代不同,你可以选择将测试的循环运行一个特定长的时间。那可能更可靠,但是你如何决定运行多长时间?你可能会猜它应该是你的操作运行一次所需时间的倍数。错。

实际上,循环持续的时间应当基于你使用的计时器的精度,具体地将不精确的 ·可能性最小化。你的计时器精度越低,你就需要运行更长时间来确保你将错误的概率最小化了。一个15ms的计时器对于精确的基准分析来说太差劲儿了;为了把它的不确定性(也就是“错误率”)最小化到低于1%,你需要将测试的迭代循环运行750ms。一个1ms的计时器只需要一个循环运行50ms就可以得到相同的可信度。

但,这只是一个样本。为了确信你排除了歪曲结果的因素,你将会想要许多样本来求平均值。你还会想要明白最差的样本有多慢,最佳的样本有多快,最差与最佳的情况相差多少等等。你想知道的不仅是一个数字告诉你某个东西跑的多块,而且还需要一个关于这个数字有多可信的量化表达。

另外,你可能想要组合这些不同的技术(还有其他的),以便于你可以在所有这些可能的方式中找到最佳的平衡。

这一切只不过是开始所需的最低限度的认识。如果你曾经使用比我刚才几句话带过的东西更不严谨的方式进行基准分析,那么…“你不懂:正确的基准分析”。

Benchmark.js

任何有用而且可靠的基准分析应当基于统计学上的实践。我不是要在这里写一章统计学,所以我会带过一些名词:标准差,方差,误差边际。如果你不知道这些名词意味着什么——我在大学上过统计学课程,而我依然对他们有点儿晕——那么实际上你没有资格去写你自己的基准分析逻辑。

幸运的是,一些像John-David Dalton和Mathias Bynens这样的聪明家伙明白这些概念,并且写了一个统计学上的基准分析工具,称为Benchmark.js(http://benchmarkjs.com/)。所以我可以简单地说:“用这个工具就行了。”来终结这个悬念。

我不会重复他们的整个文档来讲解Benchmark.js如何工作;他们有很棒的API文档(http://benchmarkjs.com/docs)你可以阅读。另外这里还有一些了不起的文章(http://calendar.perfplanet.com/2010/bulletproof-javascript-benchmarks/)(http://monsur.hossa.in/2012/12/11/benchmarkjs.html)讲解细节与方法学。

但是为了快速演示一下,这是你如何用Benchmark.js来运行一个快速的性能测试:

  1. function foo() {
  2. // 需要测试的操作
  3. }
  4. var bench = new Benchmark(
  5. "foo test", // 测试的名称
  6. foo, // 要测试的函数(仅仅是内容)
  7. {
  8. // .. // 额外的选项(参见文档)
  9. }
  10. );
  11. bench.hz; // 每秒钟执行的操作数
  12. bench.stats.moe; // 误差边际
  13. bench.stats.variance; // 所有样本上的方差
  14. // ..

比起我在这里的窥豹一斑,关于使用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)”{ .. }对象。但是这里有两个我们应当讨论的选项setupteardown

这两个选项让你定义在你的测试用例开始运行前和运行后被调用的函数。

一个需要理解的极其重要的事情是,你的setupteardown代码 不会为每一次测试迭代而运行。考虑它的最佳方式是,存在一个外部循环(重复的轮回),和一个内部循环(重复的测试迭代)。setupteardown会在每个 外部 循环(也就是轮回)迭代的开始和末尾运行,但不是在内部循环。

为什么这很重要?让我们想象你有一个看起来像这样的测试用例:

  1. a = a + "w";
  2. b = a.charAt( 1 );

然后,你这样建立你的测试setup

  1. var a = "x";

你的意图可能是相信对每一次测试迭代a都以值"x"开始。

但它不是!它使a在每一次测试轮回中以"x"开始,而后你的反复的+ "w"连接将使a的值越来越大,即便你永远唯一访问的是位于位置1的字符"w"

当你想利用副作用来改变某些东西比如DOM,向它追加一个子元素时,这种意外经常会咬到你。你可能认为的父元素每次都被设置为空,但他实际上被追加了许多元素,而这可能会显著地歪曲你的测试结果。