ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch深度学习实战:张量、自动微分、多卡训练与部署避坑指南

PyTorch深度学习实战:张量、自动微分、多卡训练与部署避坑指南

如果你和我一样,最早是从 Numpy 和手写梯度下降开始接触深度学习的,那第一次真正上手 PyTorch,多半会有一种“好像也没那么难”的感觉。张量、自动微分、模型模块化,这套设计把深度学习最日常的三件事——处理数据、搭网络、调参数——都变成了可读性很高的 Python 代码。这篇内容不是官方教程的复读,而是我在多个落地项目里反复用到、也被反复坑过的 PyTorch 实践经验总结。主要适合两类人:一类是刚把 Python 和基础机器学习概念学完、准备开始写第一个深度模型的学习者;另一类是已经能跑通 demo,但遇到显存溢出、NaN loss、加载模型报错、多卡训练起不来这类工程问题时会懵的开发者。我不想做概念陈列,直接按“从数据到训练、从单卡到多卡、从训练到部署”这条主线,把真正值得留意的细节和排查思路写清楚。

1. 从 Numpy 思维切换到 PyTorch 思维,最重要的一步是理解张量

1.1 张量不只是多维数组,它自带设备与梯度信息

很多从 Numpy 过来的人会用“多维数组”去理解 Tensor,这个方向没问题,但不够。PyTorch 的张量比 Numpy 数组多了两个非常关键的信息:它知道自己现在在哪块设备上,也知道自己是否需要计算梯度。前者决定了你的数据是在 CPU 内存还是 GPU 显存里,后者决定了 autograd 这张计算图要不要为它记录操作历史。

我见过不少人在这一步吃了亏。比如在某次训练里,我习惯性地用torch.as_tensor(data)把一批数据处理成张量,然后直接丢给模型,结果报错说“CPU tensor cannot be moved”。你不是非得每一步都手动.to(device),但你必须对张量的 device 有掌控感。设备不一致的问题在训练后期排查起来很麻烦,与其报错了再猜,不如从一开始就养成一个习惯:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu") data = torch.as_tensor(raw_data).to(device)

另一个容易忽略的点是requires_grad。默认情况下,从数据转换来的张量是requires_grad=False的,只有当你用torch.randn(..., requires_grad=True)或者通过可求导运算得到某个张量时,它才会被放进计算图里。手动实现线性回归时,这个区别尤其明显。

1.2 用最简单的线性回归把 autograd 跑通

与其背概念,不如直接看一段小代码。假设我们要拟合y = 2x + 0.3,加了一点噪声。用 PyTorch 手动实现参数更新,不引入nn.Module,整个流程是这样的:

import torch x = torch.linspace(-1, 1, 200).reshape(-1, 1) y = 2 * x + 0.3 + torch.randn_like(x) * 0.02 w = torch.randn(1, 1, requires_grad=True) b = torch.zeros(1, requires_grad=True) lr = 0.1 for step in range(1000): pred = x @ w + b loss = ((pred - y) ** 2).mean() loss.backward() with torch.no_grad(): w -= lr * w.grad b -= lr * b.grad w.grad.zero_() b.grad.zero_()

这段代码最值得琢磨的不是数学,而是loss.backward()之后发生了什么。backward()会从 loss 出发,沿着计算图反向传播梯度,把每个需要梯度的叶节点张量的.grad填上数值。然后我们用with torch.no_grad():来更新参数,这等于告诉 PyTorch:这一段不要继续追踪计算图的构建,因为参数更新不应该被记录进下一次的梯度计算里。

最后必须w.grad.zero_()和b.grad.zero_()。否则梯度会累加到下一次反向传播中。这也是新手最容易犯的错误:第一次 loss 正常下降,第二次开始乱跳,大概率就是忘记清零梯度。zero_()里的下划线表示原地操作,直接修改张量本身,这也是 PyTorch 风格里一个常见记号。

1.3 为什么说动态图让调试回归了 Python 直觉

