☰
不买显卡也能跑深度学习:CPU训练与云GPU实战指南
2026/10/7 17:04:58 网站建设 项目流程

设备管理器里那几张显卡,每天被好几个人排队用,调度全靠吼。实验室唯一一张2080Ti,跑ImageNet都要跟师兄商量三天。更别提你手里这个项目——深度学习模型训练,论文要结果,导师不拨款,显卡抢不到。

这种处境我太熟了。早几年我自己做毕业设计,实验室唯一的GPU还被师姐的GAN占着连续跑了两个月,我硬是用一台四核CPU的破工作站把一篇顶会论文的实验跑完了。所以这篇文章不是教你“凑合”,而是把“无GPU深度学习”这件事系统的拆开:哪些方案真的可行,哪些是伪需求,每一步该怎么做,踩过的坑我都给你标出来。内容面向两类人:实验室确实没有像样GPU的研究生,以及想在自己笔记本上入门深度学习、又不想花几千块买显卡的自学者。照着做,你完全能把实验跑起来、把论文写出来。

1. 先别急着装环境:CPU到底能跑什么、不能跑什么

很多人的误区是一上来就装PyTorch、下数据集,结果训练到半夜发现一个epoch要跑十几个小时,心态直接崩了。动手之前,先搞清楚CPU训练的真实边界,比任何优化技巧都重要。

1.1 一张表看懂CPU算力的“行”与“不行”

我测试过不少典型任务,用一个相对可靠的参考标准(六核十二线程的普通桌面CPU,比如i5-12400或R5 5600X,配16GB内存)来对比:

任务类型CPU训练体验说明与建议
经典CNN(LeNet、ResNet-18)在CIFAR-10/自定义小数据集能用,但难受一个epoch 5~20分钟,小模型总训练时间控制在几小时内可接受
文本分类(LSTM、Transformer小模型)视序列长度而定短文本没问题,长序列会让CPU内存先爆掉
目标检测(YOLO系列、Faster R-CNN)基本劝退单张图前向推理都要好几秒,训练不现实,建议云GPU
图像分割(UNet等)小图可以256x256以下分辨率、浅层UNet勉强能跑
大规模预训练(BERT、GPT微调)特别难受CPU可以微调小模型,7B以上的大模型别想
数据预处理/推理部署CPU的强项推理部署、数据增强、评估指标计算等在CPU上效率很高

这个表说得很清楚:CPU能做的是“小模型+小数据+可控时间”的实验,以及训练后的推理部署工作。你的策略应该是——在小规模上把代码调通、把思路验证完,需要大算力时再去租GPU跑最终版本。

1.2 为什么CPU训练这么慢?卡在哪个环节

CPU训练慢,不完全是“核心数少”这么简单。真正坑人的是这三个地方:

内存带宽瓶颈。深度学习训练的核心操作是大量小矩阵乘法,比如把一个 [64, 512] 的矩阵乘上 [512, 512] 的权重。GPU的显存带宽能到几百GB/s,普通DDR4内存带宽只有二三十GB/s,这决定了CPU每次要把数据从内存搬到寄存器计算时,搬运本身就耗掉了大部分时间。这不是你多开几个线程能解决的。

单指令流处理能力差距。CPU一个核心一次能处理一条指令,GPU一个计算单元能并行处理几十上百条。矩阵乘法这种天然并行的任务,GPU的并行架构就像是几千个人同时搬砖,CPU再怎么超频也只是一个人搬得更快一点。

数据搬运路径太长。训练时每个batch要经历“硬盘读数据→内存→CPU缓存→寄存器→算完写回内存”这么长的链路。很多人为了让CPU跑得快,直接加大batch size,结果内存带宽反而撑不住,每个epoch时间不但没缩短,反而因为内存换页把系统搞到卡死。

1.3 认清现实后的正确姿势

接受“CPU只能做小规模实验”这个前提之后,训练策略就要跟着变。我在无GPU阶段定了几条规矩,每条都是拿时间换来的:

  • 数据集不是完整数据直接灌进去,先做子集验证。比如完整数据有10万张图,先用1000张跑通流程,确认模型能收敛、loss在下降,再考虑全量。
  • 模型先用最小的网络结构试。ResNet-18足够验证思路时,绝不直接上ResNet-50。等代码逻辑全部验证完,再换大模型跑最终实验。
  • epoch数量要算着来。CPU上训练每多一个epoch都是实打实的时间,初始设定值不要拍脑袋。先跑3个epoch观察loss下降趋势,如果掉了30%以上,再决定是加大epoch还是先收手调参。

