PyTorch推理工程:04 CUDA、设备迁移、显存与同步
PyTorch 推理工程(04):CUDA、设备迁移、显存与同步
1. 本节定位
在前文基础上,张量与模型已具备正确的 dtype、设备与推理模式设置;本节讨论设备语义之上的工程问题:主机与设备之间的数据路径、显存占用与峰值、异步执行与同步点对计时的影响,以及上述因素与端到端延迟、吞吐之间的常见对应关系。
2. 设备与参与计算的张量
参与 CUDA 计算时,相关张量与参数须位于同一 CUDA 设备;PyTorch 不会在二元运算中隐式插入跨设备拷贝,设备不一致将直接报错。即便张量均在 GPU 上,仍须单独评估 H2D/D2H 拷贝、显存分配与同步对性能与测量结果的影响。
3. 典型推理数据路径
一次完整的推理,数据是这样流动的:
1 | 磁盘 / 网络 |
端到端延迟不仅包含 GPU 内核执行,还包括主机—设备传输与必要的同步点;若传输或同步占比高,仅优化算子难以降低 wall-clock 时间。
4. 标准的设备迁移写法
模型迁移(只做一次)
1 | device = "cuda" if torch.cuda.is_available() else "cpu" |
模型参数是固定的,不需要每次推理都搬,初始化时搬一次就够了。
输入迁移(每次推理前)
1 | x = x.to(device) # 把这次的输入搬到 GPU |
输入每次都不同,所以每次都要搬。
完整标准推理结构
1 | device = "cuda" if torch.cuda.is_available() else "cpu" |
5. 最常见的设备报错
1 | RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu! |
这个报错的三种典型来源:
来源 1:输入忘了搬到 GPU
1 | model = model.to("cuda") |
修正:
1 | x = x.to("cuda") |
来源 2:Transformer 推理中 mask 或 position_ids 忘了搬
这在 LLM 推理里非常常见:
1 | input_ids = tokenizer.encode(text, return_tensors="pt") # CPU |
检查项:forward() 参与运算的张量须与参数处于同一 device。
来源 3:forward() 里新建 Tensor 没指定 device
1 | def forward(self, x): |
正确写法:
1 | def forward(self, x): |
在
forward()里新建任何 Tensor,都要显式指定device=x.device,或用*_like系列函数,这是写推理代码的基本习惯。
6. CPU→GPU 数据传输:推理里常被低估的瓶颈
传输有真实成本
.to("cuda") 不是"一个赋值",它背后是一次真实的数据从主存拷贝到显存的操作。
代价有多大?粗略感受:
1 | PCIe 传输带宽(CPU→GPU):约 10~30 GB/s(取决于 PCIe 代) |
也就是说,CPU→GPU 的传输比 GPU 内部操作慢 10~100 倍。
当单次推理需搬运的数据量较大且传输时间超过计算时间时,设备侧易处于等待数据状态,表现为 GPU 利用率偏低与整体延迟升高。
搬运频率很重要
不推荐:每次迭代重复 model.to("cuda"),徒增开销。
1 | # 每次推理都重新搬模型 |
推荐:模型初始化阶段迁移一次,循环内仅迁移输入。
1 | # 模型只搬一次 |
7. GPU 的执行方式:理解同步与异步
异步执行与同步点
主机与设备各自维护执行队列。在 Python 中调用将工作提交给 GPU 的 API 时,主机往往在命令入队后即返回,内核实际完成时间晚于该返回时刻。
1 | y = model(x) |
上述语句在主机侧通常仅表示调度完成;若紧接着依赖 y 的数值(如 .item()、.cpu()),运行时将插入同步以等待设备完成先前入队工作。
同步点(Synchronization Point)
某些操作会让 CPU 停下来,等 GPU 把之前的所有操作执行完,这就叫同步(synchronize)。
触发同步的操作会打断这种"CPU 继续跑,GPU 并行跑"的并行流水:
1 | 无显式同步(示意): |
8. 哪些操作会触发同步
理解了同步的概念,就能理解为什么下面这些操作看起来无害,实际上有代价:
.item():把 GPU 标量取回 CPU
1 | y = model(x) # 异步发出计算指令 |
.item() 返回一个 Python 数字,CPU 必须等 GPU 把 sum() 算完才能拿到值,必然同步。
过早 .cpu() 或 .numpy()
1 | y = model(x) |
如果后续还要在 GPU 上继续处理 y,过早拉回 CPU 再推回去是多余开销。
用 CPU 时间戳测 GPU 耗时:得到的是错误结果
1 | import time |
这个时间几乎没有参考价值。
正确的 CUDA 计时方式是用 CUDA Events,或者用 torch.cuda.synchronize() 强制同步后再计时(但这会影响实际流水,只适合调试):
1 | import time |
9. non_blocking=True:让数据搬运和计算重叠
理解了 CPU-GPU 异步执行,现在可以理解这个参数:
1 | x = x.to("cuda", non_blocking=True) |
默认的同步搬运
不加 non_blocking=True 时,.to("cuda") 是同步的:
1 | CPU: [开始搬数据] ← 等待 → [搬完了] [做其他事] |
CPU 在等数据搬完,期间什么都不做。
异步搬运
加了 non_blocking=True 后(在满足条件时):
1 | CPU: [发起搬数据] [立刻继续做其他事] |
CPU 发出拷贝指令后立刻返回,去处理下一件事,GPU 在后台接收数据。
这样,数据搬运和其他 CPU 处理可以重叠,不会互相等待。
为什么"在满足条件时"
non_blocking=True 要发挥作用,源数据必须在 pinned memory(页锁定内存)中。下一节解释原因。
10. pinned memory(页锁定内存):为什么它让传输更快
普通内存的问题
操作系统为了节省资源,可以把很少使用的内存页"换出"到磁盘(swap),这叫内存分页。
但 GPU 的 DMA(直接内存访问)要求:拷贝期间,源数据的内存地址必须固定不动,不能被操作系统"换出"。
如果源数据在普通内存里,CUDA 驱动为了确保安全,通常会:
- 先在内部分配一块临时的 pinned 内存
- 把数据复制到这块临时内存
- 再从临时内存拷贝到 GPU
多了一次额外的 CPU 内存拷贝。
pinned memory 解决了什么
如果一开始就把数据分配在 pinned memory(页锁定内存,不会被换出)里,CUDA 可以直接从这里拷贝到 GPU,省掉中间的临时拷贝:
1 | 普通内存路径:CPU RAM → 临时 pinned buffer → GPU VRAM(两次复制) |
而且 pinned 内存支持 DMA 直传(不经过 CPU),可以和 CPU 并行,这才是 non_blocking=True 能真正异步的前提。
在 DataLoader 里使用
1 | from torch.utils.data import DataLoader, TensorDataset |
pin_memory 不是免费的
- 占用的是页锁定内存,过度使用会增加 RAM 压力
- 系统能分配的 pinned 内存有上限
- 在小数据、低频率场景,收益可能不明显
工程原则:
pin_memory=True+non_blocking=True是一对常见优化组合,适合高吞吐推理服务或大 batch 训练。但任何优化都要测量后再决定加不加。
11. 显存管理:为什么"看起来总是不释放"
初学者常见困惑
常见现象包括:
- 某些 Tensor 明明删了(
del x或超出作用域) - 某个 batch 推理完了
- 但
nvidia-smi显示显存还是很高
就以为发生了"显存泄漏"。
实际原因:CUDA caching allocator
PyTorch 内部有一个显存缓存分配器(CUDA caching allocator)。
缓存分配器可视为在驱动之上的一层显存管理:
1 | 正常 GPU 内存分配流程(没有 caching allocator): |
结果:Tensor 删除后,显存并没有还给操作系统,而是留在 PyTorch 的缓存池里。从 nvidia-smi 看,显存依然被 PyTorch 进程占着。
12. memory_allocated() vs memory_reserved()
这是理解显存状态的两个核心指标:
1 | import torch |
两个指标的含义:
| 指标 | 含义 | 类比 |
|---|---|---|
memory_allocated() |
当前活跃 Tensor 真正占用的显存 | 酒店里实际入住的房间数 |
memory_reserved() |
PyTorch 向 CUDA 申请并管理的总显存 | 酒店向业主租下的所有房间数(含空房) |
nvidia-smi 看到的更接近 reserved(PyTorch 拿着的总块),不是 allocated(实际用的)。
13. empty_cache():能做什么,不能做什么
1 | torch.cuda.empty_cache() |
能做什么
把 PyTorch 缓存池里当前未被任何活跃 Tensor 使用的缓存块还给 CUDA 驱动:
1 | del x |
不能做什么
不能释放仍被张量引用的显存。模型权重、KV cache 或未析构中间结果仍存活时,调用 empty_cache() 不会改变其占用:
1 | model = model.to("cuda") # 模型在 GPU 上 |
工程上的正确理解
1 | 显存高的真正原因可能是: |
14. 为什么"用了 GPU 还是慢":系统性排查
这个问题在面试里非常高频,也是 infra 岗和"普通调包选手"的分水岭。
先看完整的可能瓶颈列表:
1 | 推理延迟的构成: |
GPU 算子执行时间通常只是其中一部分。
常见诊断方向
| 现象 | 可能原因 |
|---|---|
| GPU 利用率很低(<50%) | 数据供给不足(CPU 太慢),或 batch 太小 |
| CPU 占用接近 100% | CPU 预处理是瓶颈,或 Python 控制逻辑太重 |
| 每次循环都有少量延迟 | 频繁同步(.item()、.cpu()) |
| 大 batch 和小 batch 速度差不多 | 传输开销占主导,GPU 算力没吃满 |
nvidia-smi 里 GPU 核心利用率低 |
可能有大量时间在等数据或同步 |
15. 一个完整的高性能推理代码结构
下面这段代码把这一部分的所有核心概念串起来:
1 | import torch |
逐行关键点:
| 写法 | 原因 |
|---|---|
pin_memory=True |
DataLoader 输出的 batch 在 pinned memory,GPU 可以直接 DMA |
non_blocking=True |
CPU 异步发起拷贝,不阻塞等待 GPU 接收 |
模型 .to(device) 只做一次 |
参数固定,只需初始化时搬一次 |
inference_mode() 在循环外 |
不需要每次循环重新进入上下文 |
不在循环里 .item() |
避免每次都触发同步,拉低整体吞吐 |
16. 模块级 .to() 和 Tensor 级 .to() 的区别
1 | # Tensor 级:只迁移这一个 Tensor |
也可以同时指定 device 和 dtype:
1 | # 把模型搬到 GPU 并转成 float16 |
device 和 dtype 在推理优化里经常一起考虑。把 float32 改成 float16 不只省显存,在支持半精度的 GPU 上通常也更快。混合精度见下一篇。
17. 推理代码的坑:一张速查表
| 坑 | 错误写法 | 正确写法 |
|---|---|---|
| forward 里新建 Tensor 用 CPU | torch.ones(n) |
torch.ones(n, device=x.device) |
| 每次推理都搬模型 | model.to("cuda") 放在循环里 |
只在初始化时搬一次 |
循环里频繁 .item() |
loss = y.sum().item() 在每次循环 |
批量处理后统一取值 |
过早 .cpu(),后续还要 GPU 操作 |
中间结果 .cpu() 再 .cuda() |
保持在 GPU 上直到真正需要 |
| 用 CPU 时间戳测 GPU 延迟 | time.time() 直接包 model(x) |
先 cuda.synchronize(),再计时 |
把高 reserved 误判为泄漏 |
频繁调 empty_cache() |
先查 allocated 再判断 |
18. 面试常见问题
Q:为什么参与同一计算的 Tensor 必须在同一 device 上?
A:PyTorch 不会隐式跨设备协调计算,设备不一致通常直接报错。
Q:pin_memory=True 和 non_blocking=True 各自的作用是什么?为什么常搭配?
A:pin_memory=True 让数据分配在页锁定内存,CUDA 可以直接 DMA 传输,省掉中间拷贝;non_blocking=True 让这次传输异步发起,CPU 不等 GPU 接收完就继续。只有源数据在 pinned memory 时,non_blocking 才真正能异步,所以二者常搭配。
Q:nvidia-smi 看到的显存和 memory_allocated() 为什么不一样?
A:PyTorch 有 caching allocator,显存不会立刻还给系统,而是留在缓存池复用。memory_allocated() 是活跃 Tensor 的实际占用,memory_reserved() 是 PyTorch 握在手里的总量,nvidia-smi 看到的接近后者。
Q:empty_cache() 能解决显存不足的问题吗?
A:不能。它只能把缓存池里没被活跃 Tensor 使用的块还给 CUDA 驱动,不能释放活跃 Tensor。真正显存不足要减小 batch size、模型规模或使用量化。
Q:为什么 y = model(x) 后紧接着 time.time() 测到的时间不准?
A:GPU 是异步执行的,.to("cuda") 和 model(x) 只是把指令发给 GPU,CPU 立刻返回,GPU 还在执行。time.time() 在 CPU 侧打点,测到的只是"发指令"的时间,不是 GPU 实际执行时间。需要 torch.cuda.synchronize() 后再计时。
19. 思考题
练习 1:复现设备报错并修复
1 | import torch |
思考:报错信息中如何区分处于 CPU 与 GPU 的张量?
练习 2:forward 里创建 Tensor 的坑
1 | class BadModule(nn.Module): |
动手跑:把两个模块都搬到 GPU,分别用 GPU 上的输入测试。观察
BadModule的报错和GoodModule的正常运行。
练习 3:显存统计实验(需要 GPU)
1 | import torch |
思考:del x 后 allocated 和 reserved 分别怎么变化?empty_cache 后呢?为什么?
练习 4:测量真正的 GPU 推理时间
1 | import torch |
思考:两种计时方式的结果差多少?为什么会有差距?
练习 5:DataLoader 推理循环
1 | import torch |
思考:为什么在循环里
.cpu()会拖慢整体速度?循环结束后再统一处理有什么好处?
20. 本节要点与自检
- 正确管理模型和输入的 device,知道模型只需搬一次
- 理解 CPU→GPU 传输是推理性能的一部分,不是免费的
- 理解 GPU 异步执行的基本模型,以及什么是同步点
- 知道
pin_memory和non_blocking的原理和适用场景 - 能区分
memory_allocated()与memory_reserved(),知道它们和nvidia-smi的关系 - 正确理解
empty_cache()的作用边界 - 看到"用了 GPU 还是慢"时,能从多个角度系统排查,而不是只怀疑算子
21. 小结
推理性能不只是"模型在 GPU 上跑多快",而是"数据如何到 GPU、GPU 如何异步执行、显存如何被管理、以及哪些地方偷偷触发了同步等待"。