PyTorch 的计算图是动态构建的,也就是说,你每跑一次 forward,计算图就新生成一次。这对于调试来说是巨大的利好:你能在loss.backward()之前随意打印中间张量的形状和数值,可以用普通的 Pythonif控制流来改变模型结构,不需要预先定义一个静态图。对比“先构图后执行”的思路,PyTorch 的设计更贴近程序员的直觉。

但这种自由也有代价。因为图是动态的,某些错误只在特定输入形状或特定分支下才会暴露。如果模型里有复杂的if分支或for循环,你要格外留意每个分支的张量形状是否一致。我自己的习惯是:数据形状如果不是特别复杂,就在 forward 的开头加几行注释,把期望的输入和输出形状写清楚。模型跑久了,这种文档能省掉大量回忆成本。

2. 搭起一套能用的训练循环:Dataset、Model、Optimizer 的协作关系

2.1 DataLoader 的 num_workers 和 pin_memory 不是摆设

单机上跑深度学习,最容易被低估的是数据管线。很多人写一个 Dataset 子类,然后直接DataLoader(dataset, batch_size=32)就开训,等到 GPU 利用率上不去才回来调参。其实 DataLoader 的几个参数对训练吞吐量有很大影响。

num_workers控制的是数据加载时使用多少个 worker 进程。如果你的__getitem__里有大量图像解码、缩放、数据增强,把它设成 0 意味着主进程边加载边训练,GPU 很容易饿着。一般我会从num_workers=2或4开始试,同时观察 CPU 和 GPU 利用率。注意 worker 数量不是越大越好,因为每个 worker 会持有数据副本,内存占用会上升;另外,在多卡训练时,worker 数量还和整体 CPU 资源相关,设得过高反而可能拖慢。

pin_memory=True是一个不太起眼的开关,但如果你在 GPU 上训练,推荐打开。它的作用是让 DataLoader 把数据放进锁页内存,后续从 CPU 拷贝到 GPU 时可以用异步传输,减少阻塞时间。有些开源代码默认不写这个参数,我通常会在自己的训练脚本里加上。

还有一个常被忽略的参数是drop_last。如果数据集大小不能被 batch size 整除,最后一个 batch 会偏小。对 BatchNorm 这类依赖 batch 统计量的层来说,最后一个小 batch 会让均值和方差估计抖动。训练时我会设置drop_last=True,保证每个 batch 大小一致;验证时用drop_last=False,把全部数据都评估完。

2.2 nn.Module 里最常见的两个结构误区

搭模型时,nn.Module看似只是把层放在__init__里、在forward里调用,但有两个结构性问题我见得最多。

第一个问题是把子模块放进普通的 Python 列表里。比如:

class BadModel(nn.Module): def __init__(self): super().__init__() self.layers = [nn.Linear(64, 64) for _ in range(3)] def forward(self, x): for layer in self.layers: x = torch.relu(layer(x)) return x

这段代码不会直接报错,但self.layers里的 Linear 层不会被model.parameters()识别,因为nn.Module只自动注册nn.ModuleList、nn.Sequential这类“被明确声明”的子模块。结果就是 optimizer 拿不到这些层的参数,训练时权重完全不更新。解决办法是把列表改成nn.ModuleList,或者直接用nn.Sequential组织。

第二个问题是把forward写得太复杂,所有 Python 逻辑都堆在里面。forward当然可以有控制流,但如果你发现一个 forward 里有五六个if、两个for,还夹杂着各种中间张量的原地修改,那就要警惕了。调试成本会随着分支数量指数级上升。更好的做法是尽量把稳定不变的部分用nn.Sequential组织,把灵活变化的部分单独抽成方法,保持 forward 的可读性。

2.3 训练循环里一步都不能乱的三个动作

标准训练循环看起来很简单,但每个动作的顺序有讲究。我的习惯是:

optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()

先清空上一步的梯度,再反向传播算梯度,最后用优化器更新参数。这三个动作的顺序不能乱。清空梯度一定要放在backward之前,否则会把这次刚算出来的梯度清掉。梯度裁剪放在backward之后、optimizer.step之前,它防的是梯度爆炸,而不是直接改 loss。

