BLOG

Record, summarize, and improve.

计算图与中间表示(IR)

1. 学习目标 2. 机器学习编译流程 3. 什么是计算图 3.1 数据流和控制流 3.2 前向计算与自动微分 4. 静态图、动态图和图捕获 5. 计算图优化 5.1 常量折叠和传播 5.2 死代码和公共子表达式 5.3 算子融合 5.4 形状、类型和布局 6. IR 的作用 6.1 SSA 6.2 图 IR 和线性 IR 7. MLIR 的核心结构 7.1 Operation 7.2 Value、Block 和 Region 7.3 Dialect 8. MLIR 的逐步 Lowering 8.1 Pass 和 Pattern Rewrite 9. 常见格式的分工 10. 学习 MLIR 的路线 11. 总结 参考资料 12. 背景:为什么需要多层 IR 12.1 从编译器传统到机器学习编译 12.2 与经典编译器理论的联系 13. 工程实现中的真实案例 13.1 MLIR Toy:从语言前端到 LLVM 13.2 Linalg:为什么矩阵乘法不应立即变成三重循环 13.3 StableHLO 和 OpenXLA:框架与编译器之间的可移植边界 13.4 Bufferization:tensor 什么时候变成内存 14. MLIR 文本语法入门 14.1 Module、函数和 SSA value 14.2 常量、比较和条件分支 14.3 循环和 block 参数 14.4 Tensor 和 MemRef 的区别 14.5 linalg.generic 的结构 14.6 属性、类型别名和 AffineMap 14.7 Generic operation 语法 15. 建议的动手实验 16. 书籍与论文阅读建议 16.1 编译器基础 16.2 深度学习系统和编译器 16.3 MLIR 和工程资料 参考资料 17. 图示化理解:从模型到机器执行 17.1 图一:机器学习编译器的分层流水线 17.2 图二:计算图、控制流和 MLIR Region 的关系 17.3 图三:MLIR 的 Operation、Value、Block、Region 层次 17.4 图四:Linalg Matmul 如何携带迭代空间 17.5 图五:Linalg 到循环和内存访问 17.6 图六:Tensor 到 MemRef 的 Bufferization 17.7 图七:一个 operation 的语法拆解 18. 图示阅读练习 练习一:判断哪些边是数据依赖 练习二:判断哪些维度可以并行 练习三:判断何时会出现 copy 19. 图与工程工具
本文面向希望系统学习 MLIR、深度学习编译器和计算图优化的读者。

1. 学习目标

学习这篇文章时,应能回答四个问题:

  1. 计算图如何表达模型的输入、参数、算子和数据依赖?
  2. 为什么需要 IR,而不是直接从模型生成机器指令?
  3. MLIR 的 operation、value、block、region 和 dialect 分别解决什么问题?
  4. 一个高层张量算子如何逐步 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.ifscf.forcf.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

其中 MKN 可以是编译期常量,也可以是运行时符号。形状推导能够提前发现维度错误,并帮助生成循环边界和内存分配。布局则决定元素在内存中的排列,例如 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:向量级操作;
  • gpunvvmllvm: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 都要回答:

  1. 输入和输出语义是否保持一致;
  2. 类型、形状、布局和副作用哪些被保留或丢弃;
  3. 下一层方言是否能够表达当前 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 的路线

  1. 先掌握 SSA、use-def、基本块和控制流。
  2. 阅读 MLIR Toy Tutorial,理解 AST 到 dialect conversion 的完整流程。
  3. 熟悉 modulefunc.func、region、attribute 和 typed SSA value。
  4. 学习 arithfuncscfmemref,建立基本骨架。
  5. 学习 linalgtensor,理解结构化算子、形状与 bufferization。
  6. 跟踪一个 pass,观察 match、rewrite、合法化和 verifier 如何协作。
  7. 实现一个小 dialect 或转换,例如把自定义 add 转换为 arith.addf
  8. 最后学习 GPU、LLVM 或 NPU 方言,分析目标硬件约束如何进入 IR。

学习时不要只记 operation 名称,要同时追踪值的类型、形状、布局、内存位置、所有权、控制流和副作用。

11. 总结

计算图提供模型的数据依赖视角;IR 提供程序的可验证、可变换和可降低视角;MLIR 通过 dialect、operation、region、SSA 和 pass 把不同抽象层连接起来。

最重要的思维链是:

模型语义
  -> 数据依赖和控制流
  -> 类型、形状、布局和副作用
  -> 合法的 IR 变换
  -> 结构化 lowering
  -> 面向目标硬件的执行代码

只要能回答每一步“表示了什么、丢失了什么、为什么可以这样变换、下一层如何实现”,就真正进入了计算图编译器和 MLIR 的核心问题。

参考资料

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 本身,而是三个工程边界:

  1. 前端语义先保存在自定义 dialect 中,而不是一开始就拆成低层循环;
  2. 通用优化通过 Interfaces 接入,而不是让每个 pass 依赖某个具体类;
  3. lowering 是分阶段的,每个阶段都能用 verifier 和 FileCheck 测试。
13.2 Linalg:为什么矩阵乘法不应立即变成三重循环

矩阵乘法可以直接写成三重循环,但如果一开始就降低为 scf.for,编译器可能失去矩阵乘法的结构信息。Linalg 通过 linalg.matmullinalg.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 参数;%sumarith.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:

  1. operation 是否被替换;
  2. SSA value 的类型是否变化;
  3. tensor 是否变成 memref;
  4. region 是否变成 block 和 branch;
  5. 是否产生了额外的 alloc、copy 或 dealloc;
  6. 高层信息在什么时候消失。

实际版本中的 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:理解框架到编译器的可移植算子边界、版本兼容和动态形状。

参考资料

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"]
}

读图重点:

  • mn 决定输出元素,可以并行;
  • 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 --------------------------------------^

%mmlinalg.matmul 的 result,%w%b 是参数输入。图中横向和斜向箭头都是数据依赖,不代表执行线程或内存地址。

练习二:判断哪些维度可以并行

对于 C(m,n) = sum(k, A(m,k) * B(k,n))

  • mn 是 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-translatellvm-dis
分析 buffer copy One-Shot Bufferize 的 test-analysis-only print-conflicts
查看硬件执行 profiler、kernel dump、trace 和性能计数器

一个成熟的调试流程不是只看最终性能,而是沿着图和 IR 逐层定位:模型是否正确捕获,形状是否正确推导,融合是否发生,bufferization 是否插入 copy,向量化和硬件映射是否成功。