在r237547版本我们介绍过一种新的 JavaScriptCore(JSC)
字节码规范。对比之前的规范为了提高编译器的吞吐量而增加了内存使用,这个新的规范提高内存的利用率并且允许字节码可以被硬盘缓存起来。
在这篇文章中,我们准备从一份 JSC
的字节码示例开始讲起,旧版字节码规范的主要作用和他使用到的优化。接着,我们会看看新规范是怎么优化编译器的。最后我们会看下新规范是如何影响内存和性能,还有这样重写之后怎样提高 JSC
的类型安全性。
在 JSC
执行任何 JS
代码之前,必须经过编译和生成字节码的过程。JSC
有四个执行层级:
JIT
:一个模板 JIT
DFG JIT
:一个低延迟的最优编译器FTL JIT
:一个高吞吐量的最优编译器执行器在最底层从编译字节码开始,编译完的字节码就越来越合适高阶层去使用。这个可以在(这篇 FTL
相关的博客)https://webkit.org/blog/3362/introducing-the-webkit-ftl-jit/中找到更多的细节。
字节码是整个引擎的驱动源泉。LLInt
执行这些字节码。底层是一个把每一个字节码指令执行成为机器码片段的模板 JIT
。最后,DFG
和 FTL
把字节码进行编译并触发在一个最优的编译器上执行的 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
,这些都是直接操作的参数类型元数据。
旧版字节码规范有一些我想修复的问题:
为了更容易理解,我把这些问题都提到了新的规范上去,我们需要对旧的字节码规范做一个简单的了解。在旧的规范中,指令可以在一个或者两个模式中,unlinked
(复杂并对存储做了优化),linked
(结构庞大并对执行做优化,包含了在运行时对象直接调用指令流的内存地址)
指令都被 (variable-width encoding)https://en.wikipedia.org/wiki/Variable-width_encoding编译过。操作码和每一个运算元都只运用最小的空间,从1-5字节中排列。从上面的程序中拿出 add
指令来作为示例,用了6字节:一个用于操作符,不同的寄存器各用一个字节(loc7
,arg1
还有再一次arg1
),剩下两个字节作为运算元类型。
在执行字节码之前需要被链接起来。链接会使所有指令都变得臃肿起来,把操作码和每一个操作元都通过指针规定好。操作码被准确的指针去替换执行对应指令,文件相关的元数据会替换掉最新创建来存储定位文件数据的结构的内存地址。上面的 add
用了40字节去展示
字节码被 LLInt
(用一个叫 offlineasm
易于移植的汇编来编写的) 执行,下面是一些关于 offlineasm
的一些要点,可以帮你了解下面的代码片段:
t0-t5
,参数寄存器是 a0-a3
,返回寄存器是 r0
和 r1
。浮点的小数点存在相等寄存器 ft0-ft5
,fa0-fa3
和 fr
。cfp
和 sp
是用于存储所有堆栈指针的寄存器。b
指字节,h
指16位的字符,i
指32位字符,q
指64位字符,p
指的是指针(根据实际硬件决定是32位还是64位)如果你想了解更多关于 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)
在设计新的字节码的时候,我们有两个主要的目标:必须更加简洁和更容易被硬盘缓存起来。带着这两个目标,我期待可以在内存使用上有重要的提升和通过缓存让我们在运行时的表现可以有所提升。根据这个规范了我们指令编码和运行时的元数据。
第一个也是最大的变化是我们不再对于执行有单独的链接编码。这具体表现在字节码不再被直接执行,因为指令地址不在存在硬盘中,在每个程序中都被改变着。去掉之前的这个优化是新设计中一个经过多番考虑的选择。
为了让同一种规范可以适配存储和执行,每一个指令都被编码成 narrow
或 wide
。
在一个 narrow
指令中,操作码和他的操作数各占一个字节。下面还是以 add
指令作为例子,不过是一个 narrow
指令的新规范:
事实上,所有指令都会被编码成 narrow
,单不是所有的操作数都适合放在一个单独的字节里面去。如果任何操作数(或者任何一个操作码)需要多于一个字节的空间,整个指令会被转换成 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
指令的执行意外的简单:每个指令有两个函数,一个是 narrow
的版本另一个是 wide
。我们利用 offlineasm
的优势去从一个单独的实现中创建两个函数(例如,op_add
的实现会自动创建他的 wide
版本,op_add_wide
)
默认情况下,narrow
版本的指令会被执行,直到制定的指令 op_wide
被执行。接下来要做的是读取下一个操作吗,并且 dispatch
他的 wide
版本,例如 op_add_wide
。一旦 wide
操作码执行完,会回到发送一个 narrow
指令,因为每一个 wide
指令需要一个字节的 op_wide
前缀。
新的字节码近乎节省了50%的内存,说明整体上对于重 JavaScript
逻辑的网站如 Facebook
或 Reddit
减少了10%的内存负载。
下面是一个关于 reddit.com
和 apple.com
和 fackbook.com
和 gmail.com
的字节码大小图片对比。
需要注意的是在 After
例子中,Metadata
已经被合并到 Linked
展示元数据表的内存使用和 Unlinked
展示了真实的字节码命令。一个重要的部分是新版元数据以前存在于旧的链接字节码当中。否则,这些对比看起来是倾斜的,因为整个 Linked
条会成0并且 Metadata
看起来是现在的2倍,但是加上只是把部分 Linked
的数据移到了 Metadata
上去了。
像我们之前讨论的一样,非直接操作提升了编译器 dispatch
的整体性能。但是,根据 JSC
中字节码指令的平均复杂度,我们并不希望 dispathc
在全局上扮演一个主要的角色对于整体的编译器性能而言。这将会在多个 JIT
层级中分期累加。我们整理了在 CLoop
中单纯的从直接到非直接执行的切换,在 C++
的后端例如 LLInt
,结果当然是禁用了 JIT
。
从元数据表中去加载有一部分消耗,包含一个大连接上的加载。为了减少这个链,我们把一个调用者存储的寄存器连接到编译器中去保存指向元数据表。通过这个优化,加载了三次去查询元数据的空间。这让编译器速度减缓了10%,但事实上是全局的 JIT
一直在使用。