几天前,我写了一篇文章:《不换 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 更快”更重要,因为它说明不同负载的瓶颈根本不同。
我没有删除 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 下运行,比较 stdout、stderr、退出码、诊断、资源限制与取消行为。
这样做会多维护一条执行路径,但它给 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,难以解释,也难以验证。后续版本把这条路径逐步收紧:
- v1.3.7 用具名操作和集中 metadata 取代 magic kind,并给出稳定 fallback reason;
- v1.3.8 把表达式形状识别从 Runtime 前移到 Compiler,生成版本化 Stream Kernel IR;
- 独立 Verifier 检查 Opcode、类型流、栈形状、常量和
RETURN; - Runtime 只执行已验证 Kernel,未知或动态闭包无损回退通用 Bytecode;
- v1.3.9 让普通执行与 Profiler 使用完全相同的优化选择。
这条设计里,“可回退”不是性能失败,而是正确性机制。Compiler 只能特化它可以证明的情况;无法证明时,通用 Bytecode 必须保持完整语义。
性能比较:快在哪里,也慢在哪里
最终 RC 门禁使用配对、交错采样,比较同一份源码在 AST 和 Bytecode 下的 fresh-process 墙钟时间。
| 工作负载 | Bytecode / AST | 解释 |
|---|---|---|
| Core Flow 1M | 0.3695 | Bytecode 约快 2.71× |
| Basic Flow 短任务 | 1.0088 | 基本持平,Bytecode 约慢 0.9% |
| JSON Flow 持续负载 | 1.0207 | Bytecode 约慢 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 | 第一轮中位数 | 第二轮中位数 |
|---|---|---|
| Go 1.27.0 | 7.995 ms | 7.969 ms |
| Lua 5.5.1 | 18.368 ms | 17.957 ms |
| PHP 8.5.10 | 43.174 ms | 43.549 ms |
| Java 26.0.2.1 | 49.394 ms | 48.153 ms |
| HHY 1.3.10 | 55.297 ms | 53.404 ms |
| Python 3.14.7 | 81.747 ms | 86.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 的完成标准从来不是“能跑指令”,而是它能否在更快的同时,仍然让语言的每一项承诺可解释、可回退、可验证。