专栏首页腾讯IVWEB团队的专栏【译】一种新的 JavaScriptCoere 字节码规范

【译】一种新的 JavaScriptCoere 字节码规范

r237547版本我们介绍过一种新的 JavaScriptCore(JSC) 字节码规范。对比之前的规范为了提高编译器的吞吐量而增加了内存使用,这个新的规范提高内存的利用率并且允许字节码可以被硬盘缓存起来。

在这篇文章中,我们准备从一份 JSC 的字节码示例开始讲起,旧版字节码规范的主要作用和他使用到的优化。接着,我们会看看新规范是怎么优化编译器的。最后我们会看下新规范是如何影响内存和性能,还有这样重写之后怎样提高 JSC 的类型安全性。

背景

JSC 执行任何 JS 代码之前,必须经过编译和生成字节码的过程。JSC 有四个执行层级:

  • 低阶编译器(LLInt):编译器启动
  • 基础 JIT:一个模板 JIT
  • DFG JIT:一个低延迟的最优编译器
  • FTL JIT:一个高吞吐量的最优编译器

执行器在最底层从编译字节码开始,编译完的字节码就越来越合适高阶层去使用。这个可以在(这篇 FTL 相关的博客)https://webkit.org/blog/3362/introducing-the-webkit-ftl-jit/中找到更多的细节。

字节码是整个引擎的驱动源泉。LLInt 执行这些字节码。底层是一个把每一个字节码指令执行成为机器码片段的模板 JIT。最后,DFGFTL 把字节码进行编译并触发在一个最优的编译器上执行的 DFG IR运行。

因为字节码是驱动源泉,它们就需要在整个程序执行的时候保持在内存中是可用的状态。在像 Facebook 或者 Reddit 这种重 JS 逻辑的页面中,字节码负有撑大20%内存的责任。

字节码

为了让说明更形象生动,我们一起来看一个简单的 JavaScript 程序,看下 JSC 怎么生成字节码和怎么理解字节码的转存。

// double.js
function double(a) {
    return a + a;
}
double(2);

如果你用 jsc -d double.js 来执行上面的程序,JSC 会以 stderr 的形式输出所有生成的字节码。具体生成看下面:

[   0] enter
[   1] get_scope          loc4
[   3] mov                loc5, loc4
[   6] check_traps
[   7] add                loc7, arg1, arg1, OperandTypes(126, 126)
[  13] ret                loc7

每一行都以带有中括号包裹的偏移量的指令,跟着是操作名字和操作数。我们看到 loc 作为本地变量,arg 是函数参数和 OperandTypes,这些都是直接操作的参数类型元数据。

旧版字节码规范

旧版字节码规范有一些我想修复的问题:

  • 消耗过多内存
  • 指令流是写入方,阻止内存匹配字节码流
  • 有部分优化我们没办法收到成效例如(direct threading)http://wiki.c2.com/?DirectThreadedCode

为了更容易理解,我把这些问题都提到了新的规范上去,我们需要对旧的字节码规范做一个简单的了解。在旧的规范中,指令可以在一个或者两个模式中,unlinked(复杂并对存储做了优化),linked(结构庞大并对执行做优化,包含了在运行时对象直接调用指令流的内存地址)

Unlinked 指令集

指令都被 (variable-width encoding)https://en.wikipedia.org/wiki/Variable-width_encoding编译过。操作码和每一个运算元都只运用最小的空间,从1-5字节中排列。从上面的程序中拿出 add 指令来作为示例,用了6字节:一个用于操作符,不同的寄存器各用一个字节(loc7arg1还有再一次arg1),剩下两个字节作为运算元类型。

Linking/Linked 指令集

在执行字节码之前需要被链接起来。链接会使所有指令都变得臃肿起来,把操作码和每一个操作元都通过指针规定好。操作码被准确的指针去替换执行对应指令,文件相关的元数据会替换掉最新创建来存储定位文件数据的结构的内存地址。上面的 add 用了40字节去展示

