在 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 设置。