3、静态形状与动态形状:什么是静态形状、什么是动态形状、为什么需要动态形状

好,咱们今天聊一个非常核心的概念——静态形状和动态形状。

说实话,我刚开始接触ONNX时,也被这两个词搞得有点晕。明明都是张量,怎么还分静态动态?后来踩了几个坑才明白,这其实是模型部署中最容易忽略、也最容易出问题的地方。

3.1 什么是静态形状

静态形状,说白了就是——模型在导出时,输入输出的尺寸就固定死了

举个例子。你训练了一个分类模型,输入图片是224x224。导出ONNX时,输入张量的形状就是 [1, 3, 224, 224]。这个形状里,每个维度都是确定的数字。1是batch size,3是通道数,224是宽和高。

一旦固定,就不能改了。你传一张448x448的图进去,模型直接报错。你传一张128x128的图,也不行。

静态形状的特点:
  • 所有维度都是具体数值,没有-1或None
  • 推理时输入必须严格匹配这个形状
  • 内存分配在模型加载时就确定了,效率高
  • 适合生产环境,因为行为可预测

我在项目中遇到过这样一个场景:一个OCR模型,导出时固定了输入尺寸为640x640。结果上线后发现,用户上传的图片五花八门,有横屏的、有竖屏的、有长条的。每次都要先resize到640x640,导致文字被压扁,识别率直线下降。这就是静态形状的局限性。

3.2 什么是动态形状

动态形状就灵活多了。它允许某些维度在推理时变化。

还是那个分类模型。如果导出时把batch size设为动态,输入形状就是 [-1, 3, 224, 224]。这里的-1表示「这个维度可以变」。你可以一次传1张图,也可以一次传32张图,模型都能处理。

更灵活一点,连宽高也可以动态。比如 [-1, 3, -1, -1],这样任意尺寸的图片都能输入。当然,这需要模型本身支持可变尺寸,比如全卷积网络。

我个人习惯:在ONNX中,动态维度用-1"batch"这样的字符串来表示。不同框架写法略有不同,但意思一样——这个维度是可变的。

你想想看,动态形状就像是一个「可伸缩的容器」。你塞多少东西进去,它都能装下。而静态形状是一个「固定大小的盒子」,只能装固定数量的东西。

3.3 为什么需要动态形状

这个问题其实很实际。我总结了三个核心原因:

  1. 业务场景多变——用户上传的图片尺寸不可能统一。你让用户先resize再上传?体验太差了。
  2. batch size不确定——线上服务的并发量是波动的。高峰期可能一次处理64张图,低峰期可能只有1张。静态形状只能固定一个batch size,要么浪费资源,要么处理不过来。
  3. 模型本身需要可变输入——比如目标检测模型,输入图片的原始尺寸就是不一样的。强行resize会丢失信息,影响检测精度。

我记得有一次做视频分析项目。模型输入是视频帧序列,每段视频的帧数都不一样。如果用静态形状,只能把所有视频都补齐到最大帧数,结果内存爆了。后来改成动态形状,按实际帧数处理,问题就解决了。

注意:动态形状不是万能的。它有两个代价:
  • 推理引擎需要做更多的内存管理,性能可能下降10%-20%
  • 某些算子不支持动态形状,导出时可能会报错
所以我的建议是:能用静态就用静态,需要动态才用动态

3.4 静态 vs 动态:一张表说清楚

对比维度 静态形状 动态形状
形状定义 所有维度固定,如[1,3,224,224] 部分维度可变,如[-1,3,-1,-1]
推理性能 高,内存预分配 略低,需要动态分配
灵活性 低,输入必须匹配 高,支持多种输入
部署难度 简单,几乎无坑 中等,需处理边界情况
适用场景 生产环境、固定输入 多变输入、研究原型

嗯,这里要注意一点。很多初学者觉得「动态形状更高级,所以应该都用动态」。其实不是的。我曾经在一个推理服务里全部用了动态形状,结果上线后QPS上不去,排查了半天才发现是动态内存分配拖了后腿。后来改成静态形状,性能直接翻倍。

3.5 代码示例:看看形状长什么样

咱们用Python代码直观感受一下。假设你用PyTorch导出一个ONNX模型:

import torch
import torch.onnx

class SimpleModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = torch.nn.Conv2d(3, 16, 3)
    
    def forward(self, x):
        return self.conv(x)

model = SimpleModel()

# 静态形状导出
dummy_input = torch.randn(1, 3, 224, 224)
torch.onnx.export(model, dummy_input, "static_model.onnx",
                  input_names=["input"],
                  output_names=["output"],
                  dynamic_axes=None)  # 不设置动态轴

# 动态形状导出(batch和宽高都动态)
torch.onnx.export(model, dummy_input, "dynamic_model.onnx",
                  input_names=["input"],
                  output_names=["output"],
                  dynamic_axes={
                      "input": {0: "batch_size", 2: "height", 3: "width"},
                      "output": {0: "batch_size", 2: "height", 3: "width"}
                  })

你看,区别就在 dynamic_axes 这个参数。不设置就是静态,设置了就是动态。就这么简单。

避坑指南:我曾经在导出时只设置了batch为动态,忘了设置宽高。结果推理时传了不同尺寸的图,模型直接崩了。所以,要动态就动态彻底,别留一半

3.6 什么时候该用哪种?

我个人的经验是这样的:

  • 纯分类模型——用静态形状。输入尺寸固定,batch size也固定,性能最好。
  • 目标检测/分割模型——用动态形状。因为输入图片尺寸多变,而且模型本身通常支持可变输入。
  • NLP模型——用动态形状。句子长度不可能一样,padding太多会浪费算力。
  • 视频处理模型——用动态形状。帧数不确定,batch size也不确定。

说白了,就是看你的输入「变不变」。变就动态,不变就静态。别为了炫技而用动态,也别为了省事而用静态。

好,这一节就到这里。下一节咱们聊聊如何在ONNX中具体配置动态形状,包括不同框架的导出技巧。到时候我会分享一些我踩过的坑,保证让你少走弯路。