最近在做深度学习推理优化时,遇到一个“灵异现象”:
同样的模型和输入尺寸,第一次推理要几百毫秒,之后就只要几毫秒
而且batch size 从 1 改成 8,平均推理时间有时还更,有时又突然得离谱。

最近我在测试超分辨率模型 FMEN 的时候,就完整踩了一遍坑。这里把过程、原因和正确的测法梳理一下,分享给大家。


1. 首帧为什么慢?预热的意义

我经常会遇到首帧推理时间较长的情况。在4090上,当处理 224x224 输入图像时,首张图片的推理时间可能达到 400ms,而后续每张图片的推理时间通常会降到仅 6ms

为什么首帧慢?

首帧慢的原因主要有以下几点:

  1. CUDA 上下文初始化
    在第一次使用 GPU 时,CUDA 会为 GPU 创建一个上下文并分配内存池。这是一个比较耗时的过程,特别是当运行的模型比较大或者需要初始化大量资源时。

  2. cuDNN 算法搜索
    在使用深度学习框架(如 PyTorch)时,如果开启了 torch.backends.cudnn.benchmark=True,cuDNN 会在第一次运行时试一遍不同的卷积算法,找出最快的实现。这个过程需要一定的时间,但一旦确定了最佳算法,后续推理就会非常快。

  3. GPU 动态频率调整
    当 GPU 刚开始工作时,尤其是在省电模式下,GPU 的频率通常较低。为了提升性能,GPU 需要花费时间将频率提升至 P0/Boost 模式,这样能提供更高的计算能力。

  4. 缓存冷启动
    在推理开始时,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 上的模型时,输入形状与模型实际推理时的输入形状不一致会影响推理的效率。原因如下:

  1. 动态调整的内存分配
    预热时使用的输入形状和正式推理时的形状不一致,可能导致 GPU 内存的分配和调整过程不符合实际推理需求。GPU 在接收到不同形状的输入时,可能需要调整内存池大小、重新编排计算图,或者调整数据传输方式,这会浪费额外的计算资源,导致预热和正式推理之间的性能差异。

  2. 缓存和内存布局
    GPU 在执行推理时,会利用缓存来提高数据访问速度。如果在预热时使用的输入形状和正式推理时的形状不同,缓存可能无法有效复用,这会影响推理速度。例如,较大的输入可能需要更多的内存带宽和缓存大小,而小的输入可能无法充分利用这些资源。

  3. 优化计算图
    PyTorch 和 cuDNN 会根据输入的形状来优化计算图。例如,某些卷积操作可能会根据输入的特征图尺寸选择不同的优化算法。如果预热时使用的形状与正式推理时的形状差异较大,可能会导致错误的优化决策,从而影响性能。

我写了个代码,使得预热和推理的形状一样

# 3) === 一次性预热(与 DataLoader 形状一致)===
    with torch.inference_mode():
        # 用数据集的第一张确定 C,H,W;用 DataLoader 的 batch_size 确定 N
        sample = 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 1
        dummy = 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_counter

torch.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 张:

  1. 固定开销摊不均:每个 batch 都要付一次 kernel 启动、调度的固定成本。满 8 张能把成本均匀分摊,但只跑 6 张时,成本摊得更少,单张更贵

  2. 算法缓存失效cuDNN 是按形状缓存最优算法的。预热时缓存了 (8,3,224,224),结果尾批变成 (6,3,224,224),相当于新形状 → 可能重新选算法,速度慢

所以在 14 张图的情况下,尾批拖慢整体,平均值被拉高到了 16ms/张。换成 16 张图,正好两个满批 (8+8),固定开销被均匀摊薄,平均值就掉到了 0.8ms/张。


总结

推理时间的“灵异现象”背后是 GPU 工作机制和统计口径的叠加结果:

  1. 首帧慢:CUDA 上下文初始化、cuDNN 算法搜索、GPU 频率提升等固定开销,导致第一张图片远慢于后续。解决方法:预热几次。

  2. 计时要同步:PyTorch 的 GPU 调用是异步的,用 time.time() 只算提交时间。→ 解决方法:perf_counter() + torch.cuda.synchronize()

  3. Batch Size 的陷阱

    • 如果数据集大小不是 batch 的整数倍,尾批是“残缺批”,固定开销摊不下去,还可能触发新形状的算法搜索,平均时间就会被拉高。

    • 测试时要么保证总图片数能被 batch 整除,要么剔除尾批再统计。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