凌晨两点的快递分拨中心,传送带轰隆作响。一票从青岛寄往成都的活蟹包裹,正经历一场“语言接力”:月台扫描员念着“库位 A3-12”,干线车调度员喊出“K102 次、冷链舱”,最后配送员在面单上写下“滨江路 17 号 301”。同一票货,不同环节用不同的“黑话”描述,但所有人都能协作——因为包裹上贴着一张全公司统一的面单。AI 编译器面对的世界也是如此:PyTorch 训练出的模型、GPU 的矩阵指令、NPU 的张量搬运,各自说着完全不同的“语言”。MLIR 就是那张统一面单,方言(Dialect)则是每个环节的岗位黑话。本文要讲的“自定义方言设计”,恰似为活蟹包裹新开一条冷链专线:先算账要不要开(需求分析),再定工牌和库位编码(方言骨架),规定每个动作怎么做(操作定义),贴上冷链规格签(类型系统),到转运点拆包重装(降级转换),最后让专线与整车场无缝衔接(集成验证)。这篇教程会带你走完这条专线的全流程。
掌握自定义方言设计,意味着你不仅能阅读和调度 MLIR 的既有表达,还能为 AI 模型和硬件芯片之间架起一座专属桥梁——这是编译器工程师与推理引擎开发者最核心的能力之一。
可视化演示
演示MLIR自定义方言设计:多层IR逐级lowering、ODS操作定义到C++代码生成,以及Pattern Rewriting重写规则,支持步进切换与参数调节。
本节配有交互动效(进入视口后自动加载,约 3KB)。
多层IR降级 高层tosa/mhlo逐级lowering high→mid→low→llvm IR逐层逼近硬件后端
需求分析定位
快递公司不会因为一个客户说“我想寄螃蟹”就立刻买一百辆冷链车。运营会先翻数据:现有常温线路能覆盖多少、客户愿意为保鲜多付多少钱、周边城市有没有可借用的冷链资源。同样的,在 MLIR 里“造一个方言”最忌拍脑袋。动手写代码前,得先回答三个问题:领域里有哪些独一无二的概念?现有通用 IR 表达这些概念时哪里难受?目标硬件希望我们用什么样的方式描述计算?
这三个问题分别对应方言的三大价值。第一,领域概念的直接表达:把“卷积”写成若干 reshape 和 dot 的组合,不如直接写一个 conv2d 让人一眼看懂,编译器也更容易抓住融合与复用机会。第二,通用 IR 的局限:builtin 方言只有最基础的类型和操作,无法表达“这个张量的物理布局是 NHWC”或“这个算子必须在 DMA 引擎上执行”。第三,硬件特征的抽象:芯片的寄存器、内存层次、并行单元,都需要方言提供“离家门口最近”的一层描述。
实践中,人们常用一份简短的评估清单来启动方言项目:列出候选操作集、估计每个操作为优化器带来的收益、对比用通用方言拼凑要付出的重写成本。收益越高、成本越低,就越值得立项。这几项权衡可以量化为一个价值公式:
就像冷链专线的开通论证报告,公式里的每一项都对应着真实投入:表达力是客户愿意付的保价费,优化潜力是少损耗一箱蟹的利润,硬件匹配是让车队不空跑的前提。三项加权得分高,才值得立项动工。
- V_D(方言价值)代表这个自定义方言在开发周期内带来的总收益,数值越高越值得投入。
- w_E × E(表达力收益)E 是领域概念被直接表达的收益,w_E 是它在决策里的权重。
- w_P × P(优化潜力收益)P 是高层次优化能释放的性能空间,如算子融合与消除冗余。
- w_H × C(硬件匹配收益)C 是方言对目标硬件特性的贴合程度,w_H 是硬件约束的权重。
方言骨架搭建
冷链专线立项后,第一步不是买蟹,而是定规矩:库区叫“冷一区”、工牌戴蓝帽、面单前缀加“LL“。这些看似琐碎的规矩,让每个员工在交接时不用猜对方在说什么。MLIR 里搭建方言骨架也是同理:明确命名空间、声明依赖、组织文件、注册进上下文——一套”工牌与编码体系”。
MLIR 的方言是一个 C++ 对象,通过继承 mlir::Dialect 创建,并注册到 MLIRContext 这个“总登记台”。命名规范要求方言名小写开头、不含特殊字符,操作名统一为“方言名。操作名”。依赖声明告诉编译器:本方言用到了 builtin 或其他自定义方言的哪些类型、属性和接口,从而构筑一张清晰的依赖网,避免循环引用。
通常一个方言项目包含 TableGen 声明文件(ODS)、C++ 实现文件和 CMake 构建脚本。ODS 文件用声明式语言描述操作、类型、属性,自动生成 C++ 类与解析打印代码——这就像先把各岗位的 SOP 写成手册,再统一翻译成培训课程。方言的完整名称也有固定格式:
工牌、编码、手册都齐了,冷链专线才算有了“身份”。方言骨架搭好,后续的操作和类型定义才有安放之处,团队协作效率也会大幅提升。
- Namespace(方言命名空间)所有操作共用的前缀,如 toy 表明来自 toy 方言。
- 连接点号分隔命名空间与操作基本名的固定字符,防止歧义。
- BaseName(操作基本名)表示具体动作,如 add、silu、conv2d。
操作定义规范
快递公司每开一条新业务线,都要给一线员工发一份详细的操作手册:接到活蟹单先核验保价、装冰袋要放底层、面单必须贴在外箱右上角。MLIR 的自定义方言同样需要把“业务动作”定义得明明白白——操作(Operation)就是方言世界里的业务动作。
每个操作有操作数(输入物料)、结果(产出)、属性(参数)和区域(子过程)。操作数对应包裹物料,结果是加工后的产出,属性是“冰袋数量”“温控模式”这类静态参数。MLIR 的 ODS 框架允许用声明式写法定义这些元素,并为操作标注特性,如“无副作用”“可交换”等,让优化器敢大胆做变换。
操作名必须唯一,格式是“方言名。操作名”;操作数数量、每个操作数的类型、结果的数量都应在定义中声明;操作验证逻辑保证不合法的调用在构造阶段就被拦截,而不是等到代码生成后崩溃。操作合法性可以表达为一条逻辑约束:
操作手册写得细,交接就不会出错。同理,操作定义得严谨,后续的降级和优化才能在稳固的地基上盖楼。
- Verify(op)编译器对操作执行的合法性校验,决定该操作能否进入后续优化。
- NumOperands(op) = N操作数的实际个数必须等于定义时声明的数量 N。
- TypeMatch(op)操作数与结果的类型必须满足声明中的约束关系。
- ∧(逻辑与)两个条件必须同时成立,只要一个不满足,校验就失败。
类型系统设计
冷链业务里,“活蟹”和“冰鲜鱿鱼”不能混放。光靠操作手册还不够,得有一套标签体系:保温箱上贴着不同的温度等级贴纸,分拣机才能自动分流。在 MLIR 中,这套贴纸就是类型系统。
自定义类型用于约束操作数的语义。操作数不能只看成“张量”或“整数”,还要表达“这是一张 NHWC 布局的量化张量”或“这是一个分布在多卡上的分块张量”这类深层信息。类型可以在 ODS 中用 TypeDef 声明,并携带参数,如形状、元素类型、布局方式。
类型参数要定义编码、解析和打印三件事:怎样存进内存、怎样从文本读出来、怎样展示给人看。类型约束校验则保证只有语义匹配的类型才能连到同一个操作上,等于分拣机只认贴对的标签。类型占用的存储空间可以这样计算:
标签贴对,分拣效率才高;类型定义得好,整个流水线就能在编译期把“不可混放”的错误拦截下来,而不是等包裹送到客户手上才被发现。
- S(T)自定义类型 T 在内存中占用的总字节数,直接影响 IR 的紧凑程度。
- Params(T)该类型声明携带的参数集合,如张量的形状、布局、量化信息。
- Σ(求和)对集合中每个参数的存储空间做累加,得到类型的完整体积。
- size(p)单个参数编码后的存储大小,由该参数的类型与精度决定。
降级转换实现
冷链车到了城市转运中心,能不能直接开进每个小区?不能。你需要在转运点把活蟹从泡沫箱里取出,套上保温袋、放进社区冷链柜,重新打印一张末端配送面单。这个过程,就是编译器里的“降级”(Lowering)。
降级把高层方言操作替换成低层方言操作组合,是自定义方言与现有工具链对接的必经之路。MLIR 提供了一套声明式机制:ConversionTarget 声明“哪些方言最终要留下来”,RewritePattern 定义“每个操作如何被拆解替换”,Pass 驱动整体转换的工序。
开发者在 ConversionTarget 中标记不合法(要被替换)的操作,在 RewritePattern 中实现“匹配 + 重写”两个动作,最后通过 Pass 运行“部分转换”或“完全转换”流水线。匹配失败时操作会被保留,并通过诊断信息提示开发者补全规则。降级可以抽象为一级一级的翻译:
拆包重装的动作越标准,末端配送越顺畅;降级规则写得越完整,高层模型就越能顺利落到各类硬件指令上。
- IR_{i+1}转换后得到的新一级中间表示,抽象层次更低、更贴近硬件。
- Lower_i流水线中第 i 个降级 Pass 的转换行为,内部包含多个 RewritePattern。
- (IR_i)括号表示该转换函数作用在当前的 IR 上,输入输出都是 IR。
架构匹配适配
同样是冷链运输,长途干线用重型冷冻车,同城急送用保温三轮车,小区投递用冷链柜。不同的车有不同装载和行驶特性,专线的设计必须因车而异——这就是硬件匹配。
自定义方言的最后归宿通常是特定硬件:GPU、NPU 或 DSP。方言分层(如高层算子方言 → 循环/向量方言 → LLVM 方言)可以看作车队里不同车型的调度体系。为了让硬件跑得最快,方言设计需要显式表达并行度、内存布局和访存模式。
硬件感知的优化贯穿其中:算子融合把多次访存合并成一次,向量化利用计算单元的宽度,内存布局控制把数据排布成硬件最顺手的形状。方言能否表达这些信息,直接决定优化器能做到多好。最终的执行耗时可以建模为:
车型和货物匹配,整条运输链才最高效;方言和硬件特征匹配,模型才能在芯片上榨出每一分算力和带宽。
- T(D)目标硬件上执行该方言描述计算的总耗时,是性能评估的核心指标。
- T_comp(D)计算核心消耗的时间,取决于算子指令数量与并行单元利用率。
- T_mem(D)访存与搬运数据消耗的时间,取决于内存布局与缓存命中率。
- 加法(+)总耗时由计算和访存两部分共同构成,只优化一边往往收效有限。
集成验证测试
冷链专线全线铺通,不能直接宣布“开业”。你得先跑几十票测试货,跟踪每一票的温控记录和送达时间,再和普通线路对比,最后才敢接大客户的单。方言落地的后半程,就是集成、验证、迭代。
新方言要注册进 MLIRContext,作为转换 Pass 挂入编译流水线,和已有的 tensor、linalg、llvm 等方言协同工作。MLIR 提供 mlir-opt 工具,开发者可以编写文本 IR 测试用例,验证方言的解析、打印、验证、降级行为是否正确。
功能测试用 FileCheck 断言 IR 转换结果;集成测试把自定义方言放进完整的“前端图 → 高层方言 → 低层方言 → LLVM IR”流水线;性能测试则和基准路径对比,算出加速比:
只有跑过压力测试的冷链线,才敢承诺“死蟹包赔”;只有经过完整验证的方言,才值得交付给生产环境。这条专线从此正式并入物流大网,成为整个分拨中心能力的一部分。
- S(加速比)衡量自定义方言相对基准路径的性能提升,是上线决策的核心数字。
- T_baseline(基准耗时)使用通用编译与执行路径时的耗时,作为对比的起点。
- 分式线(相除)用基准耗时除以新路径耗时,得到相对快了多少倍。
- T_custom(自定义耗时)接入自定义方言流水线后的端到端耗时。
AI 应用场景
PyTorch 自定义算子的硬件加速
阶段:图优化与算子编译
解决的问题:传统方式中,PyTorch 模型里的自定义激活函数(如 SiLU)会被拆成一批基础 torch 算子逐个执行,频繁启停内核,GPU 利用率低;用自定义方言把整个激活过程描述为一个高层操作,再由降级 Pass 直接翻译成融合的底层指令,可减少内核启动次数与中间张量读写。
具体案例:某推荐排序模型在部署到 GPU 前,把 hard-swish 激活写成自定义方言操作,经过 linalg-on-tensors 的向量化与融合,最终生成单次 kernel 调用,端到端推理延迟降低 23%。
AI 芯片的后端方言适配
阶段:后端代码生成与硬件适配
解决的问题:新 NPU 的指令集没有成熟编译器后端,芯片团队需要一层描述硬件指令的“设备方言”,否则模型只能靠手写算子库,开发周期按月计算。
具体案例:某自动驾驶芯片团队围绕自研 NPU 定义 ex_npu 方言,封装矩阵乘加、张量搬移、激活融合三类指令;Torch-MLIR 的前端 IR 通过多级降级最终落到 ex_npu 方言,再转成汇编,新算子接入从两周缩短到两天。
框架编译栈的统一 IR 接入
阶段:前端捕获与统一优化
解决的问题:PyTorch 2.0 编译栈需要把 eager 模式的动态图转换为可静态优化的 IR。若每个框架各搞一套中间表示,就无法共享优化逻辑。
具体案例:Torch-MLIR 项目为 PyTorch 定义 torch 方言,先捕获前向计算图,再降级到 TOSA 与 linalg 方言,让 MLIR 社区已有的融合、向量化、调度 Pass 直接复用,最终生成 LLVM IR 或接入各类硬件后端。
Python 演示
class Operation: """模拟 MLIR 中的操作节点""" def __init__(self, dialect, op_name, operands, attrs=None): self.dialect = dialect self.op_name = op_name self.operands = list(operands) self.attrs = attrs or {} def full_name(self): # 方言前缀 + 点号 + 操作名,例如 toy.silu return "{0}.{1}".format(self.dialect, self.op_name) def __repr__(self): operands_repr = ", ".join(o.full_name() for o in self.operands) return "{0}({1})".format(self.full_name(), operands_repr) class Dialect: """模拟 MLIR 方言命名空间""" def __init__(self, name): self.name = name def __repr__(self): return self.name def make(self, op_name, operands, attrs=None): return Operation(self, op_name, operands, attrs) def lower_silu(op, dialect): """把 toy.silu 降级为 sigmoid(x) * x 两个操作""" x = op.operands[0] sigmoid = dialect.make("sigmoid", [x]) mul = dialect.make("mul", [x, sigmoid]) return [sigmoid, mul] def lower_toy_to_std(toy_ops, std_dialect): """自定义方言 -> 基础方言的降级流水线""" lowered_ops = [] for op in toy_ops: if op.op_name == "silu": lowered_ops.extend(lower_silu(op, std_dialect)) else: lowered_ops.append(op) return lowered_ops def main(): toy = Dialect("toy") std = Dialect("std") x = std.make("input", [], {"name": "x"}) silu = toy.make("silu", [x]) graph = [x, silu] print("降级前:", graph) result = lower_toy_to_std(graph, std) print("降级后:", result) if __name__ == "__main__": main()
toy.silu 这样一个高层自定义操作,降级为 sigmoid 与 mul 两个基础操作。class Operation:表述 MLIR 中的操作节点,记录归属方言、操作名、操作数和属性。return "{0}.{1}".format(self.dialect, self.op_name)拼接“方言名。操作名”的完整唯一名称。def lower_silu(op, dialect):定义一个降级规则,对应 MLIR 里的 RewritePattern。sigmoid = dialect.make("sigmoid", [x])创建第一个基础操作,把原操作数传进去。mul = dialect.make("mul", [x, sigmoid])创建第二个基础操作,组合成x * sigmoid(x)。if op.op_name == "silu":模式匹配阶段,判断当前操作是否命中降级规则。lowered_ops.extend(lower_silu(op, std_dialect))把单个高层操作展开为多个基础操作,加入结果列表。if __name__ == "__main__":提供可执行入口,运行后能直接看到降级前后 IR 的变化。
常见误区
误区“我只要在 ODS 里多写几个 def,比如 my.add、my.mul,方言就算设计好了。名字起得越炫,越显得专业。”
正解方言的核心是“语义契约”。名字只是命名空间前缀;真正关键的是操作数与结果的约束、类型系统、验证逻辑和降级规则。没有这些约束,编译器无法安全地对操作做任何优化。就像光给包裹贴“海鲜”两个字,不写保存温度和配送时限,分拣员根本不知道该怎么处理。
为何常见:操作名是最直观的表面,初学者容易把“写名字”当成全部工作;加上 ODS 写起来确实简单,进一步误导了认知。
误区“既然都在同一个 MLIRContext 里,toy 方言的操作应该能自动被 linalg 方言处理。大家都是同一套 IR,互相之间应该无障碍吧?”
正解方言之间不会自动转换。你必须为它们编写显式的降级 Pass 和 RewritePattern,告诉编译器 toy.reshape 怎么拆成 linalg 操作。否则编译器只会在诊断里报无法匹配 pattern 的错误。正如快递公司不会因为两辆车停在同一个停车场,就认为货物能自动从一辆车跳上另一辆车。
为何常见:MLIR 的口号是“统一基础设施”,听起来很像“统一语言”,让人误以为所有方言天然互相理解。
误区“与其维护那么多小方言,不如把张量、循环、向量、硬件指令全部塞进一个 myall 方言,一步到位。”
正解方言的粒度决定优化的粒度。混合不同抽象层次的方言会让 Pass 难以定位目标:既难匹配算子模式,也会让验证规则纠缠在一起。MLIR 的设计哲学是“多级、分层”,小而聚焦的方言才能像积木一样灵活组装。冷链、常温、同城、干线各司其职,才是高效网络。
为何常见:从单体编译器(比如直接基于 LLVM IR)迁移过来的人习惯“一套 IR 管到底”,容易抵触分层设计。
误区“tensor<2xf32> 不是就能表达所有张量吗?自定义类型是多余的。”
正解通用张量类型表达不了“NHWC 布局”“量化缩放因子”“多卡分布式分块”这类语义。如果这些信息在 IR 中缺失,优化器只能做保守假设,生成大量内存搬运,性能大打折扣。自定义类型的本质是让编译器“看得到”那些原本只能靠命名约定传递的信息。
为何常见:学习阶段接触的都是 builtin 类型,没遇到过布局或量化问题,因此低估了类型语义的重要性。
面试题
Q1. 在 MLIR 中,方言(Dialect)存在的根本意义是什么?
- A.让编译器代码文件更美观
- B.提供可扩展的中间表示机制← 正确答案
- C.替代目标硬件的驱动程序
- D.提升前端语言的执行速度
答案:B。方言是 MLIR 实现“统一基础设施 + 领域可扩展”的关键机制,它让陌生领域的概念以统一 IR 的形式表达,而不是用一套固定 IR 硬撑所有场景。A、C、D 都偏离了方言在编译栈中的定位。
Q2. 开始设计自定义方言时,第一步应该做的是?
- A.立即编写操作定义的代码
- B.收集领域专有概念与痛点← 正确答案
- C.确定编译器的输出目录
- D.申请云端 GPU 测试资源
答案:B。需求分析是方言设计的起点。先弄清楚领域里有哪些独特概念、通用 IR 在哪里表达吃力,才能确定方言要覆盖的边界。跳步直接写代码,就像没算账就买冷链车。
Q3. 一个操作在 MLIR 中的完整名称格式是什么?
- A.方言名加点连接操作名← 正确答案
- B.仅使用操作名的英文缩写
- C.编译器随机分配的数字编号
- D.操作数加类型标注即可
答案:A。MLIR 要求操作名带命名空间前缀,格式为“方言名。操作名”,例如 toy.silu。这样不同方言里的同名操作也不会冲突,编译器能准确定位操作归属。其他选项都违背了命名规范。
Q4. 一次方言降级(Lowering)过程中会发生什么?
- A.将高层操作替换为低层操作← 正确答案
- B.将低层操作提升为高层操作
- C.删除图中所有中间表示节点
- D.仅调整操作的打印排版格式
答案:A。降级的本质是“翻译”:把抽象层次高的操作拆解、替换成更贴近硬件的低层操作序列。B 方向反了,C 删除所有中间表示会破坏 IR,D 把降级误解为格式化。
Q5. 使用 ODS 定义操作时,通常要声明的内容包括?
- A.操作数、结果与属性列表← 正确答案
- B.操作对应的 CPU 序列号
- C.自定义的验证与打印规则← 正确答案
- D.开发团队的分工与考勤表
答案:AC。ODS 的核心声明包括操作数、结果、属性,以及可选的验证逻辑和自定义解析/打印格式。B、D 属于运行时或项目管理信息,与编译期 IR 定义无关。
Q6. 在 MLIR 中,自定义类型的主要作用是什么?
- A.为操作提供更强的语义约束← 正确答案
- B.让编译器界面背景更美观
- C.避免操作数占用任何内存
- D.替代所有内置的基础类型
答案:A。自定义类型把布局、量化、分布等语义信息带进 IR,让约束校验和优化决策有据可依。B、C、D 都是对类型系统的错误理解。
Q7. 编写一个降级 Pass 通常需要提供哪些内容?
- A.转换目标集合与重写模式← 正确答案
- B.待转换的高层操作匹配规则← 正确答案
- C.适配原有方言的打印换行符
- D.目标硬件的官方价格文档
答案:AB。一个完整的降级 Pass 需要 ConversionTarget 声明哪些操作不合法、需要替换,以及 RewritePattern 定义匹配与重写逻辑。C、D 与降级机制无关。
Q8. 当降级模式与高层操作不匹配时,结果通常是什么?
- A.保留该操作并给出诊断提示← 正确答案
- B.编译器自动修改硬件设计
- C.所有操作被静默地直接丢弃
- D.编译流水线无限循环运行
答案:A。MLIR 降级框架的默认行为是“转换不了就保留原操作”,并输出诊断信息提醒开发者补全规则。静默丢弃会破坏 IR,无限循环和改硬件更是天方夜谭。
Q9. 自定义方言上线前需要验证的内容包括?
- A.功能正确性测试文件用例← 正确答案
- B.端到端的性能基准对比← 正确答案
- C.与其他方言转换链路衔接← 正确答案
- D.开发人员代码风格偏好
答案:ABC。方言交付前要验证功能正确(FileCheck 断言)、转换链路完整(高层到低层全流程)、性能达标(加速比 S)。代码风格属于工程规范,不是上线验证项。
Q10. 关于 MLIR 中 ODS / TableGen 的描述,正确的是哪一项?
- A.按声明生成操作类与打印代码← 正确答案
- B.仅生成编译器的命令行帮助文档
- C.是运行时解释执行的方言解析器
- D.硬编码各个目标平台的机器码
答案:A。ODS 基于 TableGen 在编译期自动生成操作类、类型类、验证函数和解析/打印代码,属于“声明式生成”。B 把能力说窄了,C 说成运行时解释,D 说成平台硬编码,均不符合 ODS 的工作原理。