想象一座巨大的中央厨房,几百位厨师同时开工:有人做川菜,有人做粤菜,还有人在烘焙区烤面包。这么热闹的场面靠什么保持井井有条?答案是一摞摞统一的“步骤卡”。每张卡片只写清一件事:这一步叫什么名字(川菜·爆炒)、要用哪些食材、出锅是什么成品、调味参数是多少,背面甚至还可能贴着几张小号的子步骤卡。MLIR(多级中间表示)这座“编译器中央厨房”,正是用同样的思想来组织千千万万次计算的——每一次计算都被封装成一张“步骤卡”,这张卡片,就叫 Operation(操作)。本文带你从一张步骤卡出发,看懂 Operation 的五个组成部分、它如何被命名、如何用 ODS 模板批量制造,以及它怎样撑起整个编译优化的流程。

岗位关联

掌握 Operation 定义是进入 MLIR 方言开发的第一道门槛——无论你想为 AI 芯片定义自定义算子,还是编写图优化 Pass,所有工作都从“把操作定义清楚”开始。这是 AI 编译器工程师的核心基本功。

目录
  1. 可视化演示
  2. 操作概念总览
  3. 操作名称标识
  4. 输入输出操作数
  5. 属性Attribute
  6. 区域Region
  7. ODS定义方式
  8. 验证与编译作用
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示MLIR Operation的组成结构、类型系统检查与Region嵌套层级,点击步骤切换不同核心概念演示。

本节配有交互动效(进入视口后自动加载,约 3KB)。

MLIR Operation组成结构 名称+操作数+结果+属性+区域五要素 属性字典存储编译期常量配置 %result = op.name(%arg0, %arg1) {attr: val} : (i32, i32) -> i32 Operation是MLIR计算图的最小构建单元

操作概念总览

在中央厨房里,不管多么复杂的宴席,最终都要落到一张张具体的步骤卡上。“宫保鸡丁”可以被拆成“备料、调汁、爆炒、装盘”四步,每一步都是一张独立的卡片。谁来做、用哪口锅都不重要,重要的是这张卡把“一步工序”说清楚了。MLIR 也是这样:无论多复杂的模型,前端解析后都会变成一张张 Operation“步骤卡”,它们手拉手连成数据流图,这就是编译器眼中的“宴席”。

Operation 是 MLIR 中构建计算图与层次结构的基本单元(basic unit of compute)。一段深度学习模型(如 ResNet)经过前端转换后,会变成由成千上万个 Operation 组成的中间表示。与后端机器指令不同,Operation 不依赖具体硬件,它只负责“描述一次计算”,并且永远属于某一个方言(dialect)——就像每张步骤卡都要盖上所属菜系的章,标明它是川菜的规矩还是粤菜的套路。

一张完整的“步骤卡”由五个部分组成:名称(name)标明操作身份;操作数(operands)是它消费的输入;结果(results)是它产出的新值;属性(attributes)是编译期就固定下来的参数;区域(regions)则允许操作内部再嵌套一组子操作。这五部分共同决定了一次计算的全部信息,也决定了这个 Operation 能参与哪些优化。

有了统一的五部分结构,任何菜系、任何厨师的步骤卡都能被同一套系统读懂。这张卡片一旦确立,后续的品控(verifier)、优化(pass)、出锅(代码生成)就都有了依据——这就是“先定义清楚,再谈效率”的编译器哲学。

