V8 JS AOT化的探索与实践

简介: JS 语言的动态性非常优秀,其弱类型等语言特性也使得一线业务开发者更容易上手,但这也导致 JS 每一次运行前都要重复编译,使得 JS 的执行性能不理想;虽然之前 UC 内核有做过 Code Cache 方案,但支持的场景不够完整,与原生 Native 的技术方案比,尤其是首次启动场景(如各类大促活动等)还是有比较大的差距。为了能尽可能做到与 Native 对标,缩小性能差距,同时让业务开发者无感,我们开发了 JS AOT 功能。本分享将结合目前集团内自有业务形态,以及 JS 在 Web 中的执行过程,介绍JS AOT是如何设计和实现的,以及能给业务带来哪些收益。本篇分享来自阿里巴巴的喻世江在第


图片.png

JS 语言的动态性非常优秀,其弱类型等语言特性也使得一线业务开发者更容易上手,但这也导致 JS 每一次运行前都要重复编译,使得 JS 的执行性能不理想;虽然之前 UC 内核有做过 Code Cache 方案,但支持的场景不够完整,与原生 Native 的技术方案比,尤其是首次启动场景(如各类大促活动等)还是有比较大的差距。为了能尽可能做到与 Native 对标,缩小性能差距,同时让业务开发者无感,我们开发了 JS AOT 功能。本分享将结合目前集团内自有业务形态,以及 JS 在 Web 中的执行过程,介绍JS AOT是如何设计和实现的,以及能给业务带来哪些收益。本篇分享来自阿里巴巴的喻世江在第十六届D2前端技术论坛的分享。

附:第十六届D2前端技术论坛现场分享视频

图片.png


背景


随着Web技术的不断发展壮大,JavaScript作为Web的逻辑开发语言,也凭借其简单易用等特性以及强大的生态,成为行业内最受欢迎的编程语言之一。V8作为JavaScript最强大的虚拟机引擎,在性能、稳定性、内存等各方面的指标受到业务的关注。

U4内核在V8引擎中深耕多年,进行了多方面的优化和能力扩展,为了使集团业务在Web快速发展,消除在使用Web应用时的各种问题,我们U4内核团队从快、强、稳三个方向,在 V8 引擎中做了大量的优化和能力拓展:

  1. 磁盘代码缓存技术:避免JS非首次执行时的重复编译,使JS性能提升50%以上;
  2. UC LLVM优化编辑器技术:为V8字节码处理程序生成更高效紧凑的汇编指令,使JS性能提升10%-30%;
  1. JS卡死检测:当JS出现卡死情况时,在native中可以得到告警并获取卡死时的现场信息,使业务尽早发现、定位和解决问题;
  2. OOM定位信息:在崩溃日志中增加了JS堆内存信息和OOM时的JS现场信息,使业务可以快速定位OOM问题;
  3. JS API扩展:在 JS中扩展了一系列能力,让H5开发者可以快速开发调试以及进行性能分析等;
  4. JSI:让业务可以脱离WebView使用V8,同时享有U4针对V8的所有优化成果;
  1. 疑难崩溃攻克:解决了线上发现的许多疑难崩溃稳定性问题,如 CPU 缓存刷新问题、高通部分 CPU 缺页中断处理BUG等系列非常底层的问题;
  2. 安全漏洞修复:修复各种 N-day、0-day 的安全漏洞并及时上线。


U4内核在V8引擎中做了很多优化和能力扩展,然而在将JavaScript和传统的Native技术相比时,仍然发现一些不足。

如下图所示,JavaScript在跨平台和动态化方面较Native有很大优势,但在启动性能方面却存在差距。其原因主要是JavaScript在运行前需要在用户的设备里在线编译,并且每次运行前都需要重新编译;而Native是在打包时PC离线编译,在用户设备中可以直接运行。

image.gif图片.png

简单说Native用的是AOT(Ahead of Time,提前编译)化的语言,因此,我们也尝试将AOT技术应用到JS语言中。

JS AOT的目标是让JavaScript具备动态化特性的同时,运行性能也可能与Native 对标,尤其是首次启动。


技术选型


下图是V8运行JavaScript的流水线。图片.png

首先V8会使用解析器将JS源代码进行词法分析、语法分析和语义分析,生成抽象语法树(AST),再使用解释器将AST编译生成字节码,然后以字节码的形式解释执行,在解释执行的同时V8会收集JS对象的类型信息,当某些函数多次运行变成热点函数后,V8会使用其优化编译器对代码进行重新编译,结合之前收集的对象类型信息重新生成高效的汇编指令,并以汇编的形式运行,以获得更高的执行性能。


