gpt4 book ai didi

javascript - 为什么添加立即调用的lambda会使我的JavaScript代码快2倍?

转载 作者:行者123 更新时间:2023-12-03 12:10:25 24 4
gpt4 key购买 nike

我正在将语言的编译器优化为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

最佳答案

有趣的!通过查看代码,可以很明显地看出,用IIFE封装的版本应该更慢,而不是更快:在每次循环迭代中,它都会创建一个新的函数对象并调用它(优化编译器最终会避免,但是这样做不会) t马上开始),因此通常只会做更多的工作,这应该花费更多的时间。
这种情况下的解释是内联的。
有一点背景知识:将一个函数内联到另一个函数中(而不是调用它)是优化编译器执行以获得更好性能的标准技巧之一。但是,这是一把双刃剑:从正面来看,它避免了调用开销,并且通常可以实现进一步的优化,例如恒定传播或消除重复计算(请参见下面的示例)。不利的一面是,它会使编译花费更长的时间(因为编译器会做更多的工作),并且会导致生成更多的代码并将其存储在内存中(因为内联函数有效地复制了它),并且使用了动态语言(例如JavaScript),优化的代码通常依赖于 protected 假设,这增加了这些假设之一被证明是错误的风险,因此大量的优化代码不得不被丢弃。
一般来说,做出完美的内联决策(不要太多,也不要太少)需要预测 future :预先知道执行代码的频率和参数。当然,这是不可能的,因此优化编译器会使用各种规则/“启发式”来猜测可能是一个相当不错的决定。
V8当前具有的一条规则是:不要内联递归调用。
这就是为什么在您的代码的简单版本中,add不会内联到自身中。 IIFE版本本质上具有两个相互调用的函数,称为“相互递归”-事实证明,此简单技巧足以使V8的优化编译器傻瓜并使其避开其“请勿内联递归调用”规则。取而代之的是,它愉快地将未命名的lambda内联到add中,并将add插入未命名的lambda中,依此类推,直到其内联预算在大约30轮后用完。 (附带说明:“内联多少”是一种较为复杂的启发式方法,尤其要考虑到函数的大小,因此,我们在此处看到的任何特定行为的确针对此情况。)
在这种特殊情况下,所涉及的函数非常小,内联会很大程度地帮助您,因为它避免了调用开销。因此,在这种情况下,即使是(伪装的)递归内联的情况,内联也可以提供更好的性能,这通常对性能不利。而且确实要付出代价:在简单版本中,优化编译器仅花费3毫秒的​​时间来编译add,从而为其生成562字节的优化代码。在IIFE版本中,编译器花费30毫秒的时间,并为add生成4318字节的优化代码。这就是为什么它不像得出结论“V8应该总是内联更多”那样简单的原因:编译所需的时间和电池消耗,内存消耗也很重要,以及在简单的10行中可以接受的成本(并显着提高性能)在10万行的应用程序中,demo的成本(甚至可能会降低整体性能)是 Not Acceptable 。

现在,了解了所发生的情况之后,我们可以回到“IIFE有开销”的直觉,并制定一个更快的版本:

function add(n,m) {
return add_inner(n, m);
};
function add_inner(n, m) {
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) {
    return (x ** 2) + InternalDetail(x, 0, 2);
    }

    function InternalDetail(x, offset, power) {
    return (x + offset) ** power;
    }
    (显然,这是愚蠢的代码;但是让我们假设它是在实践中有意义的简化版本。)
    天真地执行时,将发生以下步骤:
  • 评估temp1 = (x ** 2)
  • 使用参数InternalDetailx0
  • 调用 2
  • 评估temp2 = (x + 0)
  • 评估temp3 = temp2 ** 2
  • temp3返回给调用者
  • 评估temp4 = temp1 + temp3
  • 返回temp4

  • 如果优化的编译器执行内联,则第一步将得到:
    function Process_after_inlining(x) {
    return (x ** 2) + ( (x + 0) ** 2 );
    }
    这可以进行两种简化: x + 0可以折叠为 x,然后 x ** 2计算发生两次,因此第二次出现可以通过重用第一个出现的结果来代替:
    function Process_with_optimizations(x) {
    let temp1 = x ** 2;
    return temp1 + temp1;
    }
    因此,与朴素的执行相比,我们从7个步骤减少了3个步骤:
  • 评估temp1 = (x ** 2)
  • 评估temp2 = temp1 + temp1
  • 返回temp2

  • 我并不是在预测现实世界的性能会从7个时间单位变为3个时间单位。这只是为了给出一个直观的想法,为什么内联可以帮助减少一定数量的计算量。
    脚注:为了说明所有这些技巧是多么棘手,请考虑一下即使在编译器知道 x + 0始终是数字的情况下,在JavaScript中也不总是只能用 x替换 x的情况:如果 x恰好是 -0,则向其中添加 0它到 +0,这很可能是可观察到的程序行为;-)

    关于javascript - 为什么添加立即调用的lambda会使我的JavaScript代码快2倍?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/65042502/

    24 4 0
    Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
    广告合作:1813099741@qq.com 6ren.com