Operation=⟨ name, operands, results, attrs, regions ⟩\displaystyle \mathrm{Operation} = \langle\, \mathrm{name},\ \mathrm{operands},\ \mathrm{results},\ \mathrm{attrs},\ \mathrm{regions} \,\rangle
一个 Operation 就像一张标准化步骤卡:五栏信息写全了,这张卡才能被厨房(编译器)按统一流程处理。
  1. 名称 name操作的唯一标识,决定它在注册表中的查找路径。
    name=arith.addi\displaystyle \mathrm{name} = \text{arith.addi}
  2. 操作数 operands操作消费的输入值,必须来自先前定义的操作结果。
    operands={ x1, x2, …, xn }\displaystyle \mathrm{operands} = \{\, x_1,\ x_2,\ \ldots,\ x_n \,\}
  3. 结果 results操作产出的新值,供后续操作继续消费。
    results={ y1, y2, …, ym }\displaystyle \mathrm{results} = \{\, y_1,\ y_2,\ \ldots,\ y_m \,\}
  4. 属性 attrs编译期确定的常量参数,创建后不可改变。
    attrs={ a1=v1, a2=v2, … }\displaystyle \mathrm{attrs} = \{\, a_1 = v_1,\ a_2 = v_2,\ \ldots \,\}
  5. 区域 regions操作内部嵌套的子结构,用于表达控制流与层次。
    regions={ R1, R2, … }\displaystyle \mathrm{regions} = \{\, R_1,\ R_2,\ \ldots \,\}

操作名称标识

中央厨房里最怕两件事:同名不同菜、同菜不同名。为了杜绝混乱,每张步骤卡都会盖一个“菜系。动作”的章——“川菜。爆炒”一看就知道该去川菜档口找标准做法,与“粤菜。爆炒”井水不犯河水。MLIR 的 Operation 名称遵循同样的规矩。

在 MLIR 中,每个 Operation 都拥有一个全局唯一的字符串名称,格式为“方言前缀。操作名”。例如 arith.addi 表示 arith 方言中的整数加法,func.func 表示函数定义,scf.for 表示结构化循环。方言前缀就是命名空间,操作名后缀才是具体动作。两者合在一起,操作的身份就一目了然。

名称不是装饰。编译器的方言注册表(dialect registry)靠它把文本形式和 C++ 类关联起来:读 IR 时看到一个名字,就创建出对应的操作对象;写 IR 时也靠它决定输出格式。同一个操作在文本表示、内存对象、序列化文件中始终用同一个名字对齐,任何地方都不能乱改。

就像盖了章的步骤卡不会混档,带方言前缀的操作名保证了 MLIR 里“同名不同菜”和“同菜不同名”都不会发生。这是整个方言系统能够和平共处、任意扩展的地基。

OpName=DialectPrefix⋅OpSuffix\displaystyle \mathrm{OpName} = \mathrm{DialectPrefix} \cdot \mathrm{OpSuffix}
名称是操作的“身份证号”:方言前缀是省份编码,点号是分隔符,操作名是具体动作。
  1. 方言前缀 DialectPrefix命名空间,标明操作属于哪个方言。
    DialectPrefix=arith\displaystyle \mathrm{DialectPrefix} = \text{arith}
  2. 点号分隔符用点把前缀和后缀连接起来。
    sep=⋅\displaystyle \mathrm{sep} = \cdot
  3. 操作后缀 OpSuffix方言内具体的动作名。
    OpSuffix=addi\displaystyle \mathrm{OpSuffix} = \text{addi}

输入输出操作数

一张“爆炒”步骤卡的左右两侧各有一个栏目:左边“食材栏”列出鸡丁、花生米、葱段——它们不是凭空出现的,而是上一步骤的成品;右边“成品栏”写着出锅后的菜品。这些栏目的填写顺序有一条铁律:必须先有食材,才能下锅。

这条铁律在 MLIR 中叫作 SSA(Static Single Assignment,静态单赋值)语义:每个值只能被定义一次,操作的操作数必须来自之前已经定义好的值,操作产出的结果则成为后续操作的操作数。这条规则让数据流变成一条单向的河,清晰可查、无环可追。

操作数(operand)和结果(result)都带有类型(type),比如 tensor<4xf32> 表示一个 4 元素 f32 张量,i64 表示 64 位整数。类型既是约束也是信息:编译器在构建 IR 时就能发现“把张量喂给整数加法”这类低级错误,而不是等运行时才崩溃。一个操作可以消费多个操作数,也可以产出多个结果,比如返回 (值,标志) 两个结果的操作很常见。

食材栏和成品栏一旦严格按“先有鸡才有蛋”的顺序填写,整条流水线就不会出现“菜还没出锅就被端上桌”的混乱。SSA 值把一张张步骤卡串成一条单向数据河,后续所有优化都在这条河上做文章。

