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.
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.
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:
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.
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.
Let’s build the image, give it a go, and see how large it will be.
That’s less than 10 MB. To place it on a scale, the bare-bones openjdk:8u151-jre-alpineweighs 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.
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.
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.
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.
The compilation is successful and our program can be started.
An attempt to evaluate a Halunke expression leads to a following exception.
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.
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?
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.
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?
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.
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.
Now, let’s implement a simple key-value database backed by files in a temporary directory and expose it over HTTP.
In order to compile our program we will need to pass an extra option to native-image.
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 clojure.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.