$ mlir-opt 01_anatomy.mlir
... 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.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.
$ mlir-opt 01_anatomy.mlir
... 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.$ mlir-opt 01_anatomy.mlir --mlir-print-op-generic
"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.)$ mlir-opt 01_anatomy.mlir --test-print-nesting -o /dev/null
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.
$ mlir-opt 01_anatomy.mlir --print-op-stats -o /dev/null
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 , 1Five 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.
$ mlir-opt 01_anatomy.mlir --mlir-print-debuginfo | grep -m2 loc
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.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".
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. ============================================================
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
}A lab is done when you can say what it showed on your machine — not when you have read it.
It decides which problems come first, which drills open, which labs you see and how the Learn surface is organised. Change it any time from the switch in the top bar, or press Escape to look around first.