[ANN] RepliBuild.jl - A full C/C++ interop toolkit for tiered FFI generation

RepliBuild.jl, a declarative compiler bridge designed to automatically generate hyper-optimized FFI bindings directly from C and C++ source code.
​Instead of relying on fragile header parsing, RepliBuild drives a local compilation pipeline and extracts structural DWARF debug metadata directly from the generated objects. It uses this pure structural data to automatically synthesize a tiered FFI boundary, generating the safest and fastest possible dispatch for every function.
​Tiered FFI Generation
​RepliBuild classifies and routes functions automatically based on their complexity:
​Zero-Cost Abstractions (Base.llvmcall): When cross-language Link-Time Optimization (LTO) is enabled, the compiler emits LLVM Bitcode (.bc). The generated Julia wrapper dynamically loads this bitcode at parse-time, routing execution entirely through Base.llvmcall instead of standard ccall. This allows Julia’s JIT to seamlessly inline C/C++ code directly into Julia hot loops.
​Tier 1 Safe ccall & GC Preservation: For standard FFI boundaries, it emits highly structured ccall bindings. It automatically generates idiomatic mutable struct wrappers equipped with GC-traced finalizers and Base.unsafe_convert methods to guarantee memory safety across the boundary.
​Tier 2 MLIR / AOT Thunks: For complex C++ paradigms (like virtual method dispatch, packed structs, and large struct returns), execution is routed transparently through an MLIR JIT or statically compiled Ahead-Of-Time (AOT) thunks.

Declarative Workflow & Caching
​There is zero manual wrapper boilerplate required. You define the project in a single TOML file:
​replibuild.toml

[dependencies.lua]
type = "git"
url = "https://github.com/lua/lua.git"
tag = "v5.4.6"

[compile]
aot_thunks = true

[link]
enable_lto = true

[wrap]
language = "c"

On the julia side

# Resolves dependencies, builds, wraps, and globally caches the artifacts
RepliBuild.register("replibuild.toml") 

# Instantly loads the cached module and LLVM bitcode
Lua = RepliBuild.use("lua_wrapper") 

RepliBuild features aggressive project-level content hashing. Successive calls to RepliBuild.use() load the cached payload instantly, completely bypassing the compilation tax and dropping Time-To-First-Plot (TTFP) to near zero.
​Current Status
​The architecture is heavily modularized into independent C and C++ generators:
​The C Pipeline is highly stable, defaults to LTO, and automatically falls back to Julia’s internal LLVM (Clang_unified_jll) to guarantee strict llvmcall compatibility.
​The C++ Pipeline natively handles deep layout constraints like bitfields, unions, and template instantiation. (Note: Advanced C++ bridging currently requires a local LLVM 21 toolchain).
​You can find the repository, documentation in the julia registry or the public repo.
​Feedback, edge-case testing, and issue reports are highly welcome! This isnt a half baked toy. I worked on this for a year now with prior experience with the mlir jit. Have fun.

Also started working on the rustc generator will commit the first skeleton of the generator, rust dwarf is alot cleaner and I think RepliBuild.jl and julia will eventually handle rust better overall.

EDITED: The rustc intergration went really smooth, it will be awhile before any wrapping of native rust without the export shims like RepliBuild.jl can do for c and cpp, the rust compiler is very different. The v2.5.0 and up will be just stabalizing cpp features and hardening the rust intergration and anything the julia community can poke holes in that I can patch right away.

CCing @grasph and @peremato here, who have also done work regard to auto-generation of C++ bindings.

Wanna colaborate, you could send some details of what your working with, if its dwarf then I had to write a custom dwarf parser because julia just doesnt have one… Im intrested, do you compile into a dialect or merge ir anywhere im your pipeline. If i can help just let me know.

Hello @obsidianjulua,

Very interesting work. The work Oliver is mentioning is WrapIt!. It has been used to wrap large C++ framerwork, like CERN ROOT (GitHub - grasph/wrapit: Automatization of C++--Julia wrapper generation · GitHub), Geant4 (GitHub - JuliaHEP/Geant4.jl: Julia bindings to the Geant4 simulation toolkit · GitHub), and Pythia8 (GitHub - JuliaHEP/PYTHIA8.jl: Pythia8 Julia interface · GitHub).

