阿楠第一天到“鑫泰智能制造”厂报到,车间里机器轰鸣,传送带一刻不停。他注意到每台设备上都钉着一块不锈钢铭牌:额定功率 3kW、模具编号 M12、压力上限 5 吨。传送带上的物料不断变化——这块是钢板、下块是铝件——但铭牌上的字纹丝不动。老师傅拍拍他肩膀:“想听懂这条流水线,先学会读铭牌。” MLIR 编译器就是这座智能工厂。方言是不同车间,Operation 是每台加工设备,操作数(operand)是传送带上流动的物料——运行时数据。而本文的主角属性(Attribute),就是钉在设备上的那块参数铭牌:它在编译期被写死,优化器一遍遍读取它做决策,可运行时流水线从来不会去改它。 从“铭牌是什么”讲到“铭牌怎么分类”,再讲到“铭牌如何帮优化器做纸面计算”,这篇文章用一座工厂的视角,带你彻底看懂 MLIR 的属性 Attributes。
属性是 MLIR 里“编译期元数据”的载体。掌握它,你才能真正读懂 IR、写出靠谱的模式匹配与优化 Pass,是编译器工程师的基础功。
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
属性基本概念
阿楠在车间里发现,每台设备的不锈钢铭牌上都刻着三行字:额定功率、模具编号、压力上限。传送带上的工件换来换去,可铭牌从来不改。老师傅说:“铭牌管的是这台设备怎么用,不是这批工件是什么。” 一句话点醒了他——设备参数和流水线物料,是两回事。
MLIR 里每个 Operation(设备)同样挂着一组 Attribute(铭牌)。比如 arith.addi 这个加法操作:它究竟加哪两个数,由操作数(传送带上的工件)决定;但它的“可折叠性”“结果类型”这些特性,则由属性描述。编译器优化器会反复读铭牌做决策,运行时的数据流却永远不会去修改铭牌。
形式上,Attribute 是编译期不可变的静态元数据。一个操作可以通过 op->getAttr("name") 读取属性,修改和删除则必须走 setAttr / removeAttr 接口。属性既不是寄存器里的值,也不是内存里的张量,它与操作数是两条完全独立的通道。属性的生命周期由 MLIRContext 统一管理,同一份内容在上下文内只保存一份——这就是后文的唯一化机制。
阿楠在笔记本上写道:传送带上的工件会流动、会被加工,但设备铭牌纹丝不动,是设计师定好的“出厂参数”。理解这条界线,就理解了属性的第一课。
- OpMLIR 中的 Operation,IR 的基本节点,相当于车间里一台具体的加工设备,是整套编译流水线的最小工作单元。
- Key → Value键值对表示一条参数项。Key 是属性名(如
name、pad),Value 是具体值(如字符串"conv2d"、整数1),合在一起就是铭牌上的一行字。 - 整体映射每个 Operation 都拥有一张“参数清单”,把各个键映射到各自的值。这张清单在编译期定型,之后只被读取、不被修改。
属性类型体系
铭牌上的字段格式各不相同:功率写数字,型号写字母串,配件清单则是一串多行参数。如果所有信息都糊成一行文字,读起来必然打架。工厂因此规定:不同字段使用不同的书写格式。
MLIR 属性也有自己的类型系统。最基础的是 IntegerAttr(整型属性)和 StringAttr(字符串属性):前者放 padding=1 这类数值参数,后者放 name="conv2d" 这类标识信息。它们是铭牌上最常见的两种“字体”。
组合场景则需要更复杂的格式。ArrayAttr(数组属性)保存一串同类型属性,比如 strides=[1,2] 就是一个包含两个整型属性的数组;DictionaryAttr(字典属性)则保存键值对集合,把一组关联参数捆绑成一个整体。所有属性都继承自 Attribute 基类,在 IR 文本打印时会带上类型信息——例如 #arith.constant<value=5 : i32> 明确说明这是一个 32 位整型属性。
懂了,铭牌上“功率=3kW”用的是数字格式,“型号=A3”用的是字母格式,“配件清单”则是一串数组。格式门当户对,才不会读错行。
[[1,2],[3,4]] 这类结构化数据。- A数组属性的整体表示,相当于铭牌上“配件清单”那一栏。
- ai ∈ Attr数组中每个元素都是一个合法的属性,说明“清单的每一项都遵守统一格式规范”。
- 整体等式的含义数组属性把多个属性按顺序收拢成一个整体,读取时可以用下标
[i]取到第 i 项(从 1 开始编号直观对应)。
特殊属性类型
有些铭牌不走寻常路。阿楠在冲压车间看到一块铭牌,上面不是数字,而是钢印的模具三维图——它描述的是“部件应该长成什么样”;另一台检测设备上只敲了一个“已质检”章,没有任何数值;仓库货架旁的设备,铭牌上写的是一个“货位编号”,指向另一个地方。
这三类特殊属性在 MLIR 里都有对应。TypeAttr 的值本身是一个类型,比如 tensor<4x4xf32>,它描述“这一块数据应该以什么形状存在”。UnitAttr 是无值属性,不携带任何数据,只要存在就表示“该选项开启”,类似代码里的 flag(标志位)。FlatSymbolRefAttr(符号引用属性)保存一个符号名,用来引用函数或全局变量,比如 call @main 中的 @main 就是通过它完成的。
这三类属性看似简单,却在框架里高频出现:TypeAttr 帮助类型推导系统快速拿到张量形状;UnitAttr 常用来标注 no_inline、overflow 等可选项;FlatSymbolRefAttr 是模块级“跳转”的基石。新手最容易把它们混淆,因为它们打印出来都不是普通数值。
阿楠把三类记成三个形象:模具图(TypeAttr)、质检章(UnitAttr)和仓库货位号(FlatSymbolRefAttr)。都是铭牌,但“内容”完全不同——读铭牌前,先分清它是哪一种。
属性接口机制
工厂新来了一位质检员,面对各车间的五花八门设备,他只需要一个读卡器:所有铭牌都统一钉在机身右侧,字段命名规范,一刷就出数据。工厂为此专门立了《铭牌管理制度》。
MLIR 通过统一的 Attribute 基类接口约束所有属性。每个具体属性类都要实现 getId(获得唯一标识)、getDialect(返回所属方言)、print(打印文本)等接口。上层代码不需要知道具体类型,就能遍历、比较、打印任意属性——这就是“统一读卡器”的意义。
在 ODS(算子定义规范)中声明属性后,TableGen 会自动生成强类型访问器。例如给 StrideOp 声明 stride: I64ArrayAttr,框架就会生成 getStride() 方法,让 C++ 代码直接拿到数组值。访问器内部调用 getAttrOfType<ArrayAttr>("stride") 做类型转换,把“读铭牌”变成“点菜单”。此外,TypedAttr 接口进一步要求“属性必须携带类型信息”,使类型相关的算法可以统一处理所有带类型的属性。每个属性也通过 getDialect 知道自己属于哪个方言,跨方言时才能找到正确的翻译规则。
阿楠体会到了“统一读卡器”的好处:无论哪个车间的设备,只要铭牌规范,编译器框架的通用代码就能无障碍读取。接口机制,就是那本《铭牌管理制度》。
属性经典用法
真正讲究的铭牌,除了固定参数,还要处理“出生证明”和“运行档案”。阿楠看到仓库里一张巨型库存表:如果全量记录每个货位的状态,几百页纸都写不完;但老师傅只在一张稀疏表格上记录“哪排哪架有货”,其余空白。
DenseElementsAttr(稠密元素属性)以密集方式保存张量元素,权重、常量数据都常用它——每个位置都写,简单直接。SparseElementsAttr(稀疏元素属性)则只保存非零元素及其坐标,适合零值占比极高的矩阵。此外,布局属性(LayoutAttr)记录数据的内存排布,比如 NCHW 还是 NHWC,tile 形状是什么;名称属性(NameAttr)给操作或名字赋值,在符号表和调试信息里大量出现。选哪种属性,直接决定 IR 的体积和优化器遍历成本。
阿楠学会了:先看铭牌上的数据长什么样——全量密排、零值稀疏、还是带布局描述?选对属性格式,比硬塞进一个标准字段省得多。
- nnzNon-Zero,矩阵中非零元素的个数,是稀疏存储的核心变量。
- s单个元素占用的字节数(如
f32类型占 4 字节)。 - n × m稠密矩阵的行数与列数,乘积就是总元素数。
- 整体不等式稀疏只记“有值的位置”,当 nnz 远小于 n×m 时(比如 1% 非零),稀疏存储完胜。
属性与重写优化
老师傅每天做“纸面审查”:看到标着“可折叠”的设备,直接在排产表上算出结果,根本不用开机试运行;只有标着“必须现场加工”的,才放上流水线。纸面计算不耗电、不占机床,是最便宜的优化。
属性在模式匹配(Pattern-Matching)和常量折叠(Constant Folding)中扮演关键角色。匹配一段 IR 时,属性值就是“指纹”:匹配器会比较 op->getAttr("pad") 是否等于期望值,不满足就跳过当前候选操作。约束检查越严格,重写规则越精准。
折叠算法则检查每个操作:若所有操作数都是 IntegerAttr 等常量属性,且操作本身是纯函数(无副作用),就当场计算结果,把整个操作替换成一个新的常量操作。这个机制把大量“运行时的重复计算”变成“编译期的一次算术”。更广义的规范化(Canonicalization)会反复应用折叠、合并、消除规则,直到 IR 收敛到精简形态——这正是优化器质量的关键指标。
老师傅的“纸面计算”就是编译期的常量折叠。铭牌上的参数决定了能不能直接算答案——所有性能优化里,这是最便宜的一种。
- f被折叠的操作,比如加法、乘法。要求它是纯函数——同样的输入永远得到同样的输出,且不修改外部状态。
- ci ∈ Attrconst所有输入都是编译期常量属性,例如
IntegerAttr(2)、IntegerAttr(3),而不是运行时计算出来的值。 - cres算出的常量结果。折叠后,原来的
f(c1, c2, ..., cn)整体被替换成一个常量操作,运行时只需读取,无需计算。
跨方言属性处理
一台设备从 A 车间搬到 B 车间,铭牌不能原样带过去。B 车间有自己的字段规范,“功率=3kW”必须翻译成 B 车间写法的"power=3000mW",否则新车间的老师傅看不懂。
跨方言转换(DialectConversion)时,属性同样要“翻译”。新方言可以接收并保留原生属性,也可以定义转换规则把旧属性重写为新方言的属性。在 ConversionPattern 中,通常通过 rewriter.getRemappedAttr 把源属性映射为目标属性;规则没覆盖的属性则默认保留,由新方言决定是否接管。
做好属性转换要盯住三点:打印格式——IR 文本中 #dialect.attr 前缀让每个属性都有归属,读文本就能看出它来自哪个方言;内存唯一化——自定义属性必须实现 hash 和 ==,才能复用 MLIRContext 的缓存,否则每个对象各存一份,内存白白膨胀;常见陷阱——转换时丢了属性、类型不匹配、数组长度变化,都会导致后续 Pass 读到的铭牌“缺字”。处理好这三点,跨方言的 IR 才能正确往返(round-trip)。
阿楠想起老师傅的话:铭牌不是一成不变,而是“入乡随俗”地翻译。编译器的降级(lower)过程,就是一次又一次把铭牌翻译成更底层的方言语言,直到车间里的每一台机器都能接住。
AI 应用场景
深度学习编译器前端算子解析
阶段:训练模型导出 → 计算图编译(Graph Compilation)
解决的问题:TensorFlow 或 PyTorch 导出的计算图里,卷积核大小、padding、stride 这些超参散落在框架内部对象中。传统方式是每个前端重新解析一遍,代码重复且易错。利用 MLIR 属性,前端把 kernel=3、pad=1 固化为 IntegerAttr,把 data_format="NHWC" 固化为 StringAttr,统一挂在对应 Operation 上,后续所有优化 Pass 只要读属性即可。
具体案例:在框架适配层中,把 TF Conv2D 节点的 strides、padding 映射为 MLIR toy.conv2d 操作的 ArrayAttr 与 IntegerAttr,完成前端到 MLIR 的桥接。
推理引擎量化部署
阶段:模型量化(Quantization)→ 目标后端代码生成
解决的问题:量化参数(scale、zero_point)是推理正确性的关键元数据。以前用单独的结构体保存,在 Pass 里容易被遗漏;把 scale 与 zero_point 作为属性挂到量化算子(如 quant.dcast)上,跟随 IR 一起转换、一起打印、一起唯一化,生命周期由上下文管理,不容易丢失。
具体案例:对 ResNet 做 INT8 量化时,给每个权重操作附加 scale=0.0289, zero_point=34 两个浮点属性,后端代码生成直接读取属性生成定点乘加指令。
算子融合与硬件调度
阶段:后端优化(Tiling / Fusion)
解决的问题:GPU 或 NPU 上,tile 大小、流水线级数这些调度参数需要从编译期传递到后端。传统方式是全局配置表,模块化差。利用属性标注调度策略(如布局属性存储 tile 形状、UnitAttr 标注是否允许融合),同一个方言 IR 在不同硬件后端只需重新解释属性,而不用改 IR 结构。
具体案例:在 MLIR 的 GPU 方言中,给 matmul 操作附加 tile=[16,16] 的 ArrayAttr 和 unroll=1 的 IntegerAttr,GPU Pass 读取后生成对应 CUDA kernel 参数。
Python 演示
# 模拟 MLIR 属性机制:唯一化 + 常量折叠class Attribute: """属性基类:相同值的属性只保留一个实例(uniquing)。""" _interned = {} def __new__(cls, value): key = (cls.__name__, value) if key not in cls._interned: node = super().__new__(cls) node.value = value cls._interned[key] = node return cls._interned[key] def __repr__(self): return "{}({!r})".format(type(self).__name__, self.value) class IntegerAttr(Attribute): """整型属性:保存编译期整数常量。""" class StringAttr(Attribute): """字符串属性:保存编译期字符串,如算子名称。""" class Operation: """操作:模拟 MLIR Operation,携带操作数和属性字典。""" def __init__(self, name, operands, attrs): self.name = name self.operands = operands self.attrs = attrs def get_attr(self, key): return self.attrs.get(key) def __repr__(self): ops = ", ".join(repr(o) for o in self.operands) attrs = ", ".join("{}={}".format(k, v) for k, v in self.attrs.items()) return "{}({}) [{}]".format(self.name, ops, attrs) class ConstantOp(Operation): """常量操作:只有一个 value 属性,是编译期常量。""" def __init__(self, value_attr): super().__init__("arith.constant", [], {"value": value_attr}) def fold_constants(ops): """常量折叠:add 的两个操作数都是 IntegerAttr 时,编译期直接求和。""" folded = [] for op in ops: if op.name == "add": left, right = op.operands if isinstance(left, IntegerAttr) and isinstance(right, IntegerAttr): folded.append(ConstantOp(IntegerAttr(left.value + right.value))) continue folded.append(op) return folded def main(): a2 = IntegerAttr(2) a3 = IntegerAttr(3) a2_again = IntegerAttr(2) print("唯一化: IntegerAttr(2) is IntegerAttr(2) ->", a2 is a2_again) print("唯一化: IntegerAttr(2) is not StringAttr('2') ->", a2 is not StringAttr("2")) print("interned 表大小(折叠前):", len(Attribute._interned)) ops = [ ConstantOp(a2), ConstantOp(a3), Operation("add", [a2, a3], {}), ] print("折叠前:", ops) folded = fold_constants(ops) print("折叠后:", folded) print("interned 表大小(折叠后):", len(Attribute._interned)) assert isinstance(folded[-1], ConstantOp) assert folded[-1].attrs["value"].value == 5 if __name__ == "__main__": main()
key = (cls.__name__, value)用“类型名 + 值”作为唯一键,保证IntegerAttr(2)与StringAttr("2")互不干扰,这正是 MLIR 唯一化表的简化版。node.value = value只在新实例创建时赋值,重复获取已有属性不会再次初始化。class IntegerAttr(Attribute):空类体即可区分属性类型,体现 MLIR 中“类型本身也是元信息”。def get_attr(self, key)模拟op->getAttr()的按名读取接口。isinstance(left, IntegerAttr) and isinstance(right, IntegerAttr)折叠判定条件:操作数必须是整型常量属性。folded.append(ConstantOp(IntegerAttr(left.value + right.value)))折叠发生时,整个加法操作被替换为一个新的常量操作。assert folded[-1].attrs["value"].value == 5最终验证折叠结果正确,体现“编译期纸面计算”的可信度。
常见误区
误区“属性不就是操作数吗?反正都是挂在 op 上的数据,运行时需要哪个就读哪个。”
正解二者是完全不同的两个通道。操作数是传送带上的物料——运行时数据流,由 SSA 值引用;属性是设备铭牌——编译期静态元数据,不参与运行时的值传递。在 MLIR 里,操作数会出现在 operands 列表中并被数据流追踪,属性则挂在 attributes 字典里,由编译器优化器读取。混用两者会导致优化器无法区分“可折叠常量”和“真实运行时依赖”。
为何常见:初看 IR 文本时,两者都出现在操作附近,形式上都像“附加信息”,容易视觉混淆。
误区“我要保存一个常量,统统一口气用 DenseElementsAttr 存大数据,省事。”
正解DenseElementsAttr 适合保存真正的稠密张量数据(如权重、常量矩阵)。对于单个整数参数、字符串标记,用 IntegerAttr / StringAttr 更轻量,内存占用小、打印清晰、比较更快。把一个小整数包装成大张量属性,是典型的资源浪费。
为何常见:教程里高频出现 DenseElementsAttr,新手以为它是“存储常量数据的通用容器”。
误区“属性不就是一个映射表吗?我在 A 方言里写上 `unroll=4`,B 方言里直接读就行,不需要额外处理。”
正解每个属性都有所属方言(通过 getDialect() 查询),IR 打印时带 #dialect.attr 前缀。跨方言时,B 方言不一定认识 A 方言的属性,需要显式转换或保留默认处理。正确做法是在 ConversionPattern 中通过 getRemappedAttr 做映射,或确认 B 方言会保留未知属性。
为何常见:属性形式上长得像通用 JSON,容易忽略它和方言的绑定关系。
误区“反正是字典结构,拿到 attrs 后直接 `op.attrs['pad'] = 1` 不就行了?”
正解MLIR 中属性是不可变且唯一化的。直接替换字典值可能绕过唯一化机制,导致同一逻辑值在上下文里出现多份实例。正确做法是调用 op->setAttr() / op->removeAttr() 接口,让框架完成唯一化注册和 IR 一致性维护。本 Python 演示中的 _interned 表正是为了强调这一点。
为何常见:Python 的 dict 随手可改,习惯性思维把 C++ 的 Attribute 也当成普通可变容器。
误区“检测到两个输入都是 IntegerAttr,就立刻折叠这个操作。”
正解常量折叠还有一个隐含前提:操作必须是纯函数(无副作用)。如果操作会修改内存、读取外部状态或产生非确定性结果(比如随机数),即使输入是常量也不能折叠。此外,输出类型必须能在编译期确定。折叠前请检查操作的 hasNoSideEffect 特性(ODS 中的 NoSideEffect trait)。
为何常见:教材常拿 add、mul 举例,让人误以为所有操作都满足折叠条件。
面试题
Q1. MLIR 中属性与操作数最本质的区别是?
- A.属性是运行期信息,操作数是编译期信息
- B.属性是编译期静态信息,操作数是运行期值流← 正确答案
- C.属性和操作数语义一致,可以互换使用
- D.属性优化后会消失,操作数不会消失
答案:B。属性是钉在设备上的铭牌(编译期静态信息),操作数是传送带上的物料(运行时值流)。A 说反了;C 说可互换完全错误;D 中属性优化后通常保留在最终 IR 中,不会自动消失。选 B,它准确描述了两条通道的本质分工。
Q2. IntegerAttr 最适合用来存什么?
- A.整个计算图的运行时权重大小
- B.算子名称这样的字符串标志
- C.pad=1 这样的编译期整型参数← 正确答案
- D.神经网络模型的全部权重张量
答案:C。IntegerAttr 就像铭牌上“功率=3kW”这样的数字字段,适合保存 pad、stride、unroll 等编译期整型参数。字符串标志应该用 StringAttr(B),权重张量应该用 DenseElementsAttr(D),运行时权重大小不是属性该管的事(A)。
Q3. 属性唯一化(uniquing)带来的好处包括?
- A.相同内容的属性在上下文中只保存一份← 正确答案
- B.不同方言的属性无法在同一 IR 中共存
- C.节省内存,并让属性比较退化为指针比较← 正确答案
- D.属性的值可以在编译期自动被修改
答案:AC。唯一化相当于“同一型号铭牌只铸一块”,让 IntegerAttr(2) 和 IntegerAttr(2) 指向同一实例(A),从而节省内存并加速比较(C)。B 错误,不同方言属性可以共存,各有 getDialect() 标识;D 错误,属性编译期不可自动修改,变更要走 setAttr 生成新实例。
Q4. UnitAttr 的正确语义是?
- A.表示值为零的整型常量标记
- B.无值属性,存在即表示某个开关开启← 正确答案
- C.存储单位张量所需的所有元素
- D.等价于一个空的字典属性
答案:B。UnitAttr 就是“质检章”——不携带数值,只要出现在属性表里就表示选项开启,比如 no_inline。A 把它误认为零值整数,C 把它和张量混淆,D 忽视它与 DictionaryAttr 的根本差异。记住“存在即有效”即可。
Q5. 关于 DenseElementsAttr 的描述正确的是?
- A.只用于存储稀疏矩阵数据
- B.密集保存张量元素的编译期副本← 正确答案
- C.只能存储布尔类型的数组
- D.存放运行时计算的临时结果
答案:B。DenseElementsAttr 像一张全量填写的表格,把张量每个元素都作为编译期数据保存,是权重和常量矩阵的常规载体。A 说反了,稀疏矩阵用 SparseElementsAttr;C 限制过窄,它能存整数、浮点等;D 错,它属于编译期 IR,不参与运行时临时存储。
Q6. 常量折叠成立的必要条件包括?
- A.操作本身是纯函数,没有副作用← 正确答案
- B.所有操作数都是编译期常量属性← 正确答案
- C.折叠结果必须是更大的张量
- D.输出类型在编译期能够确定← 正确答案
答案:ABD。常量折叠的“纸面计算”要求三件事:操作纯函数(A)、输入全是常量属性(B)、输出类型可以确定(D)。C 完全错误——折叠往往把大计算变成一个小常量,而不是更大的张量。这题对应正文中折叠规则的三个前置条件。
Q7. 跨方言转换时,属性最常见的默认处理是?
- A.所有属性必须被转换为新方言属性
- B.属性保留原样,由新方言决定是否接管← 正确答案
- C.所有属性都转换为普通全局字符串
- D.属性必须全部丢弃以简化转换流程
答案:B。跨方言转换时,规则未覆盖的属性默认会原样保留,新方言根据自己的认识决定是否接管或忽略,而不是强制全部转换(A)、字符串化(C)或丢弃(D)。这就像设备搬车间后,新车间没登记的铭牌字段会暂时挂着,直到有明确规则来翻译它。
Q8. 创建字符串属性最常用的 API 是?
- A.IntegerAttr.get(……)
- B.StringAttr.get(……)← 正确答案
- C.UnitAttr.get(……)
- D.TypeAttr.get(……)
答案:B。字符串属性自然由 StringAttr 创建,比如 StringAttr.get("conv2d")。A 创建整型,C 创建无值标记,D 创建类型属性。记住“想存什么类型,就用对应类型的工厂方法”,和铭牌字段“功率用数字格式、型号用字母格式”一个道理。
Q9. 属性在模式匹配中发挥的作用有?
- A.读取操作属性并与期望值比对← 正确答案
- B.用属性值来计算重写的收益← 正确答案
- C.属性会自动修改被匹配的操作数
- D.属性决定匹配后选择的模式分支← 正确答案
答案:ABD。属性在重写系统中是“指纹”:匹配时先读属性做比对(A),匹配成功后用属性值估算收益(B),多个模式并存时还能按属性区分分支(D)。C 错误,属性不会被自动修改,操作数由重写器显式更新——铭牌不会自己改动工件。
Q10. TypeAttr 的特殊之处在于?
- A.它的值本身是一个 MLIR 类型定义← 正确答案
- B.它只用于符号表的名字映射
- C.它在打印时会自动被省略掉
- D.它的值是一个 UnitAttr 标志
答案:A。TypeAttr 的“值”本身是一个 Type 对象,比如 tensor<4x4xf32>,相当于铭牌上刻着模具图而不是数值。B 说的是 FlatSymbolRefAttr 的职责;C 错误,TypeAttr 打印时清晰可见;D 混淆了 TypeAttr 与 UnitAttr。记住“描述长什么样”的属性就是 TypeAttr。