为什么添加立即调用的 lambda 会使我的 JavaScript 代码速度提高 2 倍?

我正在将一种语言的编译器优化为 JavaScript,并发现了一个非常有趣(如果不是令人沮丧)的案例:


function add(n,m) {

  return n === 0 ? m : add(n - 1, m) + 1;

};

var s = 0;

for (var i = 0; i < 100000; ++i) {

  s += add(4000, 4000);

}

console.log(s);

它需要2.3s在我的机器上完成[1]。但如果我做一个很小的改变:


function add(n,m) {

  return (() => n === 0 ? m : add(n - 1, m) + 1)();

};

var s = 0;

for (var i = 0; i < 100000; ++i) {

  s += add(4000, 4000);

}

console.log(s);

它完成于1.1s. (() => ...)()请注意,唯一的区别是在 的返回周围添加了立即调用的 lambda add。为什么这个添加的调用使我的程序速度提高了一倍?


[1] MacBook Pro 13 英寸 2020,2.3 GHz 四核 Intel Core i7,Node.js v15.3.0


侃侃尔雅
浏览 86回答 1
1回答

慕的地6264312

有趣的!从代码来看,很明显 IIFE 包装的版本应该更慢,而不是更快:在每次循环迭代中,它都会创建一个新的函数对象并调用它(优化编译器最终会避免这种情况,但这并不会)不会立即启动),所以通常只是做更多的工作,这应该花费更多的时间。本例中的解释是内联。一点背景知识:将一个函数内联到另一个函数中(而不是调用它)是优化编译器为了实现更好的性能而执行的标准技巧之一。不过,它是一把双刃剑:从好的方面来说,它避免了调用开销,并且通常可以实现进一步的优化,例如恒定传播或消除重复计算(请参阅下面的示例)。不利的一面是,它会导致编译时间更长(因为编译器做了更多工作),并且会导致生成更多代码并将其存储在内存中(因为内联函数实际上会重复它),并且在像 JavaScript 这样的动态语言中,优化的代码通常依赖于受保护的假设,一般来说,做出完美的内联决策(不要太多,也不要太少)需要预测未来:提前知道代码执行的频率和参数。当然,这是不可能的,因此优化编译器使用各种规则/“启发式”来猜测什么可能是一个相当好的决定。V8 当前的一项规则是:不要内联递归调用。这就是为什么在代码的简单版本中,add不会内联到自身中。IIFE 版本本质上有两个相互调用的函数,这被称为“相互递归”——事实证明,这个简单的技巧足以欺骗 V8 的优化编译器并使其回避“不要内联递归调用”规则。相反,它愉快地将未命名的 lambda 内联到add,然后add内联到未命名的 lambda 中,依此类推,直到大约 30 轮后其内联预算用完。(旁注:“内联多少”是有点复杂的启发法之一,特别是考虑到函数大小,因此我们在这里看到的任何特定行为确实是针对这种情况的。)在这种特定场景中,所涉及的函数非常小,内联很有帮助,因为它避免了调用开销。因此,在这种情况下,内联提供了更好的性能,即使它是递归内联的(伪装的)情况,这通常通常对性能不利。它确实是有代价的:在简单版本中,优化编译器只花费 3 毫秒进行编译add,为其生成 562 字节的优化代码。在 IIFE 版本中,编译器花费 30 毫秒并生成 4318 字节的优化代码add。这就是为什么它不像“V8 应该总是内联更多”那么简单的原因之一:编译的时间和电池消耗很重要,内存消耗也很重要,以及简单的 10 行代码中可接受的成本(并显着提高性能)在 100,000 行应用程序中,演示很可能会产生不可接受的成本(甚至可能会影响整体性能)。现在,了解了发生了什么之后,我们可以回到“IIFE 有开销”的直觉,并制作一个更快的版本:function add(n,m) {&nbsp; return add_inner(n, m);};function add_inner(n, m) {&nbsp; return n === 0 ? m : add(n - 1, m) + 1;}在我的机器上,我看到:简单版本:1650 毫秒IIFE 版本:720 毫秒add_inner 版本:460 毫秒当然,如果您add(n, m)简单地实现为return n + m,那么它会在 2 毫秒内终止——算法优化胜过优化编译器可能完成的任何事情:-)附录:优化好处的示例。考虑这两个函数:function Process(x) {&nbsp; return (x ** 2) + InternalDetail(x, 0, 2);}function InternalDetail(x, offset, power) {&nbsp; return (x + offset) ** power;}(显然,这是愚蠢的代码;但我们假设它是在实践中有意义的东西的简化版本。)当天真地执行时,会发生以下步骤:评价temp1 = (x ** 2)带InternalDetail参数调用x,&nbsp;0,2评价temp2 = (x + 0)评价temp3 = temp2 ** 2返回temp3给调用者评价temp4 = temp1 + temp3返回temp4。如果优化编译器执行内联,那么第一步它将得到:function&nbsp;Process_after_inlining(x)&nbsp;{ &nbsp;&nbsp;return&nbsp;(x&nbsp;**&nbsp;2)&nbsp;+&nbsp;(&nbsp;(x&nbsp;+&nbsp;0)&nbsp;**&nbsp;2&nbsp;); }它允许两种简化:x + 0可以折叠为x,然后x ** 2计算发生两次,因此可以通过重用第一次的结果来替换第二次:function&nbsp;Process_with_optimizations(x)&nbsp;{ &nbsp;&nbsp;let&nbsp;temp1&nbsp;=&nbsp;x&nbsp;**&nbsp;2; &nbsp;&nbsp;&nbsp;&nbsp;return&nbsp;temp1&nbsp;+&nbsp;temp1; }因此,与简单的执行相比,我们从 7 个步骤减少到了 3 个步骤:评价temp1 = (x ** 2)评价temp2 = temp1 + temp1返回temp2我并不是预测实际性能会从 7 个时间单位变为 3 个时间单位;这只是为了直观地说明为什么内联可以帮助减少一定量的计算负载。脚注:为了说明所有这些东西是多么棘手,请考虑在 JavaScript 中用x + 0just替换x并不总是可能的,即使编译器知道它x总是一个数字:如果x碰巧是-0,那么添加0到它会将其更改为+0,这很可能是可观察的程序行为;-)
打开App,查看更多内容
随时随地看视频慕课网APP

相关分类

JavaScript