PyTorch 推理工程(04):CUDA、设备迁移、显存与同步

1. 本节定位

在前文基础上,张量与模型已具备正确的 dtype、设备与推理模式设置;本节讨论设备语义之上的工程问题:主机与设备之间的数据路径、显存占用与峰值、异步执行与同步点对计时的影响,以及上述因素与端到端延迟、吞吐之间的常见对应关系。


2. 设备与参与计算的张量

参与 CUDA 计算时,相关张量与参数须位于同一 CUDA 设备;PyTorch 不会在二元运算中隐式插入跨设备拷贝,设备不一致将直接报错。即便张量均在 GPU 上,仍须单独评估 H2D/D2H 拷贝、显存分配与同步对性能与测量结果的影响。


3. 典型推理数据路径

一次完整的推理,数据是这样流动的:

1
2
3
4
5
6
7
8
9
磁盘 / 网络
↓ 解码/tokenize
CPU 内存(RAM)
↓ CPU→GPU 拷贝(这里有成本!)
GPU 显存(VRAM)
↓ GPU 算子执行
GPU 显存(输出)
↓ 如果需要取结果,GPU→CPU 拷贝(这里也有成本!)
CPU 内存

端到端延迟不仅包含 GPU 内核执行,还包括主机—设备传输与必要的同步点;若传输或同步占比高,仅优化算子难以降低 wall-clock 时间。


4. 标准的设备迁移写法

模型迁移(只做一次)

1
2
3
4
device = "cuda" if torch.cuda.is_available() else "cpu"

model = model.to(device) # 把模型的所有参数和 buffer 递归搬到 GPU
model.eval()

模型参数是固定的,不需要每次推理都搬,初始化时搬一次就够了

输入迁移(每次推理前)

1
x = x.to(device)   # 把这次的输入搬到 GPU

输入每次都不同,所以每次都要搬。

完整标准推理结构

1
2
3
4
5
6
7
8
9
10
device = "cuda" if torch.cuda.is_available() else "cpu"

# 初始化时:一次性搬模型
model = model.to(device)
model.eval()

# 每次推理时:搬输入,然后跑
with torch.inference_mode():
x = x.to(device)
y = model(x)

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
2
3
model = model.to("cuda")
x = torch.randn(4, 128) # ← 还在 CPU
y = model(x) # 反例: 报错

修正:

1
2
x = x.to("cuda")
y = model(x) # 设备一致时可运行

来源 2:Transformer 推理中 mask 或 position_ids 忘了搬

这在 LLM 推理里非常常见:

1
2
3
4
5
6
7
input_ids = tokenizer.encode(text, return_tensors="pt")   # CPU
attention_mask = (input_ids != 0) # CPU

input_ids = input_ids.to("cuda")
# 忘了 attention_mask.to("cuda")

output = model(input_ids, attention_mask=attention_mask) # 反例: 报错

检查项forward() 参与运算的张量须与参数处于同一 device

来源 3:forward() 里新建 Tensor 没指定 device

1
2
3
def forward(self, x):
mask = torch.ones(x.shape[0], x.shape[1]) # 反例: 默认建在 CPU
return x + mask # 如果 x 在 GPU,就报错

正确写法:

1
2
3
4
5
6
def forward(self, x):
# 方式 1:显式指定 device
mask = torch.ones(x.shape[0], x.shape[1], device=x.device)

# 方式 2:用 ones_like,自动继承 device 和 dtype
mask = torch.ones_like(x)

forward() 里新建任何 Tensor,都要显式指定 device=x.device,或用 *_like 系列函数,这是写推理代码的基本习惯。


6. CPU→GPU 数据传输:推理里常被低估的瓶颈

传输有真实成本

.to("cuda") 不是"一个赋值",它背后是一次真实的数据从主存拷贝到显存的操作。

代价有多大?粗略感受:

1
2
PCIe 传输带宽(CPU→GPU):约 10~30 GB/s(取决于 PCIe 代)
GPU 显存带宽(GPU 内部):约 500~3000 GB/s(取决于 GPU 型号)

也就是说,CPU→GPU 的传输比 GPU 内部操作慢 10~100 倍。

