6.5 微性能
到目前为止,我们一直在围绕各种微性能问题讨论,并始终认为沉迷于此是不可取的。现在我要花费一点时间直面这个问题。
在考虑对代码进行性能测试时,你应该习惯的第一件事情就是你所写的代码并不总是引擎真正运行的代码。在第 1 章讨论编译器语句重排序问题时,我们简单介绍过这个问题。不过这里要说的是,有时候编译器可能会决定执行与你所写的不同的代码,不只是顺序不同,实际内容也会不同。
来考虑下面这段代码:
var foo = 41; (function(){ (function(){ (function(baz){ var bar = foo + baz; // .. })(1); })(); })();
可能你会认为最内层函数中的引用 foo 需要进行三层作用域查找。本系列的《你不知道的 JavaScript(上卷)》的“作用域和闭包”部分讨论了词法作用域是如何工作的。事实上,编译器通常会缓存这样的查找结果,使得从不同的作用域引用 foo 实际上并没有任何额外的花费。
但是,还有一些更深入的问题需要思考。如果编译器意识到这个 foo 只在一个位置被引用而别处没有任何引用,并且注意到这个值只是 41 而从来不会变成其他值呢?
JavaScript 可能决定完全去掉 foo 变量,将其值在线化 ,这不是很可能发生也可以接受的吗?就像下面这样:
(function(){ (function(){ (function(baz){ var bar = 41 + baz; // .. })(1); })(); })();
当然,这里编译器有可能也对 baz 进行类似的分析和重写。
当你把 JavaScript 代码看作对引擎要做什么的提示和建议,而不是逐字逐句的要求时,你就会意识到,对于具体语法细节的很多执着迷恋已经烟消云散了。
另一个例子:
function factorial(n) { if (n < 2) return 1; return n * factorial( n - 1 ); } factorial( 5 ); // 120
啊,很不错的老式阶乘算法!你可能认为 JavaScript 就像代码这样运行。但说实话,只是可能,我真的也不确定。
但作为一件趣事,同样的代码用 C 编写并用高级优化编译的结果是,编译器意识到调用 factorial(5) 可以直接用常量值 120 来代替,完全消除了函数的调用!
另外,有些引擎会进行名为递归展开 的动作,在这里,它能够意识到你表达的递归其实可以用循环更简单地实现(即优化)。JavaScript 引擎有可能会把前面的代码重写如下来运行:
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 开始计算。而第一个和第二个选择之间的区别实际上完全无关紧要。
完全有可能一个 JavaScript 引擎看到了一个使用 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 变量中的方案,你会发现尽管理论听起来没错,但实际的可测差别在统计上是完全无关紧要的。
实际上,在某些像 v8 这样的引擎中,可以看到(http://mrale.ph/blog/2014/12/24/array-length-caching.html ),预先缓存长度而不是让引擎为你做这件事情,会使性能稍微下降一点。不要试图和 JavaScript 引擎比谁聪明。对性能优化来说,你很可能会输。
6.5.1 不是所有的引擎都类似
各种浏览器中的不同 JavaScript 引擎可以都是“符合规范的”,但其处理代码的方法却完全不同。JavaScript 规范并没有任何性能相关的要求,好吧,除了 ES6 的“尾调用优化”,这部分将在 6.6 节介绍。
引擎可以自由决定一个运算是否需要优化,可能进行权衡,替换掉运算次要性能。对一个运算来说,很难找到一种方法使其在所有浏览器中都运行得较快。
在一些 JavaScript 开发社区有一场运动,特别是在那些使用 Node.js 工作的开发者中间。这场运动是要分析 v8 JavaScript 引擎的特定内部实现细节,决定编写裁剪过的 JavaScript 代码来最大程度地利用 v8 的工作模式。通过这样的努力,你可能会获得令人吃惊的高度性能优化。因此,这种努力的回报可能会很高。
如下是 v8 的一些经常提到的例子(https://github.com/petkaantonov/bluebird/wiki/Optimization-killers )。
· 不要从一个函数到另外一个函数传递 arguments 变量,因为这样的泄漏会降低函数实现速度。
· 把 try..catch 分离到单独的函数里。浏览器对任何有 try..catch 的函数实行优化都有一些困难,所以把这部分移到独立的函数中意味着你控制了反优化的害处,并让其包含的代码可以优化。
不过,与其关注这些具体技巧,倒不如让我们在通用的意义上对 v8 独有的优化方法进行一次完整性检查。
确实要编写只需在一个 JavaScript 引擎上运行的代码吗?即使你的代码目前 只需要 Node.js,假定使用的 JavaScript 引擎永远是 v8 是否可靠呢?有没有可能某一天,几年以后,会有 Node.js 之外的另一种服务器端 JavaScript 平台被选中运行你的代码呢?如果你之前的优化如今对新引擎而言成了一种运行很慢的方法,要怎么办呢?
或者如果从现在开始你的代码总是保持运行在 v8 上,但是 v8 决定在某些方面修改其运算的工作方式,过去运行很快的方式现在很慢,或者相反,那又该怎么办?
这些场景并不仅仅只是理论。过去把多个字符串值放在一个数组中,然后在数组上调用 join("") 来连接这些值比直接用 + 连接这些值要快。这一点的历史原因是微妙的,涉及字符串值在内存中如何存储和管理这样的内部实现细节。
因此,那时的工业界广泛传播的最佳实践建议是:开发者应总是使用数组的 join(..) 方法。很多人遵从了这一建议。
但随着时间的发展,JavaScript 引擎改变了内部管理字符串的方法,特别对 + 连接进行了优化。它们并没有降低 join(..) 本身的效率,而是花了更多精力提高 + 的使用,因为 join 仍然是广泛使用的。
主要基于某些方法当前的广泛使用来标准化或优化这些特定方法的实践通常称为(比喻意义上的)“给已被牛踏出的路铺砖”。
一旦新的处理字符串和连接的方法确定下来,很遗憾,所有那些使用数组 join(..) 来连接字符串的代码就成次优的了。
另一个例子:曾几何时,Opera 浏览器在如何处理原生封装的对象的封箱 / 开箱上与其他浏览器不同(参见本书的“类型和语法”部分)。同样,他们对开发者的建议是:如果需要访问 length 这样的属性或 charAt(..) 这样的方法,应使用 String 对象而不是原生字符串值。这个建议对那时候的 Opera 来说可能是正确的,但是它完全与同时代的其他主流浏览器背道而驰,因为后者都对原生字符串有特殊的优化而不是对其对象封装。
我想,即使是对于今天的代码,这些陷阱至少是可能出现的,如果不是很容易发生的话。因此,我对在我的代码中单纯根据引擎实现细节进行的广泛性能优化非常小心,特别是如果这些细节只对于单个引擎成立的话 。
反过来的情形也需要慎重:你不应该修改一段代码以通过高性能运行一段代码,进而绕过一个引擎的困难之处。
从历史上看,IE 一直是这类问题的主要源头。因为在很多场景下,老版的 IE 都挣扎于许多性能方面的问题,而同时期的其他主流浏览器却似乎没什么问题。实际上我们刚才讨论的字符串连接问题在 IE6 和 IE7 时期是一个真实的问题,那时候通过 join(..) 可能会得到比 + 更好的性能。
但是,如果只有一个浏览器出现性能问题,就建议使用可能在其他所有浏览器都是次优的代码方案,可能会带来麻烦。即使这个浏览器在你网站用户中占据最大的市场份额也是如此,可能更实际的方法是编写合适的代码,并依赖浏览器以更好的优化来更新自己。
“没有比临时 hack 更持久的了”。很有可能你现在编写的用来绕过一些性能 bug 的代码可能比浏览器的性能问题本身存在得更长久。
在浏览器每五年才更新一次的时候,这是个很难作出的抉择。但是到了现在,浏览器更新的速度要快得多(尽管移动世界显然还落在后面),它们都彼此竞争着对 Web 功能进行越来越好的优化。
如果你遇到这样的情形,即一个浏览器有性能问题而其他浏览器没有,那就要确保通过随便什么可用的渠道把这个问题报告其开发者。多数浏览器都提供了开放的 bug 跟踪工具用于此处。
我建议只有在浏览器的性能问题确实引发彻底的中断性故障时才去绕过它,不要仅仅因为它让人讨厌就那么做。我也会非常小心地检查,以确定性能 hack 在其他浏览器上不会有显著的消极副作用。
6.5.2 大局
我们应该关注优化的大局,而不是担心这些微观性能的细微差别。
怎么知道什么是大局呢?首先要了解你的代码是否运行在关键路径上。如果不在关键路径上,你的优化就很可能得不到很大的收益。
有没有听过“这是过早优化”这样的警告?这来自于高德纳著名的一句话:“过早优化是万恶之源。”很多开发者都会引用这句话来说明多数优化都是“过早的”,因此是白费力气。和通常情况一样,事实要更加微妙一些。
这里是高德纳的原话及上下文(http://web.archive.org/web/20130731202547/http://pplab.snu.ac.kr/courses/adv_pl05/papers/p261-knuth.pdf )(重点强调):
程序员们浪费了大量的时间用于思考,或担心他们程序中非关键部分的速度,这些针对效率的努力在调试和维护方面带来了强烈的负面效果。我们应该在,比如说 97% 的时间里,忘掉小处的效率:过早优化是万恶之源。但我们不应该错过关键的 3% 中的机会。
——计算访谈 6(1974 年 12 月)
我相信这么解释高德纳的意思是合理的:“非关键路径上的优化是万恶之源。”所以,关键是确定你的代码是否在关键路径上——如果在的话,就应该优化!
甚至可以更进一步这么说:花费在优化关键路径上的时间不是浪费,不管节省的时间多么少;而花在非关键路径优化上的时间都不值得,不管节省的时间多么多。
如果你的代码在关键路径上,比如是一段将要反复运行多次的“热”代码,或者在用户会注意到的 UX 关键位置上,如动画循环或 CSS 风格更新,那你就不应该吝惜精力去采用有意义的、可测量的有效优化。
举例来说,考虑一下:一个关键路径动画循环需要把一个字符串类型转换到数字。当然有很多种方法可以实现(参见本书的“类型和语法”部分),但是哪一种,如果有的话,是最快的呢?
var x = "42"; // 需要数字42 // 选择1:让隐式类型转换自动发生 var y = x / 2; // 选择2:使用parseInt(..) var y = parseInt( x, 0 ) / 2; // 选择3:使用Number(..) var y = Number( x ) / 2; // 选择4:使用一元运算符+ var y = +x / 2; // 选项5:使用一元运算符| var y = (x | 0) / 2;
我将把这个问题留给你作为练习。如果感兴趣的话,可以建立一个测试,检查这些选择之间的性能差异。
在考虑这些不同的选择时,就像别人说的,“其中必有一个是与众不同的”。parseInt(..) 可以实现这个功能,但是它也做了更多的工作:它解析字符串而不是近几年进行类型转换。你很可能会猜测 parseInt(..) 是一个比较慢的选择,应该避免,这是正确的。
当然,如果 x 可能是一个需要解析 的值,比如 "42px" (比如来自 CSS 风格查找),那 parseInt(..) 就确实是唯一合理的选择了!
Number(..) 也是一个函数调用。从行为角度说,它和一元运算符 + 选择是完全一样的,但实际上它可能更慢一些,要求更多的执行函数的机制。当然,也可能 JavaScript 引擎意识到了行为上的相同性,会帮你把 Number(..) 在线化(即 +x )!
但是,请记住,沉迷于 +x 与 x | 0 的对比在绝大多数情况下都是浪费时间。这是一个微观性能问题,是一个你不应该让其影响程序可读性的问题。
尽管程序关键路径上的性能非常重要,但这并不是唯一要考虑的因素。在性能方面大体相似的几个选择中,可读性应该是另外一个重要的考量因素。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论