执行

字节码被 LLInt(用一个叫 offlineasm 易于移植的汇编来编写的) 执行,下面是一些关于 offlineasm 的一些要点,可以帮你了解下面的代码片段:

  • 永久性寄存器是 t0-t5,参数寄存器是 a0-a3,返回寄存器是 r0r1。浮点的小数点存在相等寄存器 ft0-ft5fa0-fa3frcfpsp 是用于存储所有堆栈指针的寄存器。
  • 指令都有如下的某一个后缀:b 指字节,h 指16位的字符,i 指32位字符,q 指64位字符,p 指的是指针(根据实际硬件决定是32位还是64位)
  • 宏指令是一堆用于用0个或多个参数还有返回对应返回码的描述。宏指令可能是有名字也可能是匿名的,并且可以把参数传递给其他的宏指令

如果你想了解更多关于 offlineasm 的信息,可以去看下在 JSC 文件中的主 offlineasm 文件--LowLevelInterpreter.asm,这个文件里面含有上述的更深层次的解释。

旧的字节码规范有两大关于编译器吞吐的优势:直接操作和内联缓存。

直接操作

链接后的字节码拥有对 offlineasm 的指令实现地址从而替换掉正在执行下一步指令大小的 PC 的地址去的操作码。我们用下面的 offlineasm 代码来说明一下:

macro dispatch(instructionSize)
    addp instructionSize * PtrSize, PC
    jmp [PC]
end

这里注意一下 dispatch 是一个宏指令,在每一个指令的结尾重复的加上。尽管这只是一个简单的加法到一个非直接指向的分支去,但还是很高效的。这些宏指令减少了分支重定向带来的污染虽然我们并不是所有指令都需要跳到一个正常的标识并且享用同一个非直接跳转的下一指令去。

内联缓存

因为命令流是可写并且所有参数都是指针规范的,我们可以把元数据存储到指令流里面去。关于这个最好的例子就是在 JavaScript 中加载一个对象时会触发的 get_by_id 指令。

object.field

这里会触发一个 get_by_id 去加载 object 中的 field 属性。因为这是一个在 JavaScript 中经常使用到的操作,最重要的就是需要这个指令执行的足够快。JSC 用内联缓存进行加速。在编译器中把存储操作流的空间进行翻转去缓存加载相关的元数据。更具体的说,我们把对象用来标识我们该去内存中那个位移量寻找加载值的 StructureID 记录下来。LLInt 对于 get_by_id 的实现可以看下面:

_llint_op_get_by_id:
    // Read operand 2 from the instruction stream as a signed integer,
    // i.e. the virtual register of `object`
    loadisFromInstruction(2, t0)

    // Load `object` from the stack and its structureID
    loadConstantOrVariableCell(t0, t3, .opGetByIdSlow)
    loadi JSCell::m_structureID[t3], t1

    // Read the cached StructureID and verify that it matches the object's
    loadisFromInstruction(4, t2)
    bineq t2, t1, .opGetByIdSlow

    // Read the PropertyOffset of `field` and load the actual value from it
    loadisFromInstruction(5, t1)
    loadPropertyAtVariableOffset(t1, t3, t0)

    // Read the virtual register where the result should be stored and store
    // the value to it
    loadisFromInstruction(1, t2)
    storeq t0, [cfr, t2, 8]
    dispatch(constexpr op_get_by_id_length)

.opGetByIdSlow
    // Jump to C++ slow path
    callSlowPath(_llint_slow_path_get_by_id)
    dispatch(constexpr op_get_by_id_length)

新的字节码规范

在设计新的字节码的时候,我们有两个主要的目标:必须更加简洁和更容易被硬盘缓存起来。带着这两个目标,我期待可以在内存使用上有重要的提升和通过缓存让我们在运行时的表现可以有所提升。根据这个规范了我们指令编码和运行时的元数据。

