测量javascript函数的性能[译]

原地址:http://www.sitepoint.com/measuring-javascript-functions-performance

性能总是软件中扮演了重要的角色。在网络上,性能是尤为重要,如果我们提供缓慢的网页给用户,我们的用户可以轻松更改网站去访问我们的竞争对手的网站。作为一个专业的web开发者,我们不得不把这个问题考虑在内。很多老的网络性能优化的最佳实践,就像请求最小化,使用CDN,不写渲染代码块,如今还是管用的。然而,越来越多的web应用在使用Javascript,验证我们的代码是快的是很重要的。

假如你有一个正在运行的函数,但是你怀疑这个函数运行没有足够快,同时你计划着要改进这个函数。如何证明你的假设呢?什么是当今测试函数性能的最贱实践呢?一般来说,最好实现这个任务的方式是使用函数内建的performance.now()来测量在你执行函数前后的时间。

在这篇文章我们将会讨论如何测量代码执行时间和避免一些常见的坑的技术。

Performance.now()
高精度计时接口提供的一个方法,名字叫now()的可以返回一个DOM高精度时间戳对象。提供了一个浮点小数反映当前时间毫秒到毫秒的千分之一的时间戳。分别,这些数据没有添加过多的值来提供你分析,但是两个不同的数字之间的差值可以描述所消耗多少时间。

事实上它比内置的日期对象更准确,并且是‘单调’的。意味着,简单来说,是不会受到系统的影响(像你的笔记本操作系统)周期性地校正系统时间。在更简单来说,定义的两个日期实例计算出的插值并不表示所有消耗的时间。

“单调”的数学定义式是(一个函数或数量)不同的方式进行不是增加就是减少。

另一种方式的解释,可以尝试想象下这一年之中当时钟前进或后退。例如,当时钟在的你的国家由于需要提高白天的日照,从而把时间跳过一小时,如果你创建时间实例在这个时间回退一小时之前,另一个时间实例在这个之后,将会看到不同内容就像“1小时3秒123毫秒”。如果使用两个performance.now()实例,会是”3秒123毫秒456789千分之一毫秒”。

在这个章节,我不会讲这些API的详情。所以如果你要看更多关于如何使用,我建议你去看下探索高精度计时API这个文章。

现在你已经知道了什么是高精度计时API和如何使用,让我们深入探讨一些潜在的问题。但在这之前,我们定义一个叫makeHash()的函数来用于我本文的其余部分的介绍。

1
2
3
4
5
6
7
8
9
10
function makeHash(source) {
var hash = 0;
if (source.length === 0) return hash;
for (var i = 0; i < source.length; i++) {
var char = source.charCodeAt(i);
hash = ((hash<<5)-hash)+char;
hash = hash & hash; // 转化到32的整形
}
return hash;
}

执行这个函数像如下的测量:

1
2
3
4
var t0 = performance.now();
var result = makeHash('Peter');
var t1 = performance.now();
console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:', result);

如果你在浏览器里运行,你应该可以看到像如下的结果:

1
Took 0.2730 milliseconds to generate: 77005292

运行的例子如下:

通过这个例子,来开始我们的讨论

陷阱 #1 - 不小心测量了不重要的事情
在这个例子里,你能在performance.now()之间只做一件事并且其他的函数调用makeHash(),并指定其值设置为一个变量的结果。我们需要执行该功能,没有别的时间。这个测量详细如下:

1
2
3
4
var t0 = performance.now();
console.log(makeHash('Peter')); // bad idea!
var t1 = performance.now();
console.log('Took', (t1 - t0).toFixed(4), 'milliseconds');

直接可以运行的代码片段demo如下:

在这个例子里,我们将会测试makeHash('Peter')调用所消耗的时间,多长在发送和打印输出在控制台。我们不知道多久这两个操作分别的时间消耗。你只知道一起的时间。它需要发送和打印输出的时间将很大程度上取决于浏览器,甚至取决于当时正在进行什么。

也许你完全知道的console.log是不可预测的消耗时间。但它是将执行多个功能同样是错误,即使各功能不涉及任何的I/O。如:

