想象你接管了乐高工厂的一个新车间。工厂里早就有了城市车间、太空车间、城堡车间——每个车间都有自己的行业黑话:城市组说“铺路砖”,太空组喊“燃料舱”,城堡组聊“弩炮塔”。这些词汇外人听不懂,但同组的人用起来行云流水,因为它们是各自领域的高效密码。 MLIR 就是这样一座乐高工厂。它内部的“方言”(Dialect)就是各条生产线的行业黑话——一组领域专用的操作、类型和属性,让 AI 框架、硬件加速器能用自己的语言描述计算。“自定义方言”则是为这座工厂设计一套全新的积木套装:先画图纸(TableGen 声明式描述),再定义积木颗粒(Op 与 Type),接着办登记手续(方言注册),最后设计组装工序(转换 Pass)。当整条生产线跑通,你的专属积木就能在工厂里自由流转,与其他套装无缝衔接。 这篇文章会带你完整走一遍这套流程:从认识方言的分层结构,到用 TableGen 快速绘制积木图纸,再到定义操作与类型、注册方言、编写降级 Pass,最后搭一个可运行的最小工程。读完你会明白:自定义方言不是魔法,而是一套有章可循的“积木设计手册”。

岗位关联

对 AI 从业者而言,掌握自定义方言意味着你拥有了改造编译器的“核心扳手”——无论自研芯片、领域加速器还是框架优化,都能在最底层定制代码生成与优化策略,这是从“用编译器的人”进阶为“造编译器的人”的关键一跃。

目录
  1. 可视化演示
  2. 方言初识:乐高工厂的行业黑话
  3. TableGen基础:用填空表格替代手工画图
  4. 定义操作:设计积木颗粒的拼接接口
  5. 定义类型:给积木贴上精确的尺寸标签
  6. 方言注册:让工厂调度台认识你的新品种
  7. 转换Pass:把城市积木改装成太空积木
  8. 实战步骤:搭一条完整的积木套件产线
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

自定义方言开发允许用户根据特定领域需求定义专用的操作和类型系统。通过TableGen描述操作语义、注册方言并实... 切换模式查看不同维度。

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

自定义方言的:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

方言初识:乐高工厂的行业黑话

走进乐高工厂,你会看到城市车间的工人在聊“十字路口模块”,太空车间在聊“离子推进器”,城堡车间在聊“吊桥绞盘”。这些行业黑话不是故意让外人听不懂,而是为了让同组员工三秒钟完成沟通。MLIR 里的方言就是这个逻辑:每一种方言都是一组领域专用的“词汇”——封装了该领域最常用的操作、类型和属性。TensorFlow 方言里有 tf.matmul,而 LLVM 方言里有 llvm.mul,同一个乘法的语义在不同方言里被描述成了不同形态。

MLIR 之所以被称为“多级中间表示”,秘密就在于它允许不同方言放在同一张“工作台”上共存。一张 AI 计算图刚开始是高层框架方言(如 TensorFlow 的 HLO 方言),经过一次次降级转换后,逐步变成自定义硬件方言、再变成 LLVM 方言,最终成为机器码。这个过程就像把一座“城市街景”图纸逐步变成“标准砖块”清单——每一层都保留必要的语义,但又不断逼近真实的机械结构。自定义方言正是你在中间插入的“专属楼层”。

那么,一个方言到底由什么构成?从这个角度看,可以把方言抽象成一个简单的集合模型:

所以,当你站在乐高工厂的中央调度台前,你会发现:不同车间各说各话不是混乱,而是高效协作的前提。自定义方言,就是给工厂申请一条新生产线、定义一套新黑话。理解了这个“三层积木”模型,你就握住了整个方言系统的钥匙。

D={ Op, Type, Attr }\mathcal{D} = \{\, Op,\ Type,\ Attr \,\}
一个方言就是一个三元组的集合:它能识别哪些操作(Op)、支持哪些类型(Type)、附带哪些属性(Attr)。这三样就是积木颗粒的“形状、材质和颜色标签”。
  1. 方言符号$\mathcal{D}$ 表示一个完整的方言系统,比如 hlo 方言或 llvm 方言,它是领域词汇的“总目录”。
    D\mathcal{D}
  2. 操作集合$Op$ 是方言中最核心的积木颗粒,代表一个个计算操作,比如 matmul(矩阵乘法)或 add(加法),它们定义了“能做什么事”。
    OpOp
  3. 类型与属性$Type$ 描述数据的形状规格(如张量的维度),$Attr$ 描述操作的编译期参数(如步长、内存布局),两者共同定义积木的“拼接接口”。
    Type, AttrType,\ Attr

