番外:V8 与即时编译(JIT)

Author: Guang

计算机不能够直接在计算机系统中执行我们编写的程序源代码(source code),计算机能够直接运行的指令被称为机器码(machine code)。通常情况下,我们使用编译器或解释器来将源代码转换成可执行的机器码。编译器的工作方式是提前编译(Ahead-of-Time Compilation),编译过程是静态的,运行时之前的;解释器的工作方式是解释(Interpretation)执行,解释是动态的,是运行时的。在很久之前,JavaScript是解释型编程语言。而目前,JavaScript引擎在运行JavaScript源代码时,采用的是新型的处理方式——即时编译。

程序语言的类型

非常早期的计算机软件都是用汇编语言写就的,这是一种很低效的开发模式,后来人们编写编译器、发明了便于人类编写和维护的高级编程语言,它们更适合编写可重用的软件。编译器主要目的是将高级编程语言写就的源代码翻译为计算机能解读、运行的低阶机器码的程序。编译器拿到源代码后对其进行预处理、编译、汇编、链接之后,生成可执行文件,然后电脑才会运行这个最终的可执行文件。使用编译器编译源代码后执行的编程语言称为编译语言。我们熟知的编译型编程语言有最初的Pascal、C、C++。

一个现代编译器的主要工作流程如下: 源代码(source code)→ 预处理器(preprocessor)→ 编译器(compiler)→ 汇编程序(assembler)→ 链接器(linker)→ 可执行文件(executables),最后打包好的文件就可以给电脑去安装执行了。

使用编译类型的语言在进行软件开发时,每次调试都需要重新编译,而编译的过程也很漫长。在程序开发过程中,开发人员一直处于“编辑源代码-编译-执行-调试”工作循环中。后来人们编写了解释器(通常由Pascal或C写就),解释器对源代码一行一行的边解释边执行,执行源代码时不需要重新编译整个程序,只需要“编辑源代码-解释-调试”,进一步减轻了程序开发和更新的负担。解释器执行程序的方法主要有两种:一、直接执行源代码;二、转换源代码到更有效率的字节码,执行字节码。使用解释器进行源代码执行的编程语言称为解释型语言。我们系统上搭载的大部分Shell脚本属于执行源代码的解释器,其它我们熟知的解释型语言包括Perl、Python、Ruby,以及初期的JavaScript,会将源代码编译成字节码来执行。

编译语言和解释型语言的源代码的执行流程图如下:

解释器和编译器的工作流程图

在程序运行速度上,使用解释器来执行程序会比直接执行编译过的机器代码程序来得慢。因为解释器每次都必须去分析并翻译它所执行到的程序行,而编译过的程序就只是直接执行。这个在执行时的分析被称为"解释式的成本"。从执行流程上看,解释器虚拟机在执行字节码的时候,内部是属于编译型程序运行的,也就是说解释型的程序运行依托于编译型程序的运行之上,多了解析和解释的过程,自然有多的执行消耗。在解释器中,变量的访问也是比较慢的,因为每次要访问变量的时候它都必须找出该变量实际存储的位置,而不像编译过的程序在编译的时候就决定好了变量的位置了。同时,系统在执行解释型编程语言程序时,需要额外的内存资源维护解释器的运行。所以在严格要求程序执行效率或者严格要求内存空间的硬件设备上,仍然需要使用编译语言开发程序。

另外,解释型语言开发的软件的执行依赖于解释器,只需要实现不同版本的解释器,同样的代码便可以在不同的平台上运行(解释器隐藏了底层系统细节上的不同)。所以,解释型语言有非常良好的跨平台性。

所以,解释型编程语言和编译语言的对比如下:

解释型语言 编译语言
运行方式 逐代码行运行(运行时解释) 整体编译后运行(提前编译:AOT)
运行速度 运行慢 运行块
内存要求 内存占用较多 内存占用较少
开发调试流程 简单 繁复
移植性 容易跨平台 不容易跨平台

高级编程语言的两种执行方式各有优点和缺点。即时编译是两种传统运行的结合,它结合了两者的优点和缺点。

