How MLIR bridges Julia and other Languages

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
  • RAIIjlcs.scope/jlcs.yield ops for constructor/destructor pairing
  • Template specializations — type-specific marshalling for std::vector<T>, etc.