How to define MLIR types for Julia?

Does anyone know if there are dialects made for julia and MLIR, not wrappers for MLIR dialects but compiled to MLIR that define julias types and ops for MLIR in C++ files. The problem: MLIR types need their storage class fully defined before
registration, but I have a circular dependency:

  • JLCSTypes.h includes the typedef classes
  • But the storage definition is in JLCSTypes.cpp.inc
  • JLCSDialect.cpp includes JLCSTypes.h then tries to register the type
  • Storage is incomplete at registration time. Do I split the files and try resolving the includes seperatley. My MLIR starting cpp, h, td, cmake files are in examples of RepliBuild.jl repo.
#!/usr/bin/env julia


# Test JLCS dialect using MLIR.jl bindings

using Pkg
Pkg.activate(joinpath(homedir(), ".julia", "dev", "MLIR"))

using MLIR
using MLIR.API

# Path to our compiled JLCS dialect
const libJLCS_path = joinpath(@__DIR__, "Mlir", "build", "libJLCS.so")

# Check library exists
if !isfile(libJLCS_path)
error("JLCS library not found at: $libJLCS_path")
end
println("✓ Found JLCS library: $libJLCS_path")

# Load JLCS dialect
@assert dlopen(libJLCS_path, RTLD_GLOBAL) != C_NULL "Failed to load JLCS library"

# Create MLIR context
ctx = API.mlirContextCreate()
@assert ctx.ptr != C_NULL "Failed to create MLIR context"

# Register JLCS dialect
ccall((:registerJLCSDialect, libJLCS_path), Cvoid, (API.MlirContext,), ctx)

# Create module
loc = API.mlirLocationUnknownGet(ctx)
mod = API.mlirModuleCreateEmpty(loc)
@assert mod.ptr != C_NULL "Failed to create module"
println("✓ Created MLIR module")

# Print module
println("\nEmpty module:")
op = API.mlirModuleGetOperation(mod)
API.mlirOperationDump(op)

# Cleanup
API.mlirContextDestroy(ctx)

I figured out how to compile julia dialects to MLIR, and I can resolve ffi gen for Inheritance, Virtual methods, nested callbacks, and execute c++ without a wrapper, this goes Julia \rightarrow DWARF \rightarrow MLIR \rightarrow LLVM \rightarrow C++. Full ffi gen and execution with zero writing of any c++

The MLIR dialect is simply a carrier of this ABI fidelity.

When Julia “calls” a C++ this call, jlcs.vcall, it isn’t FFI.
It’s a direct LLVM IR call to a function pointer with:

  • correct ABI
  • correct calling convention
  • correct registers
  • correct stack frame layout

Identical to what the C++ compiler itself would generate.

C++ Code                          Julia Code
    ↓                                 ↓
MLIR IR (JLCS Dialect)  ←  Same IR  → MLIR IR (Julia types)
    ↓                                 ↓
        Unified LLVM IR (no boundary)
                ↓
        Native Machine Code
                ↓
        Direct Execution
Stage Component (File) Function Output
1. ABI Extraction DWARFParser.jl Reads vtable offsets and virtual method slots from debug info. Structured DWARF data (ABI facts).
2. IR Generation JLCSIRGenerator.jl Creates the jlcs.virtual_call operation with ABI facts as attributes. JLCS MLIR IR text.
3. Lowering LowerToLLVMPass.cpp Consumes the high-level jlcs.virtual_call op and rewrites it into LLVM IR memory and call ops using the ABI facts. Low-level LLVM IR.
4. Execution MLIR JIT Engine Compiles the LLVM IR to native code and returns a Julia-callable function pointer. Native Machine Code (MC).
// ====================================================================
// Virtual Method Call Operation
// ====================================================================
//
 4. Virtual Call Op (Call C++ virtual method via vtable)
