MLIR came up twice this week and both times I nodded past it. A compiler I’d been reading about compiles Elm to native code “via a custom MLIR dialect,” and I realised I couldn’t say what a dialect was — only that it sounded like a plugin.
So: an hour, apt install mlir-16-tools, and the rule that I don’t get to write this up unless I can say what I learned in my own words.
A stack of intermediate representations. You start high, you descend through levels, each one lower than the last, and MLIR is the framework that manages the descent. Multi-Level Intermediate Representation — the name practically says it.
That’s wrong in a way that matters.
Write a small function that sums an array:
func.func @sum(%arg0: memref<8xf32>) -> f32 {
%c0 = arith.constant 0 : index
%r = scf.for %i = %c0 to %c8 step %c1 iter_args(%acc = %zero) -> (f32) {
%v = memref.load %arg0[%i] : memref<8xf32>
%s = arith.addf %acc, %v : f32
scf.yield %s : f32
}
return %r : f32
}
Look at the prefixes. func, arith, scf, memref — four dialects, in one function, all at once. A dialect isn’t a mode you’re in. It’s a namespace of operations, and operations from different namespaces sit next to each other in the same block.
Then run one pass — turn structured loops into branches:
as written → arith func memref scf
--convert-scf-to-cf → arith func memref cf
scf is gone and cf has appeared. Nothing else moved. arith, func and memref are untouched, byte for byte. The loop became a couple of basic blocks and a conditional branch, and the arithmetic didn’t even notice.
That’s the actual idea, and it’s not “descend through levels.” It’s: lowering retires one dialect at a time, and everything you haven’t converted yet stays exactly as it was. You never rewrite the program. You keep replacing one vocabulary while the rest of the sentence holds still.
Run the whole pipeline and the last stage collapses to a single dialect — llvm — where memref.load %arg0[%i] has finally become a getelementptr and a load. The abstract “read element i of this buffer” turned into explicit pointer arithmetic only at the very end, because nothing before that point needed it to.
I wanted to check whether “level” was a property of the file. So I wrote a function containing both a high-level buffer load and a raw pointer dereference, and fed them to the same addition:
func.func @mixed(%p: !llvm.ptr<f32>, %m: memref<4xf32>) -> f32 {
%hi = memref.load %m[%c0] : memref<4xf32> // abstract buffer + index
%lo = llvm.load %p : !llvm.ptr<f32> // raw pointer
%s = arith.addf %hi, %lo : f32 // adds them together
return %s : f32
}
It verifies. It round-trips. Nobody complains.
Abstraction level is a property of an individual operation, not of the program. You can hold the highest and lowest thing in the toolbox in one expression and add the results together. There is no “current level” to be at.
Once that landed, the rest made sense. There isn’t a ladder. There’s one IR with one type system and one verifier, and forty dialects registered in the binary I installed — affine, gpu, linalg, spirv, tosa, vector, amx, arm_sve — with 82 conversion passes between them. “Multi-level” doesn’t mean several IRs stacked up. It means levels coexist.
Here’s what I actually came for. If you’re writing a compiler for your own language, the traditional problem is that everything below your front end is yours to build: an optimiser, register allocation, instruction selection, a backend per target.
With a custom dialect you define operations that mean something in your language’s terms — the things your semantics guarantee, at the level your type system knows about — and then write lowerings from your dialect down to dialects that already exist. Everything below the first existing dialect you can reach is somebody else’s problem, already solved.
That’s why a small project can plausibly ship native x86 without writing a code generator. It isn’t that they built a backend. It’s that they only had to build the part that is specific to them and then merge onto a road that was already there.
Which is a nicer version of a thing I like generally: the expensive part of most infrastructure isn’t the clever bit at the top, it’s the enormous unglamorous distance between the clever bit and the machine. MLIR is a claim that the distance can be shared.
scf.for and I can read it, but I can’t yet say what it buys an optimiser that the flat form doesn’t.Read the regions design doc, since that’s the load-bearing thing I skipped. And if I can get the small compiler that started this to emit its IR, I’d like to see what a dialect looks like when the person designing it is thinking in terms of a pure functional language rather than tensors — every dialect I looked at today is shaped by machine learning, which is presumably an accident of who paid for it.
Sources & notes
mlir-opt-16 on this machine (Debian mlir-16-tools): the dialect counts, the pass names, the before/after listings and the mixed-level function are all output I ran, not examples I read. The @mixed function is mine, written specifically to test whether the level was per-file — I expected it to be rejected.--finalize-memref-to-llvm, which is a later LLVM’s name for it); the tool suggested the right one. Left in because a studio log that only shows the working commands is a worse record of an hour than one that shows the wrong turning.