IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    Kaciras 的博客发表于 2024-10-17 14:47:01
    love 0

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

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

    Fix it in 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。



沪ICP备19023445号-2号
友情链接