3. 动态形状的常见场景:自然语言处理中的变长输入、目标检测中的动态批量大小、语音识别中的可变长度音频

3.1 核心原理:为什么这三个场景天然携带动态形状

动态形状并非 ONNX 导出的“附加难题”,而是真实业务数据在模型输入上的直接投影。理解这一点,是避免后续踩坑的前提。我们逐一拆解三个典型场景,它们分别代表了三种不同的动态维度:序列长度维度、批量维度、以及时间步维度。

  • 自然语言处理(NLP)中的变长输入:句子由单词组成,而单词数量天然不等。无论是 BERT 还是 LLaMA,其输入张量形状为 [batch_size, seq_len],其中 seq_len 是当前 batch 内最长句子的 token 数。短句需要 padding 到该长度,但下一个 batch 的最长句子可能不同,因此 seq_len 在推理时是动态变化的。
  • 目标检测中的动态批量大小:服务端推理时,请求可能来自不同用户,无法保证每次凑齐固定数量的图片。检测模型的输入形状为 [batch_size, 3, H, W],其中 batch_size 是动态的。更复杂的是,部分检测头(如 DETR 的 object query)还会输出动态数量的检测框,但输入侧的动态批量是最常见、最直接的动态形状来源。
  • 语音识别中的可变长度音频:音频时长由说话内容决定,从 0.5 秒到 10 秒不等。经过特征提取后,得到形状为 [batch_size, time_steps, feature_dim] 的声学特征,其中 time_steps 与音频时长成正比。与 NLP 类似,这是序列维度的动态,但区别在于:语音的 time_steps 往往比 NLP 的 seq_len 变化范围更大(可能相差 20 倍以上),且无法通过简单的截断来统一。

核心洞察:动态形状的本质是“张量维度中至少有一个维度的值在运行时才确定”。ONNX 模型需要显式声明这些维度为动态(用符号名如 batch、seq 或 time 表示),否则导出器会将其固化为静态值,导致运行时形状不匹配报错。

三种典型动态形状场景对比 自然语言处理 变长输入 输入: [batch, seq_len] seq_len = 当前batch最长句子 动态维度: seq_len padding 到 batch 内最大长度 典型模型: BERT / GPT / LLaMA attention mask 同步动态 ⚠ 需同时处理 position_ids 目标检测 动态批量大小 输入: [batch, 3, H, W] batch = 请求队列长度 动态维度: batch 服务端无法预知并发数 典型模型: YOLO / DETR / Faster R-CNN 后处理输出数量也动态 ⚠ NMS 阈值影响输出个数 语音识别 可变长度音频 输入: [batch, time, feat] time ∝ 音频时长 动态维度: time 0.5s ~ 10s 跨度极大 典型模型: Whisper / Conformer mel spectrogram 帧数动态 ⚠ 需处理 CTC 对齐长度

3.2 方法流程:如何为每个场景正确设置动态轴

在 ONNX 导出时,动态形状的处理核心是 symbolic shape inference(符号形状推断)。你需要告诉导出器哪些维度是动态的,并给它们起一个符号名。以下分别给出三个场景的 PyTorch 导出示例。

3.2.1 NLP 变长输入:动态 seq_len

import torch

class BertEncoder(torch.nn.Module):
    def forward(self, input_ids, attention_mask):
        return self.bert(input_ids=input_ids, attention_mask=attention_mask)[0]

model = BertEncoder()

# 关键:将 seq_len 声明为动态
torch.onnx.export(
    model,
    (torch.randint(0, 30000, (1, 128)), torch.ones(1, 128, dtype=torch.long)),
    "bert.onnx",
    input_names=["input_ids", "attention_mask"],
    output_names=["last_hidden_state"],
    dynamic_axes={
        "input_ids": {0: "batch", 1: "seq_len"},
        "attention_mask": {0: "batch", 1: "seq_len"},
        "last_hidden_state": {0: "batch", 1: "seq_len"}
    },
    opset_version=17
)

