Creating fully self-contained and pre-compiled library

Hi everyone. We intend to use julia to create a fully self-contained, completely pre-compiled library for integration in a larger software. I did read several posts, watched YouTube, etc. – there’s a lot but also quite scattered information. Also, there’s (for me) still the confusion about PackageCompiler and juliac. Size of the resulting library is not that critical, but smaller is better, of course. So, my questions are:

[Edit: I state the answers to the questions, as found so far, directly here. Will update whenever having found out something more. Also, follow-up questions are added.]

  • What is, as of 2025-08-05, the best way of compiling a standalone library (with C ABI), that does not rely (and thus not include) the julia “runtime”/compiler? (Relying on official releases, no nightly builds etc., and supporting “all” functionality.)
    • If I understood correctly, the “runtime” (libjulia) is always required, and in order not to have the compiler (LLVM) bundled, the only way is juliac.
    • The workflow, for now, is to call julia on <julia-root-folder>/share/julia/juliac/juliac.jl from the command line, providing options and the input .jl file.
  • Does a single library (DLL on Windows) result, or does it rely on other libraries that I need to bundle for distribution?
    • The resulting binary (exe or DLL) is not self-contained, many (to most/all) libraries in <julia-root-folder>/bin are required.
  • [follow-up] Is there a way to identify which ones need to be bundled along the binary to make it work?
  • What about packages that rely on third-party binaries? (Relates to the previous point.)
  • Is that workflow “officially” documented somewhere?
    • It seems not. Correct?
  • What will change with the release of julia 1.12? (buzzwords --trim, juliac)
    • Only with 1.12 (nightly, for now; not rc1), most features of juliac, including --trim become available.
    • In the future, juliac and PackageCompiler.jl should converge (since they “target the same but the former from the command-line, the latter from within julia”).
  • Will the “best way” change in the near future? (So, if possible, would it be better to wait for some weeks, or months, before starting this journey?)
    • There’s only the way with juliac for now, and a nightly build of julia 1.12 is required. Best would be to wait for the official 1.12 release, but these features will retain status “experimental” also at that point.

Thanks for pointing me to the currently right direction; could save me a lot of time searching, reading, and trying. I can also document my “journey” here, or help in updating / extending documentation etc.


Just some of the posts and other resources I found, as reference


Edit: Just wanted to say thanks to everyone working on, or otherwise involved in, julia and especially juliac and related / enabling stuff! :heart_on_fire: Really awesome to have an interactive language / environment to prototype (but running at C-like speed) and then being able to generate a binary (even if it is not as small as if it had been generated using e.g. C or Rust). Folks just have to understand that the former was the initial (and IMO still primary) focus. Still, I do think that the latter (with nice tooling to make binary generation accessible and straightforward) could boost adoption of julia a lot, since it enabled integration of julia-created modules in any other environment, in binary (and thus closed-source) form.

7 Likes

To achieve these two goals:

  • fully self-contained, completely pre-compiled
  • does not rely (and thus not include) the julia “runtime”/compiler

you actually do need --trim — even though you don’t care about file size. And --trim requires the juliac capabilities in the v1.12 RC.

PackageCompiler.jl pre-v1.12 can also create a dll/dylib/so, but it does not have the tooling to guarantee/enforce that everything gets compiled like juliac --trim does. So it bundles the compiler for anything you may have missed.

4 Likes

To add to the reference list: The “state of --trim” talk from JuliaCon’25: https://www.youtube.com/watch?v=3o0lAXCa9Wg&t=24710s

4 Likes

Thanks for the hints. Forgot to note that currently I’m developing on Windows (but will later deploy on linux; but want to have a proof-of-concept on Windows first). So I downloaded the portable nightly binaries (from today, 05:06) and tried to compile the hello-world example. That is, just the following in a .jl file:

function @main(args::Vector{String})::Cint
    println(Core.stdout, "Hello, World!")
    return 0
end