def VirtualCallOp : JLCS_Op<"vcall"> {  
  let summary = "Call a C++ virtual method through vtable dispatch.";
  let description = [{
    Calls a C++ virtual method by:
    1. Reading the vtable pointer from the object (at vtable_offset)
    2. Loading the function pointer from vtable[slot]
    3. Calling the function with the object pointer + arguments

    Example:
    ```mlir
    %result = jlcs.vcall @Base::foo(%obj)
      { vtable_offset = 0 : i64, slot = 0 : i64 }
      : (!llvm.ptr) -> i32
    ```
  }];

  let arguments = (ins
    SymbolRefAttr:$class_name,    // Class name (e.g., @Base)
    Variadic<AnyType>:$args,      // Arguments (first is always object pointer)
    I64Attr:$vtable_offset,       // Offset of vptr in object
    I64Attr:$slot                 // Vtable slot index
  );

  let results = (outs Optional<AnyType>:$result);

  let skipDefaultBuilders = 1;
  let builders = [
    OpBuilder<(ins "SymbolRefAttr":$class_name, "ValueRange":$args,
                   "IntegerAttr":$vtable_offset, "IntegerAttr":$slot,
                   "Type":$resultType)>
  ];

  let extraClassDeclaration = [{
    VirtualCallOp(::mlir::Operation *op) : Op(op) {}

    // Helper to get the object pointer (first argument)
    Value getObject() { return getArgs()[0]; }
  }];
}

Introduction

This guide teaches Julia developers how to create custom MLIR dialects for advanced FFI scenarios. The JLCS dialect demonstrates:

  • C-ABI struct manipulation (field access by byte offset)
  • Virtual method dispatch (vtable-based calls)
  • Strided array operations (cross-language arrays)
  • Complete LLVM lowering (executable code generation)
  • JIT compilation (runtime code execution)

Why MLIR for Julia FFI?

Traditional Julia FFI (ccall) works well for simple C functions, but struggles with:

  • C++ virtual methods and inheritance
  • Complex struct layouts with padding
  • STL containers with implementation-defined layouts
  • Cross-language optimization opportunities

MLIR provides:

  • Custom IR tailored to your FFI needs
  • Transformation passes for optimization
  • Direct LLVM lowering for native performance
  • Type-safe operations verified at IR level

Full Introduction and Julia and MLIR findings RepliBuild.jl/docs/mlir at main · obsidianjulua/RepliBuild.jl · GitHub

Examples will need specific toolchain versions, but docs are setup to be readable for curiosity.

Update, July 2026 — the posts above are from December, when the dialect was in
the semi-early stages of being developed seriously. It’s now been through five
releases of hardening, RepliBuild is landing in General as v3.0.0, and a couple of
things I wrote back then deserve corrections now that they’ve been battle-tested.
Since this thread apparently gets read (and quoted) as reference material for
“MLIR for FFI”, here’s the honest current state.

Correction first: “verified at IR level”

I wrote that JLCS gives you “type-safe operations verified at IR level.” As of
today no JLCS op has a hasVerifier. Two ops segfault during lowering if you
hand them malformed IR — jlcs.scope with mismatched managed_ptrs/destructors
arity, and jlcs.marshal_arg with mismatched memberTypes/juliaOffsets — instead
of diagnosing. They’re tracked as two @test_broken entries in the test suite.
This isn’t reachable from the production DWARF→codegen path (the producers
co-generate the arrays, so they can’t disagree), but if you’re hand-writing JLCS
IR: there are no guardrails yet. Verifiers are near the top of the roadmap
precisely because this thread’s description got ahead of the implementation.

What actually changed since December

jlcs.vcall emits all the way to LLVM IR now. This one’s a useful war story
for anyone hand-building llvm.call in a lowering. The op lowered cleanly but
SIGSEGV’d inside translateModuleToLLVMIR at emit time. Root cause: my
VirtualCallOpLowering built the indirect call via a raw OperationState and set
operandSegmentSizes = {1, nArgs, 0} — three entries. But llvm.call carries
AttrSizedOperandSegments with two operand groups (callee_operands,
op_bundle_operands); for an indirect call the callee pointer is the first
element of callee_operands
, so the correct value is {1 + nArgs, 0}. The
translator split a 3-entry array against a 2-segment op and walked off the end.
Fix: use the dedicated indirect-call builder CallOp(LLVMFunctionType, ValueRange) and let it set operandSegmentSizes and var_callee_type itself.
The op definition from my earlier post is unchanged — the lowering was the bug.

