7.4.tensorRT高级(2)-使用RAII接口模式对代码进行有效封装

目录

    • 前言
    • 1. RAII接口模式
    • 2. 问答环节
    • 总结

前言

杜老师推出的 tensorRT从零起步高性能部署 课程,之前有看过一遍,但是没有做笔记,很多东西也忘了。这次重新撸一遍,顺便记记笔记。

本次课程学习 tensorRT 高级-使用 RAII 接口模式对代码进行有效封装

课程大纲可看下面的思维导图

1. RAII接口模式

这节我们学习多线程知识 —— RAII + 接口模式对生产者消费者模型进行封装,以及多 batch 的体现

那这边为什么提到多 batch 呢?这是因为 GPU 拥有计算密集型的特性,任务越密集越友好,假设 1 张图推理 需要 5ms,那你给它 2 张图,它的耗时很可能只有 6 ms,如果给 4 张图,它耗时可能 8 ms,也就是说它的耗时并不是成比例的,这就是我们说的多 batch 的体现

因此我们在对生产者消费者模式进行封装时就要考虑到 GPU 的特性,利用到它的特性,实现我们的高性能,高性能就是我们说的多 batch 的体现

接下来我们来看代码,我们先来写一个典型的程序,代码如下:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

class Infer{
public:
    bool load_model(const string& file){
        context_ = file;
        return true;
    }

    void forward(){
        printf("使用 %s 进行推理\n", context_.c_str());
    }

    void destroy(){
        context_.clear();
    }

private:
    string context_;
};

int main(){

    return 0;
}

上述示例代码构建了一个 Infer 类,该类中有 load_model 和 forward 两个成员函数,模拟深度学习中的模型加载和推理过程,其中私有变量 context_ 可以认为是加载的模型权重文件,需要注意的是,实际工作中 context_ 是一个非常复杂的对象,包括有 vector、GPU 内存、CPU 内存等很多很多稀奇古怪的东西,你要进行释放的话还有硬件资源,包括你要加载的话初始化的东西也非常多,所以咱这边只是用 string 来表示一下,

我们需要通过对上述示例代码进行分析,然后慢慢的提出我们进行要学习的内容,即 RAII 接口模式

那首先我们先来使用这个类,具体代码如下:

int main(){
    
	Infer infer;
	infer.forward();	    
	return 0;
}

执行后你会发现,它直接会去拿一个空的 context_ 去进行推理,如果我的 context_ 是一个指针的情况下,你这个推理就直接崩溃了,所以这个时候你需要在 forward 函数中加上一个 context_ 是否为空的判断,如下所示:

void forward(){

    if(context_.empty()){
        // 说明模型没有加载上
        // 咱们对异常处理情况的定义很恼火
        printf("模型没有加载.\n");
        return;
    }

    printf("使用 %s 进行推理\n", context_.c_str());
}

那你可能会思考,如果 context_ 为空说明模型没有加载上,我们该如何处理呢?直接返回 false 还是干啥?头大,我们在写代码时往往对于异常处理情况的定义很恼火,我们需要在 forward 函数中同时处理异常逻辑和正常逻辑,那为什么会这样呢?其实是因为我们的代码很容易就触发这种情况,你整个 infer 类的定义就是允许我这样使用的呀,没有任何地方告诉我不允许这么使用

对于使用者来讲他是很难发现其中的一些逻辑关系,比如说必须调用 load_model 才去调用 forward。所以站在使用者角度他很容易直接去执行 forward 函数,造成程序崩溃,然后就说你的 SDK 写得就是一个垃圾

所以根本原因是开发者没有告诉他你不可以直接去执行 forward 函数,你不可以这样去调用。如果开发者要让程序能够正常调用 forward,那么势必要写上异常逻辑处理,一旦要写异常逻辑,你会发现你的脑袋瓜像一个二叉树一样,每一个分支都要去考虑下怎么去处理,又遇到什么异常情况,所以异常逻辑会占用我们大量时间

所以根据上述分析,我们可以知道:

正常工作代码,异常逻辑需要耗费大量时间。异常逻辑如果没有写好,或者没写,就会造成封装的不安全性,导致程序崩溃。并且会导致程序的使用复杂度变高,编写复杂度变高

这是我们从 forward 函数中探讨出来的问题

同样的这个问题也会出现在 load_model 这个函数上,如果你已经加载过模型了,是不是应该 destory,因此也需要在 load_model 函数假设异常逻辑判断,如下所示:

if(!context_.empty()){
    destroy();
}