这一节的结论很简单:不要指望CPU像GPU一样“大力出奇迹”,而是用“小步快跑”的方式把工程问题先解决掉。后面每一节,都是围绕这个思路展开的具体操作。

2. 代码侧的精打细算:CPU训练提速的五个核心操作

当你确定要在本地CPU上跑训练,代码层面能抠出来的速度提升,其实是超出很多人预期的。下面这五个操作,我在不同项目里反复验证过,每一步都能带来实打实的改变。

2.1 线程数设置:不是越大越好

PyTorch在CPU上默认会使用所有可用的核心,但这恰恰是新手最容易犯的错。当你把16个线程全用上时,线程切换开销极大,反而比只开8个还慢。我自己试过多次,对六核十二线程的CPU来说,最优值通常是物理核心数,也就是6,而不是逻辑线程数12。

设置方法很简单,放在训练脚本的最前面:

import torch import os # 在导入torch之后、创建任何tensor之前设置 torch.set_num_threads(6) # 用物理核心数,不是逻辑线程数 os.environ["OMP_NUM_THREADS"] = "6" os.environ["MKL_NUM_THREADS"] = "6"

这三行代码同时设置了PyTorch内部的线程池、OpenMP的并行线程和Intel MKL数学库的线程数。三个地方必须一致,否则会出现线程池冲突,反而更慢。我见过有人只设置了torch.set_num_threads,忘了设置环境变量,结果PyTorch内部的优化器用的线程数和MKL库的线程数不一致,训练速度直接折半。

2.2 让MKL/oneDNN接管矩阵运算

如果你的CPU是Intel的(实验室机器大概率是),PyTorch默认就启用了oneDNN(原MKL-DNN)对卷积、矩阵乘法等算子做优化。但很多人不知道这个功能可以被显式加强。在模型初始化之后加上:

# 启用oneDNN的自调优,让PyTorch自动为当前CPU选择最快的卷积算法 torch.backends.mkldnn.enabled = True torch.backends.cudnn.enabled = False # 这行在CPU上没实际作用,但可以避免一些混淆

CPU训练时,这个设置配合channels_last内存格式,能让ResNet系列在CPU上的推理速度提高20%到40%。如果用的是AMD的CPU,不用强求MKL,PyTorch的原生实现配好线程数也足够用。

2.3 数据加载是CPU训练里最容易忽视的巨坑

GPU训练时数据加载慢一点无所谓,因为GPU算得快,数据队列容易吃满。但CPU训练时,数据加载和模型计算争抢的是同一批CPU核心,如果DataLoader不做优化,你会发现模型计算在等数据,数据加载也在抢CPU,两头都在空转。

我通常的做法是:

from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=4, # 注意:不是越大越好,通常2~4合适 pin_memory=False, # CPU训练时这个没用,设为True反而多一次内存复制 prefetch_factor=4, # 每个worker预取4个batch persistent_workers=True # 多个epoch时复用worker,避免反复创建销毁 )

num_workers设成4是我在多数主流CPU上试出来的甜点值。设成8以上虽然数据加载速度还能快一点,但会占用大量内存,而且会让训练进程的CPU资源变少。persistent_workers这个参数很多人忽略,它能在多个epoch之间保持数据加载线程存活,省去每次重新初始化的开销。如果你的PyTorch版本较老不支持这些参数,优先升级到1.12以上。

另外,批量图不要直接读原图再Resize,而是先把所有图片缩放到较小尺寸后缓存成.npy或LMDB格式。我在一个医学图像项目里,光这一项优化,数据加载时间从每次epoch 80秒降到了8秒。这是因为CPU训练时,图像解码(尤其是大图JPEG解码)消耗的CPU周期,比卷积计算还多。

2.4 Batch Size和梯度累积:CPU的专属方案

GPU训练通常喜欢大batch size,因为GPU并行计算能力强,batch越大效率越高。CPU上反过来,batch太大内存带宽率先撑不住,所以最优batch size往往偏小。我实测下来,在16GB内存的机器上,batch size 16到32是最均衡的范围。

但batch太小会让梯度估计噪声大,模型不容易收敛。解决办法是用梯度累积来模拟更大的batch:

accumulation_steps = 4 # 相当于把batch扩大4倍 optimizer.zero_grad() for i, (inputs, labels) in enumerate(train_loader): outputs = model(inputs) loss = criterion(outputs, labels) # 除以累积步数,让梯度平均而不是累加 loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

这个写法的关键是loss = loss / accumulation_steps。如果不做这个归一化,累积4步后的梯度相当于用了4倍的学习率,模型可能直接发散。我自己踩过这个坑,loss在前几十个iteration直接冲到了NaN。梯度累积让CPU能用小batch跑出大batch的效果,代价只是多几次前向反向的重复计算,对内存带宽的压力小了很多。

2.5 早停和周期性的模型保存

CPU训练一个epoch可能要几个小时,一旦训到一半断电或者崩了,没有保存的checkpoint意味着全部白跑。所以我在CPU训练代码里一定会加上:

from torch.optim.lr_scheduler import ReduceLROnPlateau best_loss = float('inf') patience = 0 for epoch in range(num_epochs): train_loss = train_one_epoch(model, train_loader, optimizer, criterion) val_loss = validate(model, val_loader, criterion) # 保存最优模型 if val_loss < best_loss: best_loss = val_loss torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_loss': best_loss, }, 'best_checkpoint.pth') patience = 0 else: patience += 1 # 早停 if patience >= 5: print(f"Early stopping at epoch {epoch}") break

这里保存的不只是模型权重,还有优化器状态和当前epoch。这样即使中断了,你也可以从best_checkpoint.pth恢复继续训练,而不是从头再来。ReduceLROnPlateau这种自适应学习率调整在CPU训练里特别重要,因为你没有那么多时间反复手动试学习率。

3. 云GPU租用:把好钢用在刀刃上

单纯靠CPU优化解决不了所有问题。当你的模型复杂度上来了、数据集变大了,该花点钱租GPU就花,这是性价比最高、也最省时间的选择。但怎么租、怎么衔接本地代码,这里面的道道可不少。

3.1 直接选租用平台时盯准这五个配置

市面上的GPU云平台很多,功能上大同小异,真正决定体验的是下面几个细节:

配置项最低标准原因
GPU型号RTX 3090 / A5000起步24GB显存能覆盖绝大多数深度学习实验,包括中小模型微调
显存至少16GB小于这个值跑不了Batch Normalization在大batch下的稳定训练
预置镜像必须带PyTorch和CUDA省去装驱动环境的半天时间
存储至少50GB SSD数据集加模型权重加环境,30GB很容易就打不住了
计费方式按小时计费跑完就释放实例,不跑不花钱

选平台的时候,别只看GPU单价,来看看镜像是否完善、数据上传是否方便。有的平台GPU一小时便宜五块钱,但上传数据集要折腾半天,省下的钱还不够时间成本。我自己是优先用那些开箱即用、自带PyTorch完整环境的主流平台。这类服务通常都提供按量付费的实例,选择时先看看它们的GPU是否包含3090或以上的卡型,同时确认一下存储的数据卷创建方式——因为你需要一个独立于实例的数据盘,这样实例释放了,数据集和checkpoint还在,下次开机直接挂载就行,不用重新上传。

3.2 从CPU本地到云GPU的代码迁移清单

代码在本地CPU上已经调通了,现在要搬到云GPU上跑。这个迁移不需要大改代码,但有三个地方必须处理,不然会出各种奇怪的bug:

设备切换。不要在代码里写死cuda:0,用全局统一的设备判断:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 之后所有模型和数据都走 .to(device)

这样同一份代码既能在本地CPU上跑,也能在云GPU上跑,切换零成本。我见过有人把代码改成model.cuda(),回本地跑的时候又忘了改回来,直接报错。

数据加载路径。上传数据集到云平台后,把数据路径改成云端的绝对路径。建议把数据集放在与实例分离的数据盘上,而不是实例系统盘里,这样实例释放后数据不丢,下次还能接着用。

NumPy的随机种子。GPU训练有大量异步操作,如果不设随机种子,迁移后的结果可能和本地对不上。在代码开头加上:

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

注意torch.backends.cudnn.benchmark = False这行,它会让训练变慢一点点,但换来的是结果完全可复现。如果你不需要严格复现,这行可以去掉,cudnn的自动调优会让训练快不少。

3.3 省钱策略:计费规则和checkpoint时间管理

