Qiming Bao · Blog · 2026 年 11 月 12 日 · English / 中文

你的 ONNX 模型说输入是动态的,但有些其实不是

用 dynamic_shapes 导出之后,ONNX 模型的输入维度会变成符号维度,人们自然会以为这个模型现在能处理任意尺寸了。我逐个算子验证了这个假设:每个算子都把所有维度设成动态的来导出,然后用另一个尺寸运行,和 PyTorch 对比。大多数算子都通过了,有两个没有,其中一个会让 torchvision 的 VGG 出错。我把它报告成了 pytorch/pytorch#204405。

测试方法

大约 60 个算子,每个都放在一个小模块里,所有维度都用 torch.export.Dim.AUTO 导出。然后在 ONNX Runtime 上用新尺寸的输入运行,比如用 (2, 5) 导出、用 (3, 9) 运行,再和 eager 模式的 PyTorch 对比。切片、填充、池化、插值、归一化、排序、注意力、STFT 等都能正确处理新尺寸,只有 adaptive_avg_pool2d 和 kron 不行。

adaptive_avg_pool2d:只在一个尺寸下正确

class M(torch.nn.Module):
    def forward(self, x):
        return F.adaptive_avg_pool2d(x, (2, 3))

x = torch.randn(1, 2, 6, 6)
dyn = ({0: Dim.AUTO, 1: Dim.AUTO, 2: Dim.AUTO, 3: Dim.AUTO},)
onnx_program = torch.onnx.export(M(), (x,), dynamic_shapes=dyn, dynamo=True)
输入PyTorchONNX
(1, 2, 6, 6),导出时的尺寸(1, 2, 2, 3)(1, 2, 2, 3)
(2, 3, 8, 9)(2, 3, 2, 3)(2, 3, 2, 4)
(1, 1, 12, 12)(1, 1, 2, 3)(1, 1, 4, 6)

没有报错,也没有警告。ONNX 图里只有一个 AveragePool,kernel_shape=(3, 2)、strides=(3, 2),都是用 6×6 的示例输入算出来的,而输入维度却仍然被声明为动态的。

这不是什么冷门算子。torchvision 的 vgg11() 主干网络最后就是一个 AdaptiveAvgPool2d((7, 7))。用动态的高和宽导出后,224×224 能正常运行;换成 256×256 和 448×448,ONNX Runtime 会在分类头的 Gemm 处报错。去掉分类头只看主干网络,它会静默地输出 (2, 512, 8, 8) 和 (2, 512, 14, 14),而正确结果应该是 (2, 512, 7, 7)。

尺寸是在哪里被写死的

torch.export 本身没有问题。它把 aten._adaptive_avg_pool2d 当作一个完整的算子保留下来,导出的程序对 12×12 的输入能给出正确的形状 (1, 1, 2, 3)。问题出在 ONNX 导出器后面的一个步骤:

  1. 负责把 ATen 算子翻译成 ONNX 的 torchlib 里没有 _adaptive_avg_pool2d。它之前被删掉了,因为 ONNX 没有通用的自适应池化算子,所以导出器只能退回 PyTorch 自己的分解规则。
  2. 这条分解规则里有一个快速路径:如果输入尺寸能被输出尺寸整除,就调用 avg_pool2d,并取 kernel = 输入 // 输出。在符号尺寸下,这个条件和卷积核都按示例输入算出了具体的值,分解后的图就变成了 avg_pool2d(x, [3, 2], [3, 2])。
  3. 这一切发生在导出之后、导出器自己做分解的时候,所以程序的 range constraints 仍然说这些维度是动态的,ONNX 模型也一样。

kron 是同样的模式:它被分解成几个把示例尺寸写死的 _unsafe_view,换一个尺寸运行时,ONNX Runtime 会在 Reshape 处报错。这里有一个很有用的细节:分解后的 ExportedProgram 其实记录了这次特化,用新尺寸运行它会报 Guard failed: a.size()[0] == 2。所以导出器完全可以在分解前后对比一下 guard,发现这种情况并给出警告,而不是写出一个在其他尺寸下出错的模型。

一个误报

conv1d(padding="same") 一开始也导出失败了。原因是我把卷积核也设成了动态形状,于是填充量依赖于一个符号化的卷积核大小,而 ONNX Conv 的属性没法表示它。在真实模型里,卷积核是形状固定的参数。只把输入设成动态的,Conv1d(..., padding="same") 在我试过的所有卷积核大小和 dilation 下都能导出,换尺寸后的结果也和 PyTorch 一致。提 bug 之前,先让测试符合这个算子在真实模型中的用法。

现在可以做什么

编写测试、排查这些问题和撰写本文时,我使用了 Claude(Anthropic)辅助。所有结果都经过我本人审阅,各项检查也由我亲自运行。


包启明,现居新西兰奥克兰的 AI 研究者,研究方向为大模型推理与模型部署。博士毕业于奥克兰大学 Strong AI Lab,参与 PyTorch ONNX 导出器、ONNX Runtime GenAI 和 Microsoft PyRIT 等开源项目的贡献。