技术札记

从 v1.5 到 v1.7:我如何为 HHY 建立可验证的优化链路

从 Database 的资源生命周期,到 VM 画像、调用与展开,再到 HIR/MIR 优化编译器:复盘 HHY v1.5.0—v1.7.0 的研发,以及优化已经交付、却仍默认关闭的原因。

HOUHUIYANG.COM

扫码继续阅读

正在生成…

从 v1.5 到 v1.7:我如何为 HHY 建立可验证的优化链路

houhuiyang.com/zh/notes/evolving-hhy-from-1-5-to-1-7

写完 HHY Database 1.0,再回到 Runtime 和 Compiler,我对“优化”这件事的要求变得更具体了。

一个表达式少执行几条指令当然有价值。但当语言已经承载常驻 Web 服务、数据库事务、延迟 Stream 和闭包时,执行得更快只是其中一项要求。错误还要发生在原来的位置,取消还要能够到达,借出去的资源还要被收回,旧程序也不能因为升级突然换一种行为。

从 v1.5.0 到 v1.7.0,我沿着这个问题推进:先把应用的资源生命周期接进 Runtime,再找出执行成本,最后把能够证明的变换放进编译器。

这里先说清版本关系:v1.5.0 已独立发布;v1.6 是一组 VM 画像、实验与决策阶段,相关交付统一进入 v1.7.0。v1.7.0 已完成发布,但新增的 IR、反馈特化和标量替换仍需显式启用。 不能把这段经历写成三个版本轮流默认加速。

本文以 v1.7.0 发行提交 8fb0b8f、当前编译器说明和验收策略为依据。性能数字取自已有的最终发布阶段测量记录,写作时没有重新运行压测。

HHY v1.5 到 v1.7 的研发演进与交付边界

v1.5.0:让资源生命周期成为优化的前提

v1.5.0 的 Database 工作让我处理的不只是 SQL。连接池、远程 TLS、事务、游标、精确类型、取消和 Worker 隔离,都要求 Runtime 对资源有明确的责任。

事务回调失败,连接不能带着未知状态回池;Stream 没有消费完,游标也需要关闭;请求结束时,扩展资源不能继续悬挂在一个已经结束的作用域里。Database 扩展保持独立的 1.0.0 版本,HHY 1.5.0 提供它所需要的 Runtime 集成。

这些约束会直接进入下一阶段的优化设计。调整调用帧会碰到资源展开;改变分配会碰到内存配额和 GC;删除一段代码可能顺手删掉取消检查;重排表达式则可能改变第一次错误或外部副作用发生的时机。

所以,v1.5 给后面的工作留下了一条底线:Runtime 优化必须保留应用已经依赖的生命周期。 当脚本长成服务之后,仅比较最终打印结果,已经不足以证明语义一致。

v1.6:先把 VM 的成本看清楚

路线图把 v1.6 归在 VM Architecture & Runtime。我把它拆成四类问题,而没有把“更多指令、更复杂的 GC”预设成答案。

阶段我实际推进的工作随 v1.7.0 交付的结论
v1.6.0指令选择与 opcode / pair / triple 画像画像与评估工具交付;superinstruction 未准入
v1.6.1调用布局、Closure、异常区域与 unwind可关闭实验和差分验证交付;保留现有捕获语义
v1.6.2名字、调用目标与 Map 查找画像单态 Map slot 实验交付;默认关闭
v1.6.3GC、内存保留与调度需求评估保留现有 GC 和有序、有界 parallel;暂不引入新调度器

首先要避免误读画像。HHY_PROFILE_DISPATCH=1 记录的是递归 switch 的入口序列,包括 opcode 及 pair/triple 次数。它不是可以直接融合的相邻 Bytecode 列表,也不等于纯 dispatch 的 CPU 时间。

一个组合出现很多次,只能说明它值得调查。要把它变成 superinstruction,还需要确认求值顺序、错误位置、资源检查和真实执行收益。最终没有准入新的 superinstruction,是这轮评估的有效交付,而不是被省略的实现承诺。

