Day37,讲点实际的。今天这篇不是来背概念的,而是把最近折腾的GPU训练和一些Python类的高级玩法放在一起聊。标题写的是“类call的方法”,说白了就是Python类里的__call__,这东西在深度学习代码里出现的频率高得惊人,PyTorch的模型层、函数式API、各种封装器里全是它的影子。另一个主角是GPU训练,这也是跑模型绕不开的坎。
先说下这篇适合谁看。如果你已经在用PyTorch写模型,但始终是CPU在跑,或者你的代码里见过model = MyModel()然后直接model(x)这种写法,但不太明白为什么一个实例能被“调用”,那这篇就是写给你的。如果你只是听说过GPU训练能加速,但不知道怎么把代码从CPU平滑切到GPU,这篇也会把关键路径全部拆开。我尽量用踩坑现场的方式讲,不搞教科书那套。
1. GPU训练到底在优化什么
1.1 先弄明白GPU和CPU的分工逻辑
很多新手第一次接触GPU训练时会有一个直觉误区:GPU跑得快,是因为它的时钟频率高。实际上完全不是这么回事。现代CPU的单核频率经常能冲到4GHz以上,而GPU的核心频率通常只有1.5GHz到2GHz左右,单纯比频率,CPU反而占优。
GPU真正强的地方在于并行计算规模。一个普通的消费级显卡,比如RTX 3060,有3584个CUDA核心;旗舰卡像RTX 4090,核心数能到16384个。CPU再猛,消费级桌面处理器主流也就8到16个物理核心,服务器级的也就64核到96核顶天了。GPU等于用几千个低频小核心同时干活,而CPU是少数几个高频大核心按顺序硬算。
用生活类比来说,CPU像一个数学天才,你抛给它一道特别复杂的微积分,它能很快算完;但如果让它做一万道简单的四则运算,反而容易烦躁出错。GPU像是一万个小学生在排队做口算,每一道题对单个学生来说很简单,但一万个人同时开算,整体吞吐量远超那个天才。
深度学习模型里的矩阵乘法、卷积运算,本质上都是大量可并行的简单数学操作。一个卷积核在一个特征图上滑动,每个位置的计算互不依赖,这就是天然的并行任务。GPU的架构就是为这种“多而简单”的运算场景设计的。明白了这一点,你就能理解为什么数据量小的时候GPU优势不明显——启动CUDA上下文、数据传输的开销摆在那里,任务本身的计算量填不满空闲算力,反而比CPU慢。这也是一开始我为什么建议跑小demo先用CPU的原因。
1.2 我实测的CPU到GPU速度对比
给一组我最近的实测数据。环境是一个包含三层卷积和两层全连接的小型分类网络,训练数据集是CIFAR-10,batch size设为64,跑20个epoch。
- 纯CPU模式(i7-12700H,8核16线程):训练完成耗时17分42秒。
- 相同代码切换到GPU(RTX 3060 Laptop,6GB显存):训练完成耗时1分26秒。
加速比大约12.3倍。这个速度差异在更大的模型和数据规模上会更夸张。比如跑ResNet-50在ImageNet上做完整训练,CPU可能需要几周,GPU按天数甚至小时算。所以业界常说一句话:“深度学习不是被算法卡住的,是被算力卡住的。”
但别急着把代码搬到GPU就跑——里面有个非常典型的坑。如果batch size设得太小,比如设成1或者2,GPU上几千个核心大部分时间处于空闲等待状态,因为每个batch需要的数据量太少,喂不满计算管线。相反,batch size设太大又可能直接显存溢出。这个度需要你根据显卡型号去试,通常做法是看显卡的显存,6GB的卡batch size从32到128都是常见区间,8GB以上的卡可以尝试更大的值。
1.3 GPU训练的三个核心要素:显存、算力、CUDA环境
围绕GPU训练的知识可以压缩成三个关键点:显存容量、计算能力、CUDA环境。这三者缺一不可,而且互相牵制。
显存解决的是“装得下”的问题。模型参数、梯度、优化器状态、中间激活值,全都住在显存里。一个参数量为1亿的FP32模型,光参数就是400MB,加上梯度和优化器状态,直接翻三倍到1.2GB,再算上激活值,实际占用可能超过2GB。所以很多人的报错是从CUDA out of memory开始的,而不是从数学错误开始的。
算力决定“跑多快”。每张NVIDIA显卡都有对应的Compute Capability,比如RTX 30系列是8.6,RTX 40系列是8.9(部分型号是8.6)。你装的PyTorch版本会对算力有一个最低要求,太老的显卡配上太新的PyTorch,可能会直接提示找不到合适的CUDA设备。
CUDA环境解决的是“能不能用”的问题。这里说的是软件层面的CUDA Toolkit、cuDNN以及显卡驱动。很多人搞混一个概念:GPU是硬件,CUDA是驱动和运行库的集合。显卡驱动负责操作系统和GPU通信,CUDA Toolkit提供开发库和编译器,cuDNN则是深度神经网络专门优化的加速库。PyTorch是预编译的,它内部的CUDA依赖已经打包了,所以你在训练时其实不装完整的CUDA Toolkit也能跑,只要显卡驱动够新就行。
注意:如果你用conda安装的是
pytorch-cuda版本,PyTorch自带的CUDA运行时足以支撑训练。但如果你要自己编译CUDA扩展,比如某些需要写C++/CUDA混合代码的算子,那就必须单独安装CUDA Toolkit,而且要保证版本和PyTorch编译时的CUDA版本一致或兼容。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 类里的__call__方法:为什么它值得单独讲一天
2.1 __call__到底是什么
先看一段最基础的代码,我在学习第20天左右第一次遇到这个写法时是真的愣了一下:
python复制class Greeter:
def __init__(self, name):
self.name = name
def __call__(self, message):
return f"{self.name} says: {message}"
greeter = Greeter("Alice")
print(greeter("hello"))
# 输出: Alice says: hello
注意greeter("hello")这一行。greeter是Greeter类的一个实例,按常规的认知,实例不是函数,它是不能被调用的。但这里它不仅能被调用,还直接执行了__call__方法里的逻辑。这就是__call__方法的作用:让一个类的实例变得“可调用(callable)”。
在Python里,判断一个对象是不是可调用的,可以用内置函数callable():
python复制print(callable(greeter))
# 输出: True
只要类里定义了__call__方法,这个类的所有实例都会变成可调用对象。而且这个方法和普通的类方法完全一样,可以定义参数、默认值、*args和**kwargs,也可以返回值。它的特殊性只在于触发时机——当你在实例后面加括号时,Python解释器会自动调用它。
从语法层面拆解一下,greeter("hello")这行代码的背后,实际上等价于:
python复制Greeter.__call__(greeter, "hello")
Python把实例作为第一个参数自动传递进去。这种设计让类的使用方式更接近函数,但同时又保留了对象的状态和属性。这是函数做不到的:函数无法在两次调用之间稳定保存一份内部状态,除非用全局变量或闭包;而实例可以在__init__里把状态初始化好,后续每次调用__call__都能读写这份状态。
2.2 为什么写模型代码离不开__call__
深度学习框架大量使用__call__不是巧合,而是因为模型在数学上本来就是函数。一个神经网络接收输入张量,经过层层计算,输出预测张量——这不就是一个函数吗?但神经网络又不像纯函数那么简单,它内部有大量参数(权重和偏置)、有训练模式和推理模式的切换、有缓存的中间值。这些附加信息用类来承载最合适。
PyTorch的nn.Module就是典型例子。当你写:
python复制class MyModel(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Linear(10, 2)
def forward(self, x):
return self.fc(x)
model = MyModel()
output = model(x)
你调用model(x)的时候,并没有直接执行forward方法。PyTorch在nn.Module里重写了__call__方法,大致逻辑是这样的:
python复制def __call__(self, *args, **kwargs):
# 做一些前置检查,比如hook的触发、训练/推理模式切换
result = self.forward(*args, **kwargs)
# 做后置处理,比如hook的回归
return result
正因为__call__的存在,你在调用模型时不用手动写model.forward(x),而是可以像调用一个函数一样自然地写model(x)。而且,__call__这个壳里可以做很多forward之外的事情:触发自定义hook、统计调用次数、在训练模式和推理模式之间做不同的处理。如果你直接把业务逻辑塞进forward,这些能力就享受不到了。
顺带说一句,nn.Module的__call__内部还会自动处理requires_grad的上下文逻辑。你写with torch.no_grad(): output = model(x)时,no_grad上下文管理器影响的是全局的梯度记录状态,但模块内部的一些参数状态也需要在正确时机保存和恢复。这个工作就是在__call__里完成的。
2.3 自己动手写一个callable类
理解了基础概念之后,我来写一个更接近真实训练场景的示例。假设我们要做一个简单的学习率调度器,它需要根据当前训练轮次动态计算学习率。你可以用函数加全局变量来写,但用类会干净得多:
python复制class CosineAnnealingScheduler:
def __init__(self, base_lr, min_lr, total_epochs):
self.base_lr = base_lr
self.min_lr = min_lr
self.total_epochs = total_epochs
def __call__(self, epoch):
if epoch >= self.total_epochs:
return self.min_lr
progress = float(epoch) / float(self.total_epochs)
return self.min_lr + 0.5 * (self.base_lr - self.min_lr) * (1 + math.cos(math.pi * progress))
scheduler = CosineAnnealingScheduler(base_lr=0.01, min_lr=0.0001, total_epochs=100)
for epoch in range(101):
current_lr = scheduler(epoch)
print(f"Epoch {epoch}: lr = {current_lr:.6f}")
如果你问我这个类的优势在哪,答案是:调度器的所有配置参数和计算逻辑被封装在一个对象里,不需要在训练循环里维护额外变量。你可以在训练循环外把scheduler实例传进来,每次调用就拿到该轮的学习率,状态全在实例内部,逻辑清晰且易于测试。
真实的训练代码里,PyTorch官方的LambdaLR、StepLR等调度器本身也是callable的,它们内部都有__call__相关的机制,只是封装得更加隐蔽。你用熟了之后就会发现,看Python源码时只要看到类里有__call__,第一反应就应该是:这个类是要被当成函数来用的。
2.4 __call__和forward的区别(很多人第一次都会搞混)
这个问题在初学阶段极其容易踩坑,我专门花了一天时间才彻底理顺。__call__是Python类的方法,属于语言层面的内置协议;forward是PyTorch里nn.Module定义的方法,属于框架层面。
最直接的区别是这样的:
- 如果你继承
nn.Module并定义了forward,当调用model(x)时,nn.Module的__call__会被触发,内部接着调用了你的forward。 - 如果你继承了
nn.Module但重写了__call__而没有调用父类的__call__,那么forward就完全不会被执行,你的自定义逻辑会替换掉默认行为。
从调用链看,model(x)的行为是这样的:__call__是入口,forward是实际计算逻辑的出口。多数情况下你应该只重写forward,不要动__call__,除非你非常清楚自己在干什么。
有一种特殊情况可能需要重写__call__:你要在模型的前向传播前后插入一些与PyTorch官方机制冲突的自定义逻辑。但说实话,PyTorch的hook机制已经足够覆盖这些需求,重写__call__反而容易把自己绕晕。正常业务代码里,老老实实只写forward就够了。
3. 把两个知识点串起来:一次完整的GPU训练实操记录
3.1 环境准备与版本对齐
这部分我踩过最狠的坑是版本不匹配。GPU环境出问题,90%的情况是驱动、CUDA Toolkit、PyTorch三者没有对齐。
先说驱动的检查命令:
bash复制nvidia-smi
这个命令会输出显卡型号、驱动版本和CUDA版本。注意,这里显示的CUDA版本是驱动支持的最大CUDA版本,不代表你的PyTorch用的就是这个版本。PyTorch是自带CUDA运行时的,所以它可以在比驱动显示版本更低的环境里跑。
然后是PyTorch的安装。我建议用官方提供的匹配命令来装,不要图省事直接pip install torch。因为默认的PyPI源安装的是CPU版本,这点很多人中过招,装了之后torch.cuda.is_available()永远返回False。
bash复制# 请根据你的CUDA版本到PyTorch官网获取最新命令
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
装完之后立刻验证:
python复制import torch
print(torch.__version__)
print(torch.cuda.is_available())
print(torch.cuda.get_device_name(0))
如果torch.cuda.is_available()返回True,说明环境没问题。如果返回False,把nvidia-smi的执行结果、PyTorch的安装版本一起贴到搜索框里排查,大概率是驱动太旧或者装成了CPU版本。
3.2 一个可复制的训练脚本核心代码
这里给出一段可以直接跑的简化训练代码,同时把GPU相关的要点全写进去。我用的是MNIST数据集和一个小型卷积网络,这样CPU和GPU都能在合理时间内跑完,方便你做对比测试。
python复制import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# 1. 设备检测
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"当前训练设备: {device}")
# 2. 数据预处理与加载
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST(
root="./data", train=True, download=True, transform=transform
)
train_loader = DataLoader(
train_dataset, batch_size=64, shuffle=True, num_workers=4
)
# 3. 定义模型
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.pool = nn.MaxPool2d(2)
self.fc1 = nn.Linear(64 * 7 * 7, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.pool(torch.relu(self.conv1(x)))
x = self.pool(torch.relu(self.conv2(x)))
x = x.view(x.size(0), -1)
x = torch.relu(self.fc1(x))
x = self.fc2(x)
return x
model = SimpleCNN()
model = model.to(device) # 关键一步:把模型参数搬到GPU
# 4. 损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 5. 训练循环
model.train()
for epoch in range(3):
running_loss = 0.0
for images, labels in train_loader:
# 关键一步:把每个batch的数据搬到GPU
images = images.to(device)
labels = labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
avg_loss = running_loss / len(train_loader)
print(f"Epoch {epoch+1}: loss = {avg_loss:.4f}")
print("训练完成")
这段代码里有三个地方是和GPU强相关的。第一个是device = torch.device("cuda" if torch.cuda.is_available() else "cpu"),它做了一次设备探测,CPU机器上也会自动降级运行,不会直接报错。第二个是model = model.to(device),它把模型的所有参数和缓冲区搬迁到目标设备。第三处是循环里的images.to(device)和labels.to(device),它把每个batch的数据也搬过去。
为什么数据要显式搬到GPU?因为PyTorch在默认情况下不会做自动设备迁移。你放在CPU上的张量和在GPU上的模型参数之间做运算,会直接报错:Expected all tensors to be on the same device。这是新手最常见的报错之一。
3.3 观察训练进程和显存占用
训练过程中你还可以从另一个天然监控窗口里看到GPU的实际利用情况。跑上面的脚本时,在另一个终端里执行:
bash复制watch -n 1 nvidia-smi
你会看到显卡的显存占用和利用率数据。正常训练时,GPU-Util通常会跳到80%到100%之间跳动,显存占用大概在几百MB到几GB之间(取决于模型大小和batch size)。
如果你在nvidia-smi里看到GPU利用率是0%,但显存占用很高,多半是数据加载环节出了问题,比如num_workers设成了0导致数据加载慢,GPU在空转等数据。在DataLoader里把num_workers从0调到4或者8,经常能显著提升训练速度。
看显存有个细节要注意:PyTorch的显存管理不像CPU内存那样释放得很及时。即使某个张量已经不再使用,显存也可能被PyTorch的缓存机制保留着,这叫作显存碎片管理。如果你在训练中途遇到CUDA out of memory,不一定是真的显存不够,也可能是缓存碎片太碎了。一个常见的解决办法是:
python复制torch.cuda.empty_cache()
这个操作会清空PyTorch的显存缓存,但不推荐在训练循环里频繁调用,因为它每次调用都有额外开销,反而拖慢速度。只是在开始新一轮训练或者切换数据集时手动调用一次就够了。
4. 我在这37天里踩过的坑:GPU训练与__call__问题排查
4.1 CUDA不可用的排查思路
torch.cuda.is_available()返回False是GPU训练第一道大坎。我梳理一下排查顺序,按照这个顺序来基本上能快速定位:
- 先跑
nvidia-smi,看系统是否识别到显卡。如果命令都执行不了,驱动压根没装好,这就是第一步的问题。 - 如果驱动正常,看PyTorch的版本信息:
print(torch.__version__)。如果版本号末尾带+cpu,说明装的是CPU版本,卸载重装GPU版本。 - 如果PyTorch显示
+cu121这种含cu的版本,但is_available()还是False,检查显卡驱动的版本是否太旧。比如CUDA 12.1对应最低驱动版本是530左右,如果驱动停留在470以下,就会出现运行库无法初始化的情况。 - 最后一个冷门原因(这个我在Windows上遇到过):显卡被其他进程占用了,尤其是桌面窗口管理器或者某些后台应用。Windows的WDDM模式下,如果设置不对,会出现
gpu access blocked by the operating system这类提示。这个不属于编码问题,需要去NVIDIA控制面板或系统设置里调整GPU调度策略。
4.2 显存溢出(OOM)的应对方法
显存溢出是另一个高频问题,报错信息通常长这样:
code复制RuntimeError: CUDA out of memory. Tried to allocate 256.00 MiB (GPU 0; 6.00 GiB total capacity; 5.2 GiB already allocated; ...)
关键信息在“already allocated”和“total capacity”两部分。5.2GiB已占用,6GiB总容量,只剩几百MB,再申请256MB就爆了。这时有几招按顺序试:
第一招:减小batch size。这是最直接的,从64减到32,显存占用几乎线性下降。但注意batch size太小会影响BN(Batch Normalization)层的表现,一般不要低于16。
第二招:使用梯度累积。不改变batch size,但每个step不立即更新梯度,攒几个step再更新一次。代码写法是:
python复制accumulation_steps = 4
for i, (images, labels) in enumerate(train_loader):
images = images.to(device)
labels = labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels) / accumulation_steps
loss.backward()
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
第三招:启用混合精度训练。这也是英伟达官方大力推荐的方案。用torch.cuda.amp可以把部分计算从FP32降到FP16,显存占用近乎减半,训练速度也有可感知的提升。现在的PyTorch版本中推荐直接用torch.autocast:
python复制from torch.cuda.amp import autocast
model.to(device)
scaler = torch.cuda.amp.GradScaler()
for images, labels in train_loader:
images = images.to(device)
labels = labels.to(device)
optimizer.zero_grad()
with autocast():
outputs = model(images)
loss = criterion(outputs, labels)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
第四招:不光是数据占显存,模型输入尺寸也是大头。如果你用的是2D卷积网络,输入图片分辨率会影响最后一层全连接的神经元数量,间接影响参数量和显存占用。能用224x224解决的不要上448x448,很多任务对小分辨率图不够敏感是模型结构的问题,不是输入尺寸的锅。
4.3 __call__相关代码的常见报错
写自定义类时,最容易犯的一个低级错误是忘了在__init__里调用super().__init__()。尤其是在继承nn.Module时,不调用父类构造函数,self下面的模块注册列表就是空的,模型里写的那些nn.Linear不会注册为子模块,后续model.to(device)根本不会把它们搬到GPU上,推理时大概率报错。
再一个是混淆类实例和类本身的调用。比如:
python复制class MyModel(nn.Module):
def forward(self, x):
return x * 2
model = MyModel()
# 正确
output = model(1)
# 错误
output = model.forward(1)
第二行看起来没问题,但如果你在forward里用了self.dropout这类有内部状态切换的层,直接用forward会跳过PyTorch在__call__里做的模式切换逻辑,导致训练和推理的行为不一致。所有会改变状态的层(Dropout、BN)都依赖__call__来做正确的模式设置,所以务必坚持用model(x)而不是model.forward(x)。
还有一种情况是类定义了一个接收任意参数的__call__,但你在调用时把关键字参数和位置参数搞混了。我用**kwargs写了一个调度器,调用时传的是位置参数,结果一直报missing required positional argument。这种问题没有捷径,只能一遍遍对照类定义里的函数签名,关键是灵活处理*args和**kwargs的数量。
4.4 排查表格:几类高频问题的定位方向
直接整理一个速查表,方便日后再踩坑时对照:
| 报错或现象 | 可能原因 | 解决方向 |
|---|---|---|
torch.cuda.is_available()为False |
装了CPU版PyTorch / 驱动过旧 | 卸载重装对应CUDA版本;升级显卡驱动 |
CUDA out of memory |
batch size过大 / 模型过大 / 显存碎片 | 减小batch size / 开启混合精度 / 调用empty_cache |
Expected all tensors to be on the same device |
数据没搬到GPU / 模型没搬到GPU | 检查model.to(device)和images.to(device) |
DataLoader worker (pid) exited unexpectedly |
num_workers设置过高 / 内存不足 | 降低num_workers或关闭 |
| 模型推理结果和训练时差异很大 | 直接用forward跳过__call__ |
用model(x)代替model.forward(x) |
| 实例调用时提示不可调用 | 类里忘了定义__call__ 或 __call__重写有语法错误 |
检查类定义里的__call__签名 |
4.5 避开训练崩溃的一些小经验
实际跑训练时,代码层面的语法错误反而是最少见的,最多的是“逻辑上看起来对,但训练一会儿就崩”的诡异问题。
第一个经验是DataLoader的num_workers不要贪多。num_workers=4已经覆盖绝大多数情况,设成8或者16会让子进程频繁地做进程切换和数据通信,Windows上还容易触发乱码的异常。如果你的数据集很小(几千张以内),num_workers设1就够了。
第二个经验是尽量用torch.utils.data.DataLoader自带的persistent_workers=True参数。这样每个epoch开始时不会重复创建worker进程,能省下不少时间。但注意这个参数在Windows上偶发兼容问题,如果出现异常直接关掉。
第三个是模型保存和加载的设备问题。如果用torch.save(model.state_dict(), "model.pth")保存模型,然后在一台没有GPU的机器上直接model.load_state_dict(torch.load("model.pth")),有可能报错,因为权重是存在GPU显存里的,加载时不能直接和CPU模型对齐。安全写法是:
python复制torch.save(model.state_dict(), "model.pth")
# 加载时
model.load_state_dict(torch.load("model.pth", map_location="cpu"))
这个map_location="cpu"保证所有权重先落地到CPU再拷入模型,不依赖当前机器是否有GPU。
5. 实战收尾:把GPU训练包装成一个可复用的任务类
学完了__call__和GPU训练,最后把它们缝合起来。我建议你养成一个好习惯:把一段完整的训练流程封装成类,这个类除了初始化配置之外,还要实现一个__call__或train方法,让它既能被直接调用,又能被外部框架统一调度。
我最近的项目里就用了一个简化的Trainer类,结构大致是这样的:
python复制class Trainer:
def __init__(self, model, device, train_loader, optimizer, criterion):
self.model = model.to(device)
self.device = device
self.train_loader = train_loader
self.optimizer = optimizer
self.criterion = criterion
self.epoch_loss = []
def __call__(self, epochs):
self.model.train()
for epoch in range(epochs):
total_loss = 0
for images, labels in self.train_loader:
images, labels = images.to(self.device), labels.to(self.device)
self.optimizer.zero_grad()
outputs = self.model(images)
loss = self.criterion(outputs, labels)
loss.backward()
self.optimizer.step()
total_loss += loss.item()
avg_loss = total_loss / len(self.train_loader)
self.epoch_loss.append(avg_loss)
print(f"Epoch {epoch+1}, Loss: {avg_loss:.4f}")
return self.epoch_loss
使用方式就非常干净了:
python复制trainer = Trainer(model, device, train_loader, optimizer, criterion)
history = trainer(epochs=10)
把训练逻辑封装进__call__的好处很明显:整个训练过程变成了一个“接收训练轮次,返回损失历史”的调用,其他模块想复用这个训练逻辑时,只需要创建Trainer实例并调用它,不需要关心内部细节。这种做法和深度学习框架里大量出现的callable设计一脉相承,你以后看别人的源码,见到class Something:下面定义__call__,第一反应就知道这个类是可以被“当成函数”使用的。
实际使用中,如果GPU训练中途出了OOM,你可以在这个类的初始化里加一个显存预检查;如果想做断点续训,可以把epoch进度写到实例属性里。类的封装天生适合管理这些状态。
我个人在跑完这37天之后最大的感受是,GPU训练和__call__确实都是深度学习开发中最基础又最实用的技能。模型能不能跑得快,取决于你对设备和显存的理解;代码能不能写得顺手,取决于你对Python语言底层协议的掌握程度。两者合在一起,才算是把“用PyTorch训练模型”这件事做完整了。
最后再分享一个小技巧。在训练脚本里加一段自动选择设备的逻辑,然后把device全局变量传递到所有需要在设备上创建张量的地方,可以避免很多低级错误。就算暂时没有GPU,代码也能在CPU上继续跑,这样同事复现你的代码时就不会被环境卡住。设备感知能力是专业级训练代码的基本素养。