我见过太多人租了GPU,开着机器干瞪眼——代码有bug调试了半天,GPU在闲置计费。要想把成本压到最低,有几个原则非常重要:

抢占式实例永远优先。很多平台提供抢占式或Spot实例,价格是按量付费的两到三折,缺点是实例可能被中断。但深度学习的checkpoint机制天然适配这种场景——每跑完几个epoch自动存一次模型,中断了就从最新的checkpoint恢复。我在云上跑一个微调任务,用抢占式实例省了差不多60%的费用。

先本地调试,再上云跑活。所有代码逻辑、数据预处理、模型结构,全部在本地CPU上用小子集调试通过,确认训练流程能跑通三个小epoch不报错,再上云租实例。云端只干“用完整数据训练”这一件事。这样能避免在云上反复调试,把GPU的每一分钟都用在真正的计算上。

定时保存要勤。因为抢占式实例随时可能被回收,云端训练的checkpoint保存频率要比本地高得多。我给云训练的代码加了一个逻辑:每完成一个epoch就保存一次模型权重到数据盘,同时保留最近三份checkpoint轮换覆盖。这样即使实例被回收,最多丢一个epoch的进度。

账号里的数据盘才是可靠的。别把模型权重保存在实例本地磁盘上,而是保存到独立的数据盘。实例被回收后,数据盘通常还能保留一段时间,把最新权重下载到本地,再按需开新实例继续跑。

3.4 免人工盯训练的自动化作业

云GPU上还有个很实用的技巧——把训练脚本写成自动作业的形式。很多平台支持提交训练任务后关闭网页,训练在后台跑,跑完自动把结果上传到对象存储或发邮件通知。这样你不必开着电脑盯着训练进度,该睡觉睡觉,第二天起来直接看结果。

如果用的是Jupyter环境,把这个命令挂在后台跑:

nohup python train.py --config config_cloud.yaml > train.log 2>&1 &

然后定期查看train.log确认loss下降正常。nohup加&的组合,是我在云端跑长时间训练任务的标准操作,比手动开着一个终端窗口反复看可靠太多了。

4. 模型侧“降级”:选对结构和权重,比选对硬件更重要

无GPU环境下,模型选型直接决定你能不能出结果。同样的任务,用ResNet-50还是MobileNet,在CPU上训练时间能差五倍。这一节讲的就是怎么在算法层面做减法。

4.1 迁移学习是无GPU环境下性价比最高的方案

训练一个模型最消耗计算量的部分,其实是底层特征的提取——边缘、纹理、形状这些通用特征。这些特征在所有图像任务里都是通用的,完全不需要从头学。所以无GPU环境下,我的习惯是:永远从预训练权重开始,绝不随机初始化。

实现方式有两种,分别应对不同场景:

固定特征提取器。加载预训练模型,冻结backbone所有参数,只训练最后的分类层。这种方法计算量最小,一个epoch在CPU上可能只需要几分钟,而且效果往往不差。适合你的数据集跟预训练数据(比如ImageNet)比较接近的情况。

import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) for param in model.parameters(): param.requires_grad = False # 替换最后一层 num_ftrs = model.fc.in_features model.fc = torch.nn.Linear(num_ftrs, num_classes) # 只优化分类层参数 optimizer = torch.optim.Adam(model.fc.parameters(), lr=0.001)

全模型微调。所有参数都参与训练,但初始权重仍然用预训练模型,学习率调小一些。这种方法计算量大一点,但效果上限更高。在CPU上跑的话建议只对比较小的预训练模型(ResNet-18、EfficientNet-B0)做全量微调。

很多人忽视的一个细节是:预训练权重加载时,如果你的分类类别数和预训练模型不同,最后一层结构对不上,model.load_state_dict会报错。需要忽略不匹配的key,只加载backbone部分的权重:

state_dict = torch.load('pretrained.pth') # 移除fc层的权重 state_dict.pop('fc.weight', None) state_dict.pop('fc.bias', None) model.load_state_dict(state_dict, strict=False)

4.2 轻量级网络在CPU上的速度对比

如果你实在需要从头训练(比如数据集和ImageNet差异太大、或者你就是想发方法类论文),网络结构的选择就非常关键了。我在同样的CPU上、同样的数据集、同样的训练轮数下对比过几个常用网络的单epoch时间:

网络结构参数量CPU单epoch耗时(相对值)适用场景
MobileNetV3-Small约250万1x(最快)嵌入式/移动端场景,CPU友好
EfficientNet-B0约530万1.5x精度/速度均衡
ResNet-18约1170万2.5x通用基准,教师模型
ResNet-50约2560万6xCPU训练偏重,建议云GPU
VGG-16约1.38亿12x(最慢)CPU上几乎不可训练

这个表给我最大的教训是:参数量跟训练速度不是线性关系。VGG-16参数量是ResNet-18的十倍多,但训练时间只差了约五倍,因为VGG的计算主要集中在前面的卷积层,全连接层的参数虽多算起来却快。所以选网络时不能只看参数量,关键是看卷积计算量大不大。MobileNet系列的深度可分离卷积在CPU上特别占便宜,精度损失可控,是CPU训练的优先选择。

4.3 知识蒸馏的思路值得提前了解

如果你最终的目的是在一个小模型上部署,或者你导师要求你用某个复杂模型做实验,知识蒸馏是一个绕过硬件的思路。具体做法是:

  • 用云GPU把大模型(教师模型)训练好或下载开源权重;
  • 在本地CPU上训练一个小模型(学生模型),损失函数不仅包括和小标签的交叉熵,还包括和大模型输出的KL散度。

学生模型不需要重新在大数据集上训练,它只需要“模仿”教师模型的输出。这个过程的计算量大幅缩减,因为在本地只需要跑一次推理,从教师模型那里拿到软标签,然后在小模型上训练。

这里有一个实际可用的实现方式:

def distillation_loss(student_output, teacher_output, labels, alpha=0.7, temperature=4.0): """ student_output: 学生模型的logits teacher_output: 教师模型的logits (预先计算好) labels: 硬标签 alpha: 软标签损失的权重 temperature: 温度系数 """ import torch.nn.functional as F hard_loss = F.cross_entropy(student_output, labels) soft_student = F.log_softmax(student_output / temperature, dim=1) soft_teacher = F.softmax(teacher_output / temperature, dim=1) soft_loss = F.kl_div(soft_student, soft_teacher, reduction='batchmean') soft_loss = soft_loss * (temperature ** 2) # 补偿温度缩放的梯度尺度 return alpha * soft_loss + (1 - alpha) * hard_loss

注意最后的soft_loss * (temperature ** 2)。这是知识蒸馏里的标准做法,因为温度会缩小logits之间的差异,导致梯度变小,乘以温度的平方能抵消这个影响。不乘的话,温度设成4的时候梯度会小到训练几乎不动。温度系数一般取2到6之间,数值越大软标签的分布越平滑,学生模型能学到更多类别间的相似性信息。

4.4 输入分辨率也值得压缩

CPU训练的另一个隐藏成本是输入图片的大小。224x224的输入和128x128的输入,在卷积层计算量上相差约三倍,而精度损失在很多任务上只有一两个点。我习惯在项目一开始就把输入分辨率压到尽可能小,等代码跑通、确认能收敛后,再逐步提高分辨率看精度变化。

如果论文里需要展示你确实是用了224x224这种标准设置,可以分两阶段:在本地CPU上用低分辨率调通所有代码,最后用云GPU跑一次标准分辨率的完整实验,作为最终结果。

5. 训练之后的CPU部署:无GPU环境的另一条出路

很多人只盯着训练这一步,其实“训练结束后的模型部署”才是无GPU环境发挥价值的更实用方向。你的研究如果涉及把模型落地到实验室的检测系统、嵌入式设备或服务端环境,CPU推理优化反而比训练更值得写进论文或交付文档里。

5.1 ONNX Runtime加速CPU推理

PyTorch模型直接用CPU做推理,速度往往不尽如人意。但把模型转换成ONNX格式,再用ONNX Runtime跑,推理速度通常能提升1.5到3倍。转换过程很简单:

import torch model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, # 模型 dummy_input, # 输入的示例 "model.onnx", # 输出路径 input_names=["input"], # 输入名 output_names=["output"], # 输出名 dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}, # 动态batch opset_version=17 # 转ONNX时用的算子集版本 )

dynamic_axes参数值得好好理解。它标记了batch维度是动态的,这样部署后你既可以一次推理一张图,也可以一次推理一个batch的图,灵活性很高。opset_version这个参数建议用17或更高,太低的话新版PyTorch里的某些算子无法转换。

