基础数据结构

为了解析网络配置参数,DarkNet 中定义了三个关键的数据结构类型。list类型变量保存所有的网络参数, section类型变量保存的是网络中每一层的网络类型和参数, 其中的参数又是使用list类型来表示。kvp键值对类型用来保存解析后的参数变量和参数值。

  • list类型定义在src/list.h中,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 链表上的节点
typedef struct node{
void *val;
struct node *next;
struct node *prev;
} node;

//双向链表
typedef struct list{
int size; //list的所有节点个数
node *front; //list的首节点
node *back; //list的普通节点
} list;
  • section 类型定义在src/parser.c文件中,代码如下:
1
2
3
4
5
// 定义section
typedef struct{
char *type;
list *options;
}section;
  • kvp 键值对类型定义在src/option_list.h文件中,具体定义如下:
1
2
3
4
5
6
// kvp 键值对
typedef struct{
char *key;
char *val;
int used;
} kvp;

在Darknet的网络配置文件(.cfg结尾)中,以[开头的行被称为一个段(section)。所有的网络配置参数保存在list类型变量中,list中有很多的section节点,每个section中又有一个保存层参数的小list,整体上出现了一种大链挂小链的结构。大链的每个节点为section,每个section中包含的参数保存在小链中,小链的节点值的数据类型为kvp键值对,这里有个图片可以解释这种结构。

我们来大概解释下该参数网,首先创建一个list,取名sections,记录一共有多少个section(一个section存储了某一网络层所需参数);然后创建一个node,该nodevoid类型的指针指向一个新创建的section;该sectionchar类型指针指向.cfg文件中的某一行(line),然后将该sectionlist指针指向一个新创建的node,该nodevoid指针指向一个kvp结构体,kvp结构体中的key就是.cfg文件中的关键字(如:batch,subdivisions等),val就是对应的值;如此循环就形成了上述的参数网络图。

解析并保存网络参数到链表中

读取配置文件由src/parser.c中的read_cfg()函数实现:

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
/*
* 读取神经网络结构配置文件(.cfg文件)中的配置数据, 将每个神经网络层参数读取到每个
* section 结构体 (每个 section 是 sections 的一个节点) 中, 而后全部插入到
* list 结构体 sections 中并返回
*
* \param: filename C 风格字符数组, 神经网络结构配置文件路径
*
* \return: list 结构体指针,包含从神经网络结构配置文件中读入的所有神经网络层的参数
* 每个 section 的所在行的开头是 ‘[’ , ‘\0’ , ‘#’ 和 ‘;’ 符号开头的行为无效行, 除此
*之外的行为 section 对应的参数行. 每一行都是一个等式, 类似键值对的形式.

*可以看到, 如果某一行开头是符号 ‘[’ , 说明读到了一个新的 section: current, 然后第1508行
*list_insert(options, current);` 将该新的 section 保存起来.

*在读取到下一个开头符号为 ‘[’ 的行之前的所有行都是该 section 的参数, 在第 1518 行
*read_option(line, current->options) 将读取到的参数保存在 current 变量的 options 中.
*注意, 这里保存在 options 节点中的数据为 kvp 键值对类型.

*当然对于 kvp 类型的参数, 需要先将每一行中对应的键和值(用 ‘=’ 分割) 分离出来, 然后再
*构造一个 kvp 类型的变量作为节点元素的数据.
*/
list *read_cfg(char *filename)
{
FILE *file = fopen(filename, "r");
//一个section表示配置文件中的一个字段,也就是网络结构中的一层
//因此,一个section将读取并存储某一层的参数以及该层的type
if(file == 0) file_error(filename);
char *line;
int nu = 0; //当前读取行号
list *sections = make_list(); //sections包含所有的神经网络层参数
section *current = 0;//当前读取到某一层
while((line=fgetl(file)) != 0){
++ nu;
strip(line); //去除读入行中含有的空格符
switch(line[0]){
// 以 '[' 开头的行是一个新的 section , 其内容是层的 type
// 比如 [net], [maxpool], [convolutional] ...
case '[':
current = (section*)xmalloc(sizeof(section));
list_insert(sections, current);
current->options = make_list();
current->type = line;
break;
case '\0': //空行
case '#': //注释
case ';': //空行
free(line); // 对于上述三种情况直接释放内存即可
break;
default:
// 剩下的才真正是网络结构的数据,调用 read_option() 函数读取
// 返回 0 说明文件中的数据格式有问题,将会提示错误
if(!read_option(line, current->options)){
fprintf(stderr, "Config file error line %d, could parse: %s\n", nu, line);
free(line);
}
break;
}
}
//关闭文件
fclose(file);
return sections;
}

链表的插入操作

保存section和每个参数组成的键值对时使用的是list_insert()函数, 前面提到了参数保存的结构其实是大链(节点为section)上边挂着很多小链(每个section节点的各个参数)。list_insert()函数实现了链表插入操作,该函数定义在src/list.c文件中:

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
/*
* 简介: 将 val 指针插入 list 结构体 l 中,这里相当于是用 C 实现了 C++ 中的
* list 的元素插入功能
*
* 参数: l 链表指针
* val 链表节点的元素值
*
* 流程:list 中保存的是 node 指针. 因此,需要用 node 结构体将 val 包裹起来后才可以
* 插入 list 指针 l 中
*
* 注意: 此函数类似 C++ 的 insert() 插入方式;
* 而 opion_insert() 函数类似 C++ map 的按值插入方式,比如 map[key]= value
*
* 两个函数操作对象都是 list 变量, 只是操作方式略有不同。
*/
void list_insert(list *l, void *val)
{
node* newnode = (node*)xmalloc(sizeof(node));
newnode->val = val;
newnode->next = 0;
// 如果 list 的 back 成员为空(初始化为 0), 说明 l 到目前为止,还没有存入数据
// 另外, 令 l 的 front 为 new (此后 front 将不会再变,除非删除)
if(!l->back){
l->front = newnode;
newnode->prev = 0;
}else{
l->back->next = newnode;
newnode->prev = l->back;
}
l->back = newnode;
++l->size;
}

可以看到, 插入的数据都会被重新包装在一个新的node : 变量new中,然后再将这个节点插入到链表中。网络结构解析到链表中后还不能直接使用, 因为想使用任意一个参数都不得不每次去遍历整个链表, 这样就会导致程序效率变低, 所以最好的办法是将其保存到一个结构体变量中, 使用的时候按照成员进行访问。复杂度从$O(n)->O(1)$。

将链表中的网络结构保存到network结构体

  • 首先来看看network结构体的定义,在include/darknet.h中:
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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// 定义network结构体
typedef struct network {
int n; //网络的层数,调用make_network(int n)时赋值
int batch; //一批训练中的图片参数,和subdivsions参数相关
uint64_t *seen; //目前已经读入的图片张数(网络已经处理的图片张数)
int *t;
float epoch; //到目前为止训练了整个数据集的次数
int subdivisions;
layer *layers; //存储网络中的所有层
float *output;
learning_rate_policy policy; // 学习率下降策略
int benchmark_layers;
// 梯度下降法相关参数
float learning_rate; //学习率
float learning_rate_min; //学习率最小值
float learning_rate_max; //学习率最大值
int batches_per_cycle; //
int batches_cycle_mult;
float momentum;
float decay;
float gamma;
float scale;
float power;
int time_steps;
int step;
int max_batches;
int num_boxes;
int train_images_num;
float *seq_scales;
float *scales;
int *steps;
int num_steps;
int burn_in;
int cudnn_half;
// ADAM优化方法相关策略
int adam;
float B1;
float B2;
float eps;

int inputs;
int outputs;
int truths;
int notruth;
int h, w, c;
int max_crop;
int min_crop;
float max_ratio;
float min_ratio;
int center;
int flip; // horizontal flip 50% probability augmentaiont for classifier training (default = 1)
int blur;
int mixup;
float label_smooth_eps;
int resize_step;
int letter_box;
float angle;
float aspect;
float exposure;
float saturation;
float hue;
int random;
int track;
int augment_speed;
int sequential_subdivisions;
int init_sequential_subdivisions;
int current_subdivision;
int try_fix_nan;
//darknet 为每个 GPU 维护一个相同的 network, 每个 network 以 gpu_index 区分
int gpu_index;
tree *hierarchy;

//中间变量,用来暂存某层网络的输入(包含一个 batch 的输入,比如某层网络完成前向,
//将其输出赋给该变量,作为下一层的输入,可以参看 network.c 中的forward_network()
float *input;
// 中间变量,与上面的 input 对应,用来暂存 input 数据对应的标签数据(真实数据)
float *truth;
// 中间变量,用来暂存某层网络的敏感度图(反向传播处理当前层时,用来存储上一层的敏
//感度图,因为当前层会计算部分上一层的敏感度图,可以参看 network.c 中的 backward_network() 函数)
float *delta;
// 网络的工作空间, 指的是所有层中占用运算空间最大的那个层的 workspace_size,
// 因为实际上在 GPU 或 CPU 中某个时刻只有一个层在做前向或反向运算
float *workspace;
// 网络是否处于训练阶段的标志参数,如果是则值为1. 这个参数一般用于训练与测试阶段有不
// 同操作的情况,比如 dropout 层,在训练阶段才需要进行 forward_dropout_layer()
// 函数, 测试阶段则不需要进入到该函数
int train;
// 标志参数,当前网络的活跃层
int index;
//每一层的损失,只有[yolo]层有值
float *cost;
float clip;

#ifdef GPU
//float *input_gpu;
//float *truth_gpu;
float *delta_gpu;
float *output_gpu;

float *input_state_gpu;
float *input_pinned_cpu;
int input_pinned_cpu_flag;

float **input_gpu;
float **truth_gpu;
float **input16_gpu;
float **output16_gpu;
size_t *max_input16_size;
size_t *max_output16_size;
int wait_stream;

float *global_delta_gpu;
float *state_delta_gpu;
size_t max_delta_gpu_size;
#endif
int optimized_memory;
size_t workspace_size_limit;
} network;
  • network结构体分配内存空间,函数定义在src/network.c文件中,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//为network结构体分配内存空间
network make_network(int n)
{
network net = {0};
net.n = n;
net.layers = (layer*)xcalloc(net.n, sizeof(layer));
net.seen = (uint64_t*)xcalloc(1, sizeof(uint64_t));
#ifdef GPU
net.input_gpu = (float**)xcalloc(1, sizeof(float*));
net.truth_gpu = (float**)xcalloc(1, sizeof(float*));

net.input16_gpu = (float**)xcalloc(1, sizeof(float*));
net.output16_gpu = (float**)xcalloc(1, sizeof(float*));
net.max_input16_size = (size_t*)xcalloc(1, sizeof(size_t));
net.max_output16_size = (size_t*)xcalloc(1, sizeof(size_t));
#endif
return net;
}

src/parser.c中的parse_network_cfg()函数中,从net变量开始,依次为其中的指针变量分配内存。由于第一个段[net]中存放的是和网络并不直接相关的配置参数, 因此网络层的数目为sections->size - 1,即:network *net = make_network(sections->size - 1);

  • 将链表中的网络参数解析后保存到network结构体,配置文件的第一个段一定是[net]段,该段的参数解析由parse_net_options()函数完成,函数定义在src/parser.c中。之后的各段都是网络中的层。比如完成特定特征提取的卷积层,用来降低训练误差的shortcur层和防止过拟合的dropout层等。这些层都有特定的解析函数:比如parse_convolutional(), parse_shortcut()parse_dropout()。每个解析函数返回一个填充好的层l,将这些层全部添加到network结构体的layers数组中。即是:net->layers[count] = l;另外需要注意的是这行代码:if (l.workspace_size > workspace_size) workspace_size = l.workspace_size;,其中workspace代表网络的工作空间,指的是所有层中占用运算空间最大那个层的workspace。因为在CPU或GPU中某个时刻只有一个层在做前向或反向传播。输出层只能在网络搭建完毕之后才可以确定,输入层需要考虑batch_size的因素,truth是输入标签,同样需要考虑batch_size的因素。具体层的参数解析后面专门写一篇推文来帮助理解。

  • 到这里,网络的宏观解析结束。parse_network_cfg()(src/parser.c中)函数返回解析好的network类型的指针变量。

为啥需要中间数据结构缓存?

这里可能有个疑问,为什么不将配置文件读取并解析到network结构体变量中, 而要使用一个中间数据结构来缓存读取到的文件呢?因为,如果不使用中间数据结构来缓存. 将读取和解析流程串行进行的话, 如果配置文件较为复杂, 就会长时间使文件处于打开状态。如果此时用户更改了配置文件中的一些条目, 就会导致读取和解析过程出现问题。分开两步进行可以先快速读取文件信息到内存中组织好的结构中, 这时就可以关闭文件. 然后再慢慢的解析参数。这种机制类似于操作系统中断的底半部机制, 先处理重要的中断信号, 然后在系统负荷较小时再处理中断信号中携带的任务。