Command-line apps with Clojure and GraalVM: 300x better start-up times
I am a fond Clojure user, and love to use it for both server-side projects and small command-line tools. I love its expressivity and concision, and I like the fact that I can have a small “script” and hack on it. The problem I have is that while this is fine for interactive use (I don’t mind waiting a second to see results) or for server side projects (where initialization happens just once), it is not a sweet spot for a command-line tool that I want to deploy to automate something.
In this case, the cost of firing up the JVM, Clojure and the script itself, vastly outnumbers the actual processing happening, that is maybe scanning some files; so I see CPU spikes when my cron jobs run those tasks. Nothing bad, but it’s quite useless to load a complex environment to process a 100k text file and then throw everything away.
Enter GraalVM
GraalVM is an extension of the Java virtual machine to support more languages and execution modes. The Graal project includes a new high performance Java compiler which can be used in a just-in-time configuration on the HotSpot VM, or in an ahead-of-time configuration on the Substrate VM.
Substrate VM is a framework that allows ahead-of-time compilation of Java applications under closed-world assumption into executable images or shared objects.
This means that we can create a Clojure command-line app, compile it as :aot :all
into
a set of Java class files, and then compile those into a single, standalone binary file.
As all initialization is run before compiling into a binary file, it is already present in the generated image and therefore does not require running again! for Clojure, with its complex initialization phase, this is a major win.
Limits of GraalVM / Substrate VM
Substrate VM does not support all features of Java to keep the implementation small and concise, and also to allow aggressive ahead-of-time optimizations (though it does some pretty impresive things, e.g. most of Java reflection).
The biggest issue that we will face is that dynamic class loading is not allowed. We may not care much about that if we have a closed project where all classes are defined at compile time, but as Clojure itself uses it in clojure.lang.RT.resourceAsStream, at the moment we have to tell Graal not to abort at compile time but to defer such exceptions to runtime. Not very elegant, but it works.
This also means that not all valid Java libraries will work out-of-the box in GraalVM - see e.g. Akka and Graal’s native image tool.
The second limit of GraalVM is that in order to compile an even moderately simple executable, it will require a lot of RAM - 6G to 8G not being uncommon from my own experiments.
Our first GraalVM Clojure application
To get started, we want to implement the simple “toycalc” app that ships with CLI-matic - a handy library that lets you define declaratively the CLI parameters your application accepts, and takes care of generating online help, validating paramaters, casting values and all of those nitty-gritty tasks you really do not want to handle when writing a command-line app.
The Clojure side
First, we create a new Leiningen project:
lein new testgraal
Its main class will be testgraal.core
.
So we edit its project.clj
to include our dependencies and set everything up for AOT compilation:
(defproject testgraal "0.1"
:description "My first test with GraalVM"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.9.0"]
[cli-matic "0.1.14"]]
:main testgraal.core
:aot :all)
We then edit src/testgraal/core.clj
; copy and paste the source from https://github.com/l3nz/cli-matic/blob/master/examples/toycalc.clj
and modify the namespace declaration so that this namespace is compiled AOT.
(ns testgraal.core
(:require [cli-matic.core :refer [run-cmd]])
(:gen-class))
(defn add_numbers
"Sums A and B together, and prints it in base `base`"
[{:keys [a1 a2 base]}]
(println
(Integer/toString (+ a1 a2) base)))
(defn subtract_numbers
"Subtracts B from A, and prints it in base `base` "
[{:keys [pa pb base]}]
(println
(Integer/toString (- pa pb) base)))
(def CONFIGURATION
{:app {:command "toycalc"
:description "A command-line toy calculator"
:version "0.1"}
:global-opts [{:option "base"
:as "The number base for output"
:type :int
:default 10}]
:commands [{:command "add" :short "a"
:description ["Adds two numbers together"
""
"Looks great, doesn't it?"]
:opts [{:option "a1" :short "a" :env "AA" :as "First addendum" :type :int :default 0}
{:option "a2" :short "b" :as "Second addendum" :type :int :default 0}]
:runs add_numbers}
{:command "sub" :short "s"
:description "Subtracts parameter B from A"
:opts [{:option "pa" :short "a" :as "Parameter A" :type :int :default 0}
{:option "pb" :short "b" :as "Parameter B" :type :int :default 0}]
:runs subtract_numbers}]})
(defn -main
"This is our entry point.
Just pass parameters and configuration.
Commands (functions) will be invoked as appropriate."
[& args]
(run-cmd args CONFIGURATION))
At this point we can have Leiningen generate everything we need:
lein compile && lein uberjar
And we get a single, standalone JAR file under target
:
$ ls -l target/
total 5480
drwxr-xr-x 7 ll staff 224 Jul 19 21:55 classes
drwxr-xr-x 3 ll staff 96 Jul 19 21:55 stale
-rw-r--r-- 1 ll staff 4698514 Jul 19 21:55 testgraal-0.1-standalone.jar
-rw-r--r-- 1 ll staff 298095 Jul 19 21:55 testgraal-0.1.jar
And we can test it:
$ java -jar target/testgraal-0.1-standalone.jar --help
NAME:
toycalc - A command-line toy calculator
Creating a standalone executable on Linux
Now for the fun part: create a CentOS 7 Linux VM with at least 4-6G of RAM; log in and download the latest version of GraalVM Community Edition from GitHub:
wget https://github.com/oracle/graal/releases/download/vm-1.0.0-rc4/graalvm-ce-1.0.0-rc4-linux-amd64.tar.gz
tar zxvf graalvm-ce-1.0.0-rc4-linux-amd64.tar.gz
There is no actual installation to do - for the moment we will just run files within the uncompresed archive.
We will also need zlib
for compiling:
yum install zlib-devel
Believe it or not, that’s all we have to do.
Now we copy our testgraal-0.1-standalone.jar
uberjar to our work directory, and run the native-image
executable of GraalVM:
# ./graalvm-ce-1.0.0-rc4/bin/native-image \
-H:+ReportUnsupportedElementsAtRuntime \
-J-Xmx3G -J-Xms3G --no-server \
-jar testgraal-0.1-standalone.jar
classlist: 1,397.10 ms
(cap): 673.76 ms
setup: 1,087.60 ms
(objects): 14,990.15 ms
(features): 4,894.45 ms
analysis: 78,338.83 ms
universe: 7,191.33 ms
(parse): 5,807.91 ms
(inline): 4,237.25 ms
(compile): 25,749.35 ms
compile: 37,432.01 ms
image: 3,398.08 ms
write: 662.61 ms
[total]: 129,597.44 ms
Please note that I specify:
-H:+ReportUnsupportedElementsAtRuntime
so Graal won’t abort for dynamic class loading;-J-Xmx3G -J-Xms3G
so we can specify the Java heap (not really needed here, but you may need this option to compile larger projects);--no-server
so it runs as one single process without spawning a compiler daemon.
And after a couple of minutes and a lot of CPU under the bridge….
# ls -l
total 265392
drwxr-xr-x 8 root root 4096 Jul 19 21:38 graalvm-ce-1.0.0-rc4
-rw-r--r-- 1 root root 236474318 Jul 17 18:02 graalvm-ce-1.0.0-rc4-linux-amd64.tar.gz
-rwxr-xr-x 1 root root 25873378 Jul 19 22:12 testgraal-0.1-standalone
-rw-r--r-- 1 root root 4698514 Jul 19 21:55 testgraal-0.1-standalone.jar
There it is! the testgraal-0.1-standalone
executable, ~25M that we can compress to ~7M with gzip.
Results
If a normal execution of out testgraal
takes a couple of seconds on our VM, using
Graal’s own Java VM…..
# time ./graalvm-ce-1.0.0-rc4/bin/java -jar testgraal-0.1-standalone.jar -?
NAME:
toycalc - A command-line toy calculator
USAGE:
toycalc [global-options] command [command options] [arguments...]
VERSION:
0.0.1
COMMANDS:
add, a Adds two numbers together
sub, s Subtracts parameter B from A
GLOBAL OPTIONS:
--base N 10 The number base for output
-?, --help
real 0m1.563s
user 0m2.755s
sys 0m0.156s
If we run the compiled image we get the very same result….
# time ./testgraal-0.1-standalone -?
NAME:
toycalc - A command-line toy calculator
USAGE:
toycalc [global-options] command [command options] [arguments...]
VERSION:
0.0.1
COMMANDS:
add, a Adds two numbers together
sub, s Subtracts parameter B from A
GLOBAL OPTIONS:
--base N 10 The number base for output
-?, --help
real 0m0.005s
user 0m0.003s
sys 0m0.002s
Same result, but 300x faster in real time, and 900x faster in term of user time. Not half bad, I’d say!
So you have no excuses not to build your CLI tools in Clojure now.
Happy hacking!