jank 现已拥有自己的自定义 IR

Lobsters Hottest 工具

摘要

jank 是一种 Clojure 方言,现已引入一种在 Clojure 语义层面设计的自定义中间表示,以实现更好的优化并与 JVM 竞争。

<p><a href="https://lobste.rs/s/miutfd/jank_now_has_its_own_custom_ir">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/08 22:33

# jank 现在拥有了自己的自定义中间表示(IR) Source: https://jank-lang.org/blog/2026-05-08-optimization/ 好消息,大家!jank 有了一个新的自定义中间表示(IR),我们用它来优化 jank,使其能够与 JVM 竞争。今天我们将深入探讨这个问题,但首先我要感谢我的 GitHub 赞助者(https://github.com/sponsors/jeaye)以及 Clojurists Together 在这一年中对我的赞助。你们都帮了大忙。我仍在寻找继续全职从事 jank 工作的方法,以获得能够覆盖房租和生活费的收入,所以如果你还没有赞助,现在是很好的时机! ## 什么是中间表示(IR)? 编译器通常将程序表示为一组比目标 CPU 指令集更抽象的指令。这有一些额外的好处。首先,程序可以用一种可以转换到不同 CPU 架构(如 x86_64 或 arm64)的方式来表示。由于中间表示通常比 CPU 架构更高层次,它们通常更具可移植性。其次,IR 可以专门设计用来表示程序的方式,使得编写某些优化更加容易,比如单静态赋值(SSA)形式。最后,IR 设计者可以选择 IR 的抽象层次来匹配他们想要表示的语义,这可以使 IR 更加通用或更加特定于某种语言。有许多常见的流行 IR,比如 JVM 的字节码、CLR 的公共中间语言(CIL)、GCC 的 GIMPLE、LLVM 的 IR 等等。有些编译器在编译过程中可能会通过多个 IR 来处理程序。 ## 自定义 IR 的设计考虑 一直以来,jank 都不是一个优化编译器。我们基本上把所有的优化工作都委托给了 LLVM,基于我们生成的 C++ 或 LLVM IR。然而,LLVM IR 的层次非常低,相比 Clojure 来说。它没有 Clojure 的 vars、暂态(transients)、持久化数据结构、惰性序列等概念。Clojure 的动态性来自于大量的多态和间接调用,但这意味着 LLVM 在处理来自 jank 的 LLVM IR 时几乎没有优化机会。之前对 jank 进行的优化工作有助于优化其运行时和编译器本身,但对编译器生成的代码优化较少。在过去的两个月里,我试图改变这一点。我想要一个在 Clojure 语义层面操作的 IR。这将比 LLVM IR 高得多,甚至比 JVM 的字节码还要高。由于我不需要构建一个通用的虚拟机(VM)或编译器平台,我不需要为不同的语言泛化 IR。我可以让 jank 的 IR 专门为 jank 定制,这为我们提供了更强的优化能力。据我所知,目前还没有其他 Clojure 方言采取这一步骤。 ## 自定义 IR 详解 我已经在 jank book 中为 jank 的 IR 编写了一份参考资料(https://book.jank-lang.org/dev/ir.html)。这份参考资料针对的是正在开发 jank 本身的人,因为我目前无法保证 jank 的 IR 稳定性。不过,我会在这里复制部分内容来说明 jank 的 IR,并帮助提供一个心理模型来理解接下来的内容。让我们看一下这个简单的 Clojure 函数: ```clojure (defn greet [name] (if (= "jeaye" name) (println "Are you me?!") (println (str "Hello, " name "!")))) ``` jank 的 IR 在内存中存储为 C++ 数据结构,但可以渲染为 Clojure 数据以进行调试和测试。这不是完整的序列化,因为由于我们手头拥有所有 Clang AST 内部数据,它无法从 IR 往返回 jank 编译器。让我们看一下这个函数的 jank IR 模块: ```clojure {:name user_greet_82687 :lifted-vars {clojure_core_SLASH_str_82694 clojure.core/str clojure_core_SLASH_println_82691 clojure.core/println clojure_core_SLASH__EQ__82689 clojure.core/=} :lifted-constants {const_82693 "!" const_82692 "Hello, " const_82690 "Are you me?!" const_82688 "jeaye"} :functions [{:name user_greet_82687_1 :blocks [{:name entry :instructions [{:name greet :op :parameter :type "jank::runtime::object_ref"} {:name name :op :parameter :type "jank::runtime::object_ref"} {:name v3 :op :literal :value "jeaye" :type "jank::runtime::obj::persistent_string_ref"} {:name v4 :op :var-deref :var clojure_core_SLASH__EQ__82689 :type "jank::runtime::object_ref"} {:name v5 :op :dynamic-call :fn v4 :args [v3 name] :type "jank::runtime::object_ref"} {:name v7 :op :truthy :value v5 :type "bool"} {:name v8 :op :branch :condition v7 :then if0 :else else1 :merge nil :shadow nil :type "void"}]} {:name if0 :instructions [{:name v9 :op :literal :value "Are you me?!" :type "jank::runtime::obj::persistent_string_ref"} {:name v10 :op :var-deref :var clojure_core_SLASH_println_82691 :type "jank::runtime::object_ref"} {:name v11 :op :dynamic-call :fn v10 :args [v9] :type "jank::runtime::object_ref"} {:name v12 :op :ret :value v11 :type "jank::runtime::object_ref"}]} {:name else1 :instructions [{:name v13 :op :literal :value "Hello, " :type "jank::runtime::obj::persistent_string_ref"} {:name v14 :op :literal :value "!" :type "jank::runtime::obj::persistent_string_ref"} {:name v15 :op :var-deref :var clojure_core_SLASH_str_82694 :type "jank::runtime::object_ref"} {:name v16 :op :dynamic-call :fn v15 :args [v13 name v14] :type "jank::runtime::object_ref"} {:name v17 :op :var-deref :var clojure_core_SLASH_println_82691 :type "jank::runtime::object_ref"} {:name v18 :op :dynamic-call :fn v17 :args [v16] :type "jank::runtime::object_ref"} {:name v19 :op :ret :value v18 :type "jank::runtime::object_ref"}]}]}]} ``` jank 的 IR 基于 SSA,意味着每个名称只被赋值一次。这使得整个类别的优化更容易推理。jank 的 IR 也表示为控制流图(CFG),由一个或多个基本块组成,每个基本块恰好有一个终止指令(分支、跳转、抛出、返回等)。从 IR 模块中可以看出,jank 处理了 vars 和常量的提升,并具有在 Clojure 语义层面的指令,用于解引用 vars、调用函数等。让我们看看从这个 IR 生成的 C++: ```cpp extern "C" jank::runtime::object_ref user_greet_19_1(jank::runtime::object_ref const greet, jank::runtime::object_ref name) { auto const v3(const_33); auto const v4(clojure_core_SLASH__EQ__34->deref()); auto const v5(jank::runtime::dynamic_call(v4, v3, name)); auto const v7(jank::runtime::truthy(v5)); if(v7) { auto const v9(const_35); auto const v10(clojure_core_SLASH_println_36->deref()); auto const v11(jank::runtime::dynamic_call(v10, v9)); return v11; } else { auto const v13(const_37); auto const v14(const_38); auto const v15(clojure_core_SLASH_str_39->deref()); auto const v16(jank::runtime::dynamic_call(v15, v13, name, v14)); auto const v17(clojure_core_SLASH_println_36->deref()); auto const v18(jank::runtime::dynamic_call(v17, v16)); return v18; } } ``` 如果你把 C++ 和 IR 相比,你可以立即看到它们之间的对应关系。C++ 变量命名与 IR 变量相匹配。var 解引用变成对 `->deref()` 的调用。动态调用变成 `jank::runtime::dynamic_call`。这是有意为之的。 ## 优化 IR 设计和实现 IR 花了大约六周时间,包括重新构建我们的 C++ 代码生成,使其从 IR 而不是从 jank 的 AST 生成。目前,我们还没有在 IR 上运行任何优化传递。然而,我们已经具备了开始优化的条件。我想要优先合并新的 IR 管道,而不是尽可能构建它,因为六周已经是从 `main` 分支出去的较长时间了。现在 IR 已经合并,我的做法是逐个选择基准测试并进行优化,直到我满意和/或无法进一步优化为止。其中一些优化将直接涉及 IR,而其他的则不会。如果您对这项 IR 的技术开发方面更感兴趣,jank TV YouTube 频道(https://www.youtube.com/@jank-tv)上有一些视频来自我从事 IR 工作时进行的各种 Twitch 直播。这些视频深入探讨了实现细节。随着新 IR 的引入,让我们开始优化我们的第一个基准测试:递归斐波那契。 ## 插曲 在我们继续之前,请考虑订阅 jank 的邮件列表。这将是确保您及时了解 jank 版本、jank 相关讲座、研讨会等的最佳方式。邮件流量非常少。 ## 优化递归斐波那契 我们这一轮优化的第一个基准测试是递归斐波那契实现。它只有五行。我们的目标是让 jank 至少与 Clojure JVM 一样快,如果不能更快,但我们必须努力实现。 ```clojure (defn fibonacci [n] (if (<= n 1) n (+ (fibonacci (- n 1)) (fibonacci (- n 2))))) ``` 这可能看起来是一个简单的基准测试。你可能想知道为什么这能代表真实世界的应用程序。实际上,这个基准测试涵盖了编译器和运行时的一些基本方面: 1. **多态算术和关系谓词。** 基本上每个程序都在处理数字,并且需要快速处理。 2. **递归。** 许多流行的算法,特别是在 Lisp 中,都是递归的。能够高效地处理这些模式很重要。 3. **垃圾产生和回收。** 垃圾车可能每周都来,但这并不意味着我们应该产生尽可能多的垃圾。 4. **总的来说,语言运行时能够避开的能力。** 如果我们试图计算斐波那契数,我们不希望在性能分析器中出现任何与斐波那契数无关的东西。 在我们优化的过程中,在这篇文章中,请考虑这四个类别,以及我们所做的每个优化如何分类。 ### 基准斐波那契计时 我们将使用 Clojure JVM 来获得我们的基准测试数字,然后我们将用 jank 来超越这些数字。请注意,这篇文章中的所有数字都是在我的使用了五年、运行在 NixOS 上的 x86_64 台式电脑上测量的,配备 AMD Ryzen Threadripper 2950X 和 OpenJDK 21。当我在这篇文章中提到 "JVM" 时,我指的是 OpenJDK 21。 ```clojure ❯ clojure -Sdeps '{:deps {criterium/criterium {:mvn/version "0.4.6"}}}' Clojure 1.12.4 user=> (require '[criterium.core :refer [quick-bench]]) nil user=> (defn fibonacci [n] (if (<= n 1) n (+ (fibonacci (- n 1)) (fibonacci (- n 2))))) #'user/fibonacci user=> (quick-bench (fibonacci 35)) ``` Clojure 计算 `(fibonacci 35)` 大约需要 200 毫秒。这是我们的基准! #### 注意 `lein repl` 注意,我最初是在 `lein repl` 中为 Clojure 做基准测试的,结果却大相径庭。在我的系统上,Clojure 不是 200 毫秒,而是大约 2,800 毫秒!这里有一些说明(https://github.com/technomancy/leiningen/issues/1149#issuecomment-16596462)指出 `lein repl` 禁用了一些显然在这里起关键作用的 JVM 优化。感谢 Kyle Cesare 指出这一点。 ### 初始 jank 计时 从几周前的 jank 的 `main` 开始,我们将使用相同的 `fibonacci` 定义,但我们没有 criterium,因为那是 JVM 的库。相反,jank 有自己的基准测试库,随 jank 本身一起分发。 ```clojure (defn fibonacci [n] (if (<= n 1) n (+ (fibonacci (- n 1)) (fibonacci (- n 2))))) (require '[jank.perf]) (jank.perf/benchmark {:label "fib"} (fibonacci 35)) ``` 如果我们用优化和急切编译启用运行,我们可以得到我们的初始数字。 ```bash ❯ jank run -O3 --eagerness eager fib.jank ``` jank 计时为 5,522 毫秒。这……不快。不像 JVM 的 200 毫秒。 #### 内联算术 首先,我知道 Clojure 正在内联数学调用,而 jank 以前有一个临时解决方案,后来被移除了。是时候正确地这样做了。Clojure 通过元数据处理内联,因为来自其他命名空间的函数体是不可用的。这不是 Clojure 特有的问题,因为这正是 C 和 C++ 的工作方式。跨翻译单元调用 C 或 C++ 函数不会被内联,除非使用链接时优化(LTO)被使用。另一个选择是将定义移动到头文件中并将函数标记为 `inline`,以便每个翻译单元都有自己的副本。在 Clojure 中,我们可以通过更改变量的元数据来包含内联信息来实现"将函数放入头文件"的相同效果,因为任何人都可以从任何地方读取变量元数据。让我们看一个这样的例子。 ```clojure (defn ^{:inline (fn [l r] (list 'cpp/jank.runtime.max l r)) :inline-arities #{2}} max ([x] x) ([l r] (cpp/jank.runtime.max l r)) ([l r & args] (let [res (cpp/jank.runtime.max l r)] (if (empty? args) res (recur res (first args) (next args)))))) ``` 在这里,我们有 `clojure.core/max`,它定义了一些包含两个键的元数据:`:inline` 和 `:inline-arities`。后者是一组要内联的arity。这里,我们只关心 `[l r]` 的arity。`:inline` 的值是一个实际调用的函数,以获取该arity的主体。对于 `max`,我们只想内联对 `jank::runtime::max` 的 C++ 调用。稍后,我们将告诉 Clang 甚至内联那个调用。内联是在分析阶段完成的,而不是在 IR 传递中。当我们通过变量找到函数调用时,我们检查变量的元数据,如果存在的话,调用相应的 `:inline` 函数。你可以将其视为宏展开的姐妹,因为它的作用非常相似。这种内联方式有一些巨大的好处。首先,我们能够移除 var 的内部化和解引用 `clojure.core/max`。其次,由于每个 Clojure 函数都需要 boxed 参数,如果我们处理原生值,在调用 `max` 之前我们不需要对它们进行 boxing。第三,如果 `max` 返回一个 unboxed 的原生值,我们不需要在从函数返回时对它进行 boxing。这使我们能够避免 boxing 并更好地传播类型信息。在向 jank 的分析器添加内联支持并更新所有算术函数的元数据后,我们可以检查我们的新基准测试结果。这使我们从 5,522 毫秒降到了 2,309 毫秒。从一个巨大的胜利开始真是太好了。 #### 消除额外的 IR 指令 接下来,让我们看一下我们的 fibonacci 函数的 IR。我现在喜欢检查 jank 函数的 IR,因为它提供了关于 jank 编译器如何查看代码的非常好的视图。 ```clojure {:name user_fibonacci_82580 :lifted-vars {} :lifted-constants {const_82598 2 const_82597 1} :functions [{:name user_fibonacci_82580_1 :blocks [{:name entry :instructions [{:name fibonacci :op :parameter :type "jank::runtime::object_ref"} {:name n :op :parameter :type "jank::runtime::object_ref"} {:name v3 :op :literal :value 1 :type "jank::runtime::obj::integer_ref"} {:name v4 :op :cpp/call :value "jank::runtime::lte" :args [n v3] :type "bool"} {:name v5 :op :cpp/into-object :value v4 :type "jank::runtime::object_ref"} {:name v7 :op :truthy :value v5 :type "bool"} {:name v8 :op :branch :condition v7 :then if0 :else else1 :merge nil :shadow nil :type "void"}]} {:name if0 :instructions [{:name v9 :op :ret :value n :type "jank::runtime::object_ref"}]} {:name else1 :instructions [{:name v10 :op :literal :value 1 :type "jank::runtime::obj::integer_ref"} {:name v11 :op :cpp/call :value "jank::runtime::sub" :args [n v10] :type "jank::runtime::object_ref"} {:name v12 :op :named-recursion :fn fibonacci :args [v11] :type "jank::runtime::object_ref"} {:name v13 :op :literal :value 2 :type "jank::runtime::obj::integer_ref"} {:name v14 :op :cpp/call :value "jank::runtime::sub" :args [n v13] :type "jank::runtime::object_ref"}

相似文章

rustc_codegen_jvm: 可生成JVM字节码的Rust编译器后端

Lobsters Hottest

rustc_codegen_jvm 是一个自定义的Rust编译器后端,能够生成JVM字节码,从而将Rust代码编译成可在JVM 8+上运行的JAR文件。它支持多种Rust特性,包括控制流、数据结构、特征(traits)和闭包。

Clojure 速度几乎媲美 C(需借助一些优化)

Lobsters Hottest

本文详细介绍了 Clojure 如何借助 JVM 的 Vector API 和精心优化,在 3D 压力测试中达到接近 C 的帧率(仅差 20%),展示了动态语言在热循环中也能接近底层性能。

1jehuang/jcode

GitHub Trending (daily)

jcode 是一个开源编码代理工具,专为多会话工作流设计,资源占用低,提供CLI安装方式,并在性能上优于Claude Code和Cursor Agent等现有代理。