很多人会在一个循环里手动调用loss.backward()多次。如果是为了梯度累积,比如每 4 个小 batch 累积更新一次,那要记得在optimizer.step()之后再zero_grad(),同时注意 loss 除以累积步数的方式。如果只是误操作,把 backward 放在一个不需要梯度的验证阶段里,那会白费很多显存。验证阶段一定要包在torch.no_grad()或torch.inference_mode()里面,否则计算图会在推理时被保留,显存占用会异常高。

3. 训练期“炸炉”现场:显存、NaN 和设备混用的完整排查链路

3.1 从一个 OOM 报错回溯到数据管线的 batch size

“CUDA out of memory”应该是训练时最常见的崩溃之一。很多人的第一反应是调小 batch size,这当然没错,但如果你不做定位,只是盲目调小,可能反复试很多次才能跑起来。我自己的排查思路是:先算一次理论显存占用,再决定改哪里。

一个简单公式只考虑张量本身:参数量乘以每个参数的字节数,再考虑中间激活值。最典型的是卷积层输出特征图:如果输入是[batch_size, 3, 224, 224],float32 类型,那么一个 batch 的输入张量本身占用的显存大约是32 * 3 * 224 * 224 * 4字节,约 18 MB。听着很小,但每一层卷积输出的特征图、反向传播需要的中间张量都会各占一份,一个有几十层的网络累积下来就是几个 GB。你不用手动算得特别精确,只要大致估算当前 batch size 对应的规模,再对比可用显存,就能确定是不是 batch size 的问题。

另一个容易造成“假 OOM”的地方是计算图没有被及时释放。如果在验证阶段忘了包torch.no_grad(),或者在一个 Python list 里保存了每个 iteration 的 loss,那么这些张量和图会一直留在显存里。遇到这种情况,torch.cuda.empty_cache()只能清掉缓存,不能释放被强引用持有的内存,关键还是找到谁在持有张量。你可以用torch.cuda.max_memory_allocated()对比前后两次内存峰值,快速判断是模型独占还是数据管线的多余占用。

3.2 loss 变成 NaN 时,按“数据→模型→数值”的顺序找根因

NaN loss 是个让人头大的问题,因为表面看起来一切正常,loss 却突然变成nan。我一般按“数据→模型→数值”三层顺序排查。

先看数据。训练样本里是否存在无穷值或缺失值?如果输入里有 NaN 或 inf,反向传播算出的梯度几乎必然会被污染。在处理文本和表格数据时尤其容易发生:某列有空值没处理,分词结果里有空 tensor,数值特征归一化时除数为零。先把数据里的异常值扫一遍,很多时候问题就没了。

再看模型。输出层后接的 loss 函数是否稳定?比如手动实现交叉熵时,如果先对 logits 做softmax再用log,当 softmax 输出接近 0 时,log(0)会得到负无穷,loss 就是 NaN。更稳妥的做法是直接用torch.nn.functional.cross_entropy,它在内部做了数值稳定的 log_softmax。同理,如果是自己写损失函数,要检查有没有潜在的对数运算或除法运算。

最后看数值稳定性。网络层数深、梯度累乘后可能爆炸,梯度裁剪是一种常见解法。我还会检查学习率是不是太大,尤其是使用 Adam 时初始学习率设成0.1这种“看起来不太大”的值,实际很容易把 loss 打飞。还有一个容易忽略的细节:某些激活函数在输入极大时梯度消失或爆炸,比如 sigmoid;如果网络初始化的权重范围不对,深层的激活值可能迅速饱和。

3.3 device 不一致引发的报错为什么不看堆栈也能猜出来

PyTorch 最经典的报错之一是一长串“Expected all tensors to be on the same device”。这个报错出现时,第一反应不是去读堆栈,而是检查三类对象的 device:模型参数、输入数据、目标标签。