The Julia/C++ interface is performed using CxxWrap. The WrapIt! tool parses header files using LLVM/Clang to generate the wrapper code for CxxWrap.

I see that with your tool, we just provide a C++ project directory. How does the tool know how the C++ code is built and which products must be bound to Julia ?

Philippe.

To answer your questions: RepliBuild does not try to guess or reverse-engineer an existing build system like CMake. Instead, it acts as the build orchestrator and metadata extractor simultaneously, driven entirely by a declarative replibuild.toml file.
​Here is how it handles those two steps:
​1. How does it know how the C++ code is built?
The user defines the build explicitly in the replibuild.toml. You point RepliBuild at local source files or a remote Git repository, and supply the necessary include paths, compiler flags, and definitions.
​RepliBuild essentially acts as a localized compiler driver. It invokes clang (or rustc for Rust projects) on those sources directly. We bypass the host’s CMake/Make systems to guarantee we have full control over the compilation flags—specifically, ensuring that rich DWARF debug metadata is generated during the build.
​2. How does it know which products must be bound to Julia?
This is where RepliBuild fundamentally diverges from the header-parsing approach.
​Instead of parsing the C++ text headers via Clang ASTs to generate a CxxWrap C++ shim, RepliBuild analyzes the DWARF debug metadata of the compiled objects. The DWARF tree contains the absolute, ground-truth structural layout of the code exactly as the compiler synthesized it: including vtable offsets, struct padding, bitfields, and template instantiations.
​RepliBuild reads this structural data and automatically generates pure-Julia ccall or llvmcall wrappers for the public ABI. If something is present in the DWARF metadata (and not explicitly filtered out in the TOML configuration), it gets mapped into the Julia TypeRegistry and bound automatically.
​Because we have this deep structural data, we don’t need an intermediate C++ wrapping layer—the generated Julia code can marshal the memory layout perfectly and execute cross-language LTO directly through Julia’s JIT.
​I would love to hear your thoughts on this DWARF-first approach compared to WrapIt!'s header parsing! Also this just lets you force another build system to enforce debug flags and RepliBuild just wrap the binary, things are different in the IR, if your ever going to use pure julia then all the data has to be exposed to julia jit to line all the code and wrase the ffi boundry, if your relying on just header parsing then youll never wrap from julia and always need to write code in another language which is just architecturally wrong.

This does create executables, shared libs, create projects, even register wrappers but I promise those are just supplimentary features to secure the source. This is not a build system.

RepliBuild Hub

Community-maintained registry of replibuild.toml configs for popular C/C++ libraries. Search, fetch, and build wrappers directly from Julia — no manual setup required.

Repository: github.com/obsidianjulua/RepliBuild-Hub

Usage

using RepliBuild

# Search available packages
RepliBuild.search("lua")

# Install and use — one call does everything
Lua = RepliBuild.use("lua")
Lua.luaL_newstate()

use() checks your local registry first. On a miss, it fetches the TOML from the hub, registers it locally, then runs the full pipeline: dependency resolution → compile → link → DWARF introspect → wrap → load.

Subsequent calls are cached — rebuild only happens when the TOML or source content changes.

Hub Repository Structure

RepliBuild-Hub/
  index.toml                    # package listing (used by search())
  packages/
    lua/
      replibuild.toml
    sqlite/
      replibuild.toml
    cjson/
      replibuild.toml

New: DAG Diff — Structural Mismatch Detection Between C++ and Julia IR

Added a DAG-based structural diff algorithm that compares C++ layouts (DWARF ground truth) against Julia’s inferred alignment rules. This extends the existing per-function heuristics in DispatchLogic.jl — heuristics catch the obvious cases (packed returns, unions, STL), while DAGDiff catches what point-wise checks miss: transitive layout drift through by-value containment chains.

Algorithm:

  1. Build C++ graph from DWARF metadata (struct sizes, member offsets, containment edges)
  2. Build Julia graph by computing min(sizeof(field), 8) aligned layouts from the same members
  3. Parallel walk — match nodes structurally, record size and per-member offset mismatches
  4. Propagate mismatches transitively through by-value containment (if Inner is packed and Outer contains Inner by value, Outer is also mismatched)
  5. Flag functions that pass or return mismatched types by value
  6. Topo-sort (Kahn’s algorithm) all thunk sites for safe lowering order — types before the functions that depend on them