对我来说,“richer instruction selection”的重点,是让选择建立在可解释的执行证据上。增加 Opcode 的数量本身没有产品价值。

调用与 unwind:快路径也要走完失败路径

调用优化很容易只关注正常返回:少分配一个对象,少查一次函数体入口,复用一个帧。但 Runtime 最难处理的是调用中途退出。

v1.6.1 的实验把 Compiler 生成、Verifier 重建核对的调用布局,与紧凑的逻辑调用记录、异常区域表和版本化 unwind 动作表连接起来。逻辑调用记录保存环境、参数与目标的 roots,以及源码、contract、effect、trace 和 profiler 的恢复位置。

正常返回、语言 Error、取消,以及宿主内存配额触发的跳转,都需要把登记过的状态恢复干净。已经退出的记录必须清零,不能让本应结束的环境被旧引用继续保活。

这并没有把解释器改成 trampoline,机器层面的调用和返回仍然依赖 C 与已有的 setjmp/longjmp。新增的是 Runtime 能够验证、观察和展开的逻辑记录。

Closure 也继续捕获共享 Env。两个闭包读写同一个变量,看到的仍是同一个词法环境;从函数返回的闭包、从异常里传出的闭包、延迟 Stream 捕获的环境,都不能回收到帧池。可选的有界帧池只复用未逃逸环境,最多缓存 64 个 Env、64 KiB GC 分配容量。

我没有同时引入扁平 upvalue 或尾调用消除。前者需要新的捕获与别名证明,后者会改变当前递归深度和调用栈行为。这些都应当有独立的语义与收益证据。

Inline cache、GC 与调度,也需要准入理由

Map inline cache 的实现只记住上一次查找的 slot,不保留 Map 或 Value。每次进入仍核对当前 Map 的元素数量、slot 对应 key 的长度和完整字节,再读取当前值。

这个约束意味着,缓存的是一个经过检查的位置提示,而不是旧结果。对象换了、字段顺序变了、同一个 key 的值更新了,都不能返回过去的值。变化频繁的位置会退回通用路径,不无限增加缓存形态。

观测也必须分清楚:guard_hit 可以只是关闭缓存时的候选命中,cached_reads 才代表真实走了缓存读取路径。把两者混在一起,很容易做出漂亮但无效的命中率报告。

GC 和调度的判断同样来自实际问题。当前保留 Boehm conservative GC,以及有界、按输入顺序交付结果的进程 parallel。慢的第一项造成队头等待,确实是成本;直接改成完成顺序,却会改变已有语义。

这轮没有足够的生产瓶颈与需求证据推动分代 GC、增量 GC 或 async scheduler 重写。“暂不准入”不代表永远没有收益,它意味着我需要先拿到值得承担架构成本的证据。

v1.7.0:把可证明的变换前移到编译期

v1.6 让我看清了 Runtime 的优化边界。v1.7 则开始把重复的证明工作放到编译阶段。

默认执行仍然是 Source → AST → 直接 Bytecode Compiler → Verifier → VM。新增的可选路径是 AST → 结构化 HIR → 六个优化 pass → Bytecode;符合条件的函数还可以携带独立验证的整数寄存器 MIR 计划。AST 引擎永久保留,作为语义 oracle 和显式回退。

HHY v1.7 的默认 Bytecode 路径、可选 HIR/MIR 和运行时回退

这里的 HIR 是版本化、非 SSA 的结构化表示。它保存节点关系、源码位置、局部槽位、类型、常量与 effect,CFG 描述语句控制流、循环阶段、函数归属和保守异常边。表达式仍然遵守语言原本的求值顺序。

我没有把它写成通用 SSA 编译器,也没有加入 LLVM 或 JIT。整数 MIR 目前只覆盖有界的直线表达式,其余语法继续由结构化 Bytecode 执行。代码在编译边界生成,Runtime 不临时编译机器码。