当单次推理需搬运的数据量较大且传输时间超过计算时间时,设备侧易处于等待数据状态,表现为 GPU 利用率偏低与整体延迟升高。

搬运频率很重要

不推荐:每次迭代重复 model.to("cuda"),徒增开销。

1
2
3
4
# 每次推理都重新搬模型
for x in dataloader:
model = model.to("cuda") # 多余!每次都搬
y = model(x.to("cuda"))

推荐:模型初始化阶段迁移一次,循环内仅迁移输入。

1
2
3
4
5
6
7
8
# 模型只搬一次
model = model.to("cuda")
model.eval()

# 每次只搬输入
with torch.inference_mode():
for x in dataloader:
y = model(x.to("cuda"))

7. GPU 的执行方式:理解同步与异步

异步执行与同步点

主机与设备各自维护执行队列。在 Python 中调用将工作提交给 GPU 的 API 时,主机往往在命令入队后即返回,内核实际完成时间晚于该返回时刻。

1
y = model(x)

上述语句在主机侧通常仅表示调度完成;若紧接着依赖 y 的数值(如 .item().cpu()),运行时将插入同步以等待设备完成先前入队工作。

同步点(Synchronization Point)

某些操作会让 CPU 停下来,等 GPU 把之前的所有操作执行完,这就叫同步(synchronize)。

触发同步的操作会打断这种"CPU 继续跑,GPU 并行跑"的并行流水:

1
2
3
4
5
6
7
无显式同步(示意):
CPU: [入队A] [入队B] [入队C] [其它主机工作] ...
GPU: [执行A] [执行B] [执行C] ...

插入同步后:
CPU: [入队A] [等待设备] [入队B] ...
GPU: [执行A] ← 主机在此阻塞直至完成

8. 哪些操作会触发同步

理解了同步的概念,就能理解为什么下面这些操作看起来无害,实际上有代价:

.item():把 GPU 标量取回 CPU

1
2
y = model(x)          # 异步发出计算指令
loss = y.sum().item() # ← 触发同步!CPU 必须等 GPU 算完才能拿到这个数

.item() 返回一个 Python 数字,CPU 必须等 GPU 把 sum() 算完才能拿到值,必然同步。

过早 .cpu().numpy()

1
2
3
y = model(x)
y_cpu = y.cpu() # ← 触发同步,等 GPU 算完再拷到 CPU
y_np = y.numpy() # ← 同上

如果后续还要在 GPU 上继续处理 y,过早拉回 CPU 再推回去是多余开销。

用 CPU 时间戳测 GPU 耗时:得到的是错误结果

1
2
3
4
5
6
import time

start = time.time()
y = model(x) # CPU 发完指令就返回了,GPU 还在算
end = time.time()
print(end - start) # ← 测到的是"发指令"的时间,不是 GPU 实际执行时间

这个时间几乎没有参考价值。

正确的 CUDA 计时方式是用 CUDA Events,或者用 torch.cuda.synchronize() 强制同步后再计时(但这会影响实际流水,只适合调试):

1
2
3
4
5
6
7
8
9
10
import time

torch.cuda.synchronize() # 等 GPU 完成之前所有操作
start = time.time()

y = model(x)

torch.cuda.synchronize() # 等 GPU 完成这次推理
end = time.time()
print(f"GPU 推理耗时:{(end - start) * 1000:.2f} ms")

9. non_blocking=True:让数据搬运和计算重叠

理解了 CPU-GPU 异步执行,现在可以理解这个参数:

1
x = x.to("cuda", non_blocking=True)

默认的同步搬运

不加 non_blocking=True 时,.to("cuda") 是同步的:

1
2
CPU: [开始搬数据] ← 等待 → [搬完了] [做其他事]
GPU: ... [收到数据]

CPU 在等数据搬完,期间什么都不做。

异步搬运

加了 non_blocking=True 后(在满足条件时):

1
2
CPU: [发起搬数据] [立刻继续做其他事]
GPU: [接收数据(并行进行)]

CPU 发出拷贝指令后立刻返回,去处理下一件事,GPU 在后台接收数据。

这样,数据搬运和其他 CPU 处理可以重叠,不会互相等待。

为什么"在满足条件时"