为什么一定要加异常逻辑呢?这是因为我们站在使用者的角度来看,他可能会执行多次 load_model,多次加载模型。那你可能会像似乎不加这个异常逻辑也行,因为 context_ 是 string 类型,会被覆盖掉

那之前有提到,现实中的 context_ 往往非常复杂,它可能包含线程的等待或退出、资源的释放等操作,所以它不是一个简单的覆盖就能结束的事情,工作量还是很多的,所以我们要加个 destroy 函数,是不是又多了一个异常逻辑代码

我们简单举了两个函数的例子,你会发现你代码多了一堆异常逻辑的例子,你的正常逻辑可能只有一行,你的异常逻辑有一堆,而且你还要考虑不同场景下这些异常该怎么处理,这就使得我们的压力变大,程序稳定性变差,安全性变差

既然它存在这个问题,我们就要想办法去解决,这就引入了我们的概念 RAII + 接口模式

RAII → \rightarrow 资源获取即初始化

接口模式 → \rightarrow 设计模式,是一种封装模式,实现类与接口类分离的模式

在我们的示例中,Infer infer 表示资源的获取,而 infer.load_model() 则表示资源的初始化,所谓的 RAII 就是将这两步合并成一步,代如下:

shared_ptr<Infer> create_infer(const string& file){
    
    shared_ptr<Infer> instance(new Infer());
    if(!instance->load_model(file))
        instance.reset();
    return instance;
}

之前我们是通过 Infer 类来获取资源,现在我们是通过 create_infer 来获取资源,它的好处在于获取 Infer 实例即表示加载了模型,加载模型失败,则表示资源获取失败,加载模型成功,则表示资源获取成功,它们强绑定,这是 RAII 所做的事情

这个时候交给外部使用者的逻辑就非常简单了,如下所示:

int main(){

    auto infer =  create_infer("a");
    if(infer == nullptr){
        printf("failed.\n");
        return -1;
    }

    infer->forward();

    return 0;
}

RAII 给我们带来的优点有:

1. 我们避免了外部执行 load_model,永远只有在一个地方执行 load_model,不可能出现在其它地方(RAII 没有完全限制,只是做到一部分,待会我们利用接口模式干掉它)

2. 一个实例的 load_model 不会执行超过 1 次

3. 获取的模型一定初始化成功,因此 forward 函数,不必判断模型是否加载成功

因此 load_model 中可以删掉对于重复 load 的判断,forward 函数中也可以删掉对是否加载成功的判断,修改后的代码如下所示:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

class Infer{
public:
    bool load_model(const string& file){
        context_ = file;
        return true;
    }

    void forward(){

        printf("使用 %s 进行推理\n", context_.c_str());
    }

    void destroy(){
        context_.clear();
    }

private:
    string context_;
};

shared_ptr<Infer> create_infer(const string& file){
    
    shared_ptr<Infer> instance(new Infer());
    if(!instance->load_model(file))
        instance.reset();
    return instance;
}


int main(){

    auto infer =  create_infer("a");
    if(infer == nullptr){
        printf("failed.\n");
        return -1;
    }

    infer->forward();

    return 0;
}

可以发现确实简洁了很多,那你可能会问那我 Infer 类在外部不是一样可以调用 load_model 函数吗?

这就要引出我们的接口模式封装了,为了避免你外部调用这些多余的东西,我们需要使用接口模式把它们统统干掉

接口模式主要解决下面两个问题:

1. 解决 load_model 还能被外部看到的问题,拒绝外面调用 load_model

2. 解决成员变量对外可见的问题

  • 对于成员函数是特殊类型,比如 cudaStream_t,那么使用者就必须包含 cuda_runtime.h 头文件,否则会导致语法解析失败
  • 因此成员变量如果外部可见,会造成命名空间污染/头文件污染,有可能还会造成程序错误,异常,容易出现各种编译错误等等非预期的结果

我们来看具体该如何实现,首先我们需要定义一个接口类,它是一个纯虚类,原则是只暴露调用者需要的函数,其它一概不暴露,比如说 load_model,我们前面通过 RAII 做了定义,因此 load_model 属于不需要的范畴。内部如果有启动线程的函数如 start、stop 等等,也不需要暴露,而是初始化的时候就自动启动,都是 RAII 的定义。因此当前的接口类只对外暴露 forward 即可,代码实现如下:

class InferInterface{
public:
    virtual void forward() = 0;
};

我们再将之前的 Infer 类修改为 InferImpl 实现类,它继承自 InferInterface 公开类,另外之前的 RAII 部分也需要修改,代码如下所示:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