Compilation works fine, and I can see that the generated .exe depends on libjulia.dll and libjulia-internal.dll. So, put these two right next to the .exe such that they are found.

When I run the .exe, it errors out. Could get the following message (when I double-click the .exe instead of calling it from bash/cmd):

Sorry for the German. It says that the entrypoint with that very cryptic name cannot be found in libjulia-internal.dll. Any ideas what is going wrong?


Edit 1: went deeper into analysing dependencies. libjulia-internal depends on ...\mingw64\bin\libstdc++-6. So, I happened to have mingw64 installed / on the path and was not aware that this was required for juliac. For some reasons I had da very old version of it installed (from 2012 or so), but also changing to a recent one (14.2.0) does not solve the problem (but a different entrypoint is not found). For this version, it does not have available:

  • std::__once_functor
  • std::__get_once_mutex()
  • std::__set_once_functor_lock_ptr(std::unique_lock<std::mutex>*)

I then found the sentence “The msys2 libstdc+±6.dll appears to not have the needed symbols while the one distributed with Julia does.” in this thread. So, due to my installation of mingw64 being on the path, these libs are loaded.

If I remove my mingw64 installation from the PATH, four mingw64-libs are not found (atomic-1, gcc_s_seh-1, winpthread-1, stdc++-6). If I use those from the /bin folder of julia, I get a step further, namely calling the .exe providing console output about what else is missing (funnily I cannot find that these are missing using a dependency analyser on my .exe).

In the end, I get the .exe for the hello-world example to work (:slight_smile:) if these libs from <julia-root>/bin are available (besides the hello.exe):

  • hello.exe (1.648 MB)
  • libatomic-1.dll (0.256 MB)
  • libgcc_s_seh-1.dll (0.932 MB)
  • libjulia-internal.dll (14.556 MB)
  • libjulia.dll (0.214 MB)
  • libopenlibm.dll (0.523 MB)
  • libpcre2-8.dll (0.802 MB)
  • libstdc+±6.dll (25.018 MB)
  • libwinpthread-1.dll (0.323 MB)

For all binary-size guys out there, that’s 42.6 MB of libraries (almost exclusively made up by the two highlighted-in-bold-font ones).


Edit 2: I was calling the .exe from bash (on mingw64) before. Then the above libs are sufficient. If called from cmd/powershell, it complains about two other libs not being available, but still prints the “Hello world!” to the console. These libs are:

  • libgmp-10.dll (1.054 MB)
  • libmpfr-6.dll (2.505 MB)
5 Likes

First, a question: is there any way of finding out what libraries (from <julia-root>/bin) are needed for a given juliac-compiled binary? Either by juliac-related tooling, or then afterwards by analysing the binary?


So, next step: move from an .exe to a library (.dll on Windows). Changed the file to now just be a Base.@ccallable function say_hello()::Cint, everything else is the same. Compilation worked (just using --output-lib), but when I want to dlopen the library (from julia, of course :wink:), I get a popup-error-message again stating that entrypoint ijl_gc_small_alloc is not available. Any hints on what happened now and where to start to make the library work?

Edit: Forgot the additional --compile-ccallable argument. But still, when trying to load the library in julia, I now get the message about a new missing entrypoint named ijl_autoinit_and_adopt_thread.

However, when calling the library from Go, for example, it works. :thinking:

1 Like

Yeah, it’s still quite hard and on the bleeding edge right now. Julia dynamically links to lots of libraries — both at startup and dynamically loaded. A few quick notes:

  • PackageCompiler has the smarts to get the rpaths right and gather all the artifacts and other libraries. The current juliac.jl script doesn’t incorporate all of that, but the --relative-rpath flag can get you a little closer. This is a very good reason to incorporate juliac.jl into PackageCompiler.
  • Using --trim on nightly is currently broken for non-trivial use due to trimming: no way to `ccall` into a dynamically-computed library name · Issue #57707 · JuliaLang/julia · GitHub. The v1.12 RC (juliaup’s +beta channel currently) will probably get you farther.
