分析 JS 引擎的一个内联优化问题

发布时间:

最后更新:

考虑下面的代码,它测量了几个完全一样的函数的用时。

javascript
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 文件然后打开),根据引擎的不同,输出是这样的:

这个结果违反了直觉,按理来说三个一样的函数运行时间应该相等,或者由于需要预热,第一个慢点,但实际结果恰恰相反。

这个问题其实存在了很久,是由于 JIT 引擎的优化决策所导致的 ,本文将探讨该问题,以及找到让函数一直都运行在最佳速率的方法。

背景 #

最近我开发了一个 JS Benchmark 框架 ESBench,在它的一个测试中发现了这个奇怪的结果,在搜索相关资料时又发现同类项目 tinybench 也有该问题;还有 一篇 Reddit 帖子 也提到了它。

种种迹象表明这不是某个特定框架或实现的问题,而是广泛存在于 JavaScript 引擎中的行为。在参考了 Etki 的回答,以及 chromium issues 中的讨论之后,确定了这并不是扫描错误,而是一个优化问题。

实际上该行为仅出现在大量调用且函数很快的情况,真实场景中罕见,通常不会对应用整体的性能产生影响。但由于我开发的是 Benchmark 框架,得尽可能消除误差,如果调用顺序影响了结果就会让用户产生混乱,所以必须解决这个问题。

确定问题 #

为了分析引擎的执行过程,我们将代码写入文件debug.js并作一点小小的修改:

javascript
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函数内联到了循环中,而后续的调用不再这么做。众所周知调用一个函数是有开销的,包括入栈、跳转、出栈等指令,而内联则可以消除它们,代价是代码体积变大,占用更多的内存。

如果手动将函数体内联到循环中,那么每次的运行时间都将和第一次一致,这表明是内联让首次的调用比后续的更快:

javascript
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

如果让多次调用都传入同一个函数,那么打印的时间也将是相同的,说明了让引擎决定不再内联的原因是参数的变化。

javascript
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 支持函数作为参数,所以引擎无法得知到底会有多少种组合需要内联,以及曾经优化的版本是否会再次用到,而每次内联都会生成一份代码的副本,如果不进行限制,则会消耗大量的内存,同时由于代码体积膨胀导致性能下降。

对于缓存,最常见的淘汰策略有 LRU 和 LFU,但它们不一定适合 JIT;也有一些更先进的算法,能够在去优化后重新内联,或是进行长期的跟踪,找出最热的代码。所以该行为并不会一直不变,也许在未来 JS 的引擎会更聪明一点。

解决方案 #

但是现在如何解决这个问题?我们已经知道一个函数至少能有一个缓存,那么只要有 N 个函数就可以保存 N 个优化的版本,所以可以这样:

javascript
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 里就有这样的测量函数用时的代码,通过动态创建函数,解决了此问题。

Fix it in ESBenchFix it in ESBench

tinybench 也试图解决这个问题,但它的方案是错的。tinybench 将测量函数从直接调用改成了.call,像这样:

javascript
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

评论加载中