字节码 VM
默认执行路径是树遍历器——访问每个 AST 节点并返回一个值。对于被调用数百万次的热点函数,每个节点的分发开销会成为瓶颈。Phasis 在此基础上加入了推测式字节码到 PHP 闭包编译器,以降低这部分开销。
VM 何时启动
每个 JsFunction 都有一个热点调用计数器。计数超过阈值(默认为 100)后,引擎会尝试通过 src/Bytecode/JsToPhp.php 将函数体编译为 PHP 闭包。
当函数体符合一组已知形态时,编译即可成功:
- 对类型可预测的局部变量进行赋值(数值、对象、函数、数组、字符串)。
- 对数字执行算术运算。
- 读取对象类型的局部变量和
this的成员(this.v、obj.x)。 - 调用对象类型局部变量的方法(
obj.method(args)),并将接收者作为$thisValue传入。 - 调用
new Ctor(args)构造函数——将左侧局部变量的类型提升为object,并通过退出保护检查返回值是否为对象。 - 条件形态简单的
for/while循环。 - 表达式可计算的
return。 - 基于类型化条件的
if/else。
当函数体包含以下内容时,编译会失败(函数将继续由树遍历器执行):
with语句。try / catch / finally(由树遍历器处理,以保证完成语义正确)。eval()调用。- 直接修改
arguments。 - 编译器尚不支持降级处理的任何内容。
编译成功后,引擎会将该函数的调用路径切换为已编译的闭包。之后的调用只需承担一次函数调用开销,即可执行原生 PHP,无需为每个 AST 节点分别付出开销。
退出机制
编译后的快速路径会依赖一些假设(数字始终是数字,属性始终保持相同形态)。运行时若假设失效,闭包会抛出 Bailout,该次调用随即在树遍历器中重新执行。
规范正确性在这里至关重要。树遍历器必须能够从头重新执行触发退出的调用,且不能观察到快速路径失败所产生的副作用。Phasis 通过两种方式处理这一点:
- 纯前缀 — 编译器会在推测开始前检查副作用。如果函数在推测点之前执行了 I/O(console.log、宿主函数),快速路径不会省略该操作,而是先提交再继续。
- 编译时拒绝 — 对于需要回滚可观察副作用的形态(例如非数值的
let r = fn(),而fn()会修改状态),编译器会直接拒绝编译。该函数会继续由树遍历器执行。
典型情况是:快速路径执行非数值赋值 fn(),抛出 Bailout,随后在树遍历器中重新执行——导致 fn() 运行两次并产生两组副作用。编译时拒绝可避免 fn() 在快速路径中运行,因此树遍历器只会观察到唯一一次调用。
VM 分发(第二层)
部分函数会编译为 PHP 闭包;其他函数则会进入 src/Bytecode/VM.php 中更简单的字节码解释器。字节码是由操作码组成的扁平数组(LOAD_LOCAL、STORE_PROP、CALL_METHOD、…),由 switch 分发器执行。
对于那些不值得编译为 PHP 闭包、但调用频率足以从跳过 AST 遍历中获益的函数,VM 就能派上用场。对内置对象(Array.prototype.map、String.prototype.split、…)的方法调用会经过 VM 中的快速路径,绕过通用的 callFunction 跳板。
自定义调用栈
Op::CALL、Op::CALL_METHOD 和 Op::NEW_CALL 使用内联调用路径,将整个 JS 调用栈保存在 VM::execute 中——每次 JS 调用都不会新建 PHP 帧。进入时,调用方状态(pc、代码流、操作数栈、局部变量、env、thisValue、this、严格模式标志)会被压入每个 VM 专属的 savedFrames 池中,该池存放 SavedFrame 实例;随后分发循环会将其局部变量切换为被调用函数的局部变量。Op::RET 会弹出并恢复已保存的帧。异常会沿保存的帧逐层展开,然后回退到树遍历器。
是否适用会缓存在 JsFunction 上:常规调用为 vmInlineCallCache(已用 BC 编译的函数体、非 bind() 绑定、非原生函数);vmInlineConstructCache 用于 new 表达式(还要求满足 isConstructable、非派生类,且没有实例字段或私有方法初始化器)。其他情况仍会经过标准的 callFunction / vmNewExpression 跳板。
在架构上的另一项好处是:PHP 栈上限不再限制 JS 递归深度。现在唯一的限制是 CallStack::maxDepth——设为 4 096,远高于所有合理的 test262 情况(大多数需要不到 2 k 帧),同时也足够严格,能让病态的无限递归测试在运行器的超时限制内快速失败。转译闭包路径(JsToPhp)对非内联回退路径另有 8 个 192 帧的保护限制。
帧快照生成器
简单的生成器主体(没有 yield*,不会在 try/catch/finally 内使用 yield,也没有 await)会编译为字节码。主体暂停时,编译器会生成新的 Op::YIELD 操作码;Op::YIELD 会将分派状态(cf、pc+1、stack、sp、locals、env、thisValue、strict)捕获到 GeneratorSnapshot 堆对象中,并从 VM::execute 返回 YieldResult 哨兵值。驱动 JsGenerator 会保存快照,并在下一次 next() / throw() 时将其传回 VM::execute,替换操作数栈顶部的恢复值。
对于受支持的子集,这取代了 PHP-Fiber 路径。基于 Fiber 的驱动程序(executeGeneratorBody)仍用于编译器拒绝的形态——yield* 委托、在 try 块内 yield 的生成器、异步生成器,以及带有 await 的异步函数。JsGenerator 支持双模式:每次 next() / throw() / return() 都会检查实例使用的是快照还是 Fiber,并据此分发。
没有可供基准测试调节的参数;是否适用会在编译时决定并缓存在 JsFunction 上。快照路径的优势是每次 yield 少一次 PHP 上下文切换;其他情况仍使用原有的 Fiber 路径。
内联缓存
Op::LOAD_MEMBER 会为每个 PC 维护一个以原型标识为键的内联缓存,用于原型方法在实例上的模式(c.sum(...)、arr.push(...) 以及所有类方法分发)。命中条件:接收者没有该名称的自有槽位,缓存的原型引用与接收者的 getPrototype() 相同,并且原型的 PropertyMap::$version 仍与捕获的版本一致。
缓存失效依赖 PropertyMap::$version。结构变更(设置 / 删除)总会使其递增。数据槽值被覆盖时,只有在 PropertyMap::$isUsedAsProto 为 true 时才会递增——这是一个写屏障标志,在对象首次被观察到作为另一个对象的 [[Prototype]] 时翻转。永不作为原型的实例对象无需为每次 obj.x = v 支付 IC 基础设施的开销。
Getter 访问器从不缓存(每次读取都必须重新运行)。
PropertyMap 还维护第二个计数器 $mutationVersion,每次写入都会无条件递增(没有写屏障)。JSON.stringify 缓存会在每个访问过的嵌套对象 / 数组上固定记录这个计数器,因此即使 IC 的 $version 会跳过递增,类似 o.a = 99 这样的仅实例变更也能正确使缓存字符串失效。
JSON.stringify 缓存
JsObject 会保存 $jsonCacheString + $jsonCacheVersions(一组 [obj, mutationVersion] 对,用于记录简单 stringify 遍历中访问过的每个嵌套对象)。下一次调用 JSON.stringify(o) 时,如果没有 replacer / space,简单快速路径会验证所有固定记录的版本均未变化,然后直接返回缓存字符串,无需重新遍历或编码。像 JSON.parse(JSON.stringify(o)) 这样的热点深克隆模式,可让 stringify 部分提速约 2 倍。
缓存未命中时会静默丢弃(任一固定记录的版本不同),并在下一次遍历时重新填充。含有 toJSON、getter、访问器、符号或 [[PrimitiveValue]] 槽位的对象会回退到规范序列化器,且永远不会填充缓存,因此缓存路径只会处理结构统一的数据。
内置对象快速路径
最大的性能提升来自让 VM 针对特定内置对象进行优化:
Atomics.load/store/compareExchange配合loadSpinHook/storeNotifyHook协作式调度——修复了 4 项 Atomics 自旋循环测试。Date.prototype.getTimezoneOffset使用直接哈希映射 DST 缓存——SpiderMonkey DST 压力测试提速 45 %。String.fromCharCode使用内联 ASCII 快速路径——每次典型扫描可减少约 1 M 次分发。decodeURI/decodeURIComponent使用 12 字符的%XX%XX%XX%XX超快速路径,并通过hex2bin批量解码。Object.keys/Object.values/Object.entries直接访问属性映射。Array.prototype.map/filter/forEach/reduce原生迭代底层的 PHP 数组。
每条快速路径都有“形态检查”作为门控——如果接收者不符合预期形态(e.g,例如底层不是普通 PHP string 的 TypedArray 视图),VM 就会回退到符合规范的慢速路径。
此实现不做什么
从 V8 的含义来看,字节码 VM 不是 JIT。它不会为每个函数生成原生机器码,也不会基于观察到的类型进行推测(IC 的单态原型标识检查除外)。函数边界内联通过自定义调用栈来近似实现(每次 JS 调用不增加额外的 PHP 帧),而不是将被调用函数的函数体降级并写入调用方字节码。
JsToPhp 转译器是引擎中最接近 JIT 的部分:它为热点函数体生成 PHP 源代码,再由 PHP 8.5 的跟踪 JIT 编译为原生代码。JsToPhp 通过 eval() 执行,因此需要承担一次性编译开销,而且跟踪 JIT 必须像发现用户代码一样发现该函数。跳过 eval() 步骤并直接生成 opcache 操作码可以进一步缩小差距——参见 基准测试:路线图。
基准测试
bench/microbench.js 会运行十多个小型基准测试(loop-arith、loop-fib、fn-recurse、fn-deep-recurse、obj-create、obj-prop、proto-method、gen-iterate、arr-push、arr-map、str-concat、str-split-join、json-roundtrip、closure、destructure)。中位墙钟时间:
php bench/run.php
每次 bench 工作流运行后,当前数据都会提交到 BENCH.md。bench 和 test262 矩阵工作流均由人工启动。