提问人:Lewis 提问时间:7/3/2020 最后编辑:Lewis 更新时间:7/14/2020 访问量:4017
为什么这个函数调用的执行时间会发生变化?
Why is the execution time of this function call changing?
问:
前言
此问题似乎只影响 Chrome/V8,可能无法在 Firefox 或其他浏览器中重现。总之,如果函数回调在其他任何地方使用新回调调用,则函数回调的执行时间会增加一个数量级或更多。
简化的概念验证
多次任意调用会按预期工作,但是一旦调用,无论提供什么回调,函数的执行时间都会急剧增加(即,另一个调用也会受到影响)。test(callback)
test(differentCallback)
test
test(callback)
此示例已更新为使用参数,以免优化为空循环。回调参数 a
和 b
相加并相加到总计
中,并记录下来。
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
该类非常简单 - 您可以在构造函数中给它一个初始状态 或 ,然后您可以使用方法更新状态,该方法传递一个接受为参数的回调(并且通常会修改它)。 是用于状态的方法,直到不再满足 。init
update
this.state
transition
update
transitionCondition
提供了两个测试函数:和 ,它们是相同的,每个函数都会生成一个初始状态为 和 使用该方法的状态 while 。最终状态为 。red
blue
StateMachine
{ test: 0 }
transition
update
state.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.transition
transition
请参阅下文,其中后续调用速度变慢: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
stateTransition
transitionCondition
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());
...
}
可能值得提交一个错误(crbug.com/v8/new)来询问编译器团队是否认为这值得改进。从理论上讲,应该可以内联多个直接调用的函数,并根据所调用的函数变量的值在内联路径之间分支。但是,我不确定在很多情况下,影响是否像这个简单的基准测试一样明显,而且我知道最近的趋势是内联更少而不是更多,因为平均而言,这往往是更好的权衡(内联有缺点,是否值得它必然总是一个猜测, 因为引擎必须预测未来才能确定)。
总之,使用许多回调进行编码是一种非常灵活且通常很优雅的技术,但它往往会以效率成本为代价。(还有其他种类的低效率:例如,带有内联箭头函数的调用,例如每次执行时都会分配一个新的函数对象;这在手头的例子中恰好并不重要。有时引擎可能能够优化开销,有时则不能。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);
在我的机器上,我看到时间低至 0.23 毫秒,例如 A,然后当示例 B 出现时,它们上升到 7.3 毫秒,并保持在那里。哇,减速 30 倍!显然这是 V8 中的一个错误?为什么团队不跳起来解决这个问题?
好吧,情况比一开始看起来要复杂得多。
首先,“慢”的情况是正常情况。这就是您应该在大多数代码中看到的内容。它仍然相当快!您可以在短短 7 毫秒内完成 100 万次函数调用(加上 100 万次幂,外加 100 万次循环迭代)!每次迭代+调用+幂+返回只需 7 纳秒!
实际上,这种分析有点简化。实际上,对两个常量的操作在编译时会被常量折叠,因此一旦 exampleA 和 exampleB 得到优化,它们的优化代码将立即返回,而无需进行任何乘法。
另一方面,这里的代码包含一个小的疏忽,导致引擎不得不做更多的工作:exampleA 和 exampleB 接受两个参数,但它们在没有任何参数的情况下被调用,只是 。弥合预期参数数和实际参数数之间的这种差异是很快的,但是在像这样没有太多其他事情的测试中,它大约占总时间的 40%。因此,更准确的说法是:执行循环迭代、函数调用、数字常量的具体化和函数返回大约需要 4 纳秒,如果引擎还必须调整调用的参数计数,则需要 7 ns。10**10
1e10
(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**10
d8
node
--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/微码/内核/沙箱彼此密切交互)。 如果您发现这些硬件差异令人震惊,请阅读“幽灵”。
评论
StateMachine
this.state
StateMachine
StateMachine.transition
StateMachine
评论
performance.now