TableGen基础:用填空表格替代手工画图

假如你要为乐高工厂设计一款“海洋主题套装”,你不需要从零雕刻每块积木的塑料模具——只要填一份标准设计表格,工厂的自动产线就会按表格生成模具。MLIR 的 TableGen 就是这张“标准设计表格”。它是一个声明式描述语言,让你不用手写海量 C++ 胶水代码,只需写下“我要一块什么样子的积木”,剩下的交由代码生成器处理。

TableGen 搭配 ODS(Operation Definition Specification,操作定义规范)框架,构成了自定义方言开发的第一块基石。ODS 定义了一套“字段模板”:操作的名称、输入参数、输出类型、约束条件、打印格式等。你只管填空,mlir-tblgen 工具会把这张表格翻译成完整的 C++ 类定义、解析器、打印器,甚至自动生成文档。这就是为什么社区常说“TableGen 把方言开发的体力活变成了脑力活”。

从计算角度看,TableGen 的工作方式可以理解为一台“规格翻译机”:

TableGen 的设计哲学很像乐高的模块化模具库:你不需要理解注塑机的每个螺丝,只需要选对模板、填对参数。理解了它的“填空生成”心智模型,后面定义操作和类型时,你会觉得一切水到渠成。

Spec →TableGen C++ Code\text{Spec} \ \xrightarrow{\text{TableGen}} \ \text{C++ Code}
左边是开发者编写的声明式规格(spec),右边是可直接编译的 C++ 源码。中间的箭头就是 mlir-tblgen——一个不知疲倦的翻译官。
  1. 规格输入$\text{Spec}$ 是你填写的 .td 文件,里面用 def 声明操作名、输入输出和约束,像写填空题一样描述积木。
    Spec\text{Spec}
  2. 翻译过程$\text{TableGen}$ 是箭头上的引擎,它读取规格并展开模板,自动生成 Op 的 C++ 基类、接口、注册函数等。
    →TableGen\xrightarrow{\text{TableGen}}
  3. 代码输出$\text{C++ Code}$ 是最终产物,通常是 *.h.inc 和 *.cpp.inc 文件,它们会被 #include 进你自己的源码里,完成方言实体的实例化。
    C++ Code\text{C++ Code}

定义操作:设计积木颗粒的拼接接口

在设计海洋套装时,最关键的是每块积木的“拼接接口”——凸点和凹槽的尺寸、位置必须精确,否则两块积木永远无法咬合。方言里的操作(Op)也一样:它不只带一个名字,还定义了自己的“输入接口”和“输出接口”,构建起整个 IR 图的数据流。

在 MLIR 中,一个操作通常由三要素构成:操作数(operands)就是拼接口(输入数据),属性(attributes)是积木上的印刷图案(编译期固定参数),结果(results)则是拼好后的成品凸起(输出数据)。比如你用 ODS 定义一个 matmul 操作,就是声明“这块积木有两个输入凹槽、一个输出凸点,允许贴一张'转置'标签”。同时,你还能为操作添加约束与验证:例如要求输入维度匹配,否则在解析或验证阶段直接报错。

用数学形式表达一个操作的类型签名,可以写成:

在 ODS 里定义操作时,你写的 let arguments = ... 和 let results = ... 本质就是在填这个函数签名。接口设计得越清晰,后续的解析、打印、验证代码就越省力。正如乐高积木的咬合精度决定了整套建筑的稳固,Op 接口定义也在根本上决定了编译优化的质量。

