Day 34

@浙大疏锦行

cpu与gpu的性能查看

以下是对CPU和GPU性能查看要点的简要说明:

CPU性能查看 :

架构代际 :CPU架构会定期更新换代,新一代架构通常在性能和能效方面有所提升。例如Intel的酷睿系列从早期的Nehalem到现在的Alder Lake,AMD从Zen到Zen 3等。

核心数 :指CPU中独立处理单元的数量,核心数越多,理论上能并行处理的任务越多。可以在任务管理器(Windows)或通过命令行工具(如 lscpu 在Linux)查看。

线程数 :通过超线程技术,一个核心可以同时处理多个线程。线程数通常大于或等于核心数,线程数越多,并行处理能力越强。

GPU性能查看 :

显存 :显存(VRAM)是GPU专用的内存,用于存储图形数据。显存容量越大,能处理的图形数据越多,对高分辨率和复杂场景的支持越好。可以通过 nvidia-smi (NVIDIA显卡)等工具查看 。

级别 :显卡有不同的性能级别,通常通过型号来区分。例如NVIDIA的GeForce RTX系列面向游戏市场,Quadro系列面向专业图形工作站,Tesla系列面向数据中心和AI训练。

架构代际 :GPU架构也会不断更新,新一代架构通常在性能、能效和支持的新技术方面有所提升。例如NVIDIA的RTX系列采用了Ampere或更新的Ada Lovelace架构,AMD的Radeon RX系列采用了RDNA或RDNA 2架构。

本质是因为GPU在计算的时候,相较于cpu多了3个时间上的开销

  1. 数据传输开销 (CPU 内存 <-> GPU 显存)

在 GPU 进行任何计算之前,数据(输入张量 X_train、y_train,模型参数)需要从计算机的主内存 (RAM) 复制到 GPU 专用的显存 (VRAM) 中。

当结果传回 CPU 时(例如,使用 loss.item () 获取损失值用于打印或记录,或者获取最终预测结果),数据也需要从 GPU 显存复制回 CPU 内存。

对于少量数据和非常快速的计算任务,这个传输时间可能比 GPU 通过并行计算节省下来的时间还要长。

在上述代码中,循环里的 loss.item () 操作会在每个 epoch 都进行一次从 GPU 到 CPU 的数据同步和传输,以便获取标量损失值。对于 20000 个 epoch 来说,这会累积不少的传输开销。

  1. 核心启动开销 (GPU 核心启动时间)

GPU 执行的每个操作(例如,一个线性层的前向传播、一个激活函数)都涉及到在 GPU 上启动一个 "核心"(kernel)------ 一个在 GPU 众多计算单元上运行的小程序。

启动每个核心都有一个小的、固定的开销。

如果核心内的实际计算量非常小(本项目的小型网络和鸢尾花数据),这个启动开销在总时间中的占比就会比较大。相比之下,CPU 执行这些小操作的 "调度" 开销通常更低。

  1. 性能浪费:计算量和数据批次

这个数据量太少,gpu 的很多计算单元都没有被用到,即使用了全批次也没有用到的全部计算单元。

综上,数据传输和各种固定开销的总和,超过了 GPU 在这点计算量上通过并行处理所能节省的时间,导致了 GPU 比 CPU 慢的现象。

CPU (12th Gen Intel Core i9-12900KF):对于这种小任务,CPU 的单核性能强劲,且没有显著的数据传输到 "另一块芯片" 的开销。它可以非常迅速地完成计算。

GPU (NVIDIA GeForce RTX 3080 Ti):需要花费时间将数据和模型从 CPU 内存移动到 GPU 显存。每次在 GPU 上执行运算(如 model (X_train)、loss.backward ())都有核心启动的固定开销。loss.item () 在每个 epoch 都需要将结果从 GPU 传回 CPU,这在总共 20000 个 epoch 中会累积。同时,GPU 强大的并行计算能力在这种小任务上完全没有用武之地。

这些特性导致 GPU 在处理鸢尾花分类这种 "玩具级别" 的问题时,它的优势无法体现,反而会因为上述开销显得 "笨重"。

那么什么时候 GPU 会发挥巨大优势?

大型数据集:例如,图像数据集成千上万张图片,每张图片维度很高。

大型模型:例如,深度卷积网络 (CNNs like ResNet, VGG) 或 Transformer 模型,它们有数百万甚至数十亿的参数,计算量巨大。

**合适的批处理大小:**能够充分利用 GPU 并行性的 batch size,不至于还有剩余的计算量没有被 GPU 处理。

**复杂的、可并行的运算:**大量的矩阵乘法、卷积等。

针对上面反应的3个问题,能够优化的只有数据传输时间,针对性解决即可,很容易想到2个思路:

  1. 直接不打印训练过程的loss了,但是这样会没办法记录最后的可视化图片,只能肉眼观察loss数值变化。

  2. 每隔200个epoch保存一下loss,不需要20000个epoch每次都打印,

python 复制代码
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

# 设置GPU设备
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")

# 加载鸢尾花数据集
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)

