从零学习MLIR(1)
从一个demo开始
怎么编译看这里:https://mlir.llvm.org/getting_started/
准备工作
先从一个.mlir文件开始:/tmp/a.mlir
func.func @add(%a: i32, %b: i32) -> i32 { # 一个函数,属于 func 方言
%r = arith.addi %a, %b : i32 # 一次加法,属于 arith 方言
return %r : i32 # func.return
}
-
它不是机器码,也不是 C/LLVM IR,而是 MLIR IR 的文本表述——相当于”MLIR 世界里的汇编源文件”。
-
@add是符号名;%a/%b/%r是 SSA 值(%a、%b是函数参数,%r是一次计算的结果)。
现在,在仓库根目录下执行:../build/bin/mlir-opt /tmp/a.mlir,它做的事是一条固定流水线:
读文本 → 解析(parse)成内存 IR → 校验(verify) → 跑 pass(可选) → 打印(print)成文本
- 它是解析器 + 校验器 + pass 执行器 + 打印器四合一。
- 不加任何 pass 时,它只是”读进来再写出去”,这叫 round-trip,用来验证 parser/printer 是否正确。
- 加
--canonicalize、--pass-pipeline='...'等才会真正变换 IR。 - 它只认识”已注册的方言”——这正好是后面你自己造 dialect 要挂进去的地方
打印结果:
# === 不加 pass(round-trip)===
module {
func.func @add(%arg0: i32, %arg1: i32) -> i32 {
%0 = arith.addi %arg0, %arg1 : i32
return %0 : i32
}
}
看到这里,肯定有点懵。SSA是啥?pass又是啥?方言又是啥?执行命令之后好像没有很大的变化?不急,我们一个个来看。
概念解释
SSA
SSA(Static Single Assignment,静态单赋值):每个值只能被”赋值”一次,之后不再变;需要新结果就生成一个新值,而不是覆盖旧的。
对比一下普通命令式代码:
int s = 0; // 同一个变量 s
s = s + 1; // 被反复覆盖 → 不是 SSA
s = s + 2;
同样的逻辑在 MLIR(SSA 形式)里是这样,每次加法都产生一个全新的值:
%0 = arith.addi %s, %c1 // %0 是新值
%1 = arith.addi %0, %c2 // %1 是新值;%0 从没被改过
-
每个
%名字有且只有一个定义点(def),可以有多个使用点(use)。%0在哪定义、被谁用,是显式可查的。 -
值有作用域:一个 SSA 值只在其所在的 region 内可见(MLIR 里 region 是 Op 内部的代码块容器)。
-
名字毫无语义。你写
%a、%r,解析后变成长整型 ID,打印时按顺序重新编号成%arg0、%0、%1——这就是为什么刚才%a变成了%arg0。 -
为什么全世界编译器都要 SSA?因为它让优化变得简单可靠:常量折叠、死代码消除、公共子表达式消除,都变成”看 def-use 图”的机械操作。
pass
Pass(遍 / 处理步骤):Pass 是”读一段 IR、做一件事、可能改写它”的一个独立步骤。类似编译器流水线里的一环,或 Unix 管道里的一条命令。
那么,可不可以这样理解:“MLIR是多级IR,然后每次pass就相当于把IR降了一级”?
不完全对。
我们继续用实例来讲解:
- 原文件:/tmp/ssa.mlir
func.func @sum_to(%n: index) -> index {
%c0 = arith.constant 0 : index
%c1 = arith.constant 1 : index
%r = scf.for %i = %c0 to %n step %c1 iter_args(%acc = %c0) -> (index) {
%s = arith.addi %acc, %i : index
scf.yield %s : index
}
return %r : index
}
一个 scf.for 求和循环。三个方言:func(函数)、arith(常量/加法)、scf(结构化循环)。SSA 值:%n(参数)、%c0/%c1(常量)、%r(结果)、%i(归纳变量)、%acc(累加器)、%s(单轮和)。
- round-trip(不加pass)
../build/bin/mlir-opt /tmp/a.mlir
module {
func.func @sum_to(%arg0: index) -> index {
%c0 = arith.constant 0 : index
%c1 = arith.constant 1 : index
%0 = scf.for %arg1 = %c0 to %arg0 step %c1 iter_args(%arg2 = %c0) -> (index) {
%1 = arith.addi %arg2, %arg1 : index
scf.yield %1 : index
}
return %0 : index
}
}
变化:
%n → %arg0
%i → %arg1 %acc → %arg2
%r → %0 %s → %1
外部多包了一层 module { ... }
结构零变化,只有命名被规范化 → 证明 SSA 名字纯粹是给人看的。
--canonicalize(同层优化)
输出和 1(Round-trip) 一模一样。这本身是个重要观察:canonicalize 只在有规则命中时才改 IR,这里没有可化简的东西,所以它是 no-op。这就说明了,“跑 pass 不一定变样,更不一定降级”。
--convert-scf-to-cf(降一层:结构化循环 → 基本块 + 跳转)
module {
func.func @sum_to(%arg0: index) -> index {
%c0 = arith.constant 0 : index
%c1 = arith.constant 1 : index
cf.br ^bb1(%c0, %c0 : index, index)
^bb1(%0: index, %1: index): // 2 preds: ^bb0, ^bb2
%2 = arith.cmpi slt, %0, %arg0 : index
cf.cond_br %2, ^bb2, ^bb3
^bb2: // pred: ^bb1
%3 = arith.addi %1, %0 : index
%4 = arith.addi %0, %c1 overflow<nsw> : index
cf.br ^bb1(%4, %3 : index, index)
^bb3: // pred: ^bb1
return %1 : index
}
}
循环解体:
scf.for 的 iter_args(%arg2 = %c0) → ^bb1(%0: index, %1: index) 的块参数
%i(归纳变量) → %0,初始由 cf.br ^bb1(%c0, %c0) 传入
%acc(累加器) → %1,同理
循环条件 → arith.cmpi slt, %0, %arg0 + cf.cond_br
scf.yield %s : index → cf.br ^bb1(%4, %3)(把新值回灌给块参数)
后面就不详细展开讲了,大概能理解pass的意思就行。
方言(dialect)
Dialect 是一组相关 Op/Type/Attribute/规则 的命名空间,是 MLIR 的”扩展单元”。MLIR 本身不是一种固定指令集的 IR。它只是一个”IR 框架”,提供对象模型和基础设施;所有具体指令都来自某个方言。
需要注意的是,并不是“一层代表一个方言”,方言是”词汇表”,层级是”抽象高度”,两者是正交的两个维度。 一次 lowering 改的是”抽象高度”(靠 ConversionTarget 判定),而”换了哪个方言”只是随之发生的结果之一,不是定义。
常见的方言:
| 方言 | 主要用途 |
|---|---|
arith |
加法、乘法等算术运算 |
tensor |
表示张量值 |
linalg |
矩阵乘法、张量计算等线性代数操作 |
memref |
表示带有内存布局信息的内存引用 |
scf |
表示循环、条件分支等控制流 |
vector |
表示向量化计算 |
gpu |
表示 GPU 线程层级和 GPU Kernel |
llvm |
表示接近 LLVM IR 的底层操作 |