pnnx基础学习

本文最后更新于:8 个月前

什么是模型部署

本部分参考OpenMMLab在知乎的博文进行编写

模型部署需要明确部署场景,部署方式(中心服务化还是本地终端部署),模型优化指标,如何提高吞吐和减少延迟

部署场景

  • 中心服务器云端部署
    • 用户通过API调用接口或网页访问
  • 边缘部署
    • 用于嵌入式设备,要将模型打包封装到SDK,集成到嵌入式设备,数据处理和模型推理要在终端设备上执行(功耗,别耗电;要小,不占地儿)

部署方式

SDK部署 Service部署
部署环境 SDK引擎 训练框架
模型语义转换 需要进行前后处理和模型算子重实现 一般框架内部负责语义转换
前后处理对齐算子 训练和部署对应两套实现,需要进行算子数值对齐 共用算子
计算优化 挖掘芯片编译器的深度优化能力 利用引擎已有的训练优化能力

部署核心优化指标

合理把控:成本、功耗、性价比

  • 成本
    • 芯片选型
    • 算力需求挑芯片(如加个DSP增加定点算力)
  • 功耗
    • 电池电池电池!!要用专用的优化的加速器单元如NPU以节省功耗
  • 性价比
    • 云端追求多路的吞吐量优化需求
    • 终端追求单路延迟需要;针对CV和NLP问题对精度的要求不一样,因此有芯片选型的问题:CV INT4/INT8;NLP FP16

部署流程

这里用的是商汤的SenseParrots来讲解部署流程,且以SDK部署为例

模型转换

转换模型以适配不同框架。主流以ONNX或Caffe为模型交换格式。

主要分为:

  • 计算图生成
  • 计算图优化(optional,如算子融合)
  • 计算图转换
计算图生成

通过一次推理来记录,将模型翻译成静态的表达(转成静态图)。在模型推理时,框架会记录算子的详细信息,也包括入参出参,以及所属层次

计算图优化

去除冗余op,算子融合

计算图转换

分析静态计算图算子,转换到目标格式。支持多后端(啥意思?)

模型量化压缩

边缘部署要求模型小吞吐率高 -> 蒸馏/剪枝/量化

量化:FP32压缩到INT8/INT4乃至INT1(?),比如压到INT8->上面说了终端设备芯片选型会弄个定点计算单元,可以低比特指令实现低精度算子

量化技术栈:

  • 量化训练(Quantization Aware Training):插入伪量化算子(模拟低精度运算),通过梯度下降来做微调,以使得得到精度符合预期的模型
  • 离线量化(Post Training Quantization): 少量校准数据集(原始数据集中挑),获得网络的激活分布,用统计手段或优化浮点定点输出分布来获得量化参数

平衡吞吐和精度是一个问题;结合推理引擎挖掘芯片的能力;

模型打包封装SDK

要做前后处理,相当于是流水线中的一个流水段,可能是许多模型的一部分

保护模型还要加密(没想过还有这个,不过也合理)

PNNX

PNNX: PyTorch Neural Network eXchange

模型部署新方式

介绍补充

pnnx2ncnn运行ResNet-50快速上手(vulkan)

开始前请先确定自己的ncnn已编译安装完成

搭建项目结构

1
2
3
4
5
6
7
8
9
10
11
.
|-- CMakeLists.txt
|-- bin
|-- build
|-- images
| `-- cat.jpg
|-- src
| `-- infer.cpp
|-- utils
| `-- resnet50.py
`-- weight_param

项目结构如上所示,其中:

  • CMakeLists.txt: 用于构建项目
  • bin: 用于存储最终编译得到的可执行文件
  • build: 用于项目构建,以便清理
  • images: 存放用于进行推理效果检测的图片,图片大小需要为224x224,这里准备了一张小猫的图片
  • src: 用于存放源文件
  • utils: 用于存放python文件
  • weight_param: 用于存放python文件导出的TorchScript文件和pnnx进行转换后的权重和计算图参数文件

获取pnnx

1
pip install pnnx

注: 此方法方便快捷,当然也可以wget对应的源文件进行编译

PyTorch ResNet50转换为TorchScipt

1
2
3
4
5
6
7
8
9
10
11
12
import torch
import torchvision.models as models

# 加载预训练好的ResNet-50
resnet = models.resnet50(pretrained=True)
resnet.eval()

# 输入参数为[1,3,224,224]
ipt = torch.randn(1, 3, 224, 224)

jit_model = torch.jit.trace(resnet, ipt)
jit_model.save('../weight_param/resnet50.pth')

在utils下运行python resnet50.py后,在weight_param文件夹下得到resnet50.pth

pnnx转TorchScipt为ncnn

在weight_param下运行pnnx resnet50.pth [1,3,224,224]

