小雨第一天来到编译器实验室报到。师傅递给她一张实验任务卡:「把烧杯 A 和烧杯 B 混合,做 add 反应。」手册上写明了材料:「A:体积 2×3,f32 浓度;B:体积 2×3,f32 浓度。」可手册的「产物规格」一栏却是空白的。小雨正要发问,师傅指了指手册角落的小字:「同体积、同浓度混合,产物规格与输入相同。」她恍然大悟——产物的体积和浓度,根本不用等实验做完才知道,照着配方和材料就能在动手前推出来。这个故事背后,正是 StableHLO 类型推导的精髓:编译器不用真正执行模型,就能根据每个算子的规则,推断出中间张量的形状与元素类型,从而为校验、优化和显存规划提供依据。本篇文章,我们就跟着小雨,把这个「实验室推演术」彻底弄明白。
掌握 StableHLO 类型推导,你将成为能开发 AI 编译器方言、定位形状错误、设计推理框架内存策略的编译器工程师——这是构建现代 AI 编译器基础设施的核心能力。
目录
可视化演示
\u5c55\u793aStableHLO\u7b97\u5b50\u7684\u7c7b\u578b\u63a8\u5bfc\u8fc7\u7a0b\u4e0e\u5e7f\u64ad\u8bed\u4e49\uff0c\u5305\u542b\u5f20\u91cf\u5f62\u72b6\u9759\u6001\u63a8\u65ad\u4e0eNumPy\u5e7f\u64ad\u89c4\u5219\u7684\u53ef\u89c6\u5316\u6f14\u793a\u3002
本节配有交互动效(进入视口后自动加载,约 3KB)。
\u7c7b\u578b\u63a8\u5bfc\u6a21\u5f0f \u8f93\u5165\u5f20\u91cfA/B\u7ecf\u7b97\u5b50\u63a8\u5bfc\u8f93\u51fa\u5f62\u72b6 \u9759\u6001\u786e\u5b9a\u4e2d\u95f4\u5f20\u91cf\u5f62\u72b6 shape(A\u2299B) = broadcast(A, B)
类型系统基础:溶液的双重身份
小雨来到试剂柜前,发现每一瓶溶液都贴着两张标签:一张写「体积规格」(比如 2×3,表示长方体试剂瓶的底面是 2 行 3 列),另一张写「浓度类型」(比如 f32,表示用 32 位浮点记录浓度)。这两张标签共同决定了这瓶溶液的身份。张量在 StableHLO 里也有这样两张标签,合在一起就是它的「身份证」。
这张身份证在编译器里的正式写法是 tensor<2x3xf32>:尖括号里前半段 2x3 是形状(shape),后半段 f32 是元素类型(element type)。任何 StableHLO 算子一开始接触张量,就要先核验这张身份证。形状的每个维度既可以是静态的(写死数字),也可以是动态的(用 ? 表示,留到运行时才知道);元素类型则决定每个数值占用多少存储空间、如何参与运算。两者组合成类型,这是 MLIR 所有验证与推导的地基。
静态维度好比标签上写死「精确体积 2×3」,动态维度则像「加蒸馏水至 1000mL 刻度」——前者立刻可知,后者要等操作进行到那一步才能确定。张量带着这张或清晰或模糊的标签走进算子,接下来就看实验手册如何规定动作了。
- 张量完整类型 Type(T)指张量 T 的完整类型描述,例如
tensor<2x3xf32>,这是编译器识别数据的第一手资料。 - 形状 Shape(T)描述各维长度,例如
2x3表示第 0 维长 2、第 1 维长 3,类似溶液瓶的「体积规格」。 - 元素类型 ElementType(T)描述每个元素的精度,例如 f32 表示 32 位浮点,类似溶液的「浓度类型」。
- 组合成完整类型把形状和元素类型用
tensor<>拼起来,「体积」与「浓度」共同定义一瓶溶液的身份。
算子契约:实验手册规定动作
实验室里有一排「实验手册」,每本都写死了三栏:材料、条件、产物。比如 add 手册——材料:A、B 两瓶溶液;条件:浓度类型必须一致;产物:留白,待推断。而 broadcast_in_dim 手册更讲究,材料栏之外还有一行小字:「注明每个输入维度搬到输出的第几位」,这就是属性(attribute)。每本手册都构成一份「契约」,规定操作成立的全部前提。
在 StableHLO 中,每个算子都遵循这样的契约:声明自己的输入操作数(operands)、属性(attributes)、区域(regions)和输出结果(results)。输出类型往往由推导完成,但契约里写明了推导所需的全部前提。属性是推导形状的关键「实验条件」:stablehlo.broadcast_in_dim 用 broadcast_dimensions 属性说明输入的每一维对应输出形状的哪一维;stablehlo.dot_general 用 batch_dims 与 contracting_dims 指出批量维度与收缩维度;stablehlo.reduce 用 dimensions 属性列出哪些维度要被归约掉。
区域(region)则是手册里的「循环小节」,像 while 算子携带一个 body 区域,区域内部还有自己的类型约束——循环携带的张量每次迭代类型必须保持稳定。这些前提合在一起,就是推断产物规格的「充分条件」。手册把材料与条件写清楚,产物栏留给统一机制去推导——这就是 StableHLO 算子的设计哲学:不重复描述已知,把类型推导交给下一节的「推演仪器」。
MLIR推导机制:化学家工作台
小雨桌上有一台「推演仪器」:把输入试管插进左槽,翻到手册对应细则,摇动把手,右槽就会掉出一张写着产物规格的纸片。这台仪器不是水晶球,而是一套严谨的机械传动:读类型、套规则、写结论。在 MLIR 中,这套传动就是类型推导机制,由两类接口驱动:InferTypeOpInterface 提供静态方法 inferReturnTypes,直接返回完整的结果类型列表;InferShapedTypeOpInterface 提供 inferReturnTypeComponents,返回更灵活的「形状组件」(ShapedTypeComponents)。
形状组件的价值在于「可以不把话说死」:当某些输出维度是动态的,组件只记录已知的维度,对未知维度保留约束而不填具体数字。推导在什么时候触发?最典型的是算子被构造时(builder 调用)和 IR 验证阶段(verifier 调用)。如果推理失败,接口通过 emitError 产生一条包含算子位置、输入类型和冲突细节的诊断消息,编译随即停止——化学家发现配方有冲突,绝不会硬做实验。
有了这台仪器,小雨不用每次「把实验做完」才知道产物规格——编译器同样不用「把模型跑完」就知道中间张量的类型。接下来我们看看仪器里最常用的四套「配方」。
- op待推导的算子实例,比如一个
stablehlo.add,对应手册里的实验步骤。 - operands算子输入操作数的类型集合,即「材料栏」里的各瓶溶液。
- attrs算子携带的属性集合,即手册「条件栏」里的实验参数。
- types | error推导成功的类型列表或失败诊断,二选一,不会有第三种结果。
典型推导规则:四大试管配方
配方一:逐元素同型。 add 这类逐元素算子,像两瓶同规格溶液直接混合。条件是两瓶溶液规格完全一致,产物规格也随之确定。
配方二:广播对齐。 当两个输入形状不同但可兼容时,广播规则把「小溶液」按维度扩展成「大网格」再混合。维度的对齐从右向左,大小为 1 的维度自动扩展到对方大小。
配方三:矩阵乘维度。 矩阵乘像「串联反应器」:A 管出口的 K 号管径必须接上 B 管入口的 K 号管径。
配方四:归约去维度。 reduce 像「把指定行的数据汇总成一行」,输出形状就是删掉这些维度。
这四大配方覆盖了 StableHLO 绝大多数算子的形状推理。把它们固化成公式后,就能交给一个统一的函数去执行——这就是下一节的形状函数。
- dims 指明删除对象属性
dimensions列出要归约掉的维度编号,像手册圈出「这几行要汇总」。 - 按位置删除从形状中删去这些维,剩余维度顺序不变。
- 成功与失败维度编号越界或重复时推导失败,其余情况输出剩余形状。
形状函数体系:配方背后的计算器
手册角落挂着一台小小的「计算器」,上面刻着配方对应的数学式。小雨只要把输入规格拨进去,计算器就咔哒咔哒吐出产物形状。这台计算器,就是形状函数(shape function)。StableHLO 为每个算子导出一个 compute_shape_fn:当算子无法直接给出输出类型时,MLIR 的 shape function 机制把「如何算形状」从算子定义中分离出来,做成可复用、可测试的纯函数。
形状函数支持符号化推导:当输入包含动态维度(?),它把未知维度当作符号变量参与运算,产出一个符号表达式而不是立即报错。符号化推导让编译器可以「纸上谈兵」,把不确定的维度作为变量保留,等到后续 pass 再做具体化(specialization)。对于 while 循环,类型推导还施加一条「循环不变约束」——每次迭代携带的(carried)张量类型必须与上一次完全一致,否则推导直接拒绝,防止出现越跑形状越乱的失控反应器。
形状函数把「实验操作」抽象成「数学计算」,让蒸馏、配料这些工艺脱离实物,在纸面上先推演一遍。这正是编译器静态分析的浪漫:不用点火,就能知道反应的产物规格。
- carry_i第 i 次迭代开始时携带的张量,像反应器里上一轮留下的产物。
- type(……)取该张量的完整类型,包括形状与元素类型。
- 迭代间相等第 i 次的类型必须等于第 i+1 次,类型严格不变。
- 对全部迭代成立约束覆盖每一次迭代,任何一步漂移都导致推导失败。
推导工程价值:为什么重要
知道产物规格后,小雨一口气解决了三件大事:提前预订烧杯(内存)、规划工序(优化)、核对禁配表(校验)。类型推导的工程价值,正是把这三件事从运行时提前到编译期。第一是编译期正确性:形状不匹配、元素类型冲突在编译期就被拦下,像「禁配表」防止把不相容的试剂倒在一起,避免模型跑了一半才崩溃。
第二是优化触发器:算子融合、向量化、重排都需要精确的形状信息。知道每个中间张量的形状,编译器才能判断「这两个算子的布局是否兼容、能否合并」,像工序规划必须知道产物尺寸。第三是内存规划依据:推导输出张量大小后,编译器可以为显存池预留空间。
第四是跨方言稳定 ABI:StableHLO 与 HLO 互相转换时,类型推导保证两侧形状语义一致,像不同实验室共用一套容器规格标准,任何一侧的改动都不会悄悄破坏另一侧。有了推导,编译器不用等运行时才「大吃一惊」——这正是工业级编译器的底气。
- 维度乘积把形状里所有维度相乘,得到元素总数,像数清烧杯网格的格子数。
- 单个元素大小sizeof(elem) 表示每个元素占多少字节,f32 占 4 字节。
- 总字节数两者相乘,就是中间张量在显存中占用的字节数。
实践排查指南:当推导失败时
小雨第一次接 while 循环反应器,仪器吐出一张红条:「循环体携带值形状漂移!」她盯着红条愣了半天,最后乖乖把 body 区域里的 reshape 删掉,故障消失。推导失败并不可怕,可怕的是不会读诊断信息。最常见的失败场景有四类:操作数元素类型不一致(f32 与 s32 混用)、广播维度无法对齐(2x3 与 3x1)、必要属性缺失(broadcast_in_dim 忘写 broadcast_dimensions)、循环携带值形状漂移。
错误消息解读有章可循:inferReturnTypes 产出的诊断消息包含算子位置和冲突细节,例如 "requires the same element type for all operands"。读消息时先定位「哪个算子推导失败」,再看「哪个前提被违反」,最后顺着 IR 回溯到出错的那一层。调试工具链方面,mlir-opt --debug 可以逐 pass 观察 IR,-print-ir-after-all 打印每个 pass 之后的 IR,帮助找出类型「从哪一步开始失真」。而 stablehlo-opt -verify-diagnostics 支持在测试文件里用 // expected-error 声明错误位置,把验证诊断写进回归测试,防止「改一个算子弄坏十个下游」。化学家靠红条和检修记录积累经验,编译器工程师靠诊断消息和回归测试一次次加固推导引擎——每一次红条,都是通往更可靠编译器的台阶。
---
AI 应用场景
ONNX 模型转换时的形状补齐
阶段:IR 转换与类型完善
解决的问题:从 PyTorch 导出的 ONNX 图常带形状缺失(例如动态 batch),传统做法是手写 shape 推断脚本,算子一多维护成本就失控。AI 编译器可以复用 MLIR 的类型推导引擎,把「补形状」变成标准的编译期步骤。
具体案例:某推理团队把 ONNX 图转 StableHLO 时,通过 MLIR 的推导接口自动补齐所有中间张量形状;batch 维度先用 ? 保留,在后续 specialization pass 中固化为 8,整个流程无需手写一行推断代码。
推理引擎的显存池规划
阶段:编译期优化与内存分配
解决的问题:端侧 NPU 显存紧张,运行时才发现「这个中间张量要 96MB」为时已晚。通过类型推导,编译器在编译期就拿到每个中间张量的精确形状,为显存池复用提供依据。
具体案例:在 x86 服务器上跑 StableHLO 的 conv 算子,推导出输出形状 [1, 64, 112, 112],按 f32 计算占用约 96MB,内存池据此预分配并复用,碎片率下降 30%。
自定义算子接入 StableHLO
阶段:算子扩展
解决的问题:为 StableHLO 新增自定义算子(如 LayerNorm)时,手写所有输入组合的类型表不现实,且容易漏掉边缘情况。
具体案例:算子库工程师参照 add 实现自己的 inferReturnTypes,8 行代码完成类型推导注册,新算子立即享受编译期校验,任何形状错误都在模型运行之前暴露。
Python 演示
from itertools import zip_longest class TensorType: """模拟 StableHLO 的张量类型:形状 + 元素类型。""" def __init__(self, shape, elem_type): self.shape = list(shape) # 动态维度用 None 表示 self.elem_type = elem_type def __repr__(self): dims = [] for d in self.shape: dims.append(str(d) if d is not None else "?") return "tensor<{}x{}>".format("x".join(dims), self.elem_type) def _element_must_match(types, op_name): first = types[0].elem_type for t in types[1:]: if t.elem_type != first: raise ValueError( "{} 要求所有输入元素类型一致, 但收到 {} 与 {}".format( op_name, first, t.elem_type)) def infer_add(a, b): """stablehlo.add: 逐元素加法, 输出形状 = 广播(a.shape, b.shape).""" _element_must_match([a, b], "stablehlo.add") s1, s2 = list(reversed(a.shape)), list(reversed(b.shape)) out = [] for d1, d2 in zip_longest(s1, s2, fillvalue=1): if d1 is None or d2 is None: out.append(None) # 动态维度保持动态 elif d1 == 1: out.append(d2) elif d2 == 1: out.append(d1) elif d1 == d2: out.append(d1) else: raise ValueError("广播失败: 维度 {} 与 {} 不兼容".format(d1, d2)) return TensorType(list(reversed(out)), a.elem_type) def infer_dot(a, b): """stablehlo.dot: 批量矩阵乘 [...]MxK x [...]KxN -> [...]MxN.""" _element_must_match([a, b], "stablehlo.dot") m, k1 = a.shape[-2], a.shape[-1] k2, n = b.shape[-2], b.shape[-1] if k1 != k2: raise ValueError("矩阵乘契约维度不匹配: {} vs {}".format(k1, k2)) batch = infer_add(TensorType(a.shape[:-2], a.elem_type), TensorType(b.shape[:-2], b.elem_type)).shape return TensorType(batch + [m, n], a.elem_type) def infer_reduce(t, dims): """stablehlo.reduce: 删除归约维后的形状.""" kept = [d for i, d in enumerate(t.shape) if i not in dims] return TensorType(kept, t.elem_type) def demo_failure(): a = TensorType([2, 3], "f32") b = TensorType([3, 1], "f32") try: infer_add(a, b) except ValueError as ex: print("推导失败:", ex) def build_demo_graph(): """构造一条 mini StableHLO 数据流图: add -> dot -> reduce.""" x = TensorType([2, 3], "f32") y = TensorType([3], "f32") w = TensorType([3, 4], "f32") add_out = infer_add(x, y) dot_out = infer_dot(add_out, w) red_out = infer_reduce(dot_out, [1]) print("add :", add_out) print("dot :", dot_out) print("reduce:", red_out) demo_failure() if __name__ == "__main__": build_demo_graph()
class TensorType用shape列表和elem_type两个字段模拟张量的「身份证」,动态维度用None表示。infer_add(a, b)实现逐元素算子的类型推导:先校验元素类型一致,再把两个形状右对齐逐维做广播。zip_longest(s1, s2, fillvalue=1)右对齐的关键:短形状缺失的前导维度补 1,对应广播规则中「视为 1」。out.append(None)任何一侧是动态维度时,输出该维保持动态,不强行填死。raise ValueError(...)维度既不相等又非 1 时抛出推导失败,模拟 MLIR 的emitError。infer_dot(a, b)矩阵乘推导:校验内维 K 对齐,输出末两维取 M 与 N,批量维复用广播推理。infer_reduce(t, dims)归约推导:按编号删除指定维度,剩余维度顺序不变。demo_failure()演示 2x3 与 3x1 的广播冲突,说明「非 1 且不等」的维度是推导失败的重灾区。
常见误区
误区我以为类型推导要等模型真正跑起来才能确定形状,动态维度的张量必须实际执行一遍才知道长什么样。
正解类型推导发生在编译期,是纯静态的符号分析。遇到动态维度时,推导器不会去执行模型,而是把未知维度标记为 ? 或符号变量,表示「此处运行时才确定」。推导产出的不是具体数值,而是类型约束;真正固化为静态形状要靠后续的 shape specialization pass。
为何常见:日常接触 PyTorch 时形状经常运行时才报错,容易让人误以为编译器也拿动态形状没办法,于是把推导与执行混为一谈。
误区我一直以为写 StableHLO 算子时,输出类型必须逐个声明,不声明就会编译失败,推导接口只是可选的加分项。
正解恰恰相反,MLIR 的 InferTypeOpInterface / InferShapedTypeOpInterface 允许算子省略输出类型,由推导接口自动补齐。这是 StableHLO 可扩展性的关键:新算子只要实现接口,下游所有依赖类型信息的 pass 都能自动工作。没有实现接口的算子才需要显式标注。
为何常见:写普通函数时返回类型必须声明,这个直觉被带到了编译器开发里,忽略了「接口推导」这一层抽象。
误区我觉得两个张量形状不同没关系,编译器会像变魔术一样自动把短维度补长,2x3 和 3x1 肯定能广播成 2x3。
正解广播的规则精确且严格:维度从右向左对齐,只有「某维为 1」或「两维相等」两种情况允许通过。2x3 与 3x1 从右对齐时,第 0 维是 2 与 3——既不相等又非 1,直接失败。真正能广播的是 2x3 与 3(缺失维度视为 1)。
为何常见:很多框架的 Reduce 或 tile 操作会默默补维,让人误以为广播是「自动补任何维度」,忽略了「非 1 且不等则报错」的硬约束。
误区编译器推导出 `tensor<2x3xf32>` 后,我就以为运行时这个张量一定是 2x3,不会再变了。
正解推导只保证类型与算子语义一致,是「契约层面」的正确性。当输入含动态维度且算子支持动态输出时,推导器返回 ?;即使某个 pass 把它固化为静态值,也只是在特定假设(如 batch=8)下的 specialization,换一个输入配置形状可能完全不同。
为何常见:看到 IR 里出现静态数字就以为「铁板钉钉」,忽略了这些数字可能来自某个 pass 的临时假设,而非张量的本质属性。
面试题
Q1. 张量类型 tensor<2x3xf32> 中,2x3 表示什么?
- A.元素存储字节数
- B.形状和元素类型← 正确答案
- C.张量的数据总长
- D.张量的维度个数
答案:B。张量类型由形状与元素类型两要素组成,2x3 是形状(两维分别长 2、3),f32 是元素类型,二者合起来是张量的「身份证」。A 说的是字节数,C 是元素总数,D 只描述了维度个数,都不完整。
Q2. stablehlo.add 的两个输入都是 tensor<2x3xf32>,输出类型是?
- A.tensor<2x3xf32>← 正确答案
- B.tensor<3x2xf32>
- C.tensor<2x2xf32>
- D.tensor<3x3xf32>
答案:A。逐元素算子遵循「同型共形」规则:输入形状与元素类型都相同,输出沿用输入规格,像两瓶同体积同浓度的溶液混合后规格不变。形状既不交换也不缩减,因此 A 正确。
Q3. tensor<2x3xf32> 与 tensor<3xf32> 做广播加法,输出形状是?
- A.tensor<2x3xf32>← 正确答案
- B.tensor<3x3xf32>
- C.tensor<2x2xf32>
- D.tensor<3x2xf32>
答案:A。广播维度从右向左对齐:3 与 3 相等保留,2 与缺失维度(视为 1)中 1 让位,得到 2x3。像一排 3 格量杯复制成两排,每格一一对应相加。
Q4. 矩阵乘 [...]MxK 乘 [...]KxN 的输出末两维是?
- A.批量维加M和N← 正确答案
- B.批量维加N和M
- C.批量维加M和K
- D.批量维加K和N
答案:A。矩阵乘的契约是 A 的最后一维 K 必须等于 B 的倒数第二维 K,输出末两维取 A 的 M 与 B 的 N,批量维按广播对齐。K 是被消掉的「接驳口径」,不会出现在输出中。
Q5. 类型推导失败时,MLIR 编译器通常会怎么做?
- A.报错并停止编译← 正确答案
- B.猜测一个默认类型
- C.忽略错误继续编译
- D.推迟到运行时检查
答案:A。推导失败时接口通过 emitError 产生诊断消息,编译随即停止,像化学家发现配方冲突立刻停手,绝不随便拿一瓶溶液冒充产物。猜测、忽略、推迟都与静态分析的原则相悖。
Q6. InferShapedTypeOpInterface 的 inferReturnTypeComponents 返回什么?
- A.形状组件列表← 正确答案
- B.完整张量类型
- C.元素类型列表
- D.属性查询结果
答案:A。该接口返回 ShapedTypeComponents(形状组件)列表,允许某些维度未知或仅保留约束,比直接返回完整类型更灵活,特别适合动态维度场景。其他选项描述的是不同层面的信息。
Q7. stablehlo.reduce 输入 tensor<4x6x8xf32>,dimensions = [1],输出形状是?
- A.tensor<4x8xf32>← 正确答案
- B.tensor<6x8xf32>
- C.tensor<4x6xf32>
- D.tensor<6x4xf32>
答案:A。归约推导按维度编号删去指定维:[1] 表示删除第 1 维(长度 6),剩余维度顺序不变,得到 4x8。像把中间一排数据汇总掉,两端尺寸原样保留。
Q8. 以下哪些维度对可以通过广播规则?
- A.3 与 1← 正确答案
- B.2 与 3
- C.1 与 4← 正确答案
- D.3 与 3← 正确答案
答案:ACD。广播只认两种情形:某维为 1(让位给对方)或两维相等(原样保留)。3 与 1、1 与 4、3 与 3 都满足;2 与 3 既不相等又非 1,触发推导失败。
Q9. StableHLO 类型推导的工程价值包括哪些?
- A.运行前发现形状错误← 正确答案
- B.为融合优化提供形状← 正确答案
- C.替代训练流程的损失计算
- D.为显存池规划提供依据← 正确答案
答案:ABD。推导的价值集中在编译期:静态校验格式错误(禁配表)、为算子融合提供形状前提(工序规划)、为显存分配提供字节数(订烧杯)。损失计算是算法层的职责,与类型推导无关。
Q10. 以下哪些属于类型推导失败的常见场景?
- A.操作数元素类型不匹配← 正确答案
- B.广播维度无法对齐← 正确答案
- C.循环体携带值形状漂移← 正确答案
- D.输出与输入类型完全相同
答案:ABC。元素类型不一致、广播冲突、while 循环携带值类型不稳定都是典型推导失败场景。输出与输入类型完全相同反而是许多算子的正常行为(如逐元素 add),不构成失败。