即时编译

即时编译(英语:Just-in-time compilation,缩写为JIT),又译及时编译、实时编译。JIT编译是两种传统的机器代码运行方式——提前编译(AOT)运行和解释运行——的结合,它结合了两者的优点和缺点。即时编译在程序的运行过程中引入编译过程来优化程序的执行,主要思路是引入程序运行监视器,监控程序的执行状况,发现哪些频繁运行的代码,对频繁执行的方法或函数进行编译并将编译结果保存以待复用,减少相同方法函数的重复解释过程,并且为编译结果进行专门的优化。大致来说,JIT编译,以解释器的开销以及编译和链接(解释之外)的开销,结合了编译代码的速度与解释的灵活性。我们常见的.Net、Java、现代JavaScript,其程序在运行时都在虚机中建立了即时编译的机制。

整体来看,即时编译是在解释器工作流程中引入了编译流程,它的工作流程简图如下:

即时编译(JIT)工作流程简图

即时编译通过在解释流程中引入编译流程来获得编译的优点,能够明显提高程序的运行速度。即时编译机制要求运行时环境维护额外的编译器,需要开辟额外的内存资源。而且对编译结果的缓存,也很容易造成不菲的内存开销。所以,支持即时编译机制的程序很容易产生性能问题。即时编译的在程序效率上的优化,属于拿空间换时间。在实现即时编译虚拟机的工作中,开发人员总是要在内存占用上做取舍。另外,如果在程序一开始就进行即时编译的优化,可能会对程序造成一定的启动延迟。

V8 JavaScript 引擎中的即时编译

最初,JavaScript是解释型语言,谷歌公司在推出的第一版V8 JavaScript引擎之中支持了即时编译,显著提升了浏览器的运行速度。目前,几乎所有主流浏览器的JavaScript引擎都支持即时编译(V8引擎是由C++写就)。

Firefox浏览器中使用SpiderMonkey; Safari浏览器使用JavaScriptCore; Edge浏览器使用V8 JavaScript引擎。

在V8 JavaScript引擎中,JavaScript源代码从解析到执行的步骤如下:

  1. 获取JavaScript源代码;
  2. 使用解析器(Parser)解析源代码,获取源代码抽象语法树(英语:Abstract Syntax Tree,缩写AST);
  3. 解释器(Interpreter)对源代码抽象语法树进行解释,生成平台无关的字节代码。并开始运行代码;
  4. 监视器收集分析代码的运行状况,根据代码重复执行的次数判定代码的“热度”,大部分代码很大可能标记为“暖”(Warm)代码,“暖”代码重复执行次数多了被标记为“热”(Hot)代码;
  5. 基线编译器(SparkPlug)对“暖”代码进行编译,缓存编译结果,以待后续调用。Sparkplug编译速度很快,但是不会对编译内容做过多的优化,它主要的作用是快速提供简化的编译版本。
  6. 优化编译器(TurboFan)对“热”代码进行高度优化编译,缓存编译结果,以待后续调用。
  7. 对优化代码去优化。TurboFan对代码进行编译优化时做了很多假设,一旦引擎在使用优化版本的方法执行代码时发现这些假设不成立,便会释放存储的编译方法,退化到解释器去执行。一来保证程序执行逻辑正确,二来释放不再成立的优化函数,优化内存空间使用。

在V8 JavaScript引擎中,大致的JavaScript源代码从解析到执行的图示如下:

V8 JavaScript引擎即时编译工作流程简图

1. 解析器(抽象语法树)

当引擎获取到JavaScript源代码后,会启动解析器把源代码解析成抽象语法树。解析过程主要分为词法分析(Lexical Analysis)和语法分析(Syntax Analysis)两个部分。词法分析是将源代码分解成一系列标记(token)的过程,由scanner完成。这些标记是最小的、有意义的代码单元,包括关键字、标识符、运算符、常量等。词法分析器会扫描代码并识别标记,然后生成标记流供后续步骤使用。语法分析是将标记流转换为抽象语法树(AST)的过程,即parser。语法树是源代码的抽象表示形式,它描述了代码结构和语法规则。语法分析器将标记流转换为语法树,以便后续步骤(如编译或解释)能够理解和处理代码的结构和含义。