1 Like

If your functions are simple enough you may find StaticCompiler.jl as an alternative.

I am also very interested in this and actively trying to get a library to trim and distribute. I am currently able to generate the “.so” file and have filtered out the required libraries “by hand” (going through one-by-one in the RPATH and checking if renaming them breaks the executable). Now when I try to run a binary compiled with the library on another machine I am still missing the artifacts, so I’m trying to understand how PackageCompiler.jl includes them so that it’s portable… working on that right now. It seems to me that we can just copy over the required artifacts to a local dir (just like the RPATH libraries), but I’m not sure if I have to tell julia still where to look.

I was able to strip out libstdc+±6.dll by the way, which seems to take up a majority of your memory. I still have BLAS bundled though (35MB), and getting rid of that is turning out to be tricky, for instance because it is challenging to track from where BLAS is actually called.

I’m similarly trying to figure out (by reverse engineering PackageCompiler, among other things) what the minimum set of DLLs is for a given program. I’d be interested in knowing what the minimum set of these is for the Julia language to fully work – I don’t believe it is the full distro you get on a basic juliaup install. The test case would be a minimal shell application that links to libjulia and provides a REPL-like experience that would include adding packages, eval, and AOT/JIT compilation. And flag those that you could take away if you ran strictly on the interpreter.

I have not been able to find an authoritative list or tool (other than PackageCompiler, which is doing something a little different) with this information.

Depending on your use case, this might work: put your julia code inside an AppBundler.jl app and have your library call that. No trim functionality, unless you delete files manually.

I have now managed to also compile it and run everything on a clean podman VM. For the artifacts, as a crutch I also compile with PackageCompiler.jl and then just copy over the artifacts that are bundled there. Probably I could go in and figure out how PackageCompiler.jl constructs the artifacts bundle and reuse that code.

I think in my many follow-ups and edits, my current pain point was lost.

So I can compile the “hello world” example, but when trying to load the library in julia, I get an error about a missing entrypoint named ijl_autoinit_and_adopt_thread.

However, when calling the library from Go, for example, it works. What happens when Libdl.dlopen-ing (not even ccall-ing) a shared library in julia? Why is this entrypoint called (and/or by whom)?

(“But why calling it from julia?!”, you might ask. Because tests and “validation scenarios” will be available for the julia-code version, and I will want to run them again against the juliac-generated library to ensure that it works exactly the same.)


Edit: one step further. Noted that I used the release-version of julia (1.11.x) to load and call the juliac-generated DLL. So, when also using nightly to call the generated DLL, I can load it (Libdl.dlopen) and get the symbol (Libdl.dlsym). But when I then call the function, I get a fatal crash.

Here’s a minimal example:

libmwe.jl:

Base.@ccallable function answer()::Cint
    return 42
end

Compile using:

julia.exe ../share/julia/juliac/juliac.jl --output-lib libmwe --experimental --trim --compile-ccallable libmwe.jl

In the julia REPL (in the directory where the .dll is located), execute:

 @ccall "libmwe.dll".answer()::Cint

This throws the following error with trace:

