前言
杜老师推出的 tensorRT从零起步高性能部署 课程,之前有看过一遍,但是没有做笔记,很多东西也忘了。这次重新撸一遍,顺便记记笔记。
本次课程学习 tensorRT 高级-使用 RAII 接口模式对代码进行有效封装
课程大纲可看下面的思维导图
1. RAII接口模式
这节我们学习多线程知识 —— RAII + 接口模式对生产者消费者模型进行封装,以及多 batch 的体现
那这边为什么提到多 batch 呢?这是因为 GPU 拥有计算密集型的特性,任务越密集越友好,假设 1 张图推理 需要 5ms,那你给它 2 张图,它的耗时很可能只有 6 ms,如果给 4 张图,它耗时可能 8 ms,也就是说它的耗时并不是成比例的,这就是我们说的多 batch 的体现
因此我们在对生产者消费者模式进行封装时就要考虑到 GPU 的特性,利用到它的特性,实现我们的高性能,高性能就是我们说的多 batch 的体现
接下来我们来看代码,我们先来写一个典型的程序,代码如下:
#include <thread>
#include <queue>
#include <mutex>
#include <string>
#include <stdio.h>
#include <chrono>
#include <condition_variable>
#include <memory>
#include <future>
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 <thread>
#include <queue>
#include <mutex>
#include <string>
#include <stdio.h>
#include <chrono>
#include <condition_variable>
#include <memory>
#include <future>
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 <thread>
#include <queue>
#include <mutex>
#include <string>
#include <stdio.h>
#include <chrono>
#include <condition_variable>
#include <memory>
#include <future>
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 <memory>
#include <string>
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 主要有以下几个特点:
- 资源的自动管理:在对象的构造函数中获取资源,在析构函数中释放资源。这样,只要对象的生命周期结束,相关资源就会被自动释放。
- 异常安全:由于资源的释放是自动的,所以即使在异常的情况下,资源也能够被正确地释放。
- 避免资源泄漏:通过自动管理资源,可以避免因遗漏资源释放而导致的资源泄漏。
下面是一个简单的 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++ 中,子类的指针可以隐式地转换为其父类的指针,这是由于继承的性质。当子类继承了一个基类(或接口),它隐含地表示子类 “是一个” 基类。因此,子类对象可以被视为其基类的对象。
在示例中:
- InferImpl 是 InferInterface 的具体实现。这意味着 InferImpl “是一个” InferInterface。
- create_infer 函数的返回类型是 shared_pt0r<InferInterface>,这意味着它返回一个指向 InferInterface 类型的智能指针。
- 但在函数的实现中,您实际上返回了一个指向 InferImpl 的 shared_ptr。由于 InferImpl 是 InferInterface 的子类,因此这是允许的。
这样做的好处是:
1. 封装实现细节:调用者不需要知道具体的实现类是什么,他们只需要知道接口。这提供了一个清晰的分界线,使得实现的修改不会影响到使用接口的代码。
2. 灵活性:如果您决定更改实现,只需要更改
create_infer
函数内部的实现,而不会影响到使用此函数的代码。3. 多态:调用者可以透明地使用不同的实现,只要它们都遵循相同的接口。
总之,这是 C++ 多态性的一个典型应用,允许您将子类的对象当作父类的对象来使用。这为代码提供了更大的灵活性和可维护性。
总结
这次课程我们学习了 RAII + 接口模式对代码进行封装,我们模拟了一个非常常见的推理类,首先我们分析了其存在的问题,那就是实例化 Infer 类,去调用 load_model、forward 等函数时,需要在函数去写异常逻辑处理,非常让人头疼。我们首先通过 RAII 将资源在构造函数中就进行初始化,避免了外部获取资源后还要调用相关函数去做初始化,其次我们采取了接口模式,将接口纯虚类与具体实现类分离开,让使用者只考虑具体的接口,而不必关心具体的实现。这种封装方式为代码提供了更大的灵活性和可维护性,十分推荐使用