Clojure 速度几乎媲美 C(需借助一些优化)
摘要
本文详细介绍了 Clojure 如何借助 JVM 的 Vector API 和精心优化,在 3D 压力测试中达到接近 C 的帧率(仅差 20%),展示了动态语言在热循环中也能接近底层性能。
<p><a href="https://lobste.rs/s/8jxpsq/clojure_is_almost_as_fast_as_c_with_some">评论</a></p>
查看缓存全文
缓存时间: 2026/06/15 09:06
# Clojure 几乎和 C 一样快(借助一些帮助)
来源:https://ertu.dev/posts/4_clojure-reaching-c-performance/
我有一个用 C 编写的压力测试:100,000 个立方体在太空中飞行。CPU 在每一帧重新构建每个立方体的 4x4 变换矩阵,并将所有矩阵发送到 GPU。每帧大约需要 900,000 次正弦求值和 6 MB 的矩阵数据,之后 GPU 还得绘制 360 万个三角形。因此这一帧一半是 CPU 工作,一半是 GPU 工作。
我将它移植到了 Clojure,想知道我能把帧率提升到多接近 C 版本。我事先说明,优化工作不是我一个人完成的:我与 Claude Code 结对完成,这篇文章中的大部分挖掘工作(基准测试、JIT 日志、失败的尝试)都来自那次结对编程。
我本没抱太大期望。C 版本是用 clang 的 `-O2` 编译的,在这个优化级别下,clang 会自动对变换循环进行 NEON SIMD 指令的向量化,而不会告诉你任何事情。所以当你用 C 作为基准测试另一种语言时,你实际上并不是在和源文件中的循环竞争,而是在和优化器将其变成的东西竞争。
最初的测量结果证实了这一点。C 在单线程上计算所有 100K 矩阵只需要 0.70 毫秒。我最好的标量 Clojure 循环——原始数组、类型提示、无检查数学运算、所有我知道的技巧——花了 2.6 毫秒。几乎慢了四倍,我已经无计可施了。HotSpot 不会像这样自动向量化循环。Clang 会。这就是全部差距所在。
不过 JVM 确实有一个答案:Project Panama 的 Vector API。你不必寄希望于 JIT 向量化你的循环,而是自己编写 SIMD 操作,由于它只是一个 Java API,所以从 Clojure 中也能使用。
我第一次尝试 Vector API 简直就是一场灾难。7.7 毫秒。比标量循环还慢,比 C 慢了十倍。代码是正确的,我甚至可以在 JIT 日志中看到向量内联函数被编译,所以有一阵子我盯着它看,完全不知道哪里出了问题。
问题在于,只有当 JIT 能够将 "species"(描述向量宽度的描述符)视为编译时常量时,这个 API 才会变得快。我把它存储在一个 Clojure var 中了。Var 是一个字段查找,JIT 无法折叠它,每个向量操作都悄无声息地回退到了分配对象的慢路径上。一次间接寻址,性能相差 10 倍。没人会警告你这一点。
在之上再加入融合乘加运算(clang 在 C 端已经在做这件事了)之后,Clojure 那一轮计算在单线程下达到了 0.86 毫秒,而 C 是 0.70 毫秒。当我在 M3 MacBook 上并排运行这两个应用时,它们的平均帧率都在 370 FPS 左右。此时两个版本都不再受 CPU 计算的限制,GPU 成了两者的瓶颈,这正是该测试中“平起平坐”的含义。
我还一直关注着垃圾回收,因为一个分配内存的热循环最终会导致卡顿,而如果 GC 每秒钟打断你一次,0.86 毫秒也就毫无意义了。最终的内核从普通的 float 数组中读取数据,将所有数据保持在 SIMD 寄存器中,并写入一个预先分配好的 float 数组,该数组直接传入 OpenGL。垃圾收集器无事可做。堆内存始终稳定在大约 134 MB。作为对比,那是失败的第一次尝试每秒产生了大约 7.5 GB 的临时向量对象。相同的算法,相同的 API。全部区别就在于 JIT 能否完成它的工作。
没人会把这称为地道的 Clojure。热路径上没有不可变性,没有惰性求值,没有序列。它读起来就像加了括号的 C。我对此没有意见。对于程序中 99% 性能无关紧要的部分,你使用正常的 Clojure 编程;而对于那个性能攸关的循环,这门语言允许你在不离开它的前提下如此接近底层。
真正的感谢应该献给 JVM 开发者。Project Panama 的 Vector API 让这一切成为可能:从一门动态语言显式使用 SIMD,且性能落在 clang 自动向量化输出的 20% 以内。十年前,我对这个问题的答案是用 C 编写内核并通过 JNI 调用它。我很高兴现在不再需要这样做了。
相似文章
让 Julia 达到 C++ 的速度(2019)
这是 BYU FLOW Lab 于 2019 年发布的一篇博客文章,以真实的空气动力学应用(涡粒子法)作为基准测试,探讨如何优化 Julia 代码以匹配 C++ 的性能。作者分享了在 Julia 中实现高性能计算的经验,涵盖类型声明、JIT 编译以及代码优化技巧。
基于Go的Clojure
Glojure是一个开源的、基于Go的Clojure语言解释器,能够无缝访问Go库,并允许嵌入到Go应用程序中。它目前处于早期开发阶段,但已用于业余项目。
用于分析的纯 Clojure 列式数据库
Flatiron 是一个纯 Clojure 的列式分析库,用于内存表,具有类似 SQL 的 DSL,专为使用原始数组和批处理的高性能而设计。
jank 现已拥有自己的自定义 IR
jank 是一种 Clojure 方言,现已引入一种在 Clojure 语义层面设计的自定义中间表示,以实现更好的优化并与 JVM 竞争。
ClojureScript 迎来 Async/Await
ClojureScript 1.12.145 通过 ^:async 提示引入原生异步函数支持,实现与 JavaScript async/await 的直接互操作,无需额外依赖。