小李第一天到「美味厨房」连锁餐厅实习,主厨老张没有让他去大堂端菜,而是先带他钻进了后厨。小李原以为后厨就是一个大通间,结果越走越惊讶:每道菜都有自己的作业间,作业间里套着一间更小的后厨区;后厨区里不是乱哄哄的一团,而是并排着好几条工位线;工位线之间用传菜口连着,半成品从这条线递到那条线,遇到“食材够不够”的检查口还会自动分岔。老张拍了拍他的肩:“记住这个结构,MLIR 的 Region 与 Block,就是给编译器用的这套后厨图纸。”小李这才明白,原来一段程序在编译器眼里,从来不是一行行平铺的代码,而是一间套一间、一列接一列的“容器迷宫”:操作(Operation)可以挂载区域(Region),区域里装着若干基本块(Block),块与块用跳转边连成控制流图,循环、分支、嵌套作用域全都靠这套结构精确表达。本文就跟小李一起,把这套后厨图纸从头到尾看一遍。
对 AI 从业者的价值:做 AI 编译器、推理引擎或硬件加速器时,读不懂 Region 与 Block 就看不懂算子在 IR 里如何“展开”。掌握这套嵌套容器结构,你才能看懂并编写各种方言的 transformation 与 pass。
目录
可视化演示
展示MLIR中Operation包含Region、Region包含Block的层级嵌套树结构、SSACFG区域中Block参数与终止符驱动的控制流边、以及SSACFG与Graph两类Region语义对比。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Operation-Region-Block嵌套树 Operation通过Region拥有子IR结构递归嵌套 Region包含有序Block序列构成作用域 Op⊃Region⊃Block 以ModuleOp为根的层级树
操作与区域:Op 里藏着一间后厨
老张先带小李看“红烧肉作业间”。墙上挂着一排调料标签(属性),但真正干活的地方是里间的“红烧肉后厨区”——切肉、焯水、煸炒全部在里间完成。老张说,不是每个作业间都有后厨,比如“打鸡蛋”这种动作,在过道里一磕一倒就完事了,压根不需要里间。
MLIR 里每个节点叫 Operation(操作),简称 Op。有的 Op 像“打鸡蛋”,是原子动作,干完就完;有的 Op 则像“红烧肉作业间”,肚子里可以装一个甚至多个 Region(区域)。Region 就是 Op 内部用来容纳子操作序列的容器,它让一个操作从“单个动作”升级为“一个有内部流程的工作间”。
例如 scf.if 这个结构操作自带两个 Region:条件成立走第一个 Region,不成立走第二个;而 arith.addi 这样的算术操作没有任何 Region。MLIR 规定 Operation 与 Region 的包含关系,就是“作业间”与“后厨区”的关系:
老张总结:“作业间和后厨区的关系,就是 Op 和 Region 的关系。后厨区越多,作业间的流程就越复杂。”小李一边记一边想:那后厨区里面又是怎么布置的呢?老张说,别急,下一步带你认识工位线。
- Operation 符号表示 MLIR 中的基本操作实体,相当于一个“作业间”或“小动作”。
- 包含符号读作“包含”,强调 Op 是上级容器,Region 是挂在它身上的下级空间。
- Region 符号表示操作内部挂载的区域容器,里面按顺序放着一批子操作。
区域与块:后厨里的工位线
走进红烧肉后厨区,小李发现里面并不乱,而是整整齐齐排着三条工位线:接单线、制作线、出菜线。每条线上站着三四个师傅(操作),一个干完传给下一个,顺序不能乱。
MLIR 的 Region 内部,同样不是一团散沙,而是由一个或多个 Block(基本块)组成的。Block 是一连串按顺序排列的操作序列,可以想象成后厨区的“一条工位线”。Region 可以只有一条线(顺序执行),也可以有多条线(多条路径)。它俩的关系写作:
Block 内部的每个位置都是一条具体操作。当控制流进入一个 Region,会从它的某条工位线开始,把线上的操作挨个执行完;如果只有一条线,就别无选择地走到底。这也是为什么很多简单函数在 MLIR 里只有一个 Region、一个 Block:就像小面馆的后厨只有一条线,从头做到尾。
小李脱口而出:“那工位线之间怎么衔接?总不能让师傅端着锅乱跑吧!”老张笑了:“问得好,这就引出了工位线上的来料槽和传菜口。”
- Block 下标
Block_0、Block_1等表示区域里编号、排好队的基本块。 - 花括号数学上表示“集合”,这里表示 Region 把若干块收纳在一起。
- n 的取值$n$ 可以是 1、2、甚至更大,说明块数不固定。
块参数后继:来料槽与传菜口
老张指着接单线和制作线之间的隔墙:墙上有两个来料槽,前面线上处理好的半成品从槽里递过来,后面线上的师傅直接取用。每条工位线的开头还贴着标签:本线需要几样来料。
MLIR 的 Block 也开着这样的“来料槽”——Block 参数(Block Arguments)。当一个 Block 需要上一块传来的值时,就在块头部声明参数;传入方把值“塞进槽里”,接收方在块内直接使用。一条 Block 的结构可以写成:
而“传菜口”就是 terminator(终止操作)。cf.br 是“直通口”:无条件把控制流送到某个后继 Block,同时顺带传参数;cf.cond_br 是“检查口”:根据条件走两条路中的一条。正因如此,Block 与 Block 之间才出现了一条条有向边,控制流图由此而生。
小李恍然大悟:“所以一个工位线不是孤岛,头上有来料槽,尾上有传菜口,前后一接,整条后厨就连成网了。”老张点头:“对,这张网,就是我们的工位路线图。”
- argsBlock 的形参列表,声明本块需要接收哪些外来值,也就是来料槽的清单。
- ops块内按顺序执行的若干操作,像线上依次排开的师傅。
- terminator块末尾的终止操作,决定本块结束后的去向,因此也是控制流边的来源。
控制流图:一张工位路线图
老张从抽屉里翻出一张皱巴巴的图纸,上面画着后厨所有工位线,每条线是一个方框,方框之间用箭头连着:接单线→制作线→出菜线;遇到“食材够不够”的检查口,箭头分成两路,一路去解冻线,一路直接去煸炒线;制作线末尾还有一条弯回接单线的虚线——那是“加单重做”的循环。
这张图在编译领域叫控制流图(Control Flow Graph, CFG)。Region 里的每个 Block 是图中的一个节点;每个 terminator 产生一条或多条边,指向后继 Block。形式化地写:
有了 CFG,循环和分支就都有了精确的数学表达。分支是“出度超过 1 的节点”,循环是“有一条边绕回自己祖先的环”。这也是后面无数优化 pass 的地基:循环展开、分支合并、尾调用优化,第一步都是在 CFG 上做“看图说话”。
小李看着图纸突然问:“每张图总有起点和终点吧?”老张说:“有,而且规矩很严——每条工位线只能从线头进,但可以有好几个出菜口。”
- 节点集合 B图中每个节点就是一个 Block,工位线在图中浓缩成方框。
- 边集合 E每条边代表一次 terminator 跳转,从源块指向目标块,像工位线间的箭头。
- 箭头记号$Block_i \to Block_j$ 表示控制流能够从第 i 块流向第 j 块;回边让图出现环,对应循环。
入口与出口:唯一的入口,多扇安全门
老张带小李走到后厨区门口:门上写着“顾客止步,员工入口”。老张说,不管里面有几条线,所有人都只能从这扇门进后厨区;但出去的口子不止一个——正门、侧门、消防门都算出口。这就是后厨区“单入口、多出口”的规矩。
MLIR 对 Region 的约束正是如此:一个 Region 的入口 Block 必须是它的第一个 Block(Block_0),这是唯一的入口;而 Region 的出口可以是任意多个“没有后继”的 Block。用符号表达:
这个设计让编译器的“图纸检查”变得极其简单:想验证一条工位线是否合理,只需从唯一的入口进去,沿所有边走到每一个出口,便知道路径是否全部可达;任何不可达的线,要么是死代码,要么是工程师画错了图。
小李又冒出一个问题:“我听说后厨之间还有规矩,说隔壁甜品间的奶油不能拿到红烧肉间用?”老张竖起大拇指:“你问到点子上了,这叫作用域。”
- entry 函数表示“取区域的入口块”,结果固定是第一个块。
- exit 集合收集所有没有后继的基本块,它们都可以作为区域的出口,对应后厨的各个安全门。
- 空后继若某块的后继集合为空,说明控制流到此为止(比如遇到了 return 操作)。
作用域边界:菜香不出后厨
小李看见甜品间的师傅拎着一盒奶油往红烧肉间走,被领班拦住:“这是甜品间的专用食材,不能带过去!”老张解释说,每个后厨区的食材只在本区可见,跨区使用必须通过来料槽(块参数)显式传递,绝不能顺手就抓。
MLIR 中值的可见性由“定义所在的 Region”决定:一个值定义在哪个 Region,它的作用域就在那个 Region 及其内部嵌套的子区域里;同一 Region 的兄弟区域之间,默认谁也看不见谁。要用,就走 Block 参数显式传。这个规则可以写成:
这条规则保证了 IR 的“卫生”:每个块只看到自己该看的,不会出现跨区域的隐式依赖。对编译器来说,这意味着一块代码可以放心搬运、替换、并行化,不必担心牵一发动全身。这也是为什么高级控制流可以安心地降级成低级分支——值传递路径始终画得清清楚楚。
老张看小李消化得差不多了,抛出最后一个彩蛋:“刚才说的都是'一间后厨'的内部结构。其实后厨区里还可以再套一个小后厨,比如红烧肉间里划出一个熬糖色的独立小间。这种'屋中屋',才真正让编译器表达复杂循环和分支时游刃有余。”
- scope 函数表示值 $v$ 的可见范围,也就是“这盒奶油能在哪几个工位被使用”。
- 定义所在区域$\text{Region}(\text{def}(v))$ 指出这个值诞生于哪个 Region,奶油诞生在甜品间。
- 子集符号规定作用域不会超出诞生它的区域边界。
嵌套控制流:屋中屋与套餐流水线
老张带小李看总部发来的“大单套餐”指令:如果订单是宴席,就走宴席套餐线;如果是散客,就走小份线。宴席套餐线内部又是一整套完整的工位图——开胃菜区、主菜区、甜点区,各自又有自己的工位线……一层套一层,像俄罗斯套娃。
MLIR 的高级控制流操作正是这样:scf.if 条件操作本身带两个 Region,分别装“成立”和“不成立”两块工位图;scf.for 循环操作带一个 Region 作为循环体,Region 里的入口 Block 声明了循环变量和迭代变量参数。它们在 Region/Block 层面展开后,就是不断递归的嵌套结构:
在真实的 MLIR 方言里,scf.if 在降级到 CFG 方言后,会变成 cf.cond_br 加两个 Block;scf.for 会变成入口块、循环条件检查块、循环体块、回边块四个基本块的组合。但无论方言怎么换,Region 与 Block 这套容器结构始终不变——就像不管菜单怎么变,后厨里永远是一间套一间的作业间加工位线。
参观结束,小李站在后厨门口,回头再看这片“屋中屋”,眼里已经不再是杂乱无章的锅碗瓢盆,而是一张清晰嵌套的工程图纸。老张说:“记住今天看的,以后你写 AI 编译器、调推理引擎,看到 IR 里一排排花括号和块标签,就不会迷路了。”
- 递归箭头箭头表示“容纳”关系层层下钻,从操作进入它内部的区域。
- 块中再现操作块内装着一批操作,其中某个操作又自带 Region,于是递归继续。
- 省略号寓意嵌套可以无限深入,区域套区域,块中又生块。
AI 应用场景
AI 芯片编译器把嵌套循环“摊给”硬件加速器
阶段:中间表示与代码生成
解决的问题:AI 模型里大量嵌套循环(卷积、Attention),传统编译器平铺的指令流难以识别循环边界,无法完成流水线调度和并行核心映射。
具体案例:某 NPU 编译器把 scf.for 表达的组合循环,通过 Region/Block 结构识别出循环嵌套深度,再展开成硬件能并行执行的多个子 Region,最终映射到脉动阵列上。每一个子 Region 就像一个独立后厨,硬件核各自开火互不干扰。
推理引擎里的算子融合 Pass
阶段:图优化与 Pass 流水
解决的问题:多个小算子逐个执行会带来大量显存读写,推理延迟居高不下。
具体案例:TensorRT 与 MLIR 的 fusion pass 通过检查 Region 内 Block 的拓扑顺序,把 conv+bn+relu 三个 op 合并为一个带内部 Region 的自定义 op。合并后数据在片上缓存里直接流转,就像把三个摊位合进一个后厨,半成品不用端出门。
硬件综合中把控制流映射成状态机
阶段:高阶综合(HLS)
解决的问题:把带循环/分支的算法映射为有限状态机时,需要知道每个基本块的入口出口和跳转条件。
具体案例:HLS 工具利用 Region 单入口、多出口的特性,把每个 Block 变成状态机的一个状态,terminator 的分支变成状态转移条件,自动生成 RTL 状态机。后厨的“传菜口”在硬件里就是状态转移线。
Python 演示
# -*- coding: utf-8 -*-"""Mini MLIR: 用纯 Python 模拟 Region / Block 的嵌套打印。""" class Op: """一个操作: 名字 + 操作数 + 结果 + 可嵌套的 Region 列表。""" def __init__(self, name, operands=(), results=(), regions=None, suffix=None): self.name = name self.operands = list(operands) self.results = list(results) self.regions = regions or [] self.suffix = suffix def emit(self, indent=' '): head = '' if self.results: head += ', '.join('%s: i32' % r for r in self.results) + ' = ' head += self.name if self.operands: head += ' ' + ', '.join(str(o) for o in self.operands) if not self.regions: head += self.suffix if self.suffix is not None else ' : i32' lines = [indent + head] for reg in self.regions: lines.append(reg.emit(indent + ' ')) return '\n'.join(lines) class Block: """基本块: 头部参数(args) + 操作序列(ops)。""" def __init__(self, label, args=(), ops=()): self.label = label self.args = list(args) self.ops = list(ops) def emit(self, indent=' '): arg_str = ', '.join('%s: i32' % a for a in self.args) lines = ['%s^%s(%s):' % (indent, self.label, arg_str)] for op in self.ops: lines.append(op.emit(indent + ' ')) return '\n'.join(lines) class Region: """区域: 若干基本块的有序集合。""" def __init__(self, blocks): self.blocks = list(blocks) def emit(self, indent=' '): lines = ['%s{' % indent] for b in self.blocks: lines.append(b.emit(indent + ' ')) lines.append('%s}' % indent) return '\n'.join(lines) def build_choose(): """if-else: 两个分支块通过块参数接收值。""" bb0 = Block('bb0', args=['%flag', '%x', '%y'], ops=[ Op('arith.addi', operands=['%x', '%y'], results=['%sum']), Op('arith.subi', operands=['%x', '%y'], results=['%diff']), Op('cf.cond_br', operands=['%flag', '^bb1(%sum)', '^bb2(%diff)'], suffix=''), ]) bb1 = Block('bb1', args=['%v'], ops=[ Op('func.return', operands=['%v'], suffix=' : i32'), ]) bb2 = Block('bb2', args=['%v'], ops=[ Op('func.return', operands=['%v'], suffix=' : i32'), ]) return Op('func.func @pick(%flag: i32, %x: i32, %y: i32) -> i32', regions=[Region([bb0, bb1, bb2])]) def build_loop(): """scf.for: 循环体是一个 Region, 入口块带循环变量参数。""" inc = Op('arith.addi', operands=['%acc', '%i'], results=['%next']) body = Region([ Block('bb_loop', args=['%i', '%acc'], ops=[ inc, Op('scf.yield', operands=['%next']), ]), ]) for_op = Op('scf.for', operands=['%lb', '%ub', '%step', '%init'], results=['%r'], regions=[body], suffix=' -> i32') entry = Block('bb0', args=['%n', '%init'], ops=[ Op('arith.constant', operands=['0'], results=['%lb']), Op('arith.constant', operands=['%n'], results=['%ub']), Op('arith.constant', operands=['1'], results=['%step']), for_op, Op('func.return', operands=['%r'], suffix=' : i32'), ]) return Op('func.func @sum(%n: i32, %init: i32) -> i32', regions=[Region([entry])]) def main(): print(build_choose().emit()) print() print(build_loop().emit()) if __name__ == '__main__': main()
self.regions = regions or []一个 Op 可以挂 0 个或多个 Region,统一存成列表,对应“作业间可以没有后厨也可以有很多后厨”。for reg in self.regions:操作打印完自身信息后,递归打印嵌套区域,这是“屋中屋”结构的核心。head += self.suffix if self.suffix is not None else ' : i32'控制流指令不追加类型后缀,普通算术指令默认带: i32。arg_str = ', '.join(...)把块头部的“来料槽”(块参数)拼成%name: i32形式。Op('cf.cond_br', operands=[..., '^bb1(%sum)', '^bb2(%diff)'])terminator 决定控制流去向并携带参数,像检查口分岔。body = Region([Block('bb_loop', args=['%i', '%acc'], ...)])循环体就是把一个带循环变量的 Block 塞进 Region。for_op = Op('scf.for', ..., regions=[body], suffix=' -> i32')带 Region 的操作能完整表达循环,打印时缩进出嵌套结构。
常见误区
误区新人看到 MLIR 文本里的 `{ ... }` 缩进,就以为 Region 和 C 语言的代码块一样,只是个“括号作用域”。
正解Region 本质是 Op 内部容纳子操作序列的容器,是可以被单独遍历、改写、替换的一等公民。就像后厨区是一个真正能走进去的工作空间,而不只是墙上的两个符号。C 的花括号没有自己的“身份”,Region 却有类型和结构,可以被 pass 独立操作。
为何常见:文本打印太像花括号代码,加上很多语言都有类似语法,容易先入为主。
误区看到“block“就想到”一段代码“或”打包的一组语句”,很多人以为基本块就是一条大指令。
正解Block 是一个“操作序列 + 末尾终止操作”的结构,内部至少可以装多条 op;它是控制流图上的节点,尾部 terminator 决定它连向谁。就像后厨里的一条工位线,由多个工位排成,而不是一个单独的灶台。
为何常见:英文 block 在日常英语里就是“块/一段”,初学者望文生义。
误区觉得“控制流结构”就得有很多块,至少两个以上,否则不叫流程。
正解Region 可以只有 1 个 Block,甚至 0 个 Block。顺序执行的函数体就是一个 Block 的 Region;某些场景下 Region 也可以为空占位。块的数量由操作的功能决定,与“是否合法”无关。
为何常见:教材多用 if/for 举例,基本都展示多块结构,让人误以为块多是前提。
误区既然控制流边可以指向任何块,那入口块也是任意的,第一个块没什么特殊。
正解MLIR 规定 Region 的入口只能是第一个 Block。外部控制流进入 Region 时默认从它开始;其它块只能通过块间边到达。这个“单入口”约定让 pass 能快速定位分析起点,就像后厨区只有一个员工入口,侧门消防门只能出不能进。
为何常见:看到 CFG 上边可以指向任意块,就以为入口没有特殊地位。
面试题
Q1. 在 MLIR 中,一个 Operation 可以包含几个 Region?
- A.最多一个
- B.零个或多个← 正确答案
- C.至少一个
- D.恰好一个
答案:B。MLIR 的 Operation 可以携带 0 个或多个 Region。就像作业间可以没有后厨,也可以有多个后厨区;算术操作(如 arith.addi)不带 Region,而 scf.if 带两个 Region。A、C、D 都限制了数量,不符合自由度。
Q2. Region 的入口 Block 是哪一个?
- A.最后一个
- B.任意一个
- C.第一个块← 正确答案
- D.每个块都是
答案:C。Region 规定第一个 Block 是唯一的入口,外部控制流进入 Region 时从它开始。就像后厨区只有一个员工入口,不是哪个门都能进。因此 C 正确。
Q3. Block 内部紧挨着排列的是什么?
- A.只有一条指令
- B.操作序列← 正确答案
- C.表达式列表
- D.一串字符串
答案:B。Block 是一条“工位线”,里面装着一连串按顺序执行的操作(Op)。它可以容纳多条指令,不是单个指令;操作序列对应后厨里依次排开的师傅。
Q4. 控制流图中,Block 之间的“边”由什么决定?
- A.块参数的个数
- B.作用域大小
- C.终结操作决定← 正确答案
- D.操作的类型
答案:C。每条边都来自 Block 末尾的终结操作(terminator),如 cf.br、cf.cond_br。terminator 像传菜口决定半成品送往哪条线,块参数只负责传值,不决定去向。
Q5. Block 参数的主要作用是什么?
- A.声明局部变量
- B.接收传入的值← 正确答案
- C.保存常量引用
- D.标注结果类型
答案:B。Block 参数(Block Arguments)是块头部的“来料槽”,专门用来接收前驱块传来的值。声明局部变量是块内操作的工作;常量引用和结果类型也与 Block 参数无关。
Q6. 下列关于 Region 的说法,正确的有?
- A.只能含一个块
- B.可以含多个块← 正确答案
- C.只有一个入口← 正确答案
- D.块块必须相连
答案:BC。Region 可以含 1 个或多个 Block,且规定第一个块为唯一入口(单入口),但可以有多个出口。D 错在“必须相连”——Region 允许存在无后继的出口块,也不需要所有块都相互连通。
Q7. scf.if 在 MLIR 中会展开为怎样的结构?
- A.一个独立区域
- B.分支与合流块← 正确答案
- C.一个单独块
- D.全局变量表
答案:B。scf.if 携带两个 Region,分别对应条件成立与不成立的分支,展开后是一组分岔与合流的 Block 簇,配合终结操作构成完整控制流。它绝不是单块结构,也不依赖全局变量。
Q8. 循环(scf.for)在 Region/Block 层面如何体现?
- A.循环体是一个区域← 正确答案
- B.块参数承载计数← 正确答案
- C.回边构成循环← 正确答案
- D.循环体不能嵌套
答案:ABC。scf.for 的循环体是一个 Region,入口块通过块参数接收循环变量和迭代累积值;降级到 CFG 后,循环靠回边(从循环体末尾跳回条件检查块)形成环。D 错误,循环体 Region 里完全可以嵌套新的循环。
Q9. 值的作用域边界主要由什么确定?
- A.所在的基本块
- B.所在的区域← 正确答案
- C.所在的操作
- D.全局都不受限
答案:B。一个值定义在哪个 Region,它的可见域就以该 Region 为边界;兄弟区域默认不可见,跨区域必须通过块参数显式传递。这就像奶油只能在自己的后厨区使用,不能顺手拿到别的区。
Q10. 关于嵌套 Region 结构,下列说法正确的有?
- A.区域可以无限嵌套← 正确答案
- B.外层看不到内层值← 正确答案
- C.跨块传值用参数← 正确答案
- D.内层必须放首块
答案:ABC。Op 套 Region、Region 装 Block、Block 里又可以有带 Region 的 Op,理论上可无限嵌套;内层定义的值对外层不可见(作用域封闭);跨块传值通过 Block 参数完成。D 错误——内层 Region 并没有“必须放在首块”的限制。