Cross-language LTO with Base.llvmcall: getting past the "IR at every callsite" wall

I’ve been parked hard on Base.llvmcall for a while and I can’t leave it alone. At first I was cruising — “oh man, this replaces everything; I’ll just inline every FFI call and let Julia LTO the whole thing.” Then I tried it at scale and the dream broke: llvmcall embeds the entire IR string literal at every callsite. Wall of text, blown-up code size, done. I shelved it — but I’ve got an idea. Hear me out.

The dream: embed C LLVM bitcode into the wrapper and let Julia’s own codegen inline it — cross-language LTO, at compile time. Two snags:

  1. llvmcall needs its IR as a compile-time-constant literal (the (module_ir, entry_name) string-tuple form). A const you fill in __init__ is invisible to codegen, so that route’s out.
  2. You don’t want the library’s whole optimized module — or a wall of IR — sitting at every callsite (this is the wall that parked me because julia crashes on the size of the IR and it looks ugly even if julia can embed it all).

The reality: slice the library’s optimized bitcode down to one function — its definition plus declares for what it references — into a sister .ll, then pull it in with @generated so the wrapper source stays clean while llvmcall still gets its literal:

@generated function xxh64(p::Ptr{UInt8}, n::Csize_t)
    ir = read(joinpath(@__DIR__, "slices", "xxh64.ll"), String)   # jargon lives here
    :(Base.llvmcall(($ir, "XXH64"), UInt64, Tuple{Ptr{UInt8},Csize_t}, p, n))
end

Now XXH64’s actual instructions get inlined into the caller and optimized as one unit — the C isn’t a call anymore, it’s just IR. The literal blowup is gone because each wrapper carries only its own one-function slice, not the whole module × every callsite.

Follow-up to my post from a couple weeks back. The @generated route is wired into my generator now and running at scale, so here’s the report: how it works, real numbers, and one landmine I hit that I haven’t seen documented anywhere — executing llvmcall’d IR inside a precompile worker can deadlock it permanently. If you only read one section, read that one.

Where it landed

The slicing + @generated combo from the last post is now what my wrapper generator emits. Live on Lua 5.4.7: 209 functions sliced, 190 call sites emitted across 189 functions, and the wrapper passes the full deep test (callbacks, coroutines, luaL_error longjmp across a Julia frame, bytecode roundtrip, GC stress). code_typed on a wrapped function shows the C body spliced straight into the caller — the C isn’t a call anymore, it’s just IR, and LICM will happily hoist it out of your loop like it was Julia code all along.

But the emitted shape grew a brain since the sketch in my first post, because the naive version has a failure mode that will eat someone’s afternoon.

The landmine: llvmcall + precompilation = a silent, permanent deadlock

Here’s a minimal package. Precompiling it on 1.12.6 never finishes — no error, no timeout, the worker just parks at 0% CPU forever:

module Wedge
using Libdl
const SO = "/path/to/liblua.so"        # any C library you dlopen at runtime
const IR = """
declare i32 @lua_gettop(ptr)
define i32 @probe(ptr %L) alwaysinline {
  %r = call i32 @lua_gettop(ptr %L)
  ret i32 %r
}"""
probe(L) = Base.llvmcall((IR, "probe"), Cint, Tuple{Ptr{Cvoid}}, L)

if ccall(:jl_generating_output, Cint, ()) == 1
    dlopen(SO, RTLD_LAZY | RTLD_GLOBAL)             # doesn't help!
    L = ccall((:luaL_newstate, SO), Ptr{Cvoid}, ())
    probe(L)                                        # ← worker parks here, forever
end
end

Stack of the wedged worker: pthread_mutex_lockjl_unique_gcsafe_lockjl_emit_codeinst_to_jit_impl, single thread, nobody visibly holding the lock. I bisected the boundary:

  • llvmcall with no external declares during precompile: fine.
  • llvmcall declaring a process-image symbol (libc cos): fine.
  • llvmcall declaring a symbol from a dlopened library: permanent deadlock. dlopening the library inside the worker first changes nothing.

Two extra twists that make it nastier than it looks:

  1. You don’t have to call it. A top-level branch that is never takenif jl_generating_output() && some_false_thing; probe(L); end — still wedges, because the toplevel thunk gets compiled and inference reaches the llvmcall and emits it anyway.
  2. It’s not specific to @generated. The plain const IR + ordinary function shape from my first post hits it identically. Any package that ships this pattern is one PrecompileTools workload (or one precompile(f, ...) directive) away from hanging its consumers’ builds.

The fix: let the generator decide, at generation time

