技术札记

不换 Bytecode,我如何优化 HHY 的 AST 解释器

复盘 HHY Language v1.1.1 的解释器优化:Resolver、静态 Slot、轻量调用帧、标识符缓存与逃逸安全复用,如何在不改变语言语义的前提下降低函数调用成本。

HOUHUIYANG.COM

扫码继续阅读

正在生成…

不换 Bytecode,我如何优化 HHY 的 AST 解释器

houhuiyang.com/zh/notes/optimizing-hhy-ast-interpreter

HHY Language v1.1.1 仍然是一台 AST Interpreter。

源码经过 Lexer、Parser 和 Checker 后,Runtime 直接遍历 AST 执行。它没有先降低成 Bytecode,也没有 JIT。对一门系统脚本语言来说,这条路径足够直接:源码位置容易保留,错误容易解释,语义改动也不需要同时维护另一套指令系统。

但“直接”不等于“每次都从头查找”。当递归、闭包和高频函数调用进入真实负载后,最先暴露的成本不是 Parser,而是 Runtime 在一遍遍回答相同的问题:这个名字属于哪一层作用域?参数放在哪里?一次调用是否必须分配新的环境对象?调用结束后,这个 Frame 还能不能安全复用?

这次优化的目标很克制:

保留 AST Interpreter,先消除执行路径里可以提前确定、可以直接寻址、可以安全复用的工作。

HHY AST 解释器优化后的执行路径

先用 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,而是几百万次调用制造并很快丢弃了大量短命对象。

这套数据让我把调用问题拆成两类:

  1. 名称解析和环境查找在高频调用中被重复执行;
  2. 每次函数调用都构造完整环境,会产生不必要的对象和初始化成本。

这两类问题都不要求立刻引入 Bytecode。它们首先要求 Runtime 不要忘记语言前端已经知道的事实。

优化完成后,在相同 Fibonacci(30)、预热 3 次、固定随机顺序交错执行 20 次的测试里,HHY 中位数从 681.27 ms 降到 200.79 ms,累计提升约 3.39 倍。更重要的是 Profile 中算法和 2,692,537 次函数调用都没有变化:

指标优化前阶段Slot Frame变化
HHY 中位数681.27 ms200.79 ms约 3.39×
Profile 分配次数4,038,9471,346,440-66.7%
Profile 累计分配328.7 MiB61.6 MiB-81.3%
Heap 峰值2.1 MiB2.1 MiB基本不变

Fibonacci 不是 HHY 的目标负载,但它适合放大函数调用成本。这个结果证明快路径减少了通用 Runtime 工作,并不代表文件、HTTP 或进程型 Flow 会得到同等比例的端到端加速。

Resolve Pass:把重复判断移到执行之前

最初的解释器面对一个 Identifier 节点,只知道它有一个名字。执行到 total 时,Runtime 从当前环境开始逐层查找,直到找到绑定,或者继续查到全局与 builtin。

这在语义上正确,但在循环和递归里很浪费。相同 AST 节点的词法归属通常不会因为第 10000 次执行而改变。

v1.1.1 增加了预解析阶段。函数执行前,Resolver 遍历 AST,并尽可能把名称分类:

这里的关键不是“缓存字符串”,而是把词法作用域中稳定的关系变成执行期可以直接使用的元数据。

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 慢路径,语义更清楚,也避免为追求统一而制造复杂的失效规则。

这是一条我很认同的优化原则:快路径服务常见且可证明的情况,慢路径负责完整语义。 两条路径不是妥协,而是职责分工。

HHY 标识符的 Slot 快路径与 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_depthcached_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 能跑多快,而是语言承诺没有被悄悄改变。

至少要守住这些边界:

因此这类改动需要的不只是性能样例,还需要递归、深层作用域、同名遮蔽、闭包逃逸、Stream 捕获、错误栈和 GC 后存活对象等回归测试。新增的 frame-slots-escape.hhy 同时创建两个计数器闭包,并在外层函数返回后分别修改捕获的可变 Slot;它还返回一个捕获局部变量的惰性 Stream,延迟到函数返回后才 collect。实际输出 1112101[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 和资源边界完全可信。

参考

返回技术札记