前言 本文分享一篇关于opencv高性能计算基础的文章,这是一个作者对工作期间使用OpenCV和CUDA开发高性能算法库的过程所涉及到的知识要点和踩坑的记录,将会涉及OpenCV, CUDA和C 的一些知识。
作者:风暴洋@知乎(已授权)
编辑:CV技术指南
原文:https://zhuanlan.zhihu.com/p/429109879
OpenCV加速模块
OpenCV是一套Apache 2协议的C 开源库,涉及图像处理、三维重建、人工智能等领域。这些领域都对性能有着严苛的要求。OpenCV在不断的迭代中,逐渐向几个方向扩展着其计算能力,下面以OpenCV中每个方向对应的类为锚进行列举:
- cv::Mat OpenCV中最基础的数据结构,具有存储多种数据类型的多维矩阵的能力,也可用来表示图像。在CPU端,SIMD (Single instruction, multiple data) 是数值计算常用的加速方式,然而即使是相同架构的CPU,所支持的宽度和指令都不相同,为每一种目标指令集写一份加速代码代价巨大。在OpenCV中,这种差距被其 Universal Intrinsic 的抽象填平了。OpenCV Universal Intrinsic 将不同SIMD指令的向量 (vector) 封装成了统一的数据结构,重载了各种运算符,并将向量宽度描述为一个随编译环境自动变化的变量。从此,开发者不再需要过多关注不同CPU的SIMD指令间的差异,同一套SIMD加速过的代码能够通过不同的编译选项生成对应不同目标指令集的程序。此外,OpenCV的一些方法也提供多线程实现,并允许用户使用cv::parallel_for_()封装自己的方法。
- cv::UMat 于OpenCV3.0 引入,T-API (Transparent API) 的基础数据结构。T-API是曾经的OCL模块的替代,旨在允许开发者通过非常简单的代码修改将现有OpenCV程序无缝迁移到OpenCL上,从而利用强大的异构算力获取数倍的加速。
- cv::gapi::GMat 于OpenCV4.0引入,G-API (Graph API) 的基础结构。与其他的Mat类不同,GMat并不会存储实际的数据,而是会记录用户对GMat进行的操作,并最终组合多个GMat生成一个计算图用以处理真正的计算。基于图的计算,是G-API的核心思想。计算图将计算式声明与计算分离,可以带来至少两个好处:一是OpenCV可以在内部提供分散函数无法提供的跨函数优化,如算术操作的合并,高速缓存的复用和避免多次分配buffer;二是统一的接口使得用户可以相对简单地选择计算时的后端,如Halide和OCL等。目前G-API仍处于活跃的开发阶段,可能会产生不兼容的改动,建议稳定后再使用。
- cv::cuda::GpuMat 顾名思义,GPU上的Mat,代表数据指针指向CUDA的Device memory的二维矩阵,是OpenCV多个CUDA相关模块(均在opencv_contrib)的基础数据结构之一。OpenCV的CUDA模块已经开发了近10年,功能相当丰富,用户能够方便地用其改写现有项目,也能将其数据结构集成到自定义的CUDA Kernel中,实现极致的性能优化。如何利用OpenCV CUDA模块进行快速的自定义高性能图像算法开发将是本专栏的重点内容。
内存优化
内存的管理是几乎每个C 项目都要谨慎考虑的问题。OpenCV中有一套高效易用的内存管理体系,使得多数情况下内存的管理不会成为效率的额外负担。此外,一些特性可能会使用户的代码产生意外的结果,有必要在用OpenCV进行开发前进行详细的了解。
引用计数
OpenCV中的各种Mat类可能具有多种含义:它们既可以用来表示一副BGR图像,也可以用来保存浮点型的视差值或者某个图像一个ROI区域的临时表示。不幸的是,这些数据通常都较大,如果经常进行深拷贝会对程序性能造成严重影响。在CUDA上,内存分配甚至可能远比Kernel耗时。对此,OpenCV的策略是,cv::Mat类以及cv::cuda::GpuMat类对同类型实例的拷贝构造函数,operator= 重载以及ROI区域截取均为浅拷贝操作,并用简单的引用计数管理共享的内存。需要注意的是,这与写时复制 (COW) 不同,OpenCV无法判断Mat的数据何时被写入,如果改变了一个副本data指针所指向的数据而在此之前没有调用create()等函数改变data指针本身的值,那么所有副本的数据都会发生变化。实现Mat类的深拷贝,可以使用copyTo()和clone()等函数。一些常见情况如下所示:
代码语言:javascript复制cv::Mat src(100, 100, CV_32FC1); //原始矩阵
cv::Mat shared1(src); //共享了内存
cv::Mat shared2 = src; //共享了内存
cv::Mat shared3(src(cv::Rect(10, 10, 10, 10))); //共享了内存
cv::Mat independent1 = src.clone(); //申请了新的内存
cv::Mat independent2;
src.copyTo(independent2); //申请了新的内存
代码语言:javascript复制
create()
在图像处理领域,存在大量类似“连续对许多尺寸和类型相同但内容不同的数据进行相同操作”的算法,如实时的深度计算或神经网络的连续推理。以Guided Filter为例,其流程如下[1]:
Guided Filter
其中I, p是输入数据,q是输出数据,中间会产生许多尺寸相同的临时对象。对于其中的每一个步骤以及算法整体,输出矩阵的尺寸仅和当前的输入相关。当中间变量的生命周期足够长(如作为成员变量时),我们有两种内存分配策略:
- 提供setSize()接口,由用户决定设定所有中间变量的尺寸的时机。
- 仅在在用户调用filter本身时对输入尺寸进行检查,当现在的buffer尺寸与输入不同,buffer重新分配内存。
第一种方法使得用户可以完全控制内存分配的时机,提升程序内存和效率的稳定性;而第二种方法对于用户来说十分简单快捷,能够自动适应不同的输入。
事实上第二种方法是OpenCV对绝大多数函数参数中的 cv::OutputArray (作为输出参数的 cv::Mat 等数据结构的代理) 所采用的方法,拜其所赐我们免去了在调用OpenCV函数前对dst进行手动分配的麻烦。对于cv::Mat和cv::cuda::GpuMat,此操作由create()成员函数执行,其简化的源码如下:
代码语言:javascript复制void cv::cuda::GpuMat::create(int _rows, int _cols, int _type) {
if (rows == _rows && cols == _cols && type() == _type && data) return; //尺寸符合条件时直接返回
if (data) release(); //减少引用计数
allocator->allocate(this, rows, cols, esz); //重新分配内存,data将指向新的空间
if (refcount) *refcount = 1; //为新分配的空间启用引用计数
}
代码语言:javascript复制可见,当实例目前的尺寸符合要求时,create()将会立刻返回。假设用户对同一个cv::Mat进行了100次blur() (not-in-inplace),那么只有第一次blur()会申请内存。
临时缓冲区优化
由于OpenCV的算法大部分以单独的函数而非类形式提供,无法完全避免中间变量的内存分配,OpenCV提供了一些机制以减小使用临时缓冲区的代价。
- cv::AutoBuffer 一个临时栈堆结合缓冲区类,其一个模板参数接收期望栈缓冲区的尺寸(OpenCV 4.5.4 中默认为1024字节左右),当后续需求的缓冲区大小小于栈缓冲区尺寸时,可用栈缓冲区作为目标缓冲区,否则申请堆内存。此类可以用来存储一些较小的临时变量,而不会产生额外的malloc()/free()开销。
- cv::cuda::BufferPool CUDA模块的内存池,由全局变量cv::cuda::DefaultDeviceInitializer initializer管理各个设备上的内存池大小,分配内存时使用类似于栈分配的StackAllocator,因此内存释放必须遵循 LIFO(Last In, First Out) 顺序。由于CUDA上的malloc()通常远比CPU上更耗时,内存池能极大减小需要临时缓冲区的CUDA操作的额外开销。但我仍推荐开发者在能够选择接口形式时把缓冲区设为成员变量以降低风险。
理论部分到此为止,后续会更新使用OpenCV CUDA模块进行开发的一些心得。
[1] K. He, J. Sun. Fast Guided Filter.
本文仅做学术分享,如有侵权,请联系删文。