想象一家智能玩具工厂:零件被放在传送带上,经过机器加工,变成可以拼装的积木组件。厂长想优化流水线,但他不会把整条生产线拆掉重装,而是交给老师傅一叠“替换卡片”。卡片上写着:只要看到“两个固定齿轮带动一个减速箱”这种接法,就把它整体换成“一体化减速电机”。工人按卡片操作,只改局部,不碰其他地方。 MLIR 里的模式重写(Pattern Rewriting)就是这套“替换卡片”。它把中间表示(IR)看成一张由算子和依赖关系组成的“积木图”,每张卡片描述“看到某个小结构,就替换成另一个等价结构”。编译器会拿着卡片自动扫描、匹配、替换,于是算子融合、常量折叠这些优化,不再需要工程师手写繁琐的遍历逻辑。
对 AI 编译器从业者来说,模式重写是编写 MLIR Pass 的标准方式:学会用“声明规则”代替“手写变换”,能大幅提升优化开发效率,也是进阶编译器优化的必修课。
可视化演示
演示MLIR Pass模式重写机制,展示DAG重写流程、算子融合与常量折叠关键优化。
本节配有交互动效(进入视口后自动加载,约 3KB)。
MLIR模式重写 DAG重写、算子融合与常量折叠三大优化,自动应用变换简化IR Pattern→Rewrite 编译器优化自动化核心
模式重写概述
老师傅走进工厂,看到的不再是一堆零件,而是一张“积木装配图”:每个加工工位是一个节点,工位与工位之间用传送带连接,表示谁的结果被谁使用。工厂要做优化,不是推翻这张图,而是在局部插入“替换卡片”。这张卡片的学名,就叫模式重写。
在 MLIR 中,这个“装配图”就是中间表示(IR)。IR 里的每个操作(Operation)是一个节点,操作之间的数据依赖是边,整体构成一张有向无环图(DAG)。一次 Pass 就是一次“工厂巡检”,而模式重写则是让巡检员不用手工调整每一个齿轮,只需检查图上是否有卡片描述的结构;一旦发现,就按卡片完成替换。
传统的优化 Pass 往往需要手写遍历 IR、判断条件、创建新操作、删除旧操作的代码。模式重写把这些细节交给框架:你定义“看到什么、换成什么”,框架负责寻找匹配、安全改写、维护依赖。MLIR 的 RewritePattern 就是一个可复用的模式对象,PatternRewriter 则是那个严格按操作规程动手的工人。
回到玩具工厂:老师傅不需要认识每个螺丝钉,他只需要看图、认卡片、动手换。MLIR 工程师也一样,把 IR 看成 DAG,把优化看成局部替换,复杂系统就变得可拆解、可组合。
- \(G\) 表示当前 IR 图Pass 操作的中间表示不是文本,而是一张图。
- \(V\) 是算子集合图中的每个节点代表一个 MLIR 操作,比如
mul、add。 - \(E\) 是依赖边集合边表示“某个算子的结果被另一个算子使用”,这正是数据流动的方向。
匹配与替换
老师傅手中的卡片必须有两部分:上半部分是“匹配条件”,下半部分是“替换动作”。比如“匹配:两个固定齿轮带动一个减速箱;替换:一体化减速电机”。如果匹配条件不满足,卡片绝不能用;如果满足,动手时还要保证接口一致,不能让下游工位找不到输入。
MLIR 的 matchAndRewrite 正是这个结构。第一步,match 检查当前操作及其操作数是否符合 DAG 模式;第二步,rewrite 用 PatternRewriter 创建新操作、替换旧操作。匹配失败就换下一张卡片,匹配成功则继续处理其他位置。这就是“局部作用域”:每次只影响被匹配的那一小块子图,不动整张图。
系统里往往有几十张卡片,它们的优先级由“模式收益(benefit)”决定。收益越高的卡片越先尝试,避免随机顺序导致永远走不到理想优化。收益只是个相对排序,真正对错还要靠语义等价来把关。
工厂老师傅的经验就这样沉淀成一张张卡片:匹配条件越精准,误伤越少;替换动作越规范,流水线越安全。编写 Pass 时,你也能把优化的“判断”和“动手”分开,让每一部分都能独立测试。
- \(M \subseteq G\)匹配条件先选出一个子图,相当于卡片上“看到什么”的部分。
- \(G \setminus M\)原图去掉匹配部分,剩下的“无关机器”保持原样。
- \((G \setminus M) \cup R\)把替换 DAG 接回原图,并重新连接输入输出边。
DRR声明式框架
给老师傅写操作说明有许多方式。最笨的方法是直接写机器人底层指令:先移动机械臂,再拧两颗螺丝,最后检查接口。另一种方法是写“卡片”:只需写“看到什么、换成什么”,再由一个文书员自动生成底层指令。MLIR 的 DRR(Declarative Rewrite Rules)就是后者。
DRR 使用 TableGen 描述模式。开发者只需要声明一个 Pat 规则,写下源 DAG 和目标 DAG,其余遍历、匹配、IR 更新代码都由工具生成。mlir-tblgen 就是这个“文书员”,它读取 TableGen 规则,自动生成 C++ 的 RewritePattern 子类。这就是 RewriterGen 的工作:把声明式描述翻译成命令式代码。
这种做法的好处是:优化规则的意图一目了然;C++ 样板代码不再需要手工维护;新增一条优化时,只需增加几行 TableGen,而不是一整个类。DRR 让模式重写拥有了“配置化”的形态,特别适合表达算子融合、常量折叠这类结构清晰的变换。
用“卡片”而不是“机器人指令”,意味着优化知识可以被阅读、评审和复用。DRR 之所以重要,是因为它把“优化意图”和“实现细节”分离,让 AI 编译器里的规则库像工厂手册一样清晰。
- \(\mathcal{P}\) 是 DRR 规则集合它描述“匹配什么 DAG、替换成什么 DAG”。
- \(\xrightarrow{\text{mlir-tblgen}}\)表驱动代码生成器把声明式规则翻译成命令式 C++。
- C++ RewritePattern最终产物是可被 Pass 调用的模式类,能够直接参与重写。
DAG到DAG变换
老师傅最讲究“接口对得上”。替换一个减速箱时,输入轴位置、输出轴转速都必须和原来一致;否则下一道工序就会卡住。模式重写的本质也是如此:不是改一个字符,而是把一个“子 DAG”换成另一个“子 DAG”,并重新接好边界上的边。
MLIR 中,匹配的单元通常是一个操作及其操作数构成的子图。替换时,新子图要接收原来的输入值,也要产出原来的输出值给下游使用。PatternRewriter::replaceOp 这类 API 会主动把旧结果的所有使用者重定向到新结果,所以开发者不用手工维护“谁在用我”的关系。这就是 DAG-to-DAG 的“接口映射”。
除此之外,重写还要考虑约束与副作用。如果一个操作有读写内存等副作用,就不能随意删除或改变执行顺序。合法的模式重写必须保证:匹配条件足够强,替换结果与原行为语义等价,副作用要么保留、要么被证明可消去。
如果把 IR 比作电路图,DAG-to-DAG 就是“替换一个元件并重新焊线”。MLIR 框架替我们检查导线是否接错,工程师则专注设计更好的元件。
- \(\text{In}(M)\)匹配子图的输入值,也就是外部算子喂给它的操作数。
- \(\text{Operands}(R)\)替换 DAG 的操作数,必须与旧输入一一对应。
- \(\text{Results}(R)\)新 DAG 产生的结果,要替代旧子图的结果供给下游。
典型优化应用
工厂常用的三类卡片,正好对应模式重写的三个经典应用。
第一张卡片是“合并工序”:如果看到“乘法工位把结果送到加法工位”,就换成一台能直接计算 a*b+c 的复合机器。这张卡片对应算子融合,减少了中间结果的搬运和内存占用。第二张卡片是“预计算固定件”:如果看到两个常数值相乘,就提前算出答案,贴上新标签。这张卡片对应常量折叠。第三张卡片是“标准化摆放”:把同类零件统一摆到固定位置,让其他卡片更容易命中。这张卡片对应规范化(Canonicalization)。
这三类优化都非常适合用 DRR 声明。算子融合把结构性的计算合并成一个整体;常量折叠把运行期计算变成编译期计算;规范化消除冗余形态,比如 x+0 直接变成 x。它们不改变结果,只改变产生结果的方式。
玩具工厂用这三张卡片,就能让流水线越来越紧凑。AI 编译器也一样,把常见的计算模式写成卡片,编译器就能自动把笨重的计算图优化成精悍的执行图。
fma。后一个式子展示常量折叠:两个常量相乘在编译期直接算好。- \((a\times b)+c\)源 DAG 是
mul的结果作为add的一个操作数。 - \(\text{fma}(a,b,c)\)目标 DAG 是一个融合算子,一条指令完成乘加,省去中间值。
- \(c_1\times c_2\)匹配条件要求两个操作数都是编译期常量。
- \(c_1\cdot c_2\)替换结果是一个新的常量节点,数值已在编译期算出。
Pass集成与调试
卡片再漂亮,如果没有人拿着它去车间巡视,也不会产生价值。Pass 就是那个“巡视计划”,而模式重写的驱动方式则是“不断巡逻直到没有卡片可用”。
在 MLIR 中,开发者先把一组 RewritePattern 注册到 RewritePatternSet,再交给贪婪重写驱动(Greedy Rewriter)。它会反复扫描 IR:只要某张卡片能命中,就执行替换;一轮结束后继续下一轮,直到没有任何模式能匹配。这种行为叫“迭代到固定点”。注册规则的 Pass 可以很小,比如 -canonicalize 就是一个以模式重写为主的规范化 Pass。
调试时,mlir-opt 是最常用的实验室:用一个测试文件跑指定 Pass,观察 IR 的变化。配合 -debug-only=mlir-rewriter 这类开关,还能看到每条规则尝试和命中的日志。测试和调试的价值,就像给工厂做“试运行”:先在小块流水线上验证卡片不出错,再放开到完整生产线。
“巡逻到没有卡片命中”看起来简单,却能保证优化结果稳定。Pass 集成到编译流程后,AI 模型的计算图就会在编译期间被自动打磨,开发者只要观察日志和测试就能验证效果。
- \(G_0\)进入 Pass 时的初始 IR,也就是改造前的流水线。
- \(G_i \to G_{i+1}\)每一轮应用若干条模式重写规则,IR 从一个状态变到下一个状态。
- \(G_n = G_{n-1}\)如果连续两轮没有产生任何变化,就停止迭代。
收益与挑战
模式重写真正改变的是“优化开发的方式”。过去,一个优化 Pass 可能涉及复杂的 IR 遍历、节点插入、删除和依赖更新;现在,大多数优化可以写成一张张高可读性的卡片,开发和维护成本大大降低。
但它也有挑战。卡片多了,可能出现“两张卡片同时命中同一个子图”的冲突,需要用模式收益和 Pass 顺序来裁决。更麻烦的是规则之间可能互相触发,造成无限循环。比如一张规则把 a+b 变成 b+a,另一张又把 b+a 变回 a+b,贪婪重写就会来回打转。因此,模式重写不是“规则越多越好”,而是要在收益与复杂度之间找平衡。
MLIR 通过模式收益、规范化、调试开关和测试流水线来管理这些挑战。每个模式都带着一个相对优先级,重写框架按优先级决定尝试顺序;规范化则提前统一 IR 形态,减少规则的排列组合。
老师傅的手册越厚,越需要编号和索引。模式重写让 AI 编译器变得可扩展,但真正成熟的产品,还得靠纪律严明的规则管理、优先级设计和充分测试。
- \(\text{Benefit}(p)\)每个模式在注册时有一个收益值,代表它的期望优化价值。
- \(>\)收益比较决定尝试顺序,高收益规则更早获得匹配机会。
- \(\Rightarrow\)推导出实际执行顺序:先尝试 \(p\),再尝试 \(q\)。
AI 应用场景
场景一:深度学习编译器中的算子融合
阶段:模型图优化 / 编译期计算图变换
解决的问题:传统手工优化 Pass 需要遍历计算图,逐个判断“该不该融合”,代码重复且难以维护。模式重写把融合规则声明成“看到 matmul 后跟 add 就替换为 fma 风格算子”,编译器自动完成匹配与替换,显著减少开发成本。
具体案例:在基于 MLIR 的 AI 编译器中,工程师把 conv2d + bias_add + relu 写成一个 DRR 模式,让 GPU 后端一次生成融合 Kernel,减少显存读写。
场景二:推理引擎的常量折叠
阶段:部署前静态图优化
解决的问题:模型里存在大量在推理时永远不变的常量计算,例如 shape 运算、固定索引拼接。传统做法是用一个特化 Pass 处理,代码难复用。模式重写把“常量操作数”作为匹配条件,一张卡片即可覆盖多种算子。
具体案例:推理引擎加载 ONNX 模型前,用模式重写对 shape + gather + concat 做常量折叠,减少在线推理时的重复计算,降低首延迟。
场景三:自定义硬件后端的 Dialect 下降
阶段:后端代码生成 / Dialect 转换
解决的问题:新 AI 芯片需要把高层的 Tensor 算子下降到芯片支持的底层指令,手工编写下降 Pass 工作量大。模式重写用 DAG-to-DAG 规则描述“高层算子子图 → 底层算子子图”,并自动完成接口连接。
具体案例:某 NPU 编译器把 transpose + matmul 替换为自定义的 tmem_matmul 算子,让后端可以识别并映射到硬件指令,而不需要改动前端框架。
Python 演示
"""mini_drr.py: 用 Python 模拟 MLIR DRR 的 DAG-to-DAG 模式重写""" def match(pattern, ir): if isinstance(pattern, str) and pattern.startswith("$"): return {pattern: ir} if isinstance(pattern, tuple): if not isinstance(ir, tuple) or len(pattern) != len(ir): return None if pattern[0] != ir[0]: return None binds = {} for p, n in zip(pattern[1:], ir[1:]): sub = match(p, n) if sub is None: return None binds.update(sub) return binds return {pattern: ir} if pattern == ir else None def apply_rules(node): # 规则1: 常量折叠 mul(const, const) -> const binds = match(("mul", ("const", "$x"), ("const", "$y")), node) if binds is not None: return ("const", binds["$x"] * binds["$y"]) # 规则2: 算子融合 add(mul(a, b), c) -> fma(a, b, c) binds = match(("add", ("mul", "$a", "$b"), "$c"), node) if binds is not None: return ("fma", binds["$a"], binds["$b"], binds["$c"]) return node def rewrite(node): if not isinstance(node, tuple): return node while True: result = apply_rules(node) if result == node: break node = result new_args = [rewrite(arg) for arg in node[1:]] return tuple([node[0]] + new_args) if __name__ == "__main__": dag = ("add", ("mul", ("const", 3), ("const", 4)), ("const", 5)) print("before:", dag) print("after :", rewrite(dag))
match 模拟 DRR 的模式匹配,用 apply_rules 模拟声明式替换规则,展示模式重写如何把计算图优化到固定点。def match(pattern, ir):递归比较“模式卡片”和当前 DAG,返回变量绑定。if isinstance(pattern, str) and pattern.startswith("$"):$x是通配符,可绑定任意子节点。("mul", ("const", "$x"), ("const", "$y"))这是一个 DRR 风格的源 DAG 模式。return ("const", binds["$x"] * binds["$y"])常量折叠的替换动作:直接在编译期算出结果。while True:贪婪重写直到没有规则能改变当前节点,对应 MLIR 的固定点循环。
常见误区
误区我是做模型训练的,只需要用 PyTorch,模式重写是编译器内部细节,与我无关。
正解AI 模型要部署到 GPU、NPU、手机端,都会经过 AI 编译器。你的网络结构、推理性能、内存占用都受计算图优化影响。模式重写正是 AI 编译器实现算子融合、常量折叠等优化的核心手段。它像一个“隐藏的工厂老师傅”,决定模型最终跑多快、占多少资源。
为何常见:模型开发者通常只看到高层框架 API,看不到 MLIR 内部的 IR 和 Pass,因此误以为优化与己无关。
误区模式重写不就是找到字符串 `mul(a,b)`,替换成另一个字符串吗?
正解它针对的是 DAG,而非文本。节点之间的依赖关系、输入输出的连接都必须被重新映射。就像替换工厂里的一台机器,要看清楚传送带从哪进、往哪出,而不是简单撕掉一张标签。MLIR 的 PatternRewriter 会维护这些依赖关系,保证替换后 IR 仍然合法。
为何常见:初学者看到“模式”两个字,容易联想到正则表达式或字符串替换,忽略了 IR 的图结构。
误区既然 DRR 能自动生成 C++ 重写代码,以后就不需要手写重写逻辑了。
正解DRR 擅长表达结构清晰的 DAG-to-DAG 替换,但复杂约束、副作用分析、跨区域修改、需要调用外部分析的优化,仍然要手写 RewritePattern。DRR 更像“常用卡片模板”,不是全能机器人。MLIR 允许 DRR 与 C++ 辅助函数结合,关键是选择合适的表达层次。
为何常见:看到“自动生成”就以为无需编程,忽略了模式匹配背后的正确性和复杂度。
误区我只要把能想到的优化规则都注册进去,编译器自然会找到最优结果。
正解规则之间可能冲突,也可能互相触发产生死循环。比如两条规则可以把一个子图 A 变成 B,又变回 A。MLIR 用模式收益、贪婪驱动和规范化来控制行为。规则库需要像老师的“修改手册”一样编排:分优先级、做测试、避免无限应用。
为何常见:局部看每条规则都没错,但系统层面缺少顺序和终止性意识,容易忽略相互作用。
面试题
Q1. 在 MLIR 中,“模式重写”主要把 IR 看作哪种结构?
- A.线性文本列表
- B.算子构成的 DAG← 正确答案
- C.无限循环数组
- D.随机键值表
答案:B。模式重写的核心输入是算子和数据依赖组成的 DAG,而不是文本列表。理解这一点才能理解匹配子图、替换和重连边这些动作。
Q2. DRR 声明式重写规则通常写在什么类型的文件中?
- A.
.mlir文件 - B.TableGen 文件← 正确答案
- C.Python 文件
- D.
.json文件
答案:B。DRR 使用 TableGen 描述规则,再通过 mlir-tblgen 生成 C++ 代码。.mlir 是测试输入输出用的 IR 文件,不是规则定义文件。
Q3. 一个模式由“匹配子图”和“替换 DAG”组成,替换后编译器必须保证什么?
- A.输入输出关系重新连接← 正确答案
- B.所有常量都会变大
- C.Pass 数量自动变多
- D.内存占用必然减少
答案:A。模式重写不是孤立地换节点,还要把旧子图的输入输出边重新接到新 DAG 上。保证连接合法是替换动作的基本要求,优化效果并非必然。
Q4. 对 mul(const 2, const 3) 做常量折叠,最可能得到什么?
- A.
mul(2, 3) - B.
const 6← 正确答案 - C.
add(2, 3) - D.
const 5
答案:B。两个操作数都是编译期常量,模式重写可以在编译期直接算出 2 * 3 = 6,生成 const 6。这对应工厂里的“预计算固定件”卡片。
Q5. MLIR 中为什么通常不直接删除 Operation,而要借助 PatternRewriter?
- A.C++ 语言不允许删除对象
- B.需要维护依赖与替换映射← 正确答案
- C.因为这样运行速度更快
- D.因为可以少写单元测试
答案:B。直接删节点会导致下游使用悬空值。PatternRewriter 会先更新所有使用关系,再删除旧操作,保证 SSA 形式的 IR 始终合法,这也让替换动作更安全。
Q6. DAG-to-DAG 模式匹配时,下列哪些可以作为匹配条件?
- A.算子的名称← 正确答案
- B.操作数类型← 正确答案
- C.操作数来源是否常量← 正确答案
- D.当前系统时间
答案:ABC。模式可以检查算子类型、操作数类型、常量属性等静态信息。系统时间不是 IR 的属性,也不能参与确定性编译优化。
Q7. 声明式 DRR 带来的好处包括哪些?
- A.减少手写样板代码← 正确答案
- B.自动生成 C++ 模式类← 正确答案
- C.规则可读性更高← 正确答案
- D.一定比手写 Pass 运行更快
答案:ABC。DRR 把规则像卡片一样描述,并通过 TableGen 自动生成 C++ 代码,代码量和维护成本都更低。但生成的代码不一定比精心手写更快,也仍然需要正确性测试。
Q8. 贪婪重写驱动通常什么时候停止?
- A.运行时间到点
- B.不再有规则命中← 正确答案
- C.调用固定次数
- D.固定点已到达
答案:B。贪婪重写会反复应用模式,直到某一轮没有任何规则能改变 IR,也就是达到固定点。时间到点和调用次数都不是标准终止条件。
Q9. 下列哪些优化可以用模式重写表达?
- A.算子融合← 正确答案
- B.常量折叠← 正确答案
- C.冗余拷贝消除← 正确答案
- D.安装软件依赖
答案:ABC。算子融合、常量折叠、冗余拷贝消除都是计算图上的结构变换,可以用“匹配子图 + 替换 DAG”描述。安装依赖不是编译优化问题。
Q10. 当多个模式都能匹配同一个 IR 子图时,MLIR 如何决定优先尝试哪个?
- A.按文件名排序
- B.按模式收益排序← 正确答案
- C.随机选择模式
- D.按输入张量大小排序
答案:B。每个模式注册时带有一个模式收益(benefit),收益高的规则优先尝试。这样设计可以避免随机选择带来的不确定优化结果,也能解决部分规则冲突。