class InferInterface{
public:
    virtual void forward() = 0;
};

class InferImpl : public InferInterface{
public:
    bool load_model(const string& file){
        context_ = file;
        return true;
    }

    virtual void forward() override{
        printf("使用 %s 进行推理\n", context_.c_str());
    }

    // 在析构函数中进行清理操作
    ~InferImpl(){
        context_.clear();
    }

private:
    string context_;
};

shared_ptr<InferInterface> create_infer(const string& file){
    shared_ptr<InferImpl> instance(new InferImpl());
    if(!instance->load_model(file))
        instance.reset();
    return instance;
}

int main(){
    auto infer =  create_infer("a");
    if(infer == nullptr){
        printf("failed.\n");
        return -1;
    }

    infer->forward();

    return 0;
}

这么做对于使用者来讲非常友好,因为你只能看到 forward ,其它都看不到

这个时候我们就可以实际的把它拆分了写成头文件形式,如下所示:

infer.hpp

#ifndef INFER_HPP
#define INFER_HPP

#include 
#include 

class InferInterface{
public:
    virtual void forward() = 0;
};

std::shared_ptr<InferInterface> create_infer(const std::string& file);

#endif // INFER_HPP

infer.cpp

#include "infer.hpp"

using namespace std;

class InferImpl : public InferInterface{
public:
    bool load_model(const string& file){
        context_ = file;
        return true;
    }

    virtual void forward() override{
        printf("使用 %s 进行推理\n", context_.c_str());
    }

    // 在析构函数中进行清理操作
    ~InferImpl(){
        context_.clear();
    }

private:
    string context_;
};

shared_ptr<InferInterface> create_infer(const string& file){
    shared_ptr<InferImpl> instance(new InferImpl());
    if(!instance->load_model(file))
        instance.reset();
    return instance;
}

main.cpp

#include "infer.hpp"

int main(){
    
    auto infer = create_infer("a");
    if(infer == nullptr){
        printf("failed.\n");
        return -1;
    }

    infer->forward();
    return 0;
}

这就是我们建议的一个封装方式,这是我们通过 RAII + 接口模式封装的效果,通过这个效果我们来总结下几个原则:

1. 头文件,尽量只包含需要的部分

2. 外界不需要的,尽量不让它看到,保持定义的简洁

3. 不要在头文件中写 using namespace 这种,但是可以在 cpp 中写 using namespace ,对于命名空间,应当尽量少的展开

看到这个不由的想起 tensorRT_Pro,这种封装方式其实在 tensorRT_Pro 中有所体现,只是以前一直没有注意罢了

我们在 tensorRT_Pro 中如果想要用 YOLO 模型进行推理该怎么做呢?部分代码如下:

auto engine = Yolo::create_infer(...);
if(engine == nullptr){
    INFO("Engine is nullptr");
    return;
}

auto boxes = engine->commit(image);

...

上面的 create_infer 是不是一样,commit 可以看作 forward,你点开它的 yolo.hpp 和 yolo.cpp 可以看到 create_infer 以及 Infer 接口类的定义,如下所示:

class Infer{
    public:
    virtual shared_future<BoxArray> commit(const cv::Mat& image) = 0;
    virtual vector<shared_future<BoxArray>> commits(const vector<cv::Mat>& images) = 0;
};

shared_ptr<Infer> create_infer(
    const string& engine_file, Type type, int gpuid, 
    float confidence_threshold, float nms_threshold,
    NMSMethod nms_method, int max_objects,
    bool use_multi_preprocess_stream
){
    shared_ptr<InferImpl> instance(new InferImpl());
    if(!instance->startup(
        engine_file, type, gpuid, confidence_threshold, 
        nms_threshold, nms_method, max_objects, use_multi_preprocess_stream)
      ){
        instance.reset();
    }
    return instance;
}

是不是和我们的示例一模一样!!!,Infer 接口类只暴露 forward 接口,而 create_infer 体现 RAII 思想,果然再撸一遍还是没有错的,之前很多细节方面的东西似乎都遗漏了呀

2. 问答环节

博主对多线程相关的知识不怎么了解,因此疯狂询问 chatGPT,故此做个记录方便下次查看,以下内容来自于博主和 chatGPT 之间的对话

问题1:RAII是什么呢?能详细解释下吗?

回答:当然可以!

RAII 是 “Resource Acquisition Is Initialization” 的缩写,直译为 资源获取即初始化。它是 C++ 编程中的一种编程技巧,用于管理资源(如内存、文件句柄、网络套接字等)的生命周期。

