FForge
← Labsmlir-linalg-01Linalg and transforms●●○○

linalg.generic and indexing maps — the one idea behind ML compilers

mlir-linalg/01_indexing_maps.mlirneeds: mlir-opt, mlir-runner4 steps
0/4 steps
what this lab teaches

triton-shared lowers Triton to linalg. torch-mlir lowers PyTorch to linalg. IREE and triton-shared optimise linalg. a production MLIR backend will too. So linalg is the level where you will spend most of your perf time. Its central op is: linalg.generic {indexing_maps = [...], iterator_types = [...]} ins(inputs) outs(outputs) { ^bb0(scalars...): body; linalg.yield } Read it as a PERFECT LOOP NEST described as data: * iterator_types: one entry per loop. "parallel" = iterations independent; "reduction" = iterations combine into the output (needs an order... or reassoc, lab 02_lowering/01). * indexing_maps: one per operand. Maps LOOP indices -> ELEMENT index of that operand. affine_map<(m,n,k) -> (m,k)> means "A is read at [m][k]". * body: what happens to ONE element. Its block args are the scalar elements of each operand, in order (inputs first, then outputs). Why this is powerful: tiling = split a loop's range; fusion = put one generic's body into another's loop nest; vectorization = turn the body into vector ops over a tile; interchange = permute loops. ALL of these are computed from the maps. No loop analysis, no alias analysis. That's why linalg transforms are robust where LLVM's loop passes (lab 02_lowering/01's memcheck) have to guess. The five functions below are the five patterns every kernel is made of: (1) elementwise all maps identity (relu, add, gelu) (2) transpose input map permuted (layout changes) (3) reduction output map DROPS a loop dim, that dim "reduction" (sum, max, softmax denom) (4) broadcast an input map DROPS a dim, all "parallel" (bias add, scale) (5) contraction reduction + products of 2 inputs (matmul, conv, attention)

evidence to leave with
The loop nest an indexing map implies, written out by hand and confirmed against the lowered scf
walk it, one step at a time
run this
$ lower_std 01_indexing_maps.mlir > out/im.mlir && mrun out/im.mlir | grep -v Unranked
what it printed on the machine that wrote this lab
[[2,   4,   6], 
 [8,   10,   12]]
[[1,   4], 
 [2,   5], 
 [3,   6]]
[6,  15]
[[11,   22,   33], 
 [14,   25,   36]]
[[14,   32], 
 [32,   77]]
add(a,a); transpose(a); rowsum(a) = [1+2+3, 4+5+6]; bias(a,b); a @ transpose(a).
run this
$ mlir-opt 01_indexing_maps.mlir --linalg-specialize-generic-ops | grep -oE 'linalg\.[a-z_]+' | sort | uniq -c
what it printed on the machine that wrote this lab
      2 linalg.elementwise
      2 linalg.fill
      1 linalg.generic
      1 linalg.matmul
      1 linalg.transpose
      1 linalg.yield
