微观性能

好了,直至现在我们一直围绕着微观性能的问题跳舞,并且一般上不赞成痴迷于它们。我想花一点儿时间直接解决它们。

当你考虑对你的代码进行性能基准分析时,第一件需要习惯的事情就是你写的代码不总是引擎实际运行的代码。我们在第一章中讨论编译器的语句重排时简单地看过这个话题,但是这里我们将要说明编译器能有时决定运行与你编写的不同的代码,不仅是不同的顺序,而是不同的替代品。

让我们考虑这段代码:

  1. var foo = 41;
  2. (function(){
  3. (function(){
  4. (function(baz){
  5. var bar = foo + baz;
  6. // ..
  7. })(1);
  8. })();
  9. })();

你也许会认为在最里面的函数的foo引用需要做一个三层作用域查询。我们在这个系列丛书的 作用域与闭包 一卷中涵盖了词法作用域如何工作,而事实上编译器通常缓存这样的查询,以至于从不同的作用域引用foo不会实质上“花费”任何额外的东西。

但是这里有些更深刻的东西需要思考。如果编译器认识到foo除了这一个位置外没有被任何其他地方引用,进而注意到它的值除了这里的41外没有任何变化会怎么样呢?

JS编译器能够决定干脆完全移除foo变量,并 内联 它的值是可能和可接受的,比如这样:

  1. (function(){
  2. (function(){
  3. (function(baz){
  4. var bar = 41 + baz;
  5. // ..
  6. })(1);
  7. })();
  8. })();

注意: 当然,编译器可能也会对这里的baz变量进行相似的分析和重写。

但你开始将你的JS代码作为一种告诉引擎去做什么的提示或建议来考虑,而不是一种字面上的需求,你就会理解许多对零碎的语法细节的痴迷几乎是毫无根据的。

另一个例子:

  1. function factorial(n) {
  2. if (n < 2) return 1;
  3. return n * factorial( n - 1 );
  4. }
  5. factorial( 5 ); // 120

啊,一个老式的“阶乘”算法!你可能会认为JS引擎将会原封不动地运行这段代码。老实说,它可能会——但我不是很确定。

但作为一段轶事,用C语言表达的同样的代码并使用先进的优化处理进行编译时,将会导致编译器认为factorial(5)调用可以被替换为常数值120,完全消除这个函数以及调用!

另外,一些引擎有一种称为“递归展开(unrolling recursion)”的行为,它会意识到你表达的递归实际上可以用循环“更容易”(也就是更优化地)地完成。前面的代码可能会被JS引擎 重写 为:

  1. function factorial(n) {
  2. if (n < 2) return 1;
  3. var res = 1;
  4. for (var i=n; i>1; i--) {
  5. res *= i;
  6. }
  7. return res;
  8. }
  9. factorial( 5 ); // 120

现在,让我们想象在前一个片段中你曾经担心n * factorial(n-1)n *= factorial(--n)哪一个运行的更快。也许你甚至做了性能基准分析来试着找出哪个更好。但是你忽略了一个事实,就是在更大的上下文环境中,引擎也许不会运行任何一行代码,因为它可能展开了递归!

说到----nn--的对比,经常被认为可以通过选择--n的版本进行优化,因为理论上在汇编语言层面的处理上,它要做的努力少一些。

在现代的JavaScript中这种痴迷基本上是没道理的。这种事情应当留给引擎来处理。你应该编写最合理的代码。比较这三个for循环:

  1. // 方式 1
  2. for (var i=0; i<10; i++) {
  3. console.log( i );
  4. }
  5. // 方式 2
  6. for (var i=0; i<10; ++i) {
  7. console.log( i );
  8. }
  9. // 方式 3
  10. for (var i=-1; ++i<10; ) {
  11. console.log( i );
  12. }

就算你有一些理论支持第二或第三种选择要比第一种的性能好那么一点点,充其量只能算是可疑,第三个循环更加使人困惑,因为为了使提前递增的++i被使用,你不得不让i-1开始来计算。而第一个与第二个选择之间的区别实际上无关紧要。

这样的事情是完全有可能的:JS引擎也许看到一个i++被使用的地方,并意识到它可以安全地替换为等价的++i,这意味着你决定挑选它们中的哪一个所花的时间完全被浪费了,而且这么做的产出毫无意义。

这是另外一个常见的愚蠢的痴迷于微观性能的例子:

  1. var x = [ .. ];
  2. // 方式 1
  3. for (var i=0; i < x.length; i++) {
  4. // ..
  5. }
  6. // 方式 2
  7. for (var i=0, len = x.length; i < len; i++) {
  8. // ..
  9. }

这里的理论是,你应当在变量len中缓存数组x的长度,因为从表面上看它不会改变,来避免在循环的每一次迭代中都查询x.length所花的开销。