RAII 的基本思想是将资源的生命周期与对象的生命周期绑定起来:当对象被创建时,资源被获取;当对象被销毁时,资源被释放。这种自动管理资源的方式可以避免资源泄漏,同时提高代码的简洁性和可读性。

RAII 主要有以下几个特点:

  1. 资源的自动管理:在对象的构造函数中获取资源,在析构函数中释放资源。这样,只要对象的生命周期结束,相关资源就会被自动释放。
  2. 异常安全:由于资源的释放是自动的,所以即使在异常的情况下,资源也能够被正确地释放。
  3. 避免资源泄漏:通过自动管理资源,可以避免因遗漏资源释放而导致的资源泄漏。

下面是一个简单的 RAII 示例,展示了如何使用 RAII 来管理动态分配的内存:

class RAIIArray {
private:
   int* data;

public:
   // 构造函数:分配内存
   RAIIArray(size_t size) {
       data = new int[size];
   }

   // 析构函数:释放内存
   ~RAIIArray() {
       delete[] data;
   }

   // 其他成员函数...
};

在这个示例中,当 RAIIArray 对象被创建时,其构造函数会自动分配内存;当对象被销毁时,其析构函数会自动释放内存。因此,使用者无需担心内存的释放问题,可以确保不会发生内存泄漏。

RAII 不仅仅用于管理内存,它也可以用于管理其他类型的资源,如文件、数据库连接等。

问题2:什么是接口模式呢?能详细解释下吗?

回答:接口模式,通常指的是设计模式中的接口编程,它强调的是编程到接口而不是实现。这使得代码更加模块化,易于测试和维护。

在面向对象编程中,接口是一种定义了一组方法的抽象类型,但没有实现这些方法。类可以实现一个或多个接口,并提供这些方法的具体实现。这种方法强调的是:

  • 分离定义和实现:您可以定义一个接口,而不需要知道谁或如何实现它。
  • 多态性:您可以使用接口作为一个类型,然后有多个不同的实现,这为代码提供了巨大的灵活性。
  • 可互换性:如果有多个类实现了同一个接口,您可以在不更改大部分代码的情况下,轻松地替换这些类的实例。

在C++中,虽然没有专门的“接口”关键字,但我们可以使用纯虚函数来模拟接口:

class IInfer {
public:
   virtual void load_model(const std::string& file) = 0;
   virtual void forward() = 0;
};

问题3:我不太懂,为什么 RAII 中 create_infer 函数要求返回的是 InferInterface 接口类的智能指针,而实际返回的是 InferImpl 实现类的智能指针?还能够这样操作吗?

问题:很好的问题!这正是面向对象编程中多态性的一个典型应用。

在 C++ 中,子类的指针可以隐式地转换为其父类的指针,这是由于继承的性质。当子类继承了一个基类(或接口),它隐含地表示子类 “是一个” 基类。因此,子类对象可以被视为其基类的对象。

在示例中:

  • InferImplInferInterface 的具体实现。这意味着 InferImpl “是一个” InferInterface
  • create_infer 函数的返回类型是 shared_pt0r,这意味着它返回一个指向 InferInterface 类型的智能指针。
  • 但在函数的实现中,您实际上返回了一个指向 InferImplshared_ptr。由于 InferImplInferInterface 的子类,因此这是允许的。

这样做的好处是:

1. 封装实现细节:调用者不需要知道具体的实现类是什么,他们只需要知道接口。这提供了一个清晰的分界线,使得实现的修改不会影响到使用接口的代码。

2. 灵活性:如果您决定更改实现,只需要更改 create_infer 函数内部的实现,而不会影响到使用此函数的代码。

3. 多态:调用者可以透明地使用不同的实现,只要它们都遵循相同的接口。

总之,这是 C++ 多态性的一个典型应用,允许您将子类的对象当作父类的对象来使用。这为代码提供了更大的灵活性和可维护性。

总结

这次课程我们学习了 RAII + 接口模式对代码进行封装,我们模拟了一个非常常见的推理类,首先我们分析了其存在的问题,那就是实例化 Infer 类,去调用 load_model、forward 等函数时,需要在函数去写异常逻辑处理,非常让人头疼。我们首先通过 RAII 将资源在构造函数中就进行初始化,避免了外部获取资源后还要调用相关函数去做初始化,其次我们采取了接口模式,将接口纯虚类与具体实现类分离开,让使用者只考虑具体的接口,而不必关心具体的实现。这种封装方式为代码提供了更大的灵活性和可维护性,十分推荐使用

你可能感兴趣的:(模型部署,tensorRT,CUDA,高性能)