2、ONNX基础回顾:ONNX模型结构、节点与张量、静态Shape与动态Shape的区别

在深入探讨动态Shape转换策略之前,我们必须先建立对ONNX(Open Neural Network Exchange)模型格式的扎实理解。ONNX作为深度学习模型互操作的开放标准,其内部结构设计直接决定了我们后续处理动态Shape时的技术路径。本节将系统拆解ONNX模型的基本构成单元,厘清节点与张量的关系,并重点剖析静态Shape与动态Shape在模型描述、推理引擎调度以及边缘设备部署中的本质差异。理解这些基础概念,是掌握后续“固定输入转换策略”的前提——因为所有转换技巧本质上都是在与ONNX的图结构和张量描述机制打交道。

ONNX模型结构全景与Shape机制 ONNX ModelProto GraphProto(计算图) NodeProto(节点) Conv / Relu / MatMul op_type + attribute input[] / output[] ValueInfoProto Tensor(张量) name / elem_type shape(关键!) Initializer(权重/常量,TensorProto) Conv权重、BN均值/方差等 graph.input[] / graph.output[](ValueInfoProto) Shape描述机制对比 静态Shape dim_param = 固定值 如: [1,3,224,224] ✓ 内存预分配简单 ✓ 算子优化充分 ✗ 输入尺寸固定 ✗ 灵活性差 ✗ 边缘设备友好 动态Shape dim_param = 符号/变量 如: ["batch",3,H,W] ✓ 输入尺寸灵活 ✓ 多场景适配 ✗ 内存动态分配 ✗ 算子优化受限 ✗ 边缘部署困难 决定 核心:Shape信息存储在ValueInfoProto中,静态与动态的区别在于dim_param是常量还是符号变量

2.1 ONNX模型结构:从ModelProto到GraphProto

ONNX模型采用Protobuf(Protocol Buffers)序列化格式存储,其顶层结构为ModelProto。一个完整的ONNX文件(通常以.onnx为后缀)在解析后,首先得到的是ModelProto对象,它包含了模型的基本元信息(如ir_version、producer_name、opset_import等)以及一个核心的GraphProto。GraphProto是计算图的本体,它由四个主要部分组成:node(节点列表)、initializer(初始化器列表)、input(图输入列表)和output(图输出列表)。

从工程视角看,GraphProto描述了一个有向无环图(DAG)。图中的每个NodeProto代表一个算子实例,例如Conv、Relu、Add等。每个节点通过input和output字段引用张量名称,从而形成数据依赖关系。而TensorProto则用于存储权重、偏置等常量数据,它们被放在initializer中,不参与图的拓扑排序,但在推理时会被加载为张量。理解这一分层结构至关重要,因为当我们处理动态Shape时,需要修改的正是input和output中ValueInfoProto的shape字段,而非节点本身。

关键认知:ONNX的图结构是“节点+张量”的二元体系。节点定义计算逻辑,张量定义数据流。Shape信息附着在张量的ValueInfoProto上,而不是节点上。这意味着动态Shape的修改本质上是对张量描述信息的修改,与算子逻辑无关。

2.2 节点与张量:数据流的核心载体

在ONNX中,节点(Node)和张量(Tensor)构成了计算图的两大核心元素。节点通过op_type指定算子类型,通过attribute携带算子参数(如Conv的kernel_shape、strides等),通过input和output列表连接张量。张量则通过ValueInfoProto描述其数据类型(elem_type)和形状(shape)。形状由dim列表组成,每个dim可以是固定的整数值(dim_value),也可以是动态的符号变量(dim_param)。

这里需要特别区分Initializer与图输入。Initializer中的张量(如卷积权重)在模型加载时即被固定,其Shape通常是静态的,因为权重尺寸在训练完成后就已确定。而图输入(graph.input)对应的张量则是推理时由外部传入的数据,其Shape可以是静态的,也可以是动态的。例如,一个图像分类模型可以声明输入Shape为[1,3,224,224](静态),也可以声明为["batch",3,224,224](动态,其中batch为符号变量)。

组成元素 Protobuf类型 作用 Shape特征
节点 NodeProto 定义算子计算逻辑 不直接携带Shape,通过输入输出张量间接关联
张量(数据) ValueInfoProto 描述中间结果或输入输出数据的类型与形状 可静态(dim_value)或动态(dim_param)
张量(常量) TensorProto 存储权重、偏置等不可变参数 通常为静态Shape,加载后不可变
图输入/输出 ValueInfoProto 定义模型对外的数据接口 动态Shape主要修改此处

2.3 静态Shape与动态Shape:定义与判断方法

静态Shape指的是模型中所有张量的每个维度都使用具体的整数值描述,例如[1,3,224,224]。这种描述方式在模型导出时即被完全确定,推理引擎无需任何运行时推断即可预分配内存、应用算子融合和内存复用优化。对于边缘设备(如Jetson、RK3588、手机NPU)而言,静态Shape意味着可以提前规划内存池,避免动态分配带来的延迟和碎片化问题,同时也能让编译器进行更激进的算子优化(如NHWC布局转换、通道重排等)。

动态Shape则允许某些维度使用符号变量(如"batch"、"height")或未定义(None)来表示。例如["batch",3,"height","width"]。动态Shape提供了极大的灵活性,使得同一个模型可以处理不同分辨率的输入或不同批大小的数据。然而,这种灵活性是有代价的:推理引擎必须在运行时根据实际输入张量推断所有中间张量的Shape,这增加了调度开销;同时,由于Shape未知,许多编译期优化无法进行,内存分配也需要采用动态策略。

