提问者:小点点

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


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

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

这个类非常简单--在构造函数或init中为它提供一个初始状态,然后可以使用update方法更新该状态,然后传递一个回调,该回调接受this.state作为参数(通常会修改它)。 transition是用于transitioncondition状态的更新方法,直到不再满足transitioncondition

提供了两个测试函数:redblue,它们是相同的,每个函数都将生成一个初始状态为{test:0}statemachine,并在状态时使用transition方法更新状态。test<; 1E6。 结束状态为{test:1000000}

您可以通过单击红色或蓝色按钮来触发配置文件,这将运行StateMachine.Transition50次,并记录完成调用所花费的平均时间。 如果你重复点击红色或蓝色按钮,你会看到它在不到10ms的时间内毫无问题地打卡--但是,一旦你点击另一个按钮并调用相同函数的另一个版本,一切都会中断,两个函数的执行时间都会增加大约一个数量级。

任何关于如何解决这一问题的建议都将不胜感激! 我会在此期间提交一份错误报告。

null

// 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/StateMachine.js/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>

null

https://bugs.chromium.org/p/v8/issues/detail?id=10676。

归根结底,这种行为是意料之外的,IMO认为它是一个重要的bug。 对我的影响是显著的--在Intel i7-4770(8)@3.900GHz上,我在上面示例中的执行时间从平均2ms到45ms(增加了20倍)。

至于非平凡性,请考虑在第一次调用之后对StateMachine.Transition的任何后续调用都将不必要地缓慢,并且如果我没有理解错的话,这将影响任何具有频繁调用的回调参数的函数(即,Transition的每个循环调用其回调参数StateTransition1M次)。 虽然可能很容易把它当作一个玩具示例来处理,但在我看来,频繁地调用回调可能是相当例行的。 SpiderMonkey不会减慢后续对transition的调用,这一事实向我发出信号,表明V8中这种特定的优化逻辑还有改进的空间。

请参见下文,其中对StateMachine.Transition的后续调用会减慢:

null

// 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(performance.now() - start);
})();


// 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(performance.now() - start);
})();

// 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(performance.now() - start);
})();
<script src="https://cdn.jsdelivr.net/gh/TeleworkInc/StateMachine.js/StateMachine.js"></script>

null

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

null

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(performance.now() - start);
});

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

null

$ 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

共1个答案

匿名用户

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

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

解释如下:

当您只单击一个按钮时,transition将只看到一个回调。 (严格来说,它每次都是arrow函数的一个新实例,但由于它们都来自源代码中的同一个函数,因此为了类型反馈跟踪的目的,它们被“删除”了。此外,严格来说,它是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());
  ...
}

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

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