前面的专栏文章中介绍了基于RISC-V QEMU的Linux操作系统搭建方法,并在其上完成了YOLOv3算法的前向传播过程。该过程基于QEMU的指令集仿真,并且仍然是在操作系统上完成的,初步验证了算法移植到RISC-V嵌入式平台的可行性。本文将会进一步剖析Darknet网络层源码的结构,解释代码的工作过程。然后在此基础上介绍如何在C910 SMART仿真平台上进行算法各个类型的单层前向传播过程的仿真。SMART作为平头哥提供的CPU仿真平台,提供了目标处理器及其SOC集成的RTL仿真模型,能够为针对RISC-V处理器开发的嵌入式程序提供精确的指令级仿真。相比上一篇的QEMU验证,C910 SMART上的程序仿真将对算法在嵌入式裸机平台上的可实现性做出更加准确直接的评估。
本文将会使用的算法版本为YOLOv3,其原始的开源代码可以从Darknet官方Github项目下载。
YOLOv3网络层源码的构建方式
Darknet-53使用C源码搭建网络的底层结构,其核心是定义在darknet.h
的两个struct结构体layer
和network
(在较早版本的代码中,这两个结构体则分别被定义在源文件layer.h
和network.h
中)。由于C语言中结构体的局限性,它不能自由地使用继承和派生之类的特性,也无法直接在结构体内定义成员函数,因此算法源码使用了一种较为简单粗暴的方式来为网络层进行建模,即用一个通用的结构体layer
来定义每一种可能用到的具体网络层,其中基本存储了该层可能用到的一切信息,包括网络类型、输入输出尺寸、用于存放该层所有权重系数、偏置、输出值和敏感度图(delta值)的动态数组以及其他的配置信息。这种定义方法会造成存储空间的浪费,因为某些参量仅仅对特定层有意义,因而在绝大多数层中都用不到,但是它们仍然被定义在所有层中并将在实例化时被分配存储空间。
现以最常用的卷积层为例对layer
结构体中常用的成员变量解释如下:
typedef layer convolutional_layer;
struct layer{
LAYER_TYPE type; //枚举变量,网络层类型,共支持30种不同类型的网络层
ACTIVATION activation; //枚举变量,网络激活函数类型,v3总是使用ReLU或者线性激活
int batch_normalize; //非0时进行Batch Normalization
int batch; //一个batch含有的图片张数
int stride; //卷积步长
int pad; //padding值
int n; //卷积核数量
int size; //卷积核尺寸
int h,w,c; //该层输入的高、宽、通道数,与上一层的输出尺寸相同
int out_h,out_w,out_c; //该层输出的高、宽、通道数,对于卷积层,out_c = n
//out_h(或w) = (h(或w)+2*pad-size)/stride + 1
int nweights; //卷积核的权重值个数,等于n*c*size*size
int nbiases; //偏置值个数,等于n
int inputs; //输入的数据量,等于h*w*c
int outputs; //输出的数据量,等于out_h*out_w*out_c
float* weights; //该层所有权重系数值,平铺的一维数组,长度为nweights
float* biases; //该层所有偏置系数值,平铺的一维数组,长度为nbiases
float* output; //该层所有输出值,平铺的一维数组,长度为outputs
void (*forward) (struct layer, struct network);
void (*backward) (struct layer, struct network);
void (*update) (struct layer, int, float, float, float);
//etc.
}
其他没有提及的成员变量对于卷积层的前向传播不重要,读者可自行查看源码中的定义。可以看到,源码使用函数指针来解决C结构体无法定义成员函数的问题,三个函数分别用于进行前向传播、反向传播和权重更新,在实例化网络层时,程序解析网络层的类型后,将例化的结构体中的函数指针与对应的函数相关联,从而使得每个层获得正确的传播函数。
由于每层的数据规模不同,结构体将用于存放数据的变量声明为动态数组,在实例化时计算出具体的数据尺寸后用malloc/calloc方法为其分配空间。
值得注意的是,layer
仅储存该层的输出数据(数组output[]中),却不储存输入数据。输入被存放在另一个结构体network
中,该结构将所有的layer
有序组织起来,串联成为完整的Darknet网络结构,并在计算过程中作为中转站向不同的层传递数据。抛开与layer
定义相同的变量,该结构的部分重要变量说明如下:
struct network{
float epoch;
int subdivisions;
//... //此部分均为训练参数,在配置文件中赋值
int n; //网络的总层数,对YOLOv3为107
int index; //当前活跃层的标志
layer* layers; //顺序存储所有的网络层,一维数组,长度为n
float* output; //当前层的输出
float* input; //当前层的输入,即上一层的输出
float* workspace; //缓存中间数据的工作空间
//etc.
}
在程序一次的执行过程中,network
结构体只被实例化一个,而layer
层的数量则由配置文件决定。例如对YOLOv3而言结构体需要实例化107个独立的layer
层,它们按照先后顺序存放在network.layers
数组中。网络的传播过程也就是network
结构体在该数组上面顺次遍历的过程,index
变量标记了正在参与计算的活跃层,前一层网络计算所得的layer.output
被赋值给network.input
作为新一层的输入,新一层的输出又再次更新network.input
,如此往复直到计算结束。为了给计算提供存放中间数据的空间,结构体定义了一个名为workspace
的动态数组,其长度为所有层当中out_h*out_w*c*size*size
的最大值,这个值保证了任何层的任何中间数据都可以被放进workspace
数组中,因此不会发生溢出越界。
基于上述结构,可以将YOLO算法前向传播的函数调用过程简要总结为下图:
从命令行解析出将要执行一次前向传播后,load_network
函数读入网络的配置,实例化唯一的一个network
结构体,并依照配置信息执行初始化。初始化过程大体上分两个步骤:
- 第一步通过
parse_network_cfg
函数计算出每一个网络层的具体参数值,如out_w, out_h, out_c, nweights, nbiases, inputs, outputs
等取值均可以在这一步被计算出来,函数根据这些值确定layer
结构体中动态数组的尺寸,并使用malloc/calloc方法为它们分配存储空间。各层使用的函数指针也在这一步中与正确的传播函数相关联。 - 第二步发生在整个
network.layers
数组被正确初始化后,此时所有动态数组都已经被分配了空间从而做好了存放数据的准备,因此load_weights
函数会将预训练的权重加载到各层的正确位置,这样Darknet-53的网络结构就被有序地加载到内存中了。
照此思路,现在只要保证每种类型都拥有正确的网络传播函数即可。查看配置文件yolov3.cfg
可知YOLOv3算法使用了5种类型的网络层:
-YOLOv3
-convolutional # 卷积层
-upsample # 上采样层
-shortcut # 捷径层
-route # 路由层
-yolo # YOLO层
注意到Darknet在组织输入输出及权重数据的时候本身就已经把它们平铺成一维数组了,因此可以方便地调用矩阵相关函数进行计算而不需再对数据形式进行大幅度调整。以卷积层(convolutional)为例,在默认其batch
和groups
参数均为1的前提下,它的前向传播函数可以简化为:
void forward_convolutional_layer(convolutional_layer l, network net) //简化版本
{
fill_cpu(l.outputs*l.batch, 0, l.output, 1); //初始化l.output数组为全0
int m = l.n; //矩阵A和C的行数
int n = l.out_w*l.out_h; //矩阵B和C的列数
int k = l.size*l.size*l.c //矩阵A的列数,矩阵B的行数
float *a = l.weights; //矩阵A的起始地址
float *b = net.workspace; //矩阵B的起始地址
float *c = l.output; //矩阵C的起始地址
float *im = net.input;
if (l.size == 1)
b = im;
else //调用im2col调整数据的组织方式
im2col_cpu(im, l.c/l.groups, l.h, l.w, l.size, l.stride, l.pad, b);
gemm(0,0,m,n,k,1,a,k,b,n,1,c,n); //调用矩阵运算函数,完成卷积运算
if(l.batch_normalize) //BN或者直接加偏置
forward_batchnorm_layer(l, net);
else
add_bias(l.output, l.biases, l.batch, l.n, l.out_h*l.out_w);
activate_array(l.output, l.outputs*l.batch, l.activation); //激活
}
最终的矩阵运算由定义在gemm.c
当中的gemm
函数完成,它的函数原型为:
void gemm(
int TA, int TB, int M, int N, int K,
float ALPHA, float *A, int lda,
float *B, int ldb, float BETA,
float *C, int ldc
);
其计算结果为C=ALPHA*A*B+BETA*C
,其中ALPHA和BETA为常系数,A、B、C为矩阵,结果存放在矩阵C中。关于这个矩阵运算函数的实现过程,互联网上已经可以找到相当详细的讲解,这里不再赘述。
YOLOv3在C910 SMART上的单层仿真
接下来将介绍如何在C910 SMART上进行YOLOv3五种网络层的前向传播函数单层仿真。
SMART的数据组织和仿真过程
对于C910 SMART平台的总体文件结构可以参看专栏前面的文章。总体来说,平台提供其CPU核及外围总线模块的RTL模型,通过正确编写testbench文件./tb/tb.v
即可进行vcs或irun仿真。具体而言,SMART进行仿真的原理为:
- 首先调用RISC-V工具链对C源码进行编译和链接。平头哥定制的工具链存放在目录
./tools/toolchain/RV64GC
下,对于裸机嵌入式平台,需要特别关注其链接过程。查看链接脚本./lib/linker.lcf
可知,平台默认的链接脚本把内存组织为两段,0x40000以下的地址存放代码段(.text)和只读数据段(.rodata等),0x40000至0xc0000存放其他已初始化和未初始化的数据(.data .bss和.COMMON等)。链接脚本的部分内容如下(省略了SECTIONS
中的内容):
MEMORY
{
MEM1(RWX) : ORIGIN = 0x00000000, LENGTH = 0x40000
MEM2(RWX) : ORIGIN = 0x00040000, LENGTH = 0xc0000
}
__kernel_stack = 0xee000 ;
ENTRY(__start)
SECTIONS {
# 此略
}
- 其次使用脚本
./tools/Srec2vmem
将编译生成的elf文件分为两个文件inst.pat
和data.pat
,两个文件存放的数据分别对应上述链接脚本中的MEM1和MEM2两段,文件每行储存16个字节的16进制数,以便testbench将数据载入内存。 - 以vcs仿真为例,tb中的部分初始化代码如下图所示。先将
inst.pat
和data.pat
当中的数据载入两个临时数组mem_inst_temp
和mem_data_temp
,然后在一个循环中顺序将数组数据放入例化的内存模型中,观察./rtl/platform/common/soc.v
文件可知模块RTL_MEM.ram0.mem[0]
即为总线上逻辑地址为0x0的内存块,ram0~ram15共同构成连续的16字节内存空间,因此模块RTL_MEM.ram0.mem[32'h4000]
即为逻辑地址为0x40000(即16*0x4000)的内存块,这恰与链接脚本中的分段相匹配,由此二进制文件的各段在仿真初期被正确加载到了内存的RTL模型上。
- CPU核RTL模型内部的控制逻辑将PC置为初始地址,从内存中逐条取指,从而开始了嵌入式C程序的仿真。
输入数据的载入
嵌入式裸机程序没有操作系统,通常不能用scanf,fscanf
等函数来接受外部输入。对于网络层输入和权重这一类规模较大的输入数据通常有两种处理方式,一种是预先把它们显式地存放在源码中以对变量进行初始化,这样编译器会自动把它们分配到数据段中。但这样将会使代码显得臃肿,而且若链接脚本中划分的段空间不够大,将会造成数据溢出。以YOLOv3中的第一个卷积层为例,它的输入为1108992个单精度浮点数,这个规模已经超出了linker.lcf
中MEM2允许的范围。因此采用另一种方法,仿照前面testbench装载指令的思路,把要初始化变量的值事先映射到内存RTL模型中的特定位置,然后在C源码中直接为这些变量指定地址,即可将变量名和它们在内存中的值关联起来。为此需要略微修改SMART上的总线模型,查看文件.rtlplatformambaaxiaxi_interconnect128.v
中的地址分段如下:
parameter SRAM_START = 40'h0000_0000;
parameter SRAM_END = 40'h01ff_ffff;
parameter ERR1_START = 40'h0200_0000;
parameter ERR1_END = 40'h0fff_ffff;
parameter APB_START = 40'h10000000;
parameter APB_END = 40'h1fffffff;
parameter ERR2_START = 40'h20000000;
parameter ERR2_END = 40'hff_ffffffff;
可知当前总线上挂载了4个不同的AXI模块,axi_interconnect128
模块接受CPU的读写地址作为输入,该模块内部依照上述地址段的划分产生选通信号作为输出,以决定总线上这4个模块中的哪一个会被选通。实际上仿真模型中只有第一段(0x0~0x01ff_ffff)被实际使用了,其他几个模块仅起到占位作用。因此可以重写一个AXI模块将其挂载到地址0x0200_0000上,只要做好地址转换后,其内部的内存模型起始地址就可以映射到ERR1_START
上。具体而言,进行地址转换时可以使用一种取巧的方法,由于平台已经使用了功能正常的x_axi_slave128
模块且已知其基地址被映射到SRAM_START
,则可以简单地复用该模块内部的控制逻辑,然后对地址信号作如下的转换:
assign mem_addr_tmp[39:0] = mem_addr[39:0] - 40'h0200_0000;
用mem_addr_tmp
替代原有的地址线作为内存模型的输入,即可完成地址映射。此后,对C源码进行如下形式的修改(以卷积层为例):
#define DATA_BASE_ADDR (0x02000000)
volatile float *input = DATA_BASE_ADDR;
volatile float *weights = input + l.inputs;
volatile float *biases = weights + l.nbiases;
volatile float *output = biases + l.nweights;
volatile float *workspace = output + l.outputs;
l.output = output;
l.weights = weights;
l.biases = biases;
net.worksapce = workspace;
net.input = input;
这样,只要把待初始化的数据按照input, weights, biases
的顺序有序通过testbench初始化到内存模型中,就可以使这些变量获得正确的数值。这个方法还同时为数组output
和workspace
指定了起始地址,考虑的卷积层的输出规模(以第一层为例)达到了11829248个浮点数,即大约40MB,workspace
数组原则上则要更大,原有的数据堆是无法存放这么大的数组的,因此这个方法有效地避免了仿真过程中的数据溢出问题。
结果的输出和比较
C910 SMART重写了_write
桩函数以支持标准输出printf
,其实现原理非常简单粗暴,即:
#define write_char(x) asm("li x13, 0x0003fff8; sw %0, 0(x13)" : :"r" (x) )
int _write (int fd, void *buf, size_t count)
{
uint8_t * buf_tmp = (uint8_t *)buf;
write_char(buf_tmp[0]);
}
桩函数通过一条内联汇编指令,把要打印的字符写到地址线0x3fff8上面,然后testbench里面不断检测CPU的地址线,只要读写地址等于0x3fff8且写使能有效时就通过$write
命令打印这个地址里的数据即可。原则上这个地址的指定是比较随意的,只要是一个嵌入式程序不会占用的空闲地址,且保证桩函数和testbench监测的地址相匹配即可实现。
使用这种方法可以把网络层的输出数据打印到屏幕上,但频繁操作不仅会使仿真变得很慢,而且把很大规模的输出数据全部打到屏幕上去逐个比对也是不现实的。一种更简单轻便的方法是事先在PC上运行一遍算法并提取出一份参考输出,然后用前述的相同方法把参考输出放到特定的内存地址。待仿真程序完成后在C程序内部逐一比较即可:
#define OUT_REF_ADDR (0x07000000)
volatile float* output_ref = OUT_REF_ADDR; //参考输出存放位置
int pass = 1;
for(int i = 0; i < l.outputs; i++)
{
if(l.output[i] != output_ref[i])
{
pass = 0;
break;
}
}
if(pass)
printf("Test Pass!n");
else
printf("Test Fail!n");
这个方法可以在进行单层仿真时非常方便地对每一层的输出进行检查。
仿真结果
综合上面的所有讨论,现在可以进行C910上的YOLOv3算法各个网络层前向传播的单层仿真。对于SMART平台而言,可以运行一个输入规模为147个浮点数的小规模单层网络,在本项目仿真环境下,仿真时长大约为40min,完整的单层网络输入规模大约是上例的1万倍,在vcs仿真了72小时仍未结束。因此转而使用Cadence的Palladium Z1硬件加速器进行仿真,其使用方法不再赘述,使用的RTL代码方面总体上和SMART一致,但考虑到原本的testbench中使用一个中间数组为内存赋值的方法会大幅降低仿真程序的速度,因此对读入数据的方式进行了如下调整:
- 按照内存RTL模型的组织方式,把SMART工具产生的
inst.pat
和data.pat
拆分并重组为16个文件init_ram_1_0
~init_ram_1_15
,分别对应于内存模型的ram0~ram15。对于要初始化的数据也如法炮制,生成16个数据文件init_ram_2_0
~init_ram_2_15
。 - 把testbench中初始化内存的代码全部替换为
$readmemh
命令,如下图所示。这样即可大幅加快仿真速度。
此后按照流程提交仿真,即可得到仿真结果如下:
作为参考,打印到屏幕的五个浮点数为前5个中间层输出。而如前所述,全部的11829248个输出都在C程序内部进行了比对,并得到了"Test Pass!"的结论。
总结和展望
本文梳理了YOLOv3源码的组织结构,解析了Darknet网络的底层架构,并基于玄铁C910及其SMART仿真平台进行了v3中涉及的所有类型层的单层前向传播仿真,验证了网络层的功能。而对于完整的算法网络,一方面在一个单层需要大约40-60min进行仿真的情况下,对于107层的YOLOv3网络仿真势必要消耗较多的时间。另一方面由于源码中存在大量的动态内存分配代码,在工具链没有实现完善的动态内存管理的情况下,需要对代码结构进行更大规模的修改才能完成完整算法的移植。此外,欲在嵌入式平台上实现一个高性能、高实用性的YOLO算法,还应考虑充分发挥CPU核的优势,例如利用C910的多核架构进行多线程并行计算,又或者采用软硬件协同的策略,设计专用的神经网络加速器来对大规模的数据计算进行加速,这些都要求在原有算法思路的基础上进行更加大胆的顶层设计。对于YOLO算法在嵌入式裸机平台上运行的更多探索,将在今后的实验中逐步进行。