Op: x⃗×A⟶y⃗\text{Op}: \ \vec{x} \times \mathcal{A} \longrightarrow \vec{y}
一个操作是一个函数:左侧的输入操作数向量 $\vec{x}$ 和属性集 $\mathcal{A}$ 共同决定右侧的输出结果向量 $\vec{y}$。它描述了“喂进什么、输出什么”。
  1. 输入向量$\vec{x}$ 是操作数的有序数组,代表这个积木需要哪些前置拼接接口,例如 matmul 的两个矩阵输入。
    x⃗\vec{x}
  2. 属性集合$\mathcal{A}$ 是编译期就固定下来的参数集合,如转置标志、步长、量化位宽等,相当于积木上印的规格说明。
    A\mathcal{A}
  3. 结果映射$\vec{y}$ 是操作产生的结果值向量,它定义了积木拼好后暴露给后面操作的输出接口。
    y⃗\vec{y}

定义类型:给积木贴上精确的尺寸标签

乐高积木的尺寸标签(2×4、1×2)让工人一眼就知道拼接方式;如果所有积木都只标“蓝色方块”,整个工厂会立刻陷入混乱。MLIR 同理——类型(Type)就是给数据值贴上的“尺寸标签”,它告诉编译器“这个值是什么形状、占多少位”。内置类型如 f32、i64 或张量类型 tensor<4x8xf32>,都遵循同一套标签语法。

自定义方言往往需要属于自己的类型。比如你的 AI 芯片用特殊的“4×8 脉动阵列块”,就应该定义 !my.tensor<4x8xf32> 这样的专有类型。定义自定义类型时,要用 .td 声明类型的存储参数(例如形状和元素类型),然后实现该类型的解析器与打印器——否则编译器既看不懂你的文本表示,也没法把内存中的类型结构写回成字符串。

一个带参数的自定义类型,在 MLIR 的文本格式里长这样:

自定义类型最棘手的部分不是声明,而是“解析打印往返”——把类型从字符串读进内存、再还原成相同字符串。这就像乐高的尺寸标签必须正反两面信息一致,差一个数字,后面的积木就拼不上。类型定义稳妥了,你的方言才有坚实的地基。

τ=! d.type⟨p1,p2,…,pk⟩\tau = \text{!}\,d.\text{type}\langle p_1, p_2, \dots, p_k\rangle
类型 $\tau$ 由方言名 $d$、类型名 $\text{type}$ 和尖括号内的参数列表 $p_1 \dots p_k$ 组成。这些参数就是积木的“尺寸、颜色、材质”编码。
  1. 类型变量$\tau$ 是类型本身,它像一枚印章,盖在某个 IR 值上,表示“这个值是 4 行 8 列的浮点张量”。
    τ\tau
  2. 方言前缀$\text{!}\,d.\text{type}$ 中的 ! 是类型的统一前缀,$d$ 是方言名,用它来避免不同方言撞名——就像乐高不同系列都会给自己的 2×4 砖印上专属编号。
    ! d.type\text{!}\,d.\text{type}
  3. 参数列表$\langle p_1, \dots, p_k \rangle$ 是类型的参数化部分,例如张量形状 4x8 和元素类型 f32,它们存储在一个“参数池”中并在打印时序列化。
    ⟨p1,p2,…,pk⟩\langle p_1, p_2, \dots, p_k\rangle

方言注册:让工厂调度台认识你的新品种

你设计好了海洋套装的积木,可如果工厂调度系统里没有登记,产线上的机器人根本不会碰它们。MLIR 的方言注册就是这个“调度登记”过程:把方言对象挂载到 DialectRegistry(方言注册表)上,让它成为工厂正式认可的“正式员工”。

注册的另一个关键动作是声明依赖。你的新方言可能要用到其它方言的类型或操作,比如你的 !my.tensor 依赖内置的 IndexType,或者 my.matmul 的输出是标准张量类型。在 .td 中通过 dependentDialects 字段声明清楚,加载方言时就会自动先加载依赖项,避免“需要的积木还没入库”的尴尬。

把注册过程抽象一步,它实际上是从单个方言汇聚成整个上下文环境:

常见的坑是:只写好了方言类却忘了调用 registerDialect,或忘了在命令行里 --load-dialect,结果 mlir-opt 报出刺眼的 unknown dialect 错误。记住:积木再好,不登记入库就永远只能待在设计室里。