Integration:

  • DAGDiff.needs_dag_thunk(symbol, result) queries the mismatch map — wrapper generators check this alongside existing heuristics, routing to MLIR thunks if either fires
  • Backward compatible: needs_dag_thunk(_, nothing) returns false when DAG diff is not computed
  • Wired into both C and C++ generator dispatch sites in GeneratorC.jl and GeneratorCpp.jl

Visualization:

  • export_dot(result, path) — Graphviz DOT export with mismatch color-coding (red = layout mismatch, orange = function needs thunk, gray = safe)
  • render_dot(result, path) — renders DOT to SVG/PNG/PDF via the dot command
  • Per-member offset annotations, containment edges, propagation edge coloring
  • Three view modes: :diff (both graphs overlaid), :cpp (DWARF only), :julia (inferred alignment only)

TOML configuration:

[wrap]
dag = true   # exports DAG graphs to <project_root>/dag/

When enabled, the wrap stage automatically exports diff.svg, cpp.svg, julia.svg, and diff.dot to a dag/ folder in the project root.

Files:

  • src/IRGen/DAGDiff.jl — New module (~780 lines): graph types, builders, diff algorithm, topo-sort, query API, DOT visualization
  • src/Builder/ConfigurationManager.jl — Added dag::Bool to WrapConfig
  • src/Wrapper/Generator.jl — DAG diff computed before wrapper generation; graphs exported when dag=true
  • src/Wrapper/C/GeneratorC.jl, src/Wrapper/Cpp/GeneratorCpp.jl — Dispatch sites augmented with needs_dag_thunk check
  • test/dag_test/ — 178 tests covering graph building, structural diff, transitive propagation, topo-sort, query API, DOT export, and a rendered gallery of 7 scenarios

Stress test results (73 functions, test/stress_test/):

  • 25 mismatches detected: 14 types (vtable offsets on polymorphic classes, compound struct padding, bool alignment, STL internals), 5 functions routed to thunks (compute_lu, compute_qr, compute_eigen, solve_ode_rk4, solve_ode_adaptive)

  • Transitive propagation working: uniform_real_distribution<double> flagged solely because it contains param_type by value.


New operations

jlcs.marshal_arg — Julia-aligned struct → C-packed value

Defined in: src/mlir/JLCSOps.td

Lowering: src/mlir/impl/JLCSPasses.cpp (MarshalArgOpLowering)

Reads a Julia-aligned struct through a pointer and reassembles its fields into a C-packed LLVM struct value ready to pass to an external function.

Before v2.5.6, FunctionGen.jl emitted this as an inline sequence of arith.constant / llvm.getelementptr / llvm.load / llvm.insertvalue operations directly in the thunk IR. That sequence was correct but verbose, hard to pattern-match, and scattered the layout-mismatch logic across generated IR text rather than in a verifiable dialect op.

jlcs.marshal_arg lifts the whole pattern into a single named operation that the MLIR verifier can check, the pass pipeline can recognise, and the DOT visualiser can annotate.

MLIR syntax:


%packed = jlcs.marshal_arg %ptr

{ memberTypes = [i32, f64], juliaOffsets = [0 : i64, 8 : i64] }

: (!llvm.ptr) -> !llvm.struct<packed (i32, f64)>

Lowering (MarshalArgOpLowering):

  1. Emit llvm.mlir.undef for the packed result type.

  2. For each member i:

  • Create an arith.constant for juliaOffsets[i].

  • llvm.getelementptr on srcPtr using i8 element type (byte-addressed).

  • llvm.load with alignment = 1 — unaligned because Julia’s padding may differ from C’s.

  • llvm.insertvalue at position [i] into the accumulator struct.

  1. Replace the op with the final struct value.

Effect on generated thunk IR: A 6-line-per-member inline block collapses to one line:

before (v2.5.5, 2 members)


%s_undef_1 = llvm.mlir.undef : !llvm.struct<packed (i32, f64)>

%off_1_1 = arith.constant 0 : i64

%field_ptr_raw_1_1 = llvm.getelementptr %val_ptr_1[%off_1_1] : (!llvm.ptr, i64) -> !llvm.ptr, i8

%field_val_1_1 = llvm.load %field_ptr_raw_1_1 {alignment = 1 : i64} : !llvm.ptr -> i32