如果你围绕x.length的用法进行性能基准分析,与将它缓存在变量len中的用法进行比较,你会发现虽然理论听起来不错,但是在实践中任何测量出的差异都是在统计学上完全没有意义的。

事实上,在像v8这样的引擎中,可以看到(http://mrale.ph/blog/2014/12/24/array-length-caching.html)通过提前缓存长度而不是让引擎帮你处理它会使事情稍稍恶化。不要尝试在聪明上战胜你的JavaScript引擎,当它来到性能优化的地方时你可能会输给它。

不是所有的引擎都一样

在各种浏览器中的不同JS引擎可以称为“规范兼容的”,虽然各自有完全不同的方式处理代码。JS语言规范不要求与性能相关的任何事情——除了将在本章稍后将要讲解的ES6“尾部调用优化(Tail Call Optimization)”。

引擎可以自由决定哪一个操作将会受到它的关注而被优化,也许代价是在另一种操作上的性能降低一些。要为一种操作找到一种在所有的浏览器中总是运行的更快的方式是非常不现实的。

在JS开发者社区的一些人发起了一项运动,特别是那些使用Node.js工作的人,去分析v8 JavaScript引擎的具体内部实现细节,并决定如何编写定制的JS代码来最大限度的利用v8的工作方式。通过这样的努力你实际上可以在性能优化上达到惊人的高度,所以这种努力的收益可能十分高。

一些针对v8的经常被引用的例子是(https://github.com/petkaantonov/bluebird/wiki/Optimization-killers) :

  • 不要将arguments变量从一个函数传递到任何其他函数中,因为这样的“泄露”放慢了函数实现。
  • 将一个try..catch隔离到它自己的函数中。浏览器在优化任何含有try..catch的函数时都会苦苦挣扎,所以将这样的结构移动到它自己的函数中意味着你持有不可优化的危害的同时,让其周围的代码是可以优化的。

但与其聚焦在这些具体的窍门上,不如让我们在一般意义上对v8专用的优化方式进行一下合理性检验。

你真的在编写仅仅需要在一种JS引擎上运行的代码吗?即便你的代码 当前 是完全为了Node.js,那么假设v8将 总是 被使用的JS引擎可靠吗?从现在开始的几年以后的某一天,你有没有可能会选择除了Node.js之外的另一种服务器端JS平台来运行你的程序?如果你以前所做的优化现在在新的引擎上成为了执行这种操作的很慢的方式怎么办?

或者如果你的代码总是在v8上运行,但是v8在某个时点决定改变一组操作的工作方式,是的曾经快的现在变慢了,曾经慢的变快了呢?

这些场景也都不只是理论上的。曾经,将多个字符串值放在一个数组中然后在这个数组上调用join("")来连接这些值,要比仅使用+直接连接这些值要快。这件事的历史原因很微妙,但它与字符串值如何被存储和在内存中如何管理的内部实现细节有关。

结果,当时在业界广泛传播的“最佳实践”建议开发者们总是使用数组join(..)的方式。而且有许多人遵循了。

但是,某一天,JS引擎改变了内部管理字符串的方式,而且特别在+连接上做了优化。他们并没有放慢join(..),但是他们在帮助+用法上做了更多的努力,因为它依然十分普遍。

注意: 某些特定方法的标准化和优化的实施,很大程度上决定于它被使用的广泛程度。这经常(隐喻地)称为“paving the cowpath”(不提前做好方案,而是等到事情发生了再去应对)。

一旦处理字符串和连接的新方式定型,所有在世界上运行的,使用数组join(..)来连接字符串的代码都不幸地变成了次优的方式。

另一个例子:曾经,Opera浏览器在如何处理基本包装对象的封箱/拆箱(参见本系列的 类型与文法)上与其他浏览器不同。因此他们给开发者的建议是,如果一个原生string值的属性(如length)或方法(如charAt(..))需要被访问,就使用一个String对象取代它。这个建议也许对那时的Opera是正确的,但是对于同时代的其他浏览器来说简直就是完全相反的,因为它们都对原生string进行了专门的优化,而不是对它们的包装对象。

我认为即使是对今天的代码,这种种陷阱即便可能性不高,至少也是可能的。所以对于在我的JS代码中单纯地根据引擎的实现细节来进行大范围的优化这件事来说我会非常小心,特别是如果这些细节仅对一种引擎成立时。

反过来也有一些事情需要警惕:你不应当为了绕过某一种引擎难于处理的地方而改变一块代码。

历史上,IE是导致许多这种挫折的领头羊,在老版本的IE中曾经有许多场景,在当时的其他主流浏览器中看起来没有太多麻烦的性能方面苦苦挣扎。我们刚刚讨论的字符串连接在IE6和IE7的年代就是一个真实的问题,那时候使用join(..)就可能要比使用+能得到更好的性能。

不过为了一种浏览器的性能问题而使用一种很有可能在其他所有浏览器上是次优的编码方式,很难说是正当的。即便这种浏览器占有了你的网站用户的很大市场份额,编写恰当的代码并仰仗浏览器最终在更好的优化机制上更新自己可能更实际。

“没什么是比暂时的黑科技更永恒的。”你现在为了绕过一些性能的Bug而编写的代码可能要比这个Bug在浏览器中存在的时间长的多。

在那个浏览器每五年才更新一次的年代,这是个很难做的决定。但是如今,所有的浏览器都在快速地更新(虽然移动端的世界还有些滞后),而且它们都在竞争而使得web优化特性变得越来越好。

如果你真的碰到了一个浏览器有其他浏览器没有的性能瑕疵,那么就确保用你一切可用的手段来报告它。绝大多数浏览器都有为此而公开的Bug追迹系统。

提示: 我只建议,如果一个在某种浏览器中的性能问题真的是极端搅局的问题时才绕过它,而不是仅仅因为它使人厌烦或沮丧。而且我会非常小心地检查这种性能黑科技有没有在其他浏览器中产生负面影响。

大局

与担心所有这些微观性能的细节相反,我们应但关注大局类型的优化。

你怎么知道什么东西是不是大局的?你首先必须理解你的代码是否运行在关键路径上。如果它没在关键路径上,你的优化可能就没有太大价值。

“这是过早的优化!”你听过这种训诫吗?它源自Donald Knuth的一段著名的话:“过早的优化是万恶之源。”。许多开发者都引用这段话来说明大多数优化都是“过早”的而且是一种精力的浪费。事实是,像往常一样,更加微妙。

这是Knuth在语境中的原话:

程序员们浪费了大量的时间考虑,或者担心,他们的程序中的 不关键 部分的速度,而在考虑调试和维护时这些在效率上的企图实际上有很强大的负面影响。我们应当忘记微小的效率,可以说在大概97%的情况下:过早的优化是万恶之源。然而我们不应该忽略那 关键的 3%中的机会。[强调]

(http://web.archive.org/web/20130731202547/http://pplab.snu.ac.kr/courses/adv_pl05/papers/p261-knuth.pdf, Computing Surveys, Vol 6, No 4, December 1974)

我相信这样转述Knuth的 意思 是合理的:“非关键路径的优化是万恶之源。”所以问题的关键是弄清楚你的代码是否在关键路径上——你应该优化它!——或者不。

我甚至可以激进地这么说:没有花在优化关键路径上的时间是浪费的,不管它的效果多么微小。没有花在优化非关键路径上的时间是合理的,不管它的效果多么大。

如果你的代码在关键路径上,比如将要一次又一次被运行的“热”代码块儿,或者在用户将要注意到的UX关键位置,比如循环动画或者CSS样式更新,那么你应当不遗余力地进行有意义的,可测量的重大优化。

举个例子,考虑一个动画循环的关键路径,它需要将一个字符串值转换为一个数字。这当然有多种方法做到,但是哪一个是最快的呢?

  1. var x = "42"; // 需要数字 `42`
  2. // 选择1:让隐式强制转换自动完成工作
  3. var y = x / 2;
  4. // 选择2:使用`parseInt(..)`
  5. var y = parseInt( x, 0 ) / 2;
  6. // 选择3:使用`Number(..)`
  7. var y = Number( x ) / 2;
  8. // 选择4:使用`+`二元操作符
  9. var y = +x / 2;
  10. // 选择5:使用`|`二元操作符
  11. var y = (x | 0) / 2;

注意: 我将这个问题留作给读者们的练习,如果你对这些选择之间性能上的微小区别感兴趣的话,可以做一个测试。

当你考虑这些不同的选择时,就像人们说的,“有一个和其他的不一样。”parseInt(..)可以工作,但它做的事情多的多——它会解析字符串而不是转换它。你可能会正确地猜想parseInt(..)是一个更慢的选择,而你可能应当避免使用它。

当然,如果x可能是一个 需要被解析 的值,比如"42px"(比如CSS样式查询),那么parseInt(..)确实是唯一合适的选择!

Number(..)也是一个函数调用。从行为的角度讲,它与+二元操作符是相同的,但它事实上可能慢一点儿,需要更多的机器指令运转来执行这个函数。当然,JS引擎也可能识别出了这种行为上的对称性,而仅仅为你处理Number(..)行为的内联形式(也就是+x)!

但是要记住,痴迷于+xx | 0的比较在大多数情况下都是浪费精力。这是一个微观性能问题,而且你不应该让它使你的程序的可读性降低。

虽然你的程序的关键路径性能非常重要,但它不是唯一的因素。在几种性能上大体相似的选择中,可读性应当是另一个重要的考量。