老周经营一家川菜馆,后厨墙上贴满花花绿绿的工序卡。同样一盘回锅肉,切配、焯水、爆炒、回锅、装盘,五道工序环环相扣;宫保鸡丁则是切丁、滑油、调汁、爆炒。那晚生意爆火,老周却没有手忙脚乱——因为他心里装着“调度”:哪道菜先备料、哪个灶头先空出来、哪道工序可以两锅并一锅,全部井井有条。 AI 编译器里的 Pass 调度,正是老周这套功夫的镜像。编译器拿到一个深度学习模型或程序片段,并不会一步到位生成机器码,而是要经过一连串“工序”——每个变换步骤在 MLIR 里叫一个 Pass(处理步骤)。决定这些 Pass 谁先谁后、何时重复、如何嵌套的机制,就是“Pass 调度”。MLIR 正是以多级中间表示(IR)为食材、以 Pass 调度为烹饪流程的厨房。这篇教程将从基本概念讲到高级策略,穿插后厨比喻,帮你彻底看懂“编译器的后厨”如何运转。
对 AI 从业者:Pass 调度是 AI 编译器(MLIR、TVM、XLA)的核心引擎,搞懂它,等于拿到了模型优化与推理加速的钥匙。
可视化演示
动态展示PassManager驱动的Pass流水线调度、全局vs局部调度粒度、训练图编译优化案例(常量传播→算子融合→内存重用)、以及强化学习自动Pass调度四个核心视角。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Pass调度概述
后厨的工序卡上,每道菜都写着一句“先做什么、再做什么”。焯水必须在爆炒前,腌制必须在油炸前——这些顺序不是拍脑袋定的,是后厨多年踩坑总结出的铁律。Pass 调度也一样,它是编译器里一张看不见的“工序卡”,规定 IR(中间表示)在哪个阶段接受哪道变换。
在 MLIR 的世界里,Pass 是一个独立、可复用的 IR 变换操作,比如“常量折叠”“死代码消除”“算子降级”。调度的基本职责,就是把这些 Pass 按合理顺序排成管道,再逐级施加到 IR 的每个结构单元上。正如工序卡要平衡口味与出菜速度,Pass 调度要在优化收益与编译开销之间取平衡。
调度首先面临“在哪个尺度上管事”的问题。MLIR 提供三种优化粒度:模块级 Pass 作用于整个 ModuleOp,适合做跨函数的内联与去重;函数级 Pass 进入每个函数体单独执行,最常用于循环变换与指令精简;区域级 Pass 则针对函数内部的嵌套区域做精细手术。PassManager 在运行时会自动遍历 IR 结构,把函数级 Pass 一一施用到每个函数上。
回到老周的后厨:工序卡不是越厚越好,每多一道工序就多一份等待与风险。调度开销模型提醒我们——每一道“验证”和“备料”都要计入时间账本,只有砍掉冗余、复用高汤,后厨才能在晚市高峰里游刃有余。
- 累计求和Σ 表示对管道中第 1 个到第 n 个 Pass 逐一累加开销,就像把每道工序的耗时全部记进后厨台账。
- Pass 执行时间$t_{run}(i)$ 是第 i 个 Pass 真正变换 IR 所花的时间,类似每道工序的烹饪时间。
- 验证开销$t_{verify}(i)$ 是 Pass 执行后验证 IR 合法性所花的时间,类似出品前试味质检。
- 缓存收益$t_{cached}$ 是分析缓存节省的总时间,类似提前熬好的高汤省下的炖煮时间。
Pass管道设计
有经验的厨师不会把工序任意堆砌——焯水一定在爆炒之前,腌制一定在油炸之前。把多道工序按正确顺序串联成一条完整的制作流程,就是“管道设计”。MLIR 的 Pass 管道(Pipeline)正是这样一串经过编排的 Pass 序列,它定义了编译器从输入 IR 到输出 IR 的完整路径。
开发者通过命令行表达式描述管道,典型语法如 --pass-pipeline='builtin.module(func.func(convert-to-llvm))'。这行配置表示:对每个内置模块,依次进入函数级子管道,在每个函数上运行 convert-to-llvm 这个降级 Pass。管道还可以嵌套——函数级子管道可以再包含区域级管道,调度器按深度优先遍历 IR 树,将对应 Pass 应用到匹配的结构上。
更灵活的是管道重写(Pipeline Rewriting):Pass 在运行中发现某种 IR 模式后,可以动态启动一个子管道进行局部处理。这就像老周看到今天的鱼特别新鲜,临时加一道“快速过油”的工序,让鱼更酥脆——管道的骨架是固定的,但局部工序可以即兴发挥。
管道嵌套就像后厨的“分区作业”——凉菜间有凉菜间的流程,热灶间有热灶间的工序,互不干扰又统一受总调度管辖。设计管道时,把重复的工序提取为公共子管道,就是老周口中的“中央厨房”思维。
- 外层管道时间$T_{outer}$ 是外层模块级 Pass 的执行总时间,类似后厨热灶区的总工时。
- 内层管道求和$\sum_{k=1}^{m} T_{inner}^{(k)}$ 把第 1 个到第 m 个嵌套子管道的运行时间逐个相加,类似把凉菜间、面点间的工时全部汇总。
- 嵌套总时间$T_{nested}$ 表示嵌套调度的总耗时,等于外层加上所有内层的总和。
Pass管理器
后厨除了掌勺的厨师,还有一位“厨师长”——自己不动手,但盯流程、查火候、看出品。编译器里,PassManager 就是这位厨师长。它不亲自变换 IR,却掌控着整个 Pass 管道的运行节奏。
PassManager 是 MLIR 中负责 Pass 调度执行的核心对象。它的第一项职责是执行控制:调用 run 方法启动整个管道,遍历 IR 的模块、函数、区域层级,把每个 Pass 精准投放到对应结构上。第二项职责是验证机制:每当一个 Pass 修改 IR 后,管理器会在下一次 Pass 执行前自动插入验证器,确认 IR 依然合法——相当于每道菜上桌前先试味,防止坏菜进入下一步。
第三项职责是失败处理:Pass 可以返回 failure 表示“此路不通”,管理器捕获后按策略回滚或终止,而不是让整个编译进程崩溃。这就像厨师长发现今天的鱼不新鲜,果断换菜而不是硬着头皮做下去。整个过程贯穿着对验证开销的敏感:验证是必要的,但过度验证会让厨师长变成“事事亲力亲为”的独裁者。
厨师长的价值在于“严丝合缝的流程管理”——用一点点质检开销,换来整条流水线的稳定出品。PassManager 同样如此:它把“担心”变成制度,把“万一出错”变成可回滚的预案。
- 验证时间$T_{verify}$ 是所有验证步骤的总耗时,类似每道菜试味、摆盘检查的时间。
- 执行时间$T_{run}$ 是所有 Pass 真正执行变换的总耗时,类似厨师切配爆炒的时间。
- 占比约束$R_{verify}$ 表示验证开销在总时间中的占比,我们希望它不超过阈值 $\theta$。
依赖分析管理
后厨最有价值的资产,是那锅熬了六个小时的高汤——所有菜都能用它提鲜,不必每道菜重新熬制。但高汤也有保鲜期,一旦变质就不能再用。Pass 调度里的“高汤”,就是分析(Analysis)结果。
Pass 在执行时往往需要前置分析的结论,比如活跃变量分析、依赖分析。多个 Pass 可以复用同一个分析结果,避免重复计算。MLIR 中,Pass 通过 getAnalysis<A>() 请求分析结果:管理器先查缓存,命中就直接返回,否则立即计算并缓存。这就像厨师做菜时喊一句“来勺高汤”,不用重新炖。
但 IR 一经修改,部分分析就开始“变质”。失效传播是依赖管理的核心:一个 Pass 修改了 IR 的某个区域,该区域的局部分析应失效;若修改了整体结构,所有依赖它的分析都要作废。MLIR 通过 Pass 声明“我保留了哪些分析”(preserve)来精确控制缓存的生命周期。
高汤的新鲜度由厨师长把关——不是所有汤都一锅炖到尾,哪个菜用了、哪个步骤改了,都要在台账上划掉相关依赖。Pass 调度里的失效传播,就是这本台账的数字化版本。
- 分析被撤销$\text{Invalidate}(A)$ 表示分析对象 A 的缓存结果被作废,相当于这锅高汤被贴上了“不可用”的标签。
- 显式失效$\text{Killed}(A)$ 表示 Pass 主动声明“我改了东西,A 别用了”,就像厨师长喊“高汤里下了辣椒,之前的清淡菜谱作废”。
- 依赖变更$\text{Modified}(\text{Deps}(A))$ 表示 A 所依赖的底层数据真的被修改了,相当于熬汤的底料换了。
- 或逻辑$\lor$ 表示“或者”——两种原因只要有一个成立,A 就必须失效。
调度策略算法
有些菜要“回锅”才香——比如回锅肉,煮了切、切了炒、炒了还要再回锅调味。Pass 调度也经常需要“回锅”:一轮 Pass 跑完,IR 可能还未收敛,需要再跑几轮。MLIR 支持两种基本调度策略:固定调度和动态调度。
固定调度下,每个 Pass 在管道中恰好执行一轮,按静态顺序依次跑完。动态调度则依据 IR 的实时状态决定是否继续执行——通常通过模式重写驱动,比如“不断折叠常量直到不再出现新的可折叠模式”。后者更智能,但也更需要谨慎设计终止条件,否则程序可能陷入“回锅肉越炒越黑”的死循环。
收敛判定是动态调度的核心算法。调度器通过比较 Pass 执行前后的 IR 变化量,决定是否继续循环;同时设置最大迭代次数作为安全阀。这两把锁缺一不可:前者保证“变不动了就停”,后者保证“就算一直变,也不能无限跑下去”。
好厨师知道“回锅肉最多回几次”——收敛判定就是那把尺子,防止一道菜炒到天荒地老。设计动态调度时,永远同时带上收敛阈值与最大迭代次数这两个安全阀。
- IR 序列$IR_{k+1}, IR_k$ 分别表示第 k 轮和第 k+1 轮调度后的中间表示,像回锅肉第一遍和第二遍出锅的样子。
- 变化距离$d(IR_{k+1}, IR_k)$ 是 IR 变化量的度量函数,比如指令数量差异或重写事件计数。
- 判定阈值$\varepsilon$ 是工程预设的容忍阈值,变化量低于它就认为“没什么可改的了”。
- 停止结论$\Rightarrow \text{Stop}$ 表示条件满足则终止循环,防止无休止的重复执行。
性能优化实践
晚市高峰,后厨最怕的不是菜多,而是“等”——等水开、等烤箱、等汤冷却。Pass 调度同样要消灭“等”:剔除不必要的验证、复用缓存数据、并行执行独立的工序。性能优化的本质,是让每一分钟编译时间都花得值。
三个优化方向中最立竿见影的是缓存复用。分析缓存命中率越高,重复计算越少,整体调度就越快。工程中常通过 --mlir-print-ir-after-all 观察每步执行后的 IR,结合 --debug 日志定位那些“反复重新计算”的分析,就像查看后厨台账找出哪道菜老是在重熬高汤。
并行调度则是把互不依赖的 Pass 分配到不同线程同时执行——后厨里的“两锅并一锅”。但要注意,并行会引入新的竞争条件,分析失效跨线程传播时尤其要小心。MLIR 的调度器在生成并行计划时会先做依赖分析,确保没有数据竞争才放心开火。
把“炖高汤”的次数降到最低,把“直接用高汤”的次数提到最高——后厨与编译器的效率哲学殊途同归。性能优化不是一招鲜,而是在缓存、验证、并行三者之间找平衡。
- 命中次数$N_{hit}$ 是分析请求命中缓存的次数,相当于高汤直接舀来用。
- 未命中次数$N_{miss}$ 是缓存未命中、需要重新计算的次数,相当于高汤用完了只能重新熬。
- 命中率$H$ 是两者的比值,衡量缓存策略的有效程度。
AI 应用场景
深度学习框架的图优化管线
阶段:模型编译与图优化
解决的问题:深度学习模型的原始计算图包含大量冗余运算,如连续 reshape、未融合的激活函数、重复的常量计算。手工优化费时费力且极易出错,AI 编译器用 Pass 管道自动完成算子融合、内存布局转换和常量折叠。
具体案例:PyTorch 的 TorchInductor 将 Torch IR 经一系列 Pass 降级为 Triton 底层 IR。调度器先做“模式匹配与融合”,再做“并行拆分”,最后做“代码生成”,每个阶段都是 Pass 调度的产物。开发者只需注册新 Pass,编译器会像老周安排新菜品一样自动排入流程。
AI芯片后端的IR降级调度
阶段:硬件代码生成
解决的问题:把深度学习模型部署到定制 AI 芯片上,需要把高层 IR 逐级降级为芯片指令。芯片的存储层次、算子库接口各不相同,手写一遍只能服务一款芯片,复用性极差。
具体案例:使用 MLIR 的 Dialect 转换管道,调度器依次执行“高电平降级 → 负载均衡 → 存储分配 → 指令选择”等 Pass,最终生成目标芯片可执行的代码。开发者在管道中插入自定义 Pass,如同给老周的后厨增加一道“秘制酱料”工序。
大模型推理引擎的算子融合调度
阶段:推理性能优化
解决的问题:大模型推理时 KV Cache、Attention 等算子反复调用,内存带宽成为瓶颈。推理引擎通过 Pass 调度将多个小算子融合成一个大算子,减少核启动开销与显存往返。
具体案例:vLLM、TensorRT-LLM 的 Graph Fusion Pass 把 Transformer 层中十几个算子融合成三四个 Fused Kernel。调度器决定融合顺序——先融合非线性部分再融合矩阵乘,以最大化缓存复用。这就像老周把“切配、调味、爆炒”合成一个连贯动作,减少交接等待。
Python 演示
"""Pass 调度模拟器: 用后厨工序类比编译器 Pass 管道的执行过程""" class Pass: """一个编译器 Pass, 对应后厨的一道工序""" def __init__(self, name, transform, requires=()): self.name = name self.transform = transform self.requires = requires class PassManager: """调度中心: 按顺序执行 Pass, 管理分析缓存与结果验证""" def __init__(self, verify=True): self.pass_list = [] self.verify = verify self.cache = {} self.hit_count = 0 def register(self, p): self.pass_list.append(p) def load_analysis(self, req, ir): if req in self.cache: self.hit_count += 1 return self.cache[req] result = len(ir.split()) self.cache[req] = result return result def run(self, ir): for p in self.pass_list: for req in p.requires: self.load_analysis(req, ir) ir = p.transform(ir) if self.verify: assert ir, "IR 验证失败: " + p.name self.cache.clear() return ir def remove_comments(ir): return "\n".join(line for line in ir.split("\n") if not line.startswith("//")) def constant_fold(ir): return ir.replace("a * 1", "a") def dead_code_eliminate(ir): return "\n".join(line for line in ir.split("\n") if "unused" not in line) def lower_to_llvm(ir): return ir.replace("add", "llvm.add").replace("mul", "llvm.mul") def main(): pm = PassManager() pm.register(Pass("删除注释", remove_comments)) pm.register(Pass("常量折叠", constant_fold, requires=("loads",))) pm.register(Pass("死代码消除", dead_code_eliminate, requires=("loads",))) pm.register(Pass("后端降级", lower_to_llvm)) source = "// 原始IR\na = 1\nb = a * 1\nunused = 2\nc = a + b" final_ir = pm.run(source) print(final_ir) print("缓存命中次数:", pm.hit_count) print("缓存命中率:", pm.hit_count / 2) if __name__ == "__main__": main()
class PassManager:定义调度中心类,相当于后厨的厨师长角色。self.pass_list.append(p)注册 Pass,把新工序挂到工序卡末尾。self.load_analysis(req, ir)先查缓存再计算,模拟分析结果的“高汤复用”。self.cache.clear()每个 Pass 跑完就清空缓存,模拟 IR 变化后的失效传播。pm.register(Pass("常量折叠", constant_fold, requires=("loads",)))注册带依赖的 Pass,必须先有 loads 分析结果才能执行。assert ir, "IR 验证失败: " + p.name每个 Pass 执行后验证 IR 合法性,对应 PassManager 的验证机制。
常见误区
误区编译器不开个上百个 Pass 真不好意思出门,每个 Pass 都是一层优化,跑得越多 IR 肯定越小,最后性能一定越好。我把能加的 Pass 全加上,总不会吃亏吧?
正解Pass 并非免费午餐。每个 Pass 都有执行与验证开销,而且可能相互抵消——比如“循环展开”Pass 展开的循环,可能被后面的“循环合并”Pass 重新合并,白忙一场。Pass 调度的价值恰恰在于选择“少而精”的管道,按需组合。就像老周不会给每道菜都上十道工序,工序越多反而越容易翻车。
为何常见:人们直观地认为“优化 = 多次变换”,忽略了 Pass 之间的相互作用与调度开销的累积效应。
误区反正所有 Pass 都会跑一遍,先执行哪个后执行哪个,结果不都一样吗?我把管道顺序随便排一下,应该没什么影响吧。
正解顺序决定结果。先做死代码消除,可能把后续 Pass 需要的元数据删掉;先做内联再做常量折叠,折叠机会会更多。MLIR 的开发者必须像厨师排工序一样认真设计管道顺序——先焯水再爆炒与先爆炒再焯水,出来的完全是两道菜。
为何常见:把 Pass 看成相互独立的滤镜,没意识到 IR 是流动的——前一个 Pass 的输出,正是后一个 Pass 的输入。
误区只要分析结果算出来,就放在缓存里,之后所有 Pass 都能直接用,省时间。我清了缓存反而变慢了吧?
正解IR 是“活”的。任一个 Pass 修改了 IR,依赖该 IR 区域的分析就可能失效。MLIR 通过 preserve 声明与依赖追踪来精确淘汰“变质”的分析缓存。不失效的缓存,就像用隔夜高汤做新菜——轻则串味,重则吃坏肚子。正确做法是每次 IR 变更后,按依赖图精准失效。
为何常见:把分析结果当作常量,忽略了它本质上是对“某时某刻 IR 快照”的描述,快照变了,结论就得重算。
误区看到日志里 Pass failed,第一反应是编译器挂了、代码写错了,赶紧翻堆栈找 Bug。
正解Pass 返回 failure 是 MLIR 的预期机制之一,表示“该 Pass 无法在当前 IR 上完成变换”。PassManager 会捕获 failure 并按策略回滚或生成崩溃复现文件(crash repro),真正的崩溃是未捕获异常或 IR 验证失败。就像厨师长说“这道菜今天做不了”,不等于厨房起火了。
为何常见:把 failure 与 error 混为一谈,没理解 failure 是编译器主动给出的“此路不通”信号,是流程控制的正常组成部分。
误区管道里写的 Pass 跑完一遍就算完事,干嘛要重复执行?重复跑不是浪费时间吗?
正解很多优化是“越跑越香”的:常量折叠一轮可能暴露出新的可折叠模式,需要再折叠;死代码消除一轮之后又出现了新的死代码。调度器通过循环反复执行这类 Pass 直到收敛,正如回锅肉要回锅才出味。但循环必须配收敛判定与最大迭代上限,防止无限空转。
为何常见:把 Pass 当作一次性操作,忽略了 IR 变换的级联效应——一个 Pass 的产物,往往能触发下一个 Pass 的新机会。
面试题
Q1. Pass 调度在 MLIR 中主要负责什么?
- A.决定 Pass 执行顺序与时机← 正确答案
- B.直接修改源代码文件
- C.管理内存的分配释放
- D.生成最终可执行文件
答案:A。Pass 调度的核心职责是决定 Pass 的执行顺序、时机与作用范围,就像后厨决定先切还是先炒。B 是前端词法分析的事,C 是运行时的事,D 是代码生成的结果,都不是调度本身。
Q2. 下列哪些属于 Pass 调度的优化粒度?
- A.模块级 Pass 调度← 正确答案
- B.函数级 Pass 调度← 正确答案
- C.区域级 Pass 调度← 正确答案
- D.指令级 Pass 调度
答案:ABC。MLIR 的 Pass 调度粒度是模块级、函数级和区域级,分别对应整道菜、单份菜、局部摆盘。指令级调度属于后端指令选择阶段,不在 Pass 调度的粒度体系内。
Q3. 管道表达式 func.func(convert-to-llvm) 的含义是什么?
- A.将模块级 Pass 应用于整个模块
- B.在函数级子管道中运行降级← 正确答案
- C.在区域级子管道中运行转换
- D.在模块级管道中并行调度
答案:B。func.func(...) 表示进入函数级子管道,对每个函数执行括号内的 Pass。这是嵌套管道最典型的写法——外层是模块管道,内层是函数级子管道,而不是模块级应用或并行调度。
Q4. Pass 执行完毕后,分析缓存何时需要失效?
- A.当 IR 被该 Pass 修改后← 正确答案
- B.当 IR 保持完全不变时
- C.当分析结果首次计算后
- D.当 Pass 名称包含优化时
答案:A。分析结果是 IR 快照的结论,IR 一旦被 Pass 修改,依赖它的分析就可能“变质”。就像高汤底料换了,汤的鲜味结论就得重估。IR 不变时缓存应复用;与 Pass 名称是否含“优化”无关。
Q5. 收敛循环中,判定停止的充分条件包括哪些?
- A.IR 变化量低于预设阈值← 正确答案
- B.重写次数不再增长← 正确答案
- C.达到了最大迭代次数← 正确答案
- D.源程序运行速度翻倍
答案:ABC。收敛判定看的是 IR 自身的变化,而不是程序运行速度——调度器拿不到运行时数据。A 是经典收敛条件,B 是重写驱动的停止信号,C 是安全阀。D 的“速度翻倍”需要实测,不能作为调度内判据。
Q6. 嵌套管道相较于顺序管道的核心优势是什么?
- A.内层 Pass 可局部作用于特定结构← 正确答案
- B.外层 Pass 一定会重复执行多次
- C.嵌套管道不需要验证合法性
- D.嵌套管道必然减少编译时间
答案:A。嵌套管道的本质是“分区作业”——内层子管道只作用于匹配的结构单元,实现局部化优化。B 错在“一定会”;C 错在嵌套管道同样需要验证;D 错在嵌套可能增加开销,并非必然减少。
Q7. MLIR 中 PassManager 运行验证器的位置在哪里?
- A.每个 Pass 执行的前后时刻← 正确答案
- B.仅在整个管道收尾结束时
- C.仅在 Pass 返回失败的时候
- D.只在开启调试模式时运行
答案:A。PassManager 在每个 Pass 执行后、下个 Pass 执行前自动插入验证,确保 IR 始终合法。这与厨师每道工序后试味一样,是流程内建的质检。调试模式只是把验证结果打印出来,并非验证的前提。
Q8. 以下哪些因素会增大 Pass 调度总开销?
- A.频繁的 IR 结构合法性验证← 正确答案
- B.分析缓存命中率持续偏低← 正确答案
- C.在管道中内联大量小函数← 正确答案
- D.关闭全部调试信息输出口
答案:ABC。验证多了花时间(A),缓存不命中重复算(B),内联大量函数让后续 Pass 处理更多 IR(C),三者都推高开销。D 关闭调试输出只会减少开销,不会增大。
Q9. 动态调度与固定调度的核心区别是什么?
- A.是否依据 IR 状态决定继续执行← 正确答案
- B.是否使用同一个 Pass 管理器
- C.是否输出到标准错误流通道
- D.是否允许修改源代码文本内容
答案:A。固定调度按静态顺序每个 Pass 跑一轮;动态调度则看 IR 状态决定是否循环、是否重写管道。后者的“回锅”逻辑以 IR 状态为依据,与管理器对象、输出流、源码文本都无关。
Q10. 以下哪些手段能有效提升 Pass 调度效率?
- A.对分析结果进行缓存复用← 正确答案
- B.调度互不依赖的 Pass 并行← 正确答案
- C.精简重复执行的 IR 验证环节← 正确答案
- D.每个模块重新生成全部分析
答案:ABC。缓存复用减少重复计算,并行调度利用多核,精简验证降低固定开销,三者都是提速正道。D“重新生成全部分析”恰恰是在做重复劳动,等同每道菜都重新熬高汤,效率只会更低。