卷积神经网络LeNet-5的RTL实现(一):结构性电路

前言

毕业设计做的是卷积神经网络硬件加速相关内容,需要在ZYNQ7020平台上实现一个卷积神经网络。搜索资料的时候发现网上用RTL实现卷积神经网络的资料并不多,于是打算开个博客记录下项目实现过程。由于刚接触数字设计不久,在设计思路和具体实现方面还有许多不足,希望和大家一起交流进步。

本系列博客包含七期内容,分别如下:

  1. 前言以及项目用到的结构性电路介绍
  2. 预处理补零(Padding)电路实现
  3. 卷积电路实现
  4. 池化(Pooling)电路实现
  5. 全连接层电路实现
  6. 量化方案
  7. 系统整体电路实现

本系列博客只涉及加速器的PL部分,PS部分以及接口设计打算另开系列介绍。此外,后续还将进行RISC-V处理器核的移植和设计,希望最终能够真正实现一个挂载神经网络加速器的SoC,对这方面感兴趣的小伙伴可以持续关注系列博客。
本工程的开发环境为Xilinx Vivado 2019.1,所有模块均使用Verilog HDL设计,采用Verilog2001标准,不使用任何IP。系列博客的代码也会陆续全部开源,欢迎大家探讨。
说了很多废话,接下来正文开始。

结构性电路介绍

1. FIFO

为了接口的统一,本工程中各模块、各层之间的数据以串行方式传输。由于不同模块需要处理的数据量不同,在模块与模块之间、层与层之间设置有FIFO作为数据的暂存和缓冲。对FIFO的原理性解释不在这里展开,本项目中的FIFO全部为同步FIFO,相关设计可以参考博文:Verilog同步FIFO。不同的是,本项目中的FIFO增加了一个rd_rewind信号,能够将读取地址置零,用于队列数据的重复读取。FIFO电路代码如下:

module FIFO
#(
	parameter DATA_WIDTH = 16,
	parameter BUF_SIZE = 10
)
( 	input clk,
	input rst_n,
	input [DATA_WIDTH-1:0]buf_in,
	input wr_en,
	input rd_en,
	input rd_rewind,
	output fifo_empty,
	output fifo_full,
	output reg [DATA_WIDTH-1:0]buf_out
);
	//find bit width
	function integer clogb2 (input integer bit_depth);
	begin
		for(clogb2=0; bit_depth>0; clogb2=clogb2+1)
		bit_depth = bit_depth >> 1;
	end
	endfunction

	localparam CNT_BIT_NUM = clogb2(BUF_SIZE);
	localparam ALLIGN_BUF_SIZE = 32'b1 << CNT_BIT_NUM;

	reg [CNT_BIT_NUM-1:0] rd_ptr, wr_ptr;
	reg [CNT_BIT_NUM-1:0] fifo_cnt;
	reg [DATA_WIDTH-1:0] buf_mem[ALLIGN_BUF_SIZE-1:0];
	integer i;

	assign fifo_empty = (fifo_cnt == 0); 
	assign fifo_full  = (fifo_cnt == ALLIGN_BUF_SIZE);
	
	always @(posedge clk or negedge rst_n) begin
		if(!rst_n)
			fifo_cnt <= 0;
		else if((!fifo_full && wr_en) && (!fifo_empty && rd_en))
			fifo_cnt <= fifo_cnt;
		else if(!fifo_full && wr_en)     
			fifo_cnt <= fifo_cnt + 1;
		else if(!fifo_empty && rd_en)  
			fifo_cnt <= fifo_cnt-1;
		else 
			fifo_cnt <= fifo_cnt;
	end
	
	always @(posedge clk or negedge rst_n)
	begin
		if(!rst_n)
			buf_out <= 0;
		else if(rd_en && !fifo_empty)
			buf_out <= buf_mem[rd_ptr];
	end
	
	always @(posedge clk or negedge rst_n) begin
		if(!rst_n)
			for(i = 0; i < BUF_SIZE; i = i + 1)
				buf_mem[i] <= 0;
		else if(wr_en && !fifo_full)
			buf_mem[wr_ptr] <= buf_in;
	end
	
	always @(posedge clk or negedge rst_n) 
	begin
		if(!rst_n) begin
			wr_ptr <= 0;
			rd_ptr <= 0;
		end
		else begin
			if(rd_rewind)
				rd_ptr <= 0; 
			else if(!fifo_full && wr_en)
				wr_ptr <= wr_ptr + 1;
				
			if(!fifo_empty && rd_en)
				rd_ptr <= rd_ptr + 1;
		end
	end

endmodule

2. Shift RAM