判断一个ONNX模型是静态还是动态Shape,最直接的方法是使用onnx.shape_inference工具或直接解析模型文件。以下是一个简单的Python判断逻辑:

import onnx

model = onnx.load("model.onnx")
graph = model.graph

# 检查所有图输入的shape
for input_tensor in graph.input:
    shape = input_tensor.type.tensor_type.shape
    for dim in shape.dim:
        if dim.HasField("dim_param"):
            print(f"动态维度: {dim.dim_param}")
        elif dim.HasField("dim_value"):
            print(f"静态维度: {dim.dim_value}")
        else:
            print("未知维度(未指定)")

在实际工程中,我们通常关注输入张量的Shape类型,因为中间张量的Shape往往由输入Shape和算子属性共同决定。如果输入是静态的,那么整个图通常是静态的;如果输入是动态的,则中间张量可能也是动态的(取决于算子是否支持Shape传播)。

2.4 案例拆解:一个动态Shape模型的识别与影响分析

假设我们有一个基于PyTorch训练的目标检测模型,导出ONNX时未固定输入尺寸,导致输入Shape为["batch",3,"height","width"]。我们将其部署到瑞芯微RK3588平台的NPU上,发现NPU编译器报错“Unsupported dynamic shape”。以下是完整的分析过程:

背景与约束:RK3588的NPU(RKNN)要求模型输入Shape完全静态,以便在编译阶段进行权重重排和内存规划。动态Shape会导致RKNN Toolkit无法完成模型转换。

初始现象:使用rknn.config()和rknn.load_onnx()加载模型时,工具抛出E Shape not support错误,日志显示Input[0] dims: [?,3,?,?]。

关键输入数据:模型输入节点名为input,Shape声明为["batch",3,"height","width"],其中batch、height、width均为dim_param。

分析判断过程:我们首先使用onnx.shape_inference工具推断模型中间张量的Shape传播情况,发现所有卷积层的输出Shape都依赖于输入Height和Width,因此整个模型的Shape都是动态的。这意味着无法通过简单修改某一层来解决,必须从输入源头固定Shape。

实际操作步骤:

  1. 使用onnx.helper修改graph.input中的dim_param为固定值,例如将batch设为1,height设为640,width设为640。
  2. 重新运行onnx.shape_inference,确保所有中间张量的Shape被正确推断为静态值。
  3. 使用onnx.checker验证模型完整性。
  4. 重新导出模型,再次加载到RKNN Toolkit,转换成功。

结果对比:修改前,模型无法在NPU上运行;修改后,模型成功转换并在RK3588上以约30 FPS的速度运行。但代价是输入分辨率被固定为640x640,无法处理其他尺寸的图像。

风险与代价:固定Shape后,如果实际应用场景需要处理不同分辨率的图像(如1080P监控画面),则必须进行预处理(如resize、letterbox),这会引入额外的计算开销和精度损失。

复盘经验:在模型导出阶段就应明确部署目标。如果目标设备是边缘NPU,建议在导出ONNX时直接固定输入Shape,避免后续转换的麻烦。如果确实需要动态Shape,则必须选择支持动态Shape的推理引擎(如ONNX Runtime的CPU/GPU版本),并接受性能折损。

重要提醒:动态Shape并非“银弹”。在边缘设备上,动态Shape带来的灵活性远小于其造成的部署障碍。绝大多数边缘NPU、DSP和GPU推理引擎都要求静态Shape。在模型设计阶段就应确定输入尺寸范围,并采用多模型策略或预处理方案来覆盖不同尺寸需求。

2.5 常见误区与总结复盘

在理解静态与动态Shape时,工程师常陷入以下误区:

  • 误区一:认为动态Shape只是“多几个变量”而已。实际上,动态Shape会阻断推理引擎的图优化(如算子融合、内存复用),导致推理性能显著下降。在CPU上可能下降20%-50%,在NPU上则可能直接无法编译。
  • 误区二:混淆“动态Shape”与“动态尺寸输入”。动态Shape是模型层面的属性,而动态尺寸输入是应用层面的需求。即使模型是静态Shape,应用层也可以通过预处理将输入缩放到固定尺寸,从而满足模型要求。
  • 误区三:忽略中间张量的Shape传播。即使只修改了输入Shape,如果模型中存在Reshape、Transpose等算子,中间张量的Shape可能仍然保持动态,导致推理引擎报错。因此,修改后必须进行完整的Shape推断验证。
  • 误区四:认为所有算子都支持动态Shape。实际上,许多算子(如Flatten、Gather)在动态Shape下的行为是未定义的,或者需要额外的Shape推理逻辑。这可能导致模型在推理时出现运行时错误。

总结复盘:静态Shape与动态Shape的选择本质上是灵活性与性能的权衡。对于边缘设备部署,静态Shape是首选,因为它能最大化利用硬件资源,实现低延迟和高吞吐。动态Shape更适合云端GPU或CPU环境,或者需要处理变长序列(如NLP)的场景。在实际项目中,建议遵循以下决策流程:

  1. 明确部署目标设备的推理引擎能力(是否支持动态Shape)。
  2. 分析应用场景的输入尺寸变化范围(固定尺寸、有限集合、完全可变)。
  3. 如果设备不支持动态Shape,则采用“固定Shape + 预处理”策略,或训练多个固定分辨率的模型。
  4. 如果必须使用动态Shape,则优先选择ONNX Runtime、TensorRT(部分支持)等成熟引擎,并做好性能测试。