1. 从一行 import 说起:为什么 PyTorch 值得反复琢磨
很多人第一次接触深度学习框架,都是从import torch这一行开始的。看起来平平无奇,但真正把 PyTorch 用进项目里之后才会发现,这一行背后藏着的东西远比想象中多。我见过不少朋友,模型能跑通、loss 能下降,但一旦遇到显存爆炸、训练速度上不去、多卡同步出问题,就完全不知道从哪里下手。说到底,是对 PyTorch 的运行机制理解得不够透。
这篇内容不打算写成官方文档的中文翻译,那种东西网上已经够多了。我想做的是把 PyTorch 从“能跑”到“跑得好”之间那段路讲清楚——张量到底怎么在内存里排布、自动求导的计算图是怎么建起来又怎么释放的、DataLoader的num_workers到底该设多少、混合精度训练为什么能省显存、torch.compile在什么场景下真的有用。这些问题的答案,往往决定了你的项目是能落地还是只能停在 demo 阶段。
适合谁看?如果你已经写过几个 PyTorch 的小项目,能看懂基本的训练循环,但总觉得自己的代码“不够专业”,或者遇到性能瓶颈不知道怎么优化,那这篇内容就是为你准备的。如果你是完全的新手,也可以看,但建议先把官方那个 60 分钟入门教程过一遍再回来,效果会更好。
下面我会按照实际项目里最常遇到的几个层面来展开:先讲张量和计算图这些底层的东西,再讲数据加载和训练循环的工程细节,然后是性能优化和显存管理,最后聊聊调试和部署。每一块都会给出我实际踩过的坑和验证过的做法。
2. 张量与自动求导:那些文档里不会强调的细节
2.1 张量的内存布局比你想的重要
torch.Tensor看起来就是个多维数组,但它的内存布局直接影响你的操作效率。PyTorch 默认使用行优先(row-major)的连续内存,也就是说一个形状为(3, 4)的张量,内存里是按行依次排列的。当你做转置操作x.t()的时候,PyTorch 并不会真的去搬运数据,而是返回一个视图,改变了 stride(步长)。这个设计很聪明,但如果你在转置之后直接做view(),就会报错,因为内存不连续了。
我刚开始写代码的时候就踩过这个坑:对一个转置后的张量调用.view(-1),结果直接抛异常。正确的做法是用.reshape(),它会自动处理连续性,或者在view()之前先调用.contiguous()。但要注意,.contiguous()会触发一次实际的内存拷贝,如果这个操作在训练循环里频繁出现,性能损耗是实打实的。
import torch x = torch.randn(4, 8) y = x.t() # 转置,不拷贝数据,只改 stride print(y.is_contiguous()) # False # y.view(-1) # 会报错 z = y.reshape(-1) # 安全,必要时会自动拷贝 w = y.contiguous().view(-1) # 显式拷贝后再 view实际项目里,我建议在数据预处理阶段就把张量整理成需要的形状,尽量避免在训练循环内部做转置加 view 的组合操作。如果实在避不开,用reshape比手动contiguous加view更省心,虽然底层逻辑一样,但代码更干净。
2.2 自动求导的计算图什么时候建、什么时候释放
PyTorch 的动态图机制是它最大的卖点之一。每次前向传播的时候,它会实时构建一张计算图,记录所有涉及梯度的操作。反向传播完成后,这张图默认会被释放掉。这个“默认释放”的行为,是很多显存泄漏问题的根源。
举个例子,如果你在训练循环里把某个中间变量的grad_fn存下来了,比如写了个全局列表losses.append(loss),那这张计算图就不会被释放,显存会随着训练步数线性增长,最后直接 OOM。正确的做法是存loss.item()或者loss.detach(),把张量从计算图里摘出来。
# 错误做法:计算图被保留 losses = [] for batch in dataloader: loss = model(batch) losses.append(loss) # 计算图没释放 # 正确做法:只存数值 losses = [] for batch in dataloader: loss = model(batch) losses.append(loss.item()) # 只存 Python 浮点数还有一个常见的场景是验证阶段。很多人在验证的时候忘了加torch.no_grad(),结果验证集的前向传播也在建图,显存占用直接翻倍。虽然验证阶段不调用backward(),图最终会被垃圾回收,但峰值显存会高很多,batch size 稍微大一点就撑不住了。
提示:验证和推理阶段一定要用
with torch.no_grad():包起来,这是最容易被忽略但收益最明显的显存优化手段。
2.3 requires_grad 的传播规则
requires_grad这个属性有个容易被忽视的特性:它是会传播的。如果你有一个requires_grad=True的张量,任何由它参与运算得到的新张量,默认也会requires_grad=True。这在微调模型的时候特别重要。
比如你想冻结预训练模型的前几层,只训练最后几层。常见的做法是把前面层的参数requires_grad设为False。但如果你不小心在前向传播里对这些层的输出做了某些操作,导致新的张量又带上了梯度,那冻结就失效了。更稳妥的做法是在优化器里只传入需要训练的参数字段:
optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3 )这样即使某些参数的requires_grad被意外设回True,优化器也不会更新它们。双重保险,实际项目里我基本都会这么写。
3. 数据加载与训练循环:工程细节决定成败
3.1 DataLoader 的 num_workers 到底设多少
num_workers是DataLoader里最常被问到的参数。设成 0 表示在主进程里加载数据,设成大于 0 的值会启动相应数量的子进程。理论上,num_workers越大,数据加载越快,但实际并不是这样。
我做过一组实测:在一台 8 核的机器上,用同一个数据集训练同一个模型,只改num_workers,观察每个 epoch 的耗时。结果是num_workers=4的时候最快,设到 8 反而变慢了。原因是子进程之间的调度开销和内存拷贝成本开始超过并行加载带来的收益。一般来说,num_workers设成 CPU 核心数的 1/4 到 1/2 是比较稳妥的起点,然后根据实际情况微调。
还有一个坑是 Windows 上的多进程。Windows 用的是 spawn 方式启动子进程,和 Linux 的 fork 不一样,如果你的数据集类里有不可序列化的对象,或者代码没有放在if __name__ == '__main__':保护块里,就会直接报错。我在 Windows 上调试的时候被这个问题折腾过很久,后来养成了习惯:不管什么平台,DataLoader相关的代码都放在主保护块里。
if __name__ == '__main__': dataloader = DataLoader( dataset, batch_size=32, num_workers=4, pin_memory=True, persistent_workers=True )pin_memory=True在 GPU 训练时几乎必开,它会把数据放到锁页内存里,从 CPU 到 GPU 的拷贝会快很多。persistent_workers=True是 PyTorch 1.7 之后引入的,可以让子进程在 epoch 之间保持存活,避免每个 epoch 重新创建进程的开销。这两个参数配合使用,数据加载这块基本就不会成为瓶颈了。
3.2 训练循环里那些看似不起眼但很关键的操作
一个标准的训练循环大概长这样:
model.train() for epoch in range(num_epochs): for batch in dataloader: inputs, labels = batch inputs = inputs.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step()这里面有几个细节值得说。non_blocking=True配合pin_memory=True才能实现异步拷贝,如果没开pin_memory,这个参数基本没用。optimizer.zero_grad(set_to_none=True)是 PyTorch 1.7 之后推荐的写法,把梯度直接设为None而不是清零,省去了写零的操作,速度略快,显存占用也略低。
还有一个经常被忽略的是model.train()和model.eval()的切换。这两个调用会影响Dropout和BatchNorm的行为。我见过有人在验证之后忘了切回train(),结果下一个 epoch 的 Dropout 被关掉了,训练效果直接崩掉。这种 bug 很隐蔽,因为代码不报错,只是结果不对。我的习惯是在训练循环开头显式调用model.train(),在验证循环开头显式调用model.eval(),不依赖之前的状态。
3.3 梯度累积:小显存跑大 batch 的实用技巧
显存不够是常态。想用大 batch size 但显卡装不下,这时候梯度累积就是最直接的解决方案。思路很简单:多次前向传播和反向传播,把梯度攒起来,攒够一定步数再更新一次参数。
accumulation_steps = 4 optimizer.zero_grad(set_to_none=True) for i, batch in enumerate(dataloader): inputs, labels = batch inputs = inputs.to(device) labels = labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad(set_to_none=True)注意 loss 要除以accumulation_steps,否则梯度的量级会偏大。另外,如果用了BatchNorm,梯度累积和真正的大 batch 并不完全等价,因为BatchNorm统计的是每个 mini-batch 的均值和方差,而不是累积后的。这个差异在大多数场景下可以接受,但如果对BatchNorm的统计精度要求很高,就需要考虑用SyncBatchNorm或者其他方案。
4. 性能优化与显存管理:从能跑到跑得快
4.1 混合精度训练的实际收益与注意事项
混合精度训练(AMP)是 PyTorch 里性价比最高的性能优化手段之一。它让大部分计算用 float16 进行,少部分需要高精度的操作用 float32,在保持模型精度的同时,显存占用能降 30% 到 50%,训练速度也能提升 1.5 到 2 倍。
用法很简单:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for batch in dataloader: inputs, labels = batch inputs = inputs.to(device) labels = labels.to(device) optimizer.zero_grad(set_to_none=True) with autocast(): outputs = model(inputs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler的作用是防止 float16 的梯度下溢。float16 能表示的最小正数大概是 6e-8,很多梯度值比这个还小,直接算会变成 0。GradScaler先把 loss 放大,反向传播得到的梯度也相应放大,更新参数之前再缩回去,这样就不会丢失小梯度。
实际使用中有几个坑。第一,autocast的范围要控制好,不是越大越好。有些操作在 float16 下数值不稳定,比如某些归一化层或者损失函数,这些应该放在autocast外面。第二,如果用了梯度累积,scaler.scale(loss)里的 loss 也要除以累积步数。第三,GradScaler的状态在保存和加载模型时也要一并保存,否则恢复训练时可能会出问题。
4.2 torch.compile 到底能带来多少提升
torch.compile是 PyTorch 2.0 引入的重磅功能,它会把你的模型编译成优化后的内核,减少 Python 解释器的开销,并且能做一些算子融合。官方给的数字是训练速度提升 30% 到 2 倍不等,实际效果取决于模型结构。
我拿几个不同规模的模型试过。对于小模型(比如几层全连接),提升非常明显,因为 Python 开销占比大,编译后这部分几乎消失了。对于大模型(比如 Transformer),提升相对小一些,因为计算本身占主导,但算子融合还是能带来 20% 到 40% 的加速。
用法简单到离谱:
model = torch.compile(model)就这一行。但背后有几个注意事项。第一,首次运行会有编译开销,通常几十秒到几分钟,所以短时间的训练任务可能不划算。第二,不是所有模型都能顺利编译,遇到不支持的算子会回退到 eager 模式,这时候性能可能反而略降。第三,编译后的模型保存和加载需要用torch.compile的对应接口,不能直接torch.save。
我的建议是:如果你的模型结构比较标准(CNN、Transformer 这些),训练时间超过几十分钟,那就值得试试torch.compile。如果模型里有大量自定义算子或者动态控制流,先做好回退的心理准备。
4.3 显存碎片与 empty_cache 的正确用法
torch.cuda.empty_cache()这个函数被很多人当成万能药,一遇到 OOM 就调一下。但它的实际作用是把 PyTorch 缓存分配器里没用的显存还给 CUDA,并不能解决真正的显存泄漏。
PyTorch 的 CUDA 内存分配器会缓存已经申请过的显存块,方便后续复用。当你删除一个张量时,它占的显存不会立刻还给 GPU,而是留在缓存池里。这样做的好处是避免频繁的 cudaMalloc 和 cudaFree,那俩操作很慢。坏处是nvidia-smi看到的显存占用会一直很高,即使 PyTorch 内部已经释放了。
所以empty_cache()的正确使用场景是:你确实需要把显存让给其他进程,或者在测量真实显存占用之前清一下缓存。在训练循环里频繁调用它反而会拖慢速度,因为下次申请显存又得重新 cudaMalloc。
真正要解决 OOM,还是得从源头入手:减小 batch size、用梯度累积、开混合精度、及时 detach 中间变量、避免在计算图里保存不必要的张量。这些手段组合起来,比反复调empty_cache()有用得多。
5. 调试与问题排查:那些让人抓狂的报错怎么解
5.1 形状不匹配的报错怎么快速定位
形状不匹配是 PyTorch 里最常见的报错类型。错误信息通常会告诉你哪个操作期望什么形状、实际得到了什么形状,但在一大串调用栈里找到真正出问题的那一行,有时候并不容易。
我的做法是在模型的前向传播里加形状打印,用print或者logging都行。但更优雅的方式是用torchinfo这个库,它能像 Keras 的summary()一样,把每一层的输入输出形状、参数量都列出来。
from torchinfo import summary summary(model, input_size=(batch_size, channels, height, width))这样一眼就能看出哪一层的输出形状和下一层的期望对不上。比在代码里到处插 print 高效多了。
还有一个技巧是用torch.autograd.set_detect_anomaly(True),它会在反向传播遇到 NaN 或者形状问题时给出更详细的调用栈。缺点是会显著拖慢训练速度,所以只在调试阶段开,定位到问题就关掉。
5.2 loss 变成 NaN 的排查路径
Loss 变 NaN 是另一个高频问题。原因可能有很多:学习率太大、数据里有异常值、某个算子数值不稳定、梯度爆炸等等。排查的时候我一般按这个顺序来:
先检查数据。用torch.isnan(inputs).any()和torch.isinf(inputs).any()看看输入里有没有 NaN 或 Inf。如果数据本身有问题,那后面都不用查了。
再检查学习率。把学习率调小一个数量级试试,如果 NaN 消失了,那就是学习率的问题。配合梯度裁剪(torch.nn.utils.clip_grad_norm_)通常能解决。
然后检查损失函数。有些损失函数在输入接近 0 或者负数的时候会出问题,比如log(0)就是负无穷。可以在损失函数里加一个极小值eps来避免。
最后检查模型结构。某些归一化层或者激活函数在特定输入下可能产生 NaN。可以逐层打印输出的统计量,找到第一个出现 NaN 的层。
# 梯度裁剪的典型用法 loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()梯度裁剪几乎是训练 RNN 和 Transformer 的标配,即使没遇到 NaN 也建议加上,能让训练更稳定。
5.3 多卡训练中的常见同步问题
多卡训练能加速,但也引入了新的问题。最常见的是BatchNorm的同步问题。默认情况下,每张卡上的BatchNorm只统计自己卡上的数据,如果每张卡的 batch size 比较小,统计量会不准。解决方案是用SyncBatchNorm:
model = torch.nn.SyncBatchNorm.convert_sync_batchnorm(model)另一个问题是随机种子。多卡训练时,每张卡上的数据增强如果用了随机操作,需要确保每张卡的种子不同,否则所有卡可能拿到相同的增强结果,降低了数据的多样性。DistributedSampler会自动处理数据的分片,但数据增强的随机性需要自己控制。
还有一点是日志和 checkpoint 的保存。多卡训练时,通常只在主进程(rank 0)上做日志记录和模型保存,其他进程只负责计算。如果不做这个区分,多个进程同时写同一个文件,轻则日志混乱,重则文件损坏。
6. 模型保存、加载与部署的实战经验
6.1 state_dict 还是整个模型
PyTorch 提供了两种保存方式:保存整个模型对象,或者只保存state_dict(参数字典)。官方推荐用state_dict,我也强烈建议这么做。
保存整个模型的问题是,它依赖于模型类的定义。如果你之后改了模型类的代码,加载旧模型就会失败。而且整个模型对象里可能包含一些不可序列化的东西,比如 lambda 函数或者外部引用。state_dict只保存参数张量,和模型类的具体实现解耦,更灵活也更安全。
# 保存 torch.save(model.state_dict(), 'model.pth') # 加载 model = MyModel() model.load_state_dict(torch.load('model.pth', map_location='cpu')) model.eval()注意map_location='cpu'这个参数。如果你在 GPU 上训练并保存了模型,然后在没有 GPU 的机器上加载,不加这个参数会报错。加上之后,PyTorch 会把参数先加载到 CPU 上,你再根据需要移到 GPU。
6.2 推理阶段的性能优化
训练完之后,推理性能往往是更重要的指标。除了前面提到的torch.no_grad()和model.eval(),还有几个手段可以用。
torch.jit.trace或者torch.jit.script可以把模型转成 TorchScript,脱离 Python 解释器运行,速度更快,也方便部署到 C++ 环境。trace适合结构固定的模型,script适合有控制流的模型。转完之后记得对比一下输出,确保转换没有改变模型行为。
如果目标是移动端或者嵌入式设备,可以考虑torch.quantization做量化,把 float32 的参数量化成 int8,模型大小能缩小到 1/4,推理速度也能提升。但量化会带来精度损失,需要在实际数据上验证。
还有一个容易被忽略的点是输入数据的预处理。推理阶段的数据预处理应该和训练阶段完全一致,包括归一化的均值和方差、resize 的方式、通道顺序等等。我见过不少模型在验证集上表现很好,部署之后效果差很多,最后发现是预处理不一致导致的。
6.3 版本兼容性:那些升级之后才发现的坑
PyTorch 的版本迭代很快,不同版本之间的 API 变化有时候会让人措手不及。比如torch.cuda.amp在 1.10 之后推荐用torch.amp替代,optimizer.zero_grad()的默认行为在 2.0 之后从清零变成了设为None。这些变化在升级的时候如果不注意,可能会导致训练行为改变。
我的建议是在项目开始时就固定 PyTorch 的版本,写进requirements.txt或者environment.yml里。升级版本之前,先在测试环境跑一遍完整的训练流程,对比升级前后的 loss 曲线和最终指标。如果差异在可接受范围内,再升级生产环境。
另外,CUDA 版本和 PyTorch 版本的对应关系也要注意。PyTorch 官网有明确的对应表,装之前查一下,别凭感觉装。装错了轻则用不了 GPU,重则各种奇怪的报错。
7. 一些让我少走弯路的习惯
写了这么多,最后分享几个我在实际项目里养成的习惯,都是踩过坑之后总结出来的。
第一个是固定随机种子。不只是torch.manual_seed(),还有numpy和random的种子,以及torch.backends.cudnn.deterministic = True。这样每次跑的结果都可复现,调试的时候能排除随机性的干扰。虽然会稍微慢一点,但调试阶段这点代价完全值得。
第二个是训练前先跑一个 mini-batch 过拟合测试。拿一个很小的子集(比如 10 个样本),让模型过拟合上去。如果 loss 降不到接近 0,说明模型或者训练循环有问题,这时候不用浪费时间跑完整训练。这个测试通常几分钟就能做完,能省下大量排查时间。
第三个是 checkpoint 里保存优化器状态和 epoch 数。只保存模型参数是不够的,恢复训练的时候优化器的动量、学习率调度器的状态都需要。我一般会保存一个字典,包含model_state_dict、optimizer_state_dict、epoch、best_metric这些字段,恢复的时候一次性加载。
第四个是日志里记录学习率和梯度范数。学习率的变化能帮你判断调度器是否正常工作,梯度范数能提前预警梯度爆炸或者消失。这两个指标在 TensorBoard 或者 WandB 里画出来,训练是否健康一目了然。
PyTorch 这个框架的魅力在于它足够灵活,你可以用很 Pythonic 的方式表达复杂的模型和训练逻辑。但灵活也意味着很多细节需要自己把控。上面这些内容,都是我在实际项目里反复验证过的做法,希望能帮你少踩几个坑。