下图是一个JavaScript代码转换到抽象语法树的示例,我们在astexplorer.net中得到它。

JavaScript to AST

AST会忽略代码风格,将代码解析为最纯粹的语法树,因此基于AST进行的转换是更加准确严谨的,而使用正则表达式来分析转换代码,无法有效的分析一段代码的上下文,即使对简单规则的匹配都需要考虑太多边界情况,兼容各种代码风格。

AST 是非常重要的一种数据结构,在很多项目中有着广泛的应用。其中最著名的一个项目是 Babel。Babel 是一个被广泛使用的代码转码器,可以将 ES6 代码转为 ES5 代码,这意味着你可以现在就用 ES6 编写程序,而不用担心现有环境是否支持 ES6。Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。

除了 Babel 外,还有 ESLint 也使用 AST。ESLint 是一个用来检查 JavaScript 编写规范的插件,其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。

2. 解释器(Ignition)

在得到抽象语法树之后,解释器(Ignition)就可以将抽象语法树转换为字节码了,字节码是一种平台无关的执行码,JavaScript引擎内置的虚拟机会按照字节码命令在硬件层面去执行动作,完成程序的运行。在程序运行期间,监视器会收集并解析字节码的运行状态(包括运行次数和参数类型),将会发生重复执行的代码标记为“暖”代码,将总是重复执行的“暖”代码标记为“热”代码。

在第一版V8 JavaScript引擎中,谷歌的工程师激进地要移除字节码地使用,直接按照抽象语法树数据来进行即时编译的优化,存储编译结果进行调用。少了一层转换,浏览器的运行速度得到很大的提升,但是在部署移动端浏览器时出现了内存问题。一般情况下移动端设备的内存比较小,同样含义的语句,存储机器码要求的内存大小比存储字节码要大很多,这就造成了严重的内存使用问题。后来工程师又重新实现了使用字节码的方式。下图展示了同样含义的JavaScript代码、字节码、机器码的大小。 bytecode storage VS machine-code storage V8 引擎的工程师,在很多时候都要思考“要内存还是要效率”的问题。

3. 基线编译器(Sparkplug)

在V8 JavaScript引擎中,Sparkplug充当基线编译器。当示例代码中的方法arraySum被重复调用过后,很快就会被标记为“暖”代码,就会触发基线编译器对其进行编译。函数的每一行都被编译成一个“存根(stub)”。存根由行号和变量类型索引(稍后解释为什么这很重要)。如果监控器发现执行再次使用相同的变量类型命中相同的代码,它只会提取其编译版本。

Sparkplug不生成任何中间表示(IR),而是直接在单个线性遍历字节码的过程中编译为机器码,生成与该字节码执行匹配的代码。所以Sparkplug编译速度很快,但是编译结果不具备很高的优化性能。

在一开始,V8 JavaScript引擎只有优化编译器(TurboFan),基于Ignition和TurboFan之间的执行速度和编译时间的差异较大,在2021年V8推出了这款非优化编译器(non-optimizing JavaScript compiler)Sparkplug,旨在将字节码几乎即时编译成等效的机器码。在其他JavaScript引擎中,也分别实现了基线编译器和优化编译器。

4. 优化编译器(TurboFan)

当“暖”代码持续被重复执行,会被标记为“热”代码,优化编译器(TurboFan)会为它专门生成高度优化的编译函数并存储,以备后续调用。TurboFan编译速度较慢,但是编译结果具备较高的优化性能。

为了制作更快的代码版本,优化编译器必须做出一些假设。例如假设由特定构造函数创建的所有对象都具有相同的形状(相同的属性和属性顺序),假设函数的参数具备一样的类型。基于这些假设,编译器可以做函数内联或者使得生成的优化函数更快的访问内存等等。

5. 去优化

