Julia can debug MLIR, kinda

JIT’d thunks are debuggable in gdb, at source level

Break on a mangled thunk name and gdb stops inside the emitted MLIR, by file

and line, with list and disassemble /s working:

cd ~/Desktop/Projects/RepliBuild.jl
gdb -batch -nx \
  -ex 'set pagination off' -ex 'set confirm off' \
  -ex 'handle SIGSEGV nostop noprint pass' \
  -ex 'set breakpoint pending on' \
  -ex 'break _ZNK5Base15get_aEv_thunk' \
  -ex 'run' \
  -ex 'info symbol $pc' \
  -ex 'bt 5' \
  -ex 'x/6i $pc' \
  --args julia --project=. test/mi_test/verify.jl

I got the function setup now to use this and its simpler

rbdbg _ZNK5Base15get_aEv_thunk test/mi_test/verify.jl

This is the break and the julia program. The mlir execution engine has a default I didnt find till recently when I started emitting valid IR…

bool enableGDBNotificationListener = true;
bool enablePerfNotificationListener = true;

this emits the /.debug/jit/**objdump which GDB can step through from julia. Cool stuff, but this basically unlocks a very high level debugger for free working on marshaling thunks for Julia.

This is what wrapping C++ looks like using mlir thunks:

#include <iostream>

// Returns the greeting without printing it — Cstring back to Julia.
const char* hello_message() {
    return "Hello, World!";
}

// Prints the greeting, returns how many characters it wrote.
int hello_print() {
    const char* msg = hello_message();
    std::cout << msg << std::endl;
    return 13;
}

// Takes arguments from Julia: greets `name`, `times` over.
int hello_to(const char* name, int times) {
    for (int i = 0; i < times; ++i) {
        std::cout << "Hello, " << name << "!" << std::endl;
    }
    return times;
}

int main() {
    std::cout << "Hello, World!" << std::endl;
    return 0;
}

Then the julia side wrapper to call the thunks is so clean.

export main, hello_print, hello_message, hello_to

function main()::Cint
    ccall((:main, LIBRARY_PATH), Cint, (), )
end

function hello_print()
    # [Tier 2] Dispatch to MLIR JIT (Complex ABI / Packed / Union)
    return RepliBuild.JITManager.invoke("_mlir_ciface__Z11hello_printv_thunk", Cint)
end

function hello_message()
    # [Tier 2] Dispatch to MLIR JIT (Complex ABI / Packed / Union)
    return RepliBuild.JITManager.invoke("_mlir_ciface__Z13hello_messagev_thunk", Cstring)
end

function hello_to(name::Any, times::Integer)
    # [Tier 2] Dispatch to MLIR JIT (Complex ABI / Packed / Union)
    return RepliBuild.JITManager.invoke("_mlir_ciface__Z8hello_toPKci_thunk", Cint, name, times)
end

A foreign call is a compilation problem, so I compile it. Where a binding generator would paste a C shim or interpret a signature at runtime, I emit a small program in a purpose-built MLIR dialect, lowers it, and runs the result — ABI marshalling as first-class IR, which as far as I know nobody else does with MLIR. Nothing needs to be enabled and nothing links gdb: RepliBuild is the publisher of LLVM’s GDB JIT interface, and gdb reads the descriptor out of the inferior. clean() removes .debug, and it regenerates on the next JIT init.

spent 2 years to get to this point but Im very satisfied and love the language. Sorry the generator actually makes more code block comments than call sites but I havent bothered to change it and the generator makes a docstring per call site regardless.

I was able to wrap Llama.cpp and run a model close to native cxx through the wrapper including private exports to load and unload models, clear cahces basically the entire api surface not just curated hand written wraps, and I mean zero == 0 hand written edits to generate bindings other than my work in the actual generator…

is Linux guarded because Windows is just wierd.

The JLCS dialect (TableGen-defined, src/mlir/) models C/C++ interop semantics directly: !jlcs.c_struct\ types carrying explicit field offsets and packing, jlcs.ffe_call / jlcs.try_call\ (exception-safe invoke + landing pad), jlcs.vcall\ (vtable dispatch that honors overrides), jlcs.marshal_arg / marshal_ret (Julia-aligned ↔ C-packed), and constructor/destructor ops inside region-based RAII scopes for Itanium’s non-trivial by-value parameters.


func.func @_ZNK5Base25get_bEv_thunk(%args_ptr: !llvm.ptr) -> i32
    attributes { llvm.emit_c_interface } {
  %arg_ptr_1 = llvm.getelementptr %args_ptr[%idx_1] : (!llvm.ptr, i64) -> !llvm.ptr, !llvm.ptr
  %val_ptr_1 = llvm.load %arg_ptr_1 : !llvm.ptr -> !llvm.ptr     // slot → storage
  %val_1     = llvm.load %val_ptr_1 : !llvm.ptr -> !llvm.ptr     // storage → `this`
  %ret_val   = "jlcs.vcall"(%val_1) { class_name = @Base2, slot = 2 : i64, … } : (!llvm.ptr) -> i32
  return %ret_val : i32
}

Four things fall out of choosing an IR over a shim: the struct offsets in the marshalling code and in the Julia wrapper are the same DWARF numbers, read once; the ops carry verifiers, so a malformed thunk fails at parse instead of at runtime; the x86-64 SysV rules live in one readable pass (classifySysVStruct) rather than being implied by a code generator; and because the emitted dialect is written to disk and the JIT registers DWARF pointing at it, gdb stops inside the generated MLIR by file and line — disassemble /s interleaves dialect ops with the machine code they became. Ops execute through a per-library MLIR JIT engine, or ahead-of-time in a companion _thunks.so.

Full treatment — the thesis, the op reference, the lowering, source-level debugging, and the failure classes the design exists to make loud: [ABI Marshalling as Compiler IR](ABI Marshalling as Compiler IR (MLIR/JLCS) · RepliBuild.jl)

had alot of edits but wont be using any llm genrated content at all. hope it looks nice is and is readable I dont like Markdown…

Found a cheesy way to generate the config.h files for builds as well, just have cmake generate it then include it in the toml for julia to build into the library. I did this for curl and it works perfect. I was worried about pre-processing support but this works good temporarily.

These now get compiled AOT at build time instead of when the thunk is called, making every MLIR thunk at least 4x faster to call across the board.

function hello_message()::Union{String,Nothing}
    # [Tier 2] Dispatch to MLIR AOT Thunk (Complex ABI / Packed / Union)
    ptr = RepliBuild.JITManager.invoke_aot(THUNKS_HANDLE[], "_mlir_ciface__Z13hello_messagev_thunk", Cstring)
    ptr == C_NULL && return nothing
    s = unsafe_string(ptr)
    return s
end

Even the more complex thunks look very simple in the julia wrapper,

function llama_model_bitnet_load_arch_hparams(this::Any, ml::Ref{llama_model_loader})
    # [Tier 2] Dispatch to MLIR AOT Thunk
    return RepliBuild.JITManager.invoke_aot(THUNKS_HANDLE[], "_mlir_ciface__ZN18llama_model_bitnet17load_arch_hparamsER18llama_model_loader_thunk", this, ml)
end