%s_packed_1_1 = llvm.insertvalue %field_val_1_1, %s_undef_1[0] : !llvm.struct<packed (i32, f64)>

%off_1_2 = arith.constant 8 : i64

%field_ptr_raw_1_2 = llvm.getelementptr %val_ptr_1[%off_1_2] : (!llvm.ptr, i64) -> !llvm.ptr, i8

%field_val_1_2 = llvm.load %field_ptr_raw_1_2 {alignment = 1 : i64} : !llvm.ptr -> f64

%s_packed_1_2 = llvm.insertvalue %field_val_1_2, %s_packed_1_1[1] : !llvm.struct<packed (i32, f64)>

after (v2.5.6)

%packed_1 = jlcs.marshal_arg %val_ptr_1

{ memberTypes = [i32, f64], juliaOffsets = [0 : i64, 8 : i64] }

: (!llvm.ptr) -> !llvm.struct<packed (i32, f64)>

RepliBuild v3.0.1 — the inheritance-ABI + Tier-2-correctness release

No breaking changes. Wrappers are fingerprinted against the generator, so every
cached build regenerates automatically on the next use()/wrap().

C++ inheritance, end to end

  • Multiple inheritance: base subobject offsets extracted from DWARF, derived
    layouts flattened correctly, and <Derived>_as_<Base> upcast helpers emitted —
    methods of a non-primary base are now callable on derived objects with the
    correct this.
  • Virtual dispatch honors overrides: Tier-2 virtual method calls go through
    the vtable (new jlcs.vcall producer with exception-safe lowering) instead of
    statically calling the named class’s implementation. This also fixes virtual
    instance methods being entirely uncallable through Tier-2 dispatch.
  • Virtual inheritance: <Derived>_as_<VBase> upcasts resolve the vbase
    offset through the object’s vtable at runtime — correct for every dynamic
    type, diamond-proven (shared single base, overrides reached through the vbase
    vtable).

Tier-2 ABI correctness (found and fixed driving pugixml)

  • SysV small-struct classification: ≤16-byte register-class struct
    returns/args coerce per-eightbyte (INTEGER→i64, SSE→f64) instead of forcing
    sret — by-value handle returns like pugi::xml_node are now correct.
  • Nested packed structs no longer crash module load: dialect struct aliases
    are inlined inside LLVM struct bodies before translation.
  • JIT pre-flight guard: a module with an LLVM-incompatible type disables
    Tier 2 with a catchable, type-naming error instead of segfaulting the process.

Extraction & config robustness

  • DWARF parser: members following a nested type definition are no longer
    dropped; base-class offsets and virtuality extracted from inheritance DIEs.
  • discover(force=true) preserves hand-curated TOML sections
    ([types].templates, [wrap].varargs/macros/shim_headers/cstring_owned)
    instead of silently destroying them.