1
2
3
4
5
var t0 = performance.now();
var name = 'Peter';
var result = makeHash(name.toLowerCase()).toString();
var t1 = performance.now();
console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:', result);

同样,我们不知道的执行时间是如何分布的。变量赋值是toLowerCase()的调用,还是toString()的调用?

陷阱 #2 - 只测量一次
另一个通常会犯的错误只是一次测量,总体所花费的时间在这个基础上的结论。很可能是在不同的时间完全不同的。执行的时间很大程度取决于多种因素:

  • 编译器预热的时间(如 编译代码到字节码的时间)
  • 主线程是忙着做我们并没有意识到要去其他的东西,
  • 你的计算机的CPU(S)是忙于的其他事情而减慢你的整个浏览器上增量改进的重复执行的功能。

像这样:

1
2
3
4
5
6
var t0 = performance.now();
for (var i = 0; i < 10; i++) {
makeHash('Peter');
}
var t1 = performance.now();
console.log('Took', ((t1 - t0) / 10).toFixed(4), 'milliseconds to generate');

直接可以运行的代码片段demo如下:

这种方法的风险是,我们的浏览器的JavaScript引擎可能使用了子优化,这意味着第二次函数调用相同的输入,也可以从中受益记住第一输出,并简单地使用了第一次的输出。为了解决这个问题,你可以使用重复发送,在相同的输入字符串(例如:’Peter’)的多种不同的输入字符串代替。显然,随着测试与不同的输入,问题是正在测量功能需要不同的时间量。也许有些的输入导致比其他的输入更长的执行时间。

陷阱 #3 - 过分依赖的平均
在上一章节中,我们了解到,多次运行的东西,最好有不同的输入这种好的实践。但是,我们必须记住,使用不同的输入的问题是,执行可能需要比所有其它输入要长得多。因此,让我们退后一步,并发送相同的输入。假设我们发送相同的输入十次,并为每个,打印出来多久了。输出可能是这个样子:

1
2
3
4
5
6
7
8
9
10
Took 0.2730 milliseconds to generate: 77005292
Took 0.0234 milliseconds to generate: 77005292
Took 0.0200 milliseconds to generate: 77005292
Took 0.0281 milliseconds to generate: 77005292
Took 0.0162 milliseconds to generate: 77005292
Took 0.0245 milliseconds to generate: 77005292
Took 0.0677 milliseconds to generate: 77005292
Took 0.0289 milliseconds to generate: 77005292
Took 0.0240 milliseconds to generate: 77005292
Took 0.0311 milliseconds to generate: 77005292

注意为什么第一次的数据是和其他九次完全不同。最有可能的,这是因为在我们的浏览器的JavaScript引擎使用一些子优化,需要一些预热。还有一点,我们可以做,以避免这一点,但也有一些我们可以考虑,以防止有错误的结论很好的补救措施。

一种方法是计算最后九次的平均值。另一个更实际的方法是收集所有结果和计算一个中位数。基本上,它是所有的结果一字排开,排序顺序和采摘中间的一个。这个performance.now()是非常有用的,因为你会得到一个有用的数据。

使用中位值函数再尝试下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var numbers = [];
for (var i=0; i < 10; i++) {
var t0 = performance.now();
makeHash('Peter');
var t1 = performance.now();
numbers.push(t1 - t0);
}
function median(sequence) {
sequence.sort(); // note that direction doesn't matter
return sequence[Math.ceil(sequence.length / 2)];
}
console.log('Median time', median(numbers).toFixed(4), 'milliseconds');

陷阱 #4 - 在可预知的顺序中比较函数
我们已经明白,测量多次,取平均值是个好主意。此外,上一章节的例子告诉我们,可以使用优选平均的中值来代替。

现在,事实上,一个很好用的测量函数执行时间的方法是比较哪些函数更快。假设我们有两个函数,把相同类型的输入,并产生相同的结果,但在内部,他们的工作方式不同。

比方说,我们希望有一个功能,在不区分大小写情况下某个字符串是在字符串数组返回true,否则返回false。换句话说,我们不能用Array.prototype.indexOf,因为需要不区分大小写。这里有一个这样的实现

