跳转到正文

字节码 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 通过两种方式处理这一点:

  1. 纯前缀 — 编译器会在推测开始前检查副作用。如果函数在推测点之前执行了 I/O(console.log、宿主函数),快速路径不会省略该操作,而是先提交再继续。
  2. 编译时拒绝 — 对于需要回滚可观察副作用的形态(例如非数值的 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 矩阵工作流均由人工启动。