为什么这个函数调用的执行时间会发生变化?

Why is the execution time of this function call changing?

提问人:Lewis 提问时间:7/3/2020 最后编辑:Lewis 更新时间:7/14/2020 访问量:4017

问:

前言

此问题似乎只影响 Chrome/V8,可能无法在 Firefox 或其他浏览器中重现。总之,如果函数回调在其他任何地方使用新回调调用,则函数回调的执行时间会增加一个数量级或更多。

简化的概念验证

多次任意调用会按预期工作,但是一旦调用,无论提供什么回调,函数的执行时间都会急剧增加(即,另一个调用也会受到影响)。test(callback)test(differentCallback)testtest(callback)

此示例已更新为使用参数,以免优化为空循环。回调参数 ab 相加并相加到总计中,并记录下来。

function test(callback) {
    let start = performance.now(),
        total = 0;

    // add callback result to total
    for (let i = 0; i < 1e6; i++)
        total += callback(i, i + 1);

    console.log(`took ${(performance.now() - start).toFixed(2)}ms | total: ${total}`);
}

let callback1 = (a, b) => a + b,
    callback2 = (a, b) => a + b;

console.log('FIRST CALLBACK: FASTER');
for (let i = 1; i < 10; i++)
    test(callback1);

console.log('\nNEW CALLBACK: SLOWER');
for (let i = 1; i < 10; i++)
    test(callback2);


原文

我正在为我正在编写的库开发一个类(源代码),逻辑按预期工作,但在分析它时,我遇到了一个问题。我注意到,当我运行分析片段(在全局范围内)时,只需大约 8 毫秒即可完成,但如果我第二次运行它,则最多需要 50 毫秒,最终膨胀到 400 毫秒。通常,随着 V8 引擎的优化,一遍又一遍地运行相同的命名函数会导致其执行时间下降,但这里似乎发生了相反的情况。StateMachine

我已经能够通过将其包装在闭包中来摆脱这个问题,但后来我注意到另一个奇怪的副作用:调用依赖于类的不同函数会破坏所有代码的性能,具体取决于类。StateMachine

该类非常简单 - 您可以在构造函数中给它一个初始状态 或 ,然后您可以使用方法更新状态,该方法传递一个接受为参数的回调(并且通常会修改它)。 是用于状态的方法,直到不再满足 。initupdatethis.statetransitionupdatetransitionCondition

提供了两个测试函数:和 ,它们是相同的,每个函数都会生成一个初始状态为 和 使用该方法的状态 while 。最终状态为 。redblueStateMachine{ test: 0 }transitionupdatestate.test < 1e6{ test: 1000000 }

您可以通过单击红色或蓝色按钮来触发配置文件,该按钮将运行 50 次并记录呼叫完成所需的平均时间。如果反复单击红色或蓝色按钮,您会看到它以不到 10 毫秒的速度打卡而不会出现问题 - 但是,一旦您单击另一个按钮并调用同一函数的其他版本,一切都会中断,并且两个函数的执行时间将增加大约一个数量级。StateMachine.transition

// two identical functions, red() and blue()

function red() {
  let start = performance.now(),
      stateMachine = new StateMachine({
        test: 0
      });

  stateMachine.transition(
    state => state.test++, 
    state => state.test < 1e6
  );

  if (stateMachine.state.test !== 1e6) throw 'ASSERT ERROR!';
  else return performance.now() - start;
}

function blue() {
  let start = performance.now(),
      stateMachine = new StateMachine({
        test: 0
      });

  stateMachine.transition(
    state => state.test++, 
    state => state.test < 1e6
  );

  if (stateMachine.state.test !== 1e6) throw 'ASSERT ERROR!';
  else return performance.now() - start;
}

// display execution time
const display = (time) => document.getElementById('results').textContent = `Avg: ${time.toFixed(2)}ms`;

// handy dandy Array.avg()
Array.prototype.avg = function() {
  return this.reduce((a,b) => a+b) / this.length;
}