第一个也是最大的变化是我们不再对于执行有单独的链接编码。这具体表现在字节码不再被直接执行,因为指令地址不在存在硬盘中,在每个程序中都被改变着。去掉之前的这个优化是新设计中一个经过多番考虑的选择。

为了让同一种规范可以适配存储和执行,每一个指令都被编码成 narrowwide

Narrow 指令

在一个 narrow 指令中,操作码和他的操作数各占一个字节。下面还是以 add 指令作为例子,不过是一个 narrow 指令的新规范:

事实上,所有指令都会被编码成 narrow,单不是所有的操作数都适合放在一个单独的字节里面去。如果任何操作数(或者任何一个操作码)需要多于一个字节的空间,整个指令会被转换成 wide 格式。

Wide 指令

一个 wide 指令包含一个独特字节的操作码 op_wide,紧跟着一系列的四字节片段用于原始的操作码和各种参数的存储。

这里有一点需要我们去注意的,这里是不可能存在任何一个 wide 操作数-只要任何一个操作数超过一字节,整个指令就被转化为 wide 格式。这是一个重要的前提,尽管这样看起来会造成浪费,但是他实现起来更加的简单:任何给定的操作数的偏移量都是同样对于指令来说无需关心她是否 narrow 还是 wide 都适用。唯一不同的地方只是在于操作流是以四个字节还是一个字节来作为取值的区间。

链接/元数据表

另外一个新的字节码组成基础就是元数据表。在链接过程中,我们初始换了一个写满了所有给定的指令关联的可写入数据的表格来代替建立一个新的指令流。理论上来说,这个表是二维的,第一个元素是指令的操作码例如 add,指向着特定指令含有的元数据的单态数组。举个栗子,我们有一个结构调用 OpAdd::Metadata 去操作 add 指令的元数据,通过访问 metadataTable[op_add] 会返回一个 Opadd::Metadata* 的指针类型。另外,每个指令都有自己的操作数,在元数据表中的第二个参数就是metadataID

为了可以更加紧凑,元数据表被当做一个单独的内存块存放在内存当中,下面是一个关于上面表格具体是怎么存放在内存中的例子:

在表格的开头是一个含有每个操作码的整数数组头部,表示着这些操作码元数据的开始指针地址。下一个区域就是存储包含着每个操作码的真实元数据。

执行

对于执行来说最大的变化在于编译器现在对于任何允许写或者运行中的元数据都是非直接执行的,我们需要去元数据表当中进行读取。其他有趣的方面就是 wide 指令是怎么被执行的了。

非直接执行

从操作码去匹配到对应指令地址的进程在老的规范中被当做链接的一部分去操作,但现在作为编译器 dispatch 的一部分。这意味着额外加载是必须的,dispatch 宏指令现在如下:

macro dispatch(instructionSize)
    addp instructionSize, PC
    loadb [PC], t0
    leap _g_opcodeMap, t1
    jmp [t1, t0, PtrSize]
end

元数据表

和内联缓存章节一样,仍然用 get_by_id 作为例子,我们需要在执行期间写入元数据但操作流程现在是只读的,这些数据需要存在于元数据表当中。

从元数据表中加载数据会有一点耗时,因为 CodeBlock 需要从调用栈中加载,元数表又存在于 CodeBlock 中,当前操作码数组从表中拿出来最后元数据项又从基于指令的 metadataID 对应的数组中获取。

Wide 指令执行