Shift RAM是本工程实现窗口运算的核心,实际上发挥了行缓存(Line Buffer)的作用。一个长度为5、宽度为3的行缓存结构示意图如下:

mcu上神经网络的部署 神经网络电路_数据

如图所示,串行数据从shift_in流入,在每个时钟到来时依次右移,行尾的数据输出为tap向量,同时进入下一行的行首。多次移动后,输出的tap向量就构成了一个窗口,即我们需要操作的窗口。与二维的窗口做一个对比,可以看到,第一个tap向量正好是窗口的第一个列向量,在Shift RAM被填满后的第二个时钟到来时,第一个窗口输出,之后到来的每一个时钟都会使窗口向右平移一个单位。可以推断出,当FMAP尺寸为Ni×Ni,窗口尺寸为K×K时,Shift RAM的尺寸应为K行×Ni列。

mcu上神经网络的部署 神经网络电路_sed_02


需要注意的是,利用Shift RAM进行窗口滑动的过程中,靠近行尾的窗口会出现一些无效数据,针对无效的窗口数据,我们会在后续的具体窗口操作模块中进行判断和剔除。下图展示了一种数据无效的情况:

mcu上神经网络的部署 神经网络电路_mcu上神经网络的部署_03

本工程的Shift RAM模块利用上层模块配置的参数来生成实际电路,参数TAP_NUMTAP_LENGTH分别对应上述的窗口尺寸K和FMAP尺寸Ni。模块的接口位宽通过参数定义,内部的组合逻辑也通过generate根据不同的参数生成。Shift RAM的电路代码如下:

module SHIFT_RAM
#(
	parameter DATA_WIDTH = 16,
	parameter TAP_NUM = 5,
   	parameter TAP_LENGTH = 32
)
(
	input clk,
	input rst_n,
	input clear,
	input ena,
	input [DATA_WIDTH-1:0]shift_in,
	output [DATA_WIDTH-1:0]shift_out,
	output [TAP_NUM*DATA_WIDTH-1:0]taps
);
	localparam N = TAP_NUM*TAP_LENGTH;

	reg [DATA_WIDTH-1:0]data[0:N-1];
	integer i;
	genvar j;
	
	assign shift_out = data[N-1];
	
	always@(posedge clk or negedge rst_n)
		if(!rst_n) begin
			for(i = 0; i < N; i = i + 1)
				data[i] <= 0;
		end
		else if(ena) begin
			data[0] <= shift_in;
			for(i = 0; i < N - 1; i = i + 1)
				data[i+1] <= data[i];
		end
		
	generate 
		for(j = 1; j <= TAP_NUM; j = j + 1) begin
			assign taps[(DATA_WIDTH*j-1)-:DATA_WIDTH] = data[j*TAP_LENGTH-1];
		end
	endgenerate
endmodule

3. 二叉加法树

由于本工程中所有模块都利用参数进行电路构建,在编写代码时,模块中加法运算的数量并不确定,因此,工程采用二叉树实现若干个操作数的加法。二叉加法树结构中,除第一个加法器外,另外加法器的两个输入一个来自adder_out,另一个来自运算数储存区域,利用generate语句可以方便地实现多个加法树的例化,其结构图和实例电路代码如下:

mcu上神经网络的部署 神经网络电路_数据_04

//generate head adder
	generate
		for(i = 0; i < KERNEL_SIZE; i = i + 1) begin
			ADDER
			#(
				.DATA_WIDTH(DATA_WIDTH)
			)
			u_adder_head
			(
				.ina(product[i*KERNEL_SIZE]),
				.inb(product[i*KERNEL_SIZE+1]),
				.out(adder_out[i*KERNEL_SIZE])
			);
		end
	endgenerate
	
	//generate remaining adder
	generate
		for(i = 0; i < KERNEL_SIZE; i = i + 1) begin
			for(j = 2; j < KERNEL_SIZE; j = j + 1) begin
				ADDER
				#(
					.DATA_WIDTH(DATA_WIDTH)
				)
				u_adder_remain
				(
					.ina(adder_out[i*KERNEL_SIZE+j-2]),
					.inb(product[i*KERNEL_SIZE+j]),
					.out(adder_out[i*KERNEL_SIZE+j-1])
				);
			end
		end
	endgenerate

由于由于加法树结构中第一个加法器的输入与后续加法器的输入不同(参考示意图),这里采用u_adder_head和u_adder_remain两种例化,请注意它们输入的区别。当然,在generate中利用if判断也能够实现。

总结

本期文章简单介绍了工程中用到的结构性电路,它们的具体使用场景将在后续文章详细展开。下一篇文章将介绍Padding电路的实现。