这种范围选择,让我可以在现有语言上逐项验证收益,而不用同时维护另一套函数、错误和内存模型。

Verifier 要能够独立否定编译器

优化器说一个值是常量,不代表它就是常量;说一个表达式没有副作用,也不代表它可以删除。

HIR 为 throw、cancel、allocation 和 external effect 保留独立标记。未知操作保守携带全部 effect,含义是“目前没有足够证明允许删除或重排”,而不是断言它每次都会触发所有行为。

Verifier 独立重建类型、常量、effect、CFG、区域所有权和局部槽位来源。复制传播还要核对最近的先前不可变定义,不能越过 shadowing 或把自引用当成有效来源。每个启用的 pass 后重新 verify,发射前再 verify,Bytecode 生成后继续经过已有独立验证器。

我的要求是:Verifier 不需要相信某个 pass 已经正确运行。 只有它有能力拒绝编译器的错误产物,这道边界才真正有意义。

六个 pass,分别证明自己能改什么

当前执行顺序是 copy propagation、constant propagation、fold、peephole、unreachable、DCE。每个 pass 都可以独立关闭。

Pass能做的事我保留的边界
Copy propagation追溯同一区域内已知不可变标量的复制链不跨遮蔽、动态别名和未知来源
Constant propagation传播顺序区域内已知标量绑定不对 mutable 或跨调用控制流进行推断
Fold折叠能够成功求值的有限标量运算不提前吞掉溢出、除零和分配行为
Peephole简化已证明为 Int 的 +0-0*1未知 x + 0 不能直接当成 x
Unreachable删除已证明不会执行的分支体或终止后缀保留条件、循环 header 和作用域容量
DCE删除非末尾、零 effect 的纯值语句保留末尾值、声明与任何可能产生 effect 的操作

例如 (10 + 20) * (4 + 6) 有明确的标量证明空间;未知类型的 x + 0 却不能仅凭数学直觉简化。删除 block 的末尾表达式,也可能改变隐式返回值。

我还保留执行中原有的分配、闭包捕获、调用和循环取消点。优化减少成功操作的 dispatch 次数是预期现象,但错误位置、退出码、输出、副作用、配额和取消行为仍需差分一致。

MIR:反馈只提供候选,guard 才允许执行

参数类型反馈让 v1.7 可以在受支持的函数里执行整数 MIR。当前每个目标最多 8 个参数、64 条寄存器指令;反馈目标数量也有上限。

同一目标连续 8 次收到 Int 参数后,才允许进入特化路径;此后每次进入仍检查参数类型。类型变化立即退回通用路径,累计 8 次类型不匹配后停用该槽。

稳定调用记录不是未来类型永远稳定的证明。函数重绑定也不能复用旧目标的计划,所以反馈身份绑定当前 chunk 与函数 owner,不保存参数或闭包对象引用。

快路径入口去装箱,中间值放在 C 栈上的 int64 寄存器,出口再构造 Int 或 Bool。支持范围包括经过溢出检查的整数加减乘、取模、取负和关系比较。

如果算术失败,deopt 保存失败指令与操作数,在原源码操作处恢复通用错误处理。它不能从函数开头重新执行,把前面已经发生的分配再做一遍。

标量替换,为什么我仍保留分配预约

v1.7 的逃逸分析边界很窄:单个局部整数 List,直接使用已知有效常量索引,没有其他使用者。嵌套聚合、Map、别名、捕获、返回聚合与调用传递都保守回退。

这种形态可以让选中元素直接通过寄存器返回,减少 List 元素的 Value 写入与读取。但所有元素仍按原顺序求值,包括没有被选中的元素。

更重要的是,实现保留原顺序、相同尺寸和扫描类型的托管存储预约,并保持到表达式结束。因为删掉物理分配,可能让本来触发内存配额错误的程序突然成功,也可能改变 GC 观察到的生命周期。

因此我把它称为标量替换与 Value 存取优化,不把它宣传成已经消除堆分配或节省内存。这个限定决定了发布说明是否值得相信。