这里 dynamic_axes 用字典声明了 batch 和 seq_len 两个动态维度。注意 attention_mask 必须与 input_ids 保持完全一致的动态轴声明,否则推理时 mask 与输入长度不匹配。

3.2.2 目标检测:动态 batch

class DetectionModel(torch.nn.Module):
    def forward(self, images):
        # images: [N, 3, H, W]
        return self.model(images)

torch.onnx.export(
    DetectionModel(),
    torch.randn(1, 3, 640, 640),
    "detector.onnx",
    input_names=["images"],
    output_names=["boxes", "scores"],
    dynamic_axes={
        "images": {0: "batch"},
        "boxes": {0: "num_dets"},   # 输出数量也动态
        "scores": {0: "num_dets"}
    },
    opset_version=17
)

注意:检测模型的输出 boxes 和 scores 的第一维是检测框数量,它由模型推理结果决定,因此也必须声明为动态。如果只声明输入 batch 动态而忽略输出数量动态,ONNX Runtime 会报“输出形状不匹配”错误。

3.2.3 语音识别:动态 time 步

class SpeechEncoder(torch.nn.Module):
    def forward(self, mel_spec):
        # mel_spec: [B, T, 80]
        return self.encoder(mel_spec)

torch.onnx.export(
    SpeechEncoder(),
    torch.randn(1, 3000, 80),
    "speech.onnx",
    input_names=["mel_spec"],
    output_names=["encoder_out"],
    dynamic_axes={
        "mel_spec": {0: "batch", 1: "time"},
        "encoder_out": {0: "batch", 1: "time"}
    },
    opset_version=17
)

语音场景中,time 维度的变化范围极大。建议在导出时使用一个代表性的中间值(如 3000 帧,对应约 30 秒音频),但声明为动态后,实际推理时任意长度均可。

3.3 案例与场景:真实业务中的动态形状陷阱

场景 典型错误 后果 正确做法
NLP 服务 导出时固定 seq_len=128 推理时输入 256 长度直接报错 声明 seq_len 动态,并配套动态 position_ids
目标检测 只声明 batch 动态,忽略输出数量 输出维度固定为 100,实际检测到 150 个框时崩溃 同时声明 num_dets 动态
语音识别 将 time 维固定为 3000 短音频被 padding 到 3000 帧,浪费算力;长音频直接失败 time 动态,并配合动态 shape 的 ONNX Runtime 配置

实战建议:在导出前,先用 onnx.shape_inference.infer_shapes 检查模型的静态形状。如果发现某个中间节点的输出形状被固定为常量,说明该处存在隐式静态化,需要检查上游是否有 Reshape 或 Flatten 操作将动态维度拍平。

3.4 误区与复盘:动态形状声明中的常见认知偏差

误区一:动态轴越多越好。有些开发者将 batch、seq_len、height、width 全部声明为动态,导致 ONNX Runtime 在内存分配时无法做任何优化,推理速度下降 30% 以上。正确策略是:只声明业务上确实会变化的维度。例如,如果图片尺寸在预处理时已统一 resize 到 640×640,则 H、W 应保持静态。

误区二:忽略“隐式动态”。某些算子(如 NonMaxSuppression)的输出数量天然动态,即使输入是静态 batch,输出也是变长的。这类算子的输出必须显式声明动态轴,否则 ONNX 的 shape inference 会将其推断为固定值(通常是最大值),导致后续 Gather 或 Slice 操作越界。

误区三:动态形状与静态优化冲突。ONNX Runtime 的 ORT_ENABLE_ALL 优化级别会尝试将动态维度折叠为静态。如果遇到“This op only supports static shapes”错误,需要在 session 配置中关闭特定优化:

import onnxruntime as ort
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
session = ort.InferenceSession("model.onnx", sess_options)

复盘总结:动态形状的声明不是“导出时的一次性配置”,而是需要结合推理框架、硬件加速、业务并发模型综合设计。建议在导出前绘制一张“维度变化表”,列出每个输入/输出张量的每个维度在真实业务中的取值范围,再决定哪些维度声明为动态、哪些保持静态。这能避免 90% 以上的运行时形状不匹配问题。