ONNX动态输入输出与动态Shape部署:导出、推理与量化避坑

发布时间:2026/9/18 10:02:23
ONNX动态输入输出与动态Shape部署:导出、推理与量化避坑 1. ONNX 动态输入和动态输出到底在跟什么较劲ONNX 动态输入和动态输出这两个词放在实际项目里说的其实是同一件事的两面模型图里那些原本被写死的维度怎么让它随输入变化以及变化之后下游怎么接住。很多人的第一次翻车发生在导出那一刻——torch.onnx.export跑完没报错onnx.checker也过了结果一上 onnxruntime 推理换个分辨率就抛出 shape mismatch或者是模型能跑但每秒的吞吐从 400 掉到 90日志里什么都没说。这类问题的根子几乎都能追到那张跟着模型一起被冻住的维度表上。这篇内容我想聊的是把 ONNX 动态维度这件事从头到尾捋一遍导出阶段怎么把动态轴写进去、推理阶段 onnxruntime 怎么理解并使用这些符号、动态输出为什么比动态输入更难缠、以及动态 shape 一路走到 int8 量化、TensorRT、RKNN 这些部署环节时会撞上什么墙。写这篇的起因是我最近在做一套变分辨率 OCR 检测链路顺手也帮人看过几个 java onnxruntime 车牌识别和 RMBG-2.0 人物抠图的集成问题发现坑的分布相当集中来来回回就那么几类。不管你是刚接触 onnx 模型是什么的新手还是已经在调 TensorRT optimization profile 的老手应该都能从下面找到一点能直接抄的东西。1.1 静态 shape 是怎么把你逼到墙角的ONNX 这层格式最核心的东西其实不是算子列表而是跟图一起走的维度信息。你打开一个.onnx结构上是graph.input、graph.output、graph.node、graph.initializer这几块每个输入输出都是带type的ValueInfoPrototype里面最关键的就是shape——它决定了这个张量是几维、每一维多大。静态导出的模型这里的每一维都是一个具体数字比如images: [1, 3, 640, 640]。这在单场景 demo 里完全没问题甚至更好因为推理引擎可以针对这个尺寸做算法搜索、内存预分配、算子融合。但只要业务需求稍微往真实场景靠一点它就开始扎人你训练时用的 640×640上线后摄像头是 1920×1080你要么在预处理时强行缩放到 640要么就得重新导模型。前者损失精度后者是维护噩梦。更难受的是批量维度。服务化部署时你希望 batch 能按请求量弹性调整1 到 16 都支持静态 shape 下你就得导出 16 个模型或者统一用 batch1 把吞吐打骨折。还有个隐蔽的场景变长序列模型输入长度取决于文本长度静态 shape 直接判死刑。我见过最离谱的一种情况是有人为了绕开动态 shape把输入 pad 到固定的最大尺寸比如不管实际图片多大都 pad 到 1280×1280 再送进去。结果模型推理时间没变短因为卷积量由 pad 后尺寸决定预处理反而多花了 30% 的时间整体端到端延迟比原来还高。这就是典型的没想清楚动态到底省在哪儿。1.2 符号维度ONNX 里动态是怎么表示的ONNX 表达动态维度的方式其实很朴素一个维度要么是dim_value具体数字比如 3、640要么是dim_param字符串符号名比如batch、height两者都没有就是未知。你在 Netron 里看到的?或者一个英文单词就是这两种情况的可视化。关键在于dim_param的语义同一个图里同名符号代表相等约束。这一条是理解动态 shape 全部行为的钥匙。假设输入是[batch, 3, height, width]输出是[batch, 1000]那么 onnxruntime 在运行时就会校验你传进来的第一维尺寸和输出第一维必须一致如果模型内部某个 Reshape 节点引用了batch它也会按实际值去算。这个机制带来两个实际后果。第一符号名不能乱起你把输入叫N、输出叫batch运行时引擎会当成两个独立的动态维度虽然数值上碰巧一致也不影响结果但会白白丢掉一批基于相等关系做的优化。第二符号名的作用域是整个图的中间张量的形状推导会依赖它有些算子尤其是 Reshape、Resize、Tile、Expand能不能在动态图里正确推导出形状完全取决于符号链路有没有断。-1是很多人第一次接触动态导出时会写的值。要提醒一句-1是 PyTorch 那套 tensor shape 里的语义ONNX 格式本身并没有规定-1代表动态。老版本导出器遇到-1有时会生成一个dim_value0的维度也就是未设置这在某些引擎里会被解释成 0 尺寸而不是动态尺寸直接导致推理崩溃。现在的导出器基本都要求你显式给出符号名这个坑算是填上了但看到-1还是应该警惕。1.3 哪些场景必须上动态哪些其实可以硬扛不是所有模型都值得开动态。我自己的判断标准是看输入尺寸的方差和性能损失的容忍度。必须上动态的典型场景变分辨率图像检测与分割PP-OCRv6 的检测分支、RMBG-2.0 这类抠图模型、变长文本或语音序列、需要弹性 batch 的在线服务、以及输入尺寸由上游模型决定的级联链路。这些场景下动态不是优化是刚需。可以硬扛的场景固定输入尺寸的分类模型224×224 的 backbone、尺寸范围极窄的工业质检相机和工位都是固定的、以及延迟极度敏感、宁可牺牲灵活性的推理节点。这类场景固定 shape 反而更好因为你能吃满引擎的图优化和算子调优。中间地带最麻烦比如批量可变但空间尺寸固定的检测模型。这种情况我的建议是只把 batch 轴设为动态H/W 保持不变。收益是显存和吞吐可以弹性伸缩代价是几乎为零因为卷积核的算法搜索仍然能在固定 H/W 上完成。把空间维度也放开收益不确定代价却很明确。注意动态维度不是越多越好。每多一个符号维度推理引擎能做的图优化就少一层第一次推理的冷启动时间也会变长。具体到 onnxruntime CUDA EP动态 shape 会让 cuDNN 的卷积算法搜索从一次性变成按 shape 反复触发。到这里问题的性质就清楚了动态输入和动态输出不是两个独立话题而是同一条符号约束链的两端。输入端写进去的符号会一路传导到输出中间任何一个算子处理不好链条就断了。下一节说导出那才是决定后面顺不顺的地方。2. 导出阶段动态轴到底怎么写进去2.1 torch.onnx.export 的 dynamic_axes 实操PyTorch 生态里最主流的还是torch.onnx.export不管你是想导出 yolo12 还是自己训练的 UNet用法都差不多。核心参数就四个input_names、output_names、dynamic_axes、opset_version。dynamic_axes的格式是以张量名为键、以维度索引 → 符号名的字典为值import torch model.eval() dummy torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy, detector.onnx, input_names[images], output_names[preds], dynamic_axes{ images: {0: batch, 2: height, 3: width}, preds: {0: batch}, }, opset_version17, do_constant_foldingTrue, )几个细节值得说清楚。dummy的尺寸只决定导出时的图结构不影响动态性但它会参与常量折叠所以不要随手写个torch.randn(1,3,32,32)某些依赖尺寸的分支比如自适应池化的输出尺寸计算会被固化。用你线上最常见的尺寸做 dummy 比较稳。input_names和output_names建议显式指定不然会生成input.1、output.1这种名字后面在 C 或 Java 里按名字取张量时会很难受。opset_version别用默认值PyTorch 各版本默认值不一样跨环境复现时容易出问题。17 是目前兼容性和算子支持都比较均衡的选择再往上到 18、19部分推理引擎尤其是老的 TensorRT 版本和某些 NPU 工具链还没吃透。还有一个反直觉的点dynamic_axes里没列的维度导出后就是静态数字。很多人以为只要在dynamic_axes里写了张量名就算开了动态其实还得明确到维度索引。写{images: {}}等于没写。2.2 torch 2.x 的 dynamo 导出与 Dim 约束PyTorch 2.5 之后默认走 dynamo 导出路径参数名从dynamic_axes换成了dynamic_shapes语义也更强——它不只是标符号还能声明取值范围from torch.export import Dim batch Dim(batch, min1, max32) height Dim(height, min320, max1280) torch.onnx.export( model, (dummy,), detector.onnx, input_names[images], output_names[preds], dynamic_shapes{images: {0: batch, 2: height, 3: height}}, opset_version18, dynamoTrue, )Dim的min/max不是装饰它会变成导出时的形状约束dynamo 在追踪阶段会做区间分析。好处是导出阶段就能发现这个模型其实不支持这么宽的尺寸范围坏处是某些含数据依赖控制流的模型比如带动态 NMS 的检测头会在min和max两个端点上分别追踪报出一堆看起来莫名其妙的约束冲突。如果你在 dynamo 导出时看到Constraints violated之类的报错第一反应应该是把min/max范围收窄而不是直接放弃动态。实测下来绝大多数报错都来自范围开得太大而不是模型真不支持。至于用不用 dynamo我的建议是新项目直接用毕竟它是官方主推方向存量项目如果现有导出脚本跑得好好的没必要为了追新去改除非你遇到 trace 模式下控制流被固化的问题——那才是 dynamo 真正能救命的场景。2.3 导出完先自检别急着上推理导出成功后立刻做三件事能省掉后面 80% 的返工。第一件检查输入输出的符号有没有真的写进去import onnx m onnx.load(detector.onnx) def describe(t): dims [] for d in t.type.tensor_type.shape.dim: dims.append(d.dim_param if d.HasField(dim_param) else d.dim_value) return dims for v in m.graph.input: print(input :, v.name, describe(v)) for v in m.graph.output: print(output:, v.name, describe(v))输出应该是input : images [batch, 3, height, width]。如果某一维打印出0说明它既没有dim_param也没有dim_value是个未知维度这种状态在某些引擎里会被当成 0 处理必须修。第二件跑形状推导把中间张量的符号补全from onnx import shape_inference m shape_inference.infer_shapes(m) onnx.save(m, detector_inferred.onnx)形状推导能暴露很多隐藏问题。比如某个 Reshape 节点的目标 shape 里出现了两个不同的符号名那说明符号链路在中间断了运行时多半会报静态校验失败。第三件用 onnxruntime 真实跑一次不同尺寸import numpy as np, onnxruntime as ort sess ort.InferenceSession(detector.onnx, providers[CPUExecutionProvider]) for hw in [(640, 640), (320, 448), (1280, 736)]: x np.random.randn(1, 3, *hw).astype(np.float32) out sess.run(None, {images: x}) print(hw, [o.shape for o in out])这一步的意义在于它验证的是符号约束在运行时能不能自洽而不仅仅是图结构对不对。CPU EP 就够了不需要上 GPU跑通一次几秒钟的事。2.4 导出环节最容易踩的四个坑第一个坑是torch.jit.trace的静态固化。trace 是按一次前向执行记录算子序列的任何依赖张量实际数值或形状的 Python 控制流if x.shape[0] 1:、len(some_list)这类都会被固化成当次的分支。你 dummy 用的是 batch1导出的图里就永远只有 batch1 的那条路径。解决办法是要么用torch.jit.script要么上 dynamo要么把控制流改写成不依赖具体值的张量操作。第二个坑是int(x.shape[0])和x.shape[0]混用。前者把符号维度强制转成了 Python 整数等于当场把动态性掐死后者如果是直接参与张量运算dynamo 能识别成符号。写模型时要有意识地避免在 forward 里做这种转换。第三个坑是 Resize 算子的形状传递方式。ONNX 的Resize支持两种模式scales缩放系数和sizes目标尺寸。用scales时输出尺寸由输入尺寸乘系数得出符号链路能自然延续用sizes时如果 sizes 是一个常量张量那输出就被钉死了。做变分辨率模型时尽量让sizes从输入Shape推导出来而不是写死。第四个坑在量化模型上。.onnx 量化 int8在实际项目里越来越常见但量化后再导出动态轴往往出问题——QDQ 节点插入位置对符号维度的传播很敏感有些量化工具会把dynamic_axes信息丢掉。我的做法是先在 fp32 上把动态导出调通再做量化量化时用同一套动态配置出问题就退回 fp32 定位。反过来做你根本分不清是量化的问题还是动态的问题。3. 推理阶段动态输入好办动态输出才是硬骨头3.1 动态输出的两种类型难度完全不同动态输入虽然麻烦但至少有一件事是确定的你知道会传进去什么。动态输出不一样它有两种情况处理难度差了整整一个量级。第一种是形状规则、维度符号化。比如输入[batch, 3, h, w]输出[batch, 1000]batch 是动态的但输出形状的规则完全由输入推导出来。这类输出处理起来毫无压力sess.run返回的 numpy 数组形状就是对的你按out.shape[0]循环就行。第二种是输出数量不定。典型代表是检测模型的 NMS 后输出[1, N, 6]——N 是这一帧检测到的目标数量每帧都不一样。yolo12 导出成端到端模型带 NMS之后就是这个形态。再比如 OCR 检测后接的文本框数量、分割模型输出的连通域个数都属于这一类。这类输出的麻烦在于它不只是形状是符号而是形状由数据内容决定。符号在运行时被绑定成实际值模型内部一定存在某个数据依赖的算子NonMaxSuppression、TopK、Where 配合 ReduceSum在决定它。这些算子在很多加速引擎上支持得都不好——TensorRT 从 8.x 开始才比较完整地支持 NMSRKNN 上基本要靠 CPU 兜底。RMBG-2.0 这类人物抠图模型属于第一种里的变体输出是[batch, 1, h, w]的 alpha mask形状跟着输入走但内容上是连续概率图。它的动态输出问题不在形状而在于输出尺寸必须和原图严格对齐中间任何一次 Resize 的取整误差都会让边缘出现一圈半透明杂边。处理这种模型时动态输出其实是个精度问题不是形状问题。3.2 onnxruntime 的会话配置与会话级覆盖onnxruntime 处理动态维度有个很有用的机制free dimension override也就是在创建会话时把符号维度强行绑成固定值。这在两个场景下特别值钱一是你的推理引擎后端比如某些老版本的 TensorRT EP不支持动态但你又不想重新导模型二是你想给动态模型做一轮基准测试跑固定 shape 的性能数据。import onnxruntime as ort so ort.SessionOptions() so.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL so.add_free_dimension_override_by_name(height, 640) so.add_free_dimension_override_by_name(width, 640) sess ort.InferenceSession( detector.onnx, so, providers[CUDAExecutionProvider, CPUExecutionProvider], )注意这里用的是by_name参数是你在导出时写的那个符号名不是维度索引。还有一个add_free_dimension_override是按dim_denotation维度语义标签比如DATA_BATCH来匹配的但绝大多数导出流程不会写 denotation所以我一般只用by_name。如果by_name写了但没生效八成是符号名拼错了。用前面的describe函数把符号名原样打出来照着写别凭记忆。另一个值得关注的配置是graph_optimization_level。动态 shape 下ORT 会跳过一部分需要静态尺寸才能做的优化但常量折叠、算子融合这些仍然有效所以还是开到ORT_ENABLE_ALL。真正需要小心的是并行执行和内存 arena动态 shape 会让内存分配器的 arena 随遇到的最大尺寸增长而且默认不回收长跑服务里这就是内存缓慢上涨的来源。可以关掉 arena 或者设一个上限牺牲一点分配性能换内存稳定。注意add_free_dimension_override_by_name只在创建会话时生效运行中改不了。如果你的服务需要同时支持多种固定尺寸正确做法是为每档尺寸各建一个 InferenceSession别想着一个会话通吃。3.3 Java 侧调用动态模型几个必须知道的点Java 生态用 onnxruntime 越来越常见尤其是车牌识别、票据 OCR、人物抠图这类需要在后端服务里直接出结果的场景。RMBG-2.0 这种模型用 Java 集成的话输出的 alpha mask 要靠后端合成整个过程都在 JVM 里完成。Java API 和 Python API 结构高度对应但有几处必须注意的差异OrtEnvironment env OrtEnvironment.getEnvironment(); OrtSession.SessionOptions opts new OrtSession.SessionOptions(); opts.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); opts.addFreeDimensionOverrideByName(height, 1024); opts.addFreeDimensionOverrideByName(width, 1024); try (OrtSession session env.createSession(rmbg.onnx, opts); OnnxTensor input OnnxTensor.createTensor( env, FloatBuffer.wrap(pixelData), new long[]{1, 3, 1024, 1024}); OrtSession.Result result session.run(Collections.singletonMap(input, input))) { OnnxTensor out (OnnxTensor) result.get(0); long[] shape out.getInfo().getShape(); // 运行时真实形状 float[][][][] data (float[][][][]) out.getValue(); }几个坑逐个说。addFreeDimensionOverrideByName在 Java 里名字和 Python 基本一致但它要求会话在createSession之前完成配置顺序错了静默失效。OnnxTensor.createTensor传FloatBuffer时buffer 的剩余容量必须和 shape 的乘积完全相等少一个元素就抛异常多一个也不接受。这个异常信息通常只告诉你容量不匹配不告诉你期望多少需要自己算1*3*1024*1024。最要命的是资源释放。Java 这边OrtSession、OnnxTensor、OrtSession.Result都实现了AutoCloseable但它们是原生内存不走 JVM 堆。你如果不写 try-with-resourcesGC 完全看不到这部分压力结果就是进程内存一路涨到被系统 kill堆 dump 出来干干净净什么都没有。我见过一个服务因为这个跑了三天才崩排查花了整整一周。还有一个多维数组强转的问题。out.getValue()返回的是嵌套数组实际维度和运行时形状一致。如果你的输出是动态的[1, N, 6]强转float[][][]在 N 变化的场景下没问题因为外层维度固定但如果你误以为是float[][]就是ClassCastException。稳妥做法是先读getInfo().getShape()按 rank 分支处理别硬编码类型。3.4 定长输出与不定长输出的工程处理套路动态输出落到工程上最终都要变成能被下游消费的结构化数据。定长输出好办按 shape 循环切就行。不定长输出有一套比较通用的套路对于检测/NMS 类输出[1, N, 6]每行是x1, y1, x2, y2, score, class_id标准处理是先取N out.shape[1]然后按 score 阈值二次过滤再做坐标系还原把网络输入尺寸映射回原图。注意模型内部的 NMS 和你在外面做的二次过滤目的不同前者是去重后者是卡阈值别指望模型输出的都是你要的。对于 TopK 类输出N 通常是固定的比如固定取前 100 个这种情况反而更好处理。真正麻烦的是输出总个数不定、但内部结构是可变长列表的情况比如 OCR 的文本行数。这类模型我一般建议在导出时把输出做成固定的最大长度加 padding把不定长这件事转移到后处理里用有效长度字段控制。虽然多占一点显存但整个链路的确定性会好很多Java、C、Python 三边处理逻辑几乎一样。另外提一句onnx runtime / ncnn这类对比选型。ncnn 在移动端对动态 shape 的支持一直比较保守很多算子在动态输入下会回退到较慢的实现。如果你的目标平台是移动端导出时尽量把空间维度固定只留 batch 动态或者干脆准备几个固定尺寸的模型做分档。4. 动态 shape 走到部署环节墙在哪里4.1 int8 量化与动态 shape 的矛盾.onnx 量化 int8几乎成了现在部署的标配尤其是在边缘设备上。但量化这件事和动态 shape 之间有个天然张力量化的核心是给每个张量算一组缩放因子scale和零点zero point而 scale 的准确性依赖于激活值的分布统计分布统计又依赖于校准数据的形状。静态量化在做校准时会跑一批样本收集每个激活张量的 min/max。如果模型是动态 shape校准样本的形状不一致那么同一层的激活分布可能来自 320×320 和 1280×1280 两种截然不同的感受野算出来的 scale 会偏向某个方向导致另一档尺寸上精度明显下降。我的处理原则是动态 batch 可以量化动态空间尺寸要慎重。如果非得量化一个变分辨率模型就把校准集按尺寸分桶每个桶单独统计或者用 QDQ 格式让量化感知训练阶段的 scale 更鲁棒。另一个选择是只在固定尺寸上量化部署动态性由外层服务通过多模型分档实现每个档位一个量化模型。听起来笨但在 RKNN 这类工具链上往往是唯一稳定的路。顺便说一下quantize_dynamic和quantize_static的区别。前者只把 MatMul、Gemm 这类权重密集的算子做动态量化对卷积完全不起作用所以对 CNN 检测模型基本没用。需要量化卷积只能走quantize_static加校准集这条路就必须面对形状一致性的问题。4.2 TensorRT 和 RKNN 对动态 shape 的态度TensorRT 处理动态 shape 的机制是 optimization profile也就是给每个动态输入声明三组尺寸min、opt、max。构建引擎时TRT 会针对这三组尺寸做 kernel 选择和内存规划运行时在这区间内的其他尺寸靠插值调度。trtexec --onnxdetector.onnx \ --minShapesimages:1x3x320x320 \ --optShapesimages:1x3x640x640 \ --maxShapesimages:1x3x1280x1280 \ --shapesimages:1x3x640x640 \ --saveEnginedetector.plan \ --fp16三个尺寸怎么定有讲究。optShapes应该是你线上最常出现的尺寸因为它决定了 kernel 调优的目标maxShapes开得越大引擎构建时占的显存越多构建时间也越长有时候会直接 OOMminShapes开太小反而没意义TRT 在极小尺寸上的性能本来也一般。我一般让opt取 P50 尺寸max取 P99 加一点余量min就给一个实际业务里真会出现的最小值不要为了看起来覆盖广随便写 1x3x1x1。动态 shape 下还有一个必须接受的事实TRT 无法为大范围动态尺寸做完整的 kernel autotuning只能针对三个 profile 点优化。所以动态 TRT 引擎的推理速度通常比同尺寸的静态引擎慢 10% 到 30%这是设计上的取舍不是配置错了。RKNN 这边对动态输入的支持更保守。rknn-toolkit2 在转换时如果遇到动态维度通常需要你显式指定输入尺寸或者用它的动态 shape 配置功能不同版本支持程度差异较大。做 int8 量化时dataset.txt里的校准图片会被统一 resize 到模型输入尺寸如果你的模型是动态的、而校准集是单一尺寸精度和动态性会互相拖累。我的经验是在 RKNN 上跑尽量固定 shape动态性通过外层按分辨率分几个模型来解决。这不是妥协是这条工具链目前的现实。4.3 固定 shape 加尺寸分桶一个务实方案说了这么多动态 shape 的代价得给个正面方案。我在变分辨率 OCR 检测链路上最后落地的就是这样一套模型本身只保留 batch 动态空间尺寸固定外层按输入尺寸分桶每档一个模型文件或者一个 TensorRT 引擎。分桶的边界怎么定以 PP-OCRv6 检测这类模型为例输入尺寸通常是 32 的倍数因为主干网络有 5 次下采样需要被 32 整除常见档位可以取 640、960、1280 三档宽度高度按长宽比动态算完再向上取整到 32 的倍数。这样实际上每个请求落到哪一档是确定的图片缩放比例可控精度损失比强行统一到 640 小得多。这套方案的收益很直接每档模型都能吃满引擎的静态优化GPU 利用率高内存可预测排查问题时变量少。代价是模型文件多、加载的显存占用翻倍、首次加载慢。如果模型不大检测分支通常几 MB这些代价是可以接受的。在 Java 服务里实现分桶也不复杂维护一个MapInteger, OrtSessionkey 是尺寸档位请求进来先算目标档位从 map 里取对应会话。启动时预热一遍所有档位避免第一个请求触发 JIT 和内存分配抖动。4.4 一个真实链路从导出到上线的完整参数把前面的东西串起来用一个具体配置收个尾。假设你有一个基于 PP-OCRv6 的文本检测模型要在 GPU 服务器上提供 HTTP 服务输入是任意尺寸的图片输出是文本框坐标。导出阶段opset 17dynamic_axes只给 batch 和 height、width 起符号名dummy 用[1, 3, 960, 960]导出后用shape_inference补全中间张量再跑三个尺寸的 CPU 推理验证。量化阶段先不做。等分桶和精度对齐都调通了再考虑在固定尺寸上做 int8单独评估每个档位的精度衰减。服务化阶段分桶成 640、960、1280 三档各自构建 TensorRT 引擎profile 的 min/opt/max 分别设成同尺寸虽然 TRT 要求 min ≤ opt ≤ max但三档模型内部其实可以设成相等或者很窄的区间这样构建出的引擎性能最接近静态。后端 onnxruntime 或 TRT 都行我这边最终用的是 onnxruntime 的 TensorRT EP因为它在动态性和易用性之间平衡得比较好不需要单独管理 plan 文件。后处理拿到[1, N, 6]的输出后先按 score 卡阈值再做坐标反算。反算时务必记录缩放比例和 pad 偏移这两样东西不记下来框的位置就会整体偏移而且是那种看起来差不多但 IoU 就是不达标的偏移特别难查。5. 动态 shape 问题排查速查与实操心得5.1 报错对照表看到这些信息该往哪想动态 shape 的报错信息普遍不友好很多时候只说失败不说为什么。下面这张表是我这几年攒下来的对照关系按出现频率排的。报错或现象大概率原因处理方式Got invalid dimensions for input传入尺寸和符号约束不符或该维度其实是静态的打印模型输入符号确认哪一维写死了Dimension mismatch/Invalid FeedInputs符号名重复但语义不同或同名维度实际值不等用describe检查输入输出符号名是否一致换尺寸后输出形状诡异如 0 长度某一维dim_value0未设置被当成 0重新导出确保每个动态维有dim_param首次推理极慢之后正常动态 shape 触发卷积算法重新搜索CUDA EP 下调低cudnn_conv_algo_search等级内存随时间缓慢上涨ORT 内存 arena 随最大尺寸增长不回收关闭 arena 或设定上限中间某层 shape 推导失败符号链路在 Reshape/Resize 处断裂跑infer_shapes检查断裂节点的输入NMS 后输出 N 为 0阈值过高或输入尺寸导致目标过小先降阈值验证再调尺寸int8 模型某档尺寸精度暴跌校准集形状单一scale 不适配按尺寸分桶校准或该档用 fp16TensorRT 构建报显存不足maxShapes 开太大收窄 max或改用较小 batch表格里最后一类问题值得单独说一句TensorRT 构建期的显存占用有时会远超推理期。你 max 写到 1x3x1920x1920 加 fp16构建时可能要十几 GB而实际推理只有一两 GB。这是 TRT 的调优过程决定的不是你模型异常。5.2 性能逐层定位动态 shape 到底慢在哪动态 shape 的模型慢一定要定位到具体环节不然优化方向完全是瞎猜。我一般按这个顺序做。先测端到端确认慢是真的。有时候慢是预处理造成的模型本身没问题。把输入数据预生成好只测session.run排除掉 Python 侧的数据准备开销。再测不同尺寸的耗时曲线。固定 batch1把 H/W 从 320 扫到 1280画出耗时随面积变化的曲线。正常情况下耗时应该和像素数近似线性。如果出现明显的阶梯或者突刺说明某些尺寸触发了额外的算法搜索或者内存重分配。然后对比固定 shape 会话。用add_free_dimension_override_by_name把符号维度绑成一个固定值和动态会话跑同一个尺寸比耗时。差值就是动态性带来的纯开销。这个差值如果在 15% 以内基本可以接受超过 50%说明模型结构里有大量对动态shape不友好的算子得回去看导出。最后看算子级 profile。ORT 提供了session.end_profiling()导出 profiling json 的能力用它能看到每个算子的耗时和调用的 executor。动态 shape 下经常能看到某些算子被分配到 CPU 上执行GPU 实现不支持动态这些就是性能瓶颈点。注意profiling 本身会带来开销而且导出的 json 里包含时间戳不要直接提交到代码仓库。测完就删。5.3 我踩过的几个坑写出来省你时间第一个导出时 dummy 尺寸和实际尺寸差异过大导致常量折叠走错分支。有个模型里有F.interpolate(x, size(64, 64))这种写死的目标尺寸跟输入无关导出时被折叠成常量。后来业务改了预处理尺寸这个 64 就成了硬伤输出 unintentionally 变模糊。这类问题的特征是无论输入多大输出细节都一样糊。排查方法是拿两个差异很大的尺寸跑一遍看输出的高频信息是否变化。第二个Java 侧动态输出强转类型崩溃。前面提过getValue()返回的嵌套数组维度跟运行时形状绑定。我遇到过一个服务模型输出从[1, N, 6]变成[1, N]换了新版本模型输出少了一维Java 代码没跟着改直接 ClassCastException 打满日志。后来我在读输出前统一加了一层getInfo().getShape().length判断按 rank 分发到不同的解析函数这个坑就再没出现过。第三个动态 batch 和动态空间尺寸一起开内存翻倍。batch 从上到下传ORT 会为最大可能的 batch×尺寸组合预留内存实际很少有请求打满。关掉 arena 之后内存稳定了但分配耗时涨了大概 8%。最后的方案是batch 动态H/W 固定两全。第四个量化后动态轴消失。做过一次 int8 量化量化工具把dim_param全清掉了输出变成未知维度。模型能跑但输出 shape 是[1, 0, 6]这种奇怪的值下游解析全崩。解决办法是量化前先备份 fp32 模型量化后重新用describe检查一遍符号维度不对就用量化工具的keep_dynamic类选项或者干脆量化后再手动改回符号名用onnx.helper重建 ValueInfo。第五个onnxsim顺手把动态形状简化掉了。很多人导出后会跑一遍onnxsim做图优化某些版本在优化时会顺手做常量折叠和形状固化把你辛苦标的符号维度简化成固定数字。检查方法是简化前后都跑一遍describe只要有变化就要警惕。简化时如果确实需要固定形状可以显式用--overwrite-input-shape参数不同版本选项名略有差异跑--help确认这样至少是你主动固定的不会意外。这些东西写出来都是三两句话但每一个都是我花了大半天甚至好几天才定位到的。动态 shape 这件事说到底就是符号约束这条链要一路保持连通从导出时的dynamic_axes到形状推导到量化到推理引擎的 profile任何一环断了问题都会以看起来毫不相关的形式冒出来。我现在做新模型导出完的第一件事不是跑推理而是把输入输出符号打印一遍对着看这个习惯至少帮我省掉了一半返工。