前言
最近开始学习CUDA,要写一个小神经网络练练手,鉴于网上资料较少,便自己记录一下过程经验。
本篇文章将介绍如何以MNIST数据集为例,从零开始用C++ CUDA搭建出LeNet神经网络的推理代码过程。注意,本篇教程只是推理的部分,训练部分先用已有的Python代码。
因为用C++实现的训练代码涉及到反向传播等算法,博客讲解起来较复杂,后续有时间再写一篇。
从零开始不代表从零基础开始,建议掌握Python基础、神经网络基础、一丢丢CUDA基础。
一、所需环境
训练代码所需环境:python、pytorch、numpy。(版本够模型训练即可,要求不高)
推理代码所需环境:C++、对应版本的CUDA。(如果有VS 编译器的话,可以直接在安装CUDA的时候,勾选VS依赖包,从而能直接在VS编译器上新建CUDA项目 )
红框部分勾选起来。
如果已经有CUDA环境了但之前没有勾选Visual Studio Integration,可以参考这篇文章。如果嫌配置麻烦也可以卸载CUDA再重新安装。
环境安装过程本文不过多赘述,可以在网上看相关教程根据自己版本进行安装。
所需数据
本章教程所用的数据集是FashionMNIST,在执行Python语句的时候即能自动下载:
trainset = torchvision.datasets.FashionMNIST(os.path.join(script_dir, '../../data'), download=True, train=True,
transform=transform)
testset = torchvision.datasets.FashionMNIST(os.path.join(script_dir, '../../data'), download=True, train=False,
transform=transform)
下载后将其放置在任意目录即可:
二、实现思路
要用C++ CUDA实现LeNet的推理过程(即前向传播),我们需要先知道LeNet的神经网络架构是怎么样的。本篇文章所用的LeNet 训练代码如下:
#Train_LeNet.py
'''
Package Version
------------------------ ----------
certifi 2023.7.22
charset-normalizer 3.2.0
cmake 3.27.4.1
filelock 3.12.4
idna 3.4
Jinja2 3.1.2
lit 16.0.6
MarkupSafe 2.1.3
mpmath 1.3.0
networkx 3.1
numpy 1.26.0
nvidia-cublas-cu11 11.10.3.66
nvidia-cuda-cupti-cu11 11.7.101
nvidia-cuda-nvrtc-cu11 11.7.99
nvidia-cuda-runtime-cu11 11.7.99
nvidia-cudnn-cu11 8.5.0.96
nvidia-cufft-cu11 10.9.0.58
nvidia-curand-cu11 10.2.10.91
nvidia-cusolver-cu11 11.4.0.1
nvidia-cusparse-cu11 11.7.4.91
nvidia-nccl-cu11 2.14.3
nvidia-nvtx-cu11 11.7.91
Pillow 10.0.1
pip 23.2.1
requests 2.31.0
setuptools 68.0.0
sympy 1.12
torch 2.0.1
torchaudio 2.0.2
torchvision 0.15.2
triton 2.0.0
typing_extensions 4.7.1
urllib3 2.0.4
wheel 0.38.4
'''
import torch
import torchvision
import torchvision.transforms as transforms
import torch.nn as nn
import torch.optim as optim
import numpy as np
import torch.nn.functional as F
import os
# 定义LeNet模型
class LeNet(nn.Module):
def __init__(self):
super(LeNet, self).__init__()
self.conv1 = nn.Conv2d(1, 6, 5)
self.pool = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(6, 16, 5)
self.fc1 = nn.Linear(16 * 4 * 4, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)
def forward(self, x):
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = x.view(-1, 16 * 4 * 4)
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x
script_dir = os.path.dirname(__file__) # 获取脚本所在的目录
# 数据预处理
transform = transforms.Compose([transforms.ToTensor()])
# 加载数据集
trainset = torchvision.datasets.FashionMNIST(os.path.join(script_dir, '../../data'), download=True, train=True,
transform=transform)
testset = torchvision.datasets.FashionMNIST(os.path.join(script_dir, '../../data'), download=True, train=False,
transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=64, shuffle=True)
testloader = torch.utils.data.DataLoader(testset, batch_size=64, shuffle=False)
# 创建模型
model = LeNet()
model = model.to('cuda')
# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.002, momentum=0.9)
# 训练模型
for epoch in range(20):
print('epoch ', epoch)
for inputs, labels in trainloader:
inputs, labels = inputs.to('cuda'), labels.to('cuda')
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
# 测试模型的准确率
correct = 0
total = 0
with torch.no_grad():
for data in testloader:
images, labels = data
images, labels = images.to('cuda'), labels.to('cuda')
outputs = model(images)
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
print(correct / total)
# 以txt的方式导出模型参数,也可以自定义导出模型参数的文件格式,这里使用了最简单的方法。
for name, param in model.named_parameters():
np.savetxt(os.path.join(script_dir, f'./{
name}.txt'), param.detach().cpu().numpy().flatten())
#将该模型保存起来,以方便python代码对该模型进行读取调试
torch.save(model, "./model/modeltrain.pth")
简单来讲,训练代码做了以下三件事:
2.1. 定义了LeNet网络模型结构,并训练了20次
由代码可知,LeNet的模型由Conv2d(卷积层)、MaxPool2d(最大池化层)、Linear(线性层)、ReLu(激活函数层)这四个网络层组成:
2.2 以txt格式导出训练结果(模型的各个层权重偏置等参数)
将模型各个层权重参数以txt形式导出,方便C++代码读取。如果你将模型以pth/ckpt等格式进行存储,那C++读取起来有点麻烦。
导出的txt文件如下:
这些txt文件就代表了LeNet训练后的模型结果,如果你们不想训练可以直接下载 提取码:4DEF
2.3 (可选)以pth格式导出训练结果,以方便后期调试
我们已经将训练好的模型以txt形式导出了,为什么要多此一举用pth再次导出呢?
众所周知,凡是涉及到并行的代码,调试起来颇为不方便,用cuda-gdb等方式给你的CUDA代码打断点查变量可以是可以,但对于新手使用起来较麻烦。
除此之外,像LeNet这种多层神经网络,一步错则步步错,调试起来十分棘手。那我们怎么知道自己写的CUDA代码对不对呢?
故本文章提供一个简单的逐层调试方法:
我们不仅要用C++ CUDA实现LeNet的推理,还用Python的PyTorch实现一遍LeNet推理过程。
由于Python实现LeNet推理十分简单,即在原先训练代码上修改几行函数即可实现,不可能有出错情况,故我们可以将Python实现的LeNet推理结果作为参考答案,利用PyTorch提供的hook方法来打印LeNet模型每个层的输出结果,并将自己C++ CUDA每一层的输出进行逐层比较,从而得知自己用CUDA实现的LeNet推理代码是否有问题。
这里本文也提供LeNet的 python推理代码:
#Inference_LeNet.py
import torch
import torchvision
import torchvision.transforms as transforms
import torch.nn as nn
import torch.optim as optim
import numpy as np
import torch.nn.functional as F
import os
import struct
class LeNet(nn.Module):
def __init__(self):
super(LeNet, self).__init__()
self.conv1 = nn.Conv2d(1, 6, 5)
self.pool = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(6, 16, 5)
self.fc1 = nn.Linear(16 * 4 * 4, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)
def forward(self, x):
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = x.view(-1, 16 * 4 * 4)
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x
script_dir = os.path.dirname(__file__) # 获取脚本所在的目录
# 数据预处理
transform = transforms.Compose([transforms.ToTensor()])
# 加载数据集
trainset = torchvision.datasets.FashionMNIST(os.path.join(script_dir, './data'), download=False, train=True, transform=transform)
testset = torchvision.datasets.FashionMNIST(os.path.join(script_dir, './data'), download=False, train=False, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=64, shuffle=True)
testloader = torch.utils.data.DataLoader(testset, batch_size=1, shuffle=False)
#输出conv1层结果
def conv1_hook1(model,input,output):
print("conv1 ", output[0,0,:,:])#输出conv1第1个通道结果
print("conv1 ", output[0, 5, :, :])#输出conv1第5个通道结果
print("relu: ",F.relu(output[0,0,:,:]))
def conv2_hook1(model,input,output):
print("relu2: ", F.relu(output[0, 0, :, :]))
print("cov2: ",output[0,0,:,:])
def relu_hook1(model, input, output):
print("relu ", output[0, 5, :, :]) # [0, 0, 0, :]
def maxpool_hook1(model, input, output):
try:
print("max pool ", output[0, 0, :, :]) # [0, 0, 0, :]
except:
return
def fc1_hook1(model, input, output):
print("fc1 ", output) # [0, 0, 0, :]
print("fc1 ", F.relu(output)) # [0, 0, 0, :]
#print("conv1 ",output[0,2,0:10,0:10])
#想查看哪层网络输出结果,就取消注释掉哪一层
#model.conv1.register_forward_hook(conv1_hook1) #输出conv1结果
#model.pool.register_forward_hook(maxpool_hook1)
#model.relu.register_forward_hook(maxpool_hook1)
#model.conv2.register_forward_hook(conv2_hook1)
#model.fc2.register_forward_hook(fc1_hook1)
model = torch.load("./model/modeltrain.pth")
model.eval()
model = model.to('cuda')
model.conv1.register_forward_hook(conv1_hook1)
data = iter(testloader)
#print(data)
sum = 0
for i in range(10000):
image,label = next(data)
image = image.to('cuda')
output = model(image)
#print(output)
pre = 0
for i in range(10):
if output[0][i] > output[0][pre]:
pre = i
if pre == label:
sum+=1
#算准确率
print(sum/10000)
调试的时候直接注释掉hook函数即可打印相应层的输出结果。
2.4 C++ CUDA要做的事
由于图像数据可以看作是一种矩阵,故神经网络在对各个像素进行卷积、池化等操作的时候,十分适合并行操作,即CUDA可以对所有像素并行卷积得到结果,而不用前面像素卷积完再轮到下一个像素,拖累了速度。
我们要做的,就是用C++ CUDA实现这四个网络层,并为每个层开辟数组以存储txt中的模型各个层参数,并将这些参数从Host移动到Device内存中(即从CPU端移动到显卡端)。再编写运行在Device上的CUDA函数,让CUDA函数能并行调用Device内存中的参数进行卷积等运算,从而提高推理速度,实现CPU串行推理所做不到的事。
三、C++ CUDA具体实现
3.1 新建.cu文件并填好框架
首先需要新建一个.cu文件,我是用VS2022直接新建了CUDA项目。
然后在该.cu文件中填入需要的函数:读取MNIST数据集的图片、读取MNIST数据集的标签、读取上述导出的模型结果txt文件、逐张图片进行推理(我们要实现的内容)。
关于MNIST数据集的下载,建议直接运行上述的LeNet训练代码即可自动下载(Download=True),或者从网上下载后放到对应文件夹(“/…/…/data/FashionMNIST/raw/t10k-images-idx3-ubyte"和”/…/…/data/FashionMNIST/raw/t10k-labels-idx1-ubyte")
为了方便起见,基本框架和所需函数我已提前写好,此处代码可以直接使用,以读取数据集内容和训练好的模型(只要你事先准备好了FashionMNIST数据集和训练模型权重就行):
//Inference_LeNet.cu
#include <fstream>
#include <iostream>
#include <vector>
#include <chrono>
#include <iomanip>
#include <string>
#include <stdlib.h>
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#ifndef __CUDACC__
#define __CUDACC__
#endif
//#include <device_functions.h>
//定义宏函数wbCheck,该函数用于检查Device内存是否分配成功,以避免写过多代码
#define wbCheck(stmt) do {
\
cudaError_t err = stmt; \
if (err != cudaSuccess) {
\
printf( "\n\nFailed to run stmt %d ", __LINE__); \
printf( "Got CUDA error ... %s \n\n", cudaGetErrorString(err)); \
return -1; \
} \
} while(0)
// 读取MNIST数据集图片,该数据集需自行从网上下载,或直接运行上面的LeNet python训练程序自动下载
std::vector<std::vector<float>> read_mnist_images(const std::string & path) {
std::ifstream file(path, std::ios::binary);
if (!file) {
std::cout << "Cannot open file!" << std::endl;
return {
};
}
int magic_number = 0, num_images = 0, num_rows = 0, num_cols = 0;
file.read((char*)&magic_number, sizeof(magic_number));
file.read((char*)&num_images, sizeof(num_images));
file.read((char*)&num_rows, sizeof(num_rows));
file.read((char*)&num_cols, sizeof(num_cols));
// Reverse Integers (MNIST data is in big endian format)
magic_number = ((magic_number & 0xff000000) >> 24) | ((magic_number & 0x00ff0000) >> 8) |
((magic_number & 0x0000ff00) << 8) | ((magic_number & 0x000000ff) << 24);
num_images = ((num_images & 0xff000000) >> 24) | ((num_images & 0x00ff0000) >> 8) |
((num_images & 0x0000ff00) << 8) | ((num_images & 0x000000ff) << 24);
num_rows = ((num_rows & 0xff000000) >> 24) | ((num_rows & 0x00ff0000) >> 8) |
((num_rows & 0x0000ff00) << 8) | ((num_rows & 0x000000ff) << 24);
num_cols = ((num_cols & 0xff000000) >> 24) | ((num_cols & 0x00ff0000) >> 8) |
((num_cols & 0x0000ff00) << 8) | ((num_cols & 0x000000ff) << 24);
int image_size = num_rows * num_cols;
std::vector<std::vector<float>> images(num_images, std::vector<float>(image_size));
for (int i = 0; i < num_images; ++i) {
for (int j = 0; j < image_size; ++j) {
unsigned char pixel = 0;
file.read((char*)&pixel, sizeof(pixel));
images[i][j] = static_cast<float>(pixel) / 255.0f;
}
}
return images;
}
// loading MNIST Labels
std::vector<int> read_mnist_labels(const std::string & path) {
std::ifstream file(path, std::ios::binary);
if (!file) {
std::cout << "Cannot open file!" << std::endl;
return {
};
}
int magic_number = 0, num_items = 0;
file.read((char*)&magic_number, sizeof(magic_number));
file.read((char*)&num_items, sizeof(num_items));
// Reverse Integers (MNIST data is in big endian format)
magic_number = ((magic_number & 0xff000000) >> 24) | ((magic_number & 0x00ff0000) >> 8) |
((magic_number & 0x0000ff00) << 8) | ((magic_number & 0x000000ff) << 24);
num_items = ((num_items & 0xff000000) >> 24) | ((num_items & 0x00ff0000) >> 8) |
((num_items & 0x0000ff00) << 8) | ((num_items & 0x000000ff) << 24);
std::vector<int> labels(num_items);
for (int i = 0; i < num_items; ++i) {
unsigned char label = 0;
file.read((char*)&label, sizeof(label));
labels[i] = static_cast<int>(label);
}
return labels;
}
// 负责从txt文件中读取参数
std::vector<float> read_param(const std::string & path) {
std::ifstream file(path);
std::vector<float> params;
float param;
while (file >> param) {
params