y1, y2, …, ym=op( x1, x2, …, xn )\displaystyle y_1,\ y_2,\ \ldots,\ y_m = \mathrm{op}(\, x_1,\ x_2,\ \ldots,\ x_n \,)
操作像一台加工机:左边输入原料,右边吐出成品,且每个成品只生产一次,因此流水线永远不会混乱。
  1. 操作数集合等式右侧括号内的 x 是输入 SSA 值。
    xi∈Operands\displaystyle x_i \in \mathrm{Operands}
  2. 结果集合等式左侧的 y 是新产出的 SSA 值。
    yj=Resultj(op)\displaystyle y_j = \mathrm{Result}_j(\mathrm{op})
  3. SSA 约束每个 y_j 在整个 IR 中只被定义一次。
    ∀ j, def(yj)=op\displaystyle \forall\, j,\ \mathrm{def}(y_j) = \mathrm{op}

属性Attribute

步骤卡上有这么一栏:盐 3 克、油温七成、大火爆炒 30 秒。这些参数在下锅前就白纸黑字写好了,做菜过程中绝不会变。它们和食材(操作数)有本质区别:食材是动态端上来的,调味参数是静态刻在卡片上的。

在 MLIR 中,这一栏“静态参数”就叫属性(Attribute)。属性是附着在 Operation 上、编译期就完全确定的常量信息。比如 arith.constant 操作要产出常量 3,就得靠属性 value = 3 来记录这个"3";否则编译成目标代码时,机器根本不知道该生成哪条立即数指令。

属性的种类非常丰富:整数属性(IntegerAttr)、字符串属性(StringAttr)、数组属性(ArrayAttr)、稠密张量属性(DenseElementsAttr)等,它们就像菜谱里盐、糖、生抽各有各的计量单位。属性不可变(immutable),一旦创建就无法修改,这保证了并行优化时不会出现数据竞争。优化 Pass 可以直接读取属性做常量折叠、形状推导等决策。

记住一句话:操作数是“运行时端上来的食材”,属性是“刻在卡片上的调味参数”。前者让数据流动,后者让决策确定。把静态信息显式地写进 IR,编译器才能在不运行程序的情况下做大量优化。

op[ a1=v1, a2=v2, …, ak=vk ]\displaystyle \mathrm{op}[\, a_1 = v_1,\ a_2 = v_2,\ \ldots,\ a_k = v_k \,]
方括号里的属性列表是刻在卡片上的静态参数,创建后不再变化;食材(操作数)却可以源源不断地换新。
  1. 方括号标记表示后面跟随的是属性列表。
    [ ⋯ ]\displaystyle [\ \cdots\ ]
  2. 属性名 a_i属性的键,如 value、axis。
    ai∈AttrNames\displaystyle a_i \in \mathrm{AttrNames}
  3. 属性值 v_i编译期常量,参与序列化与优化决策。
    vi∈AttrValues\displaystyle v_i \in \mathrm{AttrValues}

区域Region

有些步骤卡的背面还贴着一张小号卡片夹。比如“爆炒”步骤卡的背面写着两行子步骤:一、“调汁:将酱油、醋、糖搅匀”;二、“滑炒:鸡丁下锅炒至变色”。每个子步骤自己又是一张步骤卡。步骤卡套步骤卡,这就是 Operation 的区域。

MLIR 的区域(Region)正是这种“卡片夹”。一个 Region 内部包含若干个按顺序排列的块(Block),每个 Block 里又包含一串 Operation,并以一个终结操作(terminator)收尾。函数体、循环体、if 分支体在 MLIR 里统统都是 Region,它们是操作内部天然的“子厨房”。

Region 给 IR 带来了两层能力:第一,嵌套表达——一个操作可以无限套娃地包含子操作,函数包函数、循环包循环;第二,控制流表达——分支与循环通过 Region 的内容形态自然呈现,不用额外发明跳转指令。此外 Region 还定义了值的作用域:外层定义的值可以被内层引用,内层定义的值出不了内层。

