JSVM3 的预编译、序列化与运行时优化

📅
2 分钟阅读
·

系列目录

「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:

  1. 从 AST 直驱到字节码:JSVM3 的编译与执行分层
  2. JSVM3 如何执行字节码:指令、栈帧与求值栈
  3. JSVM3 如何实现函数调用、作用域与声明提升
  4. JSVM3 如何编译循环、跳转与 try/catch/finally
  5. 用测试定义解释器的语义边界
  6. JSVM3 的预编译、序列化与运行时优化(本篇)

前五篇说明了源码编译为 Script 后由 Fiber/Frame 执行的过程。本文讨论两个工程问题:Script 如何以稳定数据传给另一运行环境,以及如何处理运行时和传输产物的体积、执行开销与排查需求。内容仅依据仓库实现和 benchmark/readme.md 的记录;性能结论仅限于记录中的各轮分数,不将单次改动表述为通用收益。

字节码之后为什么仍需要序列化协议

字节码把 AST 的树形结构变成了 Script.instructions 指令数组,但内存中的 Script 仍不能直接作为网络协议使用。它包含 Instruction 对象;对象上除了参数,还有 run 函数、操作码 id、调试名及栈深度计算方法。函数不能通过 JSON 传输,子函数 children、异常表 guardsRegExp 也各有需要明确的恢复方式。

编译期生成可传输的 Script 表示;端侧只接收 JSON 和运行时来恢复并执行,无需重新解析源码或加载编译器。该分离要求编译端和端侧运行时对操作码编号、参数位置及 Script 数组字段顺序保持一致;端侧还需提供 InsMap 中对应的指令工厂,宿主对象、全局对象和语言支持范围也必须满足目标代码需要。服务端预编译和端侧执行是现有序列化接口支持的部署方式,仓库未提供服务端分发模块。序列化协议不改变第 5 篇所述的语义边界。

JSVM3 预编译产物的分发、恢复与调试路径

src/utils/convert.tsscriptToJson() 使用数组而非带字段名的对象,当前构造的数组包含 10 个元素,索引为 0~9:

[
  fName 或 0,              // 0
  name 或 0,               // 1
  [[opcodeId, ...args]],   // 2:instructions
  [childScriptJson],       // 3:children
  localNames,              // 4
  [[start, handler, finalizer, end]], // 5:guards
  stackSize,               // 6
  strings,                 // 7
  ["source/flags"],       // 8:regexps
  globalNames              // 9
]

该“字节码”不只是指令编号数组。局部变量名表、全局变量名表和字符串表分别供 GETL/SETLGETG/SETGSTRING_LITERAL 的参数索引使用;子函数以递归的 Script JSON 表示;Guard 的四个地址沿用第 4 篇的异常控制流定义;正则表达式由 regexpToString() 编为 source + '/' + g/i/m,恢复时按最后一个 / 分隔并调用 new RegExp(source, flags)

每条指令的首项来自 Instruction.id。例如 src/opcodes/opIdx.tsGETLSETLADDJMPFUNCTION 等操作码定义为数字常量;src/opcodes/utils.tscreateOP() 在模块加载时把每个 id 对应的工厂注册到 InsMapinstructionsFromJson() 读取 [id, ...args] 后以 InsMap.get(id) 取得工厂,重新创建含有 run 函数的运行时 Instruction。JSON 不传输实现函数;协议兼容性依赖操作码 id 与指令实现的映射保持一致。

仓库还提供了 scriptToJsonObject()。它输出 fNameinstructionschildren 等具名字段,Script.toJSON()process.env.JSVM_DEBUG 为真时选择这一形态,便于观察。此分支下 instructionsToJson() 还会把每条指令写为 [name, ...args]fromJson() 则读取紧凑数组,并以数字 id 查询 InsMap。因此对象形态既不是数组协议,也不能直接作为当前反序列化器的输入;可读调试表示与可分发表示是两条不同的路径。

JSON 往返测试验证了什么

__tests__/fromJson.test.tsrunViaJson() 把验证过程写得很直接:先用 transform(code, 'test.js', { hoisting: true, convertES5: false }) 生成 Script,接着执行 JSON.parse(JSON.stringify(scriptToJson(script))),再调用 fromJson(),最后交给新的 JSVM 执行并读取 module.exports

