距离上次的文章,已经有一个月之久了。要是再不继续推进,那么我17个粉丝又要催更了(纯属本人瞎说,实际情况是没人催更)。
今天就少扯皮了,直接开淦吧!
上次的文章中,说明了如何在C++代码中解析我们的专AI模模型文件格式,大概的思路无非和构建模型的时候是反着进行的。因为这份模型文件格式是完全由flatbuffers进行解析的,因此,解析的过程是一场清晰明了的。
而之所以解析模型文件,主要是这样我们就能够实现专AI模的跨平台传送,构建好的模型能够无差异地在各个设备上使用,这正是flatbuffers作为数据交换协议的功劳。另外一个比较重要的原因是我们只有解析模型后,知道了所有的网络层、有向图中数据流的走向,才能够进行运行时(就是实际部署后的模型代码构成)推理接口的搭建。
既然模型解析的部分已经完成了,那么我们可以利用数据后的变量数据做什么操作呢?又或者怎样把模型部署到相应的设备上呢?这就是我们《推理部署篇》的核心部分了。
推理部署概念:把深度学习训练好的模型通过各种途径部署到相应设备,以实现模型的实际应用,同时期望部署后能够达到最快的速度和完全最好的精度效果。如果没有接触过深度学习模型部署,可能一时间会觉得难以理解,但是举个栗子来说:非传统的人脸解锁、非传统的指纹解锁.....这些就是应用最为广泛的推理部署例子。
如果之前了解过推理部署框架,对于诸如TVM、NCNN、TNN、MNN等等或许会有所了解。这些框架大多都遵循以下所示的系统框架构建模式,这也是一般的推理框架的主要思路。
《推理部署篇》这个系列的文章的主要目的就是实现以上的三个方面的模块。上文已经把前端解析做完了,该文主要是作运行时网络构建、推理时流程构建。
目的:从前端解析得到的模型的所有信息,我们需要利用这些信息来生成每个网络层的运行时核心、以及优化这些核心,最后持久化这些优化好的核心算子。以此来供后续的推理时能够反复无开销地调用对应的核心算子,以此达到加速的目的。
// 通过PzkM来创建一个OCL后端运行时
bool CreateNetWork(PzkM model){
// 1.首先构建所有的Tensor
for(size_t i = 0; i < model.rTensors.size(); i++){
if(CreateClMem(&model.rTensors[i]) == false){
printf("create clmem faided in id = %d\n", model.rTensors[i].id);
return false;
}
}
// 2.设置Tensor作为输入
std::vector inputs;
for(size_t i = 0; i < model.model_runtime_input_id.size(); i++){
for(size_t j = 0; j < model.rTensors.size(); j++){
if (model.rTensors[j].id == model.model_runtime_input_id[i]){
inputs.push_back(&(model.rTensors[j]));
}
}
}
SetAsInputs(inputs);
for(size_t i = 0; i < inputs.size(); i++){
input_tensors[inputs[i]->id] = *inputs[i];
}
// 3.进行网络层的运行时构建
if(BuildLayers(model) == false){
printf("failed build runtime layers\n");
}
// 4.设置Tensor作为输出
std::vector outputs;
for(size_t i = 0; i < model.model_runtime_output_id.size(); i++){
for(size_t j = 0; j < model.rTensors.size(); j++){
if (model.rTensors[j].id == model.model_runtime_output_id[i]){
outputs.push_back(&(model.rTensors[j]));
}
}
}
SetAsOutputs(outputs);
for(size_t i = 0; i < outputs.size(); i++){
output_tensors[outputs[i]->id] = *outputs[i];
}
return true;
}
上述的代码通过注释的方式来说了如何把一个PzkM(这是前端解析好的包含了模型信息的类实例)构建其运行时网络。而其中最为重要的是3.进行网络层的运行时构建,下面的代码展示了BuildLayers()函数的主要代码(目前只写了两种类型的网络层的核心代码,分别是img2col和conv2d):
// 构建运行时的网络层
bool BuildLayers(PzkM model){
for (size_t i = 0; i < model.rLayers.size(); i++){
// 进行各种不同类型的选择
layer_maker onelayer = model.rLayers[i];
if (onelayer.type == "img2col"){
add_img2col_layer(onelayer);
}else if (onelayer.type == "conv2d"){
add_conv2d_layer(onelayer);
}
else{
printf("unknown type = %s layer, cant't finish it\n", onelayer.type.c_str());
return false;
}
}
return true;
}
上述的BuildLayers中,我们可以知道网络层的主要思想就是:在排布好网络层的顺序之后,按照不同的网络层实行不同的构建函数,依次构建出整个网络。
目的:运行时构建的网络层之间,在执行其核心函数的时候,我们需要处理好之间的依赖;另外,我们需要做好同步异步的接口封装。
原因:很多的推理部署平台都是跟硬件强相关的,特别是那种速度超快的,其核心更是极大一部分由更底层的编程语言编写的;更一般的平台,更是异构的(多处理器、异步运行)。我们需要对并行编程和同步机制有非常深入的了解,才能够做好这一步的框架搭建。
特别的,我们这次使用到了OpenCL并行编程语言来作为加速手段(PS:其实在上述的算子核心编写的时候就是用OpenCL来构建的,特别是采用了OpenCL的核心运行时编译优化手段)。在推理时借助OpenCL主要完成了核心命令队列的数据依赖、同步异步的接口实现。
// 进行推理的接口
bool Inference(){
// 首先input:cpu->device
for(size_t i = 0; i < input_tensors.size(); i++){
if(WriteCLMem(&input_tensors[i], cpu_mem[input_tensors[i].id]) == false){
printf("write CLmem in inference, which id = %d\n", input_tensors[i].id);
return false;
}
}
// 然后进行推理
for(size_t i = 0; i < AllLayers.size(); i++){
AllLayers[i]->run();
}
// 最后ouput:device->cpu
for(size_t i = 0; i < output_tensors.size(); i++){
if(ReadCLMem(&output_tensors[i], cpu_mem[output_tensors[i].id]) == false){
printf("read CLmem in inference, which id = %d\n", output_tensors[i].id);
return false;
}
}
return true;
}
上述的Inference接口说明了推理的一般流程:把数据从cpu内存送入到异构设备;往命令推理队列中不断地下发推理指令;最后把推理结果从异构设备传到cpu内存上。
那么我们如何实现了数据依赖、以及同步异步的接口呢?答案在OpenCL的命令队列中:
// opencl推理引擎的命令空间
// 想要通过效仿acl-opencl的推理流程来构建自己的推理引擎
/*
1、希望opencl平台的设备管理等方面由命令空间管理
2、提供了对CLmem的操作手段,包括创建、读写等
3、提供了CLKernel排对进入命令队列的操作、以及对CLKernel的管理
4、命令队列查询、以及等待操作等
*/
namespace OCLEngine {
/*---------------------------------为了能够进行事件同步而设置前向链表结构--------------------------*/
struct wait_event{
cl_uint num = 0;
cl_event* event = NULL;
wait_event* next = NULL;
};
// 事件同步产生的变量
wait_event* wehead = NULL; // 链表头
wait_event* wenow = NULL; // 目前的链表
wait_event* weend = NULL; // 链表尾
std::unordered_map event_of_tensor;
// 添加节点
void add_wait_event_node(cl_uint event_num){
if(wehead == NULL){
wehead = (wait_event*)malloc(sizeof(wait_event));
wehead->num = event_num;
wehead->event = (cl_event*)malloc(sizeof(cl_event) * wehead->num);
weend = wehead;
}
else{
weend->next = (wait_event*)malloc(sizeof(wait_event));
weend = weend->next;
weend->num = event_num;
weend->event = (cl_event*)malloc(sizeof(cl_event) * wehead->num);
}
}
// 清除事件同步链表
void CleanEvent(){
wait_event* ptr = wehead;
while(ptr != NULL){
struct wait_event* ptr1 = ptr->next;
if(ptr->event != NULL && ptr->num > 0){
for(size_t i = 0; i < ptr->num; i++){
clReleaseEvent(*(ptr->event + i));
}
free(ptr->event);
}
free(ptr);
ptr = ptr1;
}
return;
}
// 只是单纯释放
void ReleaseEvent(){
struct wait_event* ptr = wehead;
while(ptr != NULL){
struct wait_event* ptr1 = ptr->next;
if(ptr->event != NULL && ptr->num > 0){
for(size_t i = 0; i < ptr->num; i++){
clReleaseEvent(*(ptr->event + i));
*(ptr->event + i) = NULL;
}
}
ptr = ptr1;
}
return;
}
/*---------------------------------------------opencl基本变量------------------------------------*/
// opencl平台持久化变量
cl_context context = 0;
cl_command_queue commandQueue = 0;
// cl_program program = 0;
cl_device_id device = 0;
// cl_kernel kernel = 0;
std::unordered_map clmem;
cl_int errNum;
/*后续代码没有放出来*/
}
没错,这里的思想就是把每个网络层绑定在一个事件上,该网络层运行成功后会标记该事件为成功;而该网络层的核心计算的开始,需要上一层的事件标记为成功后才能够开始运算。这样就能够形成一条前后顺序的运算链。
如此,我们针对那个专AI模的来开发的推理引擎的大体框架就完成了。后续的任务就是算子的具体优化和开发,也就是所谓的算子开发了。