Batch Size与预热导致深度学习模型推理时间忽快忽慢
最近在做深度学习推理优化时,遇到一个“灵异现象”:
同样的模型和输入尺寸,第一次推理要几百毫秒,之后就只要几毫秒;
而且batch size 从 1 改成 8,平均推理时间有时还更慢,有时又突然快得离谱。
最近我在测试超分辨率模型 FMEN 的时候,就完整踩了一遍坑。这里把过程、原因和正确的测法梳理一下,分享给大家。
1. 首帧为什么慢?预热的意义
我经常会遇到首帧推理时间较长的情况。在4090上,当处理 224x224 输入图像时,首张图片的推理时间可能达到 400ms,而后续每张图片的推理时间通常会降到仅 6ms。
为什么首帧慢?
首帧慢的原因主要有以下几点:
CUDA 上下文初始化
在第一次使用 GPU 时,CUDA 会为 GPU 创建一个上下文并分配内存池。这是一个比较耗时的过程,特别是当运行的模型比较大或者需要初始化大量资源时。cuDNN 算法搜索
在使用深度学习框架(如 PyTorch)时,如果开启了torch.backends.cudnn.benchmark=True
,cuDNN 会在第一次运行时试一遍不同的卷积算法,找出最快的实现。这个过程需要一定的时间,但一旦确定了最佳算法,后续推理就会非常快。GPU 动态频率调整
当 GPU 刚开始工作时,尤其是在省电模式下,GPU 的频率通常较低。为了提升性能,GPU 需要花费时间将频率提升至 P0/Boost 模式,这样能提供更高的计算能力。缓存冷启动
在推理开始时,GPU 的缓存尚未加载模型的权重和输入的特征图,因此第一次的数据访问速度较慢。之后的数据访问会更高效,因为权重和特征图已经被加载到缓存中。
预热的意义
由于首帧推理时间受到初始化过程的影响,如果只计算第一次的推理时间,它会严重影响整个推理过程的平均时间。因此,在进行性能评估时,通常会先执行几次预热,以确保模型加载、CUDA 上下文创建、cuDNN 算法选择等过程已经完成,并且GPU的状态已经稳定。
正确的做法
在正式计时之前,先进行预热。预热会帮助初始化 GPU 所需的资源,并确保 GPU 达到最佳的工作状态。下面是一个常见的预热代码示例:
inp = torch.randn(1, 3, 224, 224, device=device)
with torch.inference_mode():for _ in range(10): # 预热 10 次_ = model(inp)
torch.cuda.synchronize()
预热的形状与推理的不一致时会影响推理速度!
在使用 PyTorch 进行推理时,特别是在处理 GPU 上的模型时,输入形状与模型实际推理时的输入形状不一致会影响推理的效率。原因如下:
动态调整的内存分配
预热时使用的输入形状和正式推理时的形状不一致,可能导致 GPU 内存的分配和调整过程不符合实际推理需求。GPU 在接收到不同形状的输入时,可能需要调整内存池大小、重新编排计算图,或者调整数据传输方式,这会浪费额外的计算资源,导致预热和正式推理之间的性能差异。缓存和内存布局
GPU 在执行推理时,会利用缓存来提高数据访问速度。如果在预热时使用的输入形状和正式推理时的形状不同,缓存可能无法有效复用,这会影响推理速度。例如,较大的输入可能需要更多的内存带宽和缓存大小,而小的输入可能无法充分利用这些资源。优化计算图
PyTorch 和 cuDNN 会根据输入的形状来优化计算图。例如,某些卷积操作可能会根据输入的特征图尺寸选择不同的优化算法。如果预热时使用的形状与正式推理时的形状差异较大,可能会导致错误的优化决策,从而影响性能。
我写了个代码,使得预热和推理的形状一样
# 3) === 一次性预热(与 DataLoader 形状一致)===with torch.inference_mode():# 用数据集的第一张确定 C,H,W;用 DataLoader 的 batch_size 确定 Nsample = infer_loader.dataset[0] # 假设返回 Tensor: [C,H,W]if isinstance(sample, (list, tuple)):sample = sample[0] # 兼容 (lr, hr) 之类返回C, H, W = sample.shape[-3], sample.shape[-2], sample.shape[-1]N = infer_loader.batch_size or 1dummy = torch.randn(N, C, H, W, device=device, dtype=sample.dtype)for _ in range(10): # 多跑几次更稳_ = net(dummy)if device.type == "cuda":torch.cuda.synchronize()print(f"=> warmup done for shape: ({N},{C},{H},{W})")
2. 如何正确计时?
很多初学者用 time.time()
包裹 model(x)
,得到的时间很短,这是因为 PyTorch 的 GPU 调用是异步的:CPU 把 kernel 提交给 GPU 就返回了,实际上 GPU 还在算。
正确方式是加同步,并使用高精度的 perf_counter()
:
from time import perf_countertorch.cuda.synchronize()
t1 = perf_counter()
_ = model(inp)
torch.cuda.synchronize()
t2 = perf_counter()latency_ms = (t2 - t1) * 1000
print(f"真实单次推理延迟: {latency_ms:.3f} ms")
这样测到的才是 GPU 真正完成一次前向传播的耗时。
3. Batch Size 对推理时间的影响
这里才是最有趣的部分。我做了两组实验:
实验一:数据集 14 张图
Batch=1 → 平均 6ms/张
Batch=8 → 平均 16ms/张 (反而更慢!)
实验二:数据集 16 张图
Batch=1 → 平均 6ms/张
Batch=8 → 平均 0.8ms/张 (快得离谱!)
为什么会这样?
当 batch size 不能整除数据集时,最后一批就是“残缺批”。比如 14 张图 + bs=8 → 运行一次 8 张、一次 6 张:
固定开销摊不均:每个 batch 都要付一次 kernel 启动、调度的固定成本。满 8 张能把成本均匀分摊,但只跑 6 张时,成本摊得更少,单张更贵。
算法缓存失效:cuDNN 是按形状缓存最优算法的。预热时缓存了 (8,3,224,224),结果尾批变成 (6,3,224,224),相当于新形状 → 可能重新选算法,速度慢。
所以在 14 张图的情况下,尾批拖慢整体,平均值被拉高到了 16ms/张。换成 16 张图,正好两个满批 (8+8),固定开销被均匀摊薄,平均值就掉到了 0.8ms/张。
总结
推理时间的“灵异现象”背后是 GPU 工作机制和统计口径的叠加结果:
首帧慢:CUDA 上下文初始化、cuDNN 算法搜索、GPU 频率提升等固定开销,导致第一张图片远慢于后续。解决方法:预热几次。
计时要同步:PyTorch 的 GPU 调用是异步的,用
time.time()
只算提交时间。→ 解决方法:perf_counter()
+torch.cuda.synchronize()
。Batch Size 的陷阱:
如果数据集大小不是 batch 的整数倍,尾批是“残缺批”,固定开销摊不下去,还可能触发新形状的算法搜索,平均时间就会被拉高。
测试时要么保证总图片数能被 batch 整除,要么剔除尾批再统计。