测试包含两个行为断言:

  • module.exports = 1 + 2 经往返后得到 3
  • 定义递归 fibonacci,并导出 fibonacci(10),经往返后得到 55

第二个用例还覆盖子 Script 的递归恢复和函数调用。测试未对原 Script 与恢复后对象逐字段深比较,也未枚举 Guard、正则、所有操作码和异常语义。因此它仅证明两段指定程序在 JSON 往返后可执行并得到预期结果,不证明协议的每个字段、所有输入或跨版本兼容性均已覆盖。下发协议新增字段或操作码后,应补充对应的往返行为用例。

还有一个实现细节值得如实记录。fromJson() 会读取 json[10] 作为 source,而当前 scriptToJson() 只构造索引 09,并把写入 source 的两行注释掉。所以紧凑 JSON 往返得到的 sourcenull;源码文本没有进入当前实际输出的紧凑协议。文章后面的反汇编调试也不依赖它,而是读取恢复后 Script 的指令、索引表与名称。

构建阶段压缩了什么

传输 Script 只是分离编译器和运行时的一半;端侧还要携带 VM 本身。rollup.config.js 的配置可以确认几类实际动作。

首先,配置在构建开始时读取 src/opcodes/opIdx.ts,得到 OPCodeIdx 对象,构造 minifyObj,再交给 Babel 的 transform-define 插件。也就是说,源码中的 OPCodeIdx.ADDOPCodeIdx.GETL 等访问会被替换为具体数字,而不是让运行时保留整张操作码常量表的属性查找。src/opcodes/ins.ts 中的指令定义仍以常量名字书写,构建产物使用数值,这是可读源码和紧凑产物之间的分工。

其次,rollup-plugin-preprocess 对 TypeScript 文件传入 VM: trueCURRENT: 'all'。源码中可见 // @ifdef COMPILER// @if CURRENT != 'exp' 一类条件段,例如 Script.toJSON()、操作码的编译期元数据和不同 Realm 内建内容。这里能确认条件预处理是构建链的一部分,且其上下文如上;不能只凭这份默认配置推断所有目标构建一定去除了哪些代码,因为具体保留范围还取决于条件表达式和其他构建入口。

最后,Rollup 将产物输出为 CJS 和 ES Module,均生成 sourcemap;Babel 目标为 Safari 10 与 Android 53;随后 Terser 开启模块级 mangle,保留 exec 属性名。配置中还自定义了一个 Babel visitor:只把 evalStack.pushpoptop 三个调用属性分别改成 put。这是一项针对热点内部接口的静态改名,不是对所有方法的全局重命名。

这些步骤包含常量内联、条件分支处理、内部接口短名化和 Terser 压缩,但仓库未记录构建前后的体积变化。因此不能由配置推导体积百分比,也不能将构建阶段静态压缩等同于某类程序的运行时加速。

作用域索引:把名称解析留在编译期

第 3 篇已经解释了 GETL [scopeDepth, varIndex]。这一篇从优化边界补充两个事实。src/compiler/emitter.tsscope() 在编译期遍历 this.scopes,找到标识符时直接返回跨函数作用域深度和变量槽位;scopeGet()/scopeSet() 随即发射 GETL/SETLdeclareVar() 为局部名称分配 varIndex,而运行时 Scope.get()/Scope.set() 只是对 data[i] 做数组读取和写入。

运行时仍会处理闭包层级:GETL 的实现先按第一个参数循环走 scope.parentScope,再读取指定槽位。优化点不是“完全没有父链”,而是父链长度和槽位已由编译期写入指令,运行期不再按变量名搜索作用域或做名字哈希查找。对于当前帧的局部变量,深度为 0,路径就是一次数组索引;对于外层闭包,成本随既定深度增长。

benchmark/readme.md 记录了一项被撤销的尝试,原文标为“负向优化,撤销”,描述为“不 while 获取 parent 值,直接暂存在一个数组里面通过索引去获取”。对应分数为 Richards 20.3、Crypto 29.4、RayTrace 52.0、NavierStokes 58.5、DeltaBlue 18.6。它紧随 Richards 26.2、Crypto 37.2、RayTrace 68.2、NavierStokes 76.5、DeltaBlue 26.9 的一组记录;文档未说明测试机、单位、代码版本或统计方法,只能确认该轮被作者判断为负向并撤销。这些数字不能作为跨环境可复现的绝对性能结论。

