Prototype Julia Launcher via Zig

At times, I have just wanted a small executable to launch Julia with some particular command line options and a few initial commands.

This task could easily be done by a shell script, but I did not want to depend on a particular shell. I also wanted this to work on Windows.

Also this is much less ambitious than PackageCompiler.jl or StaticCompiler.jl in that I am not trying to compile Julia code into an executable.

I built a prototype of what that might look like using Zig. I chose to do this in zig because it has a compact toolchain that works well across platforms.

Here’s the prototype script. I’m considering designing a package around this.

using Libdl
using Pkg
Pkg.activate(; temp=true)
Pkg.add("zig_jll")
using zig_jll

function build_launcher(
    launcher_name::AbstractString,
    cli_options::Vector{String},
    commands::Vector{String}
)
    code = launcher_code(launcher_name, cli_options, commands)
    temp_path = mktempdir()
    code_path = joinpath(temp_path, launcher_name * ".zig")
    write(code_path, code)

    libjulia_path = dirname(dlpath("libjulia"))
    julia_h_path = abspath(joinpath(libjulia_path, "..", "include", "julia"))
    zig_jll.zig() do exe
        run(`$exe build-exe $code_path -lc -ljulia -I $julia_h_path -L $libjulia_path`)
    end
end

function launcher_code(
    launcher_name::AbstractString,
    cli_options::Vector{String},
    commands::Vector{String}
)
    libjulia_path = dirname(dlpath("libjulia"))
    julia_h_path = abspath(joinpath(libjulia_path, "..", "include", "julia"))

    options_buffer = IOBuffer()
    for option in cli_options
        println(options_buffer, "        \"$option\",")
    end
    options = String(take!(options_buffer))

    command_buffer = IOBuffer()
    for command in commands
        command = escape_string(command)
        println(command_buffer, "    _ = jl_eval_string(\"$command\");")
    end
    commands = String(take!(command_buffer))
"""
// Compile with
// zig build-exe launch.zig -lc -ljulia -I $julia_h_path -L $libjulia_path
const std = @import("std");
const jl = @cImport({
    @cInclude("julia.h");
});

const jl_parse_opts = jl.jl_parse_opts;
const jl_init = jl.jl_init;
const jl_eval_string = jl.jl_eval_string;
const jl_atexit_hook = jl.jl_atexit_hook;

pub fn main() !u8 {
    const allocator = std.heap.page_allocator;

    // Julia command line arguments
    const zig_argv = [_][*:0]const u8{
        "$launcher_name",
$options
    };
    var argc: c_int = zig_argv.len;
    // We need to allocate since jl_parse_opts will return arguments
    var argv = try allocator.alloc([*:0]const u8, zig_argv.len);
    defer allocator.free(argue);
    for (zig_argv, 0..) |_, i| {
        argv[i] = zig_argv[i];
    }

    // Basic embedding
    jl_parse_opts(&argc, @ptrCast(&argv));
    jl_init();
$commands
    jl_atexit_hook(0);

    return 0;
}
"""
end

Here’s an example of building and execution.

julia> build_launcher("julia8", ["--threads=8"], ["""@info "" Threads.nthreads()"""]);

julia> run(`./julia8`)
┌ Info: 
└   Threads.nthreads() = 8
Process(`./julia8`, ProcessExited(0))

julia> filesize("julia8")
598024

Edit: Made the example Zig 0.11.0 compatible.

9 Likes

Here’s another variant of the Zig code that uses loads libjulia dynamically. If I could figure out a procedure to locate libjulia automatically, this would be more flexible.

// Compile with
// zig build-exe launch_dlopen.zig -lc -I ~/src/julia/usr/include/julia
const std = @import("std");
const jl = @cImport({
    @cInclude("julia.h");
});

pub fn main() !u8 {
    const allocator = std.heap.page_allocator;

    // TODO: Dynamically locate Julia
    const libjulia_path = "/home/mkitti/src/julia/usr/lib/libjulia.so";
    var ptr = try std.DynLib.open(libjulia_path);
    defer ptr.close();

    // Julia command line arguments
    const zig_argv = [_][*:0]const u8{
        "launch",
        "--project=@pluto",
        "--threads=8"
    };
    var argc: c_int = zig_argv.len;
    // We need to allocate since jl_parse_opts will return arguments
    var argv = try allocator.alloc([*:0]const u8, zig_argv.len);
    defer allocator.free(argv);
    for (zig_argv) |_, i| {
        argv[i] = zig_argv[i];
    }

    //std.debug.print("jl_parse_opts: {s}\n", .{@TypeOf(jl.jl_parse_opts)});
    var jl_parse_opts = ptr.lookup(@TypeOf(jl.jl_parse_opts), "jl_parse_opts") orelse return 1;
    var jl_init = ptr.lookup(@TypeOf(jl.jl_init), "jl_init") orelse return 2;
    var jl_eval_string = ptr.lookup(@TypeOf(jl.jl_eval_string), "jl_eval_string") orelse return 3;
    var jl_atexit_hook = ptr.lookup(@TypeOf(jl.jl_atexit_hook), "jl_atexit_hook") orelse return 4;

    // Basic embedding
    jl_parse_opts(&argc, @ptrCast([*c][*c][*c]u8, &argv));
    jl_init();
    _ = jl_eval_string("println(\"Hello from Julia!\")");
    _ = jl_eval_string("@info \"\" Threads.nthreads() Base.active_project()");
    jl_atexit_hook(0);

    return 0;
}
2 Likes

There’s something perverse about using a compiled binary to get around the fact that OSes don’t have a single scripting/shell language which one can count on having around, but it actually makes sense.

It might be possible to get a cross-platform binary, not just a recipe for building the launcher cross platform, using actually portable executable. On Unices you could use which to find the executable path while building the binary, and on Windows use where.exe (I think).

1 Like

If you want to find julia at build time it seems more robust to just ask julia.

$ which julia
/home/gunnar/.juliaup/bin/julia
$ julia -E Sys.BINDIR
"/home/gunnar/.julia/juliaup/julia-1.10.2+0.x64.linux.gnu/bin"
1 Like

Sys.BINDIR does not definitely identify the location of libjulia.{so, dylib, dll} since the relative location varies by operating system.

I used the code below at build time to obtain the definitve location.

dirname(dlpath("libjulia"))

Looks like this may have some overlap with GitHub - Roger-luo/Ion: Ion - a CLI toolbox for Julia developers

1 Like

ion is meant more for developer tooling and launches julia as a distinct process.

This is more similar to PackageCompiler.jl’s embedding wrapper and probably should take more inspiration from it. This is the mechanism by which PackageCompiler.jl creates an executable.

Perhaps a main distinguishing factor from ion is that I do not launch a separate Julia process. I embed Julia in the executable’s process similar to the executable wrapper above.

I’m using zig_jll, which needs updating. This contains the entire zig toolchain and is less than 50 MiB in size on Linux:
https://ziglang.org/download/

1 Like

One reason I went with zig is that it has pretty good cross compilation capability.

A problem with which and where is that this might not lead to an actual Julia install. Attempting to run julia to discover it’s location as Gunnar suggested might be the most robust.

1 Like

Do you get any value from the cross compilation capability when you rely on locating Julia during the build?

The second piece of code I posted uses dlopen. I could defer locating Julia to runtime and then memoize that location.

I’m still thinking of applications for this, but I’m mainly thinking of the deployment space.

Essentially, we can take advantage of Zig’s compact cross compiling toolchain to embed a Julia script into a possibly executable. The executable could even assist in retrieving juliaup if Julia is not found.