Search This Blog

Saturday, November 28, 2020

Building OSX Julia Command Line Executables

I have been wanting to use Julia (I am using version 1.5.3 installed in /Applications) for some time.  I primarily develop on Mac OSX (I run Mojave 10.14.6) and needed to convert some interpreted code I was developing into a command line executable.

About the closest description of what I needed to do came from this ("Vangelis") and this ("Part 2").

Julia is an JIT/REPL/world language.  By "world" I mean that it's like Smalltalk or LISP: you have a garbage-collected "world" in which everything occurs and gets saved.  There are a variety of tools like Atom/JUNO that make development quite efficient and easy which is why I want to use it (as well as for its data capabilities).

Vangelis was a persistent bastard - it took him almost four months to figure it out.  Unfortunately his work didn't occur on OSX so the instructions didn't quite work.  Similarly with "Part 2" - it almost works but not quite.

Here's what you need to know to do this:

Given Julia's JIT/REPL/world model you have to understand that in order to build a command line (or App under OSX for that matter) you are essentially merging your code into the "world," creating an alternate "entry point" to launch your code, and adding a "glue" .c file to bring everything together.

First you need to (obviously) have Julia and a C-compiler (in my case Clang) installed.

You then build your Julia "module."  It should be structured more or less as follows:

module startup
...
using CSV
using LinearAlgebra
using DelimitedFiles
...
export real_main
export julia_main
...
function real_main()
... your code goes here ...
end
...
Base.@ccallable function julia_main()::Cint
    try
        startup.real_main()
    catch
        Base.invokelatest(Base.display_error, Base.catch_stack())
        return 1    
    end
    return 0
end
...
if abspath(PROGRAM_FILE) == @__FILE__
    julia_main()
end
...
end # startup

Let's call this "startup.jl".  (You can add argv and so on once it's all working.)

Next you need to process this file to extract what is essentially the list of Julia "packages" and internal stuff you will need in your "stand alone" executable. You do this with the Julia command line option "--trace-compile" as follows:

# this executes startup.jl - the --trace-compile saves Julia
# information in app_precompile.jl
#
julia --startup-file=no --trace-compile=app_precompile.jl startup.jl

Julia scans "startup.jl" and builds a list of "precompilations" needed to make your module work.  After this app_precompile.jl will be written to you disk.

Next you need a custom "custom_sysimage.jl" file to cause the precompilations to occur as well as to bring your module in.  Julia is based on dynamic libraries so the goal here is to create a new dynamic library containing your code that can be "built" into your command line app.

The first step is running "custom_sysimage.jl" to build a static library.

This file looks like this:

Base.reinit_stdio() # allows console debug on crash/fail
Base.init_depot_path()
Base.init_load_path()

# the following 2 lines is to tell system image that startup.jl
# shall be included in Main scope. The trick is that you need
# this to cause the elements in the .jl to appear at the "top"
# scope level.  If this is not here your exported symbols will
# not appear in the .o or .o.dylib and the final link will fail.
#

include("startup.jl")

@eval Module() begin
    Base.include(@__MODULE__, "startup.jl")
    for (pkgid, mod) in Base.loaded_modules
        if !(pkgid.name in ("Main", "Core", "Base"))
             eval(@__MODULE__, :(const $(Symbol(mod)) = $mod))
        end
    end
    for statement in readlines("app_precompile.jl")
        try
            Base.include_string(@__MODULE__, statement)
        catch
            # See julia issue #28808
            Core.println("failed to compile statement: ", statement)
        end
    end
end # module

empty!(LOAD_PATH)
empty!(DEPOT_PATH)

To execute this use the following:

# custom_sysimage.jl loads the app_precompile.jl information about
# which packages are used AND to actually include("startup.jl")
# to cause the symbols to appear at the "top level" julia scope
# a sys.o is constructed containing compiled startup.jl code.
#
julia --project=./ --startup-file=no -J"/Applications/Julia-1.5.app/Contents/Resources/julia/lib/julia/sys.dylib" --output-o sys.o custom_sysimage.jl

This will create a static OSX library "sys.o".  The key things are the include("startup.jl") and the "--project=dir".  Vangelis was able to change paths and use a Julia "using" to access his module.  I couldn't get that to work because (I think) my module exists solely as a file in the current folder and isn't some kind of already known Julia package.  In any case your symbols you exported should appear in sys.o (check with "$ nm -gU sys.o").

This takes a while and a lot of meaningless crap comes out on the console.

Next use clang to make a .dylib.  The reason to use clang is the "-all_load" which is not supported on OSX gcc.  Each different platform has different ways to do this, e.g. "--whole-archive" but on the Mac I could only figure out how to get clang to work in this way.

# clang is used here (due to the availability of -all_load)
# to convert the sys.o to a dynamic library sys.o.dylib
# This is standard Mac OSX faire.
#

clang -fpic -shared -Wl,-all_load sys.o -L"/Applications/Julia-1.5.app/Contents/Resources/julia/lib/" -ljulia -o sys.o.dylib

The result is you app as a .dylib.  Next we need to build in Julia and its components.

Create
a .c wrapper to call your library (put this into "startup.c" let's ay):

// Standard headers
#include <string.h>
#include <stdint.h>

// Julia headers (for initialization and gc commands)
#include "uv.h"
#include "julia.h"

JULIA_DEFINE_FAST_TLS()

// Forward declare C prototype of the C entry point in your application
int julia_main();

int main(int argc, char *argv[])
{
    uv_setup_args(argc, argv);
    libsupport_init();
    // JULIAC_PROGRAM_LIBNAME defined on command-line for compilation
    jl_options.image_file = JULIAC_PROGRAM_LIBNAME;
    julia_init(JL_IMAGE_JULIA_HOME);

    // Initialize Core.ARGS with the full argv.
    jl_set_ARGS(argc, argv);
    // Set PROGRAM_FILE to argv[0].
    jl_set_global(jl_base_module,
    jl_symbol("PROGRAM_FILE"),
        (jl_value_t*)jl_cstr_to_string(argv[0]));

    // Set Base.ARGS to `String[ unsafe_string(argv[i]) for i = 1:argc ]`
    jl_array_t *ARGS = (jl_array_t*)
        jl_get_global(jl_base_module, jl_symbol("ARGS"));
    jl_array_grow_end(ARGS, argc - 1);

    for (int i = 1; i < argc; i++) {
        jl_value_t *s = (jl_value_t*)jl_cstr_to_string(argv[i]);
        jl_arrayset(ARGS, s, i - 1);
    }

    // call your work function entry point, and get back a value
    int ret = julia_main();
    // Cleanup and gracefully exit
    jl_atexit_hook(ret);
    return ret;
}

And finally use this command line to link things together:

clang -DJULIAC_PROGRAM_LIBNAME=\"sys.o.dylib\" -o startup startup.c sys.o.dylib -O2 -fPIE -I'/Applications/Julia-1.5.app/Contents/Resources/julia/include/julia' -L'/Applications/Julia-1.5.app/Contents/Resources/julia/lib' -ljulia -Wl,-rpath,'/Applications/Julia-1.5.app/Contents/Resources/julia/lib:$executable_path' -ljulia

If this succeeds you'll have your new command line app "startup".

Depending on your pathing either the -rpath will work (I couldn't get it too) or you'll need to define 

export DYLD_LIBRARY_PATH=/Applications/Julia-1.5.app/Contents/Resources/julia/lib

in the path when executing startup.

That's basically it.