我第一次用 GPU 跑 PyTorch 训练时,差点被折磨疯。明明装的是带 CUDA 支持的 PyTorch 版本,训练却和 CPU 没什么区别,打开资源监控一看,显存占用始终是 0%。后来才搞清楚:不是 PyTorch 不会用 GPU,而是所有张量都默认待在 CPU 内存里,GPU 压根不知道要干活。这篇文章就把 GPU 训练的完整链路拆开讲清楚:环境怎么确认、代码怎么改、多卡怎么选、报错怎么排、提速怎么做,希望帮你少走这些弯路。适合刚开始写 PyTorch、目前还在 CPU 环境训练,想把手头模型真正跑到 GPU 上的开发者。
很多人把 GPU 训练想成“代码装对版本就行”,实际上远不止。一个训练任务真正用 GPU 跑起来,要同时满足几件事:GPU 驱动正常、CUDA 运行时可用、当前 PyTorch 编译时带的 CUDA 版本匹配、数据张量显式放到 cuda 设备、模型参数也放在 cuda 设备。这几个环节缺一个,有时候不报错,只是慢;有时候直接报一堆和版本、设备相关的错误。
1. 为什么你的训练没有用上 GPU:先搞清楚 CUDA、驱动和 PyTorch
在动手改代码之前,我建议先花十分钟理解 GPU 训练的基础结构。CPU 和 GPU 不是同一块内存,CPU 有自己的内存条,GPU 有自己的显存。数据要交给 GPU 计算,必须先把数据从内存复制到显存;计算完的结果如果还要输出,又得复制回来。这个复制动作本身是有开销的,所以模型和数据能不能长期驻留在显存里,非常关键。
1.1 驱动、CUDA、PyTorch 之间的关系
你可以把 GPU 想象成一个外包团队,CPU 是项目经理。项目经理不亲自算,而是把任务发给外包团队。但外包团队需要一套“沟通协议”,这套协议在 GPU 生态里就是 CUDA。
具体来说:
- GPU 驱动负责让操作系统识别显卡,并对外提供最基本的计算接口。
- CUDA 是建立在驱动之上的一套并行计算平台,负责管理 GPU 上的线程、显存、算子调度。
- PyTorch 编译时可以选择使用哪个 CUDA 版本,运行时通过 CUDA 与 GPU 通信。
- cuDNN 是专门加速卷积、池化等常用深度学习的库,PyTorch 在 GPU 上跑卷积网络时基本都会调用它。
很多安装失败的场景,本质就是这三层中某一层不匹配。比如 PyTorch 要求 CUDA 11.8,但 NVIDIA 驱动太老,可能就报CUDA driver version is insufficient for CUDA runtime version。有些时候你会看到代码能跑,但 GPU 利用率只有几十秒一次脉冲,这多半是驱动能用,但实际算子没有跑到核心上。
1.2 用三行代码确认 GPU 环境
写训练脚本前,先确认 PyTorch 说它能用 GPU。我一般会新建一个env_check.py,把最基础的三件事打出来:
import torch print("CUDA available:", torch.cuda.is_available()) print("GPU count:", torch.cuda.device_count()) print("Device name:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "No GPU")如果torch.cuda.is_available()返回False,别急着查代码,大概率是安装环节的问题。常见原因有:
- 装了 CPU 版本的 PyTorch。用
pip list看包装名,CPU 版本通常包含cpu字样。 - 机器上根本没有可用 GPU,或者驱动没装好。先在终端执行
nvidia-smi,如果提示找不到命令,就是驱动层面的问题。 - 在虚拟环境里装了代码,但 GPU 驱动是系统级的,环境隔离导致 PyTorch 找不到库。
把这三个清掉,再跑一次torch.cuda.is_available()往往就好了。
1.3 安装时刻最容易犯的版本错位
我一个同事遇到过特别典型的情况:nvidia-smi显示驱动支持 CUDA 12.1,于是他用 pip 装了一个 CUDA 12.1 的 PyTorch,结果torch.cuda.is_available()还是 False。原因是驱动支持的 CUDA 版本只是“上限”,PyTorch 运行时依赖的 CUDA 驱动版本必须不超过这个上限,但 PyTorch 本身需要的 CUDA Toolkit 组件,不一定和你驱动里显示的版本一致。
最不容易出错的安装方式,是直接从 PyTorch 官网的安装命令里选对应系统和 CUDA 版本。比如你需要 CUDA 11.8,就不要用默认的pip install torch,而是用官网给的那条带--extra-index-url的安装命令。安装完以后,再把torch.cuda.is_available()跑一遍,确认环境自洽了再写模型。
2. 让数据和模型“搬”到 GPU:Tensor 与 Module 的三个关键步骤
很多人第一次从 CPU 切换到 GPU,代码只改了两行:model.cuda()和x = x.cuda()。这方向没错,但有个更通用的写法,也更容易排查设备问题:定义device变量,所有数据、模型统一.to(device)。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")这个写法的好处是:同一份代码,在机器有 GPU 时自动走 GPU,没有 GPU 时退回 CPU,不至于把实验环境钉死在一台机器上。
2.1 张量迁移的核心:.to(device)不是可选项
PyTorch 设计成默认在 CPU 上创建张量,原因很简单:不是每台机器都有 GPU。所以一组数据想要在 GPU 上算,必须显式告诉 PyTorch“这段数据放到显存里”。.to(device)就是这样一条指令。
但要注意:.to()对 Tensor 来说通常返回一个新对象,并不是原地修改。比如:
x_cpu = torch.randn(10, 10) x_gpu = x_cpu.to("cuda") print(x_cpu.is_cuda) # False print(x_gpu.is_cuda) # True如果你只写了x= x_cpu.to("cuda")但后续计算仍然使用x_cpu,那 GPU 还是没起作用。更推荐的方式是直接用device创建张量:
x = torch.randn(128, 16, device=device)这样从一开始就避免了一次从 CPU 到 GPU 的拷贝。数据规模大时,这一步的差异会很明显。
2.2 模型迁移:model.to(device)会改变模块内部状态
张量需要迁移,模型的参数同样是张量。nn.Module.to()会对模块内部所有Parameter和注册的buffer统一迁移,所以模型只需要一条语句:
model = MyModel().to(device)注意,有些buffer是你自己在__init__里用register_buffer()注册的,比如 BN 的 running_mean、running_var。如果你只迁移了模型参数,忘了 buffer,模型 forward 时创建在 CPU 的 buffer 和 GPU 输入一拼接,就会报 device mismatch 错误。
另外,优化器最好在模型迁移之后再创建:
model = model.to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)这样优化器从第一步就绑定到 GPU 上的参数,避免某些场景下优化器内部的参数副本还留在 CPU,徒增问题。
2.3 一个最小可运行 GPU 训练片段
把前面几条串起来,组成一个最小的训练循环:
import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = nn.Sequential( nn.Linear(16, 32), nn.ReLU(), nn.Linear(32, 1), ).to(device) loss_fn = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) x = torch.randn(128, 16) y = torch.randn(128, 1) dataset = TensorDataset(x, y) loader = DataLoader(dataset, batch_size=32, shuffle=True) for epoch in range(3): for batch_x, batch_y in loader: batch_x = batch_x.to(device) batch_y = batch_y.to(device) pred = model(batch_x) loss = loss_fn(pred, batch_y) optimizer.zero_grad() loss.backward() optimizer.step() print(f"epoch {epoch}, loss: {loss.item():.4f}")这个代码里最关键的两行是batch_x = batch_x.to(device)和batch_y = batch_y.to(device)。没有这两行,模型虽然已经在 GPU 上,但输入还在 CPU,训练要么报错,要么因为张量跨设备复制变得极其缓慢。
3. 完整训练循环改造:从 CPU 到 GPU 的五个必须动刀的地方
如果你已经有一个在 CPU 上能跑通的训练脚本,不要只改模型和输入。我改造过的训练脚本里,有五个地方几乎每次都会碰到,按顺序检查可以少踩一半坑。
3.1 改造顺序:模型、数据、加载器,一个都不能少
我建议按下面的顺序逐个检查:
device定义:放在脚本最前面,后面所有.to(device)都引用它。- 模型迁移:
model = model.to(device)。 - 损失函数和优化器:损失函数不需要特别迁移,因为它自己没有参数;优化器在模型迁移后创建即可。
- 训练循环内:每个 batch 的输入和标签都必须
.to(device)。 - 验证/测试循环:同样要对输入和标签做
.to(device),如果只在训练循环里迁移,验证时照样会炸。
如果训练循环里写了with torch.no_grad():,也不能少这两行迁移。no_grad()只是关闭梯度记录,并不会改变张量所在的设备。
还有一个经常被忽略的点:DataLoader的参数。GPU 训练时推荐开启num_workers和pin_memory:
loader = DataLoader( dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True, )num_workers让多个子进程提前读取数据,避免 GPU 等 CPU 处理数据。pin_memory=True会让数据放到内存的锁页区域,之后把数据搬到显存时走更快的内存拷贝路径。注意num_workers不是越大越好,通常不超过 CPU 物理核心数;在 Windows 下如果想用多进程加载,还需要把训练逻辑放在if __name__ == "__main__":里,否则会重复启动子进程。
3.2 用nvidia-smi验证 GPU 是真的在跑
改完代码,不要只看 loss 在下降就以为 GPU 在工作。我之前就经常犯这个错:模型小、数据量小,CPU 机器跑得慢,GPU 机器跑得快但显存占用低,我以为 GPU 没用上,其实只是模型太小。
要验证 GPU 是否真正参与计算,开一个终端执行:
watch -n 1 nvidia-smi重点看两列:
Memory-Usage:显存占用。GPU-Util:GPU 计算单元利用率。
如果GPU-Util一直 0%,大概率数据没搬到 GPU,或者程序根本没在跑。如果显存有占用但利用率忽上忽下,问题往往在数据读取或预处理环节,CPU 成了瓶颈。
更精确一点,可以在脚本里监控当前进程的显存分配:
print(torch.cuda.memory_allocated(0) / 1024**2, "MB")memory_allocated返回 PyTorch 实际占用的显存,不是整个进程从 GPU 上拿到的全部内存,比nvidia-smi更容易看出模型和 batch 的显存需求。
3.3 别把数据留在 CPU:常见隐性瓶颈
除了输入和标签,还要留意这些隐形成分:
- 数据增强逻辑。如果你的
__getitem__里有大量 CPU 上的图像变换、文本切分、音频处理,GPU 会在每个 batch 之间空等。可以把耗时预处理提前离线完成,或者在加载器里多用几个 worker。 - Python 对象混进张量。比如列表里存了不同长度的序列,在 collate 时返回了 list,后续
.to(device)只对 Tensor 有效,list 里的 Tensor 不会被自动迁移。 - 每次调用
.item()或.tolist(),数据都会从显存拷回 CPU。如果每个 batch 都要打印 loss,可以考虑累积几个 batch 再打印一次,减少这种隐式拷贝。 - 验证阶段忘记
.eval()或no_grad(),导致验证集每个 batch 都存储了中间激活值,显存越堆越高,最后 OOM。
这些问题不会直接报错,但会让你的 GPU 利用率波动很大。我的排查习惯是:先用很小的数据集跑一个 epoch,监控显存曲线;如果GPU-Util稳定在 90% 以上,才认为当前脚本的 GPU 使用是健康的。
4. 单机多卡怎么选:DataParallel 和 DistributedDataParallel 实战
当你只有一张 GPU 时,前面的内容已经够用。一旦你有好几张卡,想同时用上,就会面临选择:DataParallel还是DistributedDataParallel。
4.1 DataParallel:适合快速验证
DataParallel用起来最简单,一行代码:
model = nn.DataParallel(model)它的做法是把输入 batch 按 GPU 数量切分,分别送进每张卡,计算完再把输出汇总回来。以前我拿到新机器、想临时跑个实验,都是先用它验证代码逻辑。
但有几个坑你迟早会踩:
- 原来模型的
state_dict里键名会多一层module.。保存权重时如果你希望以后能直接加载到原始单卡模型上,最好这样处理:
torch.save(model.module.state_dict(), "best.pth")- batch size 必须能被 GPU 数量整除,否则分不平。
- 如果模型 forward 返回的是自定义结构,比如 dict 或者多个 Tensor,
DataParallel对返回值的处理很不稳定,你可能要额外写拆分逻辑。 - 主 GPU 负载远高于其他卡,因为所有操作都要经过主卡汇总。
DataParallel本质上是对单卡代码的“最省事包装”,适合快速验证,不适合长时间训练大模型。我自己的经验是:一旦模型大到一张卡放不下,或者训练时间超过半天,就果断换DistributedDataParallel,省心太多。
4.2 生产环境更推荐 DistributedDataParallel
DistributedDataParallel,简称 DDP,控制粒度更细。它不是把一个大 batch 切分给不同卡,而是启动多个进程,每个进程负责一张卡。每张卡有独立的前向和反向,梯度通过通信库做 all-reduce,之后每个进程再用相同的梯度更新自己的参数。
相比DataParallel,DDP 的优势在于:
- 没有单卡热点,负载更均匀。
- 梯度通信与计算重叠,扩展性更好。
- 天然支持多机多卡,代码逻辑和单机多卡一致。
代价是代码稍微复杂一点。你需要引入torch.distributed、DistributedSampler,并且手动管理进程生命周期。
4.3 DDP 单机多卡最小示例
这是一个单机多卡 DDP 的最小训练脚本:
import os import torch import torch.distributed as dist import torch.nn as nn from torch.nn.parallel import DistributedDataParallel as DDP from torch.utils.data import DataLoader, TensorDataset, DistributedSampler def train_worker(rank, world_size): dist.init_process_group("nccl", rank=rank, world_size=world_size) torch.manual_seed(0) device = torch.device(f"cuda:{rank}") model = nn.Sequential( nn.Linear(16, 32), nn.ReLU(), nn.Linear(32, 1), ).to(device) model = DDP(model, device_ids=[rank]) dataset = TensorDataset(torch.randn(256, 16), torch.randn(256, 1)) sampler = DistributedSampler( dataset, num_replicas=world_size, rank=rank, shuffle=True, ) loader = DataLoader(dataset, batch_size=32, sampler=sampler, pin_memory=True) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.MSELoss() for epoch in range(5): sampler.set_epoch(epoch) for batch_x, batch_y in loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) loss = loss_fn(model(batch_x), batch_y) optimizer.zero_grad() loss.backward() optimizer.step() if rank == 0: print(f"epoch {epoch}, loss: {loss.item():.4f}") dist.destroy_process_group() if __name__ == "__main__": world_size = torch.cuda.device_count() torch.multiprocessing.spawn( train_worker, args=(world_size,), nprocs=world_size, )DDP 有一个隐藏坑:如果不用DistributedSampler,每个进程都会看到同一个 dataset,训练数据完全重叠,所有进程的梯度也一样,多卡等效于单卡。用上DistributedSampler后,数据会被自动切成world_size份,每个进程只拿属于自己的那份。
另外,DistributedSampler创建后,每个 epoch 开始前必须调用sampler.set_epoch(epoch),否则所有 epoch 的数据乱序都是从同一个随机状态生成的,等于没 shuffle。
5. GPU 训练高频报错与排查手记:跑一下就歇菜怎么办
GPU 训练报错类型其实有限。只要把最常见的几种吃透,基本能应付 90% 的问题。
5.1 CUDA out of memory:不只 batch 太大的问题
最经典的报错是:
RuntimeError: CUDA out of memory. Tried to allocate 512 MiB第一反应是减小batch_size,这没问题,但实际原因可能不止一个:
- batch 太大,模型一次前向和反向保存的中间激活值超过了显存上限。
- 代码里把每个 batch 的结果都存进了一个 list,用于后续统计,导致验证集全部留在显存。
- 训练循环忘记
optimizer.zero_grad(),梯度不断累积,显存一点点被吃掉。 - 使用了
numpy和 CUDA tensor 反复切换,导致显存碎片化。
排查时先用最直接的手段:把batch_size减半,看是否能跑通。如果减半仍然 OOM,检查是不是有累积变量。
torch.cuda.empty_cache()只能清掉 PyTorch 的缓存块,不能释放正在被引用的 tensor 内存。所以它治标不治本,不建议在训练循环里频繁调用,反而可能降低性能。
5.2 device mismatch:Expected all tensors to be on the same device
这类报错长这样:
RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!优先级很高,因为所有设备不统一的计算都会触发它。常见原因:
- 模型放在
cuda上,输入忘了.to(device)。 - 输入已迁移,但标签还留在 CPU。
- 自定义模块内部用
torch.zeros(...)创建了 CPU 张量,再和 GPU 输入做拼接或运算。 - 损失函数里的
target不在模型同一设备。
排查技巧很直观:在报错前打印关键对象各自的设备。
print(next(model.parameters()).device) # 模型参数所在的设备 print(batch_x.device) # 输入设备 print(batch_y.device) # 标签设备如果模型参数在cuda:0,输入在cpu,问题一目了然。还有一个小经验:自定义模型的forward里需要创建临时张量时,尽量基于已有张量创建,比如x.new_zeros(...),而不是torch.zeros(...)。前者的设备和x保持一致,后者默认在 CPU,容易在模型前向内部埋雷。
5.3 版本和编译兼容问题
这类问题常出现在新机器或新环境上,关键字包括undefined symbol、version、driver。
CUDA driver version is insufficient for CUDA runtime version:驱动太老,升级 GPU 驱动,或者换一个要求更低 CUDA 版本的 PyTorch。libcudnn.so: cannot open shared object file:cuDNN 库缺失或路径不对。优先用官网安装命令重装 PyTorch,不要在同一个环境里混用 conda 和 pip 安装深度学习相关包,既容易出现 OpenBLAS 冲突,也可能出现 cuDNN 版本不一致。Invalid device ordinal:代码里写了cuda:1,但当前环境只暴露了一张卡。用CUDA_VISIBLE_DEVICES=0可以控制哪些 GPU 可见,这时候只有 0 号可访问。No module named 'torch.distributed':正常 PyTorch 都会带,如果少了,大概率是安装包不完整,重装比排查更快。
5.4 高频报错速查表
把常见问题和解决动作汇总成一张表,方便实际调试时直接对照:
| 报错信息 | 常见原因 | 处理方式 |
|---|---|---|
CUDA out of memory | batch 过大、显存泄漏、缓存碎片 | 降低 batch、释放历史变量、减少缓存中驻留 |
Expected all tensors on the same device | 输入、标签、模型、buffer 设备不一致 | 统一设备,逐对象打印 device |
CUDA driver version is insufficient | 驱动版本低于 PyTorch 要求 | 升级驱动或换低版本 CUDA 的 PyTorch |
libcudnn.so not found | cuDNN 库缺失或环境混乱 | 重装匹配版本的 PyTorch |
Invalid device ordinal | 访问了不可见的 GPU 编号 | 用CUDA_VISIBLE_DEVICES控制可见卡 |
| 训练不报错但 GPU 利用率低 | 数据加载慢、pin_memory 未开、CPU 预处理重 | 增加 worker、开 pin_memory、优化预处理 |
6. 从能跑到跑得快:显存释放、混合精度、梯度累积的取舍
代码能完整跑通后,下一步是优化。GPU 训练的性能优化不是玄学,它本质上是资源权衡:显存、计算速度、代码复杂度,三者很难全要。我一般按三个层次来调。
6.1 自动混合精度 AMP:最值得先做的加速方法
混合精度训练,英文叫 Automatic Mixed Precision,简单说就是在训练时用 FP16 做前向和反向计算,用 FP32 保存主权重,再通过缩放把梯度稳定在合理范围。
PyTorch 里的启用方式非常轻量:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for batch_x, batch_y in loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() with autocast(): pred = model(batch_x) loss = loss_fn(pred, batch_y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()autocast负责在前向和 loss 计算时对合适的算子自动使用 FP16,GradScaler负责反向传播时的梯度缩放,防止小梯度在 FP16 下直接变成 0。
AMP 的好处很直接:显存占用降低,训练速度在某些模型上可以提升 20% 到 50%。坏处是代码多了一点样板,而且不是所有模型都适合。如果你用的是老卡,或者模型本身很小,收益可能不明显。我的建议是:跑通单卡后,先开一次 AMP 做 baseline 对比,再决定要不要长期开启。
6.2 梯度累积:用时间换显存,但要留意 BN
显存不够,又不想换小模型,梯度累积是最常用的办法。它的思路是:先用多个小 batch 累积梯度,然后再更新一次参数,模拟更大的 batch size。
accum_steps = 4 for i, (batch_x, batch_y) in enumerate(loader): batch_x, batch_y = batch_x.to(device), batch_y.to(device) pred = model(batch_x) loss = loss_fn(pred, batch_y) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()这里把 loss 除以accum_steps,相当于用多个小 batch 的梯度平均来近似大 batch 的梯度。如果忘了除,累积 4 步之后梯度会放大 4 倍,很容易导致参数更新异常。
但梯度累积并不等价于真正的大 batch。最明显的是 BatchNorm:BN 是在每个小 batch 内独立统计均值和方差的,累积梯度改变不了这一点。如果你训练的是目标检测、语义分割这类依赖 batch size 的任务,累积能压低显存,但 BN 的表现可能需要单独调。
6.3 性能调优的顺序感和工具
做训练提速,我建议按顺序排查,而不是先上各种高级技巧。
- 先看数据加载。
num_workers是不是太低、pin_memory有没有开、数据增强是不是太重。 - 再看模型计算。模型太小时 GPU 利用率本来就不会高,可以尝试增大 batch,让算力吃满。
- 然后看显存。如果
nvidia-smi显示显存占用很高但利用率不错,可以保持;如果总是 OOM,考虑 AMP、梯度累积、减少中间变量。 - 最后再考虑并行。数据并行 DDP 能解决多卡利用率问题,但不能解决单卡计算效率低的问题。
调试时可以顺手用 PyTorch 自带的概率性性能分析工具,不过新人最直观的还是两个命令:
watch -n 1 nvidia-smi topnvidia-smi看 GPU 利用率和显存,top看 CPU 和内存。如果 CPU 占用很高、GPU 利用率低,就加数据加载的 worker 或者优化预处理;如果两者都高,说明瓶颈在模型本身,再考虑算子融合和并行方案。
最后说一个我自己的习惯。每次开新训练,我都会在脚本最前面跑一次环境自检,打印device、显存总量、剩余可用显存和当前 GPU 名称。环境一变,第一眼就能发现问题。还有一个很实用的小技巧:如果某个 batch 莫名其妙 OOM,先把它切成两半跑通,再逐批定位,多数都是模型 forward 里残留了 CPU 张量,或者前向把所有特征都存了下来。GPU 训练不是玄学,它只是要求你时刻知道每一份数据、每一个参数在哪里。把这一点想清楚,后面的坑基本都控制得住。