老陈开了一家“AI 私房菜”,后厨墙上贴着一张祖传魔法菜谱,名字叫 ONNX。菜谱上每一道菜都画着专用餐盘:接客的盘子标“1 人份”,装图的盘子标“224×224”,上菜的托盘标“1 层”。盘子尺寸全是固定数字。客人敢拿一张 512 宽的图片来,后厨直接摆手:您的菜盘子不对,做不了。 有一天,一位熟客下了一单特殊的需求:“我家吃饭人数不固定,照片尺寸也不固定,您能不能让餐盘自己伸缩?”老陈盯着菜谱琢磨半天,最后发现 ONNX 允许把某些尺寸数字换成“伸缩标签 X”——这就是动态轴(Dynamic Axes)。盘子只要贴着 X,就能根据客人的需求自动拉长缩短:人数变、宽高变、序列长度变,统统不在话下。 本文会从“固定餐盘”讲起,带你认识动态轴的本质、符号维度、形状推理,再讲怎样写导出配置、避开算子约束、优化推理性能,最后用一段真机代码演示完整的动态轴导出与推理。读完你就能让手里的模型像伸缩餐桌一样“因客而变”。
掌握动态轴,你就能把一张“死板”的模型菜单变成能接百样客人的“弹性菜单”,从此告别上线前最让人头痛的 shape 报错。
目录
可视化演示
动态展示ONNX动态轴的可变维度业务需求、dim_param定义与表示、形状推理传播机制及各类算子的动态轴兼容性(Elementwise 100%)。
本节配有交互动效(进入视口后自动加载,约 3KB)。
静态轴:固定尺寸的餐盘
老陈的菜谱最初像一张“填空题”:每个装食材的容器都写死了尺寸。你拿来的图片必须恰好装进 [1, 3, 224, 224] 的盘子,差一点都不行。这个 [1, 3, 224, 224] 就是张量的“形状”,而形状里的每个数字都代表一个轴的长度。
在 ONNX 世界里,数据以张量(Tensor)形式流动,模型的本质是一张计算图:节点是算子,边是张量。对于图片,常用 NCHW 布局表示:N 是图片张数(批次),C 是通道数,H 是高,W 是宽。模型导出时如果没有特别说明,这些维度就会按照参考样本的尺寸被写死。
ONNX 文件里的形状并不是“画”在盘上的花纹,而是存在结构化的字段中。图输入的描述体叫 ValueInfoProto,其中的 type -> tensor_type -> shape 记录着每个维度的具体数值,这个数值的载体就是 dim_value。序列化成 .onnx 文件后,这一串固定数字就成了模型不可退让的“规矩”。
固定餐盘的好处是规格统一、便于提前备菜;坏处是只要任何一个维度对不上,模型直接“拒单”。这也是静态轴在实际部署中最让人头疼的地方:上线前总会有用户传来各种尺寸的图,而模型只认那一套数字。
- shape 的含义$X$ 是任意一个张量,$\text{shape}(X)$ 返回该张量所有轴的长度所组成的数组,例如 $[1, 3, 224, 224]$。
- 每个轴的长度$d_i$ 是第 $i$ 个轴的长度,$n$ 是轴的数量(也叫张量的阶),每个 $d_i$ 都是正整数,等价于 ONNX 里的
dim_value。
动态轴:贴上伸缩标签的盘子
为了接住“人数不固定、尺寸不固定”的客人,老陈想出个办法:在餐盘上贴一张伸缩标签 X。客人说“我这桌 8 人”,标签 X 就变成 8;说“3 人”,X 就变成 3。这就是动态轴的直觉:把固定的数字,换成一个可被赋值的符号。
ONNX 的维度描述 Dimension 里除了 dim_value,还有一个字段叫 dim_param。dim_param 是一个字符串,比如 "batch"、"height"、"width"。当某个轴写成 dim_param 时,它就是动态的;也可以写成空字符串,表示“完全未知、完全动态”。动态轴的本质,就是“把形状里的某个数字替换成符号”。
要特别注意的是,动态轴并不是“随便动”。同一个符号如果在模型里出现多次,这些位置就必须取同一个值。比如输入图片的批次维度叫 "batch",输出张量的批次维度也叫 "batch",那么输入批次是 8 时,输出批次也必须是 8。这就像同一张伸缩桌在前后厅共用一个编号 X,前面的桌坐了几个人,后面上菜就必须按几个人准备。
有了伸缩标签,老陈的菜谱终于能接客。但这只是第一步:标签贴上去之后,后厨还得会算——不同客人点同一道菜,到底该上多少份、多大份?这就轮到形状推理登场了。
d_i \in \mathbb{Z}_{\ge 1} \cup \{\ast\}, \quadd_i^{(A)} = d_j^{(B)} \iff \operatorname{sym}_i^{(A)} = \operatorname{sym}_j^{(B)}- 维度的合法取值每个轴要么是一个固定的正整数,要么是星号(用 $\ast$ 表示“可伸缩”),这就是“固定数字”与“伸缩标签”两种状态的数学写法。
- 符号共享绑定如果张量 $A$ 的第 $i$ 维上的符号名与张量 $B$ 的第 $j$ 维上的符号名相同,那么这两个轴在推理时必须取相同数值。这就是 ONNX 用来保证“输入输出批次一致”的规则。
形状推理:后厨的流水线账本
客人坐下后,后厨要拿着伸缩标签推算整条流水线上每一道菜的量。ONNX 中也有同样的机制,叫形状推理(Shape Inference):给定输入张量的形状和算子属性,推导出输出张量的形状。形状推理让模型在只有符号维度时,依然能知道中间结果长什么样。
最典型的例子是卷积层。想象后厨有一块“配菜网格”,一个固定大小的网兜(卷积核)在网格上滑动,每走一步捞起一撮菜。输出网格的大小由输入网格大小、网兜大小、滑动步长和边缘填充共同决定。数学上就是经典的卷积输出尺寸公式:
更妙的是,这个公式完全兼容符号维度:如果输入 $H_{\text{in}}$ 是动态的 $\ast$,输出 $H_{\text{out}}$ 也会被推导成动态的 $\ast$ 或一个符号表达式。动态信息就像流水线上的传单,一张接一张往下传,中间结果的形状记录在 value_info 里,供优化器和调试工具查阅。
- 输入高度$H_{\text{in}}$ 是输入特征图的高度;$H_{\text{out}}$ 是输出特征图的高度。
- 填充与核大小$P$ 是边缘填充的层数,$K$ 是卷积核的大小。分子中的 $2P$ 表示上下各填充一层,$K$ 表示网兜本身占据的格子数。
- 步长与向下取整$S$ 是滑动步长,$\lfloor \cdot \rfloor$ 表示向下取整,因为网格不能切出半个格子;最后还要加 1,因为起点本身算一格。
导出配置:写菜单时标注伸缩盘
光会推理还不够,老陈得在写菜单时就告诉后厨:哪个盘子是伸缩盘、哪个维度允许伸缩。这个“写菜单”的动作,对应到工程里就是模型导出阶段的配置。PyTorch 训练好的模型默认只认固定输入,需要我们在导出时显式声明动态轴。
核心参数是 torch.onnx.export 里的 dynamic_axes。它接收一个字典,结构是 {张量名: {维度索引: 符号名}}。举个例子:
这段配置的意思是:input 的第 0 维绑定符号 batch,第 2、3 维绑定 height、width;output 的第 0 维也绑定 batch。由于输入输出共享同一个 batch 符号,运行时它们的批次一定一致。导出后,ONNX 文件里对应的 dim_value 会变成 dim_param。
导出配置是动态轴落地最关键的一步:菜单上没写清楚 X 在哪里,后厨就只能按固定盘子出菜。但只要这一步写对,模型文件里就会留下清晰的符号标记,供后续任何推理引擎读取。
- 固定形状$s_i$ 是导出参考输入(
dummy_input)的第 $i$ 个维度的长度,例如 $s_0 = 1$。 - 动态轴集合$\Delta$ 是我们要改成动态轴的维度索引集合,比如 $\Delta = \{0, 2, 3\}$ 表示 batch 和高宽都动态。
- 替换规则属于 $\Delta$ 的维度在模型中写成 $\ast$,不属于 $\Delta$ 的维度保留原来的固定数字。这就是“只在需要伸缩的位置贴标签”。
dynamic_axes = { "input": {0: "batch", 2: "height", 3: "width"}, "output": {0: "batch"}}torch.onnx.export(model, dummy, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes=dynamic_axes)
算子约束:固定管道的传菜口
盘子可以伸缩,但后厨的传菜管道不是所有位置都能伸缩。老陈发现,有些算子天生灵活,有些算子则要求某个维度必须固定。这也是动态轴最常见的“坑”:你给输入声明了动态宽高,结果中间某个全连接层直接报错。
卷积和池化是“灵活派”。它们对空间维度没有硬性长度要求,输出尺寸可以随输入尺寸变化,因此很适合配合动态 H/W。全连接层(Gemm / MatMul)则是“固定派”,因为矩阵乘法的本质要求两个矩阵的“接缝”维度完全对齐。这个接缝维度一旦写成动态,形状推理就无法确定结果形状,部署时很容易崩溃。
此外,Reshape 也需要小心。它把张量重新排列成新形状,但总元素个数必须不变;一个维度动态时,另一个维度常常必须写成 -1,让推理引擎自动计算。正确理解每个算子的“伸缩边界”,才能避免菜单上标了 X、实际上菜时却卡在传菜口的尴尬。
- 矩阵乘法$A$ 的形状是 $m \times p$,$B$ 的形状是 $p \times n$,结果 $C$ 的形状是 $m \times n$。
- 中间维度相等两个矩阵能够相乘的必要条件是 $A$ 的“宽”等于 $B$ 的“高”,即 $p$ 必须一致。这个 $p$ 就是传菜管道的接口直径,不能一个写 16、另一个写动态 $\ast$。
推理优化:厨房的排程烦恼
动态轴让模型接客更灵活,但老陈渐渐发现一个新问题:厨房每次遇到新人数,都得重新排桌子。固定 shape 时,推理引擎可以提前做好内存布局、算子融合、自动调优;可 shape 一变,之前那些优化成果可能全部作废,需要重新规划。
在 ONNX Runtime、TensorRT 等推理引擎中,固定 shape 往往能触发更深度的图优化:预分配内存、选择最优 kernel、展开循环。而动态 shape 会迫使引擎在运行时遇到新形状时重新做这些决策。第一次遇到 8 人桌时排一次,第一次遇到 3 人桌时又要排一次。
因此,理性做法是限制动态范围:如果能用“固定几个档位”解决,就尽量不要让 shape 无限变化。给伸缩桌装上限位器,既保住了灵活性,又避免了厨房反复重排。
- 形状种类数$M$ 是生产环境中出现的不同输入形状种类数,例如图片服务可能出现 224、416、512 三种尺寸。
- 优化时间$T_{\text{opt}}^{(k)}$ 是为第 $k$ 种新 shape 重新做图优化、内存规划和自动调优所花费的时间。
- 推理时间$T_{\text{run}}^{(k)}$ 是第 $k$ 种 shape 真正跑一次推理的时间。总耗时是“重新排桌”与“正式上菜”的叠加。
工程实践:有的放矢的伸缩桌
明白了动态轴的能力与代价,老陈开始给不同客人定制不同的伸缩方案。工程上最常见的三种动态轴需求是:动态批次、多分辨率输入和动态序列长度。
动态批次适合在线推理服务。请求往往零散到达,如果模型只支持 batch=1,GPU 算力浪费严重;如果固定 batch=16,又得等请求凑齐。把 batch 维度设为动态,系统就能按当前窗口内的请求数灵活打包,兼顾延迟和吞吐。多分辨率输入则适合目标检测与分割任务——图片来自不同设备,原图直接送入网络往往比强行 resize 更保真。NLP 模型的序列长度也一样:每个请求的 token 数天然不同,把序列维度做成动态,能省去大量 padding 带来的浪费。
工程实践的原则可以总结成一句话:动态轴是给部署松绑的工具,但不是越多越好。只让必要的轴动态,其余维度保持固定,才能同时拿到灵活性与性能。
- 请求总数与批大小$N$ 是待处理的请求总数,$B$ 是每个批次包含的样本数。二者单位都是“个”,所以相除结果无量纲。
- 向上取整$\lceil \cdot \rceil$ 向上取整,因为即使最后一批不满,也要完整跑一次推理。$M$ 就是总共要跑的批次数。
- 总耗时$t(B)$ 是每批推理耗时。增大 $B$ 时,$M$ 变小,虽然单批时间略增,但总吞吐通常会更好。这就是动态批次在工程上“凑满座位”的价值。
问题排查:对不上号的传菜口
动态轴真正上线后,最常见的报错不再是“模型不会推理”,而是“传菜口对不上号”。你可能收到类似这样的报错:Actual input shape [4,3,512,512] differs from expected [1,3,224,224]。这通常说明模型根本没有被导出成动态轴,或者某个维度的符号没有绑定成功。
排查分三步走。第一,用 onnx.load 读取模型,打印 graph.input 里每个 ValueInfoProto 的 shape 字段,看 dim 是 dim_value(固定数字)还是 dim_param(符号)。第二,用 Netron 打开模型,可视化地检查各条边上的 shape 标注,找到“哪根线断了”。第三,准备多种形状的测试输入,分别用 onnxruntime 跑一遍,观察是哪一种 shape 触发异常。如果问题出在算子内部,还要检查是否满足广播条件。
排查动态轴问题,本质上是在确认“伸缩标签是否真正传递到了每一个需要的节点”。标签断了,模型就退回静态;标签通了,再大的盘子也能稳稳接住。
- 逐维检查$d_k^{(A)}$ 表示张量 $A$ 在第 $k$ 个轴的长度,$d_k^{(B)}$ 同理。广播要求每个维度都满足条件。
- 相等或为 1两个张量能直接运算的充分条件是该维长度相等;若某一方为 1,则可以自动扩展成另一方长度。这就是“传菜口尺寸要么相同、要么有一边是万能接口”的数学表达。
AI 应用场景
在线图像检测:任意尺寸图片直接推理
阶段:模型部署与推理服务
解决的问题:传统目标检测服务要求输入固定尺寸(如 608×608),用户上传的监控截图、手机照片比例各异。强行 resize 会压缩小目标,导致漏检;用动态轴让宽高维度可变化,模型可以直接接收原始图像分辨率。
具体案例:某智慧安防平台上线车牌识别模型,输入尺寸从 640×640 扩展到动态 H/W 后,同一模型同时服务抓拍机、手持设备和高空相机,省去了多份固定尺寸模型副本的维护成本。
大语言模型服务:动态序列长度
阶段:模型推理与生成
解决的问题:对话场景中每个用户的 prompt token 数不同,固定长度意味着要么大量 padding 浪费算力,要么截断丢失信息。把序列长度设为动态轴后,模型按实际 token 数计算,显著降低无效计算。
具体案例:某企业私有化 LLM 服务把 sequence_length 作为动态轴,长文档问答的平均首 token 延迟下降约 40%,同时避免了超长输入被暴力截断的问题。
推荐系统召回:动态 Batch 打包
阶段:在线特征计算与模型推理
解决的问题:推荐服务流量有高峰低谷,固定 batch 要么延迟高、要么 GPU 利用率低。动态 batch 轴让推理服务根据当前排队请求数自动凑批,在延迟边界内最大化吞吐。
具体案例:某电商推荐服务将召回模型导出为动态 batch 的 ONNX 模型,配合请求队列调度,在双 11 高峰 GPU 利用率从 42% 提升到 76%,P99 延迟保持稳定。
Python 演示
import numpy as npimport onnximport onnxruntime as ortimport torchimport torch.nn as nn class FlexibleNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 8, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(8, 16, kernel_size=3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d((4, 4)) ) self.head = nn.Linear(16 * 4 * 4, 10) def forward(self, x): x = self.features(x) x = x.flatten(1) return self.head(x) model = FlexibleNet().eval()dummy = torch.randn(2, 3, 32, 32)dynamic_axes = { "input": {0: "batch", 2: "height", 3: "width"}, "output": {0: "batch"}}onnx_path = "flexible.onnx"torch.onnx.export( model, dummy, onnx_path, input_names=["input"], output_names=["output"], dynamic_axes=dynamic_axes, opset_version=17) onnx.checker.check_model(onnx_path)loaded = onnx.load(onnx_path)g = loaded.graphfor vi in list(g.input) + list(g.output): dims = [d.dim_param if d.dim_param else d.dim_value for d in vi.type.tensor_type.shape.dim] print(vi.name, dims) session = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])for b, h, w in [(1, 16, 16), (4, 64, 64), (7, 48, 80)]: x = np.random.randn(b, 3, h, w).astype(np.float32) y = session.run(["output"], {"input": x})[0] print("input", x.shape, "->", "output", y.shape)
dynamic_axes = { "input": {0: "batch", 2: "height", 3: "width"}, ... }声明输入张量的批次、高、宽三个维度都是伸缩标签,输出批次也绑定同一个batch符号。torch.onnx.export(model, dummy, onnx_path, ..., dynamic_axes=dynamic_axes)执行导出,动态轴配置会写入 ONNX 文件,替代默认的固定形状。onnx.checker.check_model(onnx_path)校验导出文件的语法和结构,相当于检查菜单格式是否合规。d.dim_param if d.dim_param else d.dim_value逐维度打印形状信息:有符号名打印符号,否则打印固定值,用于确认动态轴是否真正写入。session.run(["output"], {"input": x})用不同的(batch, height, width)组合驱动同一个模型,验证动态轴确实生效。
常见误区
误区我把 `dynamic_axes` 写上之后,模型就百无禁忌了,任何尺寸都能高效跑,不需要再做任何处理。
正解动态轴解决的是“维度不固定”的合法性问题,不代表每个算子都支持任意形状。全连接层、Reshape、部分自定义算子仍有内部约束;而且新 shape 首次出现时往往有额外的优化开销。动态轴更像是给了盘子伸缩能力,但传菜管道的接口仍然是固定的。
为何常见:初学者看到「动态」二字,容易联想到“万能”,忽略了 ONNX 图中每个算子都有各自的形状约束。
误区在 ONNX 文件里,只要把 `dim_value` 改得大一点,比如从 224 改成 512,就得到动态轴了。
正解dim_value 表示固定数值,改大只是把固定值从 224 变成 512,模型依然只认 512。动态轴必须使用 dim_param(符号名)或将维度留空,让形状在推理时按实际输入决定。
为何常见:人们容易从“改数字”的角度理解配置,而动态轴的本质是“把数字换成符号”,这是两种完全不同的机制。
误区输入 tensor 的第 0 维叫 `"batch"`,输出 tensor 的第 0 维也叫 `"batch"`,那我输入 8 张图,输出是不是可以随意返回 16 条结果?
正解同一个符号名在 ONNX 表示“同一个伸缩标签”,所有挂着这个标签的维度在任何一次推理中都必须取同一个数值。想让输出形状与输入不同,就不要给它们绑定相同的符号名。
为何常见:大家习惯把名字当成普通字符串,忽略了 ONNX 中符号名本身就是“数值一致性约束”的载体。
误区既然动态轴这么好用,那我把所有维度都设成动态,这样以后再也不怕形状变化了。
正解动态轴需要在推理时处理可变的内存布局、可能在每次新 shape 到来时重新做图优化与 kernel 选择,GPU 场景尤其明显。工程上通常只让真正需要变化的轴动态,其余维度全部固定。
为何常见:教程里往往强调动态轴带来的灵活性,却很少画等号讲清“灵活也是要买单的”,导致初学者默认零成本。
误区模型一旦导出成 ONNX,动态轴就只能重训重导,代码丢了就等于判死刑。
正解ONNX 是开放格式,可以用 onnx 库直接修改 ValueInfoProto 里的 dim 字段,例如把 dim_value 改成 dim_param,再 onnx.save 写回。虽然手工改图需要谨慎,但绝不是“一锤定音”。
为何常见:多数人只熟悉 PyTorch 导出流程,不了解 ONNX 本身的图结构编辑能力,从而误以为导出即不可逆。
面试题
Q1. ONNX 动态轴的根本作用是什么?
- A.修改模型权重数值
- B.让输入维度灵活可变← 正确答案
- C.动态增减神经网络层
- D.自动选择推理后端
答案:B。动态轴是把形状中的固定数字换成符号(如 dim_param),解决的是“输入的 shape 维度不固定”的问题。它既不修改权重数值,也不改变网络层数,更不会自动切换推理后端。
Q2. torch.onnx.export 中 dynamic_axes={"input": {0: "batch"}} 表示什么?
- A.把第 0 维固定为 1
- B.把第 0 维绑定为 batch 符号← 正确答案
- C.删除输入的第 0 个维度
- D.把输出第 0 维改为 batch
答案:B。该写法将名为 input 的张量的第 0 维绑定为符号 batch,即“这个轴可以伸缩”。它不会固定、删除第 0 维,也不会影响输出张量的配置,除非你额外指定 output 的 dynamic_axes。
Q3. 张量形状 [1, 3, 224, 224] 中第 0 维通常表示什么?
- A.通道数
- B.批大小← 正确答案
- C.图像高度
- D.图像宽度
答案:B。NCHW 布局中第 0 维 N 是批大小(batch size)。第 1 维是通道数 C,第 2、3 维分别是高 H 和宽 W。
Q4. 关于 ONNX 符号维度,下列说法正确的有哪些?
- A.dim_param 可以为维度起符号名← 正确答案
- B.dim_value 表示可变符号
- C.同名符号维度必须取同一数值← 正确答案
- D.符号名只能写一个字母
答案:AC。dim_param 是符号名,dim_value 是固定数值。图中所有同名符号共享同一个“伸缩标签”,因此数值必须一致。符号名可以是任意字符串,不限于单字母。
Q5. 输入张量 batch 维标为 "batch",输出张量 batch 维也标为 "batch",输入 batch=8 时输出 batch 是多少?
- A.1
- B.4
- C.8← 正确答案
- D.不确定
答案:C。同名符号在 ONNX 中是等价的伸缩标签,一次推理中标签被赋成 8,所有挂着该标签的维度都会拿到 8。这是动态轴保证输入输出一致性最重要的机制。
Q6. 动态 shape 在 GPU 推理时可能额外付出什么代价?
- A.每次新 shape 重排图优化← 正确答案
- B.每个 batch 都降低模型精度
- C.权重自动变大变小
- D.输入必须全部 pad 到定长
答案:A。GPU 推理常提前做内存布局、算子融合和 kernel 自动调优。shape 变化后这些成果可能失效,需要重新优化。动态轴不改变模型精度,不改变权重,也不强制 pad 到定长。
Q7. 卷积输出高公式 H_out = floor((H_in + 2P - K) / S) + 1,若 H_in=32, P=1, K=3, S=2,则 H_out 是多少?
- A.14
- B.15
- C.16← 正确答案
- D.17
答案:C。代入公式得 (32 + 2 - 3) / 2 = 15.5,向下取整为 15,再加 1 得 16。这个计算在形状推理中频繁出现,也决定了动态空间维度的传播方式。
Q8. 全连接层(矩阵乘)对维度匹配的要求是什么?
- A.中间维度必须相等← 正确答案
- B.批次维度必须为 1
- C.通道维必须为 3
- D.所有维度都要动态
答案:A。矩阵乘法 A(m×p) × B(p×n) 要求 A 的列数与 B 的行数相等,即中间维度 p 必须相等。这也是全连接层对动态轴最不友好、最容易报错的位置。
Q9. 动态轴模型常用的排查手段有哪些?
- A.Netron 查看输入输出 shape← 正确答案
- B.打印图里的 value_info← 正确答案
- C.多种 shape 跑同一个模型← 正确答案
- D.修改训练时的损失函数
答案:ABC。Netron 可以可视化检查每条边的 shape 标注;打印 graph.input 的 dim 能确认是固定值还是符号;用多种 shape 实测能定位触发报错的具体维度。动态轴问题通常与训练损失函数无关。
Q10. 动态轴带来的主要性能风险包括哪些?
- A.内存布局无法提前固定← 正确答案
- B.算子融合要重新决策← 正确答案
- C.可能引起重复自动调优← 正确答案
- D.模型权重精度必然下降
答案:ABC。固定 shape 可以提前分配内存、预选 kernel 和静态融合算子;动态 shape 让这些优化无法一次到位,需要在新形状出现时重做。动态轴不会导致权重精度下降,精度问题应由量化或数值稳定性负责。