优化函数的定义是基于一些假设,但是JavaScript语言是动态语言,假设不一定总成立。当出现假设不成立的时候,优化便也不再有效。这时候,编译器会丢弃存储的优化函数,将函数的执行退回到解释器层面去执行。这个过程就是去优化,它保证程序的逻辑不会出现问题。比如说有一个“热”代码函数的传参一直是Number,编译器会创建一个以Number为参数类型的优化函数存储到内存中,但突然有一次函数调用使用的字符串,那么这个优化版本的编译函数便不再有效,代码的执行会被回退到解释器去执行,从新被监视器监视函数调用情况。

6. 一个示例

下面我们看一个来自于这篇博客的示例。

考虑这段代码:

function arraySum(arr) {
    var sum = 0;
    for (var i = 0; i < arr.length; i++) {
        sum += arr[i];
    }
}

+=循环中的步骤可能看起来很简单。看起来您可以一步完成计算,但由于动态类型,它需要的步骤比您预期的要多。

假设这arr是一个包含100个整数的数组。一旦代码预热(设置为“Warm”),基线编译器将为函数中的每个操作创建一个存根。所以会有一个存根for sum += arr[i],它将把+=操作作为整数加法来处理。

但是,sumarr[i]不能保证是整数。因为类型在JavaScript中是动态的,所以在循环的后续迭代中,有arr[i]可能是一个字符串。整数加法和字符串连接是两种截然不同的操作,因此它们会编译成截然不同的机器码。

编译器处理这个问题的方式是编译多个基线存根(baseline stubs)。如果一段代码是单态的(即,总是以相同的类型调用),它将得到一个存根。如果它是多态的(从一个代码传递到另一个代码,使用不同的类型调用),那么它将为通过该操作的每种类型组合获得一个存根。

这意味着引擎在选择存根之前必须询问很多问题。

jit compiler stub sample

因为每一行代码在基线编译器中都有自己的一组存根,所以编译器需要在每次执行代码行时继续检查类型。因此,对于循环中的每次迭代,它都必须提出相同的问题。

jit compiler stub confirming

如果引擎不需要重复这些检查,代码将执行得更快。这就是优化编译器所做的事情之一。

在优化编译器中,整个函数被一起编译。类型检查被移动,以便它们发生在循环之前。

jit compiler stub confirming optimization

一些即时编译甚至进一步优化了这一点。例如,在 Firefox 中,数组有一个特殊的分类,它只包含整数。如果arr是这些数组之一,则 JIT 不需要检查是否arr[i]是整数。这意味着即时编译可以在进入循环之前进行所有类型检查。

7. 新的优化编译器(Maglev)

2023年谷歌在V8 JavaScript引擎中推出的一种新的优化中间层即时编译器,位于Sparkplug和TurboFan之间。Maglev旨在快速生成非常高性能的机器码,另外Maglev还通过使用更少的离线CPU时间显着减少了V8引擎的总体资源消耗。与顶层即时编译器TurboFan相比,Maglev生成的代码优化程度较低,但编译速度更快。

总结

V8 JavaScript引擎的即时编译,通过引入代码执行监控、热点代码分析、编译、缓存等机制,实现了JavaScript程序运行效率的大幅度提升,代价是多一些内存占用。这改变了纯解释型编程语言JavaScript程序运行慢的特点,让我们生活在一个更流畅的网络世界。

配合V8 JavaScript引擎的即时编译的工作

了解了即时编译的机制,我们在写JavaScript代码的时候,就可以有意识地配合引擎即时编译机制地工作,写出更健壮地代码。以下是一些小贴士。

  1. 不要频繁增加或减少对象属性,这会是编译器地优化失效。
  2. 尽量不要频繁修改对象地原型。最好的方式是只在对象创建的时候指定原型。
  3. 固定函数传参类型。
  4. 尽量不要在函数中声明类。在函数中声明地类,每次函数执行都会被声明一次,且每次声明的类都不是一个对象。

参考资料

  1. 即时编译-维基
  2. 解释器-维基
  3. A crash course in just-in-time (JIT) compilers
  4. Sparkplug — a non-optimizing JavaScript compiler
  5. Maglev - V8’s Fastest Optimizing JIT

发表留言

历史留言

--空--