☰
PyTorch GPU训练全攻略:从CUDA配置到性能调优
2026/10/13 23:03:20 网站建设 项目流程

我第一次用 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,别急着查代码,大概率是安装环节的问题。常见原因有:

  1. 装了 CPU 版本的 PyTorch。用pip list看包装名,CPU 版本通常包含cpu字样。
  2. 机器上根本没有可用 GPU,或者驱动没装好。先在终端执行nvidia-smi,如果提示找不到命令,就是驱动层面的问题。
  3. 在虚拟环境里装了代码,但 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 改造顺序:模型、数据、加载器,一个都不能少

我建议按下面的顺序逐个检查:

  1. device定义:放在脚本最前面,后面所有.to(device)都引用它。
  2. 模型迁移:model = model.to(device)。
  3. 损失函数和优化器:损失函数不需要特别迁移,因为它自己没有参数;优化器在模型迁移后创建即可。
  4. 训练循环内:每个 batch 的输入和标签都必须.to(device)。
  5. 验证/测试循环:同样要对输入和标签做.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:常见隐性瓶颈

除了输入和标签,还要留意这些隐形成分:

  1. 数据增强逻辑。如果你的__getitem__里有大量 CPU 上的图像变换、文本切分、音频处理,GPU 会在每个 batch 之间空等。可以把耗时预处理提前离线完成,或者在加载器里多用几个 worker。
  2. Python 对象混进张量。比如列表里存了不同长度的序列,在 collate 时返回了 list,后续.to(device)只对 Tensor 有效,list 里的 Tensor 不会被自动迁移。
  3. 每次调用.item()或.tolist(),数据都会从显存拷回 CPU。如果每个 batch 都要打印 loss,可以考虑累积几个 batch 再打印一次,减少这种隐式拷贝。
  4. 验证阶段忘记.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,这没问题,但实际原因可能不止一个:

  1. batch 太大,模型一次前向和反向保存的中间激活值超过了显存上限。
  2. 代码里把每个 batch 的结果都存进了一个 list,用于后续统计,导致验证集全部留在显存。
  3. 训练循环忘记optimizer.zero_grad(),梯度不断累积,显存一点点被吃掉。
  4. 使用了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 memorybatch 过大、显存泄漏、缓存碎片降低 batch、释放历史变量、减少缓存中驻留
Expected all tensors on the same device输入、标签、模型、buffer 设备不一致统一设备,逐对象打印 device
CUDA driver version is insufficient驱动版本低于 PyTorch 要求升级驱动或换低版本 CUDA 的 PyTorch
libcudnn.so not foundcuDNN 库缺失或环境混乱重装匹配版本的 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 性能调优的顺序感和工具

做训练提速,我建议按顺序排查,而不是先上各种高级技巧。

  1. 先看数据加载。num_workers是不是太低、pin_memory有没有开、数据增强是不是太重。
  2. 再看模型计算。模型太小时 GPU 利用率本来就不会高,可以尝试增大 batch,让算力吃满。
  3. 然后看显存。如果nvidia-smi显示显存占用很高但利用率不错,可以保持;如果总是 OOM,考虑 AMP、梯度累积、减少中间变量。
  4. 最后再考虑并行。数据并行 DDP 能解决多卡利用率问题,但不能解决单卡计算效率低的问题。

调试时可以顺手用 PyTorch 自带的概率性性能分析工具,不过新人最直观的还是两个命令:

watch -n 1 nvidia-smi top

nvidia-smi看 GPU 利用率和显存,top看 CPU 和内存。如果 CPU 占用很高、GPU 利用率低,就加数据加载的 worker 或者优化预处理;如果两者都高,说明瓶颈在模型本身,再考虑算子融合和并行方案。

最后说一个我自己的习惯。每次开新训练,我都会在脚本最前面跑一次环境自检,打印device、显存总量、剩余可用显存和当前 GPU 名称。环境一变,第一眼就能发现问题。还有一个很实用的小技巧:如果某个 batch 莫名其妙 OOM,先把它切成两半跑通,再逐批定位,多数都是模型 forward 里残留了 CPU 张量,或者前向把所有特征都存了下来。GPU 训练不是玄学,它只是要求你时刻知道每一份数据、每一个参数在哪里。把这一点想清楚,后面的坑基本都控制得住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询