Honest op liveness map, because “the dialect has 12 ops” says nothing about
what’s real:

  • Emitted by the production pipeline: jlcs.type_info, jlcs.ffe_call,
    jlcs.try_call (C++ exceptions → landing pad → Julia CxxException),
    jlcs.marshal_arg/marshal_ret, and the !jlcs.c_struct type.
  • Exercised only by hand-written test IR: jlcs.vcall, ctor_call/dtor_call,
    scope/yield (region RAII — lowers correctly, reverse-order destruction
    confirmed, but no DWARF-driven producer emits it yet).
  • Functional but producer-less: jlcs.load/store_array_element +
    !jlcs.array_view. They parse and lower cleanly; nothing generates them.

Toolchain: the dialect rebuilds clean against LLVM/MLIR 22.1.6 with zero
TableGen or C++ source changes from 22.1.5 — patch bumps within a minor have been
non-events. The dual-LLVM setup (Julia’s internal libLLVM for the C path, system
MLIR for the dialect) is deliberate and documented.

Outside the dialect but relevant to the FFI story: the C generator now runs an
exact-layout proof before emitting any struct — every member typed with known
size/alignment, then the emitter proves Julia’s layout reproduces each DWARF
offset and the total byte size. Proof passes → named fields; any doubt → opaque
byte blob; ABI crossings that can’t be made safe refuse loudly at the call site
instead of corrupting. “Exact or opaque, never approximate” turned out to be the
load-bearing design rule of the whole project.

What’s being worked on

  • Op verifiersscope and marshal_arg arity checks first (see above).
  • Multiple-inheritance this-adjustment — the vcall emit fix unblocked
    observing the secondary-base case: the vtable is read from the right offset but
    this still passes unadjusted. Remaining work is a this_offset on vcall
    plus a multi-base offset table on type_info.
  • Per-function bitcode slicingBase.llvmcall embeds the whole linked
    module per call site, which works at toy scale and falls over at whole-library
    scale (and duplicates file-local statics, so mixed-tier dispatch can diverge on
    internal state). Slicing is the fix; until then production configs pin ccall.
  • Producers for scope-RAII and the strided-array ops — the dialect side works,
    the DWARF-driven generation doesn’t exist yet.

Docs (readable for curiosity, examples need specific toolchain versions):
GitHub - obsidianjulua/RepliBuild.jl · GitHub — full changelog
including the v3.0.0 “breaking changes since v2.5.7” section is in the repo.
RepliBuild Hub (prebuilt configs for lua/sqlite/cjson/box2d/etc.):
GitHub - obsidianjulua/RepliBuild-Hub: Stores the toml configurations for the RepliBuild.jl FFE generator · GitHub

Hi, nice experiment, if you want to make your effort go to julia for now I think GitHub - maleadt/IRStructurizer.jl: Pattern-matching structured control flow in Julia's SSA IR. · GitHub is the closest to MLIR IR form, hopefully, this will lead to easier direct translation in the future it is used only by CuTile.jl but please try to build over it since it already goes around a lot of the painpont you’re going to go against with the standard julia IR.
Wanna also note Reactant.jl which do way more than just wrapping MLIR.

Thanks — both are great projects, but they point the opposite direction from RepliBuild. Reactant and cuTile/IRStructurizer lower Julia code into MLIR for accelerated execution; RepliBuild starts from a compiled C/C++ binary, reads its DWARF, and uses MLIR only at the FFI boundary — the JLCS dialect compiles ABI marshalling thunks (sret, vtable dispatch, exception-safe calls) that ccall can’t express. The IR is generated from debug metadata, never from Julia IR, and thunks are straight-line code, so there’s no control-flow structurization problem to go around. That said, Reactant’s vendored-MLIR JLL is a model I’m looking at for killing RepliBuild’s system-MLIR requirement — so the pointer is appreciated, just for a different reason than intended.