第六章: 基准分析与调优 - jsPerf.com
jsPerf.com
虽然Bechmark.js对于在你使用的任何JS环境中测试代码性能很有用,但是如果你需要从许多不同的环境(桌面浏览器,移动设备等)汇总测试结果并期望得到可靠的测试结论,它就显得能力不足。
举例来说,Chrome在高端的桌面电脑上与Chrome移动版在智能手机上的表现就大相径庭。而一个充满电的智能手机与一个只剩2%电量,设备开始降低无线电和处理器的能源供应的智能手机的表现也完全不同。
如果在横跨多于一种环境的情况下,你想在任何合理的意义上宣称“X比Y快”,那么你就需要实际测试尽可能多的真实世界的环境。只因为Chrome执行某种X操作比Y快并不意味着所有的浏览器都是这样。而且你还可能想要根据你的用户的人口统计交叉参照多种浏览器测试运行的结果。
有一个为此目的而生的牛X网站,称为jsPerf(http://jsperf.com )。它使用我们前面提到的Benchmark.js库来运行统计上正确且可靠的测试,并且可以让测试运行在一个你可交给其他人的公开URL上。
每当一个测试运行后,其结果都被收集并与这个测试一起保存,同时累积的测试结果将在网页上被绘制成图供所有人阅览。
当在这个网站上创建测试时,你一开始有两个测试用例可以填写,但你可以根据需要添加任意多个。你还可以建立在每次测试轮回开始时运行的setup
代码,和在每次测试轮回结束前运行的teardown
代码。
注意: 一个只做一个测试用例(如果你只对一个方案进行基准分析而不是相互对照)的技巧是,在第一次创建时使用输入框的占位提示文本填写第二个测试输入框,之后编辑这个测试并将第二个测试留为空白,这样它就会被删除。你可以稍后添加更多测试用例。
你可以顶一个页面的初始配置(引入库文件,定义工具函数,声明变量,等等)。如有需要这里也有选项可以定义setup和teardow行为——参照前面关于Benchmark.js的讨论中的“Setup/Teardown”一节。
可行性检查
jsPerf是一个奇妙的资源,但它上面有许多公开的糟糕测试,当你分析它们时会发现,由于在本章目前为止罗列的各种原因,它们有很大的漏洞或者是伪命题。
考虑:
// 用例 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 = []
存在于setup
代码中,它实际上不会在每一次测试迭代前执行,而是在每一个轮回的开始执行一次。这意味这x
将会持续地增长到非常大,而不仅是for
循环中暗示的大小10
。那么这是有意确保测试仅被限制在很小的数组上(大小为
10
)来观察JS引擎如何动作?这 可能 是有意的,但如果是,你就不得不考虑它是否过于关注内微妙的部实现细节了。另一方面,这个测试的意图包含数组实际上会增长到非常大的情况吗?JS引擎对大数组的行为与真实世界中预期的用法相比有意义且正确吗?
它的意图是要找出
x.length
或x.push(..)
在数组x
的追加操作上拖慢了多少性能吗?好吧,这可能是一个合法的测试。但再一次,push(..)
是一个函数调用,所以它理所当然地要比[..]
访问慢。可以说,用例1与用例2比用例3更合理。
这里有另一个展示苹果比橘子的常见漏洞的例子:
// 用例 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(..)
作为内联的函数表达式生命,你就创建了一个不合理的/伪命题的测试。这里,第二个测试用例不仅测试用户自定义的JS函数,而且它还测试为每一个迭代创建一个新的函数表达式。
不知这会不会吓到你,如果你运行一个相似的测试,但是将它更改为比较内联函数表达式与预先声明的函数,内联函数表达式的创建可能要慢2%到20%!
除非你的测试的意图 就是 要考虑内联函数表达式创建的“成本”,一个更好/更合理的测试是将mySort(..)
的声明放在页面的setup中——不要放在测试的setup
中,因为这会为每次轮回进行不必要的重复声明——然后简单地在测试用例中通过名称引用它:x.sort(mySort)
。
基于前一个例子,另一种造成苹果比橘子场景的陷阱是,不透明地对一个测试用例回避或添加“额外的工作”:
// 用例 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()
做了更多“额外的工作”,内建的比较器将被比较的值转换为字符串,然后进行字典顺序的比较。这样第一个代码段的结果为[-14, 0, 0, 12, 18, 2.9, 3]
而第二段代码的结果为[-14, 0, 0, 2.9, 3, 12, 18]
(就测试的意图来讲可能更准确)。
所以这个测试是不合理的,因为它的两个测试用例实际上没有做相同的任务。你得到的任何结果都将是伪命题。
这些同样的陷阱可以微妙的多:
// 用例 1
var x = false;
var y = x ? 1 : 2;
// 用例 2
var x;
var y = x ? 1 : 2;
这里的意图可能是要测试如果x
表达式不是Boolean的情况下,? :
操作符将要进行的Boolean转换对性能的影响(参见本系列的 类型与文法)。那么,根据在第二个用例中将会有额外的工作进行转换的事实,你看起来没问题。
微妙的问题呢?你在第一个测试用例中设定了x
的值,而没在另一个中设置,那么你实际上在第一个用例中做了在第二个用例中没做的工作。为了消灭任何潜在的扭曲(尽管很微小),可以这样:
// 用例 1
var x = false;
var y = x ? 1 : 2;
// 用例 2
var x = undefined;
var y = x ? 1 : 2;
现在两个用例都有一个赋值了,这样你想要测试的东西——x
的转换或者不转换——会更加正确的被隔离并测试。