The short is MLIR can be a sort of ABI compiler so two languages can merge into the same IR. The long part is below.
The Problem
Julia’s ccall assumes C-compatible struct layouts — natural alignment, predictable padding. C++ breaks this with packed structs, virtual dispatch, RAII, and non-trivial return conventions. A ccall to a function returning a packed struct will segfault unless each return is dealt with the traditional way of writing the handling ourselves.
Packed Struct Returns: The ffe_call Op
Consider this C++ type and function:
#pragma pack(push, 1)
struct PackedTriplet { char tag; int value; char flag; }; // 6 bytes, no padding
#pragma pack(pop)
PackedTriplet pack_three(char tag, int value, char flag);
The SysV ABI returns this via sret pointer, not registers. Julia’s ccall doesn’t know that — it expects an aligned 12-byte struct (1 + 3pad + 4 + 1 + 3pad). Reading the packed 6 bytes as 12 corrupts the int field.
Here’s the MLIR thunk generates:
func.func @pack_three_thunk(%args: !llvm.ptr) -> !llvm.struct<(i8, i32, i8)>
attributes { llvm.emit_c_interface } {
// Unpack arguments from Julia's void** array
%c0 = arith.constant 0 : i64
%c1 = arith.constant 1 : i64
%c2 = arith.constant 2 : i64
%p0 = llvm.getelementptr %args[%c0] : (!llvm.ptr, i64) -> !llvm.ptr, !llvm.ptr
%p1 = llvm.getelementptr %args[%c1] : (!llvm.ptr, i64) -> !llvm.ptr, !llvm.ptr
%p2 = llvm.getelementptr %args[%c2] : (!llvm.ptr, i64) -> !llvm.ptr, !llvm.ptr
%v0 = llvm.load %p0 : !llvm.ptr -> !llvm.ptr
%v1 = llvm.load %p1 : !llvm.ptr -> !llvm.ptr
%v2 = llvm.load %p2 : !llvm.ptr -> !llvm.ptr
%tag = llvm.load %v0 : !llvm.ptr -> i8
%value = llvm.load %v1 : !llvm.ptr -> i32
%flag = llvm.load %v2 : !llvm.ptr -> i8
// Call C++ — returns packed struct (6 bytes, no padding)
%packed = jlcs.ffe_call %tag, %value, %flag { callee = @pack_three }
: (i8, i32, i8) -> !llvm.struct<packed (i8, i32, i8)>
// Repack into aligned struct for Julia (12 bytes, with padding)
%out = llvm.mlir.undef : !llvm.struct<(i8, i32, i8)>
%f0 = llvm.extractvalue %packed[0] : !llvm.struct<packed (i8, i32, i8)>
%f1 = llvm.extractvalue %packed[1] : !llvm.struct<packed (i8, i32, i8)>
%f2 = llvm.extractvalue %packed[2] : !llvm.struct<packed (i8, i32, i8)>
%o0 = llvm.insertvalue %f0, %out[0] : !llvm.struct<(i8, i32, i8)>
%o1 = llvm.insertvalue %f1, %o0[1] : !llvm.struct<(i8, i32, i8)>
%o2 = llvm.insertvalue %f2, %o1[2] : !llvm.struct<(i8, i32, i8)>
return %o2 : !llvm.struct<(i8, i32, i8)>
}
The jlcs.ffe_call op knows the C++ function returns a packed (i8, i32, i8) — 6 bytes with no padding. It extracts each field individually and inserts them into a new aligned struct that Julia can read safely. In Julia>
triplet = pack_three(UInt8('A'), Cint(999), UInt8('Z'))
@test triplet.value == 999 # just works
How Arguments Cross the Boundary
Every thunk takes a single void** — an array of pointers to Julia Ref values. The IR does a double dereference: first to get the pointer from the array slot, then to load the typed value:
// args[0] → pointer → load pointer → load i32
%slot = llvm.getelementptr %args[%idx] : (!llvm.ptr, i64) -> !llvm.ptr, !llvm.ptr
%ptr = llvm.load %slot : !llvm.ptr -> !llvm.ptr
%val = llvm.load %ptr : !llvm.ptr -> i32
This matches Julia’s Ref{T} layout — a heap-allocated box containing the value. The thunk unwraps it to get the raw primitive for C++.
What the Dialect Handles
The dialect generates thunks for cases ccall can’t handle safely:
- Packed structs — field-by-field repack between packed and aligned layouts
- Virtual dispatch — vtable pointer resolution and indirect calls
- RAII —
jlcs.scope/jlcs.yieldops for constructor/destructor pairing - Template specializations — type-specific marshalling for
std::vector<T>, etc.