1. 原文

Lazy deserialization

2. 摘要翻译

惰式反序列化最近在V8 v6.4版本中开始默认开启,平均降低每个浏览器tab超过500KB的内存消耗。

Introducing V8 snapshots

首先,让我们后退几步来看一下V8是如何使用堆快照来快速创建新Isolate的(在Chrome中这一般指创建一个新的页面tab)。我的同事Yang Guo在custom startup snapshots这篇文章中对这块做过很好的介绍。

JavaScript规范引入了很多内建的函数功能,从math函数到拥有完整功能的正则表达式引擎。每一个新创建的V8上下文环境都从创建开始拥有这些功能。为了让这点成立,全局的根节点(例如,浏览器的window对象)以及所有的内建功能都必须在该上下文环境创建的时间节点就被初始化在V8堆中。如果上述工作从头开始做的话会花费一定量的时间。

所幸,V8使用一种捷径来加速这些工作:解冻一张冰冻披萨,就可以开始就餐了。我们直接反序列化一个预先准备好的快照进入堆来获得初始化完毕的上下文环境。在一个常规的桌面电脑上,这套解决方案可以将创建上下文环境的时间从40ms降低到2ms。在一台平均水平的移动电话上,这意味着从270ms到10ms。

让我们简要复述下:快照对于启动性能来说非常关键,他们被反序列化来创建每一个Isolate中的V8堆初始状态。因此快照的大小将会直接决定V8堆的最小尺寸,越大的快照对应着Isolate更高的内存消耗。

一个快照包含着完整初始化一个新的Isolate所需要的所有内容,包含了语言常量(language constants)(e.g., the undefined value),给解析器使用的内部字节码处理器(internal bytecode handlers used by the interpreter),内建对象(e.g., String),以及内建对象里的函数(e.g., String.prototype.replace)和伴随而来的可执行代码对象(executable Code objects)。

在过去的两年里,快照的大小已经翻了将近三倍,从2016年早期的接近600KB到如今的超过1500KB。这个巨大的数量变化来源于序列化的代码对象,无论是从数量上(e.g., 因着语言规范发展和成长而添加的附加物),还是尺寸上来说(由new CodeStubAssembler pipeline生成的内建功能模块的原生代码 vs 更进一步压缩的字节码或最小化的JS样式代码)。

这是一个坏消息,因为我们希望尽可能将内存消耗降低。

Lazy deserialization

最主要的痛点之一是我们之前是将快照的整个上下文环境拷贝进入Isolate。这么做对内建函数来说特别浪费,因为所有无条件加载的内减函数可能最终连一次都没有被运行过。

这就是惰式反序列化的切入点。理念非常简单:如果我们仅仅在内建函数被使用之前反序列化他们会如何?

一个基于当前主流网站的快速调研显示出这个解决方案非常值得投入:就平均来说,仅仅30%的内建函数被使用到,部分站点甚至仅仅使用了16%。调研结果显示该解决方案非常有潜力,这些站点都是JS功能的重度使用者,因此上述数字将会是普通网站站点的潜在内存节约量的下限。

我们开始向这个方向工作,结果显示惰式反序列化的整合工作非常切合V8的整体架构,因此实现该功能仅仅只有一小部分非侵入性的设计需要被改变:

  1. 众所周知的快照内部的位置问题。在惰性反序列化设计之前,对象在整个序列化的快照之内的顺序是无关紧要的,因为整个快照只会被完整反序列化并装载进堆内一次。而惰性反序列化必须能够根据需求反序列化任何某一个给定的内建函数,因此必须知道这个函数是位于快照的什么位置。
  2. 单个对象的反序列化。V8的快照最初是被设计出来进行整个堆的反序列化,而如果需要支持单个对象的反序列化需要处理一些类似,非连续的快照布局(单个对象的序列化数据可能会被其他对象的数据穿插其中),以及所谓的逆向引用(backreferences)(能够直接引用在当前的运行中之前被反序列化的对象)。
  3. 惰性反序列化机制自身。在运行时,惰性反序列化处理器必须能够:a)确定哪个代码对象需要被反序列化;b)执行正确的反序列化;c)将序列化的代码对象附加到所有相关的函数之上。

针对之前的两点,我们的解决方案是在快照里添加一个新的专门的内建空间(dedicated built-ins area),用来专门存储序列化后的代码对象。序列化是按一个专门设计好的顺序发生,并且在内建的快照空间内每个代码对象之间的偏移保持在一个固定的值(section)。任何逆向引用(back-references)和对象数据穿插(interspersed object data)都不被允许。

惰性的内建模块反序列化(Lazy built-in deserialization)由被适当命名为DeserializeLazy的内建模块(DeserializeLazy built-in)处理,它会在反序列化时被安装(installed)到所有惰性反序列化内建函数内。当运行时被调用到的时候,它会反序列化对应的代码对象并最终将它安装到JSFunction(代表函数对象)和SharedFunctionInfo(在所有由相同function literal创建出来的函数之间分享的信息)。每个内建函数最多只会被反序列化一次。

除了内建函数之外,我们也实现了字节码处理器的惰性反序列化(lazy deserialization for bytecode handlers)。字节码处理器是包含将字节码放入V8的Ignition解释器(V8’s Ignition interpreter)内执行的相关逻辑的代码对象。不像其他内建模块,它们并不具有附加的JSFunction,也不具有SharedFunctionInfo。相对的,它们的代码对象被直接存储在the dispatch table into which the interpreter indexes when dispatching to the next bytecode handler(这句太绕了,原文看得懂翻过来有点绕,就放原文了)。惰性反序列化的行为类似其他内建模块:DeserializeLazy处理器藉由检查字节码数组来决定哪个处理器进行反序列化,反序列化代码对象,最终将反序列化完成的处理器存储在dispatch table内。同样的,每个处理器只会被反序列化一次。

Results

我们通过使用Chrome 65和一台Android设备,在打开和关闭惰性反序列化功能的不同情况下,加载排名前1000的最主流网站的方法来评估内存节约效果。

就平均结果来说,V8的堆内存降低了540KB,大约25%的被测试站点节约了超过620KB的内存,50%的站点节约了超过540KB的内存,而75%的站点节约了超过420KB的内存。

运行时性能(由类似Speedometer这样的基准JS benchmark,以及由大量主流站点中选取部分的方式进行测试)并没有受到惰性反序列化的影响。

Next steps

惰性反序列化机制保证了每一个Isolate仅仅只加载确实被用到的内建代码对象。这是一个巨大的进步,但我们相信仍旧还有空间能更进一步将每个Isolate的(内建相关的)消耗减少到基本为0。

我们希望能在今年内将这个更新实现。

3. 注

  • function literal:A function literal is just an expression that defines an unnamed function.

EOF