注: 在命令行中通过输入pnnx后运行,可以看到一些Sample Usage,里面有举例说明用法

运行指令后可以在当前文件夹下得到多个.bin,.param文件

.bin文件内部存储的是模型的权重;

.param文件存储的是计算图的详细信息

编写推理文件

在开始编写推理文件前,需要看一下pnnx转ncnn中的resnet50.ncnn.param,我们需要从该文件内获取输入输出对应的blob:

resnet50.ncnn.param

推理文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
#include "net.h"
#include <algorithm>
#if defined(USE_NCNN_SIMPLEOCV)
#include "simpleocv.h"
#else
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#endif
#include <stdio.h>
#include <vector>


static int detect_resnet50(const cv::Mat& bgr, std::vector<float>& cls_scores)
{
ncnn::Net resnet50;

resnet50.opt.use_vulkan_compute = true;

// 加载模型的计算图参数和权重
if (resnet50.load_param("weight_param/resnet50.ncnn.param"))
exit(-1);
if (resnet50.load_model("weight_param/resnet50.ncnn.bin"))
exit(-1);
// 图片格式转换 BGR->RGB
ncnn::Mat in = ncnn::Mat::from_pixels_resize(bgr.data, ncnn::Mat::PIXEL_BGR2RGB, bgr.cols, bgr.rows, 224, 224);

// 图像归一化,这里做的是:(x/255 - 0.485) / 0.229 ,上下同时乘255可得
// (x - 0.485*255) / 0.229*255 ,而在substract_mean_normalize中的标准差是倒数
// 因此norm_vals的标准差取了倒数
const float mean_vals[3] = {0.485f*255.f, 0.456f*255.f, 0.406f*255.f};
const float norm_vals[3] = {1/0.229f/255.f, 1/0.224f/255.f, 1/0.225f/255.f};
in.substract_mean_normalize(mean_vals, norm_vals);

ncnn::Extractor ex = resnet50.create_extractor();

ex.input("in0", in);

ncnn::Mat out;
ex.extract("out0", out);

cls_scores.resize(out.w);
for (int j = 0; j < out.w; j++)
{
cls_scores[j] = out[j];
}

return 0;
}

static int print_topk(const std::vector<float>& cls_scores, int topk)
{
// 前topk的局部排序
int size = cls_scores.size();
std::vector<std::pair<float, int> > vec;
vec.resize(size);
for (int i = 0; i < size; i++)
{
vec[i] = std::make_pair(cls_scores[i], i);
}

std::partial_sort(vec.begin(), vec.begin() + topk, vec.end(),
std::greater<std::pair<float, int> >());

// 打印topk及对应的分数
for (int i = 0; i < topk; i++)
{
float score = vec[i].first;
int index = vec[i].second;
fprintf(stderr, "%d = %f\n", index, score);
}

return 0;
}

int main(int argc, char** argv)
{
if (argc != 2)
{
fprintf(stderr, "Usage: %s [imagepath]\n", argv[0]);
return -1;
}

const char* imagepath = argv[1];

cv::Mat m = cv::imread(imagepath, 1);
if (m.empty())
{
fprintf(stderr, "cv::imread %s failed\n", imagepath);
return -1;
}

std::vector<float> cls_scores;
detect_resnet50(m, cls_scores);

// 打印得分前三的类别
print_topk(cls_scores, 3);

return 0;
}

编写完推理文件后,进行CMakeLists.txt的编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
project(NCNN_DEMO)
cmake_minimum_required(VERSION 2.8.12)
set(CMAKE_BUILD_TYPE Debug)


set(CMAKE_PREFIX_PATH ${CMAKE_PREFIX_PATH} "/path/to/ncnn/build/install")
find_package(ncnn REQUIRED)
find_package(OpenCV REQUIRED)


set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/bin)

add_executable(resnet50 src/infer.cpp)
target_link_libraries(resnet50 ncnn ${OpenCV_LIBS})

注: 上面的CMAKE_PREFIX_PATH加上的搜索路径是你编译安装好ncnn后,build文件夹内的install文件夹

构建及测试

进入build文件夹下,执行cmake ..构建项目,而后执行make以生成可执行文件

cd ..退到项目根目录下

运行./bin/resnet50 ./images/cat.jpg以查看推理结果:

pnnx2ncnn_infer

这里输出的283,154一类的数字是训练用的标签,因为采用的ResNet-50是在ImageNet数据集上进行预训练的,其中上述标签对应具体类别为:

1
2
3
154: 'Pekinese, Pekingese, Peke', // 哈巴狗
283: 'Persian cat', // 波斯猫
284: 'Siamese cat, Siamese', // 暹罗猫

可以看出来,它确实正确预测了是只猫(虽然具体的种类错了)

参考文件:


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!