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

Posted

技术标签:

【中文标题】为啥这个函数调用的执行时间会发生变化?【英文标题】:Why is the execution time of this function call changing?为什么这个函数调用的执行时间会发生变化? 【发布时间】:2020-10-23 13:19:23 【问题描述】:

前言

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

简化的概念验证

任意多次调用test(callback) 可以按预期工作,但是一旦调用test(differentCallback),无论提供什么回调,test 函数的执行时间都会显着增加(即,再次调用test(callback) 会受到影响以及)。

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

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);

原帖

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

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

这个类非常简单——你在构造函数或init中给它一个初始状态,你可以用update方法更新状态,你传递一个接受this.state作为参数的回调(并且通常会修改它)。 transition 是一种用于update 状态直到不再满足transitionCondition 的方法。

提供了两个测试函数:redblue完全相同,每个都会生成一个初始状态为 test: 0 StateMachine,并使用transition update 状态的方法,而 state.test &lt; 1e6。结束状态是 test: 1000000

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

// 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>
bodybox-sizing:border-box;padding:0 4rem;text-align:centerbutton,h2,pwidth: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"buttonfont-size:1rem;padding:.5rem;width:180px;margin:1rem 0;border-radius:20px;outline:none;#redbackground:rgba(255,0,0,.24)#bluebackground:rgba(0,0,255,.24)
</style>

更新

Bug Report "Feature Request" filed(等待更新)- 更多详情请参见下面@jmrk 的回答。

最终,这种行为是出乎意料的,并且在 IMO 中被认为是一个重要的错误。对我的影响是显着的 - 在 Intel i7-4770 (8) @ 3.900GHz 上,我在上面示例中的执行时间从平均 2 毫秒到 45 毫秒(增加了 20 倍)。

至于不平凡,考虑到在第一次调用之后对StateMachine.transition 的任何后续调用都会不必要地缓慢,无论代码中的范围或位置如何。 SpiderMonkey 不会减慢对transition 的后续调用这一事实向我表明,V8 中这种特定的优化逻辑还有改进的空间。

见下文,随后对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() - startms`);
)();


// 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() - startms`);
)();

// 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() - startms`);
)();
&lt;script src="https://cdn.jsdelivr.net/gh/TeleworkInc/state-machine@bd486a339dca1b3ad3157df20e832ec23c6eb00b/StateMachine.js"&gt;&lt;/script&gt;

