← Tiled Thoughts

MLIR for People Who Only Know LLVM IR: A Guided Tour

Contents
  1. TL;DR: The Mental Mapping
  2. Modules, functions, blocks, and values
  3. LLVM IR Mental Model
  4. MLIR Mental Model
  5. Example: hello, function
  6. Dialects: Instruction Sets for Different Domains
  7. Dialects as namespaces
  8. Operations, Regions, and Nested Control Flow
  9. Regions in pratctice
  10. Nested IR everywhere
  11. Types and Attributes
  12. SSA value types
  13. Attributes
  14. A side-by-side example
  15. LLVM IR
  16. MLIR
  17. Breakdown
  18. Passes and pipelines
  19. Pattern rewrites: opt passes with a twist
  20. How does this become LLVM IR?
  21. How to start reading MLIR as an LLVM person
  22. Why MLIR?

A practical mental-model bridge from LLVM IR to MLIR for people who already think in terms of functions, basic blocks, and passes.

If you already speak LLVM IR, MLIR can feel like a cousin who redesigned the house while you were out:

..but suddenly there are dialects, regions inside operations, and IR that looks like:

#map0 = affine_map<(i) -> (i)>
module {
    func.func @foo(%arg0:tensor<4xf32>, %arg1:tensor<4xf32>, %arg2:tensor<4xf32>) {
        linalg.generic {
    indexing_maps = [#map0, #map0, #map0],
    iterator_types = ["parallel"]
    } ins(%arg0, %arg1 : tensor<4xf32>, tensor<4xf32>)
    outs(%arg2 : tensor<4xf32>) {
        ^bb0(%a : f32, %b : f32, %c : f32):
        %sum = arith.addf %a, %b : f32
        linalg.yield %sum : f32
    } -> tensor<4xf32>
    return
    }
}

What.

This post is a guied tour of MLIR from an LLVM-IR mental model. I’ll assume:

The goal is to leave you thinking:

“Ah, MLIR is basically SSA + nested regions + pluggable instruction sets, with a nicer way to stage transformations.”


TL;DR: The Mental Mapping

If you want the shorter version:

With that in mind, let’s go piece by piece.


Modules, functions, blocks, and values

Start with the comforting part: MLIR is still SSA, and the top-level shape will feel familiar.

LLVM IR Mental Model

In LLVM, you think of:

MLIR Mental Model

In MLIR, the basic hierarchy is:

So instead of “module → function → block → instruction,” you can think:

Operations are all you need! A module is an op wirh regions, functions are ops with regions, blocks contain ops, and ops produce values.

Example: hello, function

Here’s a simple LLVM IR function:

define i32 @add(i32 %a, i32 %b) {
entry:
  %sum = add i32 %a, %b
  ret i32 %sum
}

In MLIR (func + arith dialects), this looks like:

module {
  func.func @add(%a: i32, %b: i32) -> i32 {
    %sum = arith.addi %a, %b : i32
    return %sum : i32
  }
}

Same structure, but notice:


Dialects: Instruction Sets for Different Domains

LLVM IR has a fixed instruction set, with intrinsics to stretch it. MLIR introduces dialects to provide domain-specific instruction sets.

Dialects as namespaces

A dialect is basically a namespace for a set of operations and types. For example:

You see them as prefixes:

%0 = arith.addi %a, %b : i32
%1 = memref.load %ptr[%idx] : memref<1024xf32>
%2 = linalg.matmul ins(%A, %B : memref<...>, memref<...>) outs(%C : memref<...>)

Mental model:

This matters because it lets you:


Operations, Regions, and Nested Control Flow

In LLVM IR, an instruction is always inside a basic block; it does not contain blocks.

In MLIR, an operation (op) can contain regions, which in turn contain blocks. This allows for nested control flow and hierarchical structure.

Regions in pratctice

Example: a simple scf.for loop from the scf dialect:

scf.for %i = %c0 to %cN step %c1 {
    %val = memref.load %A[%i] : memref<...>
    %const_two = arith.constant 2.0 : f32
    %result = arith.mulf %val, %const_two : f32
    memref.store %result, %A[%i] : memref<...>
}

What’s happening here:

If you think in LLVM IR terms,

That lets transformations reason about loops at a higher level, e.g., loop unrolling, fusion, etc.

Nested IR everywhere

Other examples of ops with regions:

Once you accept:

“Ops can contain regions, which contain blocks, which contain ops…”

…the rest of MLIR starts to feel more natural.


Types and Attributes

MLIR types look familiar but slightly more regular.

SSA value types

You will see:

%0 = arith.addi %a, %b : i32
%1 = memref.load %A[%i] : memref<1024xf32>
%2 = tensor<4x4xf32>

Types are usually in angle brackets:

Compared to LLVM:

Attributes

MLIR has attributes (immutable metadata) baked into the syntax:

%0 = arith.constant 4 : i32
%1 = arith.constant dense<0.0> : tensor<4xf32>
%2 = linalg.generic {
    indexing_maps = [#map0, #map0, #map0],
    iterator_types = ["parallel"]
}...
#map0 = affine_map<(i) -> (i)>

Attributes are regularized and part of the op syntax, not scattered comments or metadata.

Mental model:


A side-by-side example

Let’s compare a simple vector add with the same “concept” in LLVM IR and MLIR.

LLVM IR

define void @vec_add(float* %a, float* %b, float* %c, i64 %N) {
entry:
  br label %loop

loop:
  %i = phi i64 [ 0, %entry ], [ %i_next, %loop ]
  %a_i_ptr = getelementptr float, float* %a, i64 %i
  %b_i_ptr = getelementptr float, float* %b, i64 %i
  %c_i_ptr = getelementptr float, float* %c, i64 %i

  %a_i = load float, float* %a_i_ptr
  %b_i = load float, float* %b_i_ptr
  %sum = fadd float %a_i, %b_i
  store float %sum, float* %c_i_ptr

  %i_next = add i64 %i, 1
  %cmp = icmp slt i64 %i_next, %N
  br i1 %cmp, label %loop, label %exit

exit:
  ret void
}

MLIR

module {
  func.func @vec_add(
      %A : tensor<?xf32>,
      %B : tensor<?xf32>,
      %C : tensor<?xf32>,
      %N : index) {
    %c0 = arith.constant 0 : index
    %c1 = arith.constant 1 : index

    // Bounds check omitted for brevity
    %C_out = linalg.generic {
        indexing_maps = [
          affine_map<(i) -> (i)>,
          affine_map<(i) -> (i)>,
          affine_map<(i) -> (i)>
        ],
        iterator_types = ["parallel"]
      } ins(%A, %B : tensor<?xf32>, tensor<?xf32>)
        outs(%C : tensor<?xf32>) {
        ^bb0(%a : f32, %b : f32, %c_in : f32):
          %sum = arith.addf %a, %b : f32
          linalg.yield %sum : f32
      } -> tensor<?xf32>

    return
  }
}

Breakdown

The compiler can later lower this to:

You get to stage your transformations at a higher level of abstraction, rather than wrestling with low-level IR from the start.


Passes and pipelines

LLVM:

MLIR:

mlir-opt input.mlir \
  -convert-linalg-to-loops \
  -lower-affine \
  -convert-scf-to-cf \
  -convert-func-to-llvm \
  -reconcile-unrealized-casts

Key differences:

Mental model:


Pattern rewrites: opt passes with a twist

In LLVM, passes:

MLIR leans heavily on pattern rewrites:

Example (in pseudocode): “fuse multiply-add” pattern in arith dialect to a custom fma op:

pattern FuseMulAdd {
  match: arith.addf(arith.mulf(%a, %b), %c)
  rewrite: MyCustomDialect.fma(%a, %b, %c)
}

Why it’s powerful:


How does this become LLVM IR?

At some point, you may want to lower MLIR down to LLVM IR for code generation.

MLIR usually goes through the LLVM dialect as an intermediate step:

For example, an MLIR function in the llvm dialect might look like:

llvm.func @add(%a: i32, %b: i32) -> i32 {
  %sum = llvm.add %a, %b : i32
  llvm.return %sum : i32
}

From there, MLIR has a conversion pass that translates the llvm dialect to actual LLVM IR.

So, the pipeline is often:

High-level dialects (linalg, tensor, scf, gpu, etc.)

       Affine/scf/memref/etc.

       LLVM dialect

       LLVM IR

       Machine code

How to start reading MLIR as an LLVM person

If you are staring at some .mlir dump and feeling lost, try this:

  1. Find the module op and the functions
  1. Pretend every op is an LLVM instruction.
  1. Notice regions inside ops.
  1. Identify the dialect layers.
    • Is the IR still in linalg/tensor land? That’s high-level.
    • Is it all scf and memref? Mid-level.
    • Is it llvm dialect? Almost LLVM IR.
  2. Look at pass pipelines.
    • When debugging, run mlir-opt with -print-ir-after-all to see how the IR evolves.
    • Watch how linalg.generic gets lowered to loops, then to llvm ops.

With practice, you’ll start to see MLIR as a layered extension of LLVM IR, rather than a completely foreign language.


Why MLIR?

If you are fluent in LLVM IR, MLIR does not replace it; it wraps it in layers of structured abstractions:

And if all else fails, you can always lower back to LLVM IR for code generation.