darknet接口设计思路-lmy
darknet接口设计思路
概述
经过分析,我首先承认原作者提供的接口具有普适性、易读性和示范性,但是终归只是起示范作用,在性能方面是低效的,主要体现在下面几个方面
-
强调顺序性:充分利用了框架中定义的函数,按照步骤一步一步的进行参数传递,计算,结果返回,然而这中间很多步骤存在大量的冗余计算。
-
框架中边缘功能API实现低效:为了充分发挥平台的算力,我们需要尽量减少额外计算的时间,让GPU进行网络传播计算的时间占比更大,然而,darknet这个框架的虽然充分利用了cuda+cudnn来充分发挥GPU算力,但是对图像预处理,网络加载等工作的实现效率极低
eg:
1
2
3
image im = load_image_color(path,0,0);
image sized = letterbox_image(im, net->w, net->h);
这两行代码是每一次图像读取进来后进行处理的函数,之后sized这个图片才会被作为网络输入,关注一下他的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
image load_image_color(char *filename, int w, int h)
{
return load_image(filename, w, h, 3);
}
// ... ...
image load_image(char *filename, int w, int h, int c) //这里可以搞个多线程
{
#ifdef OPENCV
image out = load_image_cv(filename, c);
#else
image out = load_image_stb(filename, c);
#endif
if((h && w) && (h != out.h || w != out.w)){
image resized = resize_image(out, w, h);
free_image(out);
out = resized;
}
return out;
}
// ... ...
image load_image_stb(char *filename, int channels) //无论如何,都逃不过for循环内存拷贝的噩梦
{
int w, h, c;
unsigned char *data = stbi_load(filename, &w, &h, &c, channels);
if (!data) {
fprintf(stderr, "Cannot load image \"%s\"\nSTB Reason: %s\n", filename, stbi_failure_reason());
exit(0);
}
if(channels) c = channels;
int i,j,k;
image im = make_image(w, h, c);
for(k = 0; k < c; ++k){
for(j = 0; j < h; ++j){
for(i = 0; i < w; ++i){
int dst_index = i + w*j + w*h*k;
int src_index = k + c*i + c*w*j;
im.data[dst_index] = (float)data[src_index]/255.;
}
}
}
free(data);
return im;
}
首先这是整个load_image的过程,这里我选择展示了他使用stb API的部分,使用opencv的算法大同小异,我总结了一下他的特点:
1
* 为了API通用性而进行的连续函数转发:
中间函数很可能还被其他很多函数用到,所以为了减少重复编程,作者采用了多层转发的方式,加载一张的图片,就要进行四层函数调用,而且返回值竟然都是结构体,效率不敢恭维
1
* 极度低效的图片存储格式转换:
这个是整个框架用到比赛中最为致命的缺陷,arm架构本来就没有x86那样单核性能强大,这里还将所有的访存和计算全部交给一个线程来完成,而且更为致命的是先读,再计算,最后写,而且读出的位置和写入的位置完全不相邻,这样编译器的自动循环展开都不好用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
image letterbox_image(image im, int w, int h)
{
int new_w = im.w;
int new_h = im.h;
if (((float)w/im.w) < ((float)h/im.h)) {
new_w = w;
new_h = (im.h * w)/im.w;
} else {
new_h = h;
new_w = (im.w * h)/im.h;
}
image resized = resize_image(im, new_w, new_h);
image boxed = make_image(w, h, im.c);
fill_image(boxed, .5);
//int i;
//for(i = 0; i < boxed.w*boxed.h*boxed.c; ++i) boxed.data[i] = 0;
embed_image(resized, boxed, (w-new_w)/2, (h-new_h)/2);
free_image(resized);
return boxed;
}
// ... ...
image resize_image(image im, int w, int h)
{
image resized = make_image(w, h, im.c);
image part = make_image(w, im.h, im.c);
int r, c, k;
float w_scale = (float)(im.w - 1) / (w - 1);
float h_scale = (float)(im.h - 1) / (h - 1);
//为了保证访存效率,都是按照行遍历
//重置宽度
for(k = 0; k < im.c; ++k){
for(r = 0; r < im.h; ++r){
for(c = 0; c < w; ++c){
float val = 0;
if(c == w-1 || im.w == 1){
val = get_pixel(im, im.w-1, r, k);
} else { //重置方式
float sx = c*w_scale; //利用新图片中的坐标找到原图中坐标的位置(一般不是整数)
int ix = (int) sx; //将分数坐标分成整数和小数部分
float dx = sx - ix;
val = (1 - dx) * get_pixel(im, ix, r, k) + dx * get_pixel(im, ix+1, r, k); //整数部分作为真正坐标,小数部分作为其和相邻像素的混合系数
}
set_pixel(part, c, r, k, val);
}
}
}
//重置高度
for(k = 0; k < im.c; ++k){
for(r = 0; r < h; ++r){
float sy = r*h_scale;
int iy = (int) sy;
float dy = sy - iy;
for(c = 0; c < w; ++c){ //将主数据放入
float val = (1-dy) * get_pixel(part, c, iy, k);
set_pixel(resized, c, r, k, val);
}
if(r == h-1 || im.h == 1) continue;
for(c = 0; c < w; ++c){//将额外数据加上
float val = dy * get_pixel(part, c, iy+1, k);
add_pixel(resized, c, r, k, val);
}
}
}
free_image(part);
return resized;
}
// ... ...
void embed_image(image source, image dest, int dx, int dy)
{
int x,y,k;
for(k = 0; k < source.c; ++k){
for(y = 0; y < source.h; ++y){
for(x = 0; x < source.w; ++x){
float val = get_pixel(source, x,y,k);
set_pixel(dest, dx+x, dy+y, k, val);
}
}
}
}
然后是letterbox_image的流程,实现的功能是:
1
2
* 使用二线性插值法将图片的长边放缩到网络输入层的尺寸
* 将图片嵌入和网络输入层一样大的一张灰色图片
整个实现槽点太多:
1
2
3
* 低效的函数调用:仍然是老问题,每个函数仅完成一项工作,返回一个结构体
* 滥用单核性能:依旧是满眼for for for
* 大量中间结果:整个过程中创建了许多image结构体,很多就在用完后就直接释放掉了,这个又申请又释放的过程耽误时间
综上所述,这就是我了解的框架边缘API的不足,上述函数全部定义在iamge.c中
早先版本中存在的问题
简要评价一下我们最初版本的代码:
- 不完全符合规则:
我们并没有利用官方给出框架的输入作为深度学习框架的输入,也没有将识别结果存放到官方给出的结果数组中。我们仅仅从官方框架那里获得了文件名,并利用文件名重新读出了图片,完成识别,把结果存在自己的数组中,然后在将官方给出的输出函数中输入参数掉包
-
使用了低效的边缘API:我们没有对作者给出的图像预处理算法进行任何修改,依旧是低效的样子,无法提高并行性
-
Python与C语言格式转化耽误时间:我们将深度学习框架编译成so,并使用Python的ctypes库进行调用,这中间存在着大量的格式转换,每次调用都要进行,降低了效率
在充分考虑CPU利用的基础上,对整个比赛代码的边缘部分进行了重新设计
整体流程设计
简单描述一下我设计的计算流程
- 为了避免低效的ctypes库调用,我选择将C的代码包装成Python扩展,实现Python与我们框架的无缝衔接
- 重新利用了官方提供的网络输入,使用并行编程完成图片的格式转换
- 网络传播计算和图片预处理流水工作,即CPU和GPU计算的重叠
- 识别结果通过Python提供的相关函数进行格式转化,从C语言环境返回到Python环境,存入官方给出的输出数组
并行性设计
使用多线程对Python数组进行处理,转换成网络输入的大小,省去中间一切函数调用
实现是3线程或6线程,完成对网络输入的格式转换
- 工作分配是按通道进行分配,每个通道1~2个线程
- 每个线程首先从Python数组中取出自己负责区域的像素,按照imge结体的内部各式存入本地缓冲区
- 利用缓冲区中的数据完成该区域的二线性插值计算,将结果放入线程间共享的输入缓冲区,也就是作为结果的image结构体,交给GPU进行运算
6线程任务分配细节:
-
分到同一个通道的两个线程,按照行将图片分成两半(image结构体按通道、行、列的顺序存取),最大程度避免访存冲突和伪共享现象
-
二线性插值运算的问题:一个线程完成自己负责区域的二线性插值运算的时候,不会用到其他线程的数据,这是由我们网络的尺寸和图片尺寸决定的。
一个通道中,一号线程负责0~116行的计算,而根据二线性插值法,其对应到原图的0~179行;二号线程对应到180~233。正好都是它们自己的缓冲区中的数据
流水方式设计
整个接口中用到了如下线程:
- Python主线程,负责输入图片和获取结果,本质上就是
/usr/bin/python这个程序 - 图像预处理线程3~6个,完成上一节提到的工作
- 识别线程1个,因为GPU只有一个(也很难说多加一个能不能提高性能),等待图片识别线程完成工作,对图片进行识别
之间的同步关系如下:
- 主线程进入本模块,将Python数组(一个batch的图片)的数据域提交给图像预处理线程,开始工作,开始等待识别线程完成工作,取出结果返回
- 识别线程等待主线程输入,得到输入后开始工作,完成一张图片后紧接着开始下一张,直到batch处理完成,等待主线程信号。在处理图片的同时记录每个线程完成图片的情况,最后一个完成一张图片的线程,需要把这张图片交给识别线程
- 识别线程只等待预处理线程的信号,拿到图片开始处理,返回结果,继续等待
上面这个过程已经实现了初级的流水,但是经过测量,预处理线程的速度实在太快,耗时最长的成了官方代码中的cv.imread。为了进一步提高性能,我设计了一个可能违反规则的版本(论违规程度应该不超过最初版本),与上面描述的过程的差别如下:
- 主线程不再等待识别线程完成,而是直接返回,不向官方提供的结果数组中存结果
- 识别线程按照Python数组格式将识别结果存入模块内的全局数组
- 官方调用写结果函数的时候,官方的结果数组与模块中存储的数组掉包
这样就实现了全面的图片读取+预处理和识别的全面并行,性能有了不小的提高
下一步工作展望
我认为框架的边缘API尚且如此,核心的运算API性能可能也有提高的余地,尤其是各个层forward和backward的计算中可能还有优化的空间