有了“卡片夹”,一张步骤卡就不再只是一行孤零零的动作,而是一棵可生长的树。这种强烈的层次感,让 MLIR 能同时承载高层语义(如深度学习算子)和低层语义(如循环展开后的指令序列),真正做到“多级中间表示”。

R={ B1, B2, …, Bk },Bi=[ opi,1, opi,2, …, opi,ni ]\displaystyle R = \{\, B_1,\ B_2,\ \ldots,\ B_k \,\},\qquad B_i = [\, \mathrm{op}_{i,1},\ \mathrm{op}_{i,2},\ \ldots,\ \mathrm{op}_{i,n_i} \,]
区域是“卡片夹里的小卡片夹”:Region 装 Block,Block 装 Operation,层层嵌套、秩序井然。
  1. 区域 ROperation 内部的一个命名结构,可包含多个块。
    R⊆Operation\displaystyle R \subseteq \mathrm{Operation}
  2. 块 B_i块是操作的容器,块内操作按顺序执行。
    Bi=[ opi,1, opi,2, … ]\displaystyle B_i = [\,\mathrm{op}_{i,1},\ \mathrm{op}_{i,2},\ \ldots \,]
  3. 块间关系多个块共同组成区域,表达分支等控制流。
    R={ B1, B2, …, Bk }\displaystyle R = \{\, B_1,\ B_2,\ \ldots,\ B_k \,\}

ODS定义方式

如果每个厨师都按自己的习惯写步骤卡,有的写“盐少许”,有的写“盐:3 克”,品控就没法干了。中央厨房的解决办法是:发一张统一的申请表,所有卡片必须照着表填。MLIR 的 ODS 就是这张申请表。

ODS(Operation Definition Specification,操作定义规范)是 MLIR 官方推荐的声明式操作定义方式,基于 TableGen 语言写在 .td 文件中。它把“定义一个操作”变成“填一张表”:填名称、填参数、填结果、填约束,其余样板代码全部交给工具自动生成。下面是一个极简例子:

ODS 自动生成的 C++ 代码包括:操作类本身、访问器(getLhs())、构造器(builder)、类型辅助接口以及 verifier 骨架。手写这些代码既枯燥又容易出错,而声明式定义让整个方言的操作实现风格高度统一,也方便代码审查。正因为有了 ODS,MLIR 生态才能以极低的成本长出几十个方言。

从“手写步骤卡”到“填申请表”,看似只是格式统一,实则把生产力从“逐张手抄”提升到了“批量印刷”。ODS 是 MLIR 能够快速繁衍方言、支撑 AI 编译器百花齐放的关键基础设施。

ODS(name, traits, arguments, results)→ TableGen Cpp_Class\displaystyle \mathrm{ODS}\bigl(\mathrm{name},\ \mathrm{traits},\ \mathrm{arguments},\ \mathrm{results}\bigr) \xrightarrow{\ \mathrm{TableGen}\ } \mathrm{Cpp\_Class}
这张“申请表”经过 TableGen 工具的批量印刷,自动变成可以直接编译的 C++ 代码。
  1. 名称声明在模板中填写操作的完整名称。
    name=arith.addi\displaystyle \mathrm{name} = \text{arith.addi}
  2. 参数与结果声明声明操作数与属性的类型、结果的类型。
    arguments={ lhs:I64, rhs:I64 }\displaystyle \mathrm{arguments} = \{\, \mathrm{lhs}: I64,\ \mathrm{rhs}: I64 \,\}
  3. 自动生成TableGen 工具把这些声明翻译成 C++ 操作类。
    Generate(ODS_Def)=Cpp_Class\displaystyle \mathrm{Generate}(\mathrm{ODS\_Def}) = \mathrm{Cpp\_Class}
tablegen4 行
def AddIOp : Op<"arith.addi"> {  let arguments = (ins I64:$lhs, I64:$rhs);  let results = (outs I64:$sum);}

验证与编译作用

步骤卡填写完毕,还要过两道关。第一道是品控:厨师长核对食材栏与成品栏是否对得上、调味参数是否在合法范围内,不合规的卡片直接打回。第二道是流程优化:经验丰富的大厨会审视整条流水线,把“爆炒”和“勾芡”合并成“宫保”一步,省掉一次出锅装盘。

