操作、方言与 IR 的 C++ 表示
前面三篇讲的是”为什么”和”是什么”。从这一篇开始进入 C++ 实现层面——理解 MLIR 如何将 IR 结构映射到内存中的对象,以及你在写 Pass 时真正操作的是哪些类。
如果你之前用过 LLVM,这里的很多概念会看起来熟悉但又有微妙的不同。MLIR 在 LLVM IR 的 SSA 设计上做了一层重要的泛化——这是本篇要解释的核心。
一、两点关键差异:MLIR IR 与 LLVM IR 的模型区别
从 LLVM IR 转到 MLIR IR,有两个根本性的设计差异会反复遇到。先讲清楚这两点,后面具体的 C++ 类体系就会自然很多。
1. 不存在”指令列表遍历”——IR 遍历从 Operation 层开始
LLVM IR 中遍历一个 Function
内的指令,习惯是:
for (auto &BB : F) // 遍历基本块
for (auto &I : BB) // 遍历指令(Instruction)LLVM 的 Instruction 类继承自
User 和 Value,指令就是 IR
遍历的基本单元。但在 MLIR 中,没有独立于 Op 的 “指令”
概念——一切计算、控制流和数据移动都是
Operation。遍历一个 Block
就是遍历一个 Operation 列表:
for (auto &op : block) // op 是 Operation &MLIR 的 Operation 不继承自
Value——它产生
Value(Result),也消费
Value(Operand)。这种分离使得 IR
遍历和数据流追踪成为两个独立维度。
2. 定义了操作就是定义了新类型——没有”万能 Instruction + opcode”模式
LLVM 中所有指令共用同一个 Instruction
类,不同的指令通过 getOpcode()
返回值(Instruction::Add、Instruction::Store
等)来区分。IR 基础设施不认识 “add”——它只认识
Instruction。
MLIR 则不同:每种操作都是 Operation
的一个独立子类型。arith.addi
和 arith.muli 是两个不同的 C++ 类型。IR
基础设施不依赖一个全局的 opcode 枚举——它通过
AbstractOperation
查找操作的行为(验证、折叠、解析等),而具体的语义和访问器由类型系统强制执行。
后果:在 LLVM 中你可以写一个通用 Pass
说”对所有 Instruction 做某事”,然后在里面
switch (I.getOpcode())。在 MLIR 中你写”对所有
arith.addi
做某事”,匹配和替换都在类型安全的模板层完成,不需要 opcode
switch。这让 MLIR
的类型系统可以携带更多语义——编译器在编译你那写 Pass 的 C++
代码时就能检查很多错误。
接下来的几节从最底层的数据结构开始,逐步构建到完整的 IR 内存视图。
二、Operation:一切 IR 的原子单元
在 C++ 中,Operation 是 MLIR IR
的原子单元——所有方言的 Op 都是 Operation
实例。但大多数代码不直接操作
Operation*,而是使用它的类型化包装器
Op(见下一节)。
Operation 的内存布局(概念视图,非 ABI
级表述):
Operation
├── location (Location) // 源位置追踪
├── result types (ArrayRef<Type>) // 结果类型列表
├── operands (MutableOperandRange) // 操作数(消费的 Value)
├── successor blocks (Block* 数组) // 控制流后继(如 br 的目标)
├── attributes (DictionaryAttr) // 编译期常量元数据
├── regions (Region 数组) // 嵌套子区域
├── parent pointer (Block*) // 所属 Block
└── abstractOperation (AbstractOperation*) // 方言级元信息的虚表
关键设计细节:
操作数的存储:Operation 的
operands 存储为 Value
的指针数组。Value 本身是一个轻量级的句柄——8
字节,内部是指向 ValueImpl
的指针。操作数的读写通过
MutableOperandRange,这个类支持
insert/erase/replace 操作,同时维护 def-use 链。
Use 链表:每个 Operation 的
result 维护一个双向链表,记录所有使用者。给定
Operation* op,可以通过
op->getResult(0).getUses()
遍历所有消费该结果的 Op。反过来,给定一个 operand,可以通过
op->getOperand(0).getDefiningOp()
找到产生它的 Op。
属性(Attribute):编译期已知的常量元数据,比如
arith.addi 不需要属性(溢出行为是固定的),但
arith.constant 的常量值存储为属性。属性存储在
Operation 内的 DictionaryAttr
中,是写时复制的不可变对象。
区域(Region):每个
Operation 可以持有若干
Region。func.func 的 body 是一个
Region;scf.for 的循环体是一个
Region;scf.if 的 then-branch 和 else-branch
分别是两个 Region。Region 是 Operation
的组成部分,不独立于 Operation 存在。
三、Op:类型安全的操作包装器
直接操作 Operation*
是可能的,但极其繁琐——你需要手动查询 result types、attribute
names(通过字符串比较)等。MLIR 提供的 Op
模板类使用 CRTP(Curiously Recurring Template Pattern)将
Operation* 包装为特定方言 Op 的强类型接口。
继承关系(概念层面):
OpBase (所有 Op 的基类,继承自 OpState)
│
├── Op<ConcreteType, Traits...>
│ │
│ ├── arith::AddIOp (通过 TableGen 生成)
│ ├── scf::ForOp
│ ├── func::FuncOp
│ └── ... 用户自定义 Op
│
└── Traits(混入类,提供通用功能)
├── OpTrait::OneResult
├── OpTrait::SameOperandsAndResultType
├── OpTrait::IsTerminator
└── ... 更多 Traits
使用示例:
// 原始指针——脆弱,类型不安全
Operation *op = ...;
if (op->getName().getStringRef() == "arith.addi") {
// 手动访问属性:op->getAttr("...")——字符串拼写错误编译器不会报错
}
// 类型化包装——编译器检查类型
arith::AddIOp addOp = cast<arith::AddIOp>(op);
Value lhs = addOp.getLhs(); // 返回类型已知
Value rhs = addOp.getRhs();
Value result = addOp.getResult();类型化 Op 包装器提供了:
- 具名访问器:
getLhs()、getResult()、getCondition()等,由 TableGen 根据 Op 定义自动生成。 - 构建器:类型安全的
build()方法,参数列表由 Op 定义决定。 - 验证委托:
Op::verify()虚函数——MLIR 验证器在 Op 构造后自动调用。
从 Operation* 到 Op
的转换:
// 向下转型(可能有运行时类型检查)
auto addOp = dyn_cast<arith::AddIOp>(op);
if (addOp) { /* op 确实是 arith.addi */ }
// 强制转型(断言类型匹配)
auto addOp = cast<arith::AddIOp>(op);
// 检查 Op 名称(不加载方言定义)
if (isa<arith::AddIOp>(op)) { /* ... */ }四、Value:SSA 值句柄
Value 在 MLIR 中是 SSA
值的句柄——它是一个 8
字节的轻量对象,内部持有指向 ValueImpl
的指针。Value 的语义类似 LLVM 的
Value*,但关键区别是:Operation
不继承自 Value。
一个 Value 必然属于两种来源之一:
if (value.isa<OpResult>()) {
// 由某个 Operation 产生
OpResult result = value.cast<OpResult>();
Operation *definingOp = result.getOwner();
int resultNumber = result.getResultNumber();
}
if (value.isa<BlockArgument>()) {
// 由某个 Block 声明(函数参数、循环归纳变量等)
BlockArgument arg = value.cast<BlockArgument>();
Block *owner = arg.getOwner();
int argNumber = arg.getArgNumber();
}这个区分在写 Pass 时经常用到:
Operation *definingOp = value.getDefiningOp();
if (definingOp) {
// Value 是某个 Op 的 result
} else {
// Value 是 Block argument(无法直接追溯"谁定义了它",只能分析)
}常见 Value 操作:
Value v;
// 获取类型
Type t = v.getType();
// 替换所有使用(RAUW — Replace All Uses With)
v.replaceAllUsesWith(newValue);
// 遍历所有使用者
for (OpOperand &use : v.getUses()) {
Operation *user = use.getOwner();
// ...
}五、Block:基本块与参数化入口
Block 是一个有序的 Operation
列表,末尾必须有一个终结符(Terminator)——终结符指明了该
Block 执行完毕后的控制流去向。
Block &block = ...;
// 遍历 Operation
for (Operation &op : block) {
// ...
}
// 获取终结符——Block 的最后一个 Operation
Operation *terminator = block.getTerminator();
// 获取终止符之前的末尾(非终结符部分)
Block::iterator lastNonTerm = block.without_terminator().end();
// 添加参数
BlockArgument arg = block.addArgument(IntegerType::get(&ctx, 32), loc);
// 获取 Block 参数
for (BlockArgument arg : block.getArguments()) {
// arg 是 Value
}
// 获取父 Region
Region *parentRegion = block.getParent();Block 参数是 LLVM IR 中 phi 节点的泛化。在 MLIR
中,控制流汇合点不是靠 phi 指令,而是靠后继 Block
的参数。cf.br ^bb1(%val : i32) 将
%val 绑定到目标 Block 的参数。这种表示比 phi
节点更容易分析和转换。
六、Region:区域的嵌套容器
Region 包含一个有序的 Block
列表——第一个 Block 是入口(entry block),其他 Block
只能通过控制流到达。
Region ®ion = ...;
// 遍历 Block
for (Block &block : region) {
// ...
}
// 获取入口 Block
Block &entryBlock = region.front();
// 获取拥有此 Region 的 Op
Operation *parentOp = region.getParentOp();
// 判断 Region 是否为单 Block 区域
bool isSingleBlock = region.hasOneBlock();Region 控制的副作用范围和值作用域。SSA 值的可见性遵循嵌套规则:外层 Region 的 Value 在内层 Region 和兄弟 Region 中可见;内层 Region 的 Value 在外层 Region 中不可见。
七、Dialect:方言的 C++ 注册与加载
每个方言是一个继承自 Dialect
的类,在构造时注册该方言的 Operation 集合和 Type 集合:
// mlir/include/mlir/Dialect/Arith/IR/Arith.h (简化)
class ArithDialect : public Dialect {
public:
explicit ArithDialect(MLIRContext *context);
};
// mlir/lib/Dialect/Arith/IR/ArithDialect.cpp (简化)
void ArithDialect::initialize() {
// 注册 Operation
addOperations<
#define GET_OP_LIST
#include "mlir/Dialect/Arith/IR/ArithOps.cpp.inc"
#undef GET_OP_LIST
>();
}MLIRContext 是所有 IR
对象的生命周期容器——拥有所有方言实例、所有类型、所有属性。MLIRContext
持有已加载方言的注册表,通过 Op 名称查找对应的
AbstractOperation:
MLIRContext ctx;
ctx.getOrLoadDialect<arith::ArithDialect>();
// 通过名称查找 Operation
auto abstractOp = AbstractOperation::lookup("arith.addi", &ctx);八、综合:IR 的内存全景图
将一个完整的 .mlir Module 映射到 C++
对象的内存布局:
MLIRContext
└── OwningOpRef<ModuleOp>
└── Operation (module)
├── Region
│ └── Block (top-level)
│ ├── Operation (func.func @foo)
│ │ ├── Region (function body)
│ │ │ └── Block (entry)
│ │ │ ├── BlockArgument %arg0: i32
│ │ │ ├── Operation (arith.constant)
│ │ │ ├── Operation (arith.addi)
│ │ │ │ ├── Operands: [%arg0, %constant]
│ │ │ │ └── Results: [%add_result]
│ │ │ └── Operation (func.return)
│ │ │ └── Operands: [%add_result]
│ │ └── (attributes)
│ └── Operation (func.func @bar)
│ └── ...
└── (attributes)
这个结构在写 Pass 时有几个重要的遍历路径:
- 深度优先遍历所有
Op:
op->walk([](Operation *op) { ... }) - 遍历一个 Value
的所有使用:
value.getUses() - 遍历一个 Block
的所有操作:
block.walk([](Operation *op) { ... }) - 访问 Region
的入口块:
region.front()
大部分 Pass 代码围绕这四种遍历模式写。
九、本篇边界
本篇覆盖了 MLIR IR
所有基础数据结构。下一章进类型系统和属性——理解
Type 和 Attribute
的区别、内建类型如何映射到方言类型、OpBuilder
的用法。
参考资料
官方文档(A 级)
- MLIR Language Reference — https://mlir.llvm.org/docs/LangRef/
- MLIR Infrastructure Documentation — https://mlir.llvm.org/docs/Infrastructure/
源码(A 级)
mlir/include/mlir/IR/Operation.h— Operation 定义mlir/include/mlir/IR/Value.h— Value 定义mlir/include/mlir/IR/Block.h— Block 定义mlir/include/mlir/IR/Region.h— Region 定义mlir/include/mlir/IR/OpDefinition.h— Op 模板类mlir/include/mlir/IR/Dialect.h— Dialect 基类
论文(A 级)
- Lattner, C. et al. MLIR: A Compiler Infrastructure for the End of Moore’s Law. arXiv:2002.11054, 2020.
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【编译器与 MLIR】Region 与 Block:IR 的控制流骨架
解析 MLIR 的嵌套区域控制流表示:Block 参数替代 phi 节点的设计动机、Region 的 SSACFG 与 Graph 两种类型、结构化控制流的表示能力,以及与 LLVM 经典 SSA 形式的对比。
【编译器与 MLIR】编译器的挑战与 IR 的裂变
从三阶段编译器局限出发,串联 Halide、XLA、TVM 的 IR 裂变,说明 DSA 与 AI 编译器为何需要 MLIR 这类可组合的多层 IR 框架。
【编译器与 MLIR】MLIR 全景图与设计哲学
从 Module-Operation-Region-Block 四层结构出发,系统讲解 MLIR 的三条核心设计原则:渐进降阶、方言可组合性、基础设施复用,配合 IREE、CIRCT、Torch-MLIR 等实际案例建立心智模型。
【编译器与 MLIR】环境搭建与第一个 MLIR 程序
从零构建 LLVM/MLIR 工程,用 mlir-opt 理解 .mlir 文本表示,运行规范化 Pass 并逐行解读转换结果,建立从命令行到 IR 变换的直觉。