Native Clojure with GraalVM
转自:https://www.innoq.com/en/blog/native-clojure-and-graalvm/
GraalVM is a fascinating piece of technology. This newly-released just-in-time compiler allows efficient execution and interoperability between various programming languages on top of the JVM. Its impact on the execution speed is stunning. Benchmarks comparing Graal-based TruffleRuby to other Ruby implementations give us a hint of Graal’s potential. Speed benefits aside, the new compiler also allows us to translate our JVM bytecode into small self-contained native binaries.
And that’s just the tip of the iceberg. Needless to say, I am excited about it.
Minimal viable native binary
In a recent article my workmate Ruben Wagner discussed how Graal allows us to shrink Docker containers running our Java applications. Not only did he reduce the size of his images to 10 MB, but also made them consume less than a megabyte of RAM at runtime. On top of that, Graal allowed him to significantly speed up the startup of his Java applications.
Encouraged by his experiments, I attempted to adapt his approach to another JVM-based language, albeit a far more dynamic one: Clojure. Just like Java, this modern Lisp can be compiled ahead-of-time to *.class
files consisting of JVM bytecode. Consider the simple namespace below.
(ns io.innoq.clojure.hello
(:gen-class))
(defn -main []
(println "👋" (clojure-version)))
The :gen-class
declaration instructs the compiler to generate a JVM class named hello
in the io.innoq.clojure
package. The -main
function will be compiled to its static main
method. Let’s run the compiler in a Clojure REPL:
user=> (require 'io.innoq.clojure.hello)
nil
user=> (compile 'io.innoq.clojure.hello)
io.innoq.clojure.hello
We can find files the compiler generated in the directory given in the *compile-path*
var — in my case target/classes
. javap
confirms that the output are ordinary JVM classes.
$ javap -cp target/classes io.innoq.clojure.hello
public class io.innoq.clojure.hello {
public static {};
public io.innoq.clojure.hello();
public boolean equals(java.lang.Object);
public java.lang.String toString();
public int hashCode();
public java.lang.Object clone();
public static void main(java.lang.String[]);
}
I can reuse the set of tools Ruben prepared to compile my tiny Clojure namespace into a native binary. I begin by extracting Clojure and its standard library into target/classes
; it will simplify later steps.
$ for archive in \
clojure/1.9.0/clojure-1.9.0.jar \
core.specs.alpha/0.1.24/core.specs.alpha-0.1.24.jar \
spec.alpha/0.1.143/spec.alpha-0.1.143.jar; do
(
cd target/classes && \
jar xf ~/.m2/repository/org/clojure/$archive
)
done
Afterwards, I use Ruben’s Docker image as a base for mine.
FROM graalvm:afdbbd5 AS BASE
COPY target/classes/ /target
WORKDIR /target
RUN native-image io.innoq.clojure.hello
FROM scratch
COPY --from=BASE /lib64/libc.so.6 /lib64/libc.so.6
COPY --from=BASE /lib64/libdl.so.2 /lib64/libdl.so.2
COPY --from=BASE /lib64/libpthread.so.0 /lib64/libpthread.so.0
COPY --from=BASE /lib64/libz.so.1 /lib64/libz.so.1
COPY --from=BASE /lib64/librt.so.1 /lib64/librt.so.1
COPY --from=BASE /lib64/libcrypt.so.1 /lib64/libcrypt.so.1
COPY --from=BASE /lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
COPY --from=BASE /lib64/libfreebl3.so /lib64/libfreebl3.so
COPY --from=BASE /target/io.innoq.clojure.hello /
CMD ["/io.innoq.clojure.hello"]
Let’s build the image, give it a go, and see how large it will be.
$ docker build . -t clj-hello
...
$ docker run --rm -it clj-hello
👋 1.9.0
$ docker inspect clj-hello | jq .[0].Size
9689856
That’s less than 10 MB. To place it on a scale, the bare-bones openjdk:8u151-jre-alpine
weighs 70 MB more.
Compiling a Lisp into a Lisp
Having a minimal viable native binary under my belt, I went on to look for something more complex to experiment with. How much Clojure will Graal be able to compile for me? What are the limitations of its native compiler?
Not long ago I spent a rainy weekend playing around with Lucas Dohmen’s programming language Halunke. Check it out if you haven’t seen it. I like its thought-through, refreshing approach to object-oriented programming. Time went by and by Sunday evening I had a prototypical compiler ready. It allowed me to compile Halunke to Clojure and evaluate it on the JVM. Let’s extend it here and build a minimal Halunke REPL.
(ns jalunke.main
(:require [clojure.pprint :refer [pprint]]
[jalunke.eval :as e])
(:gen-class))
(defn -main []
(print "✨ ")
(flush)
(when-let [line (read-line)]
(try
(pprint (e/evaluate line))
(catch Throwable t
(pprint t)))
(recur)))
After printing an encouraging prompt we read a line worth of input, evaluate it, and print the result. We keep doing this as long as there’s any input left. Let’s see it in action.
$ lein run -m jalunke.main
✨ ('foo = 3) ((foo > 0) then { "pos" } else { "not pos" })
"pos"
✨ ([1 2 3] reduce { |'acc 'elem| (acc + elem) } with 0)
6
So far so good.
Now let’s compile all of our namespaces using lein compile :all
and let Graal’s native-image
process them. The operation fails and we see following error messages.
$ native-image jalunke.main
Build on Server(pid: 11, port: 26681)*
classlist: 5,823.05 ms
(cap): 1,224.07 ms
setup: 2,867.61 ms
analysis: 53,944.42 ms
error: unsupported features in 5 methods
Detailed message:
Error: com.oracle.graal.pointsto.constraints.UnsupportedFeatureException:
Unsupported constructor java.lang.ClassLoader.<init>(ClassLoader) is
reachable: The declaring class of this element has been substituted, but this
element is not present in the substitution class. To diagnose the issue,
you can add the option -H:+ReportUnsupportedElementsAtRuntime. The unsupported
element is then reported at run time when it is accessed the first time.
Trace:
at parsing java.security.SecureClassLoader.<init>(SecureClassLoader.java:76)
Call path from entry point to java.security.SecureClassLoader.<init>(ClassLoader):
at java.security.SecureClassLoader.<init>(SecureClassLoader.java:76)
at java.net.URLClassLoader.<init>(URLClassLoader.java:100)
at clojure.lang.DynamicClassLoader.<init>(DynamicClassLoader.java:41)
at clojure.main$repl.invokeStatic(main.clj:222)
at clojure.core.server$repl.invokeStatic(server.clj:177)
at clojure.core.server$repl.invoke(server.clj:177)
...
Error: com.oracle.graal.pointsto.constraints.UnsupportedFeatureException:
...
you can add the option -H:+ReportUnsupportedElementsAtRuntime. The unsupported
element is then reported at run time when it is accessed the first time.
Trace:
at parsing java.lang.reflect.Proxy.isProxyClass(Proxy.java:791)
Call path from entry point to java.lang.reflect.Proxy.isProxyClass(Class):
at java.lang.reflect.Proxy.isProxyClass(Proxy.java:791)
at sun.reflect.misc.ReflectUtil.isNonPublicProxyClass(ReflectUtil.java:289)
at sun.reflect.misc.ReflectUtil.checkPackageAccess(ReflectUtil.java:165)
at sun.reflect.misc.ReflectUtil.isPackageAccessible(ReflectUtil.java:195)
at java.beans.MethodRef.get(MethodRef.java:71)
at java.beans.PropertyDescriptor.appendTo(PropertyDescriptor.java:715)
at java.beans.FeatureDescriptor.toString(FeatureDescriptor.java:421)
at java.lang.String.valueOf(String.java:2994)
...
Interesting! It appears we’ve hit one of Graal’s limitations.
As we can read in the error message, we can try to compile our program anyway. Adding the extra option to native-image
makes it replace the unsupported behaviour with a runtime exception.
$ native-image -H:+ReportUnsupportedElementsAtRuntime jalunke.main
Build on Server(pid: 11, port: 26681)
classlist: 3,663.62 ms
(cap): 888.29 ms
setup: 1,433.31 ms
(typeflow): 35,999.69 ms
(objects): 7,178.83 ms
(features): 150.20 ms
analysis: 44,373.37 ms
universe: 2,278.12 ms
(parse): 8,019.46 ms
(inline): 6,412.02 ms
(compile): 30,652.61 ms
compile: 46,265.50 ms
image: 3,479.37 ms
write: 675.22 ms
[total]: 102,363.25 ms
The compilation is successful and our program can be started. An attempt to evaluate a Halunke expression leads to a following exception.
✨ 42
#error {
:cause "(...) The declaring class of this element has been substituted,
but this element is not present in the substitution class"
:via
[{:type com.oracle.svm.core.jdk.UnsupportedFeatureError
:message "(...)"
:at [java.lang.Throwable <init> "Throwable.java" 265]}]
:trace
[[java.lang.Throwable <init> "Throwable.java" 265]
[java.lang.Error <init> "Error.java" 70]
[com.oracle.svm.core.jdk.UnsupportedFeatureError <init> "UnsupportedFeatureError.java" 29]
[com.oracle.svm.core.jdk.Target_com_oracle_svm_core_util_VMError unsupportedFeature "VMErrorSubstitutions.java" 103]
[java.lang.ClassLoader <init> "ClassLoader.java" 316]
[java.security.SecureClassLoader <init> "SecureClassLoader.java" 76]
[java.net.URLClassLoader <init> "URLClassLoader.java" 100]
[clojure.lang.DynamicClassLoader <init> "DynamicClassLoader.java" 41]
[clojure.lang.RT$7 run "RT.java" 2162]
[com.oracle.svm.core.jdk.Target_java_security_AccessController doPrivileged "SecuritySubstitutions.java" 68]
[clojure.lang.RT makeClassLoader "RT.java" 2157]
[clojure.lang.Compiler eval "Compiler.java" 7032]
[clojure.lang.Compiler eval "Compiler.java" 7025]
[clojure.core$eval invokeStatic "core.clj" 3206]
[clojure.core$eval invoke "core.clj" 3202] 💥
[jalunke.eval$evaluate invokeStatic "eval.clj" 8]
[jalunke.eval$evaluate invoke "eval.clj" 6]
[jalunke.main$_main$fn__1500 invoke "main.clj" 14]
[jalunke.main$_main invokeStatic "main.clj" 13]
[jalunke.main$_main invoke "main.clj" 9]
...]}
The exception points directly to the cause of the problem. After compiling Halunke to Clojure we attempt to evaluate it using clojure.core/eval
, as marked with 💥
.
The stack trace indicates that Clojure attempted to call the constructor of ClassLoader
. This, however, is not possible in the native binary, as reported in the output of the failed compilation. The constructor got replaced with an UnsupportedFeatureError
.
Notice that, if we limit ourselves to compilation and do not evaluate the resulting code, our program will work. All we have to do is replace e/evaluate
with jalunke.compile/compile-code
in our loop. This way, we avoid a call to clojure.core/eval
, thus preventing an invocation of ClassLoader
’s constructor. Let’s recompile our native binary and see it translate Halunke to Clojure. Our program stops short of evaluating the generated code.
$ ./jalunke.main
✨ ('foo = 3) ((foo > 0) then { "pos" } else { "not pos" })
(clojure.core/let
[foo 3]
(then-else
(> foo 0)
(clojure.core/fn [] "pos")
(clojure.core/fn [] "not pos")))
Command line, web, and beyond
Having learned the limitations imposed on our native binaries, we can build something more useful. How about a simple tool allowing us to get nested values from EDN-formatted data structures? Just like a minimal jq, but for Clojure’s serialisation format?
(ns pprintin.main
(:require [clojure.pprint :refer [pprint]])
(:gen-class))
(defn -main [& path]
(-> (read *in*)
(get-in (mapv read-string path))
pprint))
We can compile it with native-image
just like we did with jalunke.main
. Notice how little time the resulting binary needs to deliver results. On my machine native-image
saves a whole second of startup time in comparison to a traditional Java invocation.
$ time ./pprintin.main :files 1 :lines <<eof
> {:files
> [{:name "src/core.clj"
> :added #inst "2018-04-01"
> :id #uuid "38f533db-eebb-4186-bc79-4bb777e67df7"
> :lines {:code 17
> :comments 3
> :blank 4}}
> {:name "src/internals.clj"
> :added #inst "2018-04-05"
> :id #uuid "55fbc5f3-adcb-4439-af4e-10a6f321da02"
> :lines {:code 43
> :comments 0
> :blank 11}}]}
> eof
{:code 43,
:comments 0,
:blank 11}
0.00user 0.02system 0:00.10elapsed 19%CPU (0avgtext+0avgdata 15316maxresident)k
31728inputs+0outputs (140major+637minor)pagefaults 0swaps
How about something different? Let’s try to build a tool wrapping another Clojure library. Perhaps a utility taking Hiccup data structures as its input and translating them to HTML?
(ns fromhiccup.main
(:require [hiccup.core :as h])
(:gen-class))
(defn -main []
(-> (read *in*)
h/html
println))
Once we’ve precompiled all of our namespaces ahead-of-time — including hiccup.core
and its dependencies — Graal can turn them into a self-contained Hiccup renderer. The startup time and memory usage remain satisfyingly low.
$ time ./fromhiccup.main <<eof
> [:html [:body [:div#main {:style "color:red"} "hi"]]]
> eof
<html><body><div id="main" style="color:red">hi</div></body></html>
0.00user 0.01system 0:00.08elapsed 12%CPU (0avgtext+0avgdata 10704maxresident)k
23232inputs+0outputs (102major+262minor)pagefaults 0swaps
As we can see above, Graal enables us to use Clojure to build small self-contained command-line tools. Let’s see if it will also allow us to build an entire web application. In order to try this we’ll need some extra dependencies.
(defproject webkv "0.0.0"
:dependencies [[org.clojure/clojure "1.9.0"]
[bidi "2.1.3"]
[ring/ring-defaults "0.3.1"]
[http-kit "2.3.0"]]
:aot [webkv.main]
:main webkv.main)
Now, let’s implement a simple key-value database backed by files in a temporary directory and expose it over HTTP.
(ns webkv.main
(:require [org.httpkit.server :as http]
[ring.middleware.defaults
:refer [wrap-defaults
api-defaults]]
[bidi.ring :refer [make-handler]])
(:gen-class))
;; We want to be sure none of our calls relies
;; on reflection. Graal does not support them.
(set! *warn-on-reflection* 1)
;; This is where we store our data.
(def ^String tmpdir
(System/getProperty "java.io.tmpdir"))
;; That's how we find a file given a key.
;; Keys must match the given pattern.
(defn file [^String key]
{:pre [(re-matches #"^[A-Za-z-]+$" key)]}
(java.io.File. tmpdir key))
;; Here we handle GET requests. We just
;; read from a file.
(defn get-handler
[{:keys [params]}]
{:body (slurp (file (params :key)))})
;; This is our PUT request handler. Given
;; a key and a value we write to a file.
(defn put-handler
[{:keys [params]}]
(let [val (params :val)]
(spit (file (params :key)) val)
{:body val, :status 201}))
;; Here's the routing tree of our application.
;; We pick the handler depending on the HTTP
;; verb. On top of that we add an extra middle-
;; ware to parse data sent in requests.
(def handler
(-> ["/dict/" {[:key] {:get get-handler
:put put-handler}}]
(make-handler)
(wrap-defaults api-defaults)))
;; Finally, we've got all we need to expose
;; our handler over HTTP.
(defn -main []
(http/run-server