对应到 MLIR,第一道关就是 verifier(验证器)。每个 Operation 都可以定义 verify 逻辑,在 IR 刚被构建出来以及每次 Pass 改写之后,检查操作数数量与类型、属性是否齐全合法等不变量。验证失败的 IR 会被立刻拒绝,把错误扼杀在早期——这比让错误一路传到后端再崩溃要省事得多。

第二道关是编译优化与降低(lowering)。编译器 Pass 以 Operation 为匹配目标,用模式匹配(pattern match)找到特定形态的操作子图,检查附加条件之后,把它们重写(rewrite)成新的操作序列。例如死代码消除会删掉“结果无人使用”的算子,算子融合会把“乘加”合并成“融合乘加”。通过不断把高层方言的操作降低为低层方言操作,最终生成目标代码。

品控让每一步都可信,流程优化让每一步都高效。一张张小小的步骤卡,因为被定义得足够清楚,才支撑起整座中央厨房的高效运转——这也就是 Operation 定义在 MLIR 编译流程中的最终价值。

Match(o)∧Check(o)  ⟹  Rewrite(o)\displaystyle \mathrm{Match}(o) \wedge \mathrm{Check}(o) \implies \mathrm{Rewrite}(o)
一遇到满足条件的操作子图,优化器就像大厨合并步骤一样,把一小段流程替换成更高效的一段。
  1. 匹配 Match在 IR 中找到符合特定形态的操作子图。
    o∈IR\displaystyle o \in \mathrm{IR}
  2. 检查 Check验证附加条件,如属性取值、类型约束。
    Check(o)=true\displaystyle \mathrm{Check}(o) = \mathrm{true}
  3. 重写 Rewrite用新模式替换原操作序列。
    Rewrite(o)=o′\displaystyle \mathrm{Rewrite}(o) = o'

AI 应用场景

为自研 AI 芯片定义融合算子

阶段:算子定义与中间表示构建

解决的问题:传统方式下,为 NPU 添加“GELU+加偏置”融合算子需要手写大量 C++ 样板代码(操作类、访问器、构造器、verifier),易错且难维护。用 ODS 声明式定义算子,则只需填写一张“申请表”,自动生成完整 C++ 代码,语义更清晰、风格更统一。

具体案例:某推理引擎团队在接入自研 NPU 时,用 ODS 定义了 npu.fused_gelu_bias 操作,声明两个输入(张量、偏置)和一个输出,并附加 verifier 约束输入形状一致。三十行 ODS 表格自动生成约三百行 C++ 代码,开发周期从两周缩短到两天。

编写图优化 Pass 实现算子融合

阶段:IR 优化与图重写

解决的问题:手工遍历计算图做替换极易遗漏边界情况;基于 Operation 的模式匹配可以把“卷积+批归一化”这类固定组合声明为一条重写规则,让编译器自动发现并替换。

具体案例:训练框架的图优化模块用 MLIR Pattern Rewriter 编写融合规则:匹配 conv2d 与 batch_norm 两个操作的连接模式,检查 bn 处于推理模式后,替换为单个 conv_bn_fused 操作。模型推理图节点数因此减少约 20%。

多硬件后端的方言降低

阶段:代码生成与后端适配

解决的问题:同一模型要跑 GPU、NPU、CPU,传统做法是各自维护一套前端和优化链路。利用 MLIR 多级中间表示,先把模型翻译到高层方言,再逐级降低,各后端只需要关心最后一层方言的 Operation 定义。

具体案例:某公司把 ONNX 模型转为 tosa 方言操作,再降低到 npu 方言的自定义操作,中间层优化 Pass 完全复用;适配新硬件时只需编写最后一层方言的 Operation 定义与代码生成逻辑。

Python 演示