non_blocking=True 要发挥作用,源数据必须在 pinned memory(页锁定内存)中。下一节解释原因。


10. pinned memory(页锁定内存):为什么它让传输更快

普通内存的问题

操作系统为了节省资源,可以把很少使用的内存页"换出"到磁盘(swap),这叫内存分页

但 GPU 的 DMA(直接内存访问)要求:拷贝期间,源数据的内存地址必须固定不动,不能被操作系统"换出"。

如果源数据在普通内存里,CUDA 驱动为了确保安全,通常会:

  1. 先在内部分配一块临时的 pinned 内存
  2. 把数据复制到这块临时内存
  3. 再从临时内存拷贝到 GPU

多了一次额外的 CPU 内存拷贝

pinned memory 解决了什么

如果一开始就把数据分配在 pinned memory(页锁定内存,不会被换出)里,CUDA 可以直接从这里拷贝到 GPU,省掉中间的临时拷贝

1
2
普通内存路径:CPU RAM → 临时 pinned buffer → GPU VRAM(两次复制)
Pinned 内存路径:pinned RAM → GPU VRAM(一次复制)

而且 pinned 内存支持 DMA 直传(不经过 CPU),可以和 CPU 并行,这才是 non_blocking=True 能真正异步的前提。

在 DataLoader 里使用

1
2
3
4
5
6
7
8
9
10
11
12
from torch.utils.data import DataLoader, TensorDataset

dataset = TensorDataset(torch.randn(1024, 128))
loader = DataLoader(
dataset,
batch_size=64,
pin_memory=True # ← DataLoader 会把 batch 数据分配在 pinned memory 里
)

for (x,) in loader:
x = x.to("cuda", non_blocking=True) # ← 现在这个异步拷贝才真正有效
y = model(x)

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
2
3
4
5
6
7
8
正常 GPU 内存分配流程(没有 caching allocator):
申请 Tensor → 向 CUDA 驱动要内存 → 用完 → 还给 CUDA 驱动
(频繁的系统调用,开销大)

PyTorch 的 caching allocator 流程:
申请 Tensor → 先在缓存池里找有没有合适的块 → 有就直接复用
用完 → 不还给 CUDA 驱动,而是放回缓存池等下次复用
(减少系统调用,更高效)

结果:Tensor 删除后,显存并没有还给操作系统,而是留在 PyTorch 的缓存池里。从 nvidia-smi 看,显存依然被 PyTorch 进程占着。


12. memory_allocated() vs memory_reserved()

这是理解显存状态的两个核心指标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import torch

if torch.cuda.is_available():
# 创建一个约 4MB 的 Tensor(1024*1024 个 float32,每个 4 字节)
x = torch.randn(1024, 1024, device="cuda")

print(f"allocated: {torch.cuda.memory_allocated() / 1024**2:.1f} MB")
print(f"reserved: {torch.cuda.memory_reserved() / 1024**2:.1f} MB")

# 输出示例:
# allocated: 4.0 MB ← Tensor x 实际占用的显存
# reserved: 20.0 MB ← PyTorch 向 CUDA 申请并管理的总块,包含内部对齐和预留

del x # 删除 Tensor

print(f"\n删除后:")
print(f"allocated: {torch.cuda.memory_allocated() / 1024**2:.1f} MB")
print(f"reserved: {torch.cuda.memory_reserved() / 1024**2:.1f} MB")

# 输出示例:
# allocated: 0.0 MB ← x 确实没了
# reserved: 20.0 MB ← 但缓存没有还给系统,依然被 PyTorch 持有

两个指标的含义

指标 含义 类比
memory_allocated() 当前活跃 Tensor 真正占用的显存 酒店里实际入住的房间数
memory_reserved() PyTorch 向 CUDA 申请并管理的总显存 酒店向业主租下的所有房间数(含空房)

nvidia-smi 看到的更接近 reserved(PyTorch 拿着的总块),不是 allocated(实际用的)。


13. empty_cache():能做什么,不能做什么

1
torch.cuda.empty_cache()

能做什么

把 PyTorch 缓存池里当前未被任何活跃 Tensor 使用的缓存块还给 CUDA 驱动:

1
2
3
4
5
del x
torch.cuda.empty_cache()

print(f"allocated: {torch.cuda.memory_allocated() / 1024**2:.1f} MB") # 0.0 MB
print(f"reserved: {torch.cuda.memory_reserved() / 1024**2:.1f} MB") # 0.0 MB ← 降下来了

不能做什么

不能释放仍被张量引用的显存。模型权重、KV cache 或未析构中间结果仍存活时,调用 empty_cache() 不会改变其占用:

1
2
3
model = model.to("cuda")         # 模型在 GPU 上
torch.cuda.empty_cache() # 没用,模型还在
print(torch.cuda.memory_allocated()) # 还是很高

工程上的正确理解

1
2
3
4
5
显存高的真正原因可能是:
1. 活跃 Tensor 太多(allocted 高)→ 需要减少模型大小、batch size、精度
2. 缓存保留太多(reserved 高但 allocated 低)→ empty_cache() 有效

若需频繁依赖 `empty_cache()` 缓解 OOM,宜先检查是否存在引用未释放、batch 过大或重复缓存等问题。

14. 为什么"用了 GPU 还是慢":系统性排查

这个问题在面试里非常高频,也是 infra 岗和"普通调包选手"的分水岭。

先看完整的可能瓶颈列表:

1
2
3
4
5
6
7
8
9
10
推理延迟的构成:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CPU 预处理(tokenize / resize / normalize)
+ CPU→GPU 数据传输
+ GPU 内核启动延迟(kernel launch overhead)
+ GPU 算子实际执行时间
+ (可能的)GPU→CPU 结果回传
+ Python 控制逻辑
+ 同步等待
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

GPU 算子执行时间通常只是其中一部分。

常见诊断方向

现象 可能原因
GPU 利用率很低(<50%) 数据供给不足(CPU 太慢),或 batch 太小
CPU 占用接近 100% CPU 预处理是瓶颈,或 Python 控制逻辑太重
每次循环都有少量延迟 频繁同步(.item().cpu()
大 batch 和小 batch 速度差不多 传输开销占主导,GPU 算力没吃满
nvidia-smi 里 GPU 核心利用率低 可能有大量时间在等数据或同步

15. 一个完整的高性能推理代码结构

下面这段代码把这一部分的所有核心概念串起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

# ① 选择设备
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"使用设备: {device}")

# ② 构造数据集(模拟真实数据)
dataset = TensorDataset(torch.randn(1024, 128))

# ③ DataLoader:pin_memory 让传输更高效
loader = DataLoader(
dataset,
batch_size=64,
pin_memory=True, # 数据分配在 pinned memory,加速 CPU→GPU 传输
num_workers=2 # 用多个工作进程并行预处理数据
)

# ④ 模型:只搬一次
model = nn.Sequential(
nn.Linear(128, 256),
nn.ReLU(),
nn.Linear(256, 10)
).to(device)

model.eval()

# ⑤ 推理循环
with torch.inference_mode():
for (x,) in loader:
# non_blocking=True:异步拷贝,CPU 不等 GPU 接收完就继续
x = x.to(device, non_blocking=True)

# GPU 计算
y = model(x)

# 注意:不要在这里随意 .item() 或 .cpu()
# 如果必须取值,在循环结束后统一处理

逐行关键点

写法 原因
pin_memory=True DataLoader 输出的 batch 在 pinned memory,GPU 可以直接 DMA
non_blocking=True CPU 异步发起拷贝,不阻塞等待 GPU 接收
模型 .to(device) 只做一次 参数固定,只需初始化时搬一次
inference_mode() 在循环外 不需要每次循环重新进入上下文
不在循环里 .item() 避免每次都触发同步,拉低整体吞吐

16. 模块级 .to() 和 Tensor 级 .to() 的区别

1
2
3
4
5
# Tensor 级:只迁移这一个 Tensor
x = x.to("cuda")

# Module 级:递归迁移所有参数、buffer、子模块
model = model.to("cuda")

也可以同时指定 device 和 dtype:

1
2
3
4
5
# 把模型搬到 GPU 并转成 float16
model = model.to(device="cuda", dtype=torch.float16)

