土法炼钢兴趣小组的算法知识备份

【编译器与 MLIR】操作、方言与 IR 的 C++ 表示

文章导航

分类入口
compilerarchitecture
标签入口
#mlir#llvm#compiler#cpp#operation#value#block#region#ssa#ir

目录

操作、方言与 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 类继承自 UserValue,指令就是 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::AddInstruction::Store 等)来区分。IR 基础设施不认识 “add”——它只认识 Instruction

MLIR 则不同:每种操作都是 Operation 的一个独立子类型arith.addiarith.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 包装器提供了:

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 &region = ...;

// 遍历 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 时有几个重要的遍历路径:

  1. 深度优先遍历所有 Opop->walk([](Operation *op) { ... })
  2. 遍历一个 Value 的所有使用value.getUses()
  3. 遍历一个 Block 的所有操作block.walk([](Operation *op) { ... })
  4. 访问 Region 的入口块region.front()

大部分 Pass 代码围绕这四种遍历模式写。

九、本篇边界

本篇覆盖了 MLIR IR 所有基础数据结构。下一章进类型系统和属性——理解 TypeAttribute 的区别、内建类型如何映射到方言类型、OpBuilder 的用法。

参考资料

官方文档(A 级)

源码(A 级)

论文(A 级)

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-09 · compiler / architecture

【编译器与 MLIR】Region 与 Block:IR 的控制流骨架

解析 MLIR 的嵌套区域控制流表示:Block 参数替代 phi 节点的设计动机、Region 的 SSACFG 与 Graph 两种类型、结构化控制流的表示能力,以及与 LLVM 经典 SSA 形式的对比。

2026-06-09 · compiler / architecture

【编译器与 MLIR】MLIR 全景图与设计哲学

从 Module-Operation-Region-Block 四层结构出发,系统讲解 MLIR 的三条核心设计原则:渐进降阶、方言可组合性、基础设施复用,配合 IREE、CIRCT、Torch-MLIR 等实际案例建立心智模型。


By .