很多人在把模型搬到 GPU 后,忘了把每个 batch 的输入也搬到 GPU。DataLoader 返回的是 CPU 张量,需要手动执行batch.to(device)。这个问题在 CPU 单机上永远不会暴露,一旦切到 GPU,第一次迭代就会报。还有一个相对隐蔽的点:模型内部如果有“不是通过nn.Module注册的缓冲区”,比如自定的register_buffer,在保存加载时也要确保它被正确搬到目标设备。

我习惯在一开始就定义一个统一的 device 变量,然后让模型和数据都通过它来搬运:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) for batch in dataloader: xb, yb = batch[0].to(device), batch[1].to(device)

如果你经常在几种设备之间切换,还可以在测试时打印几个关键张量的device属性。这个习惯可以帮你把 device 相关的问题扼杀在摇篮里,而不是等训练跑到一半才爆出来。

4. 让训练可以打断重来:模型保存与断点续跑的工程细节

4.1 只保存 state_dict 还是连优化器一起保存

保存模型时最稳妥的做法是torch.save(model.state_dict(), "model.pt"),而不是直接把整个model对象保存起来。直接保存模型对象虽然代码少,但要求加载环境的代码结构完全一致,类定义稍有改动就可能加载失败。state_dict只保存参数和缓冲区,本质上是一个从名字到张量的映射,加载时用load_state_dict塞回模型,灵活得多。

那什么时候才需要连优化器一起保存?答案是断点续跑。如果你只保存了model.state_dict(),重新训练时优化器里的动量、Adam 的矩估计全部丢失,学习率调度器也要从头开始。短期训练无所谓,但训练一个跑一两天的大模型时,这些附加状态很关键。优化器状态里往往包含跟每个参数对应的额外张量,文件体积比模型参数还大,但这是必要的。

4.2 一份真正可靠的 checkpoint 至少包含四样东西

我一般把 checkpoint 设计成一个字典,包含当前训练进度和所有关键状态。除了模型参数、优化器参数,还要记录 epoch、全局 step、当前最佳评估指标,以及必要的随机种子状态。这样即使断电中断,也能恢复到上一次保存的精确状态。

