前言
先说一下,这个博客不涉及TensorRt加速,不涉及半精度或者int8精度加速,仅仅是libtorch部署时,NMS和RoI Pool的加速问题。当然,最后实操下来目前没有做到比python下调用torchvision的api做到的总体结果快,算是一次失败的双阶段网络搭建尝试,后续有时间再更新找找原因吧。
关注我的朋友可能都知道我最近在写libtorch教程,有一段时间没有更新最后的关键章节——目标检测章了。我自己是写过写单阶段的目标检测器的C++实现的,没有RoI Pooling或者RoI Align,没有高性能要求的NMS,写起来用起来都很爽。
但是考虑到Faster RCNN毕竟是目标检测最最经典的方法(可能之一)了,还是硬着头皮整一下libtorch版本的Faster RCNN。过程果然如我所料,比单阶段的网络整起来麻烦很多,最后效果也不一定有(大概率没有)单阶段的目标检测网络好用。毕竟实际项目,速度为王,看YOLO系列多火就知道了。后面有机会整个YOLO全家桶吧….
RoI Pool
RoI Pool的作用及原理
进入正题,今天主要有两个点需要重点说说,RoI Pool和NMS的加速问题。先说说RoI Pool,了解双阶段目标检测的,或者直白点,了解Faster RCNN的朋友应该都知道它或者用过它(不了解的可以去b站搜下,有bub开头的up主讲得挺不错的)。RoI Pool (or Region of Interest Pool),它在Faster RCNN算法中扮演的角色相当于一个中间步骤吧,双阶段检测就靠它承上启下。
RoI Pool之前,输入图像经过一个骨干网络卷积,得到特征图,这个特征图再经过一个RPN(Region Proposal Network)网络得到粗定位的一堆预测结果。其实这里涉及到一个点,就是卷积做分类的能力要高于做回归。早期的研究者尝试直接用卷积的方式做单阶段目标检测,直接通过特征图判断位置和类别,但是最后效果都不好。直到YOLO的横空出世,用了分治的思想,把单阶段目标检测算法提了一个台阶。
这里的RPN网络就可以看作一个只有有无目标判断能力,定位效果相对不够的单阶段检测算法,通过卷积得到的特征图,配合预先设定好的先验框,得到位置的粗略信息。这些粗略信息可以看成是一堆建议框,框里就是目标,目标的类别未知。
RoI Pool的作用就是将这些建议框从特征图中裁剪出来,然后经过池化(Pool)操作统一到同一个尺寸,再将这些相同尺寸的特征图拼接起来输出。后续就是第二阶段的分类和精定位了。
几种python实现的RoI Pool
在了解了RoI Pool的原理之后,我在GitHub上找到了一些python的实现,帮助进一步了解。这个项目帮助我很多,里面有四种pytorch的RoI Pool实现,并且提供了速度对比。
简单说下该项目的四种实现方式,具体细节及代码就不展开了。四种方式:cffi实现,cupy实现,chainer实现以及pytorch实现。下面是四个方式的速度测试结果:
1 | use_cuda: True, has_backward: True |
可以发现,cffi实现的速度是最快的,甚至比项目里提供的cupy方式还要快,cupy是python调用cuda编程加速。下面我先在RTX 2070s下pytorch版本的RoI Pool速度如何。
1 | import time |
以上代码基本是从项目中复制来的,没有太多改变,输出打印如下:
1 | time: 0.12641457080841065, batch_size: 1, size: 50, num_rois: 300 |
耗时126ms,这比项目输出的速度要好一些。但是这显然不能用于生产环境中,而且还没算训练时的RoI Pool耗时,但是应该比这高得多。
libtorch版本的RoI Pool
然后我不信邪,在C++里实现了上述的pytorch代码。代码
1 | class RoIPoolImpl : public torch::nn::Module { |
测试了一下速度,测试代码如下:
1 | torch::Tensor create_rois(int config[]) { |
代码里用了libtorch和opencv,后者主要用于计时,如果不能运行可以参考网络的配置博客或者我自己的pytorch部署博客和libtorch的QT部署。
可以发现我的代码主要添加了一些安全性设置,原来的python代码,输入受create_rois影响,已经将rois内部的大小关系定义成方框的左上角点坐标小于右下角点。但是实际的Faster RCNN中,RPN网络出来的结果并非如此,所以加了安全性设置。
在同样的RTX 2070s显卡上,该libtorch代码的执行速度高达0.08s,比python下同样代码快个50%吧。除去安全性代码可能还会更快。但是,我们发现,这个速度最多只能和开源项目中的cupy或者chainer相对提速优越一点点。(0.2s->0.13s和0.126s->0.08s)。它的速度依然是比cffi实现慢许多的,如果在同样显卡上甚至慢相当多。
libtorch版本的RoI Pool提速
那我们对这个代码进行提速,看看最后能到什么效果。重新构建RoIPool类,代码如下:
1 | class RoIPoolImpl : public torch::nn::Module {//our RoIPool should be a little slower than cuda version, not tested |
这次,我们将从libtorch中tensor中取数的操作变化一下,先在循环外围将张量拷贝到数组指针中,然后再在循环内取数。重新计时,这次我们测试结果为:0.03s。显然,我们在2070s显卡上,将pytorch版本的代码转到libtorch中并添加安全性检查,速度变化为:0.126s->0.08s->0.03s。总体提速四倍以上。这个速度从值上要比项目中cffi实现更快,但是从比例上,cffi比pytorch实现提速高于四倍(0.2s->0.34s)。所以仍旧有差距,但是已经是本人目前的最优实现了。
cuda编程提速RoI Pool
实际经验告诉我,pytorch实现的Faster RCNN执行速度应该可以比0.126s要更优。这是为何?我们定位到pytorch的RoI Pool,发现它其实也是通过调用cuda编程加速了该过程。那么显然,libtorch部署同样可以采用这个代码。
直接用torchvision的源代码可能有些问题,要做些修改,主要是需要将FP16精度的部分代码注释掉,
NMS
libtorch的NMS
前面的博客NMS的几种写法中我有分享libtorch版本的NMS。代码如下:
1 | //输入boxes:Nx4; socres: N; thresh:介于(0,1)的float变量 |
这个代码,看似只有一个循环,时间复杂度近似为O(n)。但是实际上,每次循环内部,都要计算当前框与其他框的IOU,时间复杂度近似为O(n²)。如果框的数量一旦增加,速度上将大打折扣。对于单阶段的目标检测如YOLO,最后经过阈值筛选后再NMS时,框的数量已经很少,几乎不影响速度。但是对于双阶段目标检测如Faster RCNN而言,框的数量一定是很高的,一般可以指定到1000以上。两者不在一个量级上,NMS的速度将极慢,我的2070s上可能将近有几百毫秒。这无疑不能忍受。
cuda编程提速NMS
同样,我们想到了torchvision中的NMS实现,直接拷贝过来,嗯,真香。实操可能会有些编译错误,调试改改应该就能通过。
最后,本文代码只经过速度测试,未上线到生产环境,可能有许多需要改进。