1. 从一行 import 说起:为什么 PyTorch 值得花时间啃透
很多人第一次接触深度学习框架,都是从import torch这一行开始的。但真正决定你后面能不能把模型跑起来、跑得快、跑得稳的,往往不是模型结构写得多花哨,而是你对这个框架本身的理解有多深。PyTorch 这几年在学术圈和工业界的占有率一路走高,不是因为它名字好听,而是它在动态图、调试体验、生态完整度这三件事上,确实解决了很多实际痛点。
我见过太多人卡在同一个地方:论文里的模型代码抄下来,forward函数能跑通,但一换数据、一改 batch size、一上多卡就各种报错。问题不在数学,而在于对 PyTorch 的张量内存模型、自动求导机制、设备管理逻辑没有建立起清晰的认知。这篇内容就是想把这几块讲透,让你从"能跑"进阶到"知道为什么能跑,以及跑崩了怎么救"。
不管你是刚学完 Python 想入门深度学习的学生,还是从别的框架迁移过来的工程师,又或者是被项目逼着上手、只想快速把模型训起来的开发者,下面这些内容都能直接拿去用。我会尽量少讲空泛概念,多讲实际会遇到的场景和对应的处理方式,把那些文档里一笔带过、但实操中一定会踩的细节补上。
2. 张量不只是"多维数组":内存布局决定了你的代码快不快
2.1 从 list 到 tensor,中间隔着一整套内存管理
新手最容易把 tensor 当成"能跑 GPU 的 numpy 数组",这个理解不算错,但太浅了。真正影响性能的第一个关键点是内存布局。一个 tensor 在底层是一块连续的存储(storage),加上一组描述如何解读这块存储的元数据:形状(shape)、步长(stride)、偏移(offset)。理解这一点,你才能明白为什么有些操作几乎零成本,有些操作却会悄悄复制一大块内存。
举个最常见的例子。view()和reshape()看起来功能一样,都是改变形状,但view()要求张量在内存里是连续的,它只是重新解读同一块存储;而reshape()在遇到非连续张量时会先复制一份再变形。如果你在训练循环里对一个转置过的张量反复调用view(),就会直接报错,而换成reshape()虽然能跑,但每次都在偷偷分配新内存,训练速度会莫名其妙地掉下来。
import torch x = torch.randn(4, 6) y = x.t() # 转置,此时 y 非连续 # y.view(24) # 报错:view size is not compatible z = y.reshape(24) # 能跑,但发生了内存拷贝 print(y.is_contiguous()) # False print(z.is_contiguous()) # True我个人的习惯是:在性能敏感的路径上优先用view(),一旦报错就说明数据布局有问题,这时候应该回头检查是不是该用.contiguous()显式整理,而不是无脑换reshape()掩盖问题。这个习惯帮我定位过好几次隐蔽的性能瓶颈。
2.2 stride 这个参数,藏着很多"反直觉"的行为
stride 是每个维度上"走一步跨过多少个元素"。对标准的连续张量,形状(4, 6)对应的 stride 是(6, 1)。但一旦你做了切片、转置、扩展,stride 就会变得很奇怪,而很多操作的行为正是由它决定的。
比如expand()可以在不复制内存的情况下把一个维度"广播"出去,它的原理就是把那个维度的 stride 设成 0——走一步等于没走,所以所有位置读到的是同一个值。这就是为什么expand出来的张量不能直接原地修改,因为改一个位置会影响所有共享它的位置。
a = torch.tensor([[1], [2], [3]]) # shape (3, 1) b = a.expand(3, 4) # shape (3, 4),无内存拷贝 print(b.stride()) # (1, 0) # b[0, 0] = 99 # 会报错或产生意外行为理解 stride 之后,很多"为什么这个操作这么快/这么慢"的问题就迎刃而解了。这也是我建议每个想认真用 PyTorch 的人都花半小时把 stride 搞明白的原因——它是理解后续所有性能优化的地基。
2.3 设备管理:CPU 和 GPU 之间的搬运是有代价的
.to(device)这行代码几乎每个项目里都有,但它的开销经常被低估。CPU 和 GPU 之间的数据传输走的是相对慢的通道,一次搬运的延迟可能比你想象的高得多。如果你的训练循环里频繁地在两个设备之间来回倒腾数据,GPU 利用率会低得可怜。
我的经验是:数据加载阶段就把张量准备好,训练循环里尽量只做一次.to(device),并且用non_blocking=True配合pin_memory来重叠传输和计算。下面这个模式在数据量大的时候提升非常明显:
loader = DataLoader(dataset, batch_size=64, pin_memory=True, num_workers=4) for x, y in loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) # 前向、反向...pin_memory=True会把数据放在锁页内存里,这样从 CPU 搬到 GPU 时可以用异步传输,让数据搬运和 GPU 计算并行起来。这个细节在官方教程里往往一笔带过,但在实际项目里,它经常是"为什么我的 GPU 利用率只有 30%"的答案。
3. 自动求导的脾气:什么时候它会"记不住",什么时候会"记太多"
3.1 计算图是动态搭出来的,这既是优点也是坑
PyTorch 最被称道的就是动态图——每次前向传播都重新构建计算图。这让调试变得极其直观,你可以像写普通 Python 一样加print、加断点。但动态图也意味着计算图的生命周期和你想象的不一样:它在前向时建立,在反向时被消费掉,默认情况下反向一次之后就释放了。
这就引出一个高频问题:为什么我第二次调用backward()会报 "Trying to backward through the graph a second time"?因为图已经被释放了。如果你确实需要多次反向(比如某些对抗训练、梯度惩罚的场景),就得在第一次反向时传retain_graph=True。
loss1.backward(retain_graph=True) # 保留计算图 loss2.backward() # 第二次反向才能成功但要注意,retain_graph=True会阻止中间激活被释放,显存占用会上升。所以能用一次反向解决的就别保留图,这是显存优化的一个基本原则。
3.2 requires_grad 和 no_grad:省显存的两把钥匙
默认情况下,只要一个张量的requires_grad=True,所有由它参与运算得到的张量都会记录梯度信息。在推理阶段,这些记录完全是浪费。所以推理时一定要用torch.no_grad()包起来:
model.eval() with torch.no_grad(): for x in test_loader: out = model(x)model.eval()和torch.no_grad()是两件不同的事,很多人会混淆。eval()只改变某些层的行为(比如 Dropout 关闭、BatchNorm 用滑动统计量),它不影响梯度记录;而no_grad()才是真正关掉梯度追踪、省显存的那个。两个都要用,缺一不可。
还有一个进阶技巧:如果你在微调时想冻结某部分参数,除了设requires_grad=False,还要记得在优化器里不要把这些参数传进去,否则优化器仍然会为它们分配状态(比如 Adam 的一阶二阶动量),白白占显存。
3.3 梯度累积:小显存训大 batch 的实用手段
显存不够、又想用大 batch size 的时候,梯度累积是最常用的办法。原理很简单:连续做几次前向反向,把梯度攒起来,再统一更新一次参数。这样等效的 batch size 就是micro_batch * accumulate_steps。
optimizer.zero_grad() for i, (x, y) in enumerate(loader): out = model(x) loss = criterion(out, y) / accumulate_steps # 注意要除以累积步数 loss.backward() if (i + 1) % accumulate_steps == 0: optimizer.step() optimizer.zero_grad()这里有个容易忽略的点:loss 要除以累积步数,否则梯度会被放大,等效学习率就变了。我见过有人忘了这一步,结果训练发散还找不到原因。另外,BatchNorm 在梯度累积下统计的是 micro batch 的分布,和真正的大 batch 不完全等价,这点在精度敏感的任务里要留意。
4. 训练循环里那些"看起来对、其实错"的写法
4.1 zero_grad 的位置,决定了你的梯度对不对
optimizer.zero_grad()放哪儿,是个看似无关紧要、实则影响正确性的问题。PyTorch 的梯度是累加的,如果你不清零,上一次的梯度会和这一次叠加。标准写法是在每次反向之前清零:
for x, y in loader: optimizer.zero_grad() # 先清零 out = model(x) loss = criterion(out, y) loss.backward() # 再反向 optimizer.step() # 最后更新有人喜欢把zero_grad()放在循环末尾,效果上通常等价,但一旦你用了梯度累积或者提前continue跳过某些 batch,位置放错就会出问题。所以我的建议是固定放在反向之前,逻辑最清晰,不容易出错。
4.2 学习率调度:别等到 loss 不降了才想起来
学习率是最重要的超参数之一,但很多人训练时压根不调度,从头到尾一个值。实际上,配合调度器往往能让收敛更稳、最终精度更高。PyTorch 的torch.optim.lr_scheduler提供了多种策略,常用的有阶梯下降、余弦退火、以及带热启动的余弦退火。
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=epochs) for epoch in range(epochs): train_one_epoch() scheduler.step() # 注意:新版 PyTorch 建议在 epoch 级调用这里有个版本差异的坑:早期版本里 scheduler 的调用时机和optimizer.step()的关系比较微妙,不同调度器要求不一样。最稳妥的做法是查一下你所用版本的官方文档,确认scheduler.step()该放在 epoch 级还是 batch 级。我踩过这个坑,明明调度器写对了,学习率曲线却完全不对,排查半天才发现是调用时机的问题。
4.3 混合精度训练:省显存提速,但有几个雷区
torch.cuda.amp让混合精度训练变得非常简单,通常能省下三到四成显存,速度也有提升。核心是用autocast控制前向的精度,用GradScaler处理梯度缩放,防止 fp16 下梯度下溢。
scaler = torch.cuda.amp.GradScaler() for x, y in loader: optimizer.zero_grad() with torch.cuda.amp.autocast(): out = model(x) loss = criterion(out, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()雷区主要有两个。第一,不是所有操作都适合放进 autocast,某些对数值精度敏感的运算(比如涉及大范围求和的 loss)可能需要强制 fp32,可以用with autocast(enabled=False)局部关掉。第二,scaler.step()内部可能会跳过某些 step(当检测到梯度异常时),所以如果你在循环里手动做了梯度裁剪,要确保裁剪发生在scaler.unscale_()之后,否则裁剪的是被缩放过的梯度,数值完全不对。
5. 数据管道:模型再快,喂不饱 GPU 也白搭
5.1 Dataset 和 DataLoader 的分工要拎清
Dataset负责"怎么取一条数据",DataLoader负责"怎么组批、怎么并行加载、怎么打乱"。很多人把逻辑全塞进Dataset.__getitem__,结果num_workers > 0时各种诡异问题。核心原则是:__getitem__里只做和单条数据相关的、无状态的操作,任何跨样本的、有状态的逻辑都应该放到别处。
class MyDataset(torch.utils.data.Dataset): def __init__(self, paths, labels, transform=None): self.paths = paths self.labels = labels self.transform = transform def __len__(self): return len(self.paths) def __getitem__(self, idx): img = load_image(self.paths[idx]) # 只处理单条 if self.transform: img = self.transform(img) return img, self.labels[idx]5.2 num_workers 不是越大越好
num_workers控制用几个子进程加载数据。设成 0 表示在主进程里加载,简单但会阻塞训练;设大了能并行,但进程间通信和内存开销也会上升。经验值是从 4 开始试,逐步加到 GPU 利用率饱和为止,盲目设成 CPU 核心数往往适得其反。
还有一个隐蔽的坑:在 Windows 上,num_workers > 0要求你的训练代码放在if __name__ == '__main__':保护块里,否则子进程会重复导入主模块导致崩溃。这个在 Linux 上跑得好好的代码,换到 Windows 就报错,原因就在这里。
5.3 自定义 collate_fn 处理变长数据
当一批数据的长度不一致时(比如变长文本、不同尺寸的图),默认的组批逻辑会失败。这时候需要自定义collate_fn,手动决定怎么把多条数据拼成一个 batch,常见做法是 padding 到本批最大长度。
def collate_fn(batch): seqs, labels = zip(*batch) max_len = max(len(s) for s in seqs) padded = torch.zeros(len(seqs), max_len, dtype=torch.long) for i, s in enumerate(seqs): padded[i, :len(s)] = s return padded, torch.tensor(labels) loader = DataLoader(dataset, batch_size=32, collate_fn=collate_fn)padding 的时候记得同时生成 mask,否则后续计算 attention 或 loss 时会把填充位置也算进去,结果就偏了。这个细节在序列任务里几乎是必踩的坑。
6. 模型保存与加载:别让几天的训练白费
6.1 state_dict 才是正道
保存模型有两种常见写法:存整个模型对象,或者只存state_dict。强烈建议只存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这个参数很关键。如果你在 GPU 上训练保存的模型,拿到没有 GPU 的机器上加载,不加map_location='cpu'就会报错。养成加载时显式指定设备的习惯,能省掉很多跨环境部署的麻烦。
6.2 断点续训要保存的不只是模型
真正做长周期训练时,光存模型参数是不够的。你还得存优化器状态、当前 epoch、学习率调度器状态,否则续训时优化器的动量丢了,loss 曲线会突然抖一下。完整的 checkpoint 应该长这样:
checkpoint = { 'epoch': epoch, 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'scheduler': scheduler.state_dict(), 'best_acc': best_acc, } torch.save(checkpoint, f'ckpt_epoch{epoch}.pth')加载时对应地全部恢复。这套机制我在做需要跑好几天的任务时是标配,中途机器出问题或者要调整策略,都能从最近的 checkpoint 接着来,不至于从头开始。
6.3 加载预训练模型时的 key 不匹配问题
用别人训好的权重做微调时,经常会遇到state_dict的 key 对不上的情况——可能是分类头名字不同,可能是多了或少了某些层。这时候不要慌,用strict=False加载,然后检查哪些 key 没匹配上:
missing, unexpected = model.load_state_dict( pretrained_dict, strict=False) print('missing:', missing) print('unexpected:', unexpected)missing是你模型里有但权重里没有的(通常是新加的分类头),unexpected是权重里有但你模型里没有的。搞清楚这两类分别是什么,就能判断加载是否正确。如果 missing 里出现了本该加载上的主干层,那说明命名规则对不上,需要手动做 key 的映射。
7. 踩过的坑与排查思路:几个真实场景的复盘
7.1 loss 变成 nan 的常见源头
训练中途 loss 突然变 nan,是最让人头疼的问题之一。排查顺序我一般是这样的:先看学习率是不是太大,这是最常见的原因;再看数据里有没有异常值或除零;然后检查 loss 函数里有没有log(0)或sqrt(负数)这类操作。混合精度训练下还要考虑梯度下溢,可以试试临时关掉 amp 看是否复现。
一个很隐蔽的源头是自定义 loss 里的除法。比如某个归一化项分母可能为 0,前几个 batch 数据正常所以没事,跑到某个 batch 分母恰好为 0,直接 nan。加个eps就能解决,但前提是你能想到去查这里。
7.2 显存明明够却报 OOM
"我显存明明还有富余,为什么报 out of memory?"这个问题我遇到过好几次。原因通常是显存碎片化:虽然总空闲显存够,但没有一块连续的足够大的空间。解决办法一是尽量用固定形状的输入,避免动态 shape 导致反复分配释放;二是可以设置PYTORCH_CUDA_ALLOC_CONF环境变量调整分配策略。
另一个常见原因是计算图没释放。比如你在循环里把 loss 存进了一个 list 却没 detach,整个计算图就被一直引用着,显存越用越多。存指标的时候记得.item()或.detach():
losses.append(loss.item()) # 正确,只存数值 # losses.append(loss) # 错误,会保留整个计算图7.3 多卡训练时的常见误区
DataParallel和DistributedDataParallel是两个不同层次的东西。前者实现简单但效率一般,主卡负载重;后者效率高但配置复杂,需要启动多进程。如果只是单机多卡快速验证,DataParallel够用;但要认真训练,DistributedDataParallel是更好的选择。
用多卡时有个高频错误:模型保存时忘了加module.前缀。DataParallel会把模型包一层,导致state_dict的 key 都带上了module.。加载到单卡模型时就会全部 missing。解决办法是保存时用model.module.state_dict(),或者加载时手动去掉前缀。
8. 一些让代码更"专业"的小习惯
写到这里,分享几个我长期用下来觉得收益很高的小习惯。第一,固定随机种子,包括 Python、numpy、torch 以及 CUDA 的,这样实验才可复现:
import random, numpy as np, torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)第二,把设备选择写成一行工具函数,避免到处硬编码'cuda':
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')第三,训练前先跑一个 batch 的过拟合测试。拿几条数据反复训,看 loss 能不能降到接近 0。如果连几条数据都过拟合不了,那说明模型或数据管道有 bug,这时候去调超参数纯属浪费时间。这个习惯帮我省下了大量无谓的调参时间。
PyTorch 的生态还在快速演进,新版本时不时会引入更简洁的 API,比如编译加速、更统一的设备管理接口。但底层那套张量、自动求导、设备管理的逻辑是相对稳定的,把这些吃透,上层 API 怎么变你都能快速跟上。真正拉开差距的,从来不是记住了多少 API,而是遇到问题时知道往哪个方向查、为什么这么查。