checkpoint = { "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "epoch": epoch, "global_step": global_step, "best_score": best_score, "rng_state": torch.random.get_rng_state(), } torch.save(checkpoint, "checkpoint_last.pt")

加载时注意用map_location参数把张量映射到正确设备。比如你之前用多卡训练,checkpoint 里的张量可能在cuda:0,现在只有一块卡,直接加载可能报错或占用不该占的卡。我常用的写法是:

ckpt = torch.load("checkpoint_last.pt", map_location=device) model.load_state_dict(ckpt["model"]) optimizer.load_state_dict(ckpt["optimizer"]) epoch = ckpt["epoch"] global_step = ckpt["global_step"]

从 checkpoint 恢复后,第一件事就是跑一次验证集,确认 loss 和评估指标的数值和中断前的记录对得上。如果差得很远,说明恢复过程中漏了某些状态,比如随机种子没有恢复、数据集的 shuffle 顺序发生了变化。

4.3 加载预训练权重时遇到键名不匹配的通用解法

加载别人训练好的权重时,最常见的报错是Missing key(s) and unexpected key(s)。背后的原因通常是两类:一是模型结构不完全一样,比如输出类别数不同;二是键名前缀不一致,比如原模型用DataParallel包装过,state_dict 的每个键前面都多了一个module.。

面对这种情况,我很少用strict=False一关了之,而是做一个“按形状过滤”的加载逻辑:

pretrained = torch.load("pretrained.pt", map_location="cpu") model_state = model.state_dict() for k, v in model_state.items(): if k in pretrained and pretrained[k].shape == v.shape: model_state[k] = pretrained[k] model.load_state_dict(model_state)

这段代码的意义在于:名字匹配且形状匹配的层就加载,其他层保持随机初始化。如果你的模型只是在最后一层改了分类头,这个逻辑可以自动把前面所有兼容层的权重迁过来。如果键名只是差一个module.前缀,可以先把 pretrained 的键名做一次去前缀处理,再执行同样的过滤流程。

5. 当单卡不够用时:从单机多卡到分布式训练的平滑过渡

5.1 torch.nn.DataParallel 能跑,但为什么不推荐

单卡显存不够时,很多人第一反应是torch.nn.DataParallel(model)。它确实能让多卡跑起来,代码改动也很少,但它的工作方式是单进程多线程,前向传播时把 batch 切到多张卡,再把结果汇总回主卡。主卡既要负责梯度归约,又要负责输出汇总,通信很快成为瓶颈,多卡加速比很难看,尤其是小 batch 的场景。

更关键的是,DataParallel 对模型内部状态的处理比较粗,比如某些自定义操作在device_ids上的处理容易出问题。当你真的需要稳定训练、单机多卡甚至多机多卡时,应该切换到DistributedDataParallel,也就是 DDP。它的设计是每个进程一张卡,进程之间通过通信库同步梯度,扩展性和性能都好得多。

5.2 用 torchrun 启动 DDP 的三个小时踩坑记录

我第一次用 DDP 时,花了不少时间在处理初始化上。后来发现最省事的启动方式是torchrun,它负责设置RANK、LOCAL_RANK、WORLD_SIZE这些环境变量,你只需要在脚本里读取并初始化进程组。

一个最小可运行的 DDP 训练脚本大概长这样:

import os import torch import torch.distributed as dist import torch.multiprocessing as mp from torch.nn.parallel import DistributedDataParallel as DDP def main(): dist.init_process_group(backend="nccl") local_rank = int(os.environ["LOCAL_RANK"]) torch.cuda.set_device(local_rank) model = MyModel().to(local_rank) ddp_model = DDP(model, device_ids=[local_rank]) train_dataset = MyDataset() train_sampler = torch.utils.data.distributed.DistributedSampler(train_dataset) dataloader = torch.utils.data.DataLoader( train_dataset, batch_size=32, sampler=train_sampler, num_workers=4, pin_memory=True, ) optimizer = torch.optim.Adam(ddp_model.parameters(), lr=1e-3) for epoch in range(10): train_sampler.set_epoch(epoch) for xb, yb in dataloader: xb, yb = xb.to(local_rank), yb.to(local_rank) pred = ddp_model(xb) loss = torch.nn.functional.cross_entropy(pred, yb) optimizer.zero_grad() loss.backward() optimizer.step() dist.destroy_process_group() if __name__ == "__main__": main()

启动命令是:

torchrun --nproc_per_node=4 train_ddp.py

这里的--nproc_per_node表示每台机器上用几张卡。最容易踩的坑有三个。第一,忘记train_sampler.set_epoch(epoch),导致每个 epoch 的数据划分顺序相同,不仅影响随机性,还可能降低训练效果。第二,多个进程同时往同一个路径写 checkpoint,文件会互相覆盖。要约定只有rank == 0的进程才执行保存逻辑。第三,dist.init_process_group的 backend 在多机环境下通常用nccl,它要求所有进程的网络能互通,真实的多机环境里还要留意端口和网卡设置。

5.3 多卡训练里随机种子和 batch size 之间容易忽略的关系

多卡训练时,数据会被DistributedSampler自动切成多份,每个进程只处理自己的子集。正因为如此,很多人会忽略一个细节:如果你在 Dataset 里做随机数据增强,不同进程的随机状态如果完全相同,那么相同索引的样本经过的增强操作可能完全一样,等于变相缩小了每个 epoch 看到的数据多样性。

我常用的做法是给每个进程设置不同的随机种子偏移:

base_seed = 42 local_rank = int(os.environ["LOCAL_RANK"]) torch.manual_seed(base_seed + local_rank)

这样既能让整体实验可复现,又能保证每个卡上的随机增强过程不完全一致。另外要注意的是,DDP 的总 batch size 是“每卡 batch size × 卡数”。如果你原来是单卡 batch 128,改成 4 卡后,想让总体 batch 保持 128,每卡 batch 就是 32,反之每卡 128 的话总 batch 就是 512。总 batch 变大后,学习率是否需要调整取决于具体任务,但至少要在日志里记清楚当前实际总 batch,否则后续调参容易误判。

6. 训完只是开始:推理提速与导出部署的实用心得

6.1 半精度推理的收益与注意点

训练收敛之后,往推理阶段优化是另一个常见需求。最直接的一步就是把模型和数据切到半精度。对大多数深度学习模型来说,float16 推理能让显存占用减半,同时在某些 GPU 上显著提速。

但半精度不是无脑切换。使用model.half()后,模型内部所有参数都会变成 float16,输入数据也要转成 float16,否则前向传播会报类型不匹配。更稳妥的方式是用 PyTorch 提供的自动混合精度上下文:

model = model.eval().to(device) with torch.inference_mode(): with torch.autocast(device_type="cuda", dtype=torch.float16): pred = model(input_tensor)

autocast会智能地把部分运算保持高精度,把部分运算切到半精度,比手动全部转 float16 更稳妥。如果你是 CPU 推理,半精度可能不会快,甚至更慢;需要先确认当前推理设备和算子库对半精度的支持情况。

6.2 torch.compile 不是银弹,但值得先试一下

PyTorch 2.x 之后最值得关注的新特性就是torch.compile。你只需要把原来的模型包一层:

model = torch.compile(model)

在其他代码不变的情况下,编译后的模型在部分网络结构上能获得明显加速,尤其是层结构规整、没有太多动态控制流的模型。我第一次试用时,大概只改了这一行,推理延迟就降了 20% 左右。

但torch.compile不是银弹。它第一次调用时需要花时间做编译和图优化,而且有些模型结构会导致编译时间很长,甚至因为动态 shape 而触发不必要的重新编译。最简单的策略是:先保留原始模型,单独写一个基准测试脚本,对比model.eval()和torch.compile(model)在不同 batch size 下的延迟,再决定是否在正式环境里启用。如果你用的是比较新的 PyTorch 版本,遇到编译相关的报错也比较常见,这时可以先升级到稳定版,或者尝试设置不同的mode,比如torch.compile(model, mode="reduce-overhead")。

6.3 ONNX 导出时最常遇到的动态维度问题

把 PyTorch 模型部署到服务端或移动端,导出 ONNX 是常见路径。torch.onnx.export用起来不复杂:

dummy_input = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, )