wide 指令的执行意外的简单:每个指令有两个函数,一个是 narrow 的版本另一个是 wide。我们利用 offlineasm 的优势去从一个单独的实现中创建两个函数(例如,op_add 的实现会自动创建他的 wide 版本,op_add_wide

默认情况下,narrow 版本的指令会被执行,直到制定的指令 op_wide 被执行。接下来要做的是读取下一个操作吗,并且 dispatch 他的 wide 版本,例如 op_add_wide。一旦 wide 操作码执行完,会回到发送一个 narrow 指令,因为每一个 wide 指令需要一个字节的 op_wide 前缀。

内存使用

新的字节码近乎节省了50%的内存,说明整体上对于重 JavaScript 逻辑的网站如 FacebookReddit 减少了10%的内存负载。

下面是一个关于 reddit.comapple.comfackbook.comgmail.com 的字节码大小图片对比。

需要注意的是在 After 例子中,Metadata 已经被合并到 Linked 展示元数据表的内存使用和 Unlinked 展示了真实的字节码命令。一个重要的部分是新版元数据以前存在于旧的链接字节码当中。否则,这些对比看起来是倾斜的,因为整个 Linked 条会成0并且 Metadata 看起来是现在的2倍,但是加上只是把部分 Linked 的数据移到了 Metadata 上去了。

性能

像我们之前讨论的一样,非直接操作提升了编译器 dispatch 的整体性能。但是,根据 JSC 中字节码指令的平均复杂度,我们并不希望 dispathc 在全局上扮演一个主要的角色对于整体的编译器性能而言。这将会在多个 JIT 层级中分期累加。我们整理了在 CLoop 中单纯的从直接到非直接执行的切换,在 C++ 的后端例如 LLInt ,结果当然是禁用了 JIT

从元数据表中去加载有一部分消耗,包含一个大连接上的加载。为了减少这个链,我们把一个调用者存储的寄存器连接到编译器中去保存指向元数据表。通过这个优化,加载了三次去查询元数据的空间。这让编译器速度减缓了10%,但事实上是全局的 JIT 一直在使用。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 一次 Node.js 内存溢出

    因为内存上限设置不合理,引起的内存溢出问题。之前压测时候只关注了是否存在内存泄露与cpu占用,而忽视了内存占用这个问题。对于部署服务时,要根据机器的内存上限以及...

    腾讯IVWEB团队
  • React V16 给我们带来了那些东西 ?

    在如今越来越复杂的前端环境下,往往可能需要加载且渲染大量的 DOM 节点,那么在渲染的过程中,即使我们使用了 React virtualDom 进行维护,但是,...

    腾讯IVWEB团队
  • 【译】让图片更有意义——图形检测API

    有了如navigator.mediaDevices.getUserMedia这样的api结合新版Chrome为Android提供的照片选择器,无论是捕获图像、获...

    腾讯IVWEB团队
  • 基础练习python(6)取自定义数生成一个符合条件的整数集合

    射雕英雄传的题目:判断输入数字是否符合“今有物不知其数,三三数之剩二,五五数之剩三,七七数之剩二,问几何?”

    adventureisoutthere
  • Genymotion安卓模拟器

    2016-03-1613:54:08 发表评论 1,513℃热度 安卓开发的同学们用过安卓模拟器(当然我只是因为上课需要研究了下),Eclipse里面自带模拟...

    timhbw
  • P05_kafka_2.9.2-0.8.1集群搭建

    安装scala 2.11.4 1、将课程提供的scala-2.11.4.tgz使用WinSCP拷贝到sparkproject1的/usr/local目录下。 ...

    Albert陈凯
  • javascript经典面试题之拷贝

    今天和大家一起来探讨一下javascript中的拷贝,使用拷贝的情况,要根据javascript的数据类型来定,javascript的数据类型分为基础类型和引用...

    挥刀北上
  • 快速学习-ElasticaSearch6.2.1安装

    安装配置: 1、新版本要求至少jdk1.8以上。 2、支持tar、zip、rpm等多种安装方式。 在windows下开发建议使用ZIP安装方式。 3、支持...

    cwl_java
  • 「R」对象大小——R存储真的会占很大空间吗?

    在上一篇文章中介绍过列表存储的不是实际的值,而是指向值的引用,所以一个列表的大小比我们预期的可能要小的多。

    王诗翔呀
  • Linux之crontab定时任务

    AlicFeng

扫码关注云+社区

领取腾讯云代金券