[3980] signal 22: SIGABRT
in expression starting at REPL[1]:1
crt_sig_handler at C:/workdir/src\signals-win.c:99
raise at C:\WINDOWS\System32\msvcrt.dll (unknown line)
abort at C:\WINDOWS\System32\msvcrt.dll (unknown line)
jl_init_threadtls at C:/workdir/src\threading.c:334
ijl_adopt_thread at C:/workdir/src\threading.c:447
.text at C:\julia-nightly\bin_compiled\libmwe.dll (unknown line)
top-level scope at .\REPL[1]:1
jl_toplevel_eval_flex at C:/workdir/src\toplevel.c:762
__repl_entry_eval_expanded_with_loc at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:304
jl_apply at C:/workdir/src\julia.h:2375 [inlined]
jl_f_invokelatest at C:/workdir/src\builtins.c:877
toplevel_eval_with_hooks at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:311
toplevel_eval_with_hooks at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:315
toplevel_eval_with_hooks at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:308 [inlined]
eval_user_input at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:333
repl_backend_loop at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:460
#start_repl_backend#41 at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:435
start_repl_backend at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:432 [inlined]
#run_repl#50 at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:671
run_repl at C:\workdir\usr\share\julia\stdlib\v1.13\REPL\src\REPL.jl:657
jfptr_run_repl_28061.1 at C:\julia-nightly\share\julia\compiled\v1.13\REPL\u0gqU_fBDF2.dll (unknown line)
run_std_repl at .\client.jl:481
jfptr_run_std_repl_75693.1 at C:\julia-nightly\lib\julia\sys.dll (unknown line)
jl_apply at C:/workdir/src\julia.h:2375 [inlined]
jl_f_invokelatest at C:/workdir/src\builtins.c:877
run_main_repl at .\client.jl:502
repl_main at .\client.jl:589 [inlined]
_start at .\client.jl:564
jfptr__start_43814.1 at C:\julia-nightly\lib\julia\sys.dll (unknown line)
jl_apply at C:/workdir/src\julia.h:2375 [inlined]
true_main at C:/workdir/src\jlapi.c:971
jl_repl_entrypoint at C:/workdir/src\jlapi.c:1138
mainCRTStartup at C:/workdir/cli\loader_exe.c:58
BaseThreadInitThunk at C:\WINDOWS\System32\KERNEL32.DLL (unknown line)
RtlUserThreadStart at C:\WINDOWS\SYSTEM32\ntdll.dll (unknown line)
Allocations: 1 (Pool: 1; Big: 0); GC: 0

I double-checked, and calling the answer function in that library from a Go programm works perfectly fine. The Go code for reference:

package main

import "fmt"

/*
#cgo LDFLAGS: -L. -llibmwe
int answer();
*/
import "C"

func main() {
	answer := C.answer()
	fmt.Println("This is Go. Value returned by answer: ", answer)
}

Edit 2: just opened an issue: Library generated with juliac errors when called from julia itself, but works when called by other program · Issue #59237 · JuliaLang/julia

Update:

Julia not being able to call juliac-compiled library: See the progress on the issue linked above:

This is a known limitation currently, as each system image thinks it’s the only one that should be loaded. Soon we will have a workaround for this by allowing you to give a library its own private copy of the julia runtime.

juliac vs. binding partitions: I run into the same problems as reported here: Segmentation fault in executable after compilation with juliac including --trim --experimental arguments - General Usage - Julia Programming Language.

  • As long as I have a simple example that does not rely on other packages (no using or import statements), I can use --experimental --trim.
  • As soon as I rely on other packages (JSON.jl in my PoC), I cannot use --trim anymore. Errors similar to the ones reported in the referenced thread occur.
  • This seems to be due to a regression between the two experimental features “trimming during binary generation” and “binding partitions”.
  • I can compile and successfully run (on Windows and in a Docker container under ubuntu:latest) examples relying on other packages, just without --trim and thus a ~200 MB instead of 1.8 MB library. But for my PoC, that’s OK for the moment.
3 Likes

Another update, this time about Artifacts. I think to remember I read something about juliac and Artifacts, and that PackageCompiler has functionality to collect all required Artifacts – but don’t remember where. Any pointers are welcome, thanks!

Artifacts are the binary dependencies (compiled shared libraries, mostly) some packages rely on. These are automatically downloaded to a dedicated folder (something like <user-folder>.julia/artifacts), and the julia runtime knows where to look for them.

Now, there’s not much documentation on this; Artifacts · The Julia Language is quite minimalistic and the referenced section about Pkg is intended for package devs. There is some more explanation in Custom binaries · JuMP.

When I use a package which requires artifacts (my example is jump-dev/HiGHS.jl) and use juliac to generate a binary, upon execution I get the error:

ErrorException("`ccall` requires the compiler")

