字节跳动Web Infra团队揭秘模版解释器的来龙去脉与优化技术

共 12644字,需浏览 26分钟

 ·

2022-06-13 03:59

关于作者

王篁(huáng),来自字节跳动Web Infra团队,毕业于中国科学技术大学。博士期间主要方向是虚拟机和二进制翻译。

1 从跨平台到虚拟机

在移动端开发上,不可避免遇到的问题就是跨平台。开发者要同时在iOS /Android等平台侧进行开发。由于平台提供的API不同,导致要在不同平台上开发,效率较差,由此衍生出来各种跨平台解决方案。

在回顾各种跨平台方案之前,我们先来介绍一种平凡的解决方案,那就是直接从App打开网页(H5)。尽管看起来非常土气,但是这种方案仍然被国外非常多的App来使用。这是大家使用国外的App发现它动不动就跳到官网上的原因。尽管这种方法技术含量不够,但是还有市场,其原因是:

  • 实现简单。在App端(iOSAndroid)上只需要加一点代码,就可以引导到Web端。

  • 维护简单。实际上大部分精力只需要维护网页即可。

  • 上架简单。由于国外的应用市场审查机制较严,采用这种方法可以跳开繁琐的代码审查。

我们介绍的第一种跨平台方法,就脱胎于这种“直接访问Web”。

1.1 WebView

这种跨平台方案实际上和直接打开网页没有特别大的区别,只是使用的可能是自研的WebView而不是原生的WebView。不过显示效果大同小异,所以用这种方式的跨平台方案,你会觉得好像内置了一个浏览器。

  • iOS上,使用WkWebView(因为iOS存在限制)上进行封装;

  • Android,可以采用自研的各种WebView(也可以选择在原生的基础上封装)。自行封装可以实现更多功能。

这类跨平台的优势和上述平凡解决方案差不多,编写者不用特别关心跨平台方案本身,直接像前端一样实现即可。但是缺点也比较明显,就包大小来看,在iOS不会增加额外包大小;但是如果在Android上采用自研的WebView,包大小就增量可观。一般采用插件下发。另外,由于是整体“模拟”浏览器行为,所以其实很多东西下游未必需要,这种适合那种三端公用同一套逻辑地方,可以快速迭代,大大减少维护成本。

1.2 小程序

小程序的解决方案思路和前述两种方案很类似,但是由于它框架不局限在Webview层次,可以在渲染部分更底层(套native bridge API ),可以取得比前述两种方案更好的性能。不过,国内推广小程序的原因是,平台试图通过小程序来控制应用分发,实现闭环生态,类似苹果的策略。这样看来,小程序本身更多是商业视角。由这些平台厂商提供跨平台的方案,然后开发者基于这些方案进行开发。同时,在鉴权、数据互通、流量分享领域,平台都会提供一定的机制。包括广告分账(平台和开发者共享)等国内开发者最关心的地方,平台都可以提供交钥匙解决方案。而在用户侧,最直观的感受是所谓的免安装(实际上只是下载内容变少),将应用管理托管给这些平台提供者。而若干小程序共用平台提供的基础设施,总包大小也在减小。在平台侧,由于坐拥庞大的开发者和随之而来的控制力、分账,也是一本万利的方案。小程序的影响自不必说,我们日日在用的“支付宝健康码”“微信行程卡”等,都是一种小程序。

从技术上,小程序是一个hybrid应用。平台侧(宿主)会给一个改造的webview来展现H5页面(视图层),而数据操作使用一个JS引擎(有可能会对此JS做限制成为一个DSL)来实现(逻辑层)。这两层下面是一个管理层,通常称为JsBridgeJsBridge有两个基本作用,首先它承载了视图层和逻辑层之间的通信桥梁,视图层发出事件、接受指令,而逻辑层接受事件,发出指令,此外它们之间还有数据传输。这些东西的中介都是JsBridge。其次,JsBridge还要屏蔽下层的系统接口,对上提供一致性的调用,也是小程序跨平台的精彩地方。

在包大小方面,如果采用小程序实现跨平台,那么宿主必定要提供一套SDK在其中。

1.3 React Native

