使用 GraalVM 在 WASM 中运行 Clojure
在 WASM 中运行 Clojure
2025年4月26日
从 v25 版本开始,GraalVM 添加了对编译为 native image 的 Java 程序的 WASM 后端的支持,这意味着最终可以在 WASM 中编译和运行 Clojure 程序了!
虽然 WASM 后端还处于早期阶段,并且不支持线程和网络,但现在已经可以在 Java/Clojure 中编译和运行单线程的计算程序。
如果你现在打开浏览器的控制台,在这个页面上,你会看到 Hello, World!
打印在控制台中。 这是一个 Clojure 程序在和你打招呼 :)
(ns core
(:gen-class))
(defn -main [& args]
(println "Hello, World!"))
Copy
二进制文件大小
这个简单程序的输出 WASM 是一个 5.6MB 的二进制文件,可以使用 wasm-opt
工具 进行裁剪,只需确保它不会破坏任何东西。幸运的是,在压缩后 (gzip, brotli 等),二进制文件的大小仅为约 2.5MB。
在通过 wasm-opt 运行二进制文件后,我得到了 5MB 的输出,大约减少了 600KB 的大小。
wasm-opt core.js.wasm -o core.js.wasm -Oz --enable-gc --enable-strings --enable-reference-types --enable-exception-handling --enable-bulk-memory --enable-nontrapping-float-to-int
Copy
相比之下,这个基本的 Java "hello world" 程序仅产生 1MB 的 WASM。
public class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
Copy
为了让你了解添加库后二进制文件大小的变化,使用 clojure.data.json
会增加 130KB 的 WASM 二进制文件大小。请注意,如果需要一个命名空间但从未使用过,它将从输出中删除。
(ns core
(:require [clojure.data.json :as json])
(:gen-class))
(defn -main [& args]
(prn (json/write-str {:a 1 :b 2})))
Copy
为了进行详细分析,GraalVM 提供了一个构建报告,该报告显示编译输出的 70% 是堆快照,其中大约 50% 填充了字符串和哈希映射。
有趣的是,方法数量图表显示,大多数方法 (60%) 来自各种 Java 命名空间,而 Clojure 仅占 17% 的方法。
这是在此页面上运行的 WASM 二进制文件的完整交互式构建报告。
速度
通过 Node 23.9.0 运行未优化的二进制文件:
- big reduce: {:total-ops 10**1, :total-time 2.6s, :per-op-time 256.3ms}
- int/float division: {:total-ops 10**7, :total-time 763.2ms, :per-op-time 76ns}
- float division: {:total-ops 10**7, :total-time 398.9ms, :per-op-time 39ns}
- integer division: {:total-ops 10**7, :total-time 45.2ms, :per-op-time 4ns}
Copy
优化的二进制文件运行速度提高了约 10%。
wasm-opt core.js.wasm -o core.js.wasm -O4 --enable-gc --enable-strings --enable-reference-types --enable-exception-handling --enable-bulk-memory --enable-nontrapping-float-to-int
Copy
- big reduce: {:total-ops 10**1, :total-time 2.3s, :per-op-time 226.0ms}
- int/float division: {:total-ops 10**7, :total-time 646.6ms, :per-op-time 64ns}
- float division: {:total-ops 10**7, :total-time 348.0ms, :per-op-time 34ns}
- integer division: {:total-ops 10**7, :total-time 45.3ms, :per-op-time 4ns}
Copy
你可以在此页面上自己运行基准测试,按下下面的按钮并等待控制台中的日志
运行基准测试
相同的 Clojure 代码,编译为 native image,运行速度比 WASM 版本快 2-3 倍。
- big reduce: {:total-ops 10**1, :total-time 1.0s, :per-op-time 102.0ms}
- int/float division: {:total-ops 10**7, :total-time 248.9ms, :per-op-time 24ns}
- float division: {:total-ops 10**7, :total-time 87.2ms, :per-op-time 8ns}
- integer division: {:total-ops 10**7, :total-time 19.8ms, :per-op-time 1ns}
Copy
通过 Clojure CLI 在 OpenJDK Temurin-21.0.7+6 上运行基准测试速度明显更快,准确地说,快 5 倍到 12 倍。
- big reduce: {:total-ops 10**2, :total-time 1.8s, :per-op-time 18.2ms}
- int/float division: {:total-ops 10**7, :total-time 326.1ms, :per-op-time 32ns}
- float division: {:total-ops 10**7, :total-time 86.8ms, :per-op-time 8ns}
- integer division: {:total-ops 10**7, :total-time 24.0ms, :per-op-time 2ns}
Copy
最后,这是相同的代码编译为 ClojureScript 在 Node 23.9.0 上运行,比 WASM 版本快 5 倍。
- big reduce: {:total-ops 10**2, :total-time 4s, :per-op-time 41ms}
- int/float division: {:total-ops 10**7, :total-time 40ms, :per-op-time 4ns}
- float division: {:total-ops 10**7, :total-time 39ms, :per-op-time 3ns}
- integer division: {:total-ops 10**7, :total-time 39ms, :per-op-time 3ns}
Copy
这引出了以下非常科学的性能图表:
我不是 WASM 和 GraalVM 专家,所以真的不能为它辩护什么,但我也很惊讶 native image 运行速度更慢。 当然,有 JVM 启动的问题,但那是另一回事了。
与宿主环境的互操作
好的,现在尝试按此按钮...
你所看到的是 WASM<->JavaScript 互操作的示例。 让我们看一下如何使用 GraalVM 实现这一点。
(ns core
(:import [browser Browser Callback]
[org.graalvm.webimage.api JSObject])
(:gen-class))
(defn as-callback [f]
(reify Callback
(run [this value]
(f value))))
(defn invoke-method [^JSObject object ^String method & args]
(.call ^JSObject (.get object method) object (object-array args)))
(defn -main [& args]
(Browser/main)
(let [window (Browser/globalThis)
root (Browser/querySelector "#btn-root")
button (Browser/createElement "button")
on-click (fn [event]
(invoke-method window "alert" "Hello, from Clojure in WASM!"))]
(.set button "textContent" "Press me")
(Browser/addEventListener button "click" (as-callback on-click))
(Browser/appendChild root button)))
Copy
这里的主要部分是 -main
函数。 这是典型的命令式 DOM 操作:创建一个 button 元素,添加文本子元素,分配事件侦听器并将元素插入 DOM。 invoke-method
是一个执行对象方法的辅助函数,而 as-callback
将事件处理程序包装到一个实现 Callback
接口的对象中。 此接口的目的是类似于 java.lang.Runnable
。
那是面向用户的代码,现在让我们看一下“后端”——互操作层。 首先,org.graalvm.webimage.api
提供了一组类,这些类定义了访问 JavaScript 环境的各种方式。
Callback
接口必须是一个函数式接口。 请注意,其 run
方法采用 JSObject
类型的参数。
package browser;
import org.graalvm.webimage.api.JSObject;
@FunctionalInterface
public interface Callback {
void run(JSObject event);
}
Copy
最后是 Browser
类,这是为 WASM 端声明 DOM API 的地方。
package browser;
import org.graalvm.webimage.api.JS;
import org.graalvm.webimage.api.JSObject;
public class Browser {
public static void main() {
try {
// TODO GR-62854 Here to ensure run is generated. Remove once
// objects passed to @JS methods automatically have their SAM registered.
sink(Callback.class.getDeclaredMethod("run", JSObject.class));
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
@JS("")
private static native void sink(Object o);
@JS.Coerce
@JS("return globalThis;")
public static native JSObject globalThis();
@JS.Coerce
@JS("return document.querySelector(selector);")
public static native JSObject querySelector(String selector);
@JS.Coerce
@JS("return document.createElement(tag);")
public static native JSObject createElement(String tag);
@JS.Coerce
@JS("return parent.appendChild(child);")
public static native void appendChild(JSObject parent, JSObject child);
@JS.Coerce
@JS("element.addEventListener(eventType, handler);")
public static native void addEventListener(JSObject element, String eventType, Callback handler);
}
Copy
这个接口代码非常简单。 @JS
装饰器接受一个 JavaScript 代码字符串,该字符串将作为 JS 运行时的一部分生成,并且 native
Java 方法将在 WASM 端绑定到该 JS 代码。
@JS.Coerce
@JS("return document.createElement(tag);")
public static native JSObject createElement(String tag);
Copy
你必须确保 JS 和 Java 声明都使用相同的方法名称,并且类使用 -parameters
标志进行编译,以保留字节码中的参数名称。
此页面上的基准测试按钮调用 WASM 二进制文件。 这是你可以将 Clojure 函数导出到浏览器的全局范围的方法。
(.set (Browser/globalThis) "runBenchmark"
(as-callback (fn [_] (run-benchmark))))
Copy
Browser
类的 main
方法注册 Callback
接口的方法,因此它们在编译后不会被删除。 这最终应该会消失。
你可以在 roman01la/graal-clojure-wasm 存储库中找到完整的 Clojure 设置。