本文面向希望系统学习 MLIR、深度学习编译器和计算图优化的读者。
1. 学习目标
学习这篇文章时,应能回答四个问题:
- 计算图如何表达模型的输入、参数、算子和数据依赖?
- 为什么需要 IR,而不是直接从模型生成机器指令?
- MLIR 的 operation、value、block、region 和 dialect 分别解决什么问题?
- 一个高层张量算子如何逐步 lowering 为循环、内存、向量和目标指令?
2. 机器学习编译流程
典型流程如下:
模型代码
-> 前端捕获或导出,例如 FX、TorchScript、ONNX
-> 高层计算图,例如张量和神经网络算子
-> MLIR 高层方言,例如 linalg、tensor
-> 结构化循环和内存,例如 scf、affine、memref
-> 向量和目标方言,例如 vector、gpu、llvm
-> CPU 指令、GPU kernel 或专用加速器指令 每次转换都可能丢失一部分高层信息。因此编译器通常遵循“渐进式 lowering”:尽可能晚地降低抽象,以便在高层信息仍然存在时完成融合、分块、布局变换和形状分析。
3. 什么是计算图
计算图是描述计算依赖关系的有向图。节点可以表示输入、参数、常量或算子,边表示某个值被另一个算子消费。
线性层 y = ReLU(x * W + b) 可以表示为:
x -----> MatMul -----> Add -----> ReLU -----> y
^ ^
| |
W b 这里的图只说明:Add 依赖 MatMul 的结果,ReLU 依赖 Add 的结果。它没有决定线程数、内存位置、数据布局或硬件指令,这些属于后续编译阶段。
3.1 数据流和控制流
深度学习模型常用数据流图表示张量计算。许多交换格式要求图无环,并按拓扑顺序保存节点。
但完整程序还包含条件、循环和副作用,例如训练模式和推理模式可能选择不同分支。条件和循环不能只用张量边表达,需要控制流、区域或嵌套子图。MLIR 使用 scf.if、scf.for、cf.br 等 operation 表达这些结构。
因此,计算图是程序的一个重要视图,但不是完整程序语义。涉及随机数、原地更新、状态变量或输入输出时,还必须明确副作用和执行顺序。
3.2 前向计算与自动微分
前向图描述模型如何由输入计算输出。训练时还需要损失函数对参数的梯度。
如果 z = f(x)、y = g(z)、L = h(y),则反向传播按链式法则计算:
dL/dx = dL/dy * dy/dz * dz/dx 自动微分系统需要决定哪些中间值保存到反向阶段,哪些中间值在反向阶段重新计算。保存会增加内存占用,重计算会增加计算量,这种权衡会影响训练图的编译策略。
4. 静态图、动态图和图捕获
静态图在执行前构建完整图结构,便于拓扑分析、常量折叠、算子融合、内存规划和设备分配。动态图在程序执行时记录操作,更接近普通命令式代码,也更容易表达运行时控制流。
两者的核心差异不是“谁更先进”,而是编译时信息和运行时灵活性的取舍。现代框架通常通过 tracing、symbolic capture、source transformation 或运行时编译,在两者之间建立桥梁。
以 PyTorch FX 为例,symbolic tracing 可以把部分 Python module 或函数捕获为 GraphModule。捕获结果适合做节点级变换,但不代表任意 Python 程序的完整语义。反射、数据依赖的 Python 分支和无法追踪的动态行为,可能需要显式改写。
工程上应区分:原始模型程序、前端捕获结果、可验证的交换图,以及针对后端的低层程序。每次转换都需要定义输入范围和语义契约。
5. 计算图优化
图优化必须保持语义不变,同时结合目标硬件的成本模型。
5.1 常量折叠和传播
只依赖常量的子图可以在编译期求值,例如:
Add(constant_1, constant_2) -> constant_3 这可以减少运行时计算,但可能增加模型文件大小,需要综合考虑权重存储和加载成本。
5.2 死代码和公共子表达式
没有任何用户的结果可以删除。对于输入、属性和副作用都相同的纯计算,可以复用已有结果。随机数、原地写入、状态更新和 I/O 具有副作用,不能按普通纯函数处理。
5.3 算子融合
MatMul -> Add -> ReLU 可以融合为一个 kernel,减少中间张量的写回和再次读取。但融合也可能增加寄存器压力、降低并行度或限制布局选择,因此应使用成本模型,而不能只按节点数量判断。
5.4 形状、类型和布局
编译器需要区分静态形状、动态形状和未知秩。例如,矩阵乘法的输入可以描述为:
A: tensor [M, K] of f32
B: tensor [K, N] of f32
C: tensor [M, N] of f32 其中 M、K、N 可以是编译期常量,也可以是运行时符号。形状推导能够提前发现维度错误,并帮助生成循环边界和内存分配。布局则决定元素在内存中的排列,例如 NCHW、NHWC 或 blocked layout,通常直接影响缓存、向量化和硬件访存效率。
6. IR 的作用
IR 是源程序和目标指令之间的结构化表示。一个好的 IR 应当具备:
- 语义明确,操作、输入、输出和副作用可验证;
- 结构化,编译器可以稳定遍历、匹配和重写;
- 可分析,支持类型、形状、数据流和内存分析;
- 可扩展,能够加入新的算子、硬件特性和领域抽象;
- 可降低,能够逐步转换到更低层,而不是一次跳到机器码。
6.1 SSA
静态单赋值要求每个值只有一个定义。概念上:
%0 = matmul %x, %w
%1 = add %0, %b
%2 = relu %1
return %2 SSA 让 use-def 关系清晰,便于常量传播、死代码消除和依赖分析。控制流汇合时,传统 SSA 使用 phi;MLIR 中通常使用 block 参数表达同类信息。
6.2 图 IR 和线性 IR
ONNX 是面向模型交换和运行时的图格式,定义了图、节点、值、类型、形状、属性和 operator set。其计算图通常是无环数据流图,并要求输出值遵守 SSA 风格的唯一命名和定义规则。
MLIR operation 既可以表示图节点,也可以表示函数、循环、分支、内存和目标指令。因此 MLIR 不是简单的节点列表,而是一套能够组合多种程序结构的 IR 基础设施。
7. MLIR 的核心结构
7.1 Operation
Operation 是 MLIR 的基本单位,通常包含:
- operands:输入值;
- results:输出值;
- attributes:编译期已知的参数;
- regions:嵌套代码区域;
- location:源码位置和诊断信息。
一个 MLIR 风格的张量计算可以写成:
%mm = "my.matmul"(%x, %w) : (tensor_of_M_by_K_f32, tensor_of_K_by_N_f32) -> tensor_of_M_by_N_f32
%y = "my.add"(%mm, %b) : (tensor_of_M_by_N_f32, tensor_of_N_f32) -> tensor_of_M_by_N_f32 实际工程通常使用具体 dialect 定义的 operation,而不是长期保留临时的自定义 operation。
7.2 Value、Block 和 Region
Value 是 operation 的结果或 block 参数。Block 是一组顺序执行的 operation,并可以带参数。Region 是一个或多个 block 的容器,可以表示函数体、循环体、条件分支或嵌套区域。
这使 MLIR 能够同时表达数据流和控制流。循环变量、函数参数以及分支结果都可以通过 SSA value 和 block 参数传递。
7.3 Dialect
Dialect 是一组相关 operation、type 和 attribute 的命名空间。常见方言包括:
-
func:函数和函数调用; -
arith:整数、浮点和常量算术; -
tensor:值语义的张量操作; -
memref:带布局和内存空间的内存引用; -
linalg:结构化线性代数; -
scf:结构化控制流; -
affine:仿射循环和访存; -
vector:向量级操作; -
gpu、nvvm、llvm:GPU、NVVM 和 LLVM 相关低层表示。
Dialect 的价值在于保留领域语义。若过早把矩阵乘法拆成大量 load、add、mul,后续就很难做分块、融合和硬件指令匹配。
8. MLIR 的逐步 Lowering
以 y = ReLU(x * W + b) 为例,可以经过:
高层算子: matmul + bias + relu
结构化线代: linalg.matmul + linalg.add + linalg.generic
循环表示: scf.for 或 affine.for
内存表示: memref.load 和 memref.store
向量表示: vector.contract 和 vector.transfer
目标表示: llvm、gpu 或专用加速器 dialect 每次 lowering 都要回答:
- 输入和输出语义是否保持一致;
- 类型、形状、布局和副作用哪些被保留或丢弃;
- 下一层方言是否能够表达当前 operation 的全部约束。
8.1 Pass 和 Pattern Rewrite
Pass 是在 module、function 或 operation 上运行的编译变换。Pattern rewrite 通常执行匹配、条件检查和替换。
可靠的 pass 应当:
- 明确输入和输出 dialect;
- 声明需要的分析和合法化条件;
- 只在语义条件满足时重写;
- 转换后运行 verifier;
- 对动态形状、边界条件、副作用和数值精度保持谨慎。
能匹配到 operation 不代表一定可以安全融合。真正的融合还需要检查形状兼容、广播规则、别名关系、布局和执行顺序。
9. 常见格式的分工
- PyTorch FX:靠近 PyTorch 语义的程序捕获和变换图。
- ONNX:面向模型交换和运行时的标准化计算图格式。
- StableHLO 或 MHLO:偏向稳定的高层张量算子语义,常作为框架与编译器的边界。
- MLIR:承载多层语义并执行 dialect conversion、优化和目标代码生成。
- LLVM IR:更低层的 SSA 表示,接近通用 CPU 或 GPU 后端。
它们不是互相替代的关系。一个系统可以先从框架捕获为 FX,再导出为 ONNX 或 StableHLO,进入 MLIR pipeline,最后 lowering 到 LLVM、GPU 或专用硬件方言。
10. 学习 MLIR 的路线
- 先掌握 SSA、use-def、基本块和控制流。
- 阅读 MLIR Toy Tutorial,理解 AST 到 dialect conversion 的完整流程。
- 熟悉
module、func.func、region、attribute 和 typed SSA value。 - 学习
arith、func、scf和memref,建立基本骨架。 - 学习
linalg和tensor,理解结构化算子、形状与 bufferization。 - 跟踪一个 pass,观察 match、rewrite、合法化和 verifier 如何协作。
- 实现一个小 dialect 或转换,例如把自定义 add 转换为
arith.addf。 - 最后学习 GPU、LLVM 或 NPU 方言,分析目标硬件约束如何进入 IR。
学习时不要只记 operation 名称,要同时追踪值的类型、形状、布局、内存位置、所有权、控制流和副作用。
11. 总结
计算图提供模型的数据依赖视角;IR 提供程序的可验证、可变换和可降低视角;MLIR 通过 dialect、operation、region、SSA 和 pass 把不同抽象层连接起来。
最重要的思维链是:
模型语义
-> 数据依赖和控制流
-> 类型、形状、布局和副作用
-> 合法的 IR 变换
-> 结构化 lowering
-> 面向目标硬件的执行代码 只要能回答每一步“表示了什么、丢失了什么、为什么可以这样变换、下一层如何实现”,就真正进入了计算图编译器和 MLIR 的核心问题。
参考资料
- MLIR Concepts
- MLIR Toy Tutorial
- ONNX IR Specification
- PyTorch FX
- MLIR Linalg Dialect
- MLIR Pass Management
12. 背景:为什么需要多层 IR
12.1 从编译器传统到机器学习编译
传统编译器通常经历词法和语法分析、抽象语法树、类型检查、优化 IR、目标相关 IR 和机器码生成。早期编译器常为每种语言和目标机器设计一套紧耦合的中间表示,随着语言、处理器和加速器数量增加,这种方式会产生大量转换组合。
机器学习编译器进一步放大了这个问题。前端可能来自 PyTorch、TensorFlow、JAX 或 ONNX;目标可能是 CPU、GPU、TPU、NPU、DSP 或 FPGA;模型中的算子又包含张量、量化、稀疏、动态形状和分布式通信。若直接为每个前端和后端编写专用转换,维护成本会迅速失控。
MLIR 的设计回应了这个问题:用可扩展的 dialect 表示不同抽象层,并允许多个 dialect 在同一个 module 中共存。高层 dialect 保留模型语义,低层 dialect 表示循环、内存、向量和硬件指令;中间通过 conversion、interface 和 pass 连接。
12.2 与经典编译器理论的联系
学习 MLIR 不能脱离经典编译器知识:
- SSA 和 dominance 支撑 use-def 分析、常量传播和控制流变换;
- 数据依赖分析决定循环交换、融合、分块和并行化是否合法;
- polyhedral 思想把迭代空间、仿射访问和调度变换显式化;
- 寄存器分配、缓存局部性和指令选择解释了为什么 lowering 不能只看算子数量;
- ABI、调用约定和内存模型决定外部库调用以及最终代码生成是否正确。
因此,MLIR 不是替代编译器基础,而是把这些基础能力组织成适合多层抽象和领域扩展的框架。
13. 工程实现中的真实案例
13.1 MLIR Toy:从语言前端到 LLVM
MLIR 官方 Toy Tutorial 实现了一个小型数组语言。它不是为了提供生产级编译器,而是展示一条完整路线:
Toy 源代码
-> Toy AST
-> Toy dialect
-> shape inference 和 canonicalization
-> affine 或 linalg 等通用 dialect
-> LLVM dialect
-> LLVM IR 和目标代码 这个案例最值得学习的不是 Toy operation 本身,而是三个工程边界:
- 前端语义先保存在自定义 dialect 中,而不是一开始就拆成低层循环;
- 通用优化通过 Interfaces 接入,而不是让每个 pass 依赖某个具体类;
- lowering 是分阶段的,每个阶段都能用 verifier 和 FileCheck 测试。
13.2 Linalg:为什么矩阵乘法不应立即变成三重循环
矩阵乘法可以直接写成三重循环,但如果一开始就降低为 scf.for,编译器可能失去矩阵乘法的结构信息。Linalg 通过 linalg.matmul 和 linalg.generic 保留以下信息:
- 哪些维度是 parallel iterator;
- 哪些维度是 reduction iterator;
- 每个 operand 如何通过 indexing map 映射到迭代空间;
- 计算区域如何产生输出元素;
- 结果是否采用 destination-passing style。
典型的矩阵乘法索引映射如下:
#matmul_accesses = [
affine_map<(m, n, k) -> (m, k)>,
affine_map<(m, n, k) -> (k, n)>,
affine_map<(m, n, k) -> (m, n)>
]
#matmul_trait = {
indexing_maps = #matmul_accesses,
iterator_types = ["parallel", "parallel", "reduction"]
} 这组属性表达 C(m,n) += A(m,k) * B(k,n)。之后可以在仍保留结构信息的情况下做 tiling、promotion、producer-consumer fusion、vectorization,或者将操作映射到 BLAS、GPU MMA 指令和专用矩阵单元。
13.3 StableHLO 和 OpenXLA:框架与编译器之间的可移植边界
StableHLO 的定位是高层机器学习 operation set,作为机器学习框架和编译器之间的 portability layer。它的工程价值不只是“又一种算子格式”,而是把算子语义、类型推导、动态形状、量化、版本兼容和参考解释器放在一个可验证的规范中。
一个实际链路可以是:
JAX / TensorFlow / PyTorch
-> StableHLO
-> StableHLO simplification 和 shape refinement
-> linalg 或 tosa
-> tensor / bufferization / memref
-> vector / gpu / llvm 或硬件 dialect StableHLO 适合作为跨框架交换边界;MLIR 适合作为编译器内部的多层变换框架。两者职责不同,但可以通过 MLIR dialect conversion 连接。
13.4 Bufferization:tensor 什么时候变成内存
高层 tensor 是值语义,适合做融合和形状推导;真实硬件最终需要内存、地址、布局和读写。MLIR 的 One-Shot Bufferize 通常在高层 tensor 变换之后,把 tensor 语义转换为 memref 语义。
关键问题不是简单地“给每个 tensor 分配一个 buffer”,而是判断结果能否复用某个已有 buffer。如果一个 tensor 的旧值仍被后续 operation 读取,而当前 operation 又想原地写入,就会产生 read-after-write 冲突,必须插入 copy。
tensor SSA value
-> use-def 和 alias 分析
-> 判断 in-place 或 out-of-place
-> memref、load/store 和 subview
-> ownership-based deallocation 这解释了一个常见现象:同一张计算图在数学上等价,但由于 use-def 链不同,最终生成的内存拷贝数量可能不同,性能也会显著不同。
14. MLIR 文本语法入门
14.1 Module、函数和 SSA value
MLIR 文本文件通常以 module 作为顶层容器,函数用 func.func 表示。值以 % 开头,符号以 @ 开头,基本块标签以 ^ 开头,属性别名以 # 开头。
module {
func.func @add(%lhs: f32, %rhs: f32) -> f32 {
%sum = arith.addf %lhs, %rhs : f32
return %sum : f32
}
} %lhs 和 %rhs 是入口 block 参数;%sum 是 arith.addf 的结果;每个 SSA value 只有一个定义。类型写在操作数或结果之后,帮助 verifier 检查 operation 是否满足约束。
14.2 常量、比较和条件分支
func.func @select_positive(%x: f32) -> f32 {
%zero = arith.constant 0.0 : f32
%cond = arith.cmpf ogt, %x, %zero : f32
%result = scf.if %cond -> (f32) {
scf.yield %x : f32
} else {
%neg = arith.negf %x : f32
scf.yield %neg : f32
}
return %result : f32
} 这里 scf.if 拥有两个 region,每个 region 通过 scf.yield 返回一个 f32。这与只在图中放一个 Select 节点不同:scf.if 表达的是控制流区域,分支内部可以包含任意合法 operation。
14.3 循环和 block 参数
func.func @sum(%arg0: memref<?xf32>, %n: index) -> f32 {
%zero = arith.constant 0.0 : f32
%c0 = arith.constant 0 : index
%c1 = arith.constant 1 : index
%result = scf.for %i = %c0 to %n step %c1
iter_args(%acc = %zero) -> (f32) {
%value = memref.load %arg0[%i] : memref<?xf32>
%next = arith.addf %acc, %value : f32
scf.yield %next : f32
}
return %result : f32
} %i 是循环 induction variable,%acc 是循环携带值。scf.yield 把下一次迭代的值传给下一次 block,也把最后一次迭代的值作为 scf.for 的结果。这就是 MLIR 使用 block 参数和 region result 表达 SSA 控制流的典型方式。
14.4 Tensor 和 MemRef 的区别
func.func @relu_tensor(%input: tensor<4x?xf32>) -> tensor<4x?xf32> {
%output = tensor.empty() : tensor<4x?xf32>
%result = linalg.map { arith.maxf }
ins(%input : tensor<4x?xf32>)
outs(%output : tensor<4x?xf32>) -> tensor<4x?xf32>
return %result : tensor<4x?xf32>
} Tensor 表示不可变的值,适合高层变换。MemRef 表示可寻址的内存区域,包含形状、元素类型、布局和地址空间等信息:
%buffer = memref.alloc() : memref<4x128xf32>
%value = memref.load %buffer[%i, %j] : memref<4x128xf32>
memref.store %value, %buffer[%i, %j] : memref<4x128xf32>
memref.dealloc %buffer : memref<4x128xf32> Tensor 到 MemRef 的转换叫 bufferization,不应简单理解为语法替换,因为它涉及别名、生命周期、读写冲突和内存复用。
14.5 linalg.generic 的结构
#map = affine_map<(i, j) -> (i, j)>
%result = linalg.generic {
indexing_maps = [#map, #map, #map],
iterator_types = ["parallel", "parallel"]
} ins(%lhs, %rhs : tensor<4x128xf32>, tensor<4x128xf32>)
outs(%init : tensor<4x128xf32>) {
^bb0(%a: f32, %b: f32, %old: f32):
%sum = arith.addf %a, %b : f32
linalg.yield %sum : f32
} -> tensor<4x128xf32> 这个例子中:
-
indexing_maps将逻辑迭代空间映射到每个 operand 的下标; -
iterator_types声明两个维度都是 parallel; - region 的 block 参数代表当前元素;
-
linalg.yield产生输出元素; -
outs提供结果的形状和初始值,是 destination-passing style 的一部分。
14.6 属性、类型别名和 AffineMap
属性是 operation 的编译期元数据。常见形式包括字符串、整数、数组、字典和 affine map:
#identity = affine_map<(i, j) -> (i, j)>
%index = affine.apply affine_map<(d0) -> (d0 + 4)> (%i) 类型别名和属性别名可以减少大型 IR 的重复:
!matrix = tensor<4x?xf32>
#identity = affine_map<(i, j) -> (i, j)> Affine map 的输入是符号维度,输出是访问下标或计算结果。它是连接循环迭代空间、张量布局和内存访问的重要语法。
14.7 Generic operation 语法
当 dialect 没有提供 custom assembly,或者需要精确查看 operation 的所有组成部分时,可以使用 generic form:
%sum = "arith.addf"(%lhs, %rhs) : (f32, f32) -> f32 它明确列出 operation 名称、operands、attributes、regions 和函数类型。Pretty form 如 arith.addf %lhs, %rhs : f32 更适合日常阅读;Generic form 更适合调试解析器、编写测试和理解 MLIR 语法。
15. 建议的动手实验
在已构建 MLIR 工具链的环境中,可以用以下方式观察 IR 变换:
mlir-opt input.mlir -canonicalize
mlir-opt input.mlir -convert-linalg-to-loops
mlir-opt input.mlir -one-shot-bufferize
mlir-opt input.mlir -convert-scf-to-cf
mlir-opt input.mlir -convert-func-to-llvm 每次只增加一个 pass,并比较前后 IR:
- operation 是否被替换;
- SSA value 的类型是否变化;
- tensor 是否变成 memref;
- region 是否变成 block 和 branch;
- 是否产生了额外的 alloc、copy 或 dealloc;
- 高层信息在什么时候消失。
实际版本中的 pass 名称、合法化条件和方言接口可能随 MLIR 版本变化,因此应以当前构建的 mlir-opt --help 和对应 dialect 文档为准。
16. 书籍与论文阅读建议
16.1 编译器基础
- Alfred V. Aho、Monica S. Lam、Ravi Sethi、Jeffrey D. Ullman,《Compilers: Principles, Techniques, and Tools》:建立词法、语法、类型、优化和代码生成的全局框架。
- Keith D. Cooper、Linda Torczon,《Engineering a Compiler》:更强调工程实现、控制流图、数据流分析、指令选择和寄存器分配,适合连接理论与真实编译器。
- Steven S. Muchnick,《Advanced Compiler Design and Implementation》:适合深入理解过程间分析、优化和后端实现。
- Robert Allen、Ken Kennedy,《Optimizing Compilers for Modern Architectures》:理解循环变换、依赖分析、并行化和调度;阅读 Linalg 时尤其有帮助。
16.2 深度学习系统和编译器
- 陈云霁等,《深度学习处理器架构》:从硬件代价理解算子映射、数据复用、存储层次和专用加速器。
- 王海玲等,《深度学习系统》:帮助建立模型、运行时、编译器和硬件协同优化的系统视角。
- 《Machine Learning Systems: Design and Implementation》相关课程与资料:适合把模型图、自动微分、编译和运行时放在同一条链路中理解。
阅读书籍时不要把“计算图优化”只理解为节点级图重写。对真实硬件而言,算子融合、布局、内存复用、分块、量化和通信往往共同决定最终性能。
16.3 MLIR 和工程资料
- MLIR Language Reference:理解文本语法、operation、block、region、type 和 attribute。
- MLIR Toy Tutorial:最适合建立从语言前端到 LLVM 的完整样例。
- MLIR Linalg Dialect:理解 structured operation、indexing map、iterator type、tiling、fusion 和 library call。
- MLIR Bufferization:理解 tensor 到 memref 的语义变化、One-Shot Bufferize 和 read-after-write 冲突。
- StableHLO Specification:理解框架到编译器的可移植算子边界、版本兼容和动态形状。
参考资料
- MLIR Language Reference
- MLIR Toy Tutorial
- MLIR Linalg Dialect
- MLIR Bufferization
- MLIR Pass Management
- ONNX IR Specification
- StableHLO
- PyTorch FX
17. 图示化理解:从模型到机器执行
下面的图不是独立知识点,而是把本文的主线串起来。建议先看图,再回到对应章节阅读语法和实现细节。
17.1 图一:机器学习编译器的分层流水线
flowchart LR
A[模型代码] --> B[前端捕获]
B --> C[StableHLO / ONNX / FX]
C --> D[高层 MLIR 方言]
D --> E[Linalg / Tensor 优化]
E --> F[Bufferization]
F --> G[SCF / Affine / MemRef]
G --> H[Vector / GPU]
H --> I[LLVM IR 或硬件方言]
I --> J[CPU / GPU / NPU 指令] 读图重点:
- 左侧表示模型语义,右侧表示硬件执行语义;
- 越向右,类型、形状、布局、内存和并行映射越具体;
- 不是所有程序都必须经过每一个方言,实际 pipeline 会根据目标后端跳过或增加阶段;
- 每个箭头都对应一个转换或优化 pass,而不是简单的文本替换。
工程含义:
高层算子越早被拆成 load、store 和循环,越难进行算子融合和硬件匹配。因此实际编译器通常在 linalg 或 StableHLO 层完成主要的模型级优化,再逐步降低到内存和指令层。
17.2 图二:计算图、控制流和 MLIR Region 的关系
flowchart TD
X[输入 x] --> C{条件 cond}
C -->|true| A[矩阵乘法]
C -->|false| B[旁路或备用计算]
A --> D[结果值]
B --> E[结果值]
D --> F[Region 结果]
E --> F
F --> G[后续算子] 在 MLIR 中,这类结构不是把两个结果简单连到一个节点,而是由一个包含两个 region 的 operation 表达:
scf.if
region 0: matmul -> scf.yield result_a
region 1: bypass -> scf.yield result_b
result = scf.if result_a/result_b 读图重点:
- 数据流边描述值的依赖;
- 分支边描述控制流的选择;
-
scf.yield把 region 内的值返回给父 operation; - 两条分支必须满足相同的结果类型约束。
这也是普通“无环计算图”和完整程序 IR 的区别:计算图擅长表达张量依赖,MLIR Region 还能表达条件、循环、函数体和嵌套作用域。
17.3 图三:MLIR 的 Operation、Value、Block、Region 层次
graph TD
M[module operation] --> F[func.func operation]
F --> R[function region]
R --> B0[entry block]
R --> B1[then block]
R --> B2[else block]
B0 --> O1[arith.constant]
B0 --> O2[scf.if]
O2 --> R1[then region]
O2 --> R2[else region]
O1 --> V1[SSA value]
O2 --> V2[SSA result] 四个层次的职责:
| 对象 | 作用 | 常见问题 |
|---|---|---|
| Operation | 表示一次计算、函数、分支、分配或调用 | 输入输出类型是否正确 |
| Value | 表示 operation result 或 block argument | 是否满足 SSA 和作用域规则 |
| Block | 表示顺序执行的 operation 序列 | 末尾是否有合法 terminator |
| Region | 表示嵌套的代码区域 | region 的控制流和返回值语义是什么 |
可以把它们理解为:Operation 是语句,Value 是数据,Block 是基本块,Region 是带作用域的代码容器。MLIR 的强大之处在于 Operation 本身可以拥有 Region,所以函数、循环和条件都能用同一套结构表达。
17.4 图四:Linalg Matmul 如何携带迭代空间
矩阵乘法的数学形式是:
C(m, n) = sum over k of A(m, k) * B(k, n) 对应的迭代空间和索引映射如下:
flowchart LR
S[iteration space: m, n, k]
S --> A[A access: m, k]
S --> B[B access: k, n]
S --> C[C access: m, n]
K[k is reduction]
M[m is parallel]
N[n is parallel] #maps = [
affine_map<(m, n, k) -> (m, k)>,
affine_map<(m, n, k) -> (k, n)>,
affine_map<(m, n, k) -> (m, n)>
]
#traits = {
indexing_maps = #maps,
iterator_types = ["parallel", "parallel", "reduction"]
} 读图重点:
-
m和n决定输出元素,可以并行; -
k是累加维度,必须执行 reduction; -
indexing_maps把统一的逻辑迭代空间映射到不同 operand 的下标; - 由于迭代空间和访问关系是显式的,编译器可以进行 tile、交换循环、向量化和 producer-consumer fusion。
这比直接写三重循环多了一层可分析的结构信息。Linalg 文档所说的“从数据结构反向推导控制结构”,本质就是能从这些 mapping 和 iterator type 生成合法循环。
17.5 图五:Linalg 到循环和内存访问
flowchart TD
A[linalg.matmul] --> B[选择 tile size]
B --> C[生成 m,n,k 迭代器]
C --> D[生成 scf.for 或 affine.for]
D --> E[memref.load A]
D --> F[memref.load B]
D --> G[memref.load C]
E --> H[arith.mulf]
F --> H
G --> I[arith.addf]
H --> I
I --> J[memref.store C] 对应的低层伪代码是:
for m = 0 .. M:
for n = 0 .. N:
acc = C[m, n]
for k = 0 .. K:
acc = acc + A[m, k] * B[k, n]
C[m, n] = acc 工程含义:
这一步之后,矩阵乘法的高层名字可能还存在,也可能已经被拆开。若目标是 CPU,下一步可能是向量化和 cache blocking;若目标是 GPU,下一步可能是映射到 thread、warp 和 shared memory;若目标是 NPU,可能是转换为矩阵单元或 DMA/tile 操作。相同的数学图可以得到完全不同的低层实现。
17.6 图六:Tensor 到 MemRef 的 Bufferization
flowchart LR
T0[tensor value t] --> U1[operation reads t]
T0 --> U2[operation writes result from t]
U1 --> Q{旧值仍需要吗?}
U2 --> Q
Q -->|否| I[in-place: 复用同一 buffer]
Q -->|是| O[out-of-place: 插入 copy]
I --> M[memref view]
O --> C[memref.copy]
C --> M2[新 memref view] 读图重点:
- tensor 是不可变 SSA 值;
- memref 是可读写的内存引用;
- One-Shot Bufferize 通过 use-def 链和 alias 分析判断能否原地写入;
- 如果一个旧 tensor 值在写入之后仍被读取,就产生 read-after-write 冲突,必须复制;
- 复制数量会直接影响内存带宽和端到端性能。
因此,Bufferization 不是“把 tensor 改名成 memref”,而是一个带有别名分析和内存决策的编译阶段。
17.7 图七:一个 operation 的语法拆解
%result = linalg.map
{some_attr = "example"}
ins(%input : tensor<4x128xf32>)
outs(%init : tensor<4x128xf32>)
-> tensor<4x128xf32> {
^bb0(%element: f32):
%zero = arith.constant 0.0 : f32
%value = arith.maxf %element, %zero : f32
linalg.yield %value : f32
} 可以拆成以下字段:
%result operation result
= linalg.map dialect.operation 名称
{...} attributes
ins(...) 输入 operands
outs(...) destination operands
-> tensor<...> result type
{ region } 嵌套 region
^bb0(%element: f32) block label 和 block argument
linalg.yield region terminator 这张图建议在阅读任何 MLIR 文件时反复使用。先找 operation 名称,再找 operands 和 results,然后看 type、attributes,最后进入 region 分析 block 参数与 terminator。
18. 图示阅读练习
练习一:判断哪些边是数据依赖
%x -> linalg.matmul -> %mm -> linalg.add -> %y
%w --------------------^
%b --------------------------------------^ %mm 是 linalg.matmul 的 result,%w 和 %b 是参数输入。图中横向和斜向箭头都是数据依赖,不代表执行线程或内存地址。
练习二:判断哪些维度可以并行
对于 C(m,n) = sum(k, A(m,k) * B(k,n)):
-
m和n是 parallel; -
k是 reduction; - 若错误地把
k当作 parallel,可能导致多个线程同时更新同一个累加结果,除非硬件或编译器显式提供 reduction 机制。
练习三:判断何时会出现 copy
%t1 = tensor.some_op %t0
%t2 = tensor.update %t1
%r = tensor.read %t1 如果 tensor.update 想复用 %t1 的 buffer,但 %r 仍需要读取更新前的 %t1,则存在 RaW 冲突,需要 copy 或重新安排计算。
19. 图与工程工具
建议用不同工具观察不同层次:
| 目标 | 工具或方法 |
|---|---|
| 查看模型算子图 | Netron、框架 graph printer |
| 查看 FX 图 | GraphModule.graph.print_tabular() |
| 查看 MLIR 文本 | mlir-opt、编辑器语法高亮 |
| 查看 pass 前后差异 | mlir-opt --print-ir-before-all --print-ir-after-all |
| 检查 IR 合法性 | mlir-opt --verify-diagnostics 或 verifier |
| 查看 LLVM IR | mlir-translate、llvm-dis |
| 分析 buffer copy | One-Shot Bufferize 的 test-analysis-only print-conflicts |
| 查看硬件执行 | profiler、kernel dump、trace 和性能计数器 |
一个成熟的调试流程不是只看最终性能,而是沿着图和 IR 逐层定位:模型是否正确捕获,形状是否正确推导,融合是否发生,bufferization 是否插入 copy,向量化和硬件映射是否成功。