零、前言
欢迎来到 inferena!这是一个教程,介绍了这个平台的使用方法和功能。
我们的平台是一个主打模拟真实生产环境的模型训练和推理平台,提供了丰富的工具和资源来帮助你完成各种机器学习任务。
与传统的机器学习/人工智能平台不同,我们的目标是让用户可以了解在正式工业场景下模型训练,推理和部署的完整流程,包括数据准备、模型设计、训练过程、部署和监控等方面。
也许你听说过大名鼎鼎的 Kaggle 竞赛平台。和它不同,inferena 不只要求提交训练的模型——你需要在计算资源受限的端侧AI计算环境中完成一整套解决方案。
接下来,我们将通过完成 #1. MNIST手写数字识别 来带你一步一步了解成为一个 inferener 的注意事项!
一、训练模型
1.1 题目数据格式
对于所有ML任务,首先我们需要根据题目的要求训练一个基础模型。如果你已经有丰富的模型训练经验了,那么你可以直接跳过这个部分,进入后续的工程化部署部分。
当然,如果你只想研究工程化部署的部分,大部分题目也会为你提供一个基准解决方案(或者大部分人更习惯称之为 Baseline )。基准解决方案是由出题者设计的一个参考实现,通常并不会做很复杂的优化和调试,但它可以帮助你快速上手,了解题目的要求和数据的特点。
这个教程当中我们则会演示如何从零开始完成题目。首先点击题目页面中的下载数据按钮,我们将会获得题目提供的训练数据。
题目数据总是满足特定的结构:
├── train
│ ├── input
│ │ ├── 0001.npy
│ │ ├── 0002.npy
│ │ └── ...
│ └── output
│ ├── 0001.npy
│ ├── 0002.npy
│ └── ...
其中 train/input 目录下的文件是训练数据的输入部分,而 train/output 目录下的文件则是对应的标签或者目标值。每个输入文件和输出文件都是一一对应的,例如 0001.npy 的输入数据对应 0001.npy 的输出标签。
1.2 训练模型
我们接着训练一个简单的模型来完成这个任务。以下是一个基于 PyTorch 的示例代码(假设你已经安装了对应的运行库和环境):
import os
import tqdm
import torch
import numpy as np
class NpyDataset(torch.utils.data.Dataset):
def __init__(self, input_dir, output_dir):
self.input_dir = input_dir
self.output_dir = output_dir
self.file_names = sorted(os.listdir(input_dir))
def __len__(self):
return len(self.file_names)
def __getitem__(self, idx):
file_name = self.file_names[idx]
input_data = np.load(os.path.join(self.input_dir, file_name)).astype(np.float32)
output_data = np.load(os.path.join(self.output_dir, file_name)).astype(np.float32)
input_tensor = torch.from_numpy(input_data).unsqueeze(0)
output_tensor = torch.from_numpy(output_data)
return input_tensor, output_tensor
class CNN(torch.nn.Module):
def __init__(self):
super(CNN, self).__init__()
self.conv_layers = torch.nn.Sequential(
torch.nn.Conv2d(1, 32, kernel_size=3, padding=1),
torch.nn.ReLU(),
torch.nn.MaxPool2d(2),
torch.nn.Conv2d(32, 64, kernel_size=3, padding=1),
torch.nn.ReLU(),
torch.nn.MaxPool2d(2)
)
self.fc_layers = torch.nn.Sequential(
torch.nn.Flatten(),
torch.nn.Linear(64 * 7 * 7, 128),
torch.nn.ReLU(),
torch.nn.Linear(128, 10)
)
def forward(self, x):
x = self.conv_layers(x)
x = self.fc_layers(x)
return x
# --- 1. 配置参数 ---
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
batch_size = 32
learning_rate = 0.001
epochs = 5
# --- 2. 准备数据 ---
dataset = NpyDataset(input_dir='train/input', output_dir='train/output')
train_loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True)
# --- 3. 实例化模型、损失函数和优化器 ---
model = CNN().to(device)
criterion = torch.nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
# --- 4. 训练循环 ---
for epoch in range(epochs):
model.train()
running_loss = 0.0
for inputs, labels in tqdm.tqdm(train_loader):
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
running_loss += loss.item()
print(f"Epoch [{epoch+1}/{epochs}], Loss: {running_loss/len(train_loader):.4f}")
print("训练完成!")
二、导出ONNX模型并进行推理
2.1 为什么要导出为ONNX模型?
在本地训练时,我们使用的是 PyTorch 框架。但在真实的工业端侧设备(比如手机芯片、自动驾驶计算卡)上,直接运行庞大的 PyTorch 环境通常是不现实的。
ONNX 是一个开放的通用格式,用于表示深度学习模型。它允许你在不同的框架之间共享模型,例如从 PyTorch 导出模型,然后在 TensorRT 或 OpenVINO 中进行推理。
ONNX模型的推理环境相对更加轻量级,不再需要安装几 GB 大小的 PyTorch,并且可以被进一步优化(如使用 TensorRT 加速)。
2.2 从 PyTorch 导出 ONNX 模型
我们将把第一部分我们训练好的模型导出为 ONNX 格式,以便在后续的部署阶段使用。以下是从 PyTorch 导出 ONNX 模型的示例代码:
import torch
# ... 之前的模型训练代码,假设 model 是你训练好的模型 ...
model = model.eval().to('cpu') # 导出模型前,确保模型在 CPU 上
dummy_input = torch.randn(1, 1, 28, 28) # onnx要求一个模拟输入,与模型输入的形状相匹配,这样它可以追踪模型的计算图。
torch.onnx.export(model, dummy_input, "model.onnx", export_params=True, opset_version=11) # 使用ONNX 算子集版本11
运行完上述代码后,你会在当前目录下看到一个名为 model.onnx 的文件,这就是我们导出的 ONNX 模型。接着,我们就可以使用这个模型在 inferena 平台上提交了!
2.3 在 inferena 平台上进行模型推理
在 inferena 平台上进行模型推理通常较为简单,我们提供了一个高度封装且自动化的接口 aidqnn,使用这个接口你可以轻松地加载各种模型并进行推理,而不需要关心底层的细节实现。
我们在 model.onnx 的同目录下创建一个 inference.py 文件,内容如下:
import os
import numpy as np
from aidqnn import AutoModel
INPUT_DIR = './data/input' # 平台的评测环境中,数据总是以这种结构提供的
OUTPUT_DIR = './data/output' # 你的脚本需要对于 input 中的每个文件进行推理,并将结果保存在 output 的同名文件中
model = AutoModel("model.onnx", device="cpu") # 加载我们之前导出的 ONNX 模型
input_files = [f for f in os.listdir(INPUT_DIR) if f.endswith('.npy')]
for filename in input_files:
input_path = os.path.join(INPUT_DIR, filename)
output_path = os.path.join(OUTPUT_DIR, filename)
input_data = np.load(input_path).astype(np.float32)
input_data = input_data[np.newaxis, np.newaxis, :, :] # 添加维度以匹配模型输入的形状
output = model(input_data) # 输出是一个 (1, 10) 的数组,表示每个类别的概率
result = np.zeros([10,], dtype=np.float32)
result[np.argmax(output)] = 1.0 # 将概率最高的类别设置为1,形成 one-hot 编码的输出
np.save(output_path, result)
接着,为了给评测系统一个执行入口,我们需要在同目录下创建一个 start.sh 文件,内容如下:
#!/bin/bash
python3 inference.py
做完上述准备工作后,我们就可以将 model.onnx、inference.py 和 start.sh 这三个文件打包成一个 zip 文件,并在 inferena 平台上提交了!
注意,提交的时候请确保只选中这三个文件而不是包含这三个文件的整个目录,否则评测系统将无法正确识别你的提交内容。
经过以上步骤,我们已经完成了第一个完整的解决方案开发流程!在我们的样例提交中,这个模型已经能达到几乎满分的精确度了——你可以在平台上查看评测结果,看看你的模型表现如何。
三、进一步优化和调试——GPU加速从入门到精通
3.1 G。-关于GPU的计算-
想必你一定听说过,一般训练和推理模型都是在 GPU 上进行的,因为 GPU 在处理大规模矩阵运算时具有显著的性能优势。
虽然我们在 MNIST 这个简单的任务中使用 CPU 就能得到不错的结果,但在更复杂的任务中,GPU 或者其他专用计算单元的运算能力是不可或缺的。使用专用计算单元不仅可以提供通常来说更快的推理速度,还可以通过更高效的计算资源利用来降低能耗,这对于端侧设备来说尤为重要。
在这一部分,我们将介绍在 inferena 平台上使用 GPU 加速模型推理的基本方法和注意事项。
不过在开始前,我想先向大家介绍一下,通常端侧设备上的 GPU 和我们在 PC 或者服务器上使用的 GPU 是不一样的。
端侧设备上的 GPU 通常集成在 SoC(系统级芯片)中。这些 GPU 在架构和性能上与我们在 PC 上使用的 NVIDIA 或 AMD GPU 有很大的不同——由于功耗限制,它们通常具有更低的计算能力和内存带宽。不过即便如此,在大部分情况下,使用端侧 GPU 进行推理比起 CPU 仍然会显著提升性能。
3.2 模型转换和优化
为了使用 GPU 加速模型推理,我们需要将其转换成适合 GPU 运行的格式。这里我们使用 AIMO 平台 提供的工具链来完成这个过程。
在注册并登录 AIMO 平台后,你可以在平台上找到一个叫做“模型优化”的功能模块。
在这个模块中,我们上传我们之前导出的 model.onnx 文件,选择目标设备为 Qualcomm 的 QCS6490(inferena 平台的默认评测设备),目标框架选择 Qualcomm QNN 2.31。
这里你也可以选择其他目标框架,比如 TFLite 等框架也支持 GPU 计算,但是需要注意的是,不同的目标框架可能会对模型的结构和算子支持有不同的要求,因此在选择之前请务必确认你的模型是否满足这些要求。我们一般推荐尽可能使用 QNN,因为它的兼容性通常是最好的。
接着,平台会自动分析我们的模型,并且把输入和输出信息展示出来。你可以在这里确认模型的输入输出是否正确识别了。
对于 GPU 计算,我们并不需要进行量化操作。因此,在确认模型输入输出信息无误后,我们直接点击“提交”按钮,并且等待平台完成模型转换的过程。
对于 AIMO 更多的使用细节和注意事项,可以在他们的官方文档中找到。
3.3 使用GPU模型进行推理
转换完成后,我们就可以在平台上下载转换好的模型文件了。
你可以在下载下来的压缩包中找到看起来像是模型的文件,我们的平台需要后缀中含有 aarch64.gcc9_4.so 的那一个。对于我们的样例模型来说,文件名是:libmodel_qcs6490_fp32.qnn231.aarch64.gcc9_4.so.amf。
接下来,我们需要把这个文件替换掉我们之前提交的 model.onnx 文件,并且修改一下 inference.py 中加载模型的代码:
import os
import numpy as np
from aidqnn import AutoModel
INPUT_DIR = './data/input'
OUTPUT_DIR = './data/output'
model = AutoModel("libmodel_qcs6490_fp32.qnn231.aarch64.gcc9_4.so.amf", device="gpu") # 唯一修改
input_files = [f for f in os.listdir(INPUT_DIR) if f.endswith('.npy')]
for filename in input_files:
input_path = os.path.join(INPUT_DIR, filename)
output_path = os.path.join(OUTPUT_DIR, filename)
input_data = np.load(input_path).astype(np.float32)
input_data = input_data[np.newaxis, np.newaxis, :, :]
output = model(input_data)
result = np.zeros([10,], dtype=np.float32)
result[np.argmax(output)] = 1.0
np.save(output_path, result)
完成上述修改后,我们再次将 inference.py 和 start.sh 以及新的模型文件打包成一个 zip 文件,并且在 inferena 平台上提交。
可以看到,我们的模型的准确度和 CPU 版本完全一样,但是推理速度却有一部分提升。观察评测详情页面,我们也可以看到 GPU 的使用情况,确认我们的模型确实在使用 GPU 进行推理。
对于 MNIST 这种简单的任务来说,GPU 的性能优势提升并不明显,因为绝大部分时间都花在了数据加载和预处理开销上了。不过在计算密集型的任务中,GPU 的性能优势会更加显著。
四、端侧计算的尽头:NPU上的极致优化
4.1 什么是NPU?
在我们评测所用的端侧计算设备上,NPU 是被专门设计来处理 AI 任务的计算单元。这一点从它的名字就可以看出来了:Neural Processing Unit,神经处理单元。
通常来讲,NPU 在处理相同的推理任务时,功耗和用时通常比起其他计算单元都有恐怖的优势,这让它在边缘计算的场景下成为了一个理想的选择。
NPU 之所以能够达到如此高的能耗比和计算速度,是因为它设计上主要是为量化运算专门优化的。通过将模型中的权重和激活值从高精度的 float32 映射到低精度的 int8 甚至 int4,NPU 避免了复杂的浮点运算,从而大幅提升了计算效率。量化模型也有减少模型体积的好处,这对于磁盘和运存受限的端侧设备来说也是非常重要的。
当然,这并不是免费的午餐:量化模型通常会带来一定程度的精度损失,因为由指数表示的浮点数被映射到了线性的整数空间中。不过通过一些量化感知训练(QAT)或者后训练量化(PTQ)的技术,我们通常可以将这种精度损失控制在一个非常小的范围内,甚至在某些情况下完全不影响模型的性能。
这一部分,我们将介绍如何在 AIMO 平台上对模型进行量化,并且使用量化后的模型在评测设备的 NPU 上进行推理。
4.2 准备校准数据
在进行模型量化之前,我们需要准备一部分校准数据(calibration data)。这些数据将被用来分析模型中各个层的激活值分布,从而确定量化参数(如缩放因子和零点)。
校准数据应该具有代表性,符合模型在实际推理过程中可能遇到的输入数据分布。因此,通常来说,我们直接使用训练数据的一部分输入标签就可以了。
通过以下代码,我们可以从训练数据中抽取一部分作为校准数据,并且保存到一个新的目录中:
import os
import numpy as np
input_dir = 'train/input'
calibration_dir = 'calib_data'
os.makedirs(calibration_dir, exist_ok=True)
input_files = sorted(os.listdir(input_dir))
num_calib_samples = 100 # 选择100个样本进行校准
for i in range(num_calib_samples):
file_name = input_files[i]
input_data = np.load(os.path.join(input_dir, file_name)).astype(np.float32)
input_data = input_data[np.newaxis, np.newaxis, :, :] # 添加维度以匹配模型输入的形状
input_data.tofile(os.path.join(calibration_dir, file_name + ".raw")) # 保存为原始二进制格式
运行完上述代码后,你会在当前目录下看到一个名为 calib_data 的文件夹,里面包含了我们准备好的校准数据。
4.3 在 AIMO 平台上进行模型量化
在 AIMO 平台上进行模型量化的流程和我们之前进行模型转换的流程非常类似。我们同样需要上传我们之前导出的 model.onnx 文件,并且选择 Qualcomm QNN 2.31 的选项。
接着,在参数设置页面中,我们需要将开启量化选项。对于选项设置,我们可以直接使用平台提供的默认设置(即 int8 精度和 CLE 均衡)。
现在,我们选中校准辅助数据中的raw模式,将上一步准备好的校准数据上传到平台上。我们可以一键上传整个 calib_data 目录。
接着,在确认所有参数设置无误后,我们就可以点击提交按钮了。
4.4 使用量化模型进行推理
转换完成后,我们同样会得到一个新的模型文件,而接下来的步骤与之前的流程即为相似:
我们找到后缀中带有 ctx.bin 的文件并且替换掉之前提交的模型文件,在我们的例子中,文件名是 model_qcs6490_w8a8.qnn231.ctx.bin.amf。
接着,修改 inference.py 中加载模型的代码:
import os
import numpy as np
from aidqnn import AutoModel
INPUT_DIR = './data/input'
OUTPUT_DIR = './data/output'
model = AutoModel("model_qcs6490_w8a8.qnn231.ctx.bin.amf", device="npu") # 修改为npu
# 只有模型才能指定device为npu,否则出现错误
input_files = [f for f in os.listdir(INPUT_DIR) if f.endswith('.npy')]
for filename in input_files:
input_path = os.path.join(INPUT_DIR, filename)
output_path = os.path.join(OUTPUT_DIR, filename)
input_data = np.load(input_path).astype(np.float32) # 输入数据仍然使用float32类型,QNN会在内部自动进行量化处理
input_data = input_data[np.newaxis, np.newaxis, :, :]
output = model(input_data) # 输出也是fp32类型
result = np.zeros([10,], dtype=np.float32)
result[np.argmax(output)] = 1.0
np.save(output_path, result)
我们可以再次将 inference.py 和 start.sh 以及新的模型文件打包成一个 zip 文件,并且在 inferena 平台上提交。
可以观察到,我们的模型大小和占用的内存都降低了不少,并且推理速度也有了显著的提升。于此同时,在数据校准之后,模型的精度损失几乎可以忽略不计。
通常,在计算密集型的任务中,使用 NPU 推理的优势会更加明显。