# 将数据转换为PyTorch张量并移至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  # 训练的轮数

# 用于存储每100个epoch的损失值和对应的epoch数
losses = []

start_time = time.time()  # 记录开始时间

for epoch in range(num_epochs):
    # 前向传播
    outputs = model(X_train)  # 隐式调用forward函数
    loss = criterion(outputs, y_train)

    # 反向传播和优化
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    # 记录损失值
    if (epoch + 1) % 200 == 0:
        losses.append(loss.item()) # item()方法返回一个Python数值,loss是一个标量张量
        print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}')
    
    # 打印训练信息
    if (epoch + 1) % 100 == 0: # range是从0开始,所以epoch+1是从当前epoch开始,每100个epoch打印一次
        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()
python 复制代码
# 知道了哪里耗时,针对性优化一下
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
import numpy as np

# 仍然用4特征,3分类的鸢尾花数据集作为我们今天的数据集
# 加载鸢尾花数据集
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)

# # 打印下尺寸
# print(X_train.shape)
# print(y_train.shape)
# print(X_test.shape)
# print(y_test.shape)

# 归一化数据,神经网络对于输入数据的尺寸敏感,归一化是最常见的处理方式
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test) #确保训练集和测试集是相同的缩放


# 将数据转换为 PyTorch 张量,因为 PyTorch 使用张量进行训练
# y_train和y_test是整数,所以需要转化为long类型,如果是float32,会输出1.0 0.0
X_train = torch.FloatTensor(X_train)
y_train = torch.LongTensor(y_train)
X_test = torch.FloatTensor(X_test)
y_test = torch.LongTensor(y_test)

class MLP(nn.Module): # 定义一个多层感知机(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)  # 隐藏层到输出层
# 输出层不需要激活函数,因为后面会用到交叉熵函数cross_entropy,交叉熵函数内部有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)

# # 使用自适应学习率的化器
# optimizer = optim.Adam(model.parameters(), lr=0.001)

# 训练模型
num_epochs = 20000 # 训练的轮数

# 用于存储每个 epoch 的损失值
losses = []

import time
start_time = time.time() # 记录开始时间

for epoch in range(num_epochs): # range是从0开始,所以epoch是从0开始
    # 前向传播
    outputs = model.forward(X_train)   # 显式调用forward函数
    # outputs = model(X_train)  # 常见写法隐式调用forward函数,其实是用了model类的__call__方法
    loss = criterion(outputs, y_train) # output是模型预测值,y_train是真实标签

    # 反向传播和优化
    optimizer.zero_grad() #梯度清零,因为PyTorch会累积梯度,所以每次迭代需要清零,梯度累计是那种小的bitchsize模拟大的bitchsize
    loss.backward() # 反向传播计算梯度
    optimizer.step() # 更新参数

    # 记录损失值
    # losses.append(loss.item())

    # 打印训练信息
    if (epoch + 1) % 100 == 0: # range是从0开始,所以epoch+1是从当前epoch开始,每100个epoch打印一次
        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')

在 Python 中,call 方法是一个特殊的魔术方法(双下划线方法),它允许类的实例像函数一样被调用。这种特性使得对象可以表现得像函数,同时保留对象的内部状态。

可以注意到,self.fc1 = nn.Linear(4, 10) 此时,是实例化了一个nn.Linear(4, 10)对象,并把这个对象赋值给了MLP的初始化函数中的self.fc1变量。

那为什么下面的前向传播中却可以out = self.fc1(x) 呢?,self.fc1是一个实例化的对象,为什么具备了函数一样的用法,这是因为nn.Linear继承了nn.Module类,nn.Module类中定义了__call__方法。(可以ctrl不断进入来查看)

在 Python 中,任何定义了 call 方法的类,其实例都可以像函数一样被调用。

当调用 self.fc1(x) 时,实际上执行的是:

  • self.fc1.call(x)(Python 的隐式调用)

  • 而 nn.Module 的 call 方法会调用子类的 forward 方法(即 self.fc1.forward(x))。这个方法就是个前向计算方法。

relu是torch.relu()这个函数为了保持写法一致,又封装成了nn.ReLU()这个类。来保证接口的一致性

PyTorch 官方强烈建议使用 self.fc1(x),因为它会触发完整的前向传播流程(包括钩子函数)这是 PyTorch 的核心设计模式,几乎所有组件(如 nn.Conv2d、nn.ReLU、甚至整个模型)都可以这样调用。

python 复制代码
# 我们来看下昨天代码中你的定义函数的部分
class MLP(nn.Module): # 定义一个多层感知机(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)  # 隐藏层到输出层
# 输出层不需要激活函数,因为后面会用到交叉熵函数cross_entropy,交叉熵函数内部有softmax函数,会把输出转化为概率

    def forward(self, x):
        out = self.fc1(x)
        out = self.relu(out)
        out = self.fc2(out)
        return out

类名后跟(),表示创建类的实例(对象),仅在第一次创建对象时发生。

call方法无参数的情况下,在实例化之后,每次调用实例时触发 call 方法