想象一家定制家具工坊。老师傅接到订单,不会立刻把木头往机器里塞。他先铺开图纸,把每一块木板的长、宽、厚度都标出来:这块板切完剩多长,那块板刨完剩多厚,最后组装的柜门用几个抽屉。只要所有中间尺寸预先算清,整个车间就能像上了发条般运转;若算不清,工人们就得反复停下来量尺寸、调机器。 AI 编译器面临的处境几乎一样。模型没法直接“跑”,先得被翻译成一张计算图——好比工艺图纸;图中的每个加工步骤都要吞进、吐出一块有确定“形体”的数据。形状推理(Shape Inference)就是编译器在开工之前,沿着计算图静态推算出每一步输出数据块的形状,让后续的内存分配、步骤合并、代码生成都可以提前拍板,不必等运行时反复“测量”,从而省下大量性能开销。 这套思想贯穿 XLA 与 PyTorch 2.0 的编译栈:先标尺寸,再干活;标得越全,干得越快。
理解形状推理,是看懂 PyTorch 2.0 / XLA 编译优化思路的钥匙,也是排查“编译后为什么变慢”“动态形状为何反复重编译”等性能问题的必备技能。
可视化演示
演示图优化阶段Shape Inference沿算子链静态推导各层输出形状,为内存分配、算子融合与代码生成提供精确依据。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Shape Inference 算子 形状推导 融合 静态推导避免运行时检查
概念与意义
老木匠的案头功夫,从来不在锯子上。订单进门,他先铺开一张空白图纸,把每一块原木的来料尺寸、每一道工序后的半成品尺寸都写上去。柜门要四块板,每块多厚;抽屉要三条轨,每根多长——这些数字在机器启动前就定下了。图纸上多写一行字,车间里就少一次手忙脚乱的测量。
深度学习模型走进编译器时也是如此。模型先被翻译成一张计算图:节点是算子,边是流动的张量。张量的“形体”由形状(shape)描述——一个维度元组,比如 [B, 3, H, W] 依次是批次、通道、高、宽。形状推理(Shape Inference)就是编译期的一项静态分析:不真正执行任何算子,只根据算子的语义与输入形状,推算出每个输出的形状。
在 XLA 与 PyTorch 2.0 的图优化阶段,形状推理紧跟在图构建之后、内存规划与内核生成之前。它输出的“形状事实表”被下游反复消费:内存分配器按形状算缓冲大小,算子融合器按形状判断合并可能性,代码生成器按尺寸特化循环。若形状只能等到运行时才知道,编译器就只能做最保守的安排——相当于每块料都现量现裁,工效大打折扣。正因如此,形状推理被视为编译优化链条上“承上启下”的关键前置步骤。
机器还没转,但老师的图纸已经写满数字。形状推理的价值正在于此:用一次编译期的“纸上标注”,换取运行期每一步的从容。图纸上多一行尺寸,车间里就少一次等待——这就是为什么它如此重要。
- 输出形状 Shape(y)表示算子输出张量 y 的形状,是一个由各维度长度组成的元组。这是整段推导的“待求答案”。
- 变换函数 f_op每个算子自带一条“尺寸规则”,描述输入形状如何映射到输出形状,好比机器铭牌上的进料/出料尺寸对照表。
- 输入形状参数 Shape(x_i)第 i 个输入张量的形状。卷积有两个输入(数据与权重),全连接同理;每个输入的形状都要代入规则。
- 结构约束输出形状只由“算子语义 + 输入形状”决定,与张量里的具体数值无关——这正是可以静态推导的根本原因。
静态推导原理
工坊里每台机器都钉着铭牌:进料尺寸、出料尺寸、吃料深度,写得清清楚楚。老师傅按工序从第一台走到最后一台,翻一块铭牌,记一笔台账,把每个中间件的尺寸顺次填进表格。这趟“翻铭牌、记台账”的流程,就是形状推理的日常。
编译器里的每类算子同样自带“铭牌”——算子元数据中注册了一个形状规则(inference rule),描述输入形状如何映射到输出形状。推理器拿到图的输入形状后,按拓扑序遍历计算图(保证每个算子在推理时,它的输入形状都已被上游算好):遇到算子就取出它的规则,代入输入形状,算出输出形状,写进形状环境(shape env)——一张“张量名 → 形状”的映射表,供下游算子继续查用。
以卷积为例,其规则由填充(padding)p、卷积核(kernel)k 与步幅(stride)s 决定。三个参数都是编译期常量时,只要输入高宽已知,输出高宽立刻确定。若某算子的规则依赖运行时数据——比如“输出长度等于输入序列中非零元素个数”——规则返回未知(Unknown),该输出被标记为动态,不再参与静态推导。整个遍历自底向上、层层推进,形状像水一样从输入端流向输出端,直到整张图都被“标注”或标记为未知。
老师傅一路翻到底,台账越记越厚,图纸越来越清晰;遇到没有铭牌的机器,就在台账上画个问号。这个问号,就是编译器里的 Unknown——它预示了后面的工序没法全按图纸走,得留一手。
- 输入高度 H_in上一层输出的特征图高度,来自形状环境里已登记好的数值。它是整个公式的“被加工原料”。
- 填充 2p卷积前在特征图上下各补 p 圈“假像素”,让边缘信息不被丢掉;上下各补 p,所以分子里加 2p。
- 核尺寸与步幅减 k 是因为卷积核要完整落在图上;除以步幅 s 是因为每次滑动 s 个像素,能放下多少个核窗口,窗口数就是输出高度。
- 向下取整与加一尺寸必须是整数,所以向下取整;加 1 是因为从位置 0 开始计数,窗口个数 = 间隔段数 + 1。
符号形状表达
有的客户不把话说死:“柜子要三米到四米,你们看着办。”老师傅没法标一个死数字,只能在图纸上写一个大大的 H(待定),再补一行小字:H 必须是 4 的倍数,滑轨才装得上。这样的图纸依然能用,只是多了一层“带约束的弹性”。
模型部署里也常有“话不说死”的维度:批次大小随请求量浮动,序列长度随句子变化。若把所有维度都写成整数,batch 从 8 变成 16 就要重新编译一次,代价高昂。符号形状(Symbolic Shape)允许这类维度用符号变量表示——形状里的 S_b、S_h、S_w 不是具体数字,而是一个“待定但受约束”的记号。
符号变量照样参与传播:输入 [S_b, 3, S_h, S_w] 经过卷积得到 [S_b, 16, S_h-2, S_w-2]。遇到除以步幅时会引入整除要求,编译器便收集约束 S_h % 4 == 0。符号表达式既能像普通形状一样参与内存计算(表达式可化简),又给运行时留下适度的自由度——介于“完全静态”和“完全动态”之间,是工程上的经典折中。
图纸上的“H(待定)”配上“4 的倍数”的约束小字,就是符号形状的写照:自由要给,但不能无边无际。约束越清晰,编译器的优化空间就越大。
- 符号批次 S_b批次维度用符号变量表示,每次调用可以是 1、8、32,只要是正整数,编译产物就能复用。
- 静态通道 3通道数在模型定义期就固定,写死成常量 3,因为它永远不会变,不需要符号化。
- 空间符号 S_h、S_w图像高宽随输入变化,用符号表达。符号参与后续规则计算,形成符号表达式。
- 整除约束S_h mod 4 = 0,保证后续步长为 4 的下采样能整除。约束即“自由度的边界”。
编译优化收益
尺寸标全的图纸,能同时喂饱车间里的三个角色:仓管按最大尺寸预订货架,裁切师傅按余料规划顺序,打磨工序直接并进切割机——半成品不必搬来搬去。尺寸,是车间运转的公共语言。
编译器里三位“角色”同样依赖形状。第一位是内存分配器:张量占用的字节数等于元素总数乘以每个元素的字节数,形状已知,分配器就能在运行前精确预留缓冲,甚至让多个中间张量复用同一块内存,而不是运行时“现租”。
第二位是算子融合器:两个算子能否融合,先看中间形状是否兼容。卷积后面跟一个逐元素激活函数,只要形状一致就能合并成一个内核,省去中间张量的写回与读入。第三位是代码生成器:固定形状可以特化内核——展开小循环、固定边界、为特定 batch 挑选最优的 GEMM 实现。再加上运行时形状检查被整个消除(每次调用都要比较尺寸的环节直接省掉),三重收益叠加,正是形状推理带来的性能红利。
图纸齐全,仓库、裁切、合并全部提前安排;形状齐全,内存、融合、特化全部胸有成竹。老师傅照图施工,编译器按形优化——“先标后做”的朴素智慧,在编译的世界里价值千金。
- 总字节数 mem(T)张量 T 占用的内存字节,是分配器拿到的最终数字。
- 每元素字节 elemsize与数据类型绑定,float32 为 4 字节、int8 为 1 字节。这是“单价”。
- 各维度乘积从第 1 维到第 r 维的边长全部相乘,得到元素总数。形状已知,乘积就是确定值。
- 维度数 r张量的阶数,例如四维张量 r = 4。乘积按维度逐一展开,比如 [B, 3, 64, 64] 就是 B × 3 × 64 × 64。
PyTorch 2.0实践
工坊接了不少相似订单。老师傅把验证过的工序图挂在墙上,下次来了同尺寸的活,照着做就行。但每批木材到货,伙计仍要量一下尺寸——万一这批板子宽了两毫米,整套工序就得推翻重来。这道“到货核验”,就是形状守卫。
PyTorch 2.0 的 torch.compile 延续了同样的逻辑。Dynamo 把 Python 函数捕获成 FX 计算图,后端在图上完成形状推理与一系列优化,得到一份可复用的编译产物;产物上挂着一串形状守卫(guards)——对输入形状做出的编译期假设,例如“batch 等于 4 且高度等于 224”。编译产物上还记录了特化后的 TensorType(如 [4, 224, 224, 3]),它是“这张图纸对应哪批料”的精确签名。
下一次调用时,运行时先逐条求值守卫:实测本次输入形状,与假设逐一比对。全部通过,直接复用编译内核,把编译开销摊薄到一次次调用里;任一守卫失败,则触发重编译,新形状对应的产物同样被缓存。这也解释了“第一次慢、后面快”的现象。对于动态形状场景,用户可把部分维度标记为符号,守卫从“等于某值”放宽为“满足符号约束”,从而减少重编译频率。
墙上的工序图是编译产物,到货核验是形状守卫。核验通过则照图施工,核验失败则重新画图。形状推理的质量,决定了图纸被复用的概率——推理与守卫,本就是一枚硬币的两面。
- 守卫函数 g(x)以输入张量 x 为参数的布尔函数,返回“编译假设是否仍然成立”。
- 对全部维度做合取大写的 ∧(逻辑与)把每个维度的检查结果串起来——只要有一个维度不匹配,整体就是 false。
- 运行时取维度长度 x.size(d)在每次调用时实际读取张量第 d 维的长度,这是“到货实测值”。
- 编译期记录值 v_d当初形状推理时使用的假设值,是“图纸标注值”。实测值等于 v_d,守卫才通过。
局限与挑战
手艺再好的工坊,也会碰上难缠的单子:客户现场改尺寸,或要求“先看料再定方案”。这时图纸标不全,老师傅只能交代工人边做边量、做到哪算哪。不是老师傅不专业,而是这类活从来就没有“图纸上的答案”。
形状推理也有标不出的形状。最常见的来源是控制流:计算图里出现 if/while,两个分支的输出形状不一致,汇合处填什么?还有数据依赖形状:输出形状由输入张量的内容决定,例如“返回所有非零行”。这类情形在静态阶段无法确定,只能标记为 Unknown。
编译器对 Unknown 的应对是回退(fallback):放弃本段静态优化,退回解释执行,或在运行时做动态形状检查,甚至为每个新形状重新编译。回退带来性能损失,但保住正确性。另一重挑战是复杂度:符号约束越多,求解与化简越贵,编译本身可能变慢。因此工程实现普遍采用“先静态、再符号、后动态”的渐进策略,并以缓存缓解重编译的代价。
标不全的图纸配上一套“现场测量”的备案,订单照样能交付,只是慢一些。形状推理的局限不是失败——它替编译器划清了“哪些该静态做、哪些必须动态做”的边界,让回退变得有条不紊。图纸上的问号,同样是专业的一部分。
- 合并函数 merge控制流汇合处把两个分支的输出形状合并成单一形状。它必须给出一个“两全”的答案。
- 两个分支维度 d_1、d_2来自 if 的两个出口。如果两者相等则没有问题,直接取该值。
- 未知符号 ⊥两者不等时,没有任何一个静态数字能同时满足两个分支,输出标记为未知(bottom)。
- 回退语义merge 结果为 ⊥ 时,整个算子链的形状推断失败,编译器进入运行时回退路径。
AI 应用场景
推理引擎部署优化(TensorRT / ONNX Runtime)
阶段:图优化 / 部署优化
解决的问题:传统推理服务中,形状信息缺失导致每次调用都要动态分配内存、执行形状检查,GPU 空转率高。形状推理让引擎预先知道所有中间张量的尺寸,可以一次性规划显存池、实施算子融合与内核特化,把“边跑边量”变成“照图施工”。
具体案例:某视觉团队把 YOLO 模型导出为 ONNX 并部署到 ONNX Runtime,开启形状推理与内存优化后,固定分辨率下的请求延迟下降约 30%;在动态 batch 服务中用符号维度覆盖常见批次,吞吐量提升约 2 倍。
torch.compile 训练加速
阶段:图捕获与编译优化
解决的问题:训练循环逐迭代执行 Python 解释器,开销巨大。torch.compile 通过 Dynamo 捕获计算图、形状推理与后端优化,把迭代编译成高效内核;然而输入形状一变就重编译,是动态形状训练的核心痛点。
具体案例:NLP 训练中句子长度不等,团队将序列维度设为符号维度并开启动态形状支持,使同一批编译内核覆盖多种长度,重编译次数从每步一次降到数百步一次,训练吞吐显著提升。
大模型服务端批量调度(vLLM 风格)
阶段:服务端图缓存与运行时调度
解决的问题:大模型推理时每个请求的 token 数不同,batch 内序列长度持续变化。若每次新长度都重新编译,首 token 延迟会不可接受。形状守卫配合符号形状,让一组预编译内核覆盖一段长度区间,用轻量核验代替整轮编译。
具体案例:类 vLLM 的服务端在启动时为常见长度区间预编译 attention 内核,运行时通过形状守卫快速匹配,动态 batch 场景下的 TTFT(首 token 时延)大幅下降,GPU 利用率显著提升。
Python 演示
"""极简形状推理引擎: 按拓扑序遍历计算图,用算子注册的形状规则静态推断中间张量形状.""" class ShapeEnv: """形状环境: 一张'张量名 -> 形状'的台账""" def __init__(self): self.shape_map = {} def bind(self, name, shape): self.shape_map[name] = shape def lookup(self, name): return self.shape_map.get(name) def infer_conv2d(out_ch, kh, kw): """卷积规则: 高宽按 (H - k + 1) 收缩""" def _rule(in_shape): n, _, h, w = in_shape return (n, out_ch, h - kh + 1, w - kw + 1) return _rule def infer_reshape(target): """reshape 规则: -1 表示按总数与其余维度反推""" def _rule(in_shape): if target[0] != -1: return tuple(target) total = 1 for d in in_shape: total *= d rest = 1 for d in target[1:]: rest *= d return (total // rest,) + tuple(target[1:]) return _rule def infer_linear(out_features): """全连接规则: 保留前导维, 末维变为 out_features""" def _rule(in_shape): return tuple(in_shape[:-1]) + (out_features,) return _rule class MiniCompiler: """迷你编译器: 注册规则 + 编译图并执行形状推理""" def __init__(self): self.rules = {} def register(self, op_name, rule_factory): self.rules[op_name] = rule_factory def compile_graph(self, graph, input_shape): env = ShapeEnv() env.bind(graph["input"], input_shape) for node in graph["nodes"]: rule = self.rules[node["op"]](*node["params"]) in_shape = env.lookup(node["inputs"][0]) out_shape = rule(in_shape) env.bind(node["output"], out_shape) print(node["name"], "->", out_shape, "已标注") return env def main(): compiler = MiniCompiler() compiler.register("conv2d", infer_conv2d) compiler.register("reshape", infer_reshape) compiler.register("linear", infer_linear) graph = { "input": "img", "nodes": [ {"name": "conv1", "op": "conv2d", "params": (16, 3, 3), "inputs": ["img"], "output": "conv1"}, {"name": "flatten", "op": "reshape", "params": (-1, 16), "inputs": ["conv1"], "output": "flat"}, {"name": "fc", "op": "linear", "params": (10,), "inputs": ["flat"], "output": "fc"}, ], } print("=== 形状推理: 开工前标注尺寸 ===") compiler.compile_graph(graph, (1, 3, 32, 32)) if __name__ == "__main__": main()
self.shape_map = {}台账初始化:张量名到形状的映射,起步为空。def register(self, op_name, rule_factory):给算子贴铭牌:把规则注册进编译器。rule = self.rules[node["op"]](*node["params"])查铭牌取规则,并注入卷积核尺寸等参数。in_shape = env.lookup(node["inputs"][0])从台账读出输入形状,作为推导原料。out_shape = rule(in_shape)套用规则算出输出形状,即铭牌上的进料-出料对照。env.bind(node["output"], out_shape)把新形状登记进台账,供下游算子查询。compiler.compile_graph(graph, (1, 3, 32, 32))给出输入形状,启动整张图纸的标注。
常见误区
误区编译器已经把每个张量的形状都静态推出来了,运行时就不该再有任何形状检查;看到检查代码就说明没优化好。
正解形状推理产生的是编译期假设,不是运行时事实。PyTorch 2.0 的 guards、TensorRT 的 binding 校验都是必须的“到货核验”——假设可能被打破,核验能防止内存越界或错误结果,代价极低。图纸标得再全,材料到货也得量一下。
为何常见:只看到静态分析“神机妙算”的一面,忽略了任何静态假设都必须靠运行时校验兜底,这是编译器安全设计的常识。
误区我用 Python 随时能打印 tensor.shape,运行时看一眼不就知道了?形状推理大概就是这么回事。
正解两者阶段完全不同。tensor.shape 是运行时查询已存在张量;形状推理发生在编译期、算子尚未执行时,靠算子规则“预判”未来的输出形状,结果写入编译产物。打印是“量已经做好的料”,推理是“还没开工就把图纸标好”。
为何常见:日常开发接触 eager 模式多,对编译期静态分析缺少直观感受,容易把“运行时查形状”与“编译期推形状”混为一谈。
误区形状推理这么重要,编译器肯定能把图里所有算子的输出形状都精确推出来,不然怎么叫优化。
正解控制流分支导致形状分叉、数据依赖形状(如按内容裁剪)等都无法静态确定,只能标 Unknown 并回退。符号形状虽然扩大覆盖,但本质边界仍在。推理能力是“尽可能”而非“全部”,局限源于问题的内在难度,不是实现偷懒。
为何常见:教材多讲形状推理的成功案例,很少聊失败路径;初学者把“大多数场景有效”放大成“所有场景必然”。
误区batch 大小变就变,反正显存够用,为什么编译器非要纠结形状?
正解显存够用只保证“能跑”,不保证“跑得快”。形状未知时无法预分配内存、无法判断算子融合、无法特化内核,性能可能损失数倍。形状推理的意义就在于把这些隐藏损失提前消除——省下的不是内存,是时间。
为何常见:纯应用视角下正确性立即可见,性能损耗藏在 profiling 数据里,容易被“能跑就行”的思维忽略。
面试题
Q1. 形状推理(Shape Inference)在图优化阶段主要解决什么问题?
- A.静态推断每个算子输出的形状← 正确答案
- B.压缩模型权重参数量
- C.将浮点运算转换为整数运算
- D.自动选择最优的 GPU 设备
答案:A。形状推理是编译期的静态分析,核心是推断算子输出张量的形状。B 属于剪枝/压缩,C 属于量化,D 属于设备调度,都不是形状推理的目标。类比:形状推理像在图纸上标尺寸,而不是决定用哪种木材。
Q2. 在 PyTorch 2.0 编译栈中,形状守卫(Shape Guard)的作用是什么?
- A.验证输入形状匹配编译假设← 正确答案
- B.防止模型参数被意外修改
- C.限制网络模型的最大层数
- D.限制 GPU 显存占用上限
答案:A。守卫是挂在编译产物上的一组形状断言,每次调用先检查,全部通过则复用编译产物,否则触发重新编译。B 是参数保护,C 是结构限制,D 是显存管理,都与守卫无关。
Q3. 卷积层输出高度公式中 H_out = floor((H_in + 2p - k)/s) + 1,符号 p 代表什么?
- A.卷积核的高度
- B.卷积的滑动步幅
- C.边界的填充宽度← 正确答案
- D.空洞的扩张比率
答案:C。p 是 padding(填充)宽度,上下各补 p 个像素,所以公式里是 2p。k 是卷积核高度,s 是步幅,dilation 是扩张率。类比:填充相当于在木板边缘加保护条,保证边缘信息不被一刀切掉。
Q4. 下列哪种情况最适合用符号形状(符号维度)表达?
- A.输入图片分辨率固定不变
- B.批次维度在不同调用间可变← 正确答案
- C.模型输出概率分布的数值
- D.权重矩阵数值始终保持恒定
答案:B。符号形状用于“运行时才确定、但始终满足某些约束”的维度,典型就是可变 batch。A 是固定维度,用常量表达即可;C/D 是数值而非形状,与符号形状无关。类比:图纸上写“H(待定)”正是为可变尺寸准备的。
Q5. 静态形状推理给编译器带来的收益不包括下列哪项?
- A.实现精确的内存预分配
- B.支撑算子融合的决策
- C.消除运行时的形状动态检查
- D.直接提升浮点计算的精度← 正确答案
答案:D。形状推理影响内存、融合、代码生成与运行时检查,但它不改变数值计算本身,浮点精度由数据类型与算法决定。D 是把“性能优化”误当成了“数值优化”。类比:尺寸标全能让车间跑得快,但改变不了木料本身的材质。
Q6. 当两个控制流分支的输出形状不一致时,merge 的结果通常是?
- A.取两个形状的并集
- B.标记为未知并触发回退← 正确答案
- C.自动填充成两者最大值
- D.随机选择其中一个形状
答案:B。merge 的语义是“找不到同时满足两个分支的静态答案就返回 ⊥(未知)”。编译器随后回退到动态检查或解释执行,而不是强行填充。类比:两个方案尺寸不统一,图纸上只能打问号,等现场决定。
Q7. 形状传播中产生整除约束(如 S_h % s == 0)的根本原因是?
- A.算子规则要求维度必须被步幅整除← 正确答案
- B.数据加载器随机采样的结果
- C.梯度裁剪阈值带来的限制
- D.学习率调度策略引入的约束
答案:A。卷积/池化等算子在计算输出尺寸时用步幅做除法,符号维度下无法预知整除是否成立,于是把 S_h % s == 0 作为约束条件收集起来。B/C/D 与算子几何语义无关。类比:滑轨必须等分安装,尺寸约束来自装配工艺而非客户喜好。
Q8. 静态形状推理与下列哪些编译优化环节密切相关?
- A.内存分配过程← 正确答案
- B.算子融合决策← 正确答案
- C.权重初始设定
- D.内核代码生成过程← 正确答案
答案:ABD。内存分配需要形状算字节数,融合需要判断相邻算子形状兼容,代码生成需要形状特化循环与内核。权重初始化发生在模型定义/训练阶段,与编译期的形状推理无关。类比:尺寸齐全后,仓库、合并工位、机器编程都能提前安排。
Q9. PyTorch 2.0 中缓解动态形状重编译开销的机制包括哪些?
- A.形状守卫的缓存复用机制← 正确答案
- B.动态维度的符号化表示← 正确答案
- C.强制所有维度静态化处理
- D.编译图结果的多次复用← 正确答案
答案:ABD。守卫决定缓存能否复用;符号化让一个产物覆盖多个相似形状;按形状缓存可以命中历史编译结果。C 恰好相反——强制静态化会让动态输入频繁触发重编译。类比:合同里写“高 H(待定),H 为 4 的倍数”,比写死每个数字更灵活。
Q10. 下列属于形状推理典型挑战的有哪些?
- A.控制流分支导致形状不一致← 正确答案
- B.符号约束求解的复杂度上升← 正确答案
- C.静态形状完全覆盖所有算子
- D.未知维度触发编译回退损失← 正确答案
答案:ABD。控制流让 merge 返回未知;符号约束越多求解越贵;未知维度导致回退到动态/重编译。C 是理想化设想而非现实挑战。类比:现场改尺寸的订单,图纸标不全,只能边做边量——这就是局限的本质。