Context=⋃d∈Rload(d)\text{Context} = \bigcup_{d \in \mathcal{R}} \text{load}(d)
最终的 MLIR 上下文(Context)等于注册表 $\mathcal{R}$ 中所有方言的加载总和。每个方言必须被显式加载,才能在 IR 中被解析与使用。
  1. 上下文环境$\text{Context}$ 是整个编译过程共享的“大仓库”,所有方言、类型、操作都在其中注册在案,供任意模块查询。
    Context\text{Context}
  2. 注册表集合$\mathcal{R}$ 是 DialectRegistry,一个待加载方言的清单——相当于工厂调度台上的登记簿,写入的方言才能进场。
    R\mathcal{R}
  3. 加载操作$\text{load}(d)$ 是具体加载动作,它会初始化方言的注册函数、注册所有操作与类型,并通过依赖声明补齐前置方言。
    load(d)\text{load}(d)

转换Pass:把城市积木改装成太空积木

同一个乐高零件,在工厂里如何从“城市套装”变成“太空套装”?答案是产线上的改装工序——拆掉部分零件、换装新零件、重新组装。MLIR 的转换 Pass 就是这样的工序,它把高层方言的“语义积木”逐步拆解成低层方言的“机械积木”。这个过程叫降级(lowering)。

降级靠的是“模式重写”(rewrite pattern)。一条模式声明“如果遇到 hlo.matmul,就把它改写成 gpu.matmul 加上一个转置预热操作”。当编译器在 IR 里匹配到左侧形状时,就触发右侧的替换逻辑。多条模式可以串成一条流水线(Pass pipeline),让 IR 一层层从“说人话”变成“说机器话”。比如在可视化演示里,hlo.matmul 先变 gpu.matmul,再变 llvm.mul,就是两个 Pass 接力完成的工作。

把一次匹配改写过程写成数学表达,就是:

写 Pass 时最需谨记的是:不要打破 SSA 支配关系,否则后续优化会得到错误结果。就像改造产线时,不能把传动带拆了却忘了安装新的——链条断了,整个工厂停摆。

∀ o∈IR:  p(o)⟹o1′, o2′, …\forall\, o \in IR: \ \ p(o) \Longrightarrow o_1',\, o_2',\, \dots
对于 IR 中的每个操作 $o$,如果模式 $p$ 匹配成功,则将其替换为一个或多个新操作 $o_1', o_2' \dots$。这就是降级的基本单元。
  1. 全称遍历$\forall\, o \in IR$ 表示编译器会遍历当前区块里的每个操作,逐个测试模式是否匹配——就像质检员逐块检查积木。
    ∀ o∈IR\forall\, o \in IR
  2. 模式匹配$p(o)$ 是模式函数,它检查操作的种类、属性约束,匹配成功返回真值,否则跳过该操作。
    p(o)p(o)
  3. 改写结果$\Longrightarrow o_1', o_2' \dots$ 是重写动作:删除旧操作,插入低层操作序列,并保证新的操作之间 SSA(静态单赋值)关系依然成立。
    ⟹o1′, o2′\Longrightarrow o_1',\, o_2'

实战步骤:搭一条完整的积木套件产线

好,各项零件都齐了,现在我们真正动工:搭一条从设计图到质检报告的自定义方言产线。首先建立工程目录,创建 MyDialect.td 文件,用 ODS 声明方言和几个示例操作。然后运行 mlir-tblgen,让它生成 MyDialect.h.inc 与 MyDialect.cpp.inc,再写一个薄薄的 MyDialect.cpp 把这些生成代码包装成可直接编译的方言库。

第二步是把方言集成进工具链。在你的 CMakeLists.txt 中链接 MLIRDialect、MLIRIR 和 MLIRPass 库,注册 dialect 扩展,让 mlir-opt --my-dialect 能加载它。接下来编写降级 Pass:基于 mlir::RewritePattern 写模式匹配规则,把 my.matmul 等操作重写为更底层的组合。最后,跑一遍“往返测试”——把 IR 文本打印出来再重新解析,检查两次结果是否一致。整个流程的骨架就是:

乐高工厂有条铁则:新套装上市前必须经历从图纸到试产再到质检的完整闭环。自定义方言同样讲究“最小闭环先行”——先做一个能跑通 matmul 的最小工程,再逐步扩展操作与优化。这样即使出错,你也能精确知道是图纸、模具还是组装线的问题。

