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 tar­get/classesjavap 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 tar­get/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 clo­jure.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.com­pile/com­pile­-code in our loop. This way, we avoid a call to clo­jure.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 handler
                   {:port 8080})
  (println "🔥 http://localhost:8080"))

In order to compile our program we will need to pass an extra option to native-image.

$ native-image -H:+ReportUnsupportedElementsAtRuntime \
               -H:EnableURLProtocols=http webkv.main
...
[total]:  39,760.45 ms
$ du -h webkv.main
9.9M    webkv.main
$ ./webkv.main &
🔥 http://localhost:8080
$ curl http://localhost:8080/dict/abc -i -XPUT -d val="just testing"
HTTP/1.1 201 Created
Content-Type: application/octet-stream
Content-Length: 12
Server: http-kit
Date: Thu, 26 Apr 2018 15:43:49 GMT

just testing
$ curl http://localhost:8080/dict/abc -i
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 12
Server: http-kit
Date: Thu, 26 Apr 2018 15:43:54 GMT

just testing

Our server not only starts instantaneously, but also behaves exactly as its traditional JVM incarnation. The binary file relies only on a handful of shared libraries; the resulting Docker image doesn’t take more than 15 MB.

 

Let’s review what we’ve accomplished so far. At the beginning we AOT compiled a tiny Clojure namespace and used Graal to transform it into a native binary. The resulting file didn’t rely on a JVM to work; it needed just 3 MB worth of shared libraries. Afterwards, we discovered one of Graal’s limitations by trying to embed clo­jure.core/eval in our simple REPL.

We moved on and built some self-contained command-line utilities, whose memory usage and startup time challenge the JVM-based status quo. Finally, we successfully built an entire web application with an embedded HTTP server. In terms of disk and memory usage the resulting Docker image weighed a fraction of a traditional JVM-based solution.

Most importantly though, we’ve only scratched the surface of Graal’s capabilities. Its potential is astounding. I’m looking forward to what the future brings.

 

posted on 2019-11-20 09:24  荣锋亮  阅读(663)  评论(0编辑  收藏  举报

导航