根据V8执行JS的流水线分析来看,实现JS AOT有三种可行方案:本地代码(Machine Code,汇编),全字节码缓存(Full Code Cache),部分字节码缓存(Code Cache)。


1、本地代码(Machine Code,汇编)


早在2010年,V8在执行JS时,先使用解析器将JavaScript源代码解析生成抽象语法树(AST),再使用基线编译器Full-codegen将AST编译成为未优化版的汇编代码后执行;当某些函数多次执行成为热点函数后,再使用优化编译器生成优化版的汇编代码执行。然而,在2016年V8引入了字节码后,就摒弃了基线编译器Full-codegen。

图片.png

V8 为什么会摒弃基线编译器Full-codegen 呢?这就需要对JS语言规范的标准有一些了解。以加号运算符为例(如下图),在operator+的规范中,会使用到GetValue、ToString、ToNumber、ToPrimitive等语义,同时ToString、ToNumber也会调用ToPrimitive语义,在ToPrimitive语义中会调用GetMethod、Call语义,其中Call语义可能调用任意JS代码,GetMethod会调用GetV获取对象属性,GetV会调用ToObject。由此可见,一个看似简单的加号运算符涉及的标准非常多,而由于JavaScript的弱类型语言特性,在编译时无法确定两个相加的操作数会是数值、字符串、对象等还是任意类型组合,因而需要将这些路径全部编译生成庞大的汇编代码。


通过这个示例可以看到,将JS源代码直接全部编译成未优化版的汇编代码,将面临编译过程缓慢、生成代码体积庞大等问题,进而同时会导致生成的代码占用内存高、因CPU缓存频繁失效导致性能差的问题,另外还存在CPU架构不通用的问题。由此可见,使用汇编代码并不是一个可取的方案,V8验证过这个方案的不可行。

图片.png


2、全字节码缓存(Full Code Cache)


V8的字节码是一种高度抽象的二进制表示形式,每个字节码都分别对应一段字节码处理程序,每个字节码处理程序由许多条汇编指令组成。V8的解释执行过程是按照字节码顺序依次调用字节码处理程序执行。将JS全部编译成字节码,以字节码的形式运行,可以有效避免运行时的编译过程,但根据线上TOP站点分析发现,它仍然存在一些问题:

  1. JS函数运行覆盖度低(运行函数个数/总函数个数=43%);
  2. 代码膨胀严重(全字节码大小/JS源码大小=2.6倍左右);
  3. 加载 & 反序列化消耗大。

根据对TOP站点的分析发现,对小部分 JS 来说使用全字节码缓存有较明显的性能提升,但在大部分 JS 中相比于直接编译的提升非常微小,基本可以忽略;在实际业务中,将全字节码缓存与部分字节码缓存也进行了性能对比(部分字节码缓存,即只为部分需要被执行的 JS 代码预生成字节码)。该结果表明,相比于部分字节码缓存,全字节码缓存会慢 22.7%。


image.gif图片.png


3、部分字节码缓存(Code Cache)


下图是Blink执行JS的逻辑。

image.gif图片.png

在Blink中执行JS时V8会先只编译最上层的函数,并在运行到内部函数时,如未被编译,则先编译该内部函数后运行。同时JS中会注册DOM事件,在DOM事件被触发时也会执行之前未执行的函数,这时V8也会先将其编译生成字节码再运行,同样如Async function、Promise、Timer等异步任务,也会在遇到未被编译的函数时先编译再运行。最后,当页面跳转或关闭时,将所有编译过的函数序列化并生成字节码缓存。


这个方案存在的问题:

  1. V8(U4内核)版本的碎片化:U4内核对V8深入优化修改时,可能改动到 V8 内部的数据结构,导致不同版本不通用;
  2. 不同CPU架构下的通用性:V8 的字节码是全平台、所有架构一致通用的,但是字节码所使用的部分常量数据(如小整数)的内存布局在不同架构下不一样,导致整个字节码缓存与 CPU 架构相关;
  3. 机型通用性:高、低端机上生成的字节码缓存可能不通用。

根据以上三个方案总结来看,JS AOT面临的主要问题在于生成代码的有效性和兼容性。


方案设计


1、保证AOT的有效性


策略一:追求极致的性能——PGO(Profile-guided optimization)

PGO是基于性能测试结果的优化,JS使用PGO的思路是仅为需要被执行的函数生成代码,整个流程如下图。

image.gif图片.png