推理时用ONNX Runtime:

import onnxruntime as ort import numpy as np session = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"]) session.set_providers(["CPUExecutionProvider"]) def predict(image_tensor): input_data = image_tensor.numpy().astype(np.float32) result = session.run(None, {"input": input_data}) return result[0]

推理速度的提升主要来自ONNX Runtime内部的图优化和算子融合——它把多个小操作合并成一个大的算子,减少了数据在内存里的反复搬运。这个过程对模型精度没有任何影响,纯粹是工程优化。

5.2 动态量化:把模型从FP32压到INT8

如果ONNX Runtime的加速还不够,还可以叠加动态量化,把模型权重从32位浮点数压缩成8位整数。这个过程几乎不会影响精度,但推理速度和内存占用都有显著改善:

from onnxruntime.quantization import quantize_dynamic, QuantType # 只量化权重,不量化激活值,所以叫动态量化 quantized_model = quantize_dynamic( "model.onnx", "model_quantized.onnx", weight_type=QuantType.QUInt8 # 无符号8位整数 )

动态量化之后,模型文件大小大约缩小到原来的四分之一,推理速度再提升一倍左右。我在CPU上部署目标检测模型时,把PyTorch原模型的推理从200毫秒降到了60毫秒左右,这个速度已经可以满足很多实时性要求不高的应用场景了。

更激进的方案是静态量化,需要先准备校准数据集统计激活值的分布,然后在量化时把激活也变成INT8。速度提升比动态量化更明显,但精度损失也可能更大。我的建议是先用动态量化,如果精度在可接受范围内就直接用;不够再考虑静态量化。

5.3 用CPU做大规模的批量推理

CPU做训练慢,但做推理其实不慢,尤其在batch size较大、模型不太深的场景下,CPU多核并行处理大量推理请求的吞吐量相当可观。我在一个图像检索项目里,用CPU对一万张图提取特征,每张图耗时不到100毫秒,总共十几分钟就跑完了。这个场景下CPU完全够用。

大规模批量推理的代码也很简单:

def batch_extract_features(model, dataloader): model.eval() features_list = [] with torch.no_grad(): for images in dataloader: features = model(images) features_list.append(features) return torch.cat(features_list, dim=0)

这里的关键是torch.no_grad()。它告诉PyTorch不需要计算梯度,前向推理时不会为反向传播保存中间变量,内存占用大幅下降,推理速度也有提升。我见过有人部署推理时忘了加这个装饰器,一个batch的图在CPU上跑出了训练时的内存占用,直接被系统杀掉。

6. 常见问题与排查技巧:CPU训练实战避坑手册

最后这部分是我在无GPU环境下摸爬滚打积累起来的排查经验。每个问题我都踩过,写出来帮你省掉那些不必要的调试时间。

6.1 训练速度突然变慢,先看是不是内存爆了

CPU训练最容易出现“跑着跑着越来越慢”的情况。绝大多数时候,这是因为内存被吃光了,系统开始疯狂使用虚拟内存(Windows的页面文件或Linux的swap)。当你看到训练速度每况愈下,同时硬盘指示灯在疯狂闪烁时,不用怀疑,一定是内存溢出。

排查方法很简单:打开任务管理器或htop,看看内存占用率是不是接近100%。如果是,先减少num_workers(从4降到2),再调小batch_size(从32降到16),基本都能解决。我当时检查到一个项目里数据增强做了很多冗余的复制操作,把整个增强流程移到了GPU之前的预处理阶段,内存立刻减半。

6.2 输入张量报错“Expected all tensors on the same device”

这个报错在无GPU和GPU混用的场景里特别常见。用torch.device('cuda' if torch.cuda.is_available() else 'cpu')统一管理设备之后,还会出现这种错误,多半是混合精度训练时某些中间张量没有正确转换设备。

排查方法是在关键节点打印张量的设备信息:

print(f"images.device: {images.device}") print(f"model.device: {next(model.parameters()).device}") print(f"labels.device: {labels.device}")

这三个张量必须保持一致,否则就手动.to(device)。最常见的两种情况是labels忘了转换、以及某些预训练模型内部带buffer没有跟着模型走。

6.3 模型不收敛:先别调参数,检查预处理

