HHY Language v1.1.1 仍然是一台 AST Interpreter。
源码经过 Lexer、Parser 和 Checker 后,Runtime 直接遍历 AST 执行。它没有先降低成 Bytecode,也没有 JIT。对一门系统脚本语言来说,这条路径足够直接:源码位置容易保留,错误容易解释,语义改动也不需要同时维护另一套指令系统。
但“直接”不等于“每次都从头查找”。当递归、闭包和高频函数调用进入真实负载后,最先暴露的成本不是 Parser,而是 Runtime 在一遍遍回答相同的问题:这个名字属于哪一层作用域?参数放在哪里?一次调用是否必须分配新的环境对象?调用结束后,这个 Frame 还能不能安全复用?
这次优化的目标很克制:
保留 AST Interpreter,先消除执行路径里可以提前确定、可以直接寻址、可以安全复用的工作。
先用 Profile 找问题,而不是先重写 Runtime
解释器优化很容易从一个漂亮方案开始:设计 Opcode、写 Compiler、实现 VM,再希望性能自然变好。但 Bytecode 不是免费的抽象。它会带来新的中间表示、跳转规则、调试映射、错误栈、闭包捕获和兼容测试。
我更愿意先问一个简单问题:当前成本到底在哪里?
HHY v1.1.1 提供了内置 Profile:
hhy profile examples/09-profile-algorithms.hhy -- fibonacci 20
hhy profile --cpu examples/09-profile-algorithms.hhy fibonacci 20
hhy profile --heap --format json --output profile.json \
examples/09-profile-algorithms.hhy fibonacci 20
profile 会真实执行脚本。CPU 报告记录采样和调用次数,Heap 报告记录托管内存的累计分配、对象数量、峰值与 GC 后占用。报告默认写到 stderr,脚本自己的 stdout 不会被污染,退出码也保持原样。
诊断基线也把问题量化了。朴素递归 Fibonacci(30) 会进入 HHY 函数 2,692,537 次。早期路径累计产生 32,310,598 次分配和约 1.98 GB 分配流量;这不是 Heap 同时占用了 1.98 GB,而是几百万次调用制造并很快丢弃了大量短命对象。
这套数据让我把调用问题拆成两类:
- 名称解析和环境查找在高频调用中被重复执行;
- 每次函数调用都构造完整环境,会产生不必要的对象和初始化成本。
这两类问题都不要求立刻引入 Bytecode。它们首先要求 Runtime 不要忘记语言前端已经知道的事实。
优化完成后,在相同 Fibonacci(30)、预热 3 次、固定随机顺序交错执行 20 次的测试里,HHY 中位数从 681.27 ms 降到 200.79 ms,累计提升约 3.39 倍。更重要的是 Profile 中算法和 2,692,537 次函数调用都没有变化:
| 指标 | 优化前阶段 | Slot Frame | 变化 |
|---|---|---|---|
| HHY 中位数 | 681.27 ms | 200.79 ms | 约 3.39× |
| Profile 分配次数 | 4,038,947 | 1,346,440 | -66.7% |
| Profile 累计分配 | 328.7 MiB | 61.6 MiB | -81.3% |
| Heap 峰值 | 2.1 MiB | 2.1 MiB | 基本不变 |
Fibonacci 不是 HHY 的目标负载,但它适合放大函数调用成本。这个结果证明快路径减少了通用 Runtime 工作,并不代表文件、HTTP 或进程型 Flow 会得到同等比例的端到端加速。
Resolve Pass:把重复判断移到执行之前
最初的解释器面对一个 Identifier 节点,只知道它有一个名字。执行到 total 时,Runtime 从当前环境开始逐层查找,直到找到绑定,或者继续查到全局与 builtin。
这在语义上正确,但在循环和递归里很浪费。相同 AST 节点的词法归属通常不会因为第 10000 次执行而改变。
v1.1.1 增加了预解析阶段。函数执行前,Resolver 遍历 AST,并尽可能把名称分类:
- 参数绑定到当前函数的 Slot;
- 确定的局部变量绑定到 Slot;
- 跨函数边界的闭包捕获、全局名称和 builtin 保留 Env 路径;
- 含动态
import的作用域不强行固定布局; - 不能直接 Slot 化的 Identifier 记录可复用的查找信息。
这里的关键不是“缓存字符串”,而是把词法作用域中稳定的关系变成执行期可以直接使用的元数据。
Identifier("total")
before: lookup(env, "total")
after: slots[3]
Parser 仍然只负责结构,Checker 仍然负责语义约束,Resolver 则把已经确定的作用域事实翻译成 Runtime 的快速寻址信息。三者职责没有混在一起。
Slot:局部变量不必每次按名字查找
确定的参数和局部变量进入 slots[] 后,读取路径从“按名字遍历环境”变成“按索引读取数组”。
Local / Param → slots[index]
Closure / Global → Env lookup
Builtin → Env lookup
我没有把所有名称都强行压进 Slot。闭包捕获会跨越函数生命周期,全局变量属于共享命名空间,builtin 还涉及运行时注册。对这些情况保留 Env 慢路径,语义更清楚,也避免为追求统一而制造复杂的失效规则。
这是一条我很认同的优化原则:快路径服务常见且可证明的情况,慢路径负责完整语义。 两条路径不是妥协,而是职责分工。
Lightweight CallFrame:调用不再等于完整环境
Slot 解决了“值放在哪里”,下一步是“每次调用需要创建什么”。
函数调用真正需要的核心状态并不多:当前函数、参数与局部 Slot、必要的父级或捕获环境,以及返回和错误传播所需的信息。为每次调用构造一套通用 Map/Env,不仅查找更慢,也会制造更多托管分配。
因此我把高频调用路径收敛成 Lightweight CallFrame。实现上它没有另造一套与 Env 平行的对象模型,而是让 Env 同时具备连续 Binding 容量、escaped 标记和空闲链指针:
CallFrame
├── function
├── slots[]
├── parent / captured env
└── runtime state
函数调用根据 Resolver 计算的 frame_slot_count 一次准备连续容量。参数在进入函数时按顺序写入,局部绑定随后落在稳定 Slot;函数体 Block 直接使用调用 Frame,不再额外创建一个只有父指针的 Block Env。只有遇到闭包、全局或动态路径时,查找才沿兼容的 Env 链继续。
这并不是把动态语言偷偷改成静态语言。HHY 的值仍然是动态的,变化的是绑定位置:值的类型可以在运行时决定,但一个确定的局部名称不必在每次读取时重新寻找地址。
Identifier Cache:慢路径也不必永远从零开始
不能 Slot 化不代表只能接受最慢路径。
对于仍需 Env lookup 的 Identifier,Runtime 会在 AST 节点上缓存 cached_env_depth 与 cached_binding_slot。下一次执行时先按深度和 Slot 定位,然后再次比较名称长度与内容;只有验证通过才命中。环境形状不符合预期时,缓存立即失效,Runtime 退回完整查找并重新绑定。
缓存必须附着在语义稳定的对象上,也必须有明确的失效条件。把一个进程级“名字到地址”Map 当万能缓存,会在递归、模块与闭包之间制造错误共享。HHY 的缓存面向 AST 节点和对应作用域关系,而不是仅仅面向字符串。
性能优化最危险的结果不是“没有变快”,而是“在大部分输入上更快,但偶尔读到错误绑定”。因此,缓存命中永远不能绕过作用域语义。
Frame Pool:复用之前先回答是否逃逸
轻量 Frame 仍然需要初始化。如果一个函数被高频调用,调用结束后立刻丢弃 Frame,再等待 GC 回收,仍然会形成分配压力。
最自然的下一步是 Pool,但 Frame 复用不能只看“函数已经 return”。闭包或 Stream 可能保存了当前环境;从函数调用栈看它已经结束,从对象生命周期看它仍然活着。如果此时 reset 并交给下一次调用,旧闭包看到的变量就会被新调用覆盖。
所以 HHY 的 Frame Pool 有一条硬边界:
未逃逸 Frame → reset → pool → reuse
已被闭包捕获 → mark escaped → 不复用 → 交给 GC
已被 Stream 捕获 → mark escaped → 不复用 → 交给 GC
这也是为什么我把它称为“逃逸安全复用”,而不是简单对象池。Pool 只是机制,逃逸判定才是正确性。
优化过程中不能改变什么
解释器优化的验收标准不是 benchmark 能跑多快,而是语言承诺没有被悄悄改变。
至少要守住这些边界:
- 错误仍然指向原始源码位置,而不是优化后的内部结构;
- 闭包捕获的值和生命周期保持不变;
- 局部、模块、全局与 builtin 的遮蔽顺序保持不变;
- Stream 的惰性、单次消费和取消传播保持不变;
hhy check、hhy ast、--dry-run与真实运行看到同一套语义;- 资源限制、退出码和异常清理路径不能被快路径绕过。
因此这类改动需要的不只是性能样例,还需要递归、深层作用域、同名遮蔽、闭包逃逸、Stream 捕获、错误栈和 GC 后存活对象等回归测试。新增的 frame-slots-escape.hhy 同时创建两个计数器闭包,并在外层函数返回后分别修改捕获的可变 Slot;它还返回一个捕获局部变量的惰性 Stream,延迟到函数返回后才 collect。实际输出 11、12、101 和 [8, 9, 10],证明两个 Frame 没有被错误复用,Stream 也没有读到已经清空的状态。
Release 全量测试、ASan/UBSan 全量测试和 Multi-API Data Collector 自测也需要一起通过。优化器必须证明自己可以被拿掉而不改变程序结果。
为什么现在还不上 Bytecode VM
Bytecode VM 仍然是 HHY 的可能方向,但它应该由数据触发,而不是由路线图的想象触发。
Resolver、Slot、Lightweight Frame 和安全复用解决后,如果 Profile 继续显示 AST dispatch 是主要剩余热点,而且这个热点在真实 Flow、模块和系统调用负载中足够重要,那么 Bytecode 才有明确收益。到那时,现有 Resolver 也不会浪费:它已经把名称、Slot 与闭包边界整理成更适合 lowering 的信息。
反过来,如果脚本主要时间花在文件、HTTP、进程和 Stream 上,那么把 AST dispatch 再压缩一半,端到端体验也可能没有明显变化。系统脚本语言的优化不能只盯着 Fibonacci。
我现在更愿意把演进顺序写成:
Profile
→ eliminate repeated semantic work
→ direct-address stable bindings
→ reduce call-frame allocation
→ reuse only non-escaping state
→ profile again
→ consider Bytecode when AST dispatch becomes the bottleneck
这次优化带给我的判断
做解释器性能优化后,我更确信:性能问题经常不是“抽象太多”,而是系统没有消费自己已经知道的事实。
Checker 已经知道局部变量属于哪个作用域,Runtime 就不该每次重新猜;一次调用没有逃逸,Frame 就不必交给 GC 走完整生命周期;一个 Identifier 的环境位置稳定,慢路径也不必永远从根开始。
真正有效的优化不是跳过语义,而是把语义证明转化为更短的执行路径。
HHY v1.1.1 仍然直接执行 AST。这不是因为 Bytecode 不重要,而是因为当前更值得做的事情,是先让 AST Interpreter 少做重复工作,同时保持源码诊断、闭包、Stream 和资源边界完全可信。