系列目录
「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:
- 从 AST 直驱到字节码:JSVM3 的编译与执行分层
- JSVM3 如何执行字节码:指令、栈帧与求值栈
- JSVM3 如何实现函数调用、作用域与声明提升
- JSVM3 如何编译循环、跳转与 try/catch/finally
- 用测试定义解释器的语义边界
- JSVM3 的预编译、序列化与运行时优化(本篇)
前五篇说明了源码编译为 Script 后由 Fiber/Frame 执行的过程。本文讨论两个工程问题:Script 如何以稳定数据传给另一运行环境,以及如何处理运行时和传输产物的体积、执行开销与排查需求。内容仅依据仓库实现和 benchmark/readme.md 的记录;性能结论仅限于记录中的各轮分数,不将单次改动表述为通用收益。
字节码之后为什么仍需要序列化协议
字节码把 AST 的树形结构变成了 Script.instructions 指令数组,但内存中的 Script 仍不能直接作为网络协议使用。它包含 Instruction 对象;对象上除了参数,还有 run 函数、操作码 id、调试名及栈深度计算方法。函数不能通过 JSON 传输,子函数 children、异常表 guards 和 RegExp 也各有需要明确的恢复方式。
编译期生成可传输的 Script 表示;端侧只接收 JSON 和运行时来恢复并执行,无需重新解析源码或加载编译器。该分离要求编译端和端侧运行时对操作码编号、参数位置及 Script 数组字段顺序保持一致;端侧还需提供 InsMap 中对应的指令工厂,宿主对象、全局对象和语言支持范围也必须满足目标代码需要。服务端预编译和端侧执行是现有序列化接口支持的部署方式,仓库未提供服务端分发模块。序列化协议不改变第 5 篇所述的语义边界。
src/utils/convert.ts 的 scriptToJson() 使用数组而非带字段名的对象,当前构造的数组包含 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/SETL、GETG/SETG、STRING_LITERAL 的参数索引使用;子函数以递归的 Script JSON 表示;Guard 的四个地址沿用第 4 篇的异常控制流定义;正则表达式由 regexpToString() 编为 source + '/' + g/i/m,恢复时按最后一个 / 分隔并调用 new RegExp(source, flags)。
每条指令的首项来自 Instruction.id。例如 src/opcodes/opIdx.ts 将 GETL、SETL、ADD、JMP、FUNCTION 等操作码定义为数字常量;src/opcodes/utils.ts 的 createOP() 在模块加载时把每个 id 对应的工厂注册到 InsMap。instructionsFromJson() 读取 [id, ...args] 后以 InsMap.get(id) 取得工厂,重新创建含有 run 函数的运行时 Instruction。JSON 不传输实现函数;协议兼容性依赖操作码 id 与指令实现的映射保持一致。
仓库还提供了 scriptToJsonObject()。它输出 fName、instructions、children 等具名字段,Script.toJSON() 在 process.env.JSVM_DEBUG 为真时选择这一形态,便于观察。此分支下 instructionsToJson() 还会把每条指令写为 [name, ...args];fromJson() 则读取紧凑数组,并以数字 id 查询 InsMap。因此对象形态既不是数组协议,也不能直接作为当前反序列化器的输入;可读调试表示与可分发表示是两条不同的路径。
JSON 往返测试验证了什么
__tests__/fromJson.test.ts 的 runViaJson() 把验证过程写得很直接:先用 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() 只构造索引 0 到 9,并把写入 source 的两行注释掉。所以紧凑 JSON 往返得到的 source 为 null;源码文本没有进入当前实际输出的紧凑协议。文章后面的反汇编调试也不依赖它,而是读取恢复后 Script 的指令、索引表与名称。
构建阶段压缩了什么
传输 Script 只是分离编译器和运行时的一半;端侧还要携带 VM 本身。rollup.config.js 的配置可以确认几类实际动作。
首先,配置在构建开始时读取 src/opcodes/opIdx.ts,得到 OPCodeIdx 对象,构造 minifyObj,再交给 Babel 的 transform-define 插件。也就是说,源码中的 OPCodeIdx.ADD、OPCodeIdx.GETL 等访问会被替换为具体数字,而不是让运行时保留整张操作码常量表的属性查找。src/opcodes/ins.ts 中的指令定义仍以常量名字书写,构建产物使用数值,这是可读源码和紧凑产物之间的分工。
其次,rollup-plugin-preprocess 对 TypeScript 文件传入 VM: true、CURRENT: '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.push、pop、top 三个调用属性分别改成 p、u、t。这是一项针对热点内部接口的静态改名,不是对所有方法的全局重命名。
这些步骤包含常量内联、条件分支处理、内部接口短名化和 Terser 压缩,但仓库未记录构建前后的体积变化。因此不能由配置推导体积百分比,也不能将构建阶段静态压缩等同于某类程序的运行时加速。
作用域索引:把名称解析留在编译期
第 3 篇已经解释了 GETL [scopeDepth, varIndex]。这一篇从优化边界补充两个事实。src/compiler/emitter.ts 的 scope() 在编译期遍历 this.scopes,找到标识符时直接返回跨函数作用域深度和变量槽位;scopeGet()/scopeSet() 随即发射 GETL/SETL。declareVar() 为局部名称分配 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();GET、SET 旁仍保留旧写法注释。该实现可能减少方法调用,但 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.ts 的 disassembleScript() 递归遍历 Script 及其 children,按 ip 输出每条 Instruction.name 与参数。
它还用现有索引表补回人能阅读的信息:GETG/SETG 由 script.globalNames[args[0]] 注释名字;GETL/SETL 依据 scope depth 和祖先 Script 的 localNames 推导局部或闭包变量;FUNCTION 通过 children 下标显示子脚本名;STRING_LITERAL 从 strings 表取回文本。对跳转指令,工具把首参数格式化为 -> NNNN。这些数据都来自 Script 的可执行结构,而不是重新解析原始 JS。
这条路径有明确限制。紧凑 scriptToJson() 默认保存的是数字操作码 id,反汇编需要已恢复的 Instruction 才能获得 name;JSVM_DEBUG 下的具名对象 JSON 方便人工观察,但不能直接交给当前 fromJson();前面提到的 source 也没有写入紧凑数组。若产品要求端侧直接定位源码行、跨版本回放或离线审计,就需要在协议之外明确增加版本号、源码或映射信息,并为这些字段补测试。当前仓库已经有 LINE、COLUMN 操作码,Frame 也保存行列状态,但这不等于现有紧凑 JSON 带有完整源码映射。
系列小结
第 1 篇说明 Emitter 如何将源码转换为 Script;第 2 篇分析 Frame、求值栈和临时状态;第 3 篇讨论函数、Scope 与声明提升;第 4 篇解释跳转、Guard 与异常传播;第 5 篇以测试限定可确认的语义范围;本文讨论预编译产物的序列化、构建与调试路径。
“编译一次、传输 Script、端侧恢复并执行”要求协议、运行时版本、宿主环境和语义覆盖满足目标代码的需要。性能改动应先保留行为不变的测试证据,再以代表目标负载的基准决定是否保留。作用域缓存尝试被撤销及各轮 benchmark 结果的差异,记录了这一判断过程。