python69 行
# -*- coding: utf-8 -*-"""用纯 Python 模拟 MLIR Operation 的五部分结构"""  class Value:    """模拟 SSA 值: 由某个操作产出, 带有类型"""     def __init__(self, name, typ):        self.name = name        self.typ = typ     def __repr__(self):        return "%s : %s" % (self.name, self.typ)  class Operation:    """模拟 MLIR Operation: 名称 + 操作数 + 结果 + 属性 + 区域"""     def __init__(self, name, operands=None, result_types=None,                 attrs=None, regions=None):        self.name = name                # 例如 arith.addi        self.operands = operands or []  # 输入 SSA 值列表        self.attrs = attrs or {}        # 编译期常量属性        self.regions = regions or []    # 嵌套 Region 列表        self.results = []        short = name.split(".")[-1]        for i, t in enumerate(result_types or []):            self.results.append(Value("%s_out%d" % (short, i), t))     def verify(self):        """简易 verifier: 检查操作数数量与属性约束"""        expect = self.attrs.get("expect_operand_count")        if expect is not None and len(self.operands) != expect:            raise ValueError(                "%s 需要 %d 个操作数, 实际 %d 个"                % (self.name, expect, len(self.operands))            )        for i, op in enumerate(self.operands):            if not isinstance(op, Value):                raise TypeError("第 %d 个操作数不是 SSA 值" % i)        return True     def __repr__(self):        ins = ", ".join(str(v) for v in self.operands)        outs = ", ".join(str(v) for v in self.results)        return "%s(%s) -> (%s)" % (self.name, ins, outs)  def build_demo():    # 两个常量操作: 用属性 value 记录编译期常量    c1 = Operation("arith.constant", result_types=["i32"],                   attrs={"value": 3})    c2 = Operation("arith.constant", result_types=["i32"],                   attrs={"value": 5})    # 加法操作: 消费两个 SSA 值, 产出一个新值    add = Operation("arith.addi",                    operands=[c1.results[0], c2.results[0]],                    result_types=["i32"],                    attrs={"expect_operand_count": 2})    # 验证通过才打印    add.verify()    print(c1)    print(c2)    print(add)    print("属性: %s, 区域数: %d" % (add.attrs, len(add.regions)))  if __name__ == "__main__":    build_demo()
目的:用纯 Python 模拟 MLIR Operation 的“名称+操作数+结果+属性+区域”五部分结构,并演示一个迷你 verifier 如何拦截非法 IR。
  1. self.name = name # 例如 arith.addi操作名称按“方言前缀。操作名”格式保存,对应正文第二节的命名规则。
  2. self.operands = operands or [] # 输入 SSA 值列表操作数必须是其他操作产出的值,就像食材必须来自上一步骤的成品。
  3. self.results.append(Value("%s_out%d" % (short, i), t))每个结果都是一个全新的 SSA 值,只被定义一次,对应第三节的 SSA 语义。
  4. self.attrs = attrs or {} # 编译期常量属性属性是刻在卡片上的静态参数,如常量操作的 value=3。
  5. expect = self.attrs.get("expect_operand_count")verifier 从属性中读取约束条件,检查操作数数量是否符合声明。
  6. add.verify()构建完 IR 后手动触发验证,模拟编译器构建阶段的品控检查。
  7. add = Operation("arith.addi", ...)用前两个操作的 results 作为输入,体现 SSA 数据流“先定义、后消费”。

常见误区

误区我觉得 Operation 就是编译到最后的东西,跟汇编指令差不多,一条 Operation 对应一条机器指令。优化嘛,就是挑几条指令删掉。

正解Operation 是“有语义、有类型、有嵌套能力”的中间表示单元,它表达的是“这一步计算在做什么”,而不是“硬件怎么执行”。一条 matmul Operation 到最终可能展开成成百上千条机器指令,它还能携带属性、区域等结构化信息。类比:菜谱上的“爆炒”是语义描述,灶台上的一次次翻勺动作才是机器指令。

为何常见:很多教程直接用“指令”称呼 IR 操作,初学者容易把 IR 层和机器指令层混为一谈,忽略了中间表示的抽象层级。

误区属性不也是传给操作的输入吗?比如 constant 操作的 value=3,我觉得它就是把 3 当作输入传进去,只是换了个叫法。

