你或许也想拥有专属于自己的AI模型文件格式(推理部署篇)-(7)

发布时间:2022-12-14 12:00

        距离上次的文章,已经有一个月之久了。要是再不继续推进,那么我17个粉丝又要催更了(纯属本人瞎说,实际情况是没人催更)。

        今天就少扯皮了,直接开淦吧!

上次的文章中,说明了如何在C++代码中解析我们的专AI模模型文件格式,大概的思路无非和构建模型的时候是反着进行的。因为这份模型文件格式是完全由flatbuffers进行解析的,因此,解析的过程是一场清晰明了的。

        而之所以解析模型文件,主要是这样我们就能够实现专AI模的跨平台传送,构建好的模型能够无差异地在各个设备上使用,这正是flatbuffers作为数据交换协议的功劳。另外一个比较重要的原因是我们只有解析模型后,知道了所有的网络层、有向图中数据流的走向,才能够进行运行时(就是实际部署后的模型代码构成)推理接口的搭建。

        既然模型解析的部分已经完成了,那么我们可以利用数据后的变量数据做什么操作呢?又或者怎样把模型部署到相应的设备上呢?这就是我们《推理部署篇》的核心部分了。

        推理部署概念:把深度学习训练好的模型通过各种途径部署到相应设备,以实现模型的实际应用,同时期望部署后能够达到最快的速度和完全最好的精度效果。如果没有接触过深度学习模型部署,可能一时间会觉得难以理解,但是举个栗子来说:非传统的人脸解锁、非传统的指纹解锁.....这些就是应用最为广泛的推理部署例子。

一、推理部署的思想

        如果之前了解过推理部署框架,对于诸如TVM、NCNN、TNN、MNN等等或许会有所了解。这些框架大多都遵循以下所示的系统框架构建模式,这也是一般的推理框架的主要思路。

你或许也想拥有专属于自己的AI模型文件格式(推理部署篇)-(7)_第1张图片

         《推理部署篇》这个系列的文章的主要目的就是实现以上的三个方面的模块。上文已经把前端解析做完了,该文主要是作运行时网络构建、推理时流程构建。

二、运行时网络构建

        目的:从前端解析得到的模型的所有信息,我们需要利用这些信息来生成每个网络层的运行时核心、以及优化这些核心,最后持久化这些优化好的核心算子。以此来供后续的推理时能够反复无开销地调用对应的核心算子,以此达到加速的目的。

    // 通过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模的来开发的推理引擎的大体框架就完成了。后续的任务就是算子的具体优化和开发,也就是所谓的算子开发了。

ItVuer - 免责声明 - 关于我们 - 联系我们

本网站信息来源于互联网,如有侵权请联系:561261067@qq.com

桂ICP备16001015号