本文最后更新于:8 个月前
什么是模型部署
本部分参考OpenMMLab在知乎的博文进行编写
模型部署需要明确部署场景,部署方式(中心服务化还是本地终端部署),模型优化指标,如何提高吞吐和减少延迟
部署场景
- 中心服务器云端部署
- 边缘部署
- 用于嵌入式设备,要将模型打包封装到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
注: 此方法方便快捷,当然也可以wget
对应的源文件进行编译
PyTorch ResNet50转换为TorchScipt
1 2 3 4 5 6 7 8 9 10 11 12
| import torch import torchvision.models as models
resnet = models.resnet50(pretrained=True) resnet.eval()
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:

推理文件:
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); ncnn::Mat in = ncnn::Mat::from_pixels_resize(bgr.data, ncnn::Mat::PIXEL_BGR2RGB, bgr.cols, bgr.rows, 224, 224); 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) { 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> >());
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
以查看推理结果:
这里输出的283,154一类的数字是训练用的标签,因为采用的ResNet-50是在ImageNet数据集上进行预训练的,其中上述标签对应具体类别为:
1 2 3
| 154: 'Pekinese, Pekingese, Peke', // 哈巴狗 283: 'Persian cat', // 波斯猫 284: 'Siamese cat, Siamese', // 暹罗猫
|
可以看出来,它确实正确预测了是只猫(虽然具体的种类错了)
参考文件: