技术札记

从 AST 到 Bytecode:我如何让 HHY 的默认执行引擎快 2.7 倍

复盘 HHY v1.3.10 的 Bytecode VM 研发:从 AST 语义基线、Compiler 与 Verifier,到 Stream Kernel、Profiler、缓存取舍和六种 Runtime 性能对比。

HOUHUIYANG.COM

扫码继续阅读

正在生成…

从 AST 到 Bytecode:我如何让 HHY 的默认执行引擎快 2.7 倍

houhuiyang.com/zh/notes/from-ast-to-bytecode-hhy

几天前,我写了一篇文章:《不换 Bytecode,我如何优化 HHY 的 AST 解释器》。当时我的判断很明确:先用 Resolver、静态 Slot、轻量调用帧和逃逸安全复用消除重复工作,不因为“做语言就应该有 VM”而重写 Runtime。

今天,HHY v1.3.10 已经默认执行 Bytecode。

这不是路线反转,而是同一条工程原则的下一步:当 Profile 和固定负载证明 AST dispatch 成为值得解决的成本时,再引入新的执行层;引入后,也必须继续用 AST 守住语义。

最终的 CI 数据是:在 1M Core Flow CPU 负载上,Bytecode / AST 墙钟比为 0.3695,也就是 Bytecode 约快 2.71 倍;短任务为 1.0088,几乎持平;持续 JSON 负载为 1.0207,Bytecode 约慢 2.1%。这组差异比一句“Bytecode 更快”更重要,因为它说明不同负载的瓶颈根本不同。

HHY 从 AST 语义基线到默认 Bytecode 引擎的执行架构

我没有删除 AST,而是改变了它的职责

HHY 的前端没有因为 VM 出现而分裂。两条执行路径共享同一套 Lexer、Parser、Checker 和 Resolver:

UTF-8 Source
  → Lexer / Parser
  → Checker / Resolver
  ├→ AST Evaluator
  └→ Bytecode Compiler → Verifier → Execution Plan → VM

AST Evaluator 现在仍然完整存在,并且可以通过以下方式显式选择:

hhy run --engine ast script.hhy
HHY_ENGINE=ast hhy run script.hhy

我把它定义为永久的语义 oracle,而不是等待删除的旧代码。每个完整 fixture 和真实项目都分别在 AST 与 Bytecode 下运行,比较 stdoutstderr、退出码、诊断、资源限制与取消行为。

这样做会多维护一条执行路径,但它给 Bytecode 优化提供了非常清楚的边界:只要两条路径结果不同,先把它当成 VM bug,而不是重新解释语言规范。优化器不能获得定义语义的权力。

Bytecode 不是把 AST 换个名字

HHY 的 Bytecode Compiler 会把每个 AST 节点降低成显式 Opcode,同时保留源码位置、子节点与子树结构、常量引用,以及 Resolver 已经计算出的局部 Slot 元数据。

hhy bytecode examples/00-hello.hhy

这个命令只做编译、验证和反汇编,不执行结果。对我来说,可反汇编不是一个展示功能,而是一种治理能力:当 Runtime 选择某条路径时,我必须能看到 Compiler 到底生成了什么。

Bytecode 解决的核心问题,是把树形调度转换成更紧凑、更连续、可以提前验证的执行表示。AST 不再需要在每个节点上重复解释结构;VM 沿着已经准备好的指令和操作数形状执行。

但我没有让 Bytecode 绕开原有 Runtime。闭包、异常、GC、取消、Stream、effect 和资源限制仍然由共享 Runtime 唯一负责。新的执行层改变“如何到达一次操作”,不建立第二套 Value、Stream 或 Error 语义。

Verifier 是默认启用 Bytecode 的前提

如果 Bytecode 只是进程内临时数据,很容易产生一种错觉:Compiler 是自己写的,所以输出天然可信。这个假设经不起 fuzz、内存损坏和未来缓存格式的考验。

因此,Compiler 之后必须经过独立 Verifier。它会拒绝:

  • 未知 Opcode 和非法常量引用;
  • 畸形树、过深嵌套与非规范 HALT
  • 越界的 Slot 元数据;
  • 非法的 Stream Kernel 类型流和栈形状;
  • 终止 RETURN 之后仍存在的指令。

执行计划还会提前验证 instruction、frame 和 operand 的有界形状。Runtime 不能绕开 Compile、Verify 和 Prepare;这条内部边界有独立版本,并由静态治理测试约束。

我愿意让 Bytecode 成为默认引擎,不是因为它跑通了 benchmark,而是因为错误输入能够 fail closed,AST 回退始终可用,并且两个引擎有持续差分证据。

从通用 Bytecode 到 Stream Kernel

HHY 是一门 Flow-first 的系统脚本语言。只优化通用表达式调度,却不理解最常见的 Stream 组合,收益会很有限。

在 v1.3.7 之前,Runtime 内部已经有少量 Stream Int 快路径,但选择逻辑依赖 magic kind,难以解释,也难以验证。后续版本把这条路径逐步收紧:

  1. v1.3.7 用具名操作和集中 metadata 取代 magic kind,并给出稳定 fallback reason;
  2. v1.3.8 把表达式形状识别从 Runtime 前移到 Compiler,生成版本化 Stream Kernel IR;
  3. 独立 Verifier 检查 Opcode、类型流、栈形状、常量和 RETURN
  4. Runtime 只执行已验证 Kernel,未知或动态闭包无损回退通用 Bytecode;
  5. v1.3.9 让普通执行与 Profiler 使用完全相同的优化选择。

这条设计里,“可回退”不是性能失败,而是正确性机制。Compiler 只能特化它可以证明的情况;无法证明时,通用 Bytecode 必须保持完整语义。

性能比较:快在哪里,也慢在哪里

最终 RC 门禁使用配对、交错采样,比较同一份源码在 AST 和 Bytecode 下的 fresh-process 墙钟时间。

工作负载Bytecode / AST解释
Core Flow 1M0.3695Bytecode 约快 2.71×
Basic Flow 短任务1.0088基本持平,Bytecode 约慢 0.9%
JSON Flow 持续负载1.0207Bytecode 约慢 2.1%

Core Flow 会生成 0..999999,乘以 2,保留能被 3 整除的值,稳定去重、物化并计数。它放大的是表达式调度与 Stream 处理成本,所以 Bytecode 和已验证 Kernel 能产生明显收益。

短任务则主要受进程启动和固定初始化成本影响,执行引擎很难改变总时间。JSON 负载包含解析、Map 与内存操作,Bytecode 当前没有表现出优势。这两个结果没有被隐藏,因为它们直接告诉我下一阶段不应该盲目增加 Opcode,而应该继续从 Profile 找真实热点。

Profiler 本身也被纳入门禁。最终开销为 1.0269×,增加约 2.786 ms,低于 1.35× 和 12 ms 的限制。Profile JSON v2 会记录选择原因、Kernel 版本与 Opcode,并把 CPU 和 Heap 热点归因到实际执行路径。

与其他 Runtime 的同机比较

为了给性能一个更直观的位置,我又在 macOS 26.6.2 arm64 上,用同一个 1M CPU / materialization 负载比较了 Go、Lua、PHP、Java、HHY 和 Python。每种实现都返回 333334;两轮各自先预热两次,再进行七次 fresh-process、确定性随机交错采样。Go 与 Java 的编译时间不计入,Runtime 启动时间计入。

六种 Runtime 在同一 1M Flow 负载上的两轮中位数

Runtime第一轮中位数第二轮中位数
Go 1.27.07.995 ms7.969 ms
Lua 5.5.118.368 ms17.957 ms
PHP 8.5.1043.174 ms43.549 ms
Java 26.0.2.149.394 ms48.153 ms
HHY 1.3.1055.297 ms53.404 ms
Python 3.14.781.747 ms86.459 ms

在这个特定负载上,HHY 比 Python 快,接近 PHP 和 Java,但仍明显慢于 Lua 和 Go。这个结果不是语言排行榜,更不能推导出文件、HTTP、进程或真实爬虫任务的总体快慢。它只能说明:在一个确定的 CPU 与物化负载里,HHY 的默认 Bytecode Runtime 已经进入可比较的区间,同时还有很清楚的优化空间。

我更看重完整公开测量方法和全部 84 个样本,而不是挑一个最好数字做标题。

为什么我没有顺手加入 Bytecode 缓存

有了 Bytecode,下一步很容易想到 .hhyc、进程内缓存或外部预编译文件。但缓存会引入失效、兼容、安全与原子写入问题,不能因为功能看起来完整就默认需要。

v1.3.10 先测量再决策。五个固定真实负载各做 21 组配对 fresh-process 测量,compile + verify 中位数只有 0.004–0.012 ms,占冷启动 Bytecode 墙钟的 0.0078%–0.1341%。它既没有达到至少 1 ms,也没有达到至少 20% 的联合准入门槛。

所以当前明确没有:

  • 进程内 Bytecode 缓存;
  • 磁盘 .hhyc 缓存;
  • 外部预编译 Bytecode 加载器;
  • 对外公开的 Bytecode ABI。

这不是少做了一项功能,而是避免用几乎不可见的收益交换一整套攻击面和兼容负担。未来如果数据触发门槛,缓存必须绑定源码与递归依赖摘要、HHY / Bytecode / Kernel 版本、Compiler feature、target 和安全策略;缓存命中后仍要经过 checksum、有界解析、完整 Verifier 和执行计划验证。

从“不换 Bytecode”到“默认 Bytecode”

回看这条演进路径,我认为最重要的不是 AST 和 Bytecode 谁更先进,而是每一步有没有回答当时最真实的问题。

v1.1.1 先优化 AST,是因为主要成本来自重复名称查找、通用环境分配和 Frame 生命周期;Resolver 与 Slot 在同一 Fibonacci(30) 基准上带来了约 3.39 倍提升。那一步也为后来的 lowering 准备了稳定的 Slot 和作用域元数据。

当这些重复工作被拿掉,Profiler 才更清楚地暴露剩余 dispatch 成本。此时引入 Bytecode,不再是架构愿望,而是有测量依据的工程选择。

我的结论没有变:

先把语义证明变成更短的路径,再让数据决定是否需要新的执行层。

HHY v1.3.10 默认使用经过 Compiler 和 Verifier 的 Bytecode,AST 永久作为语义 oracle 与紧急回退。CPU 密集 Flow 已经得到显著收益,短任务和 JSON 路径仍然提醒我保持克制。

VM 的完成标准从来不是“能跑指令”,而是它能否在更快的同时,仍然让语言的每一项承诺可解释、可回退、可验证。

参考

返回技术札记