@浙大疏锦行
CPU 与 GPU 训练对比,解密 GPU 小数据集反而更慢 + __call__魔法方法详解
前言
今天基于鸢尾花数据集搭建 MLP 多层感知机,分别在 CPU、GPU 上训练模型,踩了一个经典大坑:小数据集、简单网络,GPU 训练速度反而不如 CPU。我们分析背后 3 大开销,并且学习 PyTorch 底层核心设计:nn.Module的__call__魔法方法。
一、完整代码:CPU 版本训练鸢尾花 MLP
import torch import torch.nn as nn import torch.optim as optim from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import MinMaxScaler import numpy as np import time import matplotlib.pyplot as plt # 加载鸢尾花数据集 iris = load_iris() X = iris.data # 特征数据 y = iris.target # 标签数据 # 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) # 归一化:神经网络对输入特征尺度敏感 scaler = MinMaxScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) #测试集只用transform,防止数据泄露 # 转为pytorch张量,分类标签用LongTensor X_train = torch.FloatTensor(X_train) y_train = torch.LongTensor(y_train) X_test = torch.FloatTensor(X_test) y_test = torch.LongTensor(y_test) # 搭建MLP模型 class MLP(nn.Module): def __init__(self): super(MLP, self).__init__() self.fc1 = nn.Linear(4, 10) # 输入4特征,隐藏层10神经元 self.relu = nn.ReLU() self.fc2 = nn.Linear(10, 3) # 输出3分类 # CrossEntropyLoss自带Softmax,输出层不用激活函数 def forward(self, x): out = self.fc1(x) out = self.relu(out) out = self.fc2(out) return out # 实例化模型、损失函数、优化器 model = MLP() criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01) num_epochs = 20000 losses = [] start_time = time.time() for epoch in range(num_epochs): # 前向传播,两种写法:model.forward(X_train) 或者 model(X_train) outputs = model(X_train) loss = criterion(outputs, y_train) # 反向传播更新参数 optimizer.zero_grad() # 梯度清零,pytorch梯度会累积 loss.backward() optimizer.step() losses.append(loss.item()) if (epoch + 1) % 100 == 0: print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}') time_all = time.time() - start_time print(f'Training time: {time_all:.2f} seconds') # 绘制loss曲线 plt.plot(range(num_epochs), losses) plt.xlabel('Epoch') plt.ylabel('Loss') plt.title('Training Loss over Epochs') plt.show()我的机器(i9-12900KF)CPU 训练耗时约2.90s
二、GPU 版本训练代码
核心改动:模型、张量全部.to(device)迁移到 GPU,张量与模型必须在同一个设备,否则直接报错Tensor is on CPU, but expected it to be on GPU
import torch import torch.nn as nn import torch.optim as optim from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import MinMaxScaler import time import matplotlib.pyplot as plt # 判断CUDA是否可用,设置设备 device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") print(f"使用设备: {device}") # 查看CUDA硬件信息 if torch.cuda.is_available(): print("CUDA可用!") device_count = torch.cuda.device_count() print(f"可用的CUDA设备数量: {device_count}") current_device = torch.cuda.current_device() print(f"当前CUDA设备索引: {current_device}") print(f"GPU名称: {torch.cuda.get_device_name(current_device)}") print(f"CUDA版本: {torch.version.cuda}") print("cuDNN版本:", torch.backends.cudnn.version()) # 加载并预处理数据 iris = load_iris() X = iris.data y = iris.target X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) scaler = MinMaxScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) # 张量迁移到GPU X_train = torch.FloatTensor(X_train).to(device) y_train = torch.LongTensor(y_train).to(device) X_test = torch.FloatTensor(X_test).to(device) y_test = torch.LongTensor(y_test).to(device) # 模型定义不变 class MLP(nn.Module): def __init__(self): super(MLP, self).__init__() self.fc1 = nn.Linear(4, 10) self.relu = nn.ReLU() self.fc2 = nn.Linear(10, 3) def forward(self, x): out = self.fc1(x) out = self.relu(out) out = self.fc2(out) return out # 模型迁移到GPU model = MLP().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01) num_epochs = 20000 losses = [] start_time = time.time() for epoch in range(num_epochs): outputs = model(X_train) loss = criterion(outputs, y_train) optimizer.zero_grad() loss.backward() optimizer.step() # 每200轮记录loss,减少GPU->CPU同步 if (epoch + 1) % 200 == 0: losses.append(loss.item()) if (epoch + 1) % 100 == 0: print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}') time_all = time.time() - start_time print(f'Training time: {time_all:.2f} seconds') plt.plot(range(len(losses)), losses) plt.xlabel('Epoch') plt.ylabel('Loss') plt.title('Training Loss over Epochs') plt.show()大坑:GPU 跑这个任务反而更慢!
RTX3080Ti 跑原始代码耗时 11s,远大于 CPU 的 2.9s。
三大核心开销:
- 数据传输开销(显存↔内存)GPU 计算前,数据、模型参数要从电脑内存拷贝到 GPU 显存;调用
loss.item()时,必须把 GPU 上张量传回 CPU,这是同步阻塞操作。20000 次循环反复传输,开销巨大。 - GPU Kernel 启动开销每一次矩阵乘法、激活函数,都要在 GPU 上启动一个计算内核 kernel,启动存在固定开销。小计算量场景下,启动开销占比极高。
- 并行能力浪费鸢尾花数据集很小,网络极简单,GPU 成千上万个计算单元大部分空闲,强大的并行算力完全发挥不出来。
✅ GPU 适合:大数据集、深层大模型、大量矩阵 / 卷积并行计算。玩具小数据集,CPU 单核性能更强。
优化实验:减少 loss.item () 调用
表格
| 记录间隔(轮) | 记录次数(次) | 剩余时长(秒) |
|---|---|---|
| 100 | 200 | 10.43 |
| 200 | 100 | 10.02 |
| 1000 | 20 | 10.12 |
| 2000 | 10 | 9.74 |
现象:减少记录次数,总耗时下降不呈线性关系原因:
loss.item()是 CPU-GPU 同步阻塞操作,但 GPU kernel 调度、硬件延迟是不稳定的。除了 item () 开销,kernel 启动开销是固定存在的,所以减少 item () 次数不会等比例减少总时间。
三、PyTorch 底层核心:__call__魔法方法
问题:self.fc1 是 nn.Linear 实例对象,为什么可以写self.fc1(x)?
self.fc1 = nn.Linear(4,10) out = self.fc1(x) # 对象直接加括号调用,像函数一样!Python 中:只要类实现__call__魔法方法,它的实例就可以像函数一样obj()调用。
nn.Linear继承nn.Module,父类nn.Module内置写好了__call__。 伪代码简化理解 nn.Module:
class nn.Module: def __call__(self, *args,**kwargs): # 1.执行前向钩子pre-forward hook # 2.调用你写的forward方法 result = self.forward(*args, **kwargs) # 3.执行后向钩子post-forward hook return result执行逻辑:self.fc1(x)→ 隐式调用self.fc1.__call__(x)→ 内部自动执行self.fc1.forward(x)
⚠️ 重点:官方推荐model(x)而不是model.forward(x)直接调用 forward () 会跳过所有钩子函数,丢失回调、日志等功能。
nn.ReLU 也是同理:torch.relu()是原生函数,nn.ReLU()是封装好的类实例,依靠__call__实现relu_layer(x)写法,统一所有网络层的调用接口。
Python __call__基础 Demo
# 无参数__call__ class Counter: def __init__(self): self.count = 0 def __call__(self): self.count += 1 return self.count counter = Counter() print(counter()) #1 print(counter()) #2 # 带参数__call__ class Adder: def __call__(self, a, b): return a + b adder = Adder() print(adder(3,5)) #8四、CPU/GPU 硬件简单科普
CPU 选型要点
- 架构代际:新架构指令集、缓存更好,性能更强
- 制程工艺:数字越小,晶体管密度高,能效更好
- 性能核 P 核:强单核,适合小模型、游戏;能效核 E 核,低功耗后台多任务
- 核心 / 线程数、主频
GPU 深度学习选型要点
- 代:RTX40xx>30xx>20xx,新架构优化 Tensor Core
- 型号等级:90 旗舰 >80 高端 >70 甜点 >60 入门,Ti 为增强版本
- 显存 VRAM(深度学习最重要)存储模型参数、特征图、输入数据。显存不足会无法加载大模型,只能用极小 batch,梯度噪声变大。
五、总结
- 小数据集 + 简单网络,GPU 不一定更快,存在显存传输、kernel 启动固定开销;大模型大数据才能发挥 GPU 并行优势。
.to(device)把模型 / 张量迁移 GPU,必须保证输入张量和模型在同一个设备。loss.item()会触发 GPU 到 CPU 的数据同步,循环内频繁调用会显著拖慢 GPU 训练。__call__魔法方法:让类实例可以像函数一样obj()调用,PyTorch nn.Module 依靠这个机制实现model(x),并且支持钩子。- CrossEntropyLoss 内部自带 Softmax,多分类网络输出层不要加激活函数。
完整项目代码放在文中,可直接复制运行,观察 CPU/GPU 训练耗时差异。