考虑下面的代码,它测量了几个完全一样的函数的用时。
function timeit(fn) {
console.time();
for (let i = 0; i < 5e7; i++) {
fn(123);
}
console.timeEnd();
}
timeit(x => x + 1);
timeit(x => x + 1);
timeit(x => x + 1);
// More timeit(x => x + 1) calls...
想象一下控制台的输出是怎样的,由于干扰的存在时间不会相等,但至少应该差不多,不会出现某个比另一个快几倍的情况。
但如果你实际运行一下(对于浏览器不要开发者工具里运行,要写一个 HTML 文件然后打开),根据引擎的不同,输出是这样的:
在 NodeJS 22.9.0 上第一个比后续的快 10 倍:
default: 21.129ms
default: 205.575ms
default: 203.184ms
在 Firefox 131 上得到了相似的结果:
default: 19ms - timer ended
default: 130ms - timer ended
default: 129ms - timer ended
在 Bun 1.1.30 上,前五次调用拥有最快的速度,后面会慢一些:
[31.56ms] default
[27.79ms] default
[27.36ms] default
[29.45ms] default
[30.85ms] default
[40.94ms] default
[82.87ms] default
[247.80ms] default
[245.71ms] default
这个结果违反了直觉,按理来说三个一样的函数运行时间应该相等,或者由于需要预热,第一个慢点,但实际结果恰恰相反。
这个问题其实存在了很久,是由于 JIT 引擎的优化决策所导致的 ,本文将探讨该问题,以及找到让函数一直都运行在最佳速率的方法。
最近我开发了一个 JS Benchmark 框架 ESBench,在它的一个测试中发现了这个奇怪的结果,在搜索相关资料时又发现同类项目 tinybench 也有该问题;还有 一篇 Reddit 帖子 也提到了它。
种种迹象表明这不是某个特定框架或实现的问题,而是广泛存在于 JavaScript 引擎中的行为。在参考了 Etki 的回答,以及 chromium issues 中的讨论之后,确定了这并不是扫描错误,而是一个优化问题。
实际上该行为仅出现在大量调用且函数很快的情况,真实场景中罕见,通常不会对应用整体的性能产生影响。但由于我开发的是 Benchmark 框架,得尽可能消除误差,如果调用顺序影响了结果就会让用户产生混乱,所以必须解决这个问题。
为了分析引擎的执行过程,我们将代码写入文件debug.js
并作一点小小的修改:
function timeit(fn) {
for (let i = 0; i < 5e7; i++) {
fn(123);
}
console.log("*******");
}
timeit(function addOne(x) { return x + 1; });
timeit(function addOne(x) { return x + 1; });
timeit(function addOne(x) { return x + 1; });
然后使用以下命令来运行:
node --print-opt-code debug.js
--print-opt-code
是 V8 引擎的参数,能够在控制台里打印 JIT 相关的信息,这里我们给函数加上了名字,这样就能够从输出里定位到它,同时还能靠*******
来得知调用的边界。
V8 的调试输出很长,通过搜索addOne
可以定位到内联函数的部分,在第一次调用时是这样:
...
Inlined functions (count = 1)
0x01d98f131789 <SharedFunctionInfo addOne>
...
找到第一个*******
,它之后的就是第二次调用,可以看到内联函数的部分为空:
...
Inlined functions (count = 0)
...
这意味着第一次调用,引擎将addOne
函数内联到了循环中,而后续的调用不再这么做。众所周知调用一个函数是有开销的,包括入栈、跳转、出栈等指令,而内联则可以消除它们,代价是代码体积变大,占用更多的内存。
如果手动将函数体内联到循环中,那么每次的运行时间都将和第一次一致,这表明是内联让首次的调用比后续的更快:
function timeit() {
console.time();
let temp;
for (let i = 0; i < 5e7; i++) {
temp = 123 + 1;
}
console.timeEnd();
}
timeit(); // default: 20.511ms
timeit(); // default: 19.208ms
timeit(); // default: 19.677ms
如果让多次调用都传入同一个函数,那么打印的时间也将是相同的,说明了让引擎决定不再内联的原因是参数的变化。
function timeit(fn) {
console.time();
for (let i = 0; i < 5e7; i++) {
fn(123);
}
console.timeEnd();
}
const addOne = x => x + 1;
timeit(addOne); // default: 22.184ms
timeit(addOne); // default: 19.051ms
timeit(addOne); // default: 19.223ms
内联优化假定了被内联的函数不变,Jit 会将fn(123)
替换为它的函数体123 + 1
并缓存下来,如果下次调用还是传入同样的函数(两次定义的函数是不同的,即使它们的函数体有相同的代码),则使用缓存中的版本;反之回退到未内联的代码(去优化)。
你可能会有疑问,为什么不把第二次调用也内联了?这的确可行,但实际中却面临着取舍,因为 JS 支持函数作为参数,所以引擎无法得知到底会有多少种组合需要内联,以及曾经优化的版本是否会再次用到,而每次内联都会生成一份代码的副本,如果不进行限制,则会消耗大量的内存,同时由于代码体积膨胀导致性能下降。
V8 引擎对于一个参数的函数似乎选择了只缓存第一次,如果内联函数变了则去优化。在相关的讨论中 V8 开发者也说 "it's a difficult tradeoff"。
在 Bun(JavaScriptCore) 中前五次都是最快,说明它提供了更大的缓存空间,但也不是无限的。
对于缓存,最常见的淘汰策略有 LRU 和 LFU,但它们不一定适合 JIT;也有一些更先进的算法,能够在去优化后重新内联,或是进行长期的跟踪,找出最热的代码。所以该行为并不会一直不变,也许在未来 JS 的引擎会更聪明一点。
但是现在如何解决这个问题?我们已经知道一个函数至少能有一个缓存,那么只要有 N 个函数就可以保存 N 个优化的版本,所以可以这样:
let cacheBusting = 0;
function timeit(fn) {
const code = `\
// ${cacheBusting++}
console.time();
for (let i = 0; i < 5e7; i++) {
fn(123);
}
console.timeEnd();
`;
new Function("fn", code)(fn);
}
timeit(x => x + 1);
timeit(x => x + 1);
timeit(x => x + 1);
我们每次调用都从字符串创建一个新的timeit
函数,同时因为 V8 会用字符串来判断函数体是否相同,所以还加了一点注释来欺骗它,这样每一份创建的timeit
都只会被调用一次,不再有参数的变化,从而让引擎为它们都进行优化。
该代码在主流的三个引擎上都能得到一致的结果。
在我的 ESBench 里就有这样的测量函数用时的代码,通过动态创建函数,解决了此问题。
tinybench 也试图解决这个问题,但它的方案是错的。tinybench 将测量函数从直接调用改成了.call
,像这样:
function timeit(fn) {
console.time();
for (let i = 0; i < 5e7; i++) {
fn.call(123); // <--
}
console.timeEnd();
}
timeit(x => x + 1); // default: 497.151ms
timeit(x => x + 1); // default: 443.34ms
timeit(x => x + 1); // default: 442.868ms
.call
阻止了引擎对fn
进行内联,把第一次调用搞得跟后面一样慢,虽然结果是差不多了,但基准测试工具应该收集有关真实场景的数据,在这些场景中,我们期望函数能够得到优化,而 tinybench 的做法反而增加了框架本身所引入的误差,让结果离真实值越来越远。
JavaScript 引擎并没有想象中的那么智能,它的设计面临着大量的取舍,而且在某些方面选择了最简单的策略,产生了一些反直觉的行为以及一些奇淫技巧。
不过大多数情况下引擎都工作得不错,没有必要过度关注这些情况,很少有应用会因为没有内联一个函数而出问题,除非你在做基准测试,或是真的在有大量调用的地方遇到了性能瓶颈。
本文给出的解决方案伴随着代码变得复杂,以及反复创建函数带来的内存开销,你应该在确定它有收益的情况下才使用。无论如何,在优化前后都需要做测试,使用最先进的工具来收集可靠的指标,比如 ESBench。