4、动态形状的核心概念:Symbolic Dimension(符号维度)、Dynamic Axes(动态轴)

好,咱们进入正题。动态形状这个东西,说白了就是让模型能处理不同大小的输入。你想想看,一个目标检测模型,图片可能是 640x480,也可能是 1920x1080。如果模型只能吃固定尺寸,那每次都得 resize,信息就丢了。

ONNX 怎么解决这个问题的?靠两个核心概念:符号维度(Symbolic Dimension)动态轴(Dynamic Axes)。我刚开始接触的时候也绕了一阵,今天帮你彻底理清楚。

4.1 符号维度:给形状一个“变量名”

先看一个静态形状的例子。假设你有一个输入张量,形状是 [1, 3, 224, 224]。这表示 batch=1,通道=3,高=224,宽=224。一旦导出 ONNX,这个形状就焊死在模型里了。

符号维度是什么?就是把这些数字换成变量名。比如 ["batch_size", 3, "height", "width"]。这里的 "batch_size""height""width" 就是符号维度。它们不是具体数值,而是一个占位符。

核心理解:符号维度就是给形状的某个轴起个名字,告诉 ONNX 运行时:“这个轴的大小是可变的,具体值你推理的时候再定。”

我个人习惯用有意义的字符串,比如 "N" 表示 batch,"C" 表示通道,"H""W" 表示高宽。这样代码可读性高,团队里其他人一看就懂。

4.2 动态轴:哪些轴可以变?

动态轴这个概念更具体。它指的是在模型推理时,允许变化的那些轴。一个张量可能有多个轴,但不是所有轴都能动态。比如卷积层的输入,通道轴通常是固定的(因为卷积核的权重是固定的),但 batch 轴和空间轴(高、宽)可以动态。

举个例子,一个典型的分类模型:

输入张量形状:[batch_size, 3, height, width]
输出张量形状:[batch_size, num_classes]

这里,batch_sizeheightwidth 都可以是动态轴。但 3(通道数)和 num_classes(类别数)是固定的。为什么?因为卷积层的权重形状是 [out_channels, 3, kernel_h, kernel_w],输入通道必须等于 3,否则权重对不上。

我的经验:在项目中,我一般只把 batch 轴和空间轴设为动态。通道轴和输出特征维度尽量固定。这样做的好处是,模型结构简单,推理引擎优化起来也容易。有一次我把所有轴都设成动态,结果 ONNX Runtime 报了一堆 shape inference 错误,排查了半天。

4.3 符号维度 vs 动态轴:到底啥区别?

这两个概念容易混淆。我直接给你一个对比表:

概念 本质 作用 举例
符号维度 形状中的变量名 声明某个轴的大小是可变的 "batch_size""height"
动态轴 实际运行时可以变化的轴 指定哪些轴在推理时允许不同值 batch 轴、宽高轴

说白了,符号维度是“声明”,动态轴是“行为”。你用符号维度告诉 ONNX:“这个轴我打算让它变”。然后在实际推理时,这个轴就变成了动态轴。

嗯,这里要注意:不是所有符号维度都能成功变成动态轴。有些算子对形状有硬性要求。比如 Reshape 算子,如果目标形状中包含 -1,那它就能自动推导。但如果目标形状是固定的,那符号维度也没用。

4.4 实战:在 PyTorch 中设置动态轴

咱们看看代码。用 PyTorch 导出 ONNX 时,通过 dynamic_axes 参数来指定:

import torch
import torch.onnx

class SimpleModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = torch.nn.Conv2d(3, 16, 3)
        self.fc = torch.nn.Linear(16 * 222 * 222, 10)  # 假设输入224x224

    def forward(self, x):
        x = self.conv(x)
        x = x.view(x.size(0), -1)
        x = self.fc(x)
        return x

model = SimpleModel()
dummy_input = torch.randn(1, 3, 224, 224)

# 定义动态轴
dynamic_axes = {
    'input': {0: 'batch_size', 2: 'height', 3: 'width'},
    'output': {0: 'batch_size'}
}

torch.onnx.export(
    model,
    dummy_input,
    'dynamic_model.onnx',
    input_names=['input'],
    output_names=['output'],
    dynamic_axes=dynamic_axes,
    opset_version=11
)

看到没?dynamic_axes 是一个字典。key 是张量的名字(对应 input_namesoutput_names),value 又是一个字典,key 是轴的索引,value 是符号维度的名字。

这里我把输入的第 0 轴(batch)命名为 "batch_size",第 2 轴(高)命名为 "height",第 3 轴(宽)命名为 "width"。输出只让 batch 轴动态。

我曾经踩过的坑:如果你在 dynamic_axes 里声明了某个轴是动态的,但模型内部有算子对这个轴做了硬编码(比如用了固定大小的 torch.onestorch.arange),导出时可能不会报错,但推理时就会出问题。所以导出后一定要用不同形状的输入测试一遍。

4.5 推理时怎么用?

导出 ONNX 后,用 ONNX Runtime 推理时,直接传入不同形状的输入就行:

import onnxruntime as ort
import numpy as np

sess = ort.InferenceSession('dynamic_model.onnx')

# 推理 batch=1
input1 = np.random.randn(1, 3, 224, 224).astype(np.float32)
output1 = sess.run(None, {'input': input1})

# 推理 batch=4
input2 = np.random.randn(4, 3, 224, 224).astype(np.float32)
output2 = sess.run(None, {'input': input2})

# 推理不同分辨率
input3 = np.random.randn(1, 3, 320, 240).astype(np.float32)
output3 = sess.run(None, {'input': input3})

print("全部成功!")

你想想看,如果没有动态形状,这三个推理请求你得导出三个不同的 ONNX 模型。有了动态轴,一个模型全搞定。

4.6 注意事项

  • opset 版本:动态形状支持从 opset 11 开始才比较完善。我建议至少用 opset 13,兼容性更好。
  • 算子兼容性:不是所有 ONNX 算子都支持动态形状。像 ResizeSliceGather 这些算子,如果输入形状是动态的,需要额外小心。
  • 性能影响:动态形状会降低推理引擎的优化空间。如果业务场景中输入形状变化不大,可以考虑用静态形状 + padding 的方式,性能会更好。
  • 调试技巧:导出后可以用 onnx.shape_inference.infer_shapes 检查形状推导是否正确。如果某个算子的输出形状变成了 unknown,那就要排查了。

我的建议:刚开始做动态形状时,先只让 batch 轴动态。等跑通了,再逐步放开空间轴。一口吃不成胖子,动态形状的坑不少,慢慢来。

好,这一节就到这里。符号维度和动态轴是 ONNX 动态形状的基石。理解了这两个概念,后面讲 shape inference 和算子兼容性时,你就不会懵了。