Now:

  • What is meant by the “compiler”? Initially I thought libLLVM* (which I remove due to its size) or a C compiler such as gcc (which I do have available). → But it seems to be libjulia-codegen* AND libLLVM*. Together, they are 218MB and, what concerns me more, make up all the dynamic “interpreted”/JIT-compiled machinery of julia, which I exactly do NOT want in a fully pre-compiled binary.
  • Why is it needed for a ccall?
  • How do I identify all required artifacts, and where do I put them (under Windows and Linux), absolute and relative to my juliac-generated binary?
    • I can run the “MWE” in juliac-compiled form, when staying on the build machine with the auto-downloaded artifacts stil in place.
    • Would just be good to know which artifacts are actually downloaded and used, where they are, and if I could package them into a portable “distribution” somehow.
1 Like

@RomeoV, did you continue with this activity? Did it lead anywhere?

BTW, is there any central roadmap / overview on binary generation (i.e. what I assume a combination of juliac and PackageCompiler would be in the future)? Just to see where it’s at, known limitations, what is (roughly) going to come at what point in time, how one can help/contribute, etc.

2 Likes

Yes, I have managed to make a self-contained library that can be relocated.
You can find the reference here: GitHub - RomeoV/RunwayLib.jl

In essence, my build and bundling steps are as follows:

  • Write your normal julia code. Make Base.@entrypoints (example). Write tests that test your code with JET.@test_opt and fix all tests, possibly by forking upstream repositories and fixing things. Depending on what you’re doing, this can be easy or challenging. It was challenging for me.
  • Make a new dir next to src, I call it juliac. Put a Makefile with something like
    COMPILED_DIR = MyLibCompiled
    LIB_DIR = $(COMPILED_DIR)/lib
    
    $(LIB_DIR)/libmylib.so: loadmylib.jl Project.toml Manifest.toml $(SRC)
        @mkdir -p $(LIB_DIR)
        JULIAC=$$(julia -e 'print(normpath(joinpath(Sys.BINDIR, Base.DATAROOTDIR, "julia", "juliac", "juliac.jl")))' 2>/dev/null || echo "") ; \
        $(JULIA) --project=. --depwarn=error "$$JULIAC" --experimental --trim=unsafe-warn --compile-ccallable --output-lib $(LIB_DIR)/libmylib loadmylib.jl --relative-rpath
    
    Manifest.toml: Project.toml ../Project.toml
        -rm -f Manifest.toml
        $(JULIA) --project=. -e 'using Pkg; Pkg.instantiate()'
        @touch $@ # Pkg.instantiate doesn't update the mtime if there are no changes
    
    Notice the --relative-rpath, which will allow us to put all the required libraries next to each other.
    Remember to run juliaup override set 1.12 in the directory.
  • Manually write a header file, and add
    # Create include directory with header file
    $(INCLUDE_DIR)/libmylib.h: libmylib.h
    	@mkdir -p $(INCLUDE_DIR)
    	cp libmylib.h $(INCLUDE_DIR)/
    
    @tim.holy has started automating this: GitHub - JuliaInterop/JuliaLibWrapping.jl: Generate language-bindings for Julia-generated shared libraries
  • Make a Project.toml in juliac that contains something like
    [sources]
    MyLib = {path = ".."}
    
    Also copy over any other custom sources from your main Project.toml file (it seems sources are not applied transitively…)
  • Make a simple loadmylib.jl file that just runs using MyLib
  • Run the compilation
    make all
    

