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_lock ← jl_unique_gcsafe_lock ← jl_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:
- You don’t have to call it. A top-level branch that is never taken —
if 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.
- 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:
cconvert → unsafe_convert → GC.@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.