# 输入也要匹配
x = x.to(device="cuda", dtype=torch.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=Truenon_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
2
3
4
5
6
7
8
9
10
import torch
import torch.nn as nn

model = nn.Linear(4, 2)
x = torch.randn(3, 4) # CPU

if torch.cuda.is_available():
model = model.to("cuda")
# 直接运行:y = model(x) ← 观察报错信息
# 修正:让 x 也到 GPU 上再运行

思考:报错信息中如何区分处于 CPU 与 GPU 的张量?

练习 2:forward 里创建 Tensor 的坑

1
2
3
4
5
6
7
8
9
class BadModule(nn.Module):
def forward(self, x):
mask = torch.ones(x.shape[0]) # 没有指定 device!
return x * mask.unsqueeze(1) # 如果 x 在 GPU,这里会报错

class GoodModule(nn.Module):
def forward(self, x):
mask = torch.ones(x.shape[0], device=x.device) # 正确
return x * mask.unsqueeze(1)

动手跑:把两个模块都搬到 GPU,分别用 GPU 上的输入测试。观察 BadModule 的报错和 GoodModule 的正常运行。

练习 3:显存统计实验(需要 GPU)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torch

if torch.cuda.is_available():
def show_mem(label=""):
print(f"{label}")
print(f" allocated: {torch.cuda.memory_allocated() / 1024**2:.1f} MB")
print(f" reserved: {torch.cuda.memory_reserved() / 1024**2:.1f} MB")

show_mem("初始状态")

x = torch.randn(1024, 1024, device="cuda") # ~4 MB
show_mem("创建 x 后")

del x
show_mem("del x 后")

torch.cuda.empty_cache()
show_mem("empty_cache() 后")

思考:del x 后 allocated 和 reserved 分别怎么变化?empty_cache 后呢?为什么?

练习 4:测量真正的 GPU 推理时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import torch
import torch.nn as nn
import time

if torch.cuda.is_available():
model = nn.Sequential(nn.Linear(512, 512), nn.ReLU(), nn.Linear(512, 10)).to("cuda")
model.eval()
x = torch.randn(64, 512, device="cuda")

with torch.inference_mode():
# 先 warm-up(消除第一次的额外开销)
for _ in range(5):
_ = model(x)

# 错误计时方式
start = time.time()
y = model(x)
end = time.time()
print(f"错误方式: {(end - start) * 1000:.3f} ms")

# 正确计时方式
torch.cuda.synchronize()
start = time.time()
y = model(x)
torch.cuda.synchronize()
end = time.time()
print(f"正确方式: {(end - start) * 1000:.3f} ms")

思考:两种计时方式的结果差多少?为什么会有差距?

练习 5:DataLoader 推理循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

device = "cuda" if torch.cuda.is_available() else "cpu"

dataset = TensorDataset(torch.randn(256, 128))
loader = DataLoader(dataset, batch_size=32, pin_memory=(device == "cuda"))

model = nn.Linear(128, 10).to(device)
model.eval()

results = []
with torch.inference_mode():
for (x,) in loader:
x = x.to(device, non_blocking=True)
y = model(x)
results.append(y) # 不要在这里 .cpu(),先攒着

# 循环结束后统一处理
all_outputs = torch.cat(results, dim=0)
print(all_outputs.shape) # torch.Size([256, 10])

思考:为什么在循环里 .cpu() 会拖慢整体速度?循环结束后再统一处理有什么好处?


20. 本节要点与自检

  • 正确管理模型和输入的 device,知道模型只需搬一次
  • 理解 CPU→GPU 传输是推理性能的一部分,不是免费的
  • 理解 GPU 异步执行的基本模型,以及什么是同步点
  • 知道 pin_memorynon_blocking 的原理和适用场景
  • 能区分 memory_allocated()memory_reserved(),知道它们和 nvidia-smi 的关系
  • 正确理解 empty_cache() 的作用边界
  • 看到"用了 GPU 还是慢"时,能从多个角度系统排查,而不是只怀疑算子

21. 小结

推理性能不只是"模型在 GPU 上跑多快",而是"数据如何到 GPU、GPU 如何异步执行、显存如何被管理、以及哪些地方偷偷触发了同步等待"。


系列导航