Hotdry.

Article

为Clojure方言设计自定义IR:从jank的SSA中间表示到渐进类型推断

剖析jank语言自定义IR的设计决策,涵盖SSA形式、Clojure语义映射、优化管道实现,以及为动态语言构建渐进类型推断系统的工程路径。

2026-05-18compilers

动态语言的编译器设计长期面临一个两难抉择:直接使用通用后端(如 LLVM IR)可以快速获得代码生成能力,但会丢失语言特定的语义信息,导致优化空间受限;而构建自定义中间表示(IR)则需要投入大量工程资源,却能在语义层面实现更激进的优化。jank—— 一个基于 LLVM 的 Clojure 方言 —— 在 2026 年 5 月完成的 IR 重构,为这一抉择提供了值得参考的工程样本。

为什么 LLVM IR 不够用

LLVM IR 的设计目标是成为通用的、低层次的中间表示,它擅长表达寄存器分配、内存布局和指令调度等机器层面的细节。然而,Clojure 的语义核心 ——var、持久化数据结构、惰性序列、动态类型 —— 在 LLVM IR 中完全没有对应概念。

当 jank 直接将 Clojure 代码翻译为 LLVM IR 时,编译器丢失了以下关键信息:

  • var 的间接层:每次 var 访问都变成了一次内存加载,LLVM 无法识别这是可缓存的常量引用
  • 动态调用的多态性:函数调用被降级为间接跳转,LLVM 无法进行去虚拟化(devirtualization)
  • 装箱 / 拆箱边界:Clojure 的算术运算需要在对象和原生值之间频繁转换,LLVM IR 中这些转换表现为不透明的函数调用

结果是,LLVM 的优化器面对 jank 生成的 IR 时,几乎无法应用面向动态语言的高级优化。jank 团队测算,在未引入自定义 IR 前,递归斐波那契基准测试耗时 5522 毫秒,而 Clojure JVM 仅需 200 毫秒 —— 差距接近 28 倍。

自定义 IR 的核心设计

jank 的 IR 位于 AST 与 LLVM IR 之间,作为专用的 "Clojure 语义层"。其设计遵循三个原则:

SSA 形式与唯一赋值

IR 采用静态单赋值(SSA)形式,每个变量仅被赋值一次。这使得常量传播、死代码消除等优化变得直接可推导。例如,简单的条件表达式:

(if (= "jeaye" name)
  (println "Are you me?!")
  (println (str "Hello, " name "!")))

在 jank IR 中表现为三个基本块(entry、if0、else1),每个值都有唯一的虚拟寄存器(v3、v4、v5...)。分支条件直接引用布尔值 v4,而非经过装箱 - 拆箱的间接路径。

控制流图结构

IR 以控制流图(CFG)组织,每个函数由若干基本块(basic block)组成,每个块以单一终止指令(branch、ret、throw 等)结束。这种结构使得:

  • 循环优化(如循环不变量外提)可以基于图的拓扑分析实现
  • 不可达代码消除简化为图可达性分析
  • 基本块级别的类型推断可以沿着控制流边传播

语言特定的操作语义

jank IR 定义了贴近 Clojure 语义的指令集:

  • :var-deref:var 解引用操作,保留 "var 是延迟初始化的可重绑定容器" 这一语义
  • :dynamic-call:动态函数调用,明确标记调用点的多态性
  • :named-recursion:命名递归,为尾递归优化(TCO)提供识别标记
  • :cpp/call:C++ 原生调用,用于内联算术运算等性能关键路径

这种设计让优化器能够在正确的抽象层次上工作 —— 识别出 "这是一个可以被常量折叠的 var 引用",而非面对不透明的内存加载指令。

渐进类型推断的基础设施

动态语言的类型推断通常采用渐进式(gradual typing)策略:在无法推导类型的位置保留动态分发,在可推导的位置生成特化代码。jank 的 IR 为这一策略提供了必要的元数据载体。

类型标记与传播

