This article is an in-depth look at my new package PackageCompiler.jl. If you just want to start building binaries for your favorite Julia package, you might already be satisfied with reading the README.
Motivation
Julia is a great language, but since its inception it had one big drawback: it combines the long compilation times of a high performance language like C with the inability to fully cache the compiled code into a binary.
This makes Julia feel slow at times, especially when programming graphical applications. A lot of people trying out Julia with the promise of it being a high performance language, quickly get disappointed after starting up their first package and calling a couple of functions. E.g. just making a simple scatter plot in Plots.jl takes around 40 seconds until you see the first image — of which 99% are spent compiling the entire dependency tree for Plots.jl.
On second call, the scatter plot will just take a fraction of a second. Because of this, it has become a common practice to run the Julia process as long as possible to not restart the JIT (Just In Time) compilation.
The Atom IDE for Julia is making this very easy, by enabling the user to interactively work on Packages and edit functions without restarting the Julia process. Tim Holy created an IDE independent way to do this with Revise.jl, which monitors all Julia files in the background and just recompiles diffs within the same Julia process.
This greatly helps developers, but if you’re just using a couple of Packages without editing any of them, quickly want to run a Julia scrip, or want to wrap a Julia Package in another language, this isn’t helping very much.
The good news is, that it’s in theory trivial to create a Julia binary without any JIT overhead. After all, Julia is based on LLVM, the same compiler backend that Clang uses for compiling C++ binaries. You can find more details in: Static and Ahead of Time (AOT) Compiled Julia.
The bad news is, that it’s not that easy to decide what to compile and how to split up, link and cache the resulting binaries.
This is why Julia so far only offers precompilation, which just caches the result of Julia’s lowering pass — which is at most half of the total compilation cost.
One of the main problems is, that Julia JIT compiles functions at runtime, so ahead of time it is not really known what function specializations will need compilation. Just have a look at a typical function like this:
foo(a, b) = a + b
Julia will compile a specialized method for this, whenever you call the function with concrete types, e.g. `foo(1.0, 2.0)`. But as long as the arguments are not typed with concrete types, there is no way to know ahead of time what those will be. This is just the tip of the iceberg and there are quite a few more problems.
But there is actually a simple solution to this problem: trace the execution of your program with SnoopCompile, and compile those methods ahead of time into the Julia system image. Julia offers the function precompile
for this. Note that precompile
alone won’t actually cache the compiled function — it’s just a nice way to trigger the JIT without calling the function. You still need to make sure with e.g. PackageCompiler, that the compiled function will get cached.
So PackageCompiler generates a file that contains a lot of statements like:
precompile(foo, (Float64, Float64))
And then caches the compiled functions in the system image, much like described in system image compilation.
The functions you do not record will be callable in the compiled binary, but they will still have a compilation overhead. If you already have a test suite with a high coverage, this is a straight forward solution.
The speed up achieved with PackageCompiler can be quite intense, as I demonstrate with my own plotting library:
So PackageCompiler combines SnoopCompile with Julia’s compilation tools into one easy to use package. Note that Julia 0.6 is still a bit fragile with static compilation — have a look at trouble shooting to get an idea, what code you need to tweak in your package to make things work.
If you have extensive tests already, you can compile a system image with just a call to `PackageCompiler.compile_package(“MyPackage”)`.
You can also combine multiple packages into the compiled system image by adding more packages: compile_package("Package1", "Package2")
.
The system image is not a stand alone binary, but instead the image that Julia loads on startup. At the moment, this is the most convenient way to get rid of the JIT overhead.
You could also compile a standalone executable or shared library, which will be added soon to PackageCompiler. It’s a trivial addition already contained in static-julia, but it is not entirely clear to me how users will want to use this. Most Julia packages are currently not written in a way to be used as an executable/shared library, so we need to figure out what format users will expect here.
Julia Packages for other Languages
A cool side effect of recording all function in a package is, that it’s very easy to also automatically generate wrappers for other languages around the Julia binary.
So you could easily emit a `my_julia_package.h` for a C library, or generate a python wrapper. If anyone is interested in helping me generate those, let me know! This should be an easy task for anyone knowing the Foreign Function Interface of the targeted language!
I hope to add those features until Julia 1.0 is released and give people easier tools to get rid of JIT overhead and distribute their packages.