开场(痛点+类比)

你是不是也遇到这种尴尬:模型推理搬上 GPU 以后,GPU 忙得冒烟,CPU 却站在旁边发呆,单次推理还是要 250 多毫秒?别急,这更像一家串串店:总厨(GPU)在火力全开,一旁的配菜师傅(CPU)却在等菜出锅才去洗下一盆。我们要教的,就是让配菜师傅边等边干活,把流水线串起来。

原理速写

  • GPU 调度是异步的:PyTorch 在背后维护一条 CUDA 任务队列,model(dummy) 只是把“菜谱”塞进队列,GPU 真正开炒时 CPU 已经获得控制权。也就是说,函数返回不代表 GPU 完活,只代表它“接单”了。
  • 同步调用是刹车:一旦你写下 tensor.cpu().numpy()torch.cuda.synchronize() 这类语句,PyTorch 会等到 GPU 完全结束才继续往下走,相当于把正在洗菜的师傅叫停去盯锅。
  • 重叠的核心是解耦:如果 CPU 要做的后处理能使用上一批数据就够了,就不用和本批的 GPU 输出死死绑在一起,本质是一种“提前准备、延后消费”的策略。
  • 双缓冲最好用:最简单的方式是维护一个长度为 2 的队列,GPU 新产出的张量先入队,CPU 优先处理队头的旧张量,让两个厨师形成稳定流水线。
  • 收尾要对齐:流水线上总有最后一个菜没被端出来,收尾时主动 torch.cuda.synchronize() 扫尾,确保“锅里没余汤”再统计耗时。

可以把两个版本的时间线对比着看:

串行版:   GPU[批0推理]---sync---CPU[批0后处理]---GPU[批1推理]---sync---CPU[批1后处理]
重叠版:   GPU[批0推理]---sync---CPU[批0后处理]
           \__CPU等待区处理上一批__/
           GPU[批1推理]---sync---CPU[批1后处理]

第二条时间线里 CPU 和 GPU 的“空窗期”明显被压缩,这就是重叠能省下来的真时间。

实战步骤

1. 环境准备

python3 -m venv venv
source venv/bin/activate
pip install torch==2.3.0 torchvision==0.18.0

在一台有 CUDA 11.8+ 驱动的 Linux 主机上执行,确保 nvidia-smi 能看到显卡。

2. 写出“串行版”基线

保存为 sequential.py

import math
import statistics
import time
import torch
import torchvision.models as models

def heavy_cpu_work():
    acc = 0.0
    for i in range(500_000):
        acc += math.sqrt(i) * math.sin(i) * math.cos(i)
    return acc

@torch.inference_mode()
def main():
    device = "cuda"
    model = models.vit_b_16(weights=None).to(device).eval()
    dummy = torch.randn(10, 3, 224, 224, device=device)

    _ = model(dummy)  # 预热

    durations = []
    for _ in range(50):
        start = time.perf_counter()
        output = model(dummy)
        torch.cuda.synchronize()
        heavy_cpu_work()
        durations.append(time.perf_counter() - start)

    print(f"Sequential avg latency: {statistics.mean(durations):.4f} s")

if __name__ == "__main__":
    main()

运行 python sequential.py,你会看到平均延迟大约在 0.26 s 左右。

代码讲解:

  • @torch.inference_mode() 让模型推理时少走计算图的开销,省得构建梯度。
  • dummy = torch.randn(...) 构造一份固定尺寸的伪数据,方便你在没有真实输入的情况下测试。
  • torch.cuda.synchronize() 是关键:它确保 GPU 的活干完后再让 CPU 去执行 heavy_cpu_work(),于是整段流程完全串行。
  • heavy_cpu_work() 用大量三角函数替代真实后处理,纯粹为了模拟 CPU 密集操作;black_box 没用到是因为 Python 里不需要,但在 Rust 里常见。

3. 改写成“重叠版”

保存为 overlap.py

import collections
import math
import statistics
import time
import torch
import torchvision.models as models

def heavy_cpu_work(batch_id):
    acc = 0.0
    for i in range(500_000):
        value = batch_id * 31 + i
        acc += math.sqrt(value) * math.sin(value) * math.cos(value)
    return acc

@torch.inference_mode()
def main():
    device = "cuda"
    model = models.vit_b_16(weights=None).to(device).eval()
    dummy = torch.randn(10, 3, 224, 224, device=device)

    _ = model(dummy)
    torch.cuda.synchronize()

    backlog = collections.deque()
    durations = []

    for step in range(50):
        start = time.perf_counter()
        backlog.append((step, model(dummy)))

        if len(backlog) >= 2:
            old_step, _ = backlog.popleft()
            heavy_cpu_work(old_step)

        torch.cuda.synchronize()
        durations.append(time.perf_counter() - start)

    while backlog:
        last_step, _ = backlog.popleft()
        heavy_cpu_work(last_step)

    print(f"Overlap avg latency: {statistics.mean(durations):.4f} s")

if __name__ == "__main__":
    main()

运行 python overlap.py,平均延迟一般能掉到 0.17 s 左右 —— 同样的模型,多出三成时间给业务。

代码讲解:

  • backlog = collections.deque() 相当于准备一个托盘,存放 GPU 刚出锅但 CPU 还没处理的菜。
  • 循环里先让 GPU 接手下一批:backlog.append((step, model(dummy)));随后只要队列里超过 1 批,就取出最老的那份交给 CPU 处理。
  • torch.cuda.synchronize() 仍然存在,但位置被挪到了“处理完上一批 CPU 任务之后”,确保 GPU 能尽量长时间不被刹车。
  • 循环结束后还有一个 while backlog,那是为了兜底处理最后一批 CPU 任务,否则最后一道菜会被忘在台面上。

4. 快速对比

python sequential.py
python overlap.py

把两个结果放在一起,就能量化这波厨房分工带来的收益。

常见坑与对策

  • CPU 仍然读取当前批次输出?立刻同步,重叠失效。解决:只处理上一次存好的张量。
  • 忘记最后 torch.cuda.synchronize()?尾批推理时间没算进去。解决:收尾时强制同步一次。
  • CPU 预处理还在等磁盘?流水线被 I/O 卡住。解决:把读盘放在单独线程或使用数据加载器的预取功能。
  • 显存紧张? backlog 越大越占内存。解决:保持队列长度为 2~3,出队后立刻 del 旧张量。
  • 不理解算力利用率?可以同时开 watch -n 0.5 nvidia-smihtop,观察 GPU 带宽保持高位、CPU 核心同样忙碌时,说明重叠已经生效。

总结与下一步

  • GPU 推理默认异步,别让 CPU 在门口干等。
  • CPU/GPU 分工一旦解耦,平均延迟就能直接降下去。
  • PyTorch 上手门槛不高,几行代码就能开启重叠模式。

下一步你可以:

  1. heavy_cpu_work 换成真实的后处理或日志写入,验证收益。
  2. 接入 torch.cuda.streams.Stream 把预处理、推理、后处理拆成多条流水线。
  3. nvtx 标记关键区间,配合 nsys 看时间线,确保重叠真的发生。

读完有收获?不妨留言聊聊你在推理提速上的小妙招,也欢迎转发给同事一起调教流水线;多一个点赞,我们就多写一篇 CPU/GPU 协作的实战故事。