Spec⇒Dialect⇒Pass⇒Test\text{Spec} \Rightarrow \text{Dialect} \Rightarrow \text{Pass} \Rightarrow \text{Test}
从声明式规格出发,生成方言库,编写转换 Pass,最终用 roundtrip 测试验证正确性。四步串成一条完整的开发流水线。
  1. 规格驱动$\text{Spec}$ 是起点,TableGen 文件里的每一个 def 都在描述“我想造什么积木”——这就是声明式开发的源头。
    Spec\text{Spec}
  2. 方言落地$\text{Dialect}$ 是由生成代码组装出的可链接库,它包含 Dialect 类、Op 类和 Type 类,能通过 mlir-opt 被加载。
    Dialect\text{Dialect}
  3. 转换与验证$\text{Pass} \Rightarrow \text{Test}$ 完成“改写 + 验证”闭环,测试通过才意味着生产线真正畅通。
    Pass⇒Test\text{Pass} \Rightarrow \text{Test}

AI 应用场景

场景一:自研 AI 芯片的指令集适配

阶段:后端代码生成与硬件抽象

解决的问题:传统做法是把高层框架直接翻译到 CUDA/LLVM,但自研芯片的脉动阵列、片上存储结构无法用现成方言表达,生成的代码效率低且难维护。通过自定义一套 my_accel 方言,将矩阵乘、卷积等关键算子定义为带硬件布局属性的操作,再由专属 Pass 完成到机器指令的映射,编译器与硬件就实现了“精准对话”。

具体案例:某 AI 芯片公司将 hlo.convolution 首先降级为自定义的 accel.conv(包含 tile=4x8、dataflow=systolic 等属性),再通过生成 Pass 直接输出芯片汇编——性能比通用 LLVM 路径提升 2.3 倍。

场景二:AI 框架的算子桥接层

阶段:前端图优化与方言转换

解决的问题:PyTorch/TensorFlow 的算子和用户自定义算子语义不同,统一转成 builtin 纯数学计算会丢失语义信息。为框架定义一个 torch 或 tf 方言保留高层语义,再编写转换 Pass 把它们一步步降到自定义通用算子方言,能大幅减少图优化阶段的语义损失。

具体案例:某训练框架团队自定义了 fw 方言记录算子特有的梯度语义(如 fw.embedding),同时实现了 fw-to-linalg 的转换 Pass,使导出到 MLIR 的图保留了自定义反向传播信息,混合专家模型的自动并行成功率提升了 40%。

场景三:领域定制硬件的 DSA 编译器

阶段:领域专用加速与代码生成

解决的问题:传统编译器遇到特定领域(如稀疏计算、图神经网络)时难以做深度优化。通过自定义领域方言,将“稀疏矩阵累加”这类复杂语义直接建模为方言操作,配合专门的重写 Pass,把领域知识固化进编译流水线。

具体案例:一个图神经网络加速器团队定义了 gnn.aggregate 和 gnn.scatter 两个自定义 Op,并用 Pass 直接生成 DMA 指令流,使端到端推理延迟相比手工 CUDA 版本降低 30%。

Python 演示