这里最容易出问题的是动态维度。如果你不设置dynamic_axes,导出模型会完全绑定 dummy input 的形状,比如 batch 只能是 1;一旦线上请求的 batch size 变成 4,推理框架就会报 shape 不匹配。设置dynamic_axes可以解决这个问题,代价是某些优化器可能无法对动态 shape 做深度优化。所以我的建议是:如果业务里 batch size 固定,就不要动态化;如果一定需要变长 batch,再开动态维度。

另一个常见问题是模型里有 Python 控制流,比如if x.shape[0] > 10:或者for循环依赖张量值。这类逻辑在导出时无法被完整表达,ONNX 导出器通常会直接报错或导出成不正确的图。如果遇到这种问题,优先把控制流从模型主体中拆出来,放到调用侧去处理,让模型的输入输出保持简单。

最后补充一个我自己的使用习惯:导出后不要直接部署,先用onnxruntime加载导出文件,跑一遍和 PyTorch 原模型相同的输入,对比输出差异。两次结果会有数值偏差,但如果平均绝对误差大得不正常,大概率是某个算子导出的行为不一致,比如归一化层的位置、常量折叠方式等。这个验证步骤通常只需要几分钟,却可以避免把坏模型直接带到线上。

关于 PyTorch 我能分享的实操经验还有很多很多,但最核心的还是那句话:框架只是工具,真正决定项目质量的,是你在训练循环前对数据、模型、设备状态有没有掌控感。每次遇到问题,先冷静拆解是数据、模型、数值还是通信层面的原因,再动手改,比盲目调参有效得多。以后如果再碰到显存溢出或加载 checkpoint 的诡异报错,不妨回想一下这篇里的排查顺序,你会发现大部分问题都是那几个典型原因在反复变着花样出现。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进