// bindings
document.getElementById('red').addEventListener('click', () => {
  const times = [];
  for (var i = 0; i < 50; i++)
    times.push(red());
    
  display(times.avg());
}),

document.getElementById('blue').addEventListener('click', () => {
  const times = [];
  for (var i = 0; i < 50; i++)
    times.push(blue());
    
  display(times.avg());
});
<script src="https://cdn.jsdelivr.net/gh/TeleworkInc/state-machine@bd486a339dca1b3ad3157df20e832ec23c6eb00b/StateMachine.js"></script>

<h2 id="results">Waiting...</h2>
<button id="red">Red Pill</button>
<button id="blue">Blue Pill</button>

<style>
body{box-sizing:border-box;padding:0 4rem;text-align:center}button,h2,p{width:100%;margin:auto;text-align:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"}button{font-size:1rem;padding:.5rem;width:180px;margin:1rem 0;border-radius:20px;outline:none;}#red{background:rgba(255,0,0,.24)}#blue{background:rgba(0,0,255,.24)}
</style>

更新

错误报告“功能请求”已提交(等待更新) - 有关详细信息,请参阅下面的 @jmrk 的回答。

归根结底,这种行为是出乎意料的,并且 IMO 有资格成为一个重要的错误。对我的影响是巨大的 - 在英特尔 i7-4770 (8) @ 3.900GHz 上,我在上述示例中的执行时间从平均 2 毫秒增加到 45 毫秒(增加了 20 倍)。

至于非平凡性,请考虑在第一个调用之后的任何后续调用都会不必要地缓慢,无论代码中的范围或位置如何。事实上,SpiderMonkey 不会减慢后续调用的速度,向我发出信号,表明 V8 中这个特定的优化逻辑还有改进的余地。StateMachine.transitiontransition

请参阅下文,其中后续调用速度变慢:StateMachine.transition

// same source, several times

// 1
(function() {
  let start = performance.now(),
    stateMachine = new StateMachine({
      test: 0
    });

  stateMachine.transition(state => state.test++, state => state.test < 1e6);

  if (stateMachine.state.test !== 1e6) throw 'ASSERT ERROR!';
  console.log(`took ${performance.now() - start}ms`);
})();


// 2 
(function() {
  let start = performance.now(),
    stateMachine = new StateMachine({
      test: 0
    });

  stateMachine.transition(state => state.test++, state => state.test < 1e6);

  if (stateMachine.state.test !== 1e6) throw 'ASSERT ERROR!';
  console.log(`took ${performance.now() - start}ms`);
})();

// 3
(function() {
  let start = performance.now(),
    stateMachine = new StateMachine({
      test: 0
    });

  stateMachine.transition(state => state.test++, state => state.test < 1e6);

  if (stateMachine.state.test !== 1e6) throw 'ASSERT ERROR!';
  console.log(`took ${performance.now() - start}ms`);
})();
<script src="https://cdn.jsdelivr.net/gh/TeleworkInc/state-machine@bd486a339dca1b3ad3157df20e832ec23c6eb00b/StateMachine.js"></script>

通过将代码包装在命名闭包中可以避免这种性能下降,其中优化器可能知道回调不会更改:

var test = (function() {
    let start = performance.now(),
        stateMachine = new StateMachine({
            test: 0
        });
  
    stateMachine.transition(state => state.test++, state => state.test < 1e6);
  
    if (stateMachine.state.test !== 1e6) throw 'ASSERT ERROR!';
    console.log(`took ${performance.now() - start}ms`);
});

test();
test();
test();
<script src="https://cdn.jsdelivr.net/gh/TeleworkInc/state-machine@bd486a339dca1b3ad3157df20e832ec23c6eb00b/StateMachine.js"></script>

平台信息

$ uname -a
Linux workspaces 5.4.0-39-generic #43-Ubuntu SMP Fri Jun 19 10:28:31 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

$ google-chrome --version
Google Chrome 83.0.4103.116
JavaScript 性能测试 Chromium v8

评论

8赞 Scott Sauyet 7/3/2020
这是一个非常好的问题,您的演示准确地说明了您的意思。我真的很抱歉,我没有任何见解可以提供。您是否在非 V8 环境中尝试过?
3赞 Lewis 7/3/2020
@ScottSauyet 噢,你看!我在 FF 中没有看到这个,所以它一定是一个 V8 错误 - 您建议我关闭它还是保留它,以防万一它因我犯的错误或其他原因而加剧?
5赞 D. Pardal 7/3/2020
真的看起来像是 V8 的东西。SpiderMokey 相当稳定,平均约为 5 毫秒。您可能想要检查这是否也发生在 Node.js 中,只是为了检查问题是否来自 V8。
3赞 Lewis 7/3/2020
@JonTrent 如果一次运行得到 16 毫秒,下次运行得到 46 毫秒,这几乎不是一致性(在 3 倍方差下)。如果您的执行时间在单击第二个按钮后像这样膨胀(然后停滞不前),那么您在不知不觉中重现了该问题。无论如何,请不要将责任归咎于此,除非您能证明情况确实如此。performance.now
4赞 user120242 7/3/2020
@OP 先不要关闭它。等待一些常驻的 V8 专家(如 @jmrk)跳上去交谈。它可能是一个错误,也可能是某种预期的行为优化。(如果我们能就特殊问题向知名专家求助,那就太好了)

答:

50赞 jmrk 7/3/2020 #1

V8 开发人员在这里。这不是一个错误,这只是 V8 没有做的优化。有趣的是,Firefox似乎做到了......

FWIW,我没有看到“膨胀到 400 毫秒”;相反(类似于 Jon Trent 的评论)我首先看到大约 2.5 毫秒,然后是大约 11 毫秒。

解释如下:

当您只单击一个按钮时,则只会看到一个回调。(严格来说,它每次都是箭头函数的新实例,但由于它们都源于源中的相同函数,因此出于类型反馈跟踪目的,它们被“删除了重复数据”。此外,严格来说,它是 和 一个回调,但这只是重复了这种情况;任何一个单独都会重现它。当被优化时,优化编译器决定内联被调用的函数,因为过去只看到一个函数,它可以做出一个高置信度的猜测,即它将来也总是那个函数。由于该函数执行的工作非常少,因此避免调用它的开销可提供巨大的性能提升。transitionstateTransitiontransitionConditiontransition

单击第二个按钮后,会看到第二个功能。第一次发生这种情况时,它必须被取消优化;由于它仍然很热,它很快就会被重新优化,但这次优化器决定不内联,因为它之前见过不止一个函数,而且内联可能非常昂贵。结果是,从现在开始,您将看到实际执行这些调用所需的时间。(两个函数具有相同的来源这一事实并不重要;检查这一点是不值得的,因为除了玩具示例之外,这种情况几乎永远不会发生。transition

有一个解决方法,但它有点像黑客,我不建议将黑客放入用户代码中以考虑引擎行为。V8 确实支持“多态内联”,但(目前)只有当它可以从某个对象的类型中推断出调用目标时。因此,如果你构造的“config”对象在其原型上安装了正确的函数作为方法,你可以让 V8 将它们内联起来。这样:

class StateMachine {
  ...
  transition(config, maxCalls = Infinity) {
    let i = 0;
    while (
      config.condition &&
      config.condition(this.state) &&
      i++ < maxCalls
    ) config.transition(this.state);

    return this;
  }
  ...
}

class RedConfig {
  transition(state) { return state.test++ }
  condition(state) { return state.test < 1e6 }
}
class BlueConfig {
  transition(state) { return state.test++ }
  condition(state) { return state.test < 1e6 }
}

function red() {
  ...
  stateMachine.transition(new RedConfig());
  ...
}
function blue() {
  ...
  stateMachine.transition(new BlueConfig());
  ...
}

可能值得提交一个错误(crbug.com/v8/new)来询问编译器团队是否认为这值得改进。从理论上讲,应该可以内联多个直接调用的函数,并根据所调用的函数变量的值在内联路径之间分支。但是,我不确定在很多情况下,影响是否像这个简单的基准测试一样明显,而且我知道最近的趋势是内联更少而不是更多,因为平均而言,这往往是更好的权衡(联有缺点,是否值得它必然总是一个猜测, 因为引擎必须预测未来才能确定)。

总之,使用许多回调进行编码是一种非常灵活且通常很优雅的技术,但它往往会以效率成本为代价。(还有其他种类的低效率:例如,带有内联箭头函数的调用,例如每次执行时都会分配一个新的函数对象;这在手头的例子中恰好并不重要。有时引擎可能能够优化开销,有时则不能。transition(state => state.something)

评论

2赞 Lewis 7/3/2020
非常感谢您非常有帮助的回答。我将尝试重现一些额外的调用会将执行时间推高到 400 毫秒的情况(我肯定设法独立多次生成),然后我会将其标记为答案。=)
3赞 Lewis 7/3/2020
我无法在随后的函数调用中重现 400 毫秒的执行时间,我确实用一些额外的示例更新了 OP,以及为什么我认为这是一个错误(而且是一个不平凡的错误)。非常感谢您的回答!
0赞 Lewis 7/4/2020
用简化的概念验证更新了 OP,并将其卡在顶部。V8 团队似乎同意你的观点,认为这不是一个错误,并将其标记为功能请求(字面意思是 INABIAF),所以我想每个人都应该记住,你应该只调用一个函数,该函数在 V8 环境中经常访问回调一次,否则你会受到巨大的性能打击。完全是设计使然,这里没什么可看的!🤐
1赞 jmrk 7/4/2020
我认为你的新总结具有误导性。这与重复通话无关。我总结为:如果你对一个函数有多个调用,每个调用都传递一个不同的回调,那么这些回调就不会内联。如果你在简化的重现中删除“第一次调用”行,你会看到你可以随心所欲地运行循环,并且不会有减速。不同的箭头函数具有相同的源这一事实并不重要 - 如果它们是单独定义的,那么它们就算作单独的函数。
1赞 jmrk 7/4/2020
哦,还有,说“此行为是一个功能,而不是一个错误”和“您的报告是对新功能的请求”之间有一个关键的区别。后者(这就是这里发生的事情)只是意味着:软件没有损坏;所以你要求的不是修复它,而是改进它。
18赞 jmrk 7/4/2020 #2

由于这引起了如此多的兴趣(以及对问题的更新),我想我会提供一些额外的细节。

新的简化测试用例很棒:它非常简单,并且非常清楚地显示了一个问题。

function test(callback) {
  let start = performance.now();
  for (let i = 0; i < 1e6; i++) callback();
  console.log(`${callback.name} took ${(performance.now() - start).toFixed(2)}ms`);
}

var exampleA = (a,b) => 10**10;
var exampleB = (a,b) => 10**10;

// one callback -> fast
for (let i = 0; i < 10; i++) test(exampleA);

// introduce a second callback -> much slower forever
for (let i = 0; i < 10; i++) test(exampleB);
for (let i = 0; i < 10; i++) test(exampleA);

在我的机器上,我看到时间低至 0.23 毫秒,例如 A,然后当示例 B 出现时,它们上升到 7.3 毫秒,并保持在那里。哇,减速 30 倍!显然这是 V8 中的一个错误?为什么团队不跳起来解决这个问题?

好吧,情况比一开始看起来要复杂得多。

首先,“慢”的情况是正常情况。这就是您应该在大多数代码中看到的内容。它仍然相当快!您可以在短短 7 毫秒内完成 100 万次函数调用(加上 100 万次幂,外加 100 万次循环迭代)!每次迭代+调用+幂+返回只需 7 纳秒!

实际上,这种分析有点简化。实际上,对两个常量的操作在编译时会被常量折叠,因此一旦 exampleA 和 exampleB 得到优化,它们的优化代码将立即返回,而无需进行任何乘法。 另一方面,这里的代码包含一个小的疏忽,导致引擎不得不做更多的工作:exampleA 和 exampleB 接受两个参数,但它们在没有任何参数的情况下被调用,只是 。弥合预期参数数和实际参数数之间的这种差异是很快的,但是在像这样没有太多其他事情的测试中,它大约占总时间的 40%。因此,更准确的说法是:执行循环迭代、函数调用、数字常量的具体化和函数返回大约需要 4 纳秒,如果引擎还必须调整调用的参数计数,则需要 7 ns。10**101e10(a, b)callback()

那么,示例A的初始结果呢,这种情况怎么可能快得多呢?好吧,这是幸运的情况,在 V8 中遇到了各种优化,并且可以走几条捷径——事实上,它可以走很多捷径,以至于它最终成为一个误导性的微基准:它产生的结果不能反映真实情况,并且很容易导致观察者得出错误的结论。“总是相同的回调”(通常)比“几个不同的回调”快的一般效果当然是真实的,但这个测试显着扭曲了差异的大小。 起初,V8 发现调用的始终是同一个函数,因此优化编译器决定内联函数而不是调用它。这避免了立即对论点进行调整。内联后,编译器还可以看到幂的结果从未被使用过,因此它完全删除了该结果。最终结果是,此测试测试了一个空循环!亲眼看看:

function test_empty(no_callback) {
  let start = performance.now();
  for (let i = 0; i < 1e6; i++) {}
  console.log(`empty loop took ${(performance.now() - start).toFixed(2)}ms`);
}

这给了我与调用 exampleA 相同的 0.23 毫秒。因此,与我们的想法相反,我们没有测量调用和执行示例A所需的时间,实际上我们根本没有测量调用,也没有幂。(如果你喜欢更直接的证明,你可以在 或 with 中运行原始测试,并查看 V8 内部生成的优化代码的反汇编。10**10d8node--print-opt-code

综上所述,我们可以得出以下结论:

(1) 这不是“天哪,你必须在代码中注意和避免这种可怕的减速”的情况。当您不担心这一点时,您获得的默认性能很棒。有时,当星星对齐时,您可能会看到更令人印象深刻的优化,但是......轻描淡写地说:仅仅因为你每年只收到几次礼物,并不意味着所有其他不带礼物的日子都是必须避免的可怕错误。

(2) 测试用例越小,观察到的默认速度和幸运快速用例之间的差异就越大。如果你的回调正在做编译器无法消除的实际工作,那么差异将比这里看到的要小。如果您的回调比单个操作执行更多的工作,那么在调用本身上花费的总时间比例会更小,因此用内联替换调用的区别会比这里小。如果使用所需的参数调用函数,则可以避免此处看到的不必要的惩罚。因此,尽管这个微基准测试设法给人一种误导性的印象,即存在令人震惊的 30 倍差异,但在大多数实际应用中,在极端情况下,它可能会介于 4 倍之间,而在许多其他情况下“甚至根本无法测量”。

(3) 函数调用确实有成本。(对于许多语言,包括 JavaScript)我们有优化编译器,有时可以通过内联来避免它们,这真是太好了。如果你有一个案例,你真的非常关心每一个性能,而你的编译器碰巧没有内联你认为它应该内联的内容(无论出于什么原因:因为它不能,或者因为它有内部启发式方法决定不内联),那么重新设计你的代码可以带来显着的好处——例如,你可以手动内联, 或者以其他方式重构您的控制流,以避免在最热的循环中对微小函数进行数百万次调用。(不过,不要盲目地过度:函数太少、太大也不利于优化。通常最好不要担心这一点。将代码组织成有意义的块,让引擎处理剩下的工作。我只是说,有时,当你观察到特定问题时,你可以帮助引擎更好地完成它的工作。 如果您确实需要依赖对性能敏感的函数调用,那么您可以做的一个简单的调整是确保使用与预期数量完全相同的参数来调用函数 - 这可能是您经常会做的事情。当然,可选参数也有其用途;与许多其他情况一样,额外的灵活性伴随着(小的)性能成本,这通常可以忽略不计,但当您觉得有必要时可以考虑。

(4) 观察这种性能差异可能会令人惊讶,有时甚至令人沮丧,这是可以理解的。不幸的是,优化的本质是,它们不能总是被应用:它们依赖于简化的假设,而不是涵盖所有情况,否则它们将不再快。我们非常努力地为您提供可靠、可预测的性能,尽可能多地使用快速案例和尽可能少的慢速案例,并且它们之间没有陡峭的悬崖。但我们无法逃避这样一个现实,即我们不可能“让一切变得快”。(当然,这并不是说没有什么可做的:每增加一年的工程工作都会带来额外的性能提升。如果我们想避免所有或多或少相似的代码表现出明显不同的性能的情况,那么实现这一目标的唯一方法就是根本不做任何优化,而是让一切都处于基线(“慢”)实现状态——我认为这不会让任何人满意。

编辑以添加:这里似乎不同 CPU 之间存在重大差异,这可能解释了为什么之前的评论者报告了如此截然不同的结果。在我能拿到的硬件上,我看到:

  • i7 6600U:3.3 毫秒(内联案例),28 毫秒(通话)
  • i7 3635QM:2.8 ms(内联案例),10 ms(通话)
  • i7 3635QM,最新微码:2.8 ms(内联大小写),26 ms(通话)
  • Ryzen 3900X:2.5 毫秒(内联案例),5 毫秒(通话)

这一切都适用于 Linux 上的 Chrome 83/84;在 Windows 或 Mac 上运行很可能会产生不同的结果(因为 CPU/微码/内核/沙箱彼此密切交互)。 如果您发现这些硬件差异令人震惊,请阅读“幽灵”。

评论

0赞 Lewis 7/5/2020
非常感谢您的第二个(更明确)的回答。我唯一的问题是,为什么你把注意力集中在我的新复制函数被优化为空循环这一事实上——在示例中,情况显然并非如此。回调正在做事,它正在修改为参数,我不明白“优化到空循环”如何扩展到该示例,但是您对简化 POC 的解释非常详细,值得赞赏。StateMachinethis.state
0赞 Lewis 7/5/2020
对于所有的问题和评论,我深表歉意,我很难将所有这些与模式相协调,该模式在非 V8 环境中完全符合我的预期(例如,所有重现在 Firefox 中都失败了)。您确实提供了解决方法,但也建议不要使用它。StateMachine
0赞 Lewis 7/5/2020
我更新了简单的 POC 以实际接受参数,因此不会优化为空循环。我很欣赏有人提到这一点,但它似乎与手头的实际问题无关(正如第一个示例所证明的那样,它使用了回调参数并对其进行了操作)。StateMachine.transition
1赞 jmrk 7/5/2020
这个答案侧重于你的新复制品;我的原始答案(不变)侧重于您的原始示例。这就是为什么一个提到空循环而另一个没有提到。-- 正如你所看到的,通过进一步更改示例以生成未使用的巨大数字,你使非内联调用的速度慢得多(7 -> 40 毫秒),因为它现在做了更多的工作,而内联大小写仍然被优化为空循环。因此,该测试观察到的性能差异更加夸张,因此现在更具误导性。StateMachine
1赞 Bergi 7/16/2020
@jmrk “最终结果是这个测试测试了一个空循环” - 我正在等待一个版本的 v8(偶然 - 我知道你不会专门搜索空循环,因为它们不常见)会检测空循环并完全优化它,这样基准测试将测量 0.00 毫秒,每个被他们的微基准测试愚弄的人都会注意到有些不对劲。