正解两者有本质区别:操作数是运行时才能确定的 SSA 值,由其他操作动态产出;属性是编译期就写死、不能再变的常量信息。value=3 这个属性在编译期就决定了生成“立即数 3 的指令”,不需要任何值流入。类比:食材是开火前才端上来的,而“盐 3 克”是刻在菜谱纸上的字。

为何常见:从使用者的视角看,属性和操作数都“给操作提供了数据”,不深入理解编译期与运行期的区别,就很容易混淆这两栏。

误区Region 不就是把一些操作用花括号包起来吗?为了好看和缩进,删掉也不影响语义。

正解Region 是结构性的:它由块组成,块内操作有严格顺序并以终结操作收尾;它定义了值的作用域边界,决定了哪些值可见;分支、循环等控制流全靠它表达。删掉 Region 等于删掉语义。类比:步骤卡背面的子步骤夹不是装饰,它决定了“调汁”必须在“下锅”之前完成。

为何常见:Region 在文本格式里看起来只是缩进的代码块,初学者容易把它当作纯排版而非核心语义结构。

误区ODS 生成一堆固定代码,限制太多。我手写 C++ 想怎么写就怎么写,遇到特殊操作 ODS 根本搞不定。

正解手写 C++ 的问题不是“不能写”,而是“容易写错、写得不一致”。ODS 除了生成类与访问器,还负责生成 builder、verifier 骨架、解析打印逻辑,并天然对接 traits、interfaces 等机制;遇到特殊需求,ODS 也允许自定义代码片段。MLIR 官方及绝大多数方言都采用 ODS。类比:标准化申请表之外还有“备注栏”,但没人会因此放弃主表格。

为何常见:有 C++ 经验的开发者容易高估手写代码的掌控力,低估样板代码在长期维护中的成本。

面试题

Q1. 在 MLIR 中,Operation 的名称通常采用什么格式?

单选题 · easy

  • A.dialect前缀点号加操作名← 正确答案
  • B.可以用任意字符串只要全篇唯一
  • C.只能由英文字母和数字构成
  • D.必须与C++实现类的类名一致

答案:A。操作名称必须采用“方言前缀。操作名”格式,如 arith.addi 中 arith 是前缀、addi 是操作名。前缀充当命名空间,防止不同方言的操作互相冲突,就像厨房里“川菜。爆炒”和“粤菜。爆炒”各归各档口。B 忽略命名空间,C 不是硬性格式要求,D 混淆了 IR 名称与 C++ 类名。

Q2. 下面哪一项属于 Operation 的五个组成部分?

单选题 · easy

  • A.名称输入输出与属性区域← 正确答案
  • B.编译器版本与构建时间
  • C.源代码文件名与行号
  • D.机器码指令和寄存器号

答案:A。Operation 由五部分组成:名称、操作数(输入)、结果(输出)、属性、区域,选项 A 完整覆盖了这五栏。编译器版本、源文件行号属于工具链信息,机器码指令属于后端产物,都不属于操作本身。就像一张步骤卡只有“名称、食材、成品、调味参数、子步骤”这几个栏目,不会写厨师几点下班。

Q3. 属性(Attribute)与操作数(Operand)最本质的区别是什么?

单选题 · mid

  • A.属性是编译期常量操作数是SSA值← 正确答案
  • B.属性只能是整数操作数只能是浮点数
  • C.属性保存文件名操作数保存函数名
  • D.两者没有本质区别只是叫法不同

答案:A。属性是编译期常量,创建后不可变;操作数是运行时 SSA 值,由其他操作产生并流入。例如 value=3 是写在 constant 操作里的属性,而某个 add 的输入 x 是前一个操作算出来的操作数。类比:盐 3 克刻在菜谱上,鸡丁是从上一道工序端上来的。B、C、D 均不符合 MLIR 的语义。

Q4. 一个 Region(区域)内部直接包含什么?

单选题 · mid

  • A.若干个Block块的序列← 正确答案
  • B.多条边组成的计算图
  • C.若干条机器汇编指令
  • D.一组键值对的属性表

