批次(Batch)概念解析

说实话,刚接触深度学习推理那会儿,我对「Batch」的理解特别肤浅。不就是一次多塞几张图进去嘛,有什么好琢磨的?后来在实际项目中栽了跟头,才真正体会到——Batch 是吞吐量和延迟之间的天平,调好了是火箭,调歪了是炸弹。

那咱们今天就把 Batch 掰开了聊。我会结合 MNN 的实际用法,把批处理是怎么一回事、MNN 怎么支持、以及你实操时要注意哪些坑,一次说透。

什么是 Batch?

Batch 就是“批”。你给模型一次送进去的样本数量就叫 batch size。比如一次送 1 张图,batch=1;一次送 32 张,batch=32。

为什么不用单张依次跑?因为现代硬件(GPU、NPU、DSP)擅长并行计算。模型里大部分运算是矩阵乘法。你可以想象成:一张图是一个向量,多张图叠成一个矩阵。一次算一个矩阵,比一次算一个向量再循环 32 次,要快得多。

说白了,Batch 就是把硬件算资源填满,让你 GPU 别再“摸鱼”。

举个例子。我曾在某安防项目中批量处理 1080p 视频帧。单帧推理耗时 12ms,但视频流每秒 25 帧,单卡只能处理约 80 路。后来我把 batch size 调到 4,单帧耗时只涨到 16ms,但吞吐量翻了两倍多。这就是 Batch 的魔力。

Batch 在推理中的两个关键指标

指标定义Batch 越大
延迟处理一个样本的时间通常略有增加(因为要等所有样本算完)
吞吐量单位时间处理的样本总数大幅提升(硬件利用率高)

你肯定发现了——延迟和吞吐量存在 trade-off。有些场景(比如无人驾驶)对延迟极度敏感,batch size 必须小;有些场景(比如离线批量转码)则尽量拉大 batch 吃满显存。我建议你自己拿目标设备跑一轮微基准测试,别只看理论值。

MNN 对 Batch 的支持方式

MNN 的批处理分为两类:静态批处理和动态批处理。

  • 静态批处理:模型在转换时固定 batch size。所有张量形状都按固定 batch 分配内存。推理时输入的 batch 必须与模型定义一致。
  • 动态批处理:模型训练或转换时用动态 shape。MNN 运行时根据实际输入自动调整中间张量大小。更灵活,但会有额外的内存分配开销。

我记得刚用 MNN 搞一个目标检测服务,模型是从 PyTorch 转的 ONNX,ONNX 默认 batch=1。我天真地把输入 batch 改成 8,结果 MNN 创建会话时报错——形状不匹配。后来查文档才发现,MNN 默认按转换时的输入 shape 固化推理图。如果你要用动态 batch,必须在转换时指定可变维度。

如果你用 MNN 做动态 batch,建议在转换时设置输入 shape 的某个维度为 -1,并在运行时调用 resizeTensor 重新指定尺寸。但要提前评估内存申请次数,别在实时路径里频繁 resize。

MNN 设置静态 Batch 的示例

假设你要转换一个 batch=4 的模型:

# 用 MNNConvert 工具,在转换脚本里指定输入形状
MNNConvert -f ONNX --modelFile model.onnx --MNNModel model.mnn \
  --inputNames input --inputShapes 4,3,224,224

# 或者用 Python API 转换时控制
from MNN.tools import MNNConvert
MNNConvert.convert(
    model_file="model.onnx",
    framework="ONNX",
    MNN_model="model.mnn",
    input_names=["input"],
    input_shapes={"input": (4, 3, 224, 224)}
)

动态 Batch 配置(推荐用于生产)

# 转换时把 batch 维度设为 -1
MNNConvert -f ONNX --modelFile model.onnx --MNNModel model.mnn \
  --inputNames input --inputShapes -1,3,224,224

# C++ 推理时动态设定 batch
MNN::Interpreter* interp = MNN::Interpreter::createFromFile("model.mnn");
MNN::Session* session = interp->createSession();
MNN::Tensor* input = interp->getSessionInput(session, "input");

// 假设当前要 batch=8
std::vector<int> shape = {8, 3, 224, 224};
input->resize(shape);
interp->resizeSession(session);

// 填充数据并运行
interp->runSession(session);
曾经有一次我在移动端用动态 batch,每帧都 resize session,结果 CPU 占用直接飙到 90%。后来发现 resizeSession 内部会重新分配所有中间张量。高频场景下一定要做批处理缓存——比如维护几个固定 batch size(1, 2, 4)的 session 池子,用哪个拿哪个。

Batch 在 MNN 运行时的影响

MNN 的算子内部做了优化:当 batch > 1 时,会把矩阵乘法拆分到多个线程或 SIMD 向量上。比如 Conv2D 算子,batch 维度会参与 im2col 的张量重组。实际效果上,batch=2 比 batch=1 跑两次快 1.6~1.8 倍。到了 batch=8 以上,提速会慢慢饱和,因为内存带宽成了瓶颈。

我个人习惯在部署前先做一轮 batch sweep:从 1 到 64(显存上限),测延迟和吞吐,画个曲线找到最佳点。

一张图看懂 Batch 在推理流程中的位置

下面这张图展示了数据从输入到模型处理再到输出的全过程。batch 维度的作用贯穿始终:

批量推理流程图 输入 Batch 预处理 MNN 推理 Session + 算子 并行处理 Batch 输出 Batch Batch 维度如何贯穿流程? 1. 输入 -> 预处理:多张图组成一个 4D 张量 (N, C, H, W),N 为 batch size。 2. MNN 推理:算子内部将 N 维度拆分到线程/向量,一次计算所有样本。 3. 输出:每个样本得到独立结果,按 batch 顺序排列。 Batch Size = 4 ~ 64

从上图能清晰看到:batch 维度在输入时确定,经过预处理后直接喂给 MNN 模型。模型内部所有算子的计算张量都多了一个 N 维度。输出时,每个样本的结果被按序存放。

常用 Batch 实验命令

我习惯用 MNN 自带的 benchmark 工具快速摸清设备的上限:

# 跑单 batch 延迟
./benchmark.out model.mnn 1 20

# 测不同 batch 的吞吐
./benchmark.out model.mnn 4 20
./benchmark.out model.mnn 8 20
./benchmark.out model.mnn 16 20

注意,后面的数字 20 是预热后测试 20 次取均值。我一般至少跑三轮,取中位数。

避坑经验总结

  • 不要盲目迷信大 batch:显存/内存有限,新一批数据的拷贝开销也会涨。
  • 动态 batch 的 resize 成本:我见过有人每帧都 resize,结果速度反而比固定 batch=1 还慢。建议做 session 池。
  • MNN 多线程配合 batch:如果你开了 4 线程,batch=4 时每个线程处理一个样本,会有接近线性的加速。但线程数超过 CPU 核数时,效果打折。
  • 量化模型对 batch 更敏感:量化后计算密度低,batch 带来的收益更小。优先测延迟,再考虑提 batch。
总结一句话:Batch 是吞吐量的加油站,也是延迟的减速带。用 MNN 做推理,先搞懂静态 vs 动态,再拿真实数据跑一轮 benchmark,别凭直觉调大小。