python117 行
# -*- coding: utf-8 -*-"""迷你自定义方言引擎: 注册方言并用Pattern重写实现Pass降级""" class Op:    """操作节点: name + 输入变量列表 + 属性字典"""    def __init__(self, name, inputs=None, attrs=None):        self.name = name        self.inputs = inputs if inputs else []        self.attrs = attrs if attrs else {}     def __repr__(self):        ins = ", ".join(self.inputs)        attr = ", ".join(f"{k}={v}" for k, v in self.attrs.items())        text = f"{self.name}({ins})"        return f"{text} [{attr}]" if attr else text  class Dialect:    """方言 = 名字 + 允许的操作白名单"""    def __init__(self, name, ops=None):        self.name = name        self.ops = set(ops or [])     def allows(self, op):        return op.name in self.ops     def __repr__(self):        return f"Dialect[{self.name}]{sorted(self.ops)}"  class RewritePattern:    """一条转换规则: 匹配源Op名, 由factory返回新Op列表"""    def __init__(self, src, factory):        self.src = src        self.factory = factory     def apply(self, op):        if op.name != self.src:            return None        return self.factory(op)  class Pass:    """一次降级Pass: 遍历所有Op并尝试改写"""    def __init__(self, name, patterns):        self.name = name        self.patterns = patterns     def run(self, ops):        result = []        for op in ops:            for pat in self.patterns:                new_ops = pat.apply(op)                if new_ops is not None:                    result.extend(new_ops)                    break            else:                result.append(op)        return result  class Compiler:    """迷你编译器: 装载方言 + 串起Pass管线"""    def __init__(self):        self.dialects = {}        self.pipeline = []     def load_dialect(self, dialect):        self.dialects[dialect.name] = dialect     def add_pass(self, pass_obj):        self.pipeline.append(pass_obj)     def compile(self, ops):        print("== 初始IR ==")        for op in ops:            print(op)        current = list(ops)        for ps in self.pipeline:            current = ps.run(current)            print(f"== after: {ps.name} ==")            for op in current:                print(op)        return current  def main():    # 1. 声明三种方言    hlo = Dialect("hlo", ["hlo.matmul", "hlo.relu"])    gpu = Dialect("gpu", ["gpu.matmul", "gpu.relu"])    llvm = Dialect("llvm", ["llvm.call"])     # 2. 自定义方言的降级Pattern    def matmul_to_gpu(op):        return [Op("gpu.matmul", op.inputs, {"tma": True})]     def relu_to_gpu(op):        return [Op("gpu.relu", op.inputs, {"chunk": 2})]     lower_pass = Pass("hlo->gpu", [        RewritePattern("hlo.matmul", matmul_to_gpu),        RewritePattern("hlo.relu", relu_to_gpu),    ])     # 3. 装载并编译    comp = Compiler()    for d in (hlo, gpu, llvm):        comp.load_dialect(d)    comp.add_pass(lower_pass)     module = [Op("hlo.matmul", ["%a", "%b"]), Op("hlo.relu", ["%c"])]    comp.compile(module)    print("方言注册表:", {k: sorted(v.ops) for k, v in comp.dialects.items()})  if __name__ == "__main__":    main()
目的:用 100 行 Python 模拟 MLIR 自定义方言的三大核心——方言注册表、操作定义和 Pattern 重写 Pass,让你直观看到 IR 如何从高层方言被降级到低层方言。
  1. class Dialect对应“方言 = 操作集合”的概念,每个方言都有一个操作白名单,类似乐高套件的零件清单。
  2. def __init__(self, name, ops=None)方言构造函数,记录方言名与其允许的操作,这是注册表的基本单元。
  3. class RewritePattern对应 ODS 中的模式重写规则,src 是要匹配的高层操作名,factory 是产生低层操作的工厂函数。
  4. def apply(self, op)核心匹配逻辑:若操作名匹配则调用工厂生成新操作,否则返回 None 表示不命中。
  5. class Pass模拟一次转换 Pass,它遍历所有操作并尝试用模式改写,未匹配的操作原样保留。
  6. def run(self, ops)Pass 的主体循环,对应编译器遍历 IR 并逐个节点应用 rewrite pattern 的过程。
  7. class Compiler迷你编译器总装:load_dialect 装载方言,add_pass 串联 Pass 管线,compile 依次执行。
  8. def matmul_to_gpu(op)一个具体 pattern 工厂:把 hlo.matmul 降级为带 tma=True 属性的 gpu.matmul,模拟自定义硬件方言的定制参数。
  9. for op in ops: ... else:利用 for...else 语义实现“任一模式命中就改写,否则保留原操作”的优雅遍历。
  10. if __name__ == "__main__": main()可执行入口,保证脚本直接被运行时能够自测。

常见误区

误区很多初学者以为定义方言要手写全套 C++ 代码——操作类、解析器、打印器、验证器全都自己动手,导致还没开始就绝望放弃。

正解实际上 TableGen + ODS 承担了约 80% 的胶水代码生成。你只需在 .td 文件里声明操作结构、约束和格式,mlir-tblgen 自动产出 .h.inc 和 .cpp.inc。手写的只是少量定制逻辑(如自定义验证、复杂打印规则),就像乐高的标准连接件全部由模具生产,你只需设计特殊积木和拼装说明书。

