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.

1 Like