之所以称为RN 型,是因为这一类包括了RNWeex等一系列跨平台解决方案。这类看起来好像和小程序用法类似(开发者只需要写一个类似Js代码来完成各种操作),但是其实底层实现不完全一样。例如,在Android上,RNJs 同样作为数据操作逻辑层,但是它其实是native域的一个线程,换言之,从native域发出多个线程,其中一个线程运行Js引擎,用来执行js代码,其他的线程用来做其他事情,例如通信等等,最重要的是UI 渲染。所谓的Js 域和native域通信通过相应的RN Bridge 来实现。(小程序那套通信机制类似)

React Native型最大优势是其开发群体迁移方便,ReactVue的开发群体都很广,而且渲染就是真正的native渲染(因为就是映射到系统的控件),渲染性能好。缺点也很明显,首先是首屏加载时间,主要是Js引擎初始化耗费时间。其次是开发上手不如其他跨平台方案,开发者学习成本较高。如果下层操作系统更改,框架就要做出相应更新。一句话,跨了,又好像没跨。

1.4 自绘界面

这类以Flutter为代表。它的最独特特征就是,它的UI 既不复用H5那一套Web,又不映射到系统组件,而是自己写渲染,操作DSL等组件。Flutter的目的更像在桌面端的Qt一样(在WinLinux 端实现一致性的界面和操作逻辑)。但是Flutter使用的DSLDart,它可以高效AOT,也可以JIT ,对比JS 有一定优势。

1.5 总结

实际上,随着时间演进,会有更多的跨平台解决方案推出。不过,当我们回顾这些方案时发现,实际上Javascript语言在很多跨平台方案中占据了重要的位置,从开发者端分析,是因为Javascript学习成本低,很多开发者都是从前端转来,采用Javascript 更好上手,特别是渲染是基于webview类似方案时候。从平台侧,Javascript引擎易得,并且Javascript支持动态化。

动态化对于国内App的意义很大,因为需求经常更改,需要频繁应对竞品新功能进行演进,加之国内对于应用审核环境宽松,所以Javascript作为第一语言地位短时间仍然存在。带来的挑战是,如何实现一个高效、精简的Javascript 虚拟机。

2 高级语言虚拟机(HLLVM

2.1 虚拟机分类

要谈高级语言虚拟机(high-level language virtual machine, HLLVM)技术 ,就要先谈虚拟机技术。虚拟机技术是一类历久弥新的技术。广义上说,我们接触的诸多系统虚拟机(如Qemu/VirtualBox/Vmware等)、动态二进制翻译系统(如DynamicRIO/Rosetta)、高级语言虚拟机(Hotspot/v8)都是一种虚拟机技术。尽管它们设计目标和实现手段差异较大,但是仍然可以统一到一个视角下。

按照提供何种级别的ISA,我们把虚拟机分为进程虚拟机和系统虚拟机。对于前者,它致力于提供一种ABIApplication Binary Interface),换言之,是提供了用户级指令和系统调用;对于后者,它致力于提供完整的ISA,包括用户级指令和系统级指令。这两种虚拟机又可以根据它的两侧(guesthost)是否属于同一ISA来进一步细化。从这个分类意义下,高级语言虚拟机被分类在跨ISA的进程虚拟机下,区别是高级语言虚拟机的程序级接口是一种高级的、和其他的进程虚拟机不同的(高级语言语义,一组可以被移植的库)。