This is where @generated stops being a convenience and becomes load-bearing. The generator body runs in the process that’s compiling — so it can ask “am I a precompile worker?” and splice a different body. A runtime if can’t do this (inference reaches both arms — see twist #1). A const can’t do this. Only a generated function gets to make a per-process, per-context decision about what the method is:

const _SLICE_lua_gettop = joinpath(@__DIR__, "slices", "lua_gettop.ll")
isfile(_SLICE_lua_gettop) && include_dependency(_SLICE_lua_gettop)

@generated function _TIER1_lua_gettop(__ptr_L::Ptr{lua_State})
    # llvmcall is opportunistic: any doubt resolves to the ccall body.
    if ccall(:jl_generating_output, Cint, ()) == 1 || !isfile(_SLICE_lua_gettop)
        return :(ccall((:lua_gettop, LIBRARY_PATH), Cint, (Ptr{lua_State},), __ptr_L))
    end
    ir = read(_SLICE_lua_gettop, String)
    return :(Base.llvmcall(($ir, "lua_gettop"), Cint, Tuple{Ptr{lua_State}}, __ptr_L))
end

function lua_gettop(L::Any)::Cint
    __cc_L = Base.cconvert(Ptr{lua_State}, L)
    __ptr_L = Base.unsafe_convert(Ptr{lua_State}, __cc_L)
    GC.@preserve __cc_L begin
        return _TIER1_lua_gettop(__ptr_L)
    end
end

Inside a precompile worker the kernel is a ccall — precompiles clean, workloads run, correct results. At runtime the generator runs again and the kernel is the llvmcall. I verified both directions: the consumer package that used to deadlock now precompiles to completion, and code_typed in a fresh session shows llvmcall. (If a precompiled specialization does get cached from a workload, you keep the ccall body for that signature — correct, just not inlined. Fine by me: my design rule is that llvmcall is a passenger, never the driver. Anything ambiguous demotes to ccall and the wrapper keeps working.)

The isfile guard is the same philosophy: ship or relocate the wrapper without its slices/ directory and it degrades to a plain ccall wrapper instead of refusing to load.

What @generated buys over the const shape, concretely

  • No load-time I/O. 190 slices used to mean 190 read()s at using and ~1.5 MB of strings resident whether you called anything or not. Now: zero until first call, and only for functions you actually use.
  • No double storage. const IR = read(...) serializes the IR string into the .ji on top of the .ll files you ship. The generated version stores paths.
  • Staleness still works. include_dependency is content-tracked on 1.11+, so editing a slice still invalidates the cache; the read living in the generator doesn’t change that.
  • The precompile deadlock becomes unreachable by construction — which, given twist #1 above, I don’t know how to guarantee any other way.

Sharp edges checklist (things that cost me a debugging session each)

  • An unresolved declare does not error. ORC prints “Symbols not found” and blocks forever on first call. Pre-flight every declared symbol with dlsym against the library handle before you ship a slice, and demote to ccall on a miss.
  • Mirror ccall’s GC discipline exactly: cconvertunsafe_convertGC.@preserve around the call. And Ref{T} is ptr addrspace(10) to llvmcall while C IR wants plain ptr — convert to Ptr{T} explicitly.
  • Key your slice files and consts on the mangled symbol, never the pretty name. Two C symbols can collapse to one Julia name, and because llvmcall resolves at codegen, the loser fails on first call, long after wrap time.
  • Don’t embed internal constants unless they’re unnamed_addr. The .so keeps its copy and the JIT gets a second one at a different address — any address-identity check silently diverges between your inlined calls and your ccall calls. Mutable statics: never embed, bind by symbol.

Everything here is generated by RepliBuild.jl — slicing lives in src/IRGen/Slicer.jl, the kernel emission in src/Wrapper/C/GeneratorC.jl. The deadlock repro is small and self-contained if anyone from the compiler side wants it as an issue — arguably jl_emit_codeinst_to_jit should error there, not hang.

please contribute in your own words, and don’t just paste AI text.

Its mine but refined, I wouldn’t make it look this nice for markdown.

It sure does seem to have done a lot more load bearing work than that :stuck_out_tongue:

That’s just the shape of things these days. :rofl:

Yeah Im not hiding any of that at all, Im no top flight engineer but Fable 5 was able to unblock alot of this for me, I had the CONST all wrong and how I wanted to slice each function and its deps into another .ll that the function keeps with it, now it looks really nice with no IR string embedded. Its lonely in the julia space so building with AI just seems natural now.

there is no problem at all building with AI, reviewing code with AI, etc.

the only thing I am challenging is pasting walls of AI-generated prose into the forum. it’s much nicer to interact with humans

I get what your saying, If you as a person want to spend time to read something then it should be from another person. That person should take the time to learn what was generated by an LLM and present a readable explanation. I agree, regardless if an LLM generated the code you should be able to understand it and argue its functionality. Imma make a point to write updates personally and mark what part the LLM generated that I had to read to understand rather than write myself or what was optimized by AI and why, I do post the blocks sometimes because I do honestly feel like the AI explains it better for the overall better than I would for each case because even though I understand the endpoints I dont always understand the insane regex stuff fable 5 cruises through like its nothing.