为何常见:MLIR 的教程常先展示生成的复杂 C++ 文件,容易让人误以为这些都要手写。其实那正是 TableGen 的工作成果,不是你的工作起点。

误区一些团队觉得自定义方言“多多益善”,把每个小概念都单独建一个方言,结果项目中出现 20 多个互相依赖的方言,编译期爆炸、Pass 难以定位。

正解方言的价值在于“职责清晰的语义分层”,而非数量。合理的做法是围绕“一个领域 / 一种抽象层级”建设一个方言,内部用多个 Ops 表达不同操作。就像乐高工厂不会为一块 1×1 砖单独开生产线——开条新产线的代价远高于它的收益。

为何常见:看到 MLIR 有很多成功方言案例,误以为方言就是“越细分越好”,忽略了每个方言背后需要配套的注册、转换、维护成本。

误区写好了自定义方言和 Pass,运行时却报 `unknown dialect`,第一反应是“MLIR 内部坏了”或者“Pass 写错”,反复查 Pass 逻辑却始终定位不到问题。

正解诊断顺序应该是“从外到内”:先确认方言是否被注册进 DialectRegistry,其次检查命令行是否 --load-dialect,再检查依赖方言是否在 dependentDialects 中声明。这就像新积木套装进了仓库却没录入系统,机器人取货当然报“零件号不存在”。

为何常见:初学者往往先接触 Pass 逻辑,对“注册”这种基础设施动作不够敏感,看到和 Pass 相关的错误就一头扎进自己的代码里。

误区写降级 Pass 时,只关心“匹配到 Op 并替换成新 Op”,认为只要替换后的操作数类型一致就行,不管新操作插在哪个位置。

正解MLIR 是 SSA(静态单赋值)形式的,每个值只能被定义一次,且使用点必须位于定义点的支配范围之内。改写时如果不插入到正确的区域、不维护值的前后关系,后续优化会得到错误结果甚至编译崩溃。就像改装产线时不能把传动皮带拆下来就随手扔在过道上——新皮带必须走合规的安装路径。

为何常见:写脚本时习惯了“就地替换变量”,把 IR 也当成可任意修改的数据结构,忽略了 SSA 对支配关系的硬约束。

误区跑通 `mlir-opt --my-dialect`,看到 IR 能打印输出,就觉得方言开发大功告成,不再做 roundtrip 解析一致性验证。

正解真正严格的验证是“往返测试”:把打印出的文本重新 parse 回内存,再打印一次,两次文本必须完全一致。此外还要用 mlir/test 下的 lit 测试覆盖解析错误、约束检查等边界情况。文本能打印只说明“出得去”,不保证“进得来”——就像乐高说明书印得再漂亮,按图拼不出模型就是废纸。

为何常见:打印是开发中最直观的反馈,容易建立虚假的安全感,让人忽视解析和打印这一对“镜像操作”必须严格对称。

面试题

Q1. MLIR 中“方言”最准确的比喻是?

单选题 · easy

  • A.编译器里的报错分级系统
  • B.一组领域专用的操作类型集合← 正确答案
  • C.机器码的一种二进制装载格式
  • D.优化器运行时使用的内存缓冲池

答案:B。方言本质上是领域专用的操作(Op)、类型(Type)和属性(Attr)的集合,类似乐高各车间的行业术语体系。选项 B 准确;A 与错误处理无关;C 混淆了方言与机器码格式;D 把编译器的运行时设施错当方言。

Q2. TableGen 在自定义方言开发中的角色是?

单选题 · easy

  • A.运行时解释执行 IR 的虚拟机
  • B.从声明式规格生成 C++ 代码的生成器← 正确答案
  • C.管理编译进程内存分配的分配器
  • D.生成调试符号表的二进制工具

答案:B。TableGen + ODS 让开发者用 .td 声明式文件描述 Op/Type,并由 mlir-tblgen 生成 C++ 片段。选项 B 完全正确;A 混淆了 TableGen 与解释器;C 属于运行时设施;D 不是 TableGen 的主要职责。

Q3. 定义自定义 Op 时,operands 代表什么?