性能准入:合成收益不能代替真实收益

我给优化编译器设了联合预算:编译成本不超过直接编译的 2 倍加 500 μs;候选指令数和逻辑 Bytecode 存储不能增长;运行时间不高于基线的 95%;托管分配不高于 101%。候选代码尺寸超预算时,整体回退直接 Bytecode。

这里的运行时间包含 prepare,不能只测已经编译完的热循环。逻辑 Bytecode 字节、IR arena 保留字节、进程峰值内存也是不同指标,不能混用。

下面取最终发布阶段的 macOS arm64 本地预算记录:每模式预热 2 次、保留 15 对样本。静态 IR 和整数 MIR 是两组独立评估,不把它们拼成一条“版本加速曲线”。比值为优化模式 / 对照模式,越小越好。

HHY v1.7 静态 IR 本地预算数据:合成样例获益,通用负载未达准入门槛

负载静态 IR 比值类型特化比值
constant-loop(合成)0.7491
propagation(合成)0.9145
typed-arithmetic(合成)0.8616
typed-scalar(合成)0.8004
core-flow1.00430.9991
json-flow1.04291.0260
call-closure0.99291.0350

静态 IR 的常量循环耗时下降约 25.1%,类型特化的局部 List 样例下降约 20.0%。但 core、JSON、closure 这些代表通用执行路径的负载没有达到 5% 收益门槛,部分还有回退。

这组数字的结论是:实现确实在特定形态上产生了效果,但还不足以让所有人默认承担新增编译成本。它们也不是 v1.5.0 与 v1.7.0 的整版本对比,更不能外推成企业应用的整体提速。

四平台验收通过,和默认启用是两件事

最终发行记录包含 macOS arm64、Linux arm64、Linux x86_64 和 Windows x86_64 的 CI 与发布验收。正确性、打包和兼容通过,不意味着所有优化已经获得跨平台的真实性能准入。

验证链路覆盖 AST、直接 Bytecode 与 IR Bytecode 差分,六个 pass 的 64 种组合、独立 IR/MIR 变异、sanitizer、fuzz、配额、取消和真实项目。现有记录还包括每构建 10,000 次 IR 字段变异与新增类型特化的 285 条执行路径。

我在发布阶段也修正了测量和测试方法:Profiler 改为交错配对采样并保存全部样本,原有阈值不变;重复的大规模完整执行用例使用同构小规模样例,原大规模取消用例和真实 SIGINT 验证继续保留。

这些工作不是为了让结果看起来更好,而是让测量能回答它真正应该回答的问题。最终默认仍然是直接 Bytecode,AST 保留,新优化显式启用。

我希望下一次优化仍然能够被关闭

当前的开关把实验边界直接呈现出来。下面的 program.hhy 代表待验证的业务脚本:

# 显式启用结构化 IR 编译链
HHY_COMPILER=ir hhy run program.hhy

# 保留 IR 链路,关闭全部静态 pass
HHY_COMPILER=ir HHY_COMPILER_DISABLE=all hhy run program.hhy

# 在 IR 路径上启用整数反馈特化与局部 List 标量替换
HHY_COMPILER=ir HHY_FEEDBACK_SPECIALIZATION=1 HHY_SCALAR_REPLACEMENT=1 hhy run program.hhy

# 查看编译指标和报告
HHY_COMPILER=ir HHY_COMPILER_REPORT=1 hhy bytecode --metrics program.hhy

从 v1.5 的资源生命周期,到 v1.6 的画像和 Runtime 实验,再到 v1.7 的 HIR/MIR,我真正积累的是一套能够继续迭代的优化方法:找到成本,说明支持范围,独立验证,保存观测,再按平台和负载决定是否准入。

优化已经实现、验证并发布,而默认保持关闭,可以是一个完整且负责的研发结果。 我希望 HHY 之后的提速,都能说明快在哪里、代价是什么,以及条件不再成立时怎样回到可靠的路径。

参考与关联文章

返回技术札记