3. 批量推理概念:批量大小定义、批处理优势、动态形状与固定形状
3.1 核心原理:什么是批量大小(Batch Size)
在ONNX模型推理中,批量大小(Batch Size)指的是单次推理调用时,一次性输入给模型的样本数量。它对应模型输入张量(Tensor)的第一个维度(维度0)的大小。例如,一个输入形状为 [1, 3, 224, 224] 的张量,表示批量大小为1,通道数为3,高度和宽度均为224像素;而 [32, 3, 224, 224] 则表示一次处理32张图片。
从数学角度看,批量推理的本质是将多个独立样本的推理计算合并为一次矩阵运算。以全连接层为例,单样本推理时执行 Y = X·W + b,其中X的形状为 [1, N];而批量推理时X的形状变为 [B, N],其中B为批量大小。由于底层BLAS库(如OpenBLAS、MKL)对矩阵乘法做了高度优化,合并后的计算效率远高于循环执行B次单样本推理。
批量大小定义要点:
- 维度位置:批量维度始终是输入张量的第0维(axis=0)。
- 动态性:批量大小可以是固定的(如始终为1),也可以是动态的(如1~16之间任意值)。
- 与样本数的关系:若总样本数为1000,批量大小为32,则需要 ceil(1000/32) = 32 次推理调用(最后一次不足32个样本时需补齐或单独处理)。
3.2 批处理优势:为什么批量推理能提升性能
批量推理带来的性能提升主要来源于以下三个层面:
| 优势维度 | 原理说明 | 典型收益 |
|---|---|---|
| 计算效率 | 矩阵乘法合并后,BLAS库能更好地利用CPU的SIMD指令和缓存局部性,减少循环开销 | 吞吐量提升2~8倍(视模型而定) |
| 内存带宽 | 权重参数只需从内存加载一次,被批量内所有样本共享,摊薄了I/O成本 | 内存访问量减少约B倍(B为批量大小) |
| 调度开销 | ONNX Runtime的会话调用、图优化、算子分发等固定开销被分摊到多个样本上 | 单样本平均延迟降低30%~70% |
以ONNX Runtime CPU执行器为例,当批量大小从1增加到32时,推理引擎会将多个样本的中间张量合并存储,减少多次调用之间的张量分配与释放操作。同时,对于卷积层和矩阵乘法等计算密集型算子,批量维度上的并行度更高,能更充分地利用多核CPU的计算资源。
经验法则:批量大小并非越大越好。当批量超过一定阈值(通常为64~128)时,性能提升趋于平缓,甚至因内存带宽瓶颈而下降。建议通过基准测试找到当前硬件上的“甜点批量值”。
3.3 方法流程:动态形状与固定形状的配置与选择
在ONNX模型转换和推理部署中,形状策略直接影响模型的通用性和执行效率。以下是两种形状模式的定义与配置方法:
3.3.1 固定形状(Fixed Shape)
固定形状要求模型输入张量的每一维在转换时都确定下来。例如,使用PyTorch导出ONNX时,若指定 input_shape=(1,3,224,224),则生成的模型只接受批量大小为1的输入。若尝试传入 [2,3,224,224] 的张量,ONNX Runtime会抛出维度不匹配错误。
# PyTorch 导出固定形状 ONNX 模型
import torch
model = torchvision.models.resnet18(pretrained=True)
model.eval()
dummy_input = torch.randn(1, 3, 224, 224) # 固定批量=1
torch.onnx.export(
model,
dummy_input,
"resnet18_fixed.onnx",
input_names=["input"],
output_names=["output"],
dynamic_axes=None # 不设置动态轴
)
3.3.2 动态形状(Dynamic Shape)
动态形状允许在推理时指定不同的批量大小,甚至动态的宽高(如目标检测模型)。通过 dynamic_axes 参数标记维度0为动态批量轴:
# PyTorch 导出动态批量 ONNX 模型
torch.onnx.export(
model,
dummy_input,
"resnet18_dynamic.onnx",
input_names=["input"],
output_names=["output"],
dynamic_axes={
"input": {0: "batch_size"}, # 第0维为动态批量
"output": {0: "batch_size"}
}
)
# 推理时传入不同批量
import onnxruntime as ort
sess = ort.InferenceSession("resnet18_dynamic.onnx")
# 批量=1
out1 = sess.run(None, {"input": torch.randn(1,3,224,224).numpy()})
# 批量=16
out16 = sess.run(None, {"input": torch.randn(16,3,224,224).numpy()})
3.3.3 选择策略
- 在线服务场景(如Web API):请求到达时间不确定,单次请求量小,推荐使用动态形状,批量大小设为1~4,避免等待攒批带来的延迟。
- 离线批处理场景(如视频帧批量分析):数据预先准备好,推荐使用固定形状(如批量=32或64),配合ONNX Runtime的
IOBinding技术减少内存拷贝。 - 混合策略:导出动态形状模型,但在推理代码中实现“动态攒批”——将多个请求合并为不超过最大批量阈值的批次,兼顾延迟与吞吐。
3.4 案例与场景:图像分类服务的批量推理优化
假设我们部署一个基于ResNet-50的图像分类服务,原始实现采用单样本推理(批量=1),平均单张延迟为15ms,吞吐量约67张/秒。通过引入批量推理,将请求队列中的图片每32张合并为一批,单批推理耗时约120ms,折算单张平均延迟为3.75ms,吞吐量提升至约267张/秒,整体性能提升近4倍。
批量推理实现伪代码:
import onnxruntime as ort
import numpy as np
from collections import deque
sess = ort.InferenceSession("resnet50_dynamic.onnx")
input_name = sess.get_inputs()[0].name
batch_queue = deque()
MAX_BATCH = 32
def process_batch(batch_data):
"""批量推理核心函数"""
# batch_data: list of np.ndarray, 每个形状为 (3,224,224)
batch_tensor = np.stack(batch_data, axis=0) # (B,3,224,224)
results = sess.run(None, {input_name: batch_tensor})
return results[0] # (B,1000)
def inference_single(img):
"""对外接口:单张图片推理"""
batch_queue.append(img)
if len(batch_queue) < MAX_BATCH:
# 攒批中,返回占位符(实际场景中通过异步返回)
return None
else:
batch = [batch_queue.popleft() for _ in range(MAX_BATCH)]
return process_batch(batch)
该案例展示了动态形状模型配合攒批逻辑的典型用法。值得注意的是,攒批策略需要结合请求到达率动态调整——若请求稀疏,等待攒满32张会造成额外延迟,此时应设置最大等待时间(如5ms),超时后即使未满也立即执行推理。
3.5 误区与复盘:批量推理中的常见陷阱
误区一:盲目追求大批量
当批量大小超过CPU缓存容量时,内存带宽成为瓶颈,性能不升反降。例如在单核CPU上,批量从64增至128时,推理时间可能从800ms增至1700ms。务必通过实验绘制“批量大小-吞吐量”曲线,找到拐点。
误区二:忽略动态形状的额外开销
动态形状模型在推理时需要进行形状推断和内存重规划,相比固定形状模型有约5%~15%的额外开销。若业务场景中批量大小恒定,应使用固定形状模型以获得极致性能。
误区三:批量维度设置错误
某些模型(如LSTM、Transformer)的输入维度顺序可能为 [seq_len, batch, hidden],此时批量维度在第1维而非第0维。导出ONNX时需仔细核对 dynamic_axes 的索引,否则会导致推理结果错误。
复盘建议:在项目初期就明确推理场景是“延迟敏感型”还是“吞吐优先型”。前者应使用小批量(1~4)配合动态形状;后者应使用大批量(32~128)配合固定形状。同时,在模型转换阶段就通过 onnx.shape_inference 工具验证形状配置是否正确,避免将问题遗留到部署阶段。