1
2
3
4
5
6
7
8
9
10
11
12
function isIn(haystack, needle) {
var found = false;
haystack.forEach(function(element) {
if (element.toLowerCase() === needle.toLowerCase()) {
found = true;
}
});
return found;
}
console.log(isIn(['a','b','c'], 'B')); // true
console.log(isIn(['a','b','c'], 'd')); // false

立即我们注意到,因为haystack.forEach循环可以使用一个早期匹配元素来改善替代。让我们尝试用好的老的写法,for循环的版本。

1
2
3
4
5
6
7
8
9
10
11
function isIn(haystack, needle) {
for (var i = 0, len = haystack.length; i < len; i++) {
if (haystack[i].toLowerCase() === needle.toLowerCase()) {
return true;
}
}
return false;
}
console.log(isIn(['a','b','c'], 'B')); // true
console.log(isIn(['a','b','c'], 'd')); // false

现在我们可以看下哪个更快。我们每个函数进行十次测量并收集测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function isIn1(haystack, needle) {
var found = false;
haystack.forEach(function(element) {
if (element.toLowerCase() === needle.toLowerCase()) {
found = true;
}
});
return found;
}
function isIn2(haystack, needle) {
for (var i = 0, len = haystack.length; i < len; i++) {
if (haystack[i].toLowerCase() === needle.toLowerCase()) {
return true;
}
}
return false;
}
console.log(isIn1(['a','b','c'], 'B')); // true
console.log(isIn1(['a','b','c'], 'd')); // false
console.log(isIn2(['a','b','c'], 'B')); // true
console.log(isIn2(['a','b','c'], 'd')); // false
function median(sequence) {
sequence.sort(); // note that direction doesn't matter
return sequence[Math.ceil(sequence.length / 2)];
}
function measureFunction(func) {
var letters = 'a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z'.split(',');
var numbers = [];
for (var i = 0; i < letters.length; i++) {
var t0 = performance.now();
func(letters, letters[i]);
var t1 = performance.now();
numbers.push(t1 - t0);
}
console.log(func.name, 'took', median(numbers).toFixed(4));
}
measureFunction(isIn1);
measureFunction(isIn2);

运行下得到如下的输出:

1
2
3
4
5
6
true
false
true
false
isIn1 took 0.0050
isIn2 took 0.0150

直接可以运行的代码片段demo如下:

刚刚发生了什么?第一个功能是快三倍。这是不应该发生的!

原因很简单,但是很微妙。第一个功能,使用了haystack.forEach得益于在浏览器的JavaScript引擎的一些低级别的优化,当我们使用数组索引去做,就不会被优化。这证明了我们的观点:如果你不测量,你永远不知道!

结论
在我们试图如何使用performance.now()取得JavaScript的一个准确的执行时间,我们偶然发现了一个基准场景,我们的实证研究结果得出结论和我们的直觉完全相反。问题的关键是,如果你想写得更快的Web应用程序,JavaScript代码就需要优化。由于电脑(差不多)是活生生的东西,令人如此的不可预知。最可靠的方法就是通过衡量和比较来改进代码确保更快的执行速度。

另一个原因,我们永远不知道这些代码是更快的,因为不同的上下文中,我们有做同一件事的多种方式。在上一节中,我们执行不区分大小写字符串搜索寻找除其他26个字符串。如果我们10万名中匹配字符串,该结论可能是完全不同的。

上述例子并不完整,还有更多的陷阱,以做到心中有数。例如,测量不现实的场景或仅测量在一个JavaScript引擎。但肯定的是,谁想要写出更快,更好的Web应用程序,开发者就可以使用performance.now()这个不错的协助。最后但并非最不重要的,请记住,测试执行时间只产生了“更好的代码”的一个方面。还有内存和代码的复杂性考虑,要牢记。

现在你这么想?你有没有用这个功能来测试你的代码的性能?如果没有,你怎么在这个阶段进行?请在下面的评论中分享你的看法。让我们开始讨论!