第 2 篇的 evalStack.tail(2) 通过一次索引下移和数组切片取出多个栈顶值,替代连续 pop()GETSET 旁仍保留旧写法注释。该实现可能减少方法调用,但 benchmark 记录没有逐轮关联代码改动,也未隔离其他变量,不能将任何一组分数直接归因于它。优化假设仍需在可复现的固定环境和比较口径下验证。

“调整参数格式”为什么必须按基准验证

benchmark/readme.md 一共保留了五组 Richards、Crypto、RayTrace、NavierStokes、DeltaBlue 的数值。第一组为 21.5、29.8、54.7、66.2、23.7;第二组是上一节的 26.2、37.2、68.2、76.5、26.9;第三组是被标为负向并撤销的作用域父链缓存尝试;第四组标题为“调整参数格式”,数值为 Richards 28.6、Crypto 39.4、RayTrace 53.4、NavierStokes 61.8、DeltaBlue 31.3;第五组标题为“计算指令函数展开,函数参数调用压缩,数组/对象 声明加速”,数值为 23.6、26.9、80.7、72.0、38.7。

各轮在五项基准上的变化方向不同,不能只以某一项最高分判断效果。第二组与“调整参数格式”相比,Richards、Crypto、DeltaBlue 更高,RayTrace 与 NavierStokes 更低。第五组与“调整参数格式”相比,RayTrace、NavierStokes、DeltaBlue 更高,Richards、Crypto 更低。文档未给出总分、单位和评判规则,无法据此确定整体最快的一轮,也无法判定“调整参数格式”是正向或负向优化。

第三组作用域父链缓存方案被明确标为负向,第四组“调整参数格式”没有该标记。将“调整参数格式”称为负向案例与文档不一致。参数格式调整表明不同基准可能向不同方向变化;父链缓存方案则被记录为负向并撤销。优化假设需要以代表性基准验证,并在改动前定义比较口径;“少一次循环”“少一次对象访问”或“参数更紧凑”本身不能证明存在收益。

基准也不能替代语义测试。前者只观察特定负载的性能数值,后者检查解释器在给定输入下的行为。涉及 GETL、指令参数或栈操作的改动,至少要同时运行回归测试和基准;性能变好而闭包、异常或调用语义改变,仍然不是可接受的优化。

预编译后怎样保留排查能力

紧凑数组和数字操作码降低可读性。JSVM3 通过 Script 元数据和独立反汇编工具保留排查所需的信息。demo/src/disassemble.tsdisassembleScript() 递归遍历 Script 及其 children,按 ip 输出每条 Instruction.name 与参数。

它还用现有索引表补回人能阅读的信息:GETG/SETGscript.globalNames[args[0]] 注释名字;GETL/SETL 依据 scope depth 和祖先 ScriptlocalNames 推导局部或闭包变量;FUNCTION 通过 children 下标显示子脚本名;STRING_LITERALstrings 表取回文本。对跳转指令,工具把首参数格式化为 -> NNNN。这些数据都来自 Script 的可执行结构,而不是重新解析原始 JS。

这条路径有明确限制。紧凑 scriptToJson() 默认保存的是数字操作码 id,反汇编需要已恢复的 Instruction 才能获得 nameJSVM_DEBUG 下的具名对象 JSON 方便人工观察,但不能直接交给当前 fromJson();前面提到的 source 也没有写入紧凑数组。若产品要求端侧直接定位源码行、跨版本回放或离线审计,就需要在协议之外明确增加版本号、源码或映射信息,并为这些字段补测试。当前仓库已经有 LINECOLUMN 操作码,Frame 也保存行列状态,但这不等于现有紧凑 JSON 带有完整源码映射。

系列小结

第 1 篇说明 Emitter 如何将源码转换为 Script;第 2 篇分析 Frame、求值栈和临时状态;第 3 篇讨论函数、Scope 与声明提升;第 4 篇解释跳转、Guard 与异常传播;第 5 篇以测试限定可确认的语义范围;本文讨论预编译产物的序列化、构建与调试路径。

“编译一次、传输 Script、端侧恢复并执行”要求协议、运行时版本、宿主环境和语义覆盖满足目标代码的需要。性能改动应先保留行为不变的测试证据,再以代表目标负载的基准决定是否保留。作用域缓存尝试被撤销及各轮 benchmark 结果的差异,记录了这一判断过程。


532 字 · 47 段落
ximing

Follow onGitHub

相关文章