跨ISA同ISA
进程虚拟机动态二进制翻译器,高级语言虚拟机动态二进制优化器
系统虚拟机虚拟机系统(如Qemu虚拟机系统(如kvm

2.2 高级语言虚拟机(HLLVM

如上所属,高级语言虚拟机的设计思想来自进程虚拟机。进程虚拟机向客户进程提供了一个虚拟的执行环境,让客户进程认为它自己运行在这样的执行环境上。但是实际上进程虚拟机的开发是比较困难的,因为存在着多种ISA和多种操作系统。从一个角度看去,进程虚拟机是一种事后诸葛亮。也就是,客户进程(或言程序)已经被开发出来后,我们想去移植到不同的平台(不同的ISA、不同的操作系统接口)。既然如此,我们何不让客户进程面向一致性的ISA和一致性的“操作系统接口”呢?这正是高级语言虚拟机的初衷。

这种一致性的ISA正是虚拟指令集架构。它被设计为一种虚拟的、独立于具体的实现的指令集架构。一致性的“操作系统接口”,或言一致性的系统调用,被设计为一种高级语言(这是它得名来历)的接口,也就是一组库。

上面的解说有点抽象,我们举个具体的例子。以Java语言虚拟机为例,这种虚拟的ISAJVM标准,Java 字节码就如同这个虚拟ISA的“指令”。而这种一致性的系统调用,是Java语言规范中关于库的描述。

3 解释器

3.1 基本概念

解释器也是一种古老的技术,它被广泛地应用在各种虚拟机的实现和优化上。更严格的说,它被用来实现ISA的虚拟化。本篇文章的重点就是各种解释器技术。所谓解释(interprete),是相对于翻译(translation)而言。一般而言,解释是这样的过程,它先取源指令,对其进行分析,执行所要进行的操作,然后再取下一条指令,如此循环,直到遇到停机或者中断(end of dispatch- EOD)。而翻译则试图分担取指和分析的代价,它将源代码块翻译成目标代码块,并存起来备用。

这里还要辨析一个常见的误解,就是Just-In-Time(JIT),它常常被代指翻译。实际上,JIT只是一种技术。它可以用来实现解释,也可以用来实现翻译。反过来说,解释也可以用JIT,也可以用快照(snapshot)。翻译也可以直接到代码块(Ahead-of-Time,AoT),也可以JIT

谈到解释器,我们就不得不谈另一个概念,即所谓的“解释型语言”。这个概念并没有一个确切的定义,甚至“解释型”本身也是模糊的概念。我们常说Javascript是一种“解释型语言”,只是一种习惯性称呼而已(比如某种语言的解释器更为大众熟知,遂认为这是一种解释型语言),不代表它一定要用解释器实现。如同我们常说“鲸鱼”和“海马”,不代表它们是鱼或者马一样。对应的“编译型语言”,同样是一个内涵和外延不明的概念。更合适对于语言进行分类的方法,应该是从诸如“强类型”和“弱类型”这种维度。

3.2 基本解释(Basic Interpretation)

即便从来没有接触过解释器相关技术,我们也会自然想到用一个switch-case来实现一个基本的解释器。基本的解释器遵循一个译码派发(decode-and-dispatch)流程,也就是一个while循环加上一个switch-case结构。其基本结构大致如此:

while (!EOD()) { // 1 主循环 
op = fetch(pc); // 2 取指令
switch(op) { // 3 根据不同指令,进行相应操作
case op1: {op1()} // 4 根据不同指令,进行相应操作
...
}
}

这里稍微解释一下这个流程。

  • 1 是一个主循环(main loop),它一般是遇到字节码结束或者一些必须要暂停的指令才会结束。

  • 2 是一个取opcode的过程。注意这里一般情况下仅仅是取字节码指令的操作码部分,因为很多指令实际上是变长的,真正结束的地方要等到具体的opcode而定,换言之,在第4步才能移动pc 指针。

  • 3-4 这是一个switch-case,它根据不同的opcode,来调用不同的例程,从而完成对应的操作。其中就包括移动pc 指针。

pc 指针,有时候在代码里会被叫做bcp-- bytecode pointer,是一个关键指针。它用来索引字节码,指向的位置正是下一条要执行的字节码。

以上是一个基本解释(或称朴素解释)的过程。接下来要介绍的其他解释,实际上都是在此基础上的优化。

3.3 间接线索解释(Indirect Threaded Interpretation)

在进入线索解释之前,我们先看看基本解释的性能问题。其最主要的问题就是有大量的跳转指令,比如

  • 在2处,需要访存,取出op ,放入寄存器。在3中,使用一个间接跳转来跳转到不同的case。这条分支指令很难被预测,因为这条指令是被各种op 共享的,分支预测机构(BTB难以利用指令之间的逻辑关系)

  • 在4的op1()的实现部分,需要有一个return回到主循环,这是一个间接跳转。一般而言,返回地址会在调用op1()之前被压入栈结构,然后返回时候弹出到返回地址的寄存器,然后依据此寄存器进行跳转。

  • 还有一个地方隐藏着一个间接跳转,在1处。我们测试是否满足结束(EOD()- end of dispatch条件时),我们会有一个返回主循环的跳转。

线索解释改进了对于跳转的处理,它的基本结构不再是switch-case,而是将每个handler处理成一个个的标签

while (!EOD()) { // 1 主循环 
op = fetch(pc); // 2 取指令
goto *dispatch_table[op] //3 跳转到对应的例程
op1:
{...} //4 op1 的例程
op2:
{...} //
...
}

除此以外,主要的改变有两个,第一点是所有例程地址都被放在dispatch_table[]数组里面,用op作为索引;第二点是每个具体的op handler部分,在尾部添加了判断是否跳出循环(1)和取指令(2)、派发(3)。请注意,这样并没有在实际上减少跳转,只是对于分支预测机构更加友好。

我们可以看到,整个分发流程通过dispatch_table[]来进行,这种分发过程因此被称为间接线索解释。所谓线索,指的是现在执行流实际上是一个个的handler拼接而成,看起来像一个个由线穿起来一样。所谓间接,指的是这种线索解释,需要由一个dispatch_table[]来负责分发。

3.4 直接线索解释(Direct Threaded Interpretation)

间接线索解释有个问题,就是每个例程实际上还是要通过一个派发表访问。那么如何可以减少这个访问开销呢?我们可以通过一种预译码技术来实现。我们首先把字节码预先翻译为中间代码。最简单的一种替换方法,是把对应的字节码替换成对应的例程地址,这样结构还维持线索解释,就可以直接执行派发表中的代码了(请注意个别需要重定位的指令)。

3.5 总结

作为一种常见的实现ISA虚拟化的手段,解释有着广泛的用途。我们梳理了针对解释器基本结构的若干优化,下面我们就对具体的解释器其中的实现技术进行介绍,这些技术被用在我们内部的JS引擎中。

4 解释器的优化技术

4.1 基于栈和基于寄存器的虚拟机

常见的高级语言虚拟机的字节码上的设计有两种,基于寄存器或者基于栈的。

  • 基于栈的虚拟机,字节码指令在执行一项Operation时候,是在操作数栈中进行的。所谓的,并非是堆栈的栈,而是操作数栈。在模板解释器中,这两者通常是融合的,也就是混栈。

  • 基于寄存器的虚拟机。字节码指令在执行一项Operation时候,指令都是操作虚拟机寄存器。这个寄存器也和真实机器的寄存器没有关系。

  • 无论是操作数栈,还是虚拟机寄存器,在解释器实现的时候,都可以映射到物理机器寄存器或者物理机堆栈。

  • 但是重要的是,字节码指令条数是有区别的,例如对于一个加法add dest, src1, src2

    • 在基于栈的虚拟机中,应该是:
push #src1 // src1的数值 
store src1
push #src2
store src2
load #src1
load #src2
add
store dest
  • 在基于寄存器的虚拟机中,应该是:
mov r1, #src1
mov r2, #src2
add dR, r1, r2
  • 粗看以下以为基于寄存器的虚拟机似乎效率更高,实际上优势没有那么大,因为,这些寄存器归根结底还是要映射掉真实的寄存器上,最终结果还是要存到公共上下文里面保存,仍然需要访存。尽管可以使用常用寄存器缓存(或称寄存器映射),但是由于基于寄存器的虚拟机实际上是无限寄存器,早晚是需要溢出一些的。同时使用栈顶缓存和寄存器缓存时候,两者差异没有在字节码层面以为的这么大。

  • 基于寄存器虚拟机的优势在另一些地方,前面提到实际上dispatch()操作是占用时间的大头,由于基于寄存器的虚拟机的指令条数较少,所以派发次数较小,所以它是有一定优势的。

  • 那么基于栈虚拟机是否就该否定呢?也不全是,主要是:

    • 基于寄存器的虚拟机,需要在parser阶段就要分配寄存器。尽管寄存器可被认为是无限的(由于考虑字节码要编码,寄存器是有限的,只是在一个程序里面理论上不会用完),但是要做转换。
    • 可以通过窥孔优化,一定程度缓解基于栈的虚拟机的字节码条数过多的问题。例如我们实际上可以通过拷贝传播来将上述的基于栈的虚拟机的add实现转成基于寄存器的虚拟机的add实现的。
    • 从源码生成基于栈的字节码是简单的。无论是从源码到AST再到字节码,还是直接从源码生成字节码。
  • 总体来说,在普通的解释器层面,必须承认,栈式虚拟机是不如寄存器式虚拟机效率高的。当然,在Opt JIT 层面两者并无差异。

这里列表展示一下主流虚拟机的字节码选择:

引擎名公司用途字节码
v8GoogleChrome寄存器
JSCAppleSafari寄存器
SpiderMonkeyMozillaFirefox
ChakraMicrosoftEdge寄存器
HotspotOracle-
DalvikGoogle-寄存器

从表中对比可以看出来,除了较为早期的虚拟机,基本上主流虚拟机都采用基于寄存器的设计,因为解释器效率是可见的。对于大团队来说,基于寄存器虚拟机的开发和维护代价不成为大问题。

在实际工程上,选择何种虚拟机还要考虑历史代码、包大小等因素。我们的虚拟机需要兼容之前的字节码(为基于栈的字节码),如果重新搞一套基于寄存器的体系,则需要额外的parse,势必对于包大小有一定影响。综合考虑之后,我们还是选择了基于栈的虚拟机。

4.2 数值表示

4.2.1 什么是数值表示

对于一些强类型语言(比如Java)来说,数值表示并非一个重要的问题。但是对于Javascript这种语言来说,数值如何表示就成为一个较为关键的问题。按照标准2018中第6章 ECMAScript Data Types and Values,数据类型有以下七种:

  • undefined

  • null

  • boolean

  • string

  • symbol

  • number (IEEE 754 double)

  • object

我们选取的JSValue的表示必须能够高效地编码这几种。我们仔细分析上述的7种类型,发现可以大致分为三类:

  • 数字类型 - number, boolean,为了优化而细分的整数

  • 引用类型 - string,symbol,object

  • 其他杂项 - undefined,null

实际上JS中是没有真正的整数的,但是为了加速,所有的高效引擎都会选择把整数列入编码。

4.2.2 各种编码方式

剩下的主要问题是编码,对于64位平台我们看到代码中主要有4类可选的技术方案:

  • nan-boxing

  • pun-boxing

  • tagged pointer

  • tagged union

4.2.2.1 nan-boxing

  • JSC选用

  • 这种方法的基本原理如下,对于IEEE754中,double 中用来表示指数的有11位,如果指数部分全设置成1, 而小数部分非全0(全0为正负无穷),则为NaN。注意,这样的话,对于一个NaN来说,实际上 小数部分没有使用(当然需要确保它不为0, 要做到这一点很容易,我们只需要让小数部分最高位为1即可,这就是qNaN编码)。既然这样,我们可以在剩下的小数部分存放指针(注意,即便在64位上,指针也只是48位)。以JSC为例:

* The top 16-bits denote the type of the encoded JSValue:
*
* Pointer { 0000:PPPP:PPPP:PPPP
* / 0001:****:****:****
* Double { ...
* \ FFFE:****:****:****
* Integer { FFFF:0000:IIII:IIII
*

它的编码方式如上。请注意,这里并非是完全的qNaN,而是做了一定运算:

  • 对于指针,直接使用

  • 对于小整数(32位整数),直接取后32位

  • 对于double类型,要做一个编码级别的转换

inline double JSValue::asDouble() const
{
ASSERT(isDouble());
return reinterpretInt64ToDouble(u.asInt64 - DoubleEncodeOffset);
}

注意这样相当于,每次的double运算都要额外做一次整数减法。

4.2.2.2 pun-boxing

同样的思想,我们不动double的编码,然后直接把指针编码进入后面的48位,也是可以的。在SpiderMonkey里面采用这种。

4.2.2.3 tagged pointer

这种方法用了一个事实:在堆上分配时候,是字对齐(实际上指针的最后两位始终是0,是四字节对齐,只是这里只用了一位,这里可以修改成一个tag,让JSValue存double)。V8使用这种方式。在v8中,除了32位(准确的说,是31位)整数外,其他的所有value都是分配到堆上的。所以JSValue只需要关心这种整数(v8 称为小整数,SMI)和指针两类存储即可。v8的做法是将SMI腾挪到前32位,而让后32位空成0,并且把指针最后一位填充成1。

4.2.2.4 tagged union

这是qjs中使用的办法,就是用一个单独的标志字段(居然也是64位,这个很可能是作者为了对齐考量,另外,在传输参数时候,至少要用一个寄存器)标记是何种类型。qjs 做了一点小小的优化:

enum {
/* all tags with a reference count are negative */
JS_TAG_FIRST = -11, /* first negative tag */
JS_TAG_BIG_DECIMAL = -11,
JS_TAG_BIG_INT = -10,
JS_TAG_BIG_FLOAT = -9,
JS_TAG_SYMBOL = -8,
JS_TAG_STRING = -7,
JS_TAG_MODULE = -3, /* used internally */
JS_TAG_FUNCTION_BYTECODE = -2, /* used internally */
JS_TAG_OBJECT = -1,

JS_TAG_INT = 0,
JS_TAG_BOOL = 1,
JS_TAG_NULL = 2,
JS_TAG_UNDEFINED = 3,
JS_TAG_UNINITIALIZED = 4,
JS_TAG_CATCH_OFFSET = 5,
JS_TAG_EXCEPTION = 6,
JS_TAG_FLOAT64 = 7,
/* any larger tag is FLOAT64 if JS_NAN_BOXING */
};

这样的话,取出来tag的时候就能看到对象是在本地槽位还是堆上了。

4.2.3 数值表示对比

我们来看看各类的取用效率上有什么特点:

  • nan-boxingJSC 的实现,取用指针较为直接,取用double要多一个操作。

  • pun-boxing:取用double比较方便。

  • tagged pointer:把double放到堆上,唯一值得称道的就是可望将来可以做指针压缩。

    • 因为JSValue只表示整数和指针,所以它可以做指针压缩。
  • tagged union:粗看好像有点蠢,取用任何元素都要两个操作:

    • tag,根据tag再取出来真实的数据类型
    • 但是这个操作并不是两次load,因为tagvalue是在一个cacheline里面的。
    • 而其他的编码方式都需要对数字做一次额外的运算。

最终我们选择nan-boxing编码,tagged union每次都要耗费两个栈顶位置来存放,过于浪费空间和指令了。

4.3 模板解释器

4.3.1 基本概念

模板解释器并没有对于解释器的基本流程有什么更改,而是对于具体每个op的实现(也就是所谓的bytecode handler),采用了宏汇编的方式,看起来每条字节码的实现,都是一个个的汇编“模板”,所以得名。不过仔细推究起来,即便是不采用宏汇编的方式,每个op的实现也是一个个“模板”(只是不由宏汇编直接生成罢了),所以“模板”云云,不过是一个名字而已,或者认为它是“汇编模板”的缩写。

一种常见的误解是,线索解释器必定是模板解释器。这里我们要区分一下,线索解释或者基本解释,都是解释器的一种算法结构,和实现语言无关。高级语言可以实现线索解释,模板解释器也可以用基本解释结构。如果仔细强调它们的区别,就是每个bytecode handler使用的语言,到底是宏汇编(称为“模板解释器”)还是高级语言(根据高级语言不同,称为对应的语言解释器,如“C++解释器”)。

4.3.2 优势和劣势

如何要使用宏汇编来实现bytecode handler?这里面有若干原因:

  • 历史上高级语言编译器效率未必够好。从而用高级语言编译器生成的bytecode handler效率不如手写的宏汇编。不过随着编译器技术加强,现代编译器(llvm/gcc 等)已经足够好,甚至有时比起一个熟练汇编程序员的水平还好。所以这一点不是选择模板解释器的理由。

  • 如果采用模板解释器,可以自己控制寄存器分配。方便将特定的结构分配到寄存器中。模板解释器可以采用手动分配寄存器手段来进行寄存器分配,从而实现一些独特的优化技术(比如全局的寄存器分配或者寄存器缓存)。这种寄存器分配是否比用高级语言编译器分配的更好?这可能需要度量和权衡。我们会在下一小节进行详细介绍。通常情况下,这种寄存器分配策略比起高级语言编译器的结果更好。主要是对于跨bytecode handler中热点区域,减少了频繁的loadsave

  • 更利于操作程序。宏汇编对于空间操作更方便,包括混合栈之类的技术运用起来更方便。此外,对于某些特定的bytecode handler,用宏汇编可以生成高效的结果(换言之,结果可控)。

不过,世界上没有免费的午餐。汇编模板解释器也有一些问题:

  • 解释器编写代价高。一般来说,需要有一个宏汇编模块,还需要针对每个bytecode handler书写对应的汇编代码。

  • 效率提升需要较高的编程技巧。因为现代编译器已经很高效,稍不注意自己写的bytecode handler可能还不如编译器生成的。

  • 可能会导致代码膨胀。如果采用多栈顶缓存,可能会在每个bytecode handler处引入额外的过渡代码。

我们最终采用大量的度量和权衡,还是选择了模板解释器来实现,不过我们把这些劣势控制在可接受的范围内。

4.4 寄存器缓存

何时进行寄存器分配,是一个编译器策略抉择问题。对于一个模板解释器而言,自然的方式是手动分配寄存器、在生成残桩代码时候分配好寄存器。为了加速,实际上常常混用两种简单的策略:

  • 指定寄存器分配

    • 此方法给一些频繁使用的变量一个专用寄存器,使得对于这些变量的访问不再是从内存取得而是直接从寄存器获取。
    • 指定寄存器非常要注意的是在切换边界地方。在进入解释器前,要在prologue里面把全局量准备好;在从解释器跳到不受控制的C代码时候(例如runtime)要保存那些需要保存的寄存器,等待跳入解释器后再恢复。最后,退出解释器时候,需要在epilogue里面将全局量写回。
    • 指定寄存器最需要注意的点就是保存和恢复。注意,并非所有的寄存器都需要解释器来保存和恢复。操作系统、体系结构、编译器三者会约定寄存器的性质。
  • 寄存器缓存

    • 这个实际上是利用某个寄存器作为热点内存区域的缓存。基本思想和前面的指定寄存器分配一致。不同的是,指定寄存器分配所用的量是基本不变的,读远远大于写。而寄存器缓存存放的量是读写都很频繁的量。
    • 在栈基虚拟机上,这个技术可以应用在栈顶缓存(top of stack cache,TOSCA),因为栈基虚拟机的栈顶是操作频繁的地方。在寄存器基虚拟机上,被称为累加器(accumulator,这个就是EAX寄存器的那个A,这两个命名是同源的)

我们最终综合使用了这两种技术,尽可能地保证常用的量在寄存器中,减少频繁的溢出操作。

4.5 快照技术

大部分的情况下,我们需要预先生成一些bytecode handler模板,这些生成模板过程会导致两个典型问题:

  • 引擎启动时间变长。因为需要汇编器JIT生成,这还带来一个额外问题,就是iOS 这种系统不允许这种JIT 的方式。

  • 引擎二进制变大。这是相对于C++解释器而言。如果在最终的解释器二进制中保留一个汇编器,那么引擎将会变大,从而影响最终的包大小。

我们可以采用快照技术来解决这两个问题。所谓快照,即将对应的模板代码片段预先生成好放到代码段,使用时候启动时直接加载即可。快照技术需要解决两个问题:

  • 如何将汇编器生成结果和代码的其他部分混合编译。

  • 如何解决其中的重定位问题,主要是调用runtime函数的重定位问题。

首先看第一个问题,一种解决办法是直接生成相应的汇编文件。也就是汇编器同时承载两个功能,当用为JIT编译器时,就生成汇编二进制;当用作快照汇编器的时候,就生成符合编译器要求的asm file(如.S文件),随后通过二次编译把相应的asm file和其他部分混编即可。

然后解决第二个问题,实际上汇编器本来就有重定位的问题需要解决,例如跳转标签。不过快照带来了额外的问题,就是调用runtime部分的时候,如果用作JIT编译器的时候,可以直接让编译器来处理。但是如果写入到文件,需要额外的找到一个重定位标记。这里可以使用的手段是,我们直接将函数和一个label 关联,在生成的汇编中直接生成调用标签的宏汇编即可。

4.6 小结

在一个好的解释器设计中,最重要的就是“权衡”。这其中不仅包含一些技术的权衡(例如,基于寄存器还是基于栈,启动时间还是编译质量等),还包括软件管理的一些权衡(开发成本和效率等)。我愿称解释器的编写为一种权衡的艺术。

5 总结

本文概要地介绍了解释器的来龙去脉,着重介绍了几种常见的解释器的优化技术。由于时间比较仓促,加上对于相关知识理解不够,文中难免有各种错误,欢迎大家指正。

- END -
浏览 300
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报