FForge
← Labsmlir-basics-01MLIR anatomy●○○○

The anatomy of MLIR — ops, regions, blocks, SSA values

mlir-basics/01_anatomy.mlirneeds: mlir-opt (source ../tools/env.sh)5 steps
0/5 steps
what this lab teaches

MLIR has exactly ONE core concept: the Operation. Everything you see below — a function, a loop, a constant, a `return`, even the enclosing module — is an operation. An operation has: %results = "dialect.opname"(%operands) <{properties}> ({regions}) {attrs} : (operand types) -> (result types) * operands / results : SSA values. Each value is defined exactly once. * properties / attrs : compile-time constants (e.g. the `0` in a constant). * regions : nested lists of BLOCKS. This is how a loop body or a function body lives *inside* an op. * blocks : lists of ops; a block can take ARGUMENTS (this is how MLIR does phi-nodes: the loop induction variable %i is a block argument of the loop body). A DIALECT is a namespace of ops + types + attributes (arith, scf, memref, func, linalg, vector, llvm, ...). "Lowering" means rewriting ops of a high-level dialect into ops of a lower-level dialect, until only the `llvm` dialect is left — which then translates 1:1 to LLVM IR. The "pretty" syntax below (scf.for %i = ... { }) is sugar that each dialect defines for itself. The GENERIC form (STEP 2) is what every op really is. Whenever you cannot read an op, print it generically.

evidence to leave with
The same function printed in generic form, and a sentence naming what each region and block argument is for
walk it, one step at a time
run this
$ mlir-opt 01_anatomy.mlir
what it printed on the machine that wrote this lab
... prints the module back, with %xi renamed to %0 etc.
The names you write (%xi, %ax) are NOT preserved — SSA names are cosmetic.
Add --mlir-print-value-users to see who uses each value.
run this
$ mlir-opt 01_anatomy.mlir --mlir-print-op-generic
what it printed on the machine that wrote this lab
"builtin.module"() ({
  "func.func"() <{function_type = (f32, memref<?xf32>, memref<?xf32>) -> (), sym_name = "axpy"}> ({
  ^bb0(%arg0: f32, %arg1: memref<?xf32>, %arg2: memref<?xf32>):
    %0 = "arith.constant"() <{value = 0 : index}> : () -> index
    %1 = "arith.constant"() <{value = 1 : index}> : () -> index
    %2 = "memref.dim"(%arg1, %0) : (memref<?xf32>, index) -> index
    "scf.for"(%0, %2, %1) ({
    ^bb0(%arg3: index):
      %3 = "memref.load"(%arg1, %arg3) : (memref<?xf32>, index) -> f32
      %4 = "memref.load"(%arg2, %arg3) : (memref<?xf32>, index) -> f32
      %5 = "arith.mulf"(%arg0, %3) <{fastmath = #arith.fastmath<none>}> : (f32, f32) -> f32
      %6 = "arith.addf"(%5, %4) <{fastmath = #arith.fastmath<none>}> : (f32, f32) -> f32
      "memref.store"(%6, %arg2, %arg3) : (f32, memref<?xf32>, index) -> ()
      "scf.yield"() : () -> ()
    }) : (index, index, index) -> ()
    "func.return"() : () -> ()
  }) : () -> ()
}) : () -> ()
Read it and notice:
  - the function's arguments are just ^bb0's block arguments;
  - the loop IV %arg3 is the loop body's block argument;
  - `scf.for` is an op with 3 operands (lb, ub, step) and ONE region;
  - `fastmath<none>` was always there — the pretty printer hid the default.
    (Lab 04_optimization/03 shows what turning it on buys you.)
run this
$ mlir-opt 01_anatomy.mlir --test-print-nesting -o /dev/null
what it printed on the machine that wrote this lab
visiting op: 'builtin.module' with 0 operands and 0 results
 1 nested regions:
  Region with 1 blocks:
    Block with 0 arguments, 0 successors, and 1 operations
      visiting op: 'func.func' with 0 operands and 0 results
       1 nested regions:
        Region with 1 blocks:
          Block with 3 arguments, 0 successors, and 5 operations
            visiting op: 'arith.constant' with 0 operands and 1 results
            ...
            visiting op: 'scf.for' with 3 operands and 0 results
             1 nested regions:

Region with 1 blocks: Block with 1 arguments, 0 successors, and 6 operations This recursive structure is WHY passes are scheduled "on func.func" or "on builtin.module" (lab 02_lowering/03): a pass is anchored at one op kind and sees only what is nested inside it.

run this
$ mlir-opt 01_anatomy.mlir --print-op-stats -o /dev/null
what it printed on the machine that wrote this lab
Operations encountered:
-----------------------
    arith.addf     , 1
    arith.constant , 2
    arith.mulf     , 1
  builtin.module   , 1
     func.func     , 1
     func.return   , 1
   memref.dim      , 1
   memref.load     , 2
   memref.store    , 1
      scf.for      , 1
      scf.yield    , 1

Five dialects in twelve ops: builtin, func, arith, memref, scf. Run this again at the end of a lowering and only `llvm.*` must remain — that is the single best "am I done lowering?" check you have.

run this
$ mlir-opt 01_anatomy.mlir --mlir-print-debuginfo | grep -m2 loc
what it printed on the machine that wrote this lab
Every op carries a `loc(...)`. When you lower Triton code, these locations
point back to the Python line — they're how an error in pass #40 can still
tell you which line of the kernel it came from.
the mental model to keep

LLVM IR has a fixed instruction set. MLIR has an OPEN op set: anyone can define `vendormem.alloc` or `ttx.scan`, and vendor backends do exactly that (see ../11_reading_real_compilers). The compiler is a sequence of passes that rewrite ops from "what the programmer meant" to "what the machine does".

take it further

1. Change `%s = arith.addf %ax, %yi : f32` to use `f64`. Read the error. (Lab 03 is about errors.) 2. Run `mlir-opt 01_anatomy.mlir --test-print-defuse -o /dev/null` and find who uses the value produced by `memref.dim`. 3. Add a second function that calls @axpy with `func.call`. Print it generically. ============================================================

read the source · mlir-basics/01_anatomy.mlir
func.func @axpy(%a: f32, %x: memref<?xf32>, %y: memref<?xf32>) { %c0 = arith.constant 0 : index %c1 = arith.constant 1 : index %n = memref.dim %x, %c0 : memref<?xf32> scf.for %i = %c0 to %n step %c1 { %xi = memref.load %x[%i] : memref<?xf32> %yi = memref.load %y[%i] : memref<?xf32> %ax = arith.mulf %a, %xi : f32 %s = arith.addf %ax, %yi : f32 memref.store %s, %y[%i] : memref<?xf32> } return }
◇
Finish the lab: write the evidence note

A lab is done when you can say what it showed on your machine — not when you have read it.