每个 IR 指令携带:type字段,记录值的运行时类型。类型系统区分:

  • 具体类型:如jank::runtime::obj::integer_ref,表示已知的整数对象
  • 抽象类型:如jank::runtime::object_ref,表示任意对象引用
  • 原生类型:如boolint64_t,表示未装箱的原生值

类型推断沿着 SSA 的 def-use 链传播。例如,当:cpp/call指令调用已知返回bool的函数时,下游的:branch指令可以直接使用该布尔值,无需经过:truthy转换。

指针标记(Pointer Tagging)

jank 利用 64 位指针的低 3 位(因对齐要求始终为 0)嵌入类型标记。最低位为 1 表示这是一个内联整数,其余 63 位存储整数值。这使得:

  • 小整数无需堆分配
  • 算术运算无需装箱 / 拆箱
  • 类型检查简化为位掩码操作

在斐波那契基准测试中,指针标记将整数分配开销从性能瓶颈位置完全消除,执行时间从 1400 毫秒降至 282 毫秒。

内联与特化触发

类型推断的结果驱动内联决策。当优化器识别出某个:dynamic-call的接收函数在特定调用上下文中类型已知时,可以触发特化内联:

^{:inline (fn [l r]
            (list 'cpp/jank.runtime.max l r))
  :inline-arities #{2}}
(defn max [l r] ...)

:inline元数据提供了一个函数,在编译期生成特化的 IR 片段。对于算术运算,这消除了 var 查找、动态分发和装箱开销。

优化管道的实战数据

jank 的性能优化不是单一技术的胜利,而是 IR 层面、运行时层面和代码生成层面协同作用的结果。以下是斐波那契基准测试的优化轨迹:

优化阶段 耗时 关键改进
基线(直接 LLVM IR) 5522ms 无优化
算术内联 2309ms 消除 var 查找和动态调用
消除冗余转换 2247ms 移除:cpp/into-object+:truthy
nil 全局化 1400ms jank_nil()函数改为全局常量
指针标记 282ms 避免整数堆分配
强制内联 114ms [[gnu::always_inline]]算术函数

最终 114 毫秒的成绩比 Clojure JVM 快约 43%,证明自定义 IR 配合激进优化可以超越成熟的 JVM JIT。

可落地参数与实施清单

对于计划为动态语言构建自定义 IR 的团队,以下参数和检查点可供参考:

IR 设计阶段

  • SSA 形式:强制每个变量单次赋值,使用版本号(v1、v2...)区分不同定值
  • 基本块结构:每个块单一入口、单一出口,终止指令明确标记控制流转移
  • 类型注解:为每个值节点附加类型字段,支持 "具体 / 抽象 / 原生" 三级分类
  • 调试渲染:实现 IR 到宿主语言(如 Clojure 数据)的序列化,便于测试和诊断

优化管道

  • 常量提升:将字面量、闭包捕获的常量提升到模块级别,避免重复分配
  • var 内联:通过元数据标记可内联的函数,在 AST 到 IR lowering 阶段展开
  • 类型驱动特化:当调用点的参数类型已知时,生成特化调用路径
  • 指针标记:利用 64 位对齐特性,为常用小类型(整数、nil、布尔)提供内联编码

性能监控

  • 基准测试选择:覆盖多态算术、递归调用、GC 压力、运行时开销四个维度
  • 火焰图分析:识别非业务代码的时间消耗(如 jank 的 nil 访问开销曾占 30% 以上)
  • 渐进验证:每轮优化后验证正确性,避免过早引入复杂优化导致调试困难

结语

jank 的自定义 IR 实践表明,为动态语言构建专门的中间表示层是一项高回报的工程投资。通过在 LLVM IR 之上保留 Clojure 语义,编译器得以实施类型驱动的特化、消除动态分发开销、并充分利用原生代码生成能力。对于追求性能的动态语言实现者而言,自定义 IR 不是 "过早优化",而是为后续所有优化建立正确的基础设施。


资料来源

compilers

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com