Now it’s time to create the “bundle” that we can ship. This includes three aspects.

  1. ship all the required libraries (such as libjulia.so, libjulia-internal.so, libopenblas, etc) but try to strip out the libraries that we don’t need
  2. figure out which artifacts (share/julia) we have to ship
  3. make sure to compile on a machine where the GLIBC version is older than on your target deployment machine.
  • to bundle up all the libraries, you can do something like see EDIT below
    $(LIB_DIR)/.installed:
    	@echo "Creating directory structure..."
    	@mkdir -p $(LIB_DIR)
    	@echo "Copying Julia libraries..."
    	JULIA_LIB_DIR=$$($(JULIA) -e "print(joinpath(Sys.BINDIR, \"..\", \"lib\"))"); \
    	cp -r "$$JULIA_LIB_DIR"/* $(LIB_DIR)
    	@touch $(LIB_DIR)/.installed
    	@echo "Julia libraries installed to $(LIB_DIR)"
    
    in your Makefile.
    To then filter for only the required libraries, I have not found a reliable way. What I have done is make a small mainc file that you can execute and see if it runs, and then by hand start deleting libraries and see if mainc crashes. Through this process, I made a required_libraries.txt file and then filter the bundled libraries with
    $(LIB_DIR)/.cleaned: $(LIB_DIR)/.installed required_libraries.txt
    	@echo "Cleaning $(LIB_DIR)/julia directory based on required_libraries.txt"
    	@echo "Before cleanup: $$(du -sh $(LIB_DIR)/ | cut -f1)"
    	cd $(LIB_DIR)/julia && \
    	for file in *.so*; do \
    		[ -f "$$file" ] && \
    		stem=$$(echo "$$file" | sed 's/\.so.*/.so/') && \
    		! grep -q "$$stem" ../../../required_libraries.txt && \
    		rm -f "$$file" || true; \
    	done
    	@echo "After cleanup: $$(du -sh $(LIB_DIR)/julia/ | cut -f1)"
    	@touch $(LIB_DIR)/.cleaned
    
    I would warmly welcome a better way to do this.
  • to figure out which artifacts to bundle, I do something equally terrifying. see EDIT below I use PackageCompiler.jl to create a bundled version of my code, and then just copy over the share directory that PackageCompiler comes up with. Probably it’s possible to directly use PackageCompiler.bundle_artifacts, but I haven’t dug into it enough yet.
  • finally, you can compile everything first locally, and then on a CI machine running e.g. a ubuntu:22.04 container. I have had trouble getting everything to run under ubuntu:20.04, unfortunately. For examples, check my juliac workflow with relocation test or my python packaging (glibc) workflow.

Then, when this is all done you can even write a python interface to the c_api of your code and package it up as a python package, see e.g. here.
I am currently in the process of streamlining this.

Happy to have more discussions on this, and am looking forward to each step becoming more automated.

EDIT: It seems I have also found a way to bundle the libraries and artifacts more easily with PackageCompiler.jl. I think something like this should do the trick:

using PackageCompiler
ctx = PackageCompiler.create_pkg_context(".")
PackageCompiler.bundle_artifacts(ctx, ENV["COMPILED_DIR"]; include_lazy_artifacts=false)
stdlibs = PackageCompiler.gather_stdlibs_project(ctx)
PackageCompiler.bundle_julia_libraries(ENV["COMPILED_DIR"], stdlibs)
for (root, _, files) in walkdir(ENV["COMPILED_DIR"]), file in files
  (contains(file, "libjulia-codegen") || contains(file, "libLLVM")) && rm(joinpath(root, file); force=true)
end
15 Likes

Also, FYI trying to build with musl libc for better compatibility seems to be a red herring, in case someone else was thinking in this direction.

Wow, thanks! Quite a lot of manual steps, though. But nice that it is basically doable, and that PackageCompiler seems to provide a good portion of it already.

One question though: You do remove the libjulia-codegen and libLLVM libs, which I have found to be required when artifacts (a third-party dynamically loaded library) are called from the compiled code. Or is this resolved by

just copy over the share directory that PackageCompiler comes up with

or

PackageCompiler.bundle_artifacts(ctx, ENV["COMPILED_DIR"]; include_lazy_artifacts=false)

respectively?

Thanks for the heads up! A colleague wanted to switch to an Alpine base image and I intuitively said this would most probably cause problems due to musl :sweat_smile:.