单选题 · easy

  • A.操作执行时依赖的环境变量
  • B.操作的输入操作数列表← 正确答案
  • C.输出结果所属的目标硬件编号
  • D.方言之间的加载顺序标记

答案:B。operands 是操作的输入值,构建 IR 数据流图的数据依赖。B 正确;A、D 与操作数无关;C 混淆了结果类型与硬件绑定,硬件信息通常放在属性中。

Q4. 关于方言注册,以下说法正确的是?

多选题 · mid

  • A.方言类写完以后会被自动加载
  • B.需通过注册表挂载到 MLIRContext← 正确答案
  • C.加载前可声明依赖的其它方言← 正确答案
  • D.注册成功后依赖方言无需按序加载

答案:BC。方言必须显式注册并通过 registry 加载,依赖声明(dependentDialects)会在加载时按序补齐。B、C 正确;A 错在“自动”——不注册就不会被上下文感知;D 错在依赖仍然需要管理加载顺序。

Q5. Pass 的“降级”(lowering)操作本质是?

单选题 · mid

  • A.对代码做字符串压缩混淆
  • B.将高层 Op 重写为若干低层 Op← 正确答案
  • C.把变量名全部改为英文字母
  • D.删除编译过程中产生的大量注释

答案:B。降级通过 rewrite pattern 把语义丰富的高层操作替换为更接近机器的低层操作序列。B 正确;A、C、D 都停留在文本层面,与 IR 降级无关。

Q6. 编写模式重写规则时需要考虑?

多选题 · mid

  • A.匹配操作的类型与属性约束← 正确答案
  • B.重写后新 Op 的插入位置是否合法← 正确答案
  • C.维护 SSA 形式与支配关系← 正确答案
  • D.直接修改生成的机器码指令

答案:ABC。模式重写需要匹配约束、正确插入新 Op,并维护 SSA 支配关系。A、B、C 均正确;D 混淆了 IR 层重写与机器码生成,pattern 作用在 IR 上而非二进制上。

Q7. 自定义类型需要实现“解析打印往返”的主要原因是?

单选题 · mid

  • A.使类型在内存中占据更小的空间
  • B.保证文本打印后能重新解析为相同类型← 正确答案
  • C.使类型能自动转换为任意其它类型
  • D.帮助运行时动态分配 GPU 显存

答案:B。解析器将文本(如 !my.tensor<4x8xf32>)读入内存,打印器反向输出文本,两者严格一致才能通过 roundtrip 测试。B 正确;A 与存储优化无关;C 强转是另一套机制;D 属于运行时内存管理,不属于类型解析职责。

Q8. 方言依赖声明的主要价值是?

单选题 · mid

  • A.增加编译器的整体二进制体积
  • B.保证依赖方言被按序加载与符号可见← 正确答案
  • C.让 Pass 的匹配模式自动失效
  • D.替代 LLVM 后端的所有优化步骤

答案:B。声明依赖让加载器先行加载被依赖的方言,避免操作或类型解析时找不到目标。B 正确;A 是副作用不是价值;C、D 与依赖声明的意义完全无关。

Q9. ODS 自动生成的代码主要负责?

单选题 · hard

  • A.生成可直接运行的机器码
  • B.生成 Op 类、Type 类和注册函数← 正确答案
  • C.生成编译器的词法分析器源码
  • D.生成 LLVM 后端的指令选择器

答案:B。mlir-tblgen 从 .td 规格产出操作类、类型类、解析打印接口以及方言注册需要的辅助代码。B 正确;A 是 LLVM 后端的工作;C、D 属于编译器前端和指令选择阶段,不由 ODS 负责。

Q10. 关于方言“多级中间表示”的分层设计,正确的理解是?

多选题 · hard

  • A.不同层级方言可共存于同一 IR 模块← 正确答案
  • B.高层语义方言逐渐降级为低层方言← 正确答案
  • C.方言之间通过转换 Pass 建立桥梁← 正确答案
  • D.所有方言必须一次性降级到 LLVM

答案:ABC。MLIR 的核心优势就是多方言共存、逐级降级,Pass 作为桥梁,降级节奏由开发者灵活控制。A、B、C 正确;D 错在“必须一次性”,实际可以分多级、按需保留高层方言做跨域优化。