可以通过将代码包装在 named 闭包中来避免这种性能下降,大概优化器知道回调不会改变:

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() - startms`);
);

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

平台信息

$ 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

【问题讨论】:

这是一个问得很好的问题,您的演示准确地说明了您的意思。我真的很抱歉我没有任何见解可以提供。你在非 V8 环境下试过了吗? @ScottSauyet 哦,看那个!我在 FF 中没有看到这个,所以它一定是一个 V8 错误 - 你会建议我关闭它还是保留它,以防万一它因我犯的错误或其他原因而加剧? 真的看起来像一个 V8 的东西。 SpiderMokey 相当一致,平均在 5ms 左右。您可能想检查 Node.js 中是否也发生这种情况,只是为了检查问题是否来自 V8。 @JonTrent 如果你一次运行 16 毫秒,下一次运行 46 毫秒,这几乎不是一致性(在 3 倍方差下)。如果在单击第二个按钮后您的执行时间像那样膨胀(然后是平稳),那么您已经在不知情的情况下重现了该问题。无论如何,请不要将责任归咎于performance.now,除非你能证明是这样。 @OP 不要关闭它。等待一些常驻 V8 专家(如 @jmrk)加入并交谈。这可能是一个错误,或者可能是某种预期行为的优化。 (如果我们能针对特殊问题联系知名专家,那就太好了) 【参考方案1】:

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

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

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);

在我的机器上,我看到仅 exampleA 的时间低至 0.23 ms,然后当 exampleB 出现时,它们会上升到 7.3ms,并保持在那里。哇,慢了 30 倍!显然这是 V8 中的一个错误?为什么团队不着手解决这个问题?

嗯,情况比起初看起来要复杂。

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

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

那么对于 exampleA 的初始结果呢,这种情况怎么会快得多?好吧,这是幸运的情况,它在 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 毫秒。因此,与我们的想法相反,我们没有测量调用和执行 exampleA 所需的时间,实际上我们根本没有测量任何调用,也没有测量到 10**10 求幂。 (如果你喜欢更直接的证明,可以在d8node--print-opt-code 中运行原始测试,并查看V8 内部生成的优化代码的反汇编。)

所有这一切让我们得出一些结论:

(1) 这不是“天哪,您必须在代码中意识到并避免这种可怕的减速”。当您不担心这一点时,您获得的默认性能非常好。 有时当星星对齐时,您可能会看到更令人印象深刻的优化,但是……简单地说:仅仅因为您每年只收到几次礼物,并不意味着所有其他非送礼日是一些必须避免的可怕错误。

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

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

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

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

i7 6600U:内联外壳 3.3 毫秒,调用 28 毫秒 i7 3635QM:内联 case 2.8 ms,调用 10 ms i7 3635QM,最新微码:2.8 ms 内联 case,26 ms 调用 Ryzen 3900X:内联外壳 2.5 毫秒,调用 5 毫秒

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

【讨论】:

非常感谢您的第二个(更明确的)回答。我唯一的问题是为什么你关注我的新 repro 函数被优化为一个空循环这一事实——在 StateMachine 示例中,显然不是这种情况。回调正在做事,它正在修改 this.state 作为参数,我不明白“优化为空循环”如何扩展到该示例,但您对简化 POC 的解释非常详细,值得赞赏。 我为所有的问题和 cmets 道歉,我很难将所有这些与StateMachine 模式协调一致,这与我在非 V8 环境中的预期完全一致(例如,所有的 repro 在 Firefox 中都失败了)。您确实提供了解决方法,但也建议不要使用它。 我更新了简单的 POC 以实际接受参数,因此不会针对空循环进行优化。我很欣赏有人提到了这一点,但它似乎与手头的实际问题无关(正如第一个示例 StateMachine.transition 所证明的那样,它使用了回调参数并对其进行了操作)。 这个答案的重点是你的新复制品;我的原始答案(未更改)侧重于您原来的 StateMachine 示例。这就是为什么一个提到空循环而另一个没有提到的原因。 -- 如您所见,通过进一步更改示例以生成未使用的大数字,您使非内联调用变得更慢(7 -> 40ms),因为它现在做了更多的工作,而内联的情况仍然得到优化为空循环。因此,测试中观察到的性能差异更加夸张,因此现在更加具有误导性。 非常抱歉,如果我用可重复的测量值误导了您。我更新了 repro 函数,以证明即使没有优化循环,这个问题仍然存在,我欢迎您强调 观察到的性能差异实际的性能差异 i> 因为它与这个问题有关。【参考方案2】:

V8 开发人员在这里。这不是 bug,只是 V8 没有做的优化。有趣的是,Firefox 似乎可以做到这一点......

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

解释如下:

当您只点击一个按钮时,transition 只会看到一个回调。 (严格来说,它每次都是箭头函数的一个新实例,但由于它们都源自源代码中的同一个函数,因此出于类型反馈跟踪的目的,它们被“重复数据删除”。此外,严格来说,这是一个回调 each 对于stateTransitiontransitionCondition,但这只是重复了这种情况;任何一个都会重现它。)当transition 得到优化时,优化编译器决定内联被调用的函数,因为只看到了一个过去那里的功能,它可以做出高度自信的猜测,它也总是会成为未来的一个功能。由于该函数所做的工作极少,因此避免调用它的开销可显着提升性能。

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

有一个解决方法,但它有点像黑客,我不建议将黑客放入用户代码中以解释引擎行为。 V8 确实支持“多态内联”,但(目前)只有当它可以从某个对象的类型中推断出调用目标时。因此,如果您构造“配置”对象,并在其原型上安装了正确的函数作为方法,您可以让 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) 以询问编译器团队是否认为这值得改进。从理论上讲,应该可以内联几个直接调用的函数,并根据被调用的函数变量的值在内联路径之间分支。但是我不确定在很多情况下影响是否像在这个简单的基准测试中那样明显,而且我知道最近的趋势是内联 less 而不是更多,因为平均而言做更好的权衡(内联有缺点,是否值得总是猜测,因为引擎必须预测未来才能确定)。

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

【讨论】:

非常感谢您非常有帮助的回答。我将尝试重现一些额外调用会将执行时间推至 400 毫秒的情况(我确实设法独立产生了多次),然后我将其标记为答案。 =) 我无法在后续函数调用中重现 400 毫秒的执行时间,但是我确实用一些额外的示例更新了 OP,以及为什么我认为这是一个错误(和一个不平凡的)。非常感谢您的回答! 使用简化的概念验证更新了 OP,并将其固定在顶部。 V8 团队似乎同意您认为这不是错误,并将其标记为功能请求(literally INABIAF),所以我想每个人都最好记住您应该只调用一个函数它在 V8 环境中只一次频繁地访问回调,否则您将受到巨大的性能影响。完全按照设计,这里没什么可看的! ? 我认为您的新摘要具有误导性。这与重复呼叫无关。我总结为:如果您对一个函数有多个调用,每个调用都传递一个不同的回调,那么这些回调将不会被内联。如果您在简化的重现中删除“第一次调用”行,您会发现您可以根据需要多次运行循环,并且不会减速。您的不同箭头函数具有相同来源这一事实并不重要 - 如果它们是单独定义的,那么它们将被视为单独的函数。 哦,另外,说“此行为是一项功能,而不是错误”和“您的报告是对新功能的请求”之间存在关键区别。后者(这就是这里发生的事情)只是意味着:软件没有损坏;所以你要的不是修复它,而是改进它。

以上是关于为啥这个函数调用的执行时间会发生变化?的主要内容,如果未能解决你的问题,请参考以下文章

如果 java 是按值传递的,为啥我的对象在执行方法后会发生变化?

为啥执行两次 "a, x = x, a" 不会导致值发生变化?

为啥在浏览器中调用和显示时存储在 localStorage 中的 HTML 会发生变化?

PyTorch:为啥在训练时期循环内部或外部调用验证准确性会发生变化?

为啥从模板或观察者调用时 this 范围会发生变化?

如果我为我的表命名,为啥 SQL 查询顺序会发生变化? [关闭]