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

Why is the execution time of this function call changing?

前言

此问题似乎只影响 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);


原版post

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

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

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

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

您可以通过单击红色或蓝色按钮触发配置文件,这将 运行 StateMachine.transition 50 次并记录完成通话所用的平均时间。如果您重复单击红色或蓝色按钮,您将看到它在不到 10 毫秒的时间内正常运行 - but,一旦您单击 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>
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>

更新

Bug Report "Feature Request" filed(等待更新)- 有关详细信息,请参阅下面的@jmrk 的回答。

最终,这种行为是出乎意料的,IMO 认为这是一个重要的错误。对我的影响很大——在英特尔 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() - 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

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

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

解释如下:

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

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

有一个解决方法,但它有点像 hack,我不建议将 hack 放入用户代码中以解释引擎行为。 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 => state.something) 这样的内联箭头函数的调用在每次执行时都会分配一个新的函数对象;在手头的示例中,这恰好无关紧要。)有时引擎可能能够优化开销,有时不能。

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

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

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 毫秒,然后当 exampleB 出现时它们上升到 7.3ms,并保持在那里。哇,减速 30 倍!显然这是 V8 中的错误?为什么团队不着手解决这个问题?

嗯,情况比一开始看起来的要复杂。

首先,“慢”是正常情况。这就是您应该期望在大多数代码中看到的内容。速度还是挺快的!您可以在 7 毫秒内完成一百万次函数调用(加上一百万次求幂,再加上一百万次循环迭代)!每次迭代+调用+求幂+return!

只需 7 纳秒

实际上,该分析有点简化。实际上,像 10**10 这样的两个常量的操作会在编译时被常量折叠,所以一旦 exampleA 和 exampleB 被优化,它们的优化代码将立即 return 1e10 ,没有做任何乘法。 另一方面,这里的代码包含一个小的疏忽,导致引擎必须做更多的工作:exampleA 和 exampleB 有两个参数 (a, b),但它们被调用时没有任何参数,就像 callback() .弥合预期参数数量和实际参数数量之间的这种差异很快,但在像这样没有做太多其他事情的测试中,它占总时间的 40% 左右。因此,更准确的说法是:执行循环迭代、函数调用、数字常量的物化和函数 return 大约需要 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`);
}

这给了我与调用 exampleA 相同的 0.23ms。因此,与我们的想法相反,我们没有测量调用和执行 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 毫秒

这就是 Chrome 83/84 在 Linux 上的全部内容; Windows 或 Mac 上的 运行ning 很可能会产生不同的结果(因为 CPU/microcode/kernel/sandbox 彼此密切互动)。 如果您发现这些硬件差异令人震惊,请继续阅读“spectre”。