在科研中,大多数论文其实还是看精度和效果的,对于速度其实没有那么高的追求,很多人用速度评价自己算法的复杂度很低,但实际上这是不准确的,当然在精度占优的情况下,能够提高速度,给自己的实验结果增彩。
关于算法程序的加速,在动手前先要按照如下流程进行思考,以决定从哪里入手加速。
强烈注意:
所有的优化,都是在自己算法流程不变的前提下进行优化,因为优化后的程序,高度面向过程,如果算法某个流程要换以达到更高精度,则改动工作量较大。
下面我对每种加速方法进行详细的说明(本文只列举加速方法,并给出几种参考示例,并不会详细讲解如何使用,仅介绍思想)。
算法优化分为两种类型:① 降低算法复杂度;② 减少重复计算过程。
越是高级的语言,开发效率越高,执行速度越慢。
并行思想从小到大可以总结为:指令集开发→多核并行→CUDA并行,在深度学习中,TensorRT是一种更高级的CNN网络加速方法。
指令集加速,一般是针对CPU架构进行的底层优化,常见于OpenCV和Tensorflow的CPU版本。之所以OpenCV是个经典的开源图像框架,很大原因是因为其在多个平台上执行效率很高,其中底层的优化,比如指令集优化,起到了关键作用。
数据并行的两种实现在计算机体系中,数据并行有两种实现路径:
CPU指令集的发展(针对Intel的x86指令集系列):
利用CPU-Z软件可以查看电脑的CPU信息,
关于指令集的使用,在博客《论文阅读——椭圆检测 2020:Arc Adjacency Matrix-Based Fast Ellipse Detection》给出的源码中,使用了AVX指令集对代码进行处理,为了方便理解使用,我们以计算椭圆的采样点为例,其中 x o , y o , R , r , θ x_o,y_o,R,r,\theta xo,yo,R,r,θ分别为椭圆的中心点、长短轴及旋转角。 c o s t , s i n t cost,sint cost,sint表示椭圆参数方程用于采样。指令集的文档参考Intel® C++ Compiler XE 12.1 User and Reference Guides 。
{ x = R c o s θ c o s t − r s i n θ s i n t + x o y = R s i n θ c o s t + r c o s θ s i n t + y o \left\{\begin{array}{l}x = Rcos\theta cost-rsin\theta sint + x_o\\y = Rsin\theta cost + r cos\theta sint + y_o\end{array}\right. { x=Rcosθcost−rsinθsint+xoy=Rsinθcost+rcosθsint+yo
则利用指令集计算采样点的方法如下所示,显然原来需要计算VALIDATION_NUMBER次采样点的过程,现在仅需要VALIDATION_NUMBER/8次(sizeof(__m256) / sizeof(float)=8)。
// 初始化旋转变换矩阵,这里angleRot = \theta
const float _ROT_TRANS[4] = {
R * cos(angleRot), -r * sin(angleRot),
R * sin(angleRot), r * cos(angleRot) };
// Estimate the sampling points number N. Note: N = RoundEllipseCircum;
// Use SSE to faster the step of ellipse validation.
// 考虑到指令集实际上一次性计算8个数据,
// 则_mm256_set1_ps的目的是用一个float初始化一个__m256
// 举个例子:假如需要初始化的float为k,则调用_mm256_set1_ps之后得到
// [k,k,k,k,k,k,k,k]
__m256 _rot_trans_0 = _mm256_set1_ps(_ROT_TRANS[0]),
_rot_trans_1 = _mm256_set1_ps(_ROT_TRANS[1]),
_rot_trans_2 = _mm256_set1_ps(_ROT_TRANS[2]),
_rot_trans_3 = _mm256_set1_ps(_ROT_TRANS[3]);
__m256 x_center = _mm256_set1_ps(xyCenter[0]),
y_center = _mm256_set1_ps(xyCenter[1]);
__m256 tmp_x, tmp_y, tmp_wx, tmp_wy, tmp_w;
for (int i = 0; i < VALIDATION_NUMBER; i += sizeof(__m256) / sizeof(float))
{
// 一次性读取256位数据,实际上就是加载8个float到base_x,base_y
// 这里的base_x = cost, base_y = sint
__m256 base_x = _mm256_load_ps(vldBaseDataX + i);
__m256 base_y = _mm256_load_ps(vldBaseDataY + i);
// calculate location x
// _mm256_mul_ps 计算乘法:计算每个float的乘法,_mm256_add_ps
// 举个例子:两个__m256数据为[k1,k2,...,k8], [p1,p2,...,p8]
// 调用_mm256_mul_ps之后,得到[k1p1,k2p2,...,k8p8]
// 调用_mm256_add_ps之后,得到[k1+p1,k2+p2,...,k8+p8]
tmp_x = _mm256_add_ps(
_mm256_mul_ps(_rot_trans_0, base_x),
_mm256_mul_ps(_rot_trans_1, base_y));
tmp_x = _mm256_add_ps(tmp_x, x_center);
// calculate location y
tmp_y = _mm256_add_ps(
_mm256_mul_ps(_rot_trans_2, base_x),
_mm256_mul_ps(_rot_trans_3, base_y));
tmp_y = _mm256_add_ps(tmp_y, y_center);
// Save location x, y
// _mm256_storeu_ps的目的是将计算后的8个float存入float矩阵中
_mm256_storeu_ps(sample_x + i, tmp_x);
_mm256_storeu_ps(sample_y + i, tmp_y);
}
其实后期测试速度发现,实际加速效果没有特别明显,毕竟用了大量中间变量,且函数的调用传递了参数,指令集实际上在汇编中用的多,如果在代码中内嵌指令集可能效果会更好。
参考资料:
1 C/C++指令集介绍以及优化(主要针对SSE优化)
多核编程可以理解为就是多线程编程,总体上可以分为三个部分:OpenMP并行,opencv并行和多线程并行。在设计相关代码时候,切记变量可以被多个线程访问,但同一时间只能被一个线程修改,如果多个线程想修改同一个变量,可使用原子操作或加锁。 当然,多核编程不止这些,还有tbb,mkl等等。
#pragma omp parallel for
即可,程序会自动将for循环分解。值得注意的是,该方法是在for循环前开始创建线程,结束后并销毁,这个过程会产生一些时间消耗,大约在3-5ms之间,做实时性应用开发的时候需要注意这个问题。下面给出了一个并行示例。当然openmp有多种操作函数,感兴趣去找对应的开发教程即可。#include
#include
using namespace std;
int main() {
omp_set_num_threads(4);
#pragma omp parallel for
for (int i = 0; i < 3; i++)
printf("i = %d, I am Thread %d\n", i, omp_get_thread_num());
getchar();
}
// i = 0, I am Thread 0
// i = 1, I am Thread 1
// i = 2, I am Thread 2
parallel_for_
,内部集成多种并行框架。在c++11中,可以不必定一个类去继承并行计算循环体类ParallelLoopBody
,可以直接使用。#include
#include
using namespace std;
using namespace cv;
class Parallel_My : public ParallelLoopBody
{
public:
Parallel_My (Mat &img, const float x1, const float y1, const float scaleX, const float scaleY)
: m_img(img), m_x1(x1), m_y1(y1), m_scaleX(scaleX), m_scaleY(scaleY){
}
virtual void operator ()(const Range& range) const
{
for (int r = range.start; r < range.end; r++) //process of for loop
{
/***
这里写每个线程要做的事情
***/
}
}
Parallel_My& operator=(const Parallel_My &) {
return *this;
};
private:
Mat &m_img;
float m_x1, m_y1, m_scaleX, m_scaleY;
};
int main()
{
Mat Img(480, 640, CV_8U1);
float x1 = -2.1f, x2 = 0.6f, y1 = -1.2f, y2 = 1.2f;
float scaleX = mandelbrotImg.cols / (x2 - x1), scaleY = mandelbrotImg.rows / (y2 - y1);
#ifdef CV_CXX11 // 使用lambda函数的示例
parallel_for_(Range(0, Img.rows*tImg.cols), [&](const Range& range)
{
for (int r = range.start; r < range.end; r++) //这是需要并行计算的for循环
{
// 自己补充函数
}
});
#else // 默认情况下需要定义一个类,将参数全部传进去。
Parallel_My parallel_my0(Img, x1, y1, scaleX, scaleY);
parallel_for_(Range(0, Img.rows*Img.cols), parallel_my0);
#endif
}
参考资料:
1 opencv 并行计算函数 parallel_for_的使用
CUDA加速其实是最好的加速手段,CUDA最大的特性就是核心数特别多,一般是几千个,相比于CPU,加速倍数高达20-200倍之间。特别是推出的Jetson NX系列嵌入式卡,核心数在128-512之间,推进了更多算法的落地应用。
如果想学习CUDA,我非常推荐下真本书,基础的都涵盖了,看完之后基本就能动手写程序了。
CUDA开发主要还是有C语言风格,C++用的很少,切记一点避免在CUDA中动态分配内存,最好通过参数传递内存指针。
下面给出一个向量加法示例,来简单说明CUDA的用法。
#define N (33*1024)
// 核函数就是表示每个CUDA核心执行的函数,用关键字__global__ 表示
__global__ void add(int *a,int *b,int *c)
{
int tid = threadIdx.x + blockIdx.x*blockDim.x;
while(tid < N){
c[tid] = a[tid] + b[tid];
tid += blockDim.x*gridDim.x;
}
}
int main(void)
{
int a[N],b[N],c[N];
int *dev_a,*dev_b,*dev_c;
//在GPU上分配内存
cudaMalloc((void**)&dev_a,N*sizeof(int));
cudaMalloc((void**)&dev_b,N*sizeof(int));
cudaMalloc((void**)&dev_c,N*sizeof(int));
//在CPU上为数组'a'和数组'b'赋值
for(int i=0;i<N;i++){
a[i] = i;
b[i] = i*i;
}
//将数组‘a’和数组‘b’复制到GPU内存中
cudaMemcpy(dev_a,a,N*sizeof(int),cudaMemcpyHostToDevice);
cudaMemcpy(dev_b,b,N*sizeof(int),cudaMemcpyHostToDevice);
add<<<128,128>>>(dev_a,dev_b,dev_c);
//将数组‘C’从GPU复制到CPU中
cudaMemcpy(c,dev_c,N*sizeof(int),cudaMemcpyDeviceToHost);
//验证GPU计算结果
for(int i=0;i<N;i++){
if(a[i]+b[i] != c[i]){
printf("error:%d+%d=%d\n",a[i],b[i],c[i]);
}
}
cudaFree(dev_a);
cudaFree(dev_b);
cudaFree(dev_c);
return 0;
}
我曾经加速过经典导向滤波程序WGIF算法,导向滤波核心是以每个像素为核心,根据周围像素做滤波处理,在原始Matlab上的运算速度大约是20s左右,经过CUDA加速后,仅需要80ms即可跑完一张图片。
在CPU上多线程开发遇到的一些同步、线程通信问题CUDA下都有。
对于一些常数内存,也就是不需要被修改的内存,CUDA给了很多种形式用于快速访问:
当然,更加详细的用法,去看书即可,这里只是简单介绍。
TensorRT早期叫法叫GIE (GPU Inference Engine, GPU推理引擎),从名字上就知道这个东西用于推理(也就是测试过程),Tensor可以理解为高维数组。在TensorRT中,所有的数据都被组成最高四维的数组,如果对应到CNN中其实就是 { N , C , H , W } \{N, C, H, W\} { N,C,H,W},N表示batch size,即多少张图片或者多少个推断(Inference)的实例;C表示channel数目;H和W表示图像或feature maps的高度和宽度。RT表示的是Runtime。
在深度学习的落地应用中,主要就是输入图片,推断结果,模型如果做得不好,没有做优化,可能需要500多ms才推断完一张图片,延迟较高,导致系统灵活性变弱。下图红色部分指的就是TensorRT要干的事。
float32
类型,这也是最小的浮点类型。很多研究表明可以用低精度,如半长(16)的float类型FP16
,也可以用8位的整型(INT8)来做推断,研究结果表明没有特别大的精度损失,尤其对CNN。二值权值目前也在研究中。只不过FP16和INT8的研究使用相对来说比较成熟。低精度推断的优点很明显:速度快、内存低。下面给出的一种网络,就非常适合用TensorRT优化。
总的来说,尽管TensorRT做了很多优化,但加速效果普遍在20%左右,做好剪枝或改变数据类型可以提升2-3倍的性能。TensorRT只是在计算上优化了,想变得更快还是得想办法设计出一个更加轻量的网络。
参考资料:
1 高性能深度学习支持引擎实战——TensorRT
用汇编加速的方法往往指的是C/C++与汇编混合编程,尽管多种编译器均有优化等级选项,但是越是高级的语言,时间损耗越多。
什么时候使用汇编加速呢? 一般有两种情况需要嵌入汇编代码:
如何嵌入汇编代码呢? 方法很简单,使用关键字__asm来加入一段汇编语言的程序,C++下具体的格式为:__asm{ 指令 [;指令] /* comments */ ... 指令}
在C语言下,格式为:
asm [ volatile ] (
assembler template
[ : output operands ] /* optional /
[ : input operands ] / optional /
[ : list of clobbered registers ] / optional */
);
下面给一个示例(VS x64似乎不支持汇编扩展,我仅仅是见别人的算法中用过,自己没开发过)。
#include
/* 赋值 */
static int value_assignment(int input) {
int ret = 0;
asm volatile(
/* ret = input */
"movl %1, %0\n" /* 通过占位符指定交互的变量 : %0:ret %1:input*/
:"=r"(ret)
:"r"(input)
);
return ret;
}
int main() {
int input = 1;
int ret = value_assignment(input);
printf("input = %d\n", input);
printf("ret = %d\n", ret);
return 0;
}
// 打印结果:
// input = 1
// ret = 1
前面介绍了各种通过编程加速的方法,核心是将算法进行并行化处理,总的来说
如今,FPGA是日趋热门的一种加速方法,与软件加速不同,该方法是直接将算法设计在电路上,变成专有模块进行并行。
FPGA是目前新的一种低功耗加速设备,虽然通用的CPU主频很高,但做某个特定运算(如图像处理中的Sobel)可能需要很多个时钟周期;而FPGA可以通过编程重组电路,直接生成专用电路,加上电路并行性,可能做这个特定运算只需要一个时钟周期。举例来说,CPU主频3GHz,FPGA主频500MHz,若做某个特定运算CPU需要15个时钟周期,FPGA只需一个,则耗时情况: CPU:15/3GHz =5ns; FPGA:1/500MHz =2ns。可以看到,FPGA做这个特定运算速度比CPU块,能帮助加速。
以图像处理中常见的Sobel算法来说,在FPGA上的实现可以参考A FPGA based implementation of Sobel edge detection,达到这个速度已经可以与opencv的sobel速度媲美了。
在一些经典算法中,比如椭圆检测,也是可以利用FPGA的,比如论文《Effective ellipse detection method in limited-performance embedded system》,就是FPGA和DSP的一种结合,利用FPGA进行预处理,利用DSP处理串行操作,以实现实时处理。
目前来说,显卡的性能受制于能耗和物理极限,每次更新换代,感觉并没有那么高的性能提升,多卡缓解这类问题,但是功耗实在太大。
而光衍射深度神经网络,提出了一种非常新奇的思想。Science发表了加州大学洛杉矶分校(UCLA)研究人员的最新研究:All-optical machine learning using diffractive deep neural networks,他们使用 3D 打印打造了一套 “全光学” 人工神经网络,可以分析大量数据并以光速识别目标。它使用来自物体的光散射来识别目标。研究团队先用计算机进行模拟,然后用 3D 打印机打造出 8 平方厘米的聚合物层。每个晶圆表面都是不平整的,目的是为了衍射来自目标的光线。
以手写数字识别为例,设计了一个五层的DNN,训练之后测试,实现了91.75%的分类精度。根据这些数值结果,我们将这个5层的DNN 设计3D打印出来,每一层的面积为8cm×8cm,然后在衍射网络的输出平面定义10个检测器区域。
光学电路深度学习是一项重大突破,光的延迟非常低,所需的功耗也是极低,如果未来的加工工艺更加成熟,未来将是一个非常帮的突破。
参考资料:
1 Science重磅!用光速实现深度学习,跟GPU说再见
我认为作为科研工作者,应该掌握一些基本的加速方法,比如多线程、CUDA之类。很多专业,跑一些算法,用Matlab跑好几分钟甚至好几个小时才能出结果,大大降低了科研效率。这些开发语言完全可以套上C++的外壳进行加速。
在工业机械领域,很看重实时性,如果自己设计出的算法效果又好,又能落地,那多完美。
当然,大多数的加速方法破坏了面向对象这个性质,高度过程化。所以,一般是当自己的程序确定不改了,实验做完了,再去考虑加速,让你的效果更上一层楼。