The pass pattern-matched maps+body: our hand-written matmul generic IS
linalg.matmul, the transpose IS linalg.transpose, add and bias became
linalg.elementwise. The rowsum stayed generic (there's linalg.reduce, but
the specializer doesn't target it). Named ops matter because some
transforms and ukernels (IREE's mmt4d, and vendor matmul-to-ukernel passes)
match on the NAME.
run this
$ mlir-opt 01_indexing_maps.mlir --linalg-specialize-generic-ops --linalg-generalize-named-ops | grep -oE 'linalg\.[a-z_]+' | sort | uniq -c
what it printed on the machine that wrote this lab
      7 linalg.generic
      7 linalg.yield
a vendor MLIR backend runs `createLinalgGeneralizePass()` in its pipeline for
exactly this reason: one form to match against.
run this
$ mlir-opt 01_indexing_maps.mlir --one-shot-bufferize=bufferize-function-boundaries --convert-linalg-to-loops | sed -n '/@matmul_generic/,/^  }/p' | sed -n '12,25p'
what it printed on the machine that wrote this lab
    }
    scf.for %arg2 = %c0 to %c2 step %c1 {
      scf.for %arg3 = %c0 to %c2 step %c1 {
        scf.for %arg4 = %c0 to %c3 step %c1 {
          %0 = memref.load %arg0[%arg2, %arg4] : memref<2x3xf32, strided<[?, ?], offset: ?>>
          %1 = memref.load %arg1[%arg4, %arg3] : memref<3x2xf32, strided<[?, ?], offset: ?>>
          %2 = memref.load %alloc[%arg2, %arg3] : memref<2x2xf32>
          %3 = arith.mulf %0, %1 : f32
          %4 = arith.addf %2, %3 : f32
          memref.store %4, %alloc[%arg2, %arg3] : memref<2x2xf32>
        }
      }
    }
    return %alloc : memref<2x2xf32>
Three loops in map order (m, n, k), loads at exactly the positions the
maps say ([m,k], [k,n], [m,n]). This is the NAIVE lowering: C[m][n] is
loaded and stored on every k iteration, B is walked down a column (stride
= row length, cache-hostile). Track 04 replaces this with tiled,
vectorized code and measures the difference.
take it further

1. Write a column-sum generic (reduce over i instead of j). Which map changes? 2. Write `C = A^T @ B` by changing ONLY #mA. Run it. 3. Write softmax's max-subtract step: out[i][j] = a[i][j] - rowmax[i]. It's a broadcast. What's rowmax's map? 4. Swap the iterator_types of @rowsum to ["reduction", "parallel"] without changing maps. Read the verifier error. ============================================================

read the source · mlir-linalg/01_indexing_maps.mlir
#id2 = affine_map<(i, j) -> (i, j)> #transp = affine_map<(i, j) -> (j, i)> #row = affine_map<(i, j) -> (i)> #col = affine_map<(i, j) -> (j)> #mA = affine_map<(m, n, k) -> (m, k)> #mB = affine_map<(m, n, k) -> (k, n)> #mC = affine_map<(m, n, k) -> (m, n)> // (1) elementwise add: every map is identity, every loop parallel func.func @add(%a: tensor<2x3xf32>, %b: tensor<2x3xf32>) -> tensor<2x3xf32> { %e = tensor.empty() : tensor<2x3xf32> %r = linalg.generic {indexing_maps = [#id2, #id2, #id2], iterator_types = ["parallel", "parallel"]} ins(%a, %b : tensor<2x3xf32>, tensor<2x3xf32>) outs(%e : tensor<2x3xf32>) { ^bb0(%x: f32, %y: f32, %o: f32): %s = arith.addf %x, %y : f32 linalg.yield %s : f32 } -> tensor<2x3xf32> return %r : tensor<2x3xf32> } // (2) transpose: the INPUT map is permuted; loop space is the output's shape func.func @transpose(%a: tensor<2x3xf32>) -> tensor<3x2xf32> { %e = tensor.empty() : tensor<3x2xf32> %r = linalg.generic {indexing_maps = [#transp, #id2], iterator_types = ["parallel", "parallel"]} ins(%a : tensor<2x3xf32>) outs(%e : tensor<3x2xf32>) { ^bb0(%x: f32, %o: f32): linalg.yield %x : f32 } -> tensor<3x2xf32> return %r : tensor<3x2xf32> } // (3) row sum: j is a REDUCTION dim, the output map drops it func.func @rowsum(%a: tensor<2x3xf32>) -> tensor<2xf32> { %z = arith.constant 0.0 : f32 %e = tensor.empty() : tensor<2xf32> %init = linalg.fill ins(%z : f32) outs(%e : tensor<2xf32>) -> tensor<2xf32> %r = linalg.generic {indexing_maps = [#id2, #row], iterator_types = ["parallel", "reduction"]} ins(%a : tensor<2x3xf32>) outs(%init : tensor<2xf32>) { ^bb0(%x: f32, %acc: f32): %s = arith.addf %acc, %x : f32 linalg.yield %s : f32 } -> tensor<2xf32> return %r : tensor<2xf32> } // (4) bias add = BROADCAST: the bias map drops i func.func @bias(%a: tensor<2x3xf32>, %b: tensor<3xf32>) -> tensor<2x3xf32> { %e = tensor.empty() : tensor<2x3xf32> %r = linalg.generic {indexing_maps = [#id2, #col, #id2], iterator_types = ["parallel", "parallel"]} ins(%a, %b : tensor<2x3xf32>, tensor<3xf32>) outs(%e : tensor<2x3xf32>) { ^bb0(%x: f32, %y: f32, %o: f32): %s = arith.addf %x, %y : f32 linalg.yield %s : f32 } -> tensor<2x3xf32> return %r : tensor<2x3xf32> } // (5) matmul written as a generic: 3 loops (m, n parallel; k reduction) func.func @matmul_generic(%a: tensor<2x3xf32>, %b: tensor<3x2xf32>) -> tensor<2x2xf32> { %z = arith.constant 0.0 : f32 %e = tensor.empty() : tensor<2x2xf32> %init = linalg.fill ins(%z : f32) outs(%e : tensor<2x2xf32>) -> tensor<2x2xf32> %r = linalg.generic {indexing_maps = [#mA, #mB, #mC], iterator_types = ["parallel", "parallel", "reduction"]} ins(%a, %b : tensor<2x3xf32>, tensor<3x2xf32>) outs(%init : tensor<2x2xf32>) { ^bb0(%x: f32, %y: f32, %acc: f32): %m = arith.mulf %x, %y : f32 %s = arith.addf %acc, %m : f32 linalg.yield %s : f32 } -> tensor<2x2xf32> return %r : tensor<2x2xf32> } func.func private @printMemrefF32(tensor<*xf32>) func.func @main() { %a = arith.constant dense<[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]> : tensor<2x3xf32> %b = arith.constant dense<[10.0, 20.0, 30.0]> : tensor<3xf32> %r1 = call @add(%a, %a) : (tensor<2x3xf32>, tensor<2x3xf32>) -> tensor<2x3xf32> %r2 = call @transpose(%a) : (tensor<2x3xf32>) -> tensor<3x2xf32> %r3 = call @rowsum(%a) : (tensor<2x3xf32>) -> tensor<2xf32> %r4 = call @bias(%a, %b) : (tensor<2x3xf32>, tensor<3xf32>) -> tensor<2x3xf32> %r5 = call @matmul_generic(%a, %r2) : (tensor<2x3xf32>, tensor<3x2xf32>) -> tensor<2x2xf32> %u1 = tensor.cast %r1 : tensor<2x3xf32> to tensor<*xf32> %u2 = tensor.cast %r2 : tensor<3x2xf32> to tensor<*xf32> %u3 = tensor.cast %r3 : tensor<2xf32> to tensor<*xf32> %u4 = tensor.cast %r4 : tensor<2x3xf32> to tensor<*xf32> %u5 = tensor.cast %r5 : tensor<2x2xf32> to tensor<*xf32> call @printMemrefF32(%u1) : (tensor<*xf32>) -> () call @printMemrefF32(%u2) : (tensor<*xf32>) -> () call @printMemrefF32(%u3) : (tensor<*xf32>) -> () call @printMemrefF32(%u4) : (tensor<*xf32>) -> () call @printMemrefF32(%u5) : (tensor<*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.