CPU训练本来就很慢,如果你训了三个epoch loss纹丝不动,心疼那点时间是对的。但这时候不要急着改学习率或加正则化,先做最基础的检查:

  • 标签是不是从0开始连续编号?分类模型要求类别索引从0到num_classes-1,如果数据集标签文件是1-based,模型输出却在0-based索引上计算loss,模型怎么训都训不对。
  • 数据增强里有没有使用in-place操作改掉原图?transforms可以在每个epoch迭代时对同一份数据做不同增强,但如果你误用了原地翻转这类操作,多次迭代后数据会被增强“污染”,模型学到的还是那些图片,只是扭曲程度越来越大。
  • 归一化参数是否匹配?ImageNet预训练模型需要用mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]做归一化。如果用了别的数据集统计量,输入分布和预训练权重的分布对不上,迁移学习的优势就全没了。

这些坑非常低级的,但每一条都让我的实验白跑过。

6.4 反向传播报错“Ran out of memory”

CPU“Out of Memory”和GPU的显示内存耗尽不是同一回事。CPU环境看到这个报错,通常不是单个张量太大,而是累积的内存碎片加上大量中间变量导致的。排查步骤按优先级排:

  1. 把batch_size直接砍半,这通常是最快的解决办法。
  2. 检查代码里有没有保留多余的计算图。训练循环里不能有类似outputs = model(inputs)这样的操作被存放在某个列表里,每迭代一次列表里多存一份,内存就线性增长。及时del不需要的中间变量。
  3. 在安全地方加torch.cuda.empty_cache()没用(那是GPU),CPU环境用gc.collect()强制内存回收,虽然PyTorch的内存池不一定完全把空间还给系统,但至少能缓解碎片化。

我见过的CPU内存溢出,有一半以上是因为训练循环里不断向列表添加loss记录,跑了几千个iteration之后,那个列表已经存了几千个张量。解决办法是用running_loss = running_loss * 0.9 + loss.item() * 0.1这种滑动平均,而不是记一整个list。

6.5 离线环境装不上包怎么办

实验室的机器很多是内网离线环境,PyTorch装不上是常事。我的经验是提前在能联网的机器上把安装包下载好,拷贝过去离线安装。关键是选择正确的.whl文件:

# 在联网机器上 pip download torch torchvision --index-url https://download.pytorch.org/whl/cpu -d ./local_pkgs # 拷贝到离线机器后 pip install --no-index --find-links=./local_pkgs torch torchvision

注意上面用的是cpu的轮子,而不是cu118或cu121这种CUDA版本的轮子。在无GPU机器上装CUDA版PyTorch是常见错误,它不会报错,还会白白占用十几个G的硬盘空间,而且每次import torch的时候还会尝试初始化CUDA上下文,白白增加了启动时间。CPU版本的wheel文件小得多,而且完全满足CPU训练的需求。

6.6 训练中断了怎么恢复

这个坑相信每个人都遇到过:训练到一半,实验室突然断电,或者你的云实例被回收,一切从零开始。我的标准做法是:

# 保存时同时保存epoch和优化器状态 checkpoint = { 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict(), 'best_val_loss': best_val_loss, } torch.save(checkpoint, 'checkpoint.pth') # 加载时这样恢复 checkpoint = torch.load('checkpoint.pth') model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) scheduler.load_state_dict(checkpoint['scheduler_state_dict']) start_epoch = checkpoint['epoch'] + 1 # 从下一个epoch开始

优化器状态必须保存,因为Adam这类优化器内部维护的动量(一阶、二阶矩估计)如果丢了,训练恢复后前几百个iteration梯度估计是乱的,相当于损失了这部分训练进度。scheduler状态也很重要,否则学习率会被重置回初始值,后面训练可能会乱。

我在整个无GPU做深度学习的经历里,记忆最深刻的不是某个模型效果有多好,而是把“在资源受限条件下把问题拆解清楚”这个能力本身练出来了。没有GPU,你会被迫想清楚每一个epoch为什么慢、数据加载卡在哪、模型结构哪里冗余,这些思考在GPU充裕后依然是做研究的基本功。GPU可以买到,但这些用时间堆出来的排查经验,才是在实验室资源紧张时真正值钱的东西。

如果你现在正因为没显卡而卡住,我的建议很简单:先按这篇文章里的思路把本地CPU环境跑通一个小实验,用真实数据验证模型能收敛,再考虑上云租GPU跑全量——你会发现,本地CPU上的每一步调试,都在帮你在云上省时间和钱。

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

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

立即咨询