Ingest, honestly labeled (issue #4)

ingest() is experimental and C-only; C++ ingest now warns loudly at both
entry paths instead of emitting unusable wrappers, and the docs describe the
actual support matrix plus how to load and use a generated wrapper.

RepliBuild v3.3.2 — and a plainer re-introduction

The first post in this thread was dense, and rereading it five months later it
buried the part people actually ask me about: why there is an MLIR dialect
inside a binding generator. So this is less a changelog and more a second
attempt at explaining the thing.

What it does

You point it at C or C++ source. It compiles the library itself, then reads the
compiled binary’s debug info to find out what the compiler actually
produced — every struct’s real field offsets, every function’s real signature,
every vtable — and writes a Julia module from that.

The difference from Clang.jl, SWIG or bindgen is one step. They read the headers
and trust them. RepliBuild reads the object file. A header describes intent; the
binary is what your CPU is going to run. Most of the time those agree and it
doesn’t matter. When they don’t — packed structs, bitfields, small structs
passed in registers, anything C++ inheritance touches — a header-based tool
gives you a binding that looks correct and corrupts memory on the third call.

The other half of the design is what happens when it can’t prove a call is
safe. It refuses, at that call site, with a message explaining why, and the
other 5,000 functions in the library still work. I would rather hand you a
module with a hole in it than one that looks complete.

Three ways to make a call

Tier How Status
3 ccall into the .so The default. Every Hub config uses this.
2 An MLIR thunk The C++ cases ccall can’t express: virtual dispatch, large or packed struct returns, exception-safe calls.
1 Base.llvmcall on a per-function bitcode slice Experimental, C only, off by default.

The original post led with Tier 1 as the headline feature. That was optimistic.
It does work — Lua runs 190 of them, and it lets Julia’s JIT inline C into a hot
loop — but it is opt-in, C-only, and I am not calling it production. The tier
that actually carries C++ is Tier 2, which is the rest of this post.

The part nobody expects: MLIR

MLIR normally turns up in compilers and ML frameworks. Here it does something
small and boring, which is exactly why it works.

To call a C++ method from Julia, something has to shuffle the arguments into the
precise shape the C++ ABI expects: this first, small structs split across
registers, large ones passed by a hidden pointer, and a dozen other rules. Every
wrapper tool solves this somehow. The usual answers are to hand-write a C shim,
or to work it out at runtime from a signature string.

RepliBuild writes a tiny program that does the shuffling, and compiles it.

That program is written in a purpose-built MLIR dialect — ops like call this
virtual method through vtable slot 4
, marshal this Julia-aligned struct into
its C-packed form
— then lowered to LLVM and run. Four things fall out of using
an IR instead of generating C text:

  • The offsets in the marshalling code and the offsets in the Julia wrapper are
    the same DWARF numbers, read once. They cannot drift apart.
  • The ops carry verifiers, so a malformed thunk fails when the module is parsed
    rather than when you call it.
  • The x86-64 SysV rules live in one readable pass instead of being implied by a
    code generator.
  • The dialect is written to disk and the JIT registers debug info pointing at
    it — so gdb stops inside the generated MLIR by file and line, and
    disassemble /s interleaves the dialect ops with the machine code they became.
    When a foreign call goes wrong at 2am, that is the thing you want.

No optimization, no graph rewriting, no clever passes. It is a compiler IR doing
the one job that is normally done with string templates.

New in 3.3: those thunks now compile ahead of time

The cost of the above: that MLIR module had to be built and JIT’d in every
process
that loaded the wrapper. On a small library nobody notices. llama.cpp
has 3,686 functions, and it was 25 seconds of every single startup.

aot_thunks = true in the TOML compiles the thunks once, at build time, into a
companion .so. Loading the wrapper afterwards is a dlopen.

llama.cpp wrapper JIT AOT
wrapper load 24.83s 5.38s
test suite (33 tests) 35.4s 1.0s
JIT engines spun up at load 1 0

The flag had existed for a long time and was switched off in every package,
because it was broken in four ways that only appear at scale — the library I
developed it against has 283 functions and triggered none of them. If you like
that sort of thing the changelog has the full autopsy; the short version is one
regex that exhausted PCRE outright, two separate duplicate-symbol bugs, and an
rpath that let a vendored copy load two copies of a 41 MB library into one
process, which then abort at exit while tearing down each other’s state.

This was the most atrocious bug I have ever dealt with in my life.

That last one is the one worth passing on. The package’s own test suite was
green — 33/33, exit 0 — the entire time, because in the build tree the two paths
happen to name the same file. Only a consumer vendoring the artifacts ever saw
it. A generated wrapper isn’t proven by the tests that ship next to it.

What it looks like

llama.cpp is in the Hub now, and the reference example is a chat client built on
it:

using LlamaChat

list_models()               # what's on the box
load("qwen3-coder")         # load it and start talking
julia> load("qwen3-coder")
qwen3-coder  ·  32768 ctx  ·  /help for commands

>>> write a haiku about pointers
…
[9 prompt tok @ 43.2/s · 31 gen tok @ 20.1/s]

>>> /exit
(unloaded)

julia>

Replies render as markdown in the terminal, code blocks and all. /exit frees
the context and the weights before it returns, so the session you come back to
is holding nothing.

That package used to take 32.4 seconds to load. It now takes 0.24 — the
package, that is; the weights still take as long as the weights take.

Where I’m at, honestly

  • x86-64 Linux. ABI classification is SysV only. Win64 and AArch64 are not
    modeled, and I would rather say that than pretend otherwise.
  • C libraries need nothing but Julia — clang comes from a JLL and everything
    else runs in-process on Julia’s own libLLVM. C++ and Tier 2 need a system
    LLVM + MLIR install.
    It is by far the largest dependency in the project, and
    a C-only library never touches it. check_environment() tells you which tiers
    your machine supports.
  • Just over twenty libraries in the Hub — lua, sqlite, zlib, cJSON, box2d,
    curl, pcre2, imgui, blake3, llama.cpp and the rest. RepliBuild.search("json")
    to browse, RepliBuild.use("lua") to get a loaded module.
  • Parked, deliberately: Tier 1 llvmcall (works, unproven at library scale)
    and the old whole-module LTO payload (it embeds the entire linked module per
    call site, which does not survive contact with a real library).
  • The known-unbuilt list lives in the repo and stays honest. It is not short.

Holes, edge cases and “this library breaks it” reports are the most useful thing
anyone can send me. If a wrapper refuses a call it shouldn’t, that’s a bug I want
to hear about — the refusals are the design, but a wrong refusal is not.

I rewrote this with claude but I verified everything and most is verbatim from my recent release notes I spent all night making for the 3.3.1 AOT patch, much love.

ABI reconstruction + ABI adaptation compiler between Julia and foreign native code is a more accurate description, think of it as a way to call a cpp function using types extracted from dwarf and giving it to mlir to repack as a thunk that julia can call, rules for that are written once in the dialect and not in the julia wrapper or shimmed into the cpp source, were moving the IR around.

I really need help vendoring my dialect, I really cant figure this part out, Enzyme does it but its under tha same c api version that ships with julias llvm, this needs a seperate mlir version(latest) and i dont want users to have to build or AUR the mlir package for the small dialect its a issue for me.

If I have an existing C++ codebase for a simulation, and I want to extract components of that so that I can maintain component-level correctness assurances (avoiding wholesale translation and revalidation), will this allow me to pull out the specific components (and, as needed, their supporting class hierarchies), and collapse them into something instantiable and callable within a Julia runtime?

Its exactly why I built this

Nothing is translated. Your C++ is compiled by clang, and the Julia module is a calling convention over the resulting .so, with the marshalling derived from the DWARF the compiler emitted. There’s no ported logic to revalidate.

it’s a rebuild. RepliBuild compiles your sources itself with flags you declare in a TOML — it replaces your build for the subset you extract rather than consuming it. So it’s your validated source through a compiler you specify, not your validated binary. You’d re-establish on the built artifact; what you never do is revalidate a translation.

Selection is per translation unit, not per symbol — you name source files and exclude paths, and everything with debug info in what you compiled gets wrapped. Class hierarchies come through: base subobject offsets from DWARF, as upcasts so methods of a non-primary base get the right this, and virtual dispatch through the vtable so overrides are honored. Ctor/dtor pairs become GC-finalized handles, so objects are instantiable from Julia and destructed when Julia drops them. box2d, box2d3, imgui, tinyxml2, pugixml and llama.cpp are all wrapped this way in the Hub.

Two things that would stop you: x86-64 Linux only, and templates need their instantiations declared — an uninstantiated template emits no code, so it isn’t in the DWARF. On numerics-heavy code that’s usually the largest piece of work.

Tell me the library and I’ll do a local test case and upload it to the Hub vendored — thunks built, you git pull and it loads. Happy to walk you through the MLIR dialect setup separately; that part is cmake plus a script in the repo.

Im also only lowering to the llvm dialect, I havent experimented with lowering the JLCS dialect to a gpu backend yet but I really want to but thats the fast path stuff I can optimize later or try to use Enzyme when I close the opaque ccall into the dialect sorta thing.

You dont need extern c or anything, the pkg is marshalling the cpp sysv abi directly.

This is an example of wrapping a simulation cpp library, you fill out the toml, even the git repo if you want RepliBuild to pull it from git to a location. Then call build("replibuild.toml") the wrap it with wrap("replibuild.toml") the wrappers are completely generated by the replibuild.toml, 0 source edits, include(“wrapper.jl”) and start calling the library or build a pkg using the wrapper.

To instatiate templates do this for any in the replibuild.toml

[types]
allow_unknown_enums = false
strictness = "warn"
allow_unknown_structs = true
allow_function_pointers = true
templates = ["std::vector<int>", "std::string", "std::map<int, int>"]
template_headers = ["<vector>", "<string>", "<map>"]