你的 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)
| 输入 | PyTorch | ONNX |
|---|---|---|
| (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 导出器后面的一个步骤:
- 负责把 ATen 算子翻译成 ONNX 的 torchlib 里没有
_adaptive_avg_pool2d。它之前被删掉了,因为 ONNX 没有通用的自适应池化算子,所以导出器只能退回 PyTorch 自己的分解规则。 - 这条分解规则里有一个快速路径:如果输入尺寸能被输出尺寸整除,就调用
avg_pool2d,并取kernel = 输入 // 输出。在符号尺寸下,这个条件和卷积核都按示例输入算出了具体的值,分解后的图就变成了avg_pool2d(x, [3, 2], [3, 2])。 - 这一切发生在导出之后、导出器自己做分解的时候,所以程序的 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 之前,先让测试符合这个算子在真实模型中的用法。
现在可以做什么
- 用动态形状导出后,用两三个和示例不同的尺寸运行 ONNX 模型,并和 PyTorch 对比。只在示例尺寸下跑一次,对动态维度什么也证明不了。
- 如果模型里用了输出尺寸不是 1 的
AdaptiveAvgPool2d,要么用静态的空间尺寸导出,要么检查导出后AveragePool的属性。输出尺寸为 1 的情况在我的测试里是通过的。 - 修复进展可以关注 pytorch/pytorch#204405。我在里面提出了一个支持动态形状的 torchlib 实现(基于
CumSum和Gather),以及在导出器里加一个通用检查。这个实现已经提交为 microsoft/onnxscript#3201,正在等待审阅。加上它之后,用动态高宽导出的 VGG11 在 224、256 和 448 下都和 PyTorch 一致。
编写测试、排查这些问题和撰写本文时,我使用了 Claude(Anthropic)辅助。所有结果都经过我本人审阅,各项检查也由我亲自运行。
包启明,现居新西兰奥克兰的 AI 研究者,研究方向为大模型推理与模型部署。博士毕业于奥克兰大学 Strong AI Lab,参与 PyTorch ONNX 导出器、ONNX Runtime GenAI 和 Microsoft PyRIT 等开源项目的贡献。