目录
-
- 前言
- [0. 简述](#0. 简述)
- [1. 回顾一下RAII是什么](#1. 回顾一下RAII是什么)
- [2. 实现类,接口类与命名空间](#2. 实现类,接口类与命名空间)
- [3. CUDA-BEVFusion设计框架(namespace)](#3. CUDA-BEVFusion设计框架(namespace))
- [4. CUDA-BEVFusion设计框架(接口类)](#4. CUDA-BEVFusion设计框架(接口类))
- [5. CUDA-BEVFusion设计框架(实现类)](#5. CUDA-BEVFusion设计框架(实现类))
- [6. CUDA-BEVFusion设计框架(各个类负责的内容)](#6. CUDA-BEVFusion设计框架(各个类负责的内容))
- [7. CUDA-BEVFusion中的接口函数和实现类(RAII模式初始化)](#7. CUDA-BEVFusion中的接口函数和实现类(RAII模式初始化))
- [8. CUDA-BEVFusion中的接口函数和实现类(forward)](#8. CUDA-BEVFusion中的接口函数和实现类(forward))
- 总结
- 下载链接
- 参考
前言
自动驾驶之心推出的 《CUDA与TensorRT部署实战课程》,链接。记录下个人学习笔记,仅供自己参考
本次课程我们来学习下课程第八章------实战:CUDA-BEVFusion部署分析,一起来学习 CUDA-BEVFusion 推理框架设计模式
Note :接口模式和 RAII 杜老师之前也讲到过,感兴趣的可以看看 7.4.tensorRT高级(2)-使用RAII接口模式对代码进行有效封装
课程大纲可以看下面的思维导图
0. 简述
本小节目标:理解大项目 C++ 推理框架阅读的技巧,以及 CUDA-BEVFusion 中的设计模式,命名空间/接口类/实现类的设计用意
今天给大家讲第八章的第 8 小节,学习 CUDA-BEVFusion 的推理框架设计模式,这个可以说是整个第八章节中比较精彩的一部分,我们学习一下大项目,其实不光是 CUDA-BEVFusion,我们以后如果要自己用 C++ 写一个推理框架的时候,要学会推理框架的设计模式。比如哪些模块可以共享同一个设计方案,类与类之间的对应关系是什么,namespace 命名空间是怎么设计的,那这些都是我们在设计初期就需要考虑的
我们在第六章的时候给大家讲过一个好的推理框架需要注意以下 5 点:
- 代码可复用性
- 可扩展性
- 安全性
- 可读性
- 可调试性
第一个要注意的是代码的可复用性,可复用性就是说代码哪些地方是可以重复使用的,哪些地方我们尽量给它做个封装,尽量给它做个模块化,那这样大家在以后写代码的时候,只需要调用每一个模块就好了,这是一个比较好的代码设计方案。
那第二个要注意的是代码的可扩展性,可扩展性就是说一个代码写好后它需要能够做一个类似于像 Plugin 那种,每个部分能够像搭积木一样,每次有新的功能时能够实现最小限度的修改,只关注和它有关的东西,那些偏底层无关的东西不用关注,最小限度的去加入一些模块搭积木上去,这是一个可扩展性比较强的方案
第三个要注意的是代码的安全性,安全性讲的就是我们尽量在代码设计初期的时候不要暴露很多没有必要的接口,比如我们从 main 调用一个函数的时候实现某个复杂功能时能够越简单越好,一些跟底层实现相关的接口我们没有必要暴露给 main,在类的使用中我们也可以通过接口类去把不必要的接口给隐藏起来,全部放到实现类中,那这就是代码的安全性
同时还要注意 RAII 资源获取即初始化,我们在创建一个对象时就把一系列的初始化给做了,那这样的话就避免我们在设计的时候还得做一个构造函数,在结束的时候再做个析构函数,还要做各种 free、destroy、release 这些东西,那这个我们尽量能避免就避免
最后要注意的是代码的可调试性,代码可调试性就是说能够在代码中随处看到它的各种日志信息,设置不同的级别会打印出来
那这就是设计推理框架时要注意的几个点,CUDA-BEVFusion 框架其实是一个写得非常好的代码,我们在阅读代码之前需要先把它的框架理解好,要不然我们在看代码时会一头雾水,不知道从哪开始看,那所以整个第 8 小节我们主要是围绕它的命名空间、接口类和实现类这样一个设计关系
1. 回顾一下RAII是什么
我们先来回顾下 RAII 是什么,RAII 全称是 R esource A cquisition I s Initialization 资源换取即初始化
拿上图为例,比如说我们要去操作系统里面调用一个 API 获取资源时,它是直接调用 RAII Constructor,调用了它之后就开始去做各种初始化,比如说分配空间、分配内等等。以 CUDA-BEVFusion 来看的话就是分配 CPU 上的空间,分配 GPU 上的空间或者是创建推理引擎,创建 network,反序列化,还有设置各种参数,这一切它跟设计实现没有任何关系,它其实就是初始化
那么对于初始化的话,当我们系统在拿到这个资源的时候,它就是自动做了这些初始化的,那做完初始化之后,当我们这个系统不用它这个资源了,需要 release resource 怎么办,我们的系统也没有必要自己去 destroy 这个资源,那这个资源它自己有个 RAII Destrcutor,让资源自己直接释放就好了,那这个主要体现在哪呢,体现的就是 shared_ptr 智能指针这方面,我们可以把一个类里面的成员变量都设置成智能指针,那它们的生命周期如果到了,它们里面的各种信息就自动销毁了,这是一个比较好的设计模式
RAII 参考资料:RAII in C++, RAII is one of the patterns in C++ to manage resources.
2. 实现类,接口类与命名空间
那我们再看实现类、接口类和命名空间,我们直接看代码里面它们的设计关系我们可能看不懂,我们先看几个例子
我们现在有个 main 函数,右边还有一个类 classA,那 class A 它开放的接口的函数有 N 个,那现在在 main 函数中我想调用 class A 中的接口函数该怎么做呢?一个能想到的的方法就是我们可以先创建一个 classA 的一个 instance 实例,之后我们就可以根据它的实例对象调用 func1()、func2()...,
那这其实就代表我们 main 里面的函数会特别多,那只有一个 class 还好,那如果有多个 class,那每一个 class 之间它还有很多个函数,那 class 后面又有 class,那样的话调用起来特别麻烦,那其实这样子就是意味着我们把所有接口全暴露出来了,暴露给 main 看,那写的程序可读性特别差,并且写的程序比较危险,因为有的时候我们不知道哪些函数它是可以调用的,哪些函数是不可以调用的,有可能某些函数以错误的调用方式或者调用的一个顺序造成访问越界问题
那怎么做才是一种比较好的方式呢?一般来说我们可以给它设计一个接口类,我们设计一个 classA,那 classA 它暴露的函数就只有两个,一个 func1() 一个 func2(),那 main 函数它主要跟接口类 classA 打交道,main 可以调用的只有一个 func1() 或 func2(),那后续的 func3() 到 funcn() 都放到实现类 class AImplement 中去了
那这样我们就可以通过 class A 里面的 func1() 去调用 func3()、func4()...,func2 里面去调用 func()5、func6()...,通过这样的一个方式,我们让 main 函数只关注它需要的接口,而具体的实现细节它无需关注。那我们如果在看 CUDA-BEVFusion 代码的实现,可以发现接口类和实现类之间是一个继承关系,class A 是父类,class AImplement 是子类,那这个子类它是继承 classA 里面的很多函数。
那比如我们在 main 中在调用 class A 的一个函数的时候,如果这个函数在 class A 是个虚函数,而这个函数在 class AImplement 有一个自己的实现,那整个调用过程就是 main 调用接口类的 func1(),实际上是调用了实现类中的 func1(),从而完成某个功能的实现,就是这么一个调用关系
拿上面的案例来说,我们 main 调用 initialization() 初始化函数,我们通过接口类 class A 的 initialization 最终调用的是实现类 class AImplement 中的 init_step1()、init_step2()...,同理 forward 也一样,都是这么一套调用关系
多个类的情况也一样,class A、class B、class C、class D 每一个 class 它都有一个自己的接口类和自己的实现类,每一个类都调用自己的一个子类即实现类,那这样子的话其实也会有一个问题,那问题就是 main 这边它还是调用的是每一个 class 的 initialization 和 forward,其实还是会有一点不好操作,每次都需要调用不同类的 initialization 和 forward,我们能不能只调用一次初始化函数和前向传播函数呢
那这边有一个方案,在 main 和 class A、B、C、D 之间再设计一个类,这个类的名字叫 class Core,那 Core 它也是有一个接口类和一个实现,我们希望暴露给 main 的接口越简单越好,不要让它去访问其它没有用的东西,那上图中 main 访问 Core initialization 就意味着 Core 会调用 CoreImplement 实现类里面的 init_A()、init_B()、init_C()、init_D(),之后调用每个类相应的 initialization(),之后再调用子类实现类的 initialization,本质上就是一个套娃,那最终我们这么做的目的就是为了让 main 调用的接口越简单越好,实现起来比较方便也比较好管理。
OK,为了设计起来比较方便,我们可以把同一种类型的类放在同一个命名空间,比如说 class A 和 class B 是同一个类型的 task,比如都是跟 Camera 有关的 task,那我们可以放到一个命名空间中,class C 和 class D 也是同一个类型的 task,比如都是跟 LiDAR 有关的,那我们也可以放到一个命名空间中。接口 Core 中的两个 class 属于同一个类型也放在同一个命名空间,那这个就是 CUDA-BEVFusion 中实现类、接口类与命名空间一个参考的部分
3. CUDA-BEVFusion设计框架(namespace)
OK,我们理解了实现类、接口类与命名空间之间的关系后,我们来看看 CUDA-BEVFusion 是如何做的,我们先看 namespace 命名空间,如下图所示:
CUDA-BEVFusion 中最上层的命名空间是 bevfusion,在 bevfusion 下面又有 camera、lidar、fuser、head 四个命名空间,这其实对应的是 BEVFusion 网络架构中的四个 ONNX,不是说 namespace 和 onnx 是相对应的,只是说命名空间里面会调用相应的 onnx
我们可以看到 head 下面还有一个 transbbox 的命名空间,它这个设计可能是考虑 BEVFusion 是一个 multi-task 的网络,它有两个 task,一个是 3D Detection,一个是 Segmentation,只是这里没有设计 Segmentation 只有一个 transbbox 命名空间,大家感兴趣的话可以在 head 下面扩展一个 segmentation 的命名空间
这个就是 CUDA-BEVFusion 中 namespace 之间的层级关系,同时下面还有一个独立的 TensorRT 的 namespace,这个也是 NVIDIA 自己设计的,里面包含网络的反序列化、读取 onnx 等等,与 TensorRT 相关的接口都在这个命名空间里面
值得注意的是虽然 bevfusion 和 TensorRT 两个命名空间是相互独立的,但是二者是可以相互调用的,
4. CUDA-BEVFusion设计框架(接口类)
我们看完命名空间之后再来看接口类,如下图所示:
首先 bevfusion 命名空间有一个自己的接口类叫做 Core,之后它下面的每一个命名空间都有属于自己的接口类,camera 命名空间下有 Backbone、Geometry、BEVPool、Normalization、Depth、VTransform 接口类;lidar 命名空间下有 SCN、Voxelization 接口类;fuser 命名空间下有 Transfusion 接口类;transbbox 命名空间下有 TransBBox 接口类,每个接口类所负责的功能我们之后会提到
同时 TensorRT 命名空间也有一个接口类叫做 Engine,可以做反序列化等操作
5. CUDA-BEVFusion设计框架(实现类)
OK,我们再看实现类,如下图所示:
那实现类的话和接口类其实是一一对应的,我们可以看到每一个接口类都有属于它自己的实现类,比如 Backbone 有属于自己的 Backbone Implement,SCN 有属于自己的 SCN Implement,接口类暴露最简单最少的一些接口,实现类的一些具体实现函数是不会暴露出来的
大家看代码可以对照着上图来看
6. CUDA-BEVFusion设计框架(各个类负责的内容)
OK,我们来看看各个类负责的内容:
首先 core 有一个 Core 类,它是作为 main 的接口部分,通过 core 来调用其它的接口类
camera 一共有六个类,Backbone 是用于 camera backbone 的 DNN 推理,这部分是 TensorRT 来做加速的;BEVPool 是用于 camera 到 BEV grid 的投影,这部分是 CUDA 来做加速的;Depth 是用于将 LiDAR 点云投影到 camera 上,这部分是 CUDA 加速的;Geometry 是计算 camera 到 ego 到 bev 过程的投影,这部分是 CUDA 来做加速的,BEVPool 中优化方式有一个是 Precomputation,提取计算 BEV Grid 上的每个点对应的是哪个 camera 上的哪个点,那这个就是 Geometry 要做的事情;Normalization 是用于做图像预处理的,这部分是 CUDA 来做加速的;VTransform 用于对 BEV Feature map 特征提取,做一个 downsample,这部分是 TensorRT 来做加速的。
lidar 有两个类,SCN 用于调用 voxelization,LiDAR SCN 网络推理,这部分使用自定义的 CUDA 加速,它内部可能实现了 spconv 的加速方案;Voxelization 用于点云预处理,使用 CUDA 进行体素化加速。
fuser 有 一个 Transfusion 类,它用于 Fuser DNN 网络的推理,这部分是 TensorRT 来做加速的,它主要是融合模块将 Camera BEV Feature 和 LiDAR BEV Feature 进行融合
head 有一个 TransBBox 类,它用于 head DNN 网络的推理,这部分是 TensorRT 来做加速的,它主要是检测头模块利用融合后的 BEV Feature 得到检测结果,值得注意的是这里还包括检测结果的 decode 与绘图等相关内容,那这部分则是 CUDA 来做加速的
以上都是 bevfusion 命名空间下的类,最后我们来看下 TensorRT namespace,TensorRT 有一个 Engine 类,它用于反序列化 TensorRT 推理引擎,创建 context、runtime 这些东西,那它这里没有做序列化,因为这里 engine 的生成方式不是通过 TensorRT C++ API 创建的,直接是通过 trtexec 工具来创建的,所以这里只需要拿一个 engine 反序列化就 OK 了。
7. CUDA-BEVFusion中的接口函数和实现类(RAII模式初始化)
我们通过 CUDA-BEVFusion 中命名空间、接口类与实现类的关系来看它们之间的函数调用关系,我们主要看两个函数,一个是初始化一个是 forward,我们先来看初始化函数的调用关系,如下图所示:
初始化这边是用的 RAII 模式的初始化,main 函数先调用 bevfusion 命名空间中的 create_core 函数,它会创建 Core Impl 类的 instance 之后初始化 Impl 类,接着同理调用下面各个命名空间(camera、lidar、fuser、head)中的 create 函数,然后分别创建对应的实现类的实例对象之后初始化实现类,值得注意的是 create 函数中创建的是实现类的实例对象,返回的是接口类
代码实现部分如下:
src/bevfusion/bevfusion.cpp
8. CUDA-BEVFusion中的接口函数和实现类(forward)
那下面我们在来看看 forward 函数的调用关系,如下图所示:
main 函数调用 forward 的时是调用的 Core class 的 forward,又由于这个 forward 是纯虚函数,它在 Core Implement class 中有实现,而且 Core Implement 继承自 Core,因此我们调用的是 Core Implement class 中的 forward 函数,同理接下来就是调用每个模块各自接口类的 forward 函数,而实际上最终调用的是各个接口类对应实现类中的 forward
代码实现部分如下所示:
src/bevfusion/bevfusion.cpp
这里面实现了以下几个 forward:
- lidar_scn_:cuda 预处理 + TensorRT DNN 推理,先将点云转为 voxel feature,输入的点云 shape 是(242180, 5), 输出的 shape 是 (1200, 1200, 40), 并且是 fp16
- normalizer_:cuda 预处理,将图像进行预处理 (bilinear + normalization + NHWC2NCHW), 并将多个 camera 的数据汇总在一起, output 是 fp32, shape 是 (1x6x3x256x704)
- camera_depth_:cuda 预处理,将 LiDAR 点云的信息投影到各个 camera 的坐标系上,camera 上每一个点的坐标表示的是 distence,output 是 fp16
- camera_backbone_:TensorRT DNN 推理,直接调用 engine 的一个 context 里的 enqueueV2
- camera_bevpool_:cuda 预处理,将 6 个 camera 坐标系下的 camera feature 和 depth feature 融合到 BEV grid 空间中, 得到 camera-bev-feature, output 是 fp16
- camera_vtransform_:TensorRT DNN 推理,将 BEVPool 结束后的 BEV feature map 通过几个卷积进行特征提取。直接调用 engine 的一个 context 里的 enqueueV2, output 是 fp16
- transfusion_:TensorRT DNN 推理,直接调用 engine 的一个 context 里的 enqueueV2 (注意这里输入是 camera_bev 和 lidar_bev), output 是 fp16
- transbbox_:TensorRT DNN 推理 + cuda 后处理,直接调用 engine 的一个 context 里的 enqueueV2 (注意这的输出是 6 个), 以及从输出中 decode 出想要的 3D bbox
值得注意的是这里调用的是接口类的 forward,而接口类的 forward 并没有任何实现,它是个纯虚函数,它最终调用的其实是实现类中的 forward,每个 task 都有自己具体的实现,大家看代码的时候看相关实现就行了,这里是个引导给大家作为参考
OK,CUDA---BEVFusion 整个代码的设计框架就介绍到这里,接下来我们要去看代码了,建议大家对照着这小节的内容自己先去阅读下相关代码,看看自己是怎么理解的,之后再去看韩君老师的讲解会比较好
总结
这节课程我们主要学习了 CUDA-BEVFusion 推理框架设计模式,我们首先了解了 RAII、接口类、实现类和命名空间,然后深入到 CUDA-BEVFusion 中详细讲解了其中的各个关系,CUDA-BEVFusion 中的命名空间和 BEVFusion 网络结构的各个模块相对应,每个命名空间下都有实现其功能的接口类和实现类,此外初始化函数采用了 RAII + 接口模式的方式,总的来说 CUDA-BEVFusion 整个推理框架的设计是非常不错的,值得大家借鉴学习
OK,以上就是第 8 小节有关 CUDA-BEVFusion 推理框架设计模式的全部内容了,下节是本章的最后一小节,跟随韩君老师一起去阅读下 CUDA-BEVFusion 的相关代码,敬请期待😄