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

C. *_*wis 60 javascript v8 performance-testing chromium

前言

此问题似乎只影响 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);
Run Code Online (Sandbox Code Playgroud)


原帖

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

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

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

提供了两个测试函数:redand blue它们是相同的,每个都会生成一个StateMachine初始状态为 的 ,{ test: 0 }并使用transition方法来update状态 while state.test < 1e6。结束状态是{ test: 1000000 }

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

// 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());
});
Run Code Online (Sandbox Code Playgroud)
<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>
Run Code Online (Sandbox Code Playgroud)

更新

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

最终,这种行为是出乎意料的,在 IMO 看来,它是一个重要的错误。对我的影响是巨大的 - 在 Intel i7-4770 (8) @ 3.900GHz 上,我在上面示例中的执行时间从平均 2ms 增加到 45ms(增加了 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() - 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`);
})();
Run Code Online (Sandbox Code Playgroud)
<script src="https://cdn.jsdelivr.net/gh/TeleworkInc/state-machine@bd486a339dca1b3ad3157df20e832ec23c6eb00b/StateMachine.js"></script>
Run Code Online (Sandbox Code Playgroud)

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

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();
Run Code Online (Sandbox Code Playgroud)
<script src="https://cdn.jsdelivr.net/gh/TeleworkInc/state-machine@bd486a339dca1b3ad3157df20e832ec23c6eb00b/StateMachine.js"></script>
Run Code Online (Sandbox Code Playgroud)

平台信息

$ 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
Run Code Online (Sandbox Code Playgroud)

jmr*_*mrk 48

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

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

这是解释:

当您只单击一个按钮时,transition只会看到一个回调。(严格地说它的箭头功能的新实例每次,但由于它们从源同样的功能都干,他们是“重复数据删除”的类型的反馈跟踪。此外,严格来说,它是一个回调stateTransitiontransitionCondition,但这只是重复了情况;任何一个都会重现它。)transition优化后,优化编译器决定内联被调用的函数,因为在过去只看到一个函数,它可以高度自信地猜测它是也将永远是未来的一个功能。由于该函数执行的工作极少,因此避免调用它的开销可提供巨大的性能提升。

单击第二个按钮后,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());
  ...
}
Run Code Online (Sandbox Code Playgroud)

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

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

  • 我无法在后续函数调用中重现 400 毫秒的执行时间,*但是*我确实用一些额外的示例更新了 OP,以及为什么我认为这是一个错误(而且是一个不平凡的错误)。非常感谢您的回答! (3认同)
  • 非常感谢您非常有帮助的回答。我将尝试重现这样的情况:一些额外的调用会将执行时间推至 400 毫秒(我确实成功地独立生成了多次),然后我将其标记为答案。=) (2认同)

jmr*_*mrk 15

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

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

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);
Run Code Online (Sandbox Code Playgroud)

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

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

首先,“慢”的情况是正常情况。这就是您应该在大多数代码中看到的内容。还是蛮快的!您可以在短短 7 毫秒内执行一百万次函数调用(加上一百万次求幂,加上一百万次循环迭代)!每次迭代+调用+求幂+返回只有7纳秒!

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

那么仅 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`);
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

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

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

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

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

  • i7 6600U:内联情况 3.3 毫秒,调用 28 毫秒
  • i7 3635QM:内联情况为 2.8 毫秒,调用为 10 毫秒
  • i7 3635QM,最新微码:2.8 毫秒内联案例,26 毫秒调用
  • Ryzen 3900X:内联情况 2.5 毫秒,调用 5 毫秒

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