答案:A。Region 内部直接包含若干 Block(块),每个块再包含一串 Operation,并以终结操作收尾。如果把 Operation 比作步骤卡,Region 就是卡片背面的子步骤夹,夹子里第一层放的是“子步骤纸条”(Block),而不是散落的文字或指令。

Q5. ODS(Operation Definition Specification)的主要作用是什么?

单选题 · mid

  • A.声明式表格定义并自动生成C++代码← 正确答案
  • B.完全替代所有手写的编译器代码
  • C.只能定义属性不能定义操作
  • D.用于调试程序运行过程的工具

答案:A。ODS 用声明式 TableGen 表格定义操作,自动生成 C++ 类、访问器、构造器和 verifier 骨架。它不能替代所有手写代码,也不只定义属性,更不是调试工具。就像标准申请表能批量印刷步骤卡,但不能替厨师炒菜——它解决的是“样板代码”问题,而不是“算法逻辑”问题。

Q6. 使用 ODS 定义一个操作时,通常会声明哪些内容?

多选题 · mid

  • A.操作名称与方言前缀信息← 正确答案
  • B.参数包括操作数和属性← 正确答案
  • C.声明结果的类型信息← 正确答案
  • D.操作在磁盘上的物理存储布局

答案:ABC。ODS 定义操作时会声明完整名称(方言前缀+操作名)、参数(操作数和属性)以及结果类型,还会附加 traits 和约束。磁盘物理布局是运行时实现细节,不属于 ODS 声明范畴。类比填申请表:要写步骤名、食材栏、成品栏,但不用规定这张纸用什么材质印刷。

Q7. Operation 的 verifier 主要检查什么?

单选题 · hard

  • A.操作数类型与数量是否符合约束← 正确答案
  • B.机器码能否在目标CPU上运行
  • C.优化后的性能是否显著提升
  • D.源码中的注释是否完整规范

答案:A。verifier 负责检查操作的不变量:操作数数量是否匹配、类型是否合法、属性是否齐全、区域结构是否合规。机器码执行、性能提升、注释完整性都不是它的职责。就像品控厨师只核对步骤卡栏目是否齐全、食材是否对得上,不管灶台火大火小。

Q8. 关于 MLIR 的 SSA 语义,下列哪些说法是正确的?

多选题 · hard

  • A.每个SSA值只能被定义一次← 正确答案
  • B.操作数必须是之前定义的值← 正确答案
  • C.一个操作可以产生多个结果值← 正确答案
  • D.值可在同一操作内被重复定义

答案:ABC。SSA 语义规定每个值只能被定义一次(A 对),操作数必须引用已定义的值(B 对),一个操作可以产出多个结果(C 对),而 D 在同一位置重复定义同一值违反 SSA 规则。这就像菜谱铁律:每道成品只能出一次锅,谁也不能把同一份鸡丁定义成两份。

Q9. 操作名称中的方言前缀(如 arith)主要作用是什么?

单选题 · easy

  • A.作为命名空间隔离不同方言的算子← 正确答案
  • B.标记操作执行的硬件平台
  • C.指定操作的优化优先级
  • D.表示操作所属的源文件

答案:A。方言前缀作为命名空间,把不同方言的操作隔离开,避免名字冲突,也便于注册与查找。它不表示硬件平台、优化优先级或源文件。就像步骤卡上的菜系章决定你该去哪个档口找标准做法,并不说明这道菜要在哪口锅里炒。

Q10. 关于 Region 的嵌套结构,下列说法正确的是?

多选题 · hard

  • A.函数体本身就是一个Region← 正确答案
  • B.控制流操作用Region表示分支体← 正确答案
  • C.Region之间完全不能相互嵌套
  • D.Region内操作可以访问外层值← 正确答案

答案:ABD。函数体、循环体、if 分支体都用 Region 表示,所以 A、B 正确;Region 支持无限嵌套(函数包循环、循环包分支),所以 C 错误;外层定义的值可以被内层 Region 引用,所以 D 正确。Region 就像可无限套娃的子步骤夹:大卡片夹里还能再放小卡片夹,内层能看到外层准备好的食材。