在业务发布之前先在移动端访问一次页面,JS执行完后会生成函数运行信息并上报给服务端,服务端结合JS源码对信息进行处理,如补回已被GC的函数、合并和简化信息等,最后在服务端或移动端使用函数信息结合JS源码预编译生成AOT代码。


策略二:追求便捷的使用——先验规则

使用PGO的方式生成AOT,每次发布时都需要先收集函数信息,这在无形中增加了业务发布的复杂度。为了追求更便捷的使用,内置了另一种先验规则的策略。这个策略的核心在于预测需要被执行的函数,然后只为这些函数生成字节码。


下图是一段JS代码,在代码执行前 V8 先编译灰色背景部分的代码,这些代码运行时会编译并执行第二层代码(紫色部分),第二层代码执行时会编译第三层代码(黄色部分),然后是第四层代码(蓝色部分)。


image.gif图片.png


通过对线上TOP站点的JS分析,发现了以下特征:

  1. JS层级嵌套越深,使用率越低;相反,层级越浅使用率越高,如最上层代码的使用率是100%;
  2. JS越小,覆盖度越高:一些小型JS内的函数使用率可以达到90% 以上。

结合以上特征对TOP站点进行不断的实验和分析后,最终确定了一套整体最优的生成策略,如下:

  • 小型 JS:不生成AOT

因为如果生成AOT后都需要从磁盘加载反序列化,可能会比直接编译JS源码更耗时。

  • 中型 JS:全字节码缓存

因为这部分JS函数覆盖度达80% 以上,使用全字节码性价比较高。

  • 大型 JS:只生成Top 3层函数字节码

其它未被编译的函数在运行时用到会及时编译。

  • 运行后增量更新

页面生命周期结束时,将新增编译的函数序列化,增量更新保存;运行次数越多,AOT包含的函数就越多,运行性能越优。


2、保证AOT的兼容性


策略一:在线生成

  • 空闲时预热


下图是简化版U4内核线程模型图。首先,在UI线程发起AOT预热,然后在AOT线程将JS预编译生成AOT缓存在磁盘,当用户打开页面,Blink主线程在运行JS代码时加载AOT,避免了JS的编译过程从而提升性能。

  • 影响或不足:存在资源浪费(磁盘 & CPU),部分用户可能不会访问相应的页面;
  • 适用场景:框架JS,不经常变动。

image.gif图片.png

在线生成(空闲时预热)

  • 访问时生成


如下图,当用户打开页面时向后台AOT线程发起预编译任务,让后台线程同步生成AOT,同时Blink主线程发起页面打开加载请求,并进行解析、排版、渲染等操作,在结束这些操作时JS代码也已经在AOT线程编译完成,Blink主线程可以直接加载和反序列化并运行。

  • 影响或不足:如果AOT线程执行缓慢,在Blink主线程执行JS时字节码可能未生成完成,导致Blink主线程需要重新编译;
  • 适用场景:业务JS,已经离线到本地。


图片.pngimage.gif

在线生成(访问时生成)


策略二、离线生成

离线生成的方式提供了一个离线工具,可以在服务端使用函数信息或先验规则结合JS源码预编译生成AOT,然后将AOT内置到发布包中,提供给线上用户直接运行,省去编译的过程。

适用场景:

  • APP冷启动时执行(没有预热时机);
  • JS不经常变动;
  • JS不需要动态更新。


影响或不足:

  • 更新U4内核后,可能需同步更新AOT,如果不更新,可能导致加载失败,需要运行时重新编译。

图片.pngimage.gif

离线生成

既然使用了AOT离线生成,那是否可以在线上只保留AOT而抛弃JS源码吗?答案是不可以,原因有以下两点:

  • 抛弃JS源码意味着需要将JS全部编译成字节码,前面提到的PGO或先验规则,都只是生成部分函数的字节码,而有些JS代码是在非主路径才会使用,如果没有JS源码,在调用这些函数时就会出现异常;而使用全字节码缓存则会出现前面提到的问题;
  • JS的一个特殊功能是将任意函数通过toString调用取得原始的字符串,如果将JS源码抛弃,这个功能就无法实现。


3、AOT方案总览


根据以上介绍的所有内容,AOT整体方案总结如下:

在预发布环节,首先在移动端访问页面,然后上报函数信息至服务端,服务端进行信息处理后生成函数信息(如果使用先验规则策略则可省去预发布环节);在线上后台线程中使用函数信息或先验规则结合JS源码进行预编译生成AOT,当用户访问页面时可以直接从AOT运行而无需编译,从而提升JS执行性能;对于预热环节,还提供了离线工具,可以在服务端离线生成AOT。

图片.png


优化效果


下面左图是某页面未使用AOT的 Trace结果,右图是基于先验规则打开页面时后台预热生成AOT的结果。对比两个结果发现,后者JS执行时间减少35% 左右。

图片.png

另外,针对线上TOP站点进行对比分析发现,使用PGO策略可以使执行性能平均提升49%以上,使用先验规则可以平均提升33.9% 以上。我们以夸克高考为例,对比使用AOT后,首屏性能提升了17.6% 以上。


展望


在当前的U4 3.0 & 4.0版本中,V8 执行JS的流程是:通过解析器将JS源码解析生成抽象语法树,然后使用Ignition解释器将抽象语法树编译成字节码并以字节码形式运行;当部分函数多次运行变成热点函数后,V8使用优化编译器将其编译成优化汇编代码并运行。

而在即将发布的升级U4 5.0版本中,使用V8的火花塞基线编译器,可以从字节码直接编译生成未优化版的汇编代码并运行。当部分JS代码多次运行成为热点函数后,会再次使用优化编译器重新编译,生成优化版的汇编代码。

新增的火花塞基线编译器,在保留字节码抽象度的同时,将字节码的解码过程提前,并能够充分利用CPU的分支预测能力,从而提升JS的执行性能。初步预测,如果JS AOT使用火花塞编译器后,我们在AOT中可以进行更多的优化,使JS性能提升20% 左右。

图片.png

以上。

相关文章
|
2月前
|
存储 JavaScript 前端开发
使用JavaScript构建动态交互式网页:从基础到实践
【10月更文挑战第12天】使用JavaScript构建动态交互式网页:从基础到实践
146 1
|
2月前
|
JavaScript 前端开发 安全
TypeScript的优势与实践:提升JavaScript开发效率
【10月更文挑战第8天】TypeScript的优势与实践:提升JavaScript开发效率
|
2月前
|
JavaScript 前端开发 开发者
理解JavaScript中的原型链:基础与实践
【10月更文挑战第8天】理解JavaScript中的原型链:基础与实践
|
4月前
|
数据采集 Web App开发 JavaScript
利用Selenium和XPath抓取JavaScript动态加载内容的实践案例
利用Selenium和XPath抓取JavaScript动态加载内容的实践案例
|
14天前
|
存储 网络架构
Next.js 实战 (四):i18n 国际化的最优方案实践
这篇文章介绍了Next.js国际化方案,作者对比了网上常见的方案并提出了自己的需求:不破坏应用程序的目录结构和路由。文章推荐使用next-intl库来实现国际化,并提供了详细的安装步骤和代码示例。作者实现了国际化切换时不改变路由,并把当前语言的key存储到浏览器cookie中,使得刷新浏览器后语言不会失效。最后,文章总结了这种国际化方案的优势,并提供Github仓库链接供读者参考。
|
1月前
|
缓存 监控 JavaScript
Vue.js 框架下的性能优化策略与实践
Vue.js 框架下的性能优化策略与实践
|
1月前
|
缓存 负载均衡 JavaScript
构建高效后端服务:Node.js与Express框架实践
在数字化时代的浪潮中,后端服务的重要性不言而喻。本文将通过深入浅出的方式介绍如何利用Node.js及其强大的Express框架来搭建一个高效的后端服务。我们将从零开始,逐步深入,不仅涉及基础的代码编写,更会探讨如何优化性能和处理高并发场景。无论你是后端新手还是希望提高现有技能的开发者,这篇文章都将为你提供宝贵的知识和启示。
|
1月前
|
JavaScript 前端开发 API
Vue.js 3:深入探索组合式API的实践与应用
Vue.js 3:深入探索组合式API的实践与应用
|
2月前
|
前端开发 JavaScript
深入理解JavaScript中的事件循环(Event Loop):从原理到实践
【10月更文挑战第12天】 深入理解JavaScript中的事件循环(Event Loop):从原理到实践
44 1
|
1月前
|
Web App开发 JavaScript 前端开发
构建高效后端服务:Node.js与Express框架的实践
【10月更文挑战第33天】在数字化时代的浪潮中,后端服务的效率和可靠性成为企业竞争的关键。本文将深入探讨如何利用Node.js和Express框架构建高效且易于维护的后端服务。通过实践案例和代码示例,我们将揭示这一组合如何简化开发流程、优化性能,并提升用户体验。无论你是初学者还是有经验的开发者,这篇文章都将为你提供宝贵的见解和实用技巧。