ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FPGA上实现LeNet-5:RTL设计、定点量化与并行度分析

FPGA上实现LeNet-5:RTL设计、定点量化与并行度分析 简介这是一份使用Verilog/SystemVerilog编写的CNN卷积神经网络硬件实现项目基于Vivado工具链在Xilinx FPGA上完成综合。项目参考LeNet网络架构实现了卷积、最大池化、ReLU激活和迭代器四个基本模块其中全连接层以卷积形式统一处理便于硬件数据流的控制与复用。代码定位为功能实验未做时序和面积优化适合FPGA开发者、算法转硬件方向的初学者以及需要课程设计素材的学生。压缩包共55个文件大小11.85MB主体包含25个Verilog源文件、6个XDC约束文件、2个TCL工程脚本、VHDL辅助代码以及Notebook、PNG图像和说明文档可完整查看RTL设计、综合约束与仿真验证流程。已有923人学习可结合MNIST数据与LeNet测试样例快速理解CNN硬件计算引擎与存储、控制模块的协作关系。 把训练好的LeNet-5塞进FPGA最容易被低估的一步是“把矩阵运算翻译成硬件数据流”。直接用PyTorch的循环写法去写Verilog综合出来要么是巨长的组合逻辑要么是时序根本收敛不了。CNN的硬件实现不是加载某个现成IP核而是从RTL层面把卷积、池化、全连接拆成可综合的Verilog模块在FPGA上完成综合、实现与上板验证。整个过程真正要设计的是三件事定点数格式、行缓冲结构、乘加单元的并行度。这篇文章适合有Verilog基础、想把MNIST这类小网络落到硬件的工程师对HLS或纯软件背景的人也能借此看清FPGA上跑CNN的基本约束。2. 把LeNet-5拆成RTL模块先画数据流再写VerilogLeNet-5的结构足够经典也足够小32x32灰度输入两个卷积层、两个池化层、三个全连接层。硬件实现它不复杂但模块边界如果画错后面改数据通路会非常难受。先画的不是代码是数据流每个时钟周期数据从哪来、往哪去、在哪一拍能对齐。2.1 LeNet-5各层在RTL里的映射关系先把每层运算对应到电路上层级输入运算RTL模块推荐位宽CONV132x32x15x5卷积 x6通道ReLU行缓冲 乘加阵列输入8~12bit权重8bitPOOL128x28x62x2最大池化比较器16bitCONV214x14x65x5x6卷积 x16通道复用CONV1乘加阵列累加器32bitPOOL210x10x162x2最大池化比较器16bitFC1/FC2/OUT400→120→84→10全连接MAC引擎累加器32bit输出16bit这个映射的关键是卷积核的权重在训练时是浮点数进FPGA前必须量化成定点数。池化层不产生乘法只需要在时序上等卷积输出对齐。一个容易判断错的地方是CONV2。它有6输入通道、16输出通道一种做法是把CONV1的乘加阵列复制6份来加速另一种做法是让CONV2复用CONV1的阵列每拍换一组权重。前者费DSP后者费时间选型标准是“每层能容忍多少拍延迟”。2.2 卷积的滑动窗口在RTL里是行缓冲窗口寄存器组卷积运算每到一个位置需要取出以当前点为中心的5x5邻域。FPGA上不可能把整张特征图都放到寄存器里常见做法是让输入像素串行流入行缓冲。以32列灰度图像为例一行512bit16bit像素存在BRAM里用两个深度为32的FIFO串起来每个时钟周期同时输出第0行、第1行、第2行的对应像素module line_buffer #( parameter W 32, parameter DATA_W 16 )( input wire clk, input wire rst_n, input wire [DATA_W-1:0] din, input wire din_valid, output wire [DATA_W-1:0] row0, output wire [DATA_W-1:0] row1, output wire [DATA_W-1:0] row2 ); reg [DATA_W-1:0] mem0 [0:W-1]; reg [DATA_W-1:0] mem1 [0:W-1]; reg [$clog2(W)-1:0] wptr; always (posedge clk or negedge rst_n) begin if (!rst_n) begin wptr 0; end else if (din_valid) begin mem1[wptr] mem0[wptr]; // 当前行写入时上一行同列顺移到第二行 mem0[wptr] din; wptr (wptr W-1) ? 0 : wptr 1b1; end end assign row0 mem0[wptr]; assign row1 mem1[wptr]; assign row2 din; endmodule逻辑说明这个模块用两块深度为W的RAM模拟两条行延迟线。din_valid为高时新像素写入第0行当前列mem1写入的是mem0在上一拍的内容等效于延迟一行。输出端各取当前列就组成3x1的竖直窗口。需要一个5行的窗口时把同样结构再串两级。参数说明W是特征图宽度DATA_W是像素位宽。wptr是循环写指针计数器满W时回绕。这里row0/row1/row2的读取本质上是“写到哪读到哪”BRAM的写带宽每拍只有一端口瓶颈在窗口列方向的数据搬移不在这里。窗口内水平方向的5个点通常用寄存器组做一个5x5滑窗每个新像素到来时整体左移一列再把row0/row1/row2填入最右列。这样每个时钟周期只移动一列环路短、时序干净。需要提一句的是这种滑动窗口结构和“滑动窗口滤波verilog”是同一套硬件模板区别只是CNN的权重来自训练结果滤波器的系数是固定的。这里有个容易踩的坑图像宽度不是2的整数次幂时wptr的$clog2向上取整会浪费地址空间但不会出错。真正的坑是行缓冲的换行动作如果上一行最后一个像素还没被窗口消费完下一行第一个像素就挤进来窗口边缘会混入错误数据。处理办法是让上游在行尾多停一拍显式保持busy信号。2.3 池化和ReLU不需要乘法器最大池化在RTL里就是比较器2x2窗口内取最大值三个比较器足够两个先比较、结果再与第三个比较。硬件上不需要存整个窗口池化的输入是卷积输出的连续两行用两个寄存器打一拍就组合出2x2。ReLU更简单16bit有符号数取负则清零。写成Verilog时不写if/else用一个位选择更直白assign out_relu din[15] ? 16h0000 : din;说明din[15]是有符号数的符号位为1表示负数直接清零否则透传。这个写法不会因为if语句产生优先级逻辑综合器也容易优化成数据选择器。池化和ReLU的顺序要注意。标准LeNet-5是先ReLU再池化硬件实现里常常把ReLU吞进卷积输出寄存器池化紧跟其后省掉一拍中间存储。代价是调试时少一个可观测点所以工程上我建议保留ReLU输出作为调试探针等上板稳定后再合并。3. 写Verilog之前先把浮点网络换成fixed-point格式浮点乘法在FPGA上要么占用大量DSP要么需要额外IP核位宽宽、面积高、时序差。对MNIST这种小网络业内通行做法是完全定点化。这也是“fpga fixed point 使用原理”里最核心的内容用整数运算模拟小数关键是确定小数点的位置而不是纠结绝对数值大小。3.1 用Q格式给LeNet-5定标位宽选多少Q格式写作Qm.n表示1位符号加m位整数加n位小数总共mn1位。选型只看数据范围输入像素LeNet-5训练时通常归一化到[-1,1]。选16bit Q8.8整数位有8位实际只用很小一部分但累加中间结果不容易溢出。权重第一层权重基本在[-0.5, 0.5]以内8bit Q1.7足够量化误差约0.0078。偏置和累加器一起用32bit最后再做饱和截断。累加器5x5卷积一次累加25次FC层一次累加400次。25次乘加用32bit累加器不会溢出FC层必须控制累加顺序并做饱和。推荐配置通常做成一张可查的表数据对象Q格式位宽量化前处理输入特征图Q8.816像素除以255后乘256卷积核权重Q1.78权重乘128后四舍五入偏置Q8.816与权重同尺度乘加结果Q16.1632乘法后宽位保留每层输出Q8.816饱和截断不四舍五入工程习惯是让所有层保持同一个缩放因子能省掉层间重定标的麻烦。如果每层各自定标推荐在C参考模型里先算一遍每层输出最大值再决定Q格式如果不方便统一用Q8.8溢出概率不高代价是浪费几位小数精度。3.2 定点乘法与饱和截断的Verilog函数有了Q格式乘法规则是固定的两个Q8.8相乘得到Q16.16然后截取中间16位作为Q8.8输出。直接贴一段可用的函数function signed [15:0] q8_mul_sat; input signed [15:0] a; input signed [15:0] w; reg signed [31:0] mul; begin mul a * w; // Q8.8 * Q8.8 Q16.16 if (mul[31:24] ! {8{mul[31]}}) // 丢弃的高8位不是符号扩展说明溢出 q8_mul_sat mul[31] ? -16sd32768 : 16sd32767; else q8_mul_sat mul[23:8]; // 右移8位回到Q8.8 end endfunction逻辑说明mul是32bit暂存保留完整乘法结果。要回到Q8.8保留mul[23:8]相当于右移8位并丢掉低8位。饱和判断比较的是被丢弃的高8位是否与符号位一致一致说明整数部分没有溢出直接截断不一致就把结果钳制到正负最大值。参数说明如果权重只有8bit可以把w扩展成16bit再乘综合工具会自动优化高位或者自行调整截断位置。累加时不要把每个乘积都截断先累加再截断误差只出现一次不会随累加深度放大。3.3 定点化的误差来源与Chisel/RTL选型定点化误差来自三个地方权重量化误差、输入量化误差、累加中途截断误差。前两种是静态误差只能靠提高位宽缓解第三种可以完全避免做法就是上面说的累加结束后再截断。反面例子是每个乘加结果都立即截断到16bit。这样误差会被卷积核深度放大输出特征图出现条纹噪声。实践中更隐蔽的是池化层池化只取最大值不做运算但如果输入特征图是带符号数比较器必须按有符号数比较。Verilog里写if (a b)默认是无符号比较必须显式转signed。提示如果像素时钟和系统时钟不同步行缓冲的写侧和读侧要用异步FIFO隔离不能直接用同步FIFO顶替。关于Chisel方式生成RTL与原生Verilog开发的区别如果网络层数多、位宽参数化需求多Chisel这类硬件构建语言生成RTL会方便很多但它生成的Verilog可读性差综合后定位问题仍要回到生成器。对LeNet-5这种层次固定的结构原生Verilog控制行缓冲和状态机更直接不推荐为此引入额外构建链。4. 在Vivado里把CNN RTL合成到FPGA上RTL写完后放到Vivado里走一遍综合、实现、生成bitstream。这一步看似机械其实约一半项目时间花在约束和排查上。4.1 用Tcl脚本建一个最小工程并跑synth/implement假设RTL文件在rtl目录下板子是Artix-7。用Tcl命令而不是GUI操作方便改约束后重跑create_project cnn_lenet ./proj -part xc7a35tcsg324-1 add_files -norecurse ./rtl read_xdc ./top.xdc set_property top cnn_top [current_fileset] launch_runs synth_1 -jobs 4 wait_on_run synth_1 launch_runs impl_1 -to_step write_bitstream -jobs 4 wait_on_run impl_1逻辑说明launch_runs synth_1是综合impl_1 -to_step write_bitstream一次性完成布局布线并生成bitstream。两步之间如果想看时序报告先open_run synth_1再report_timing_summary。工程目录./proj不要和源文件放一起否则cleanup时容易误删RTL。参数说明-jobs 4是多线程综合4核以上机器可以设8。CNN这种中等规模设计综合一次大约5到10分钟布线稍久。4.2 set_input_delay约束怎么给FPGA工程常见的坑不是逻辑写错而是约束写错。CNN芯片的顶层接口一般有像素时钟和总线时钟为了简化先统一单时钟域create_clock -period 10.0 -name sys_clk [get_ports clk] set_input_delay -clock sys_clk -max 3.0 [get_ports {din[*]}] set_input_delay -clock sys_clk -min 1.0 [get_ports {din[*]}] set_output_delay -clock sys_clk -max 2.0 [get_ports {result[*]}] set_output_delay -clock sys_clk -min 0.5 [get_ports {result[*]}]逻辑说明周期10ns对应100MHz是FPGA上最稳妥的工作频率。set_input_delay声明的是外部器件把数据送到pin相对于时钟沿的延迟范围max/min分别对应setup和hold检查。没有这组约束工具会默认数据对齐时钟沿IO时序报告失真。参数说明din是输入像素总线名称带向量下标时用大括号括起来。如果数据来自摄像头或ADCmax/min要按器件手册填写纯学习验证时min设1ns、max设3ns比较现实。时钟用PLL倍频到200MHz时要先report_clock_utilization确认时钟走线。4.3 用ILA抓中间信号替代print大法FPGA上不能printfVivado的ILA核可以在线抓信号。综合前给需要观察的信号打标记(* mark_debug true *) reg conv_data_valid; (* mark_debug true *) reg [15:0] conv1_dout;综合完成后执行open_run synth_1再通过Setup Debug向导把标记过的信号接进ILA。抓取深度一般设1024触发条件用conv_data_valid 1b1就能看到卷积输出与输入的时间对应关系。逻辑说明ILA本质是把一组信号寄存器采样后通过JTAG读回深度和位宽都会占用BRAM。CNN每层输出特征图很多不要一次性抓全部中间信号建议先抓CONV1输出验证第一层正确后再往下走。这个阶段最常见的报错是“input/output delay”与“datapath delay”冲突导致的时序违规。如果看到负slack且路径在累加器内部多半是组合逻辑层级太多标准做法是在累加链上按16bit为单位插入寄存器让每个周期只做一次16bit乘法加一次16bit加法。5. 并行度换算用几路PE、几块BRAM把卷积算完LeNet-5的计算量不大全串行在100MHz下也只要几毫秒。但FPGA上CNN的价值在于流水线和确定性延迟所以先算清需求再决定并行度。5.1 先算乘加次数再选并行度LeNet-5的乘加次数CONV1是28x28x6x25约11.8万次CONV2是10x10x16x6x25约24万次FC1是400x120约4.8万次FC2和输出层约1万次。总计约42万次乘加。100MHz下串行执行只要4.2ms半帧视频都不到。这个数字很有意义如果目标只是识别单张MNIST图片一个乘加器就够了如果要做实时视频流才需要并行展开。常见设计选型并行方案乘法器数量单个输出像素所需拍数特性全串行125资源最少时序轻松单行并行55一行内并行计算5x5并行251每拍出一个像素4像素并行1001每拍出4个输出带宽压力大表中“单个输出像素所需拍数”只针对卷积层。具体选择还要看BRAM带宽5x5并行时每个窗口需要5行数据同时可读行缓冲天然满足并行4个输出像素时需要同时读4组地址资源占用成倍增加。5.2 多路PE阵列的generate结构并行度再往上加就是脉动阵列systolic array的思路。把25个乘法器按5x5排布权重提前加载到每个PE输入特征图从左侧流入部分和从上方流出。每个PE只做一次乘加然后向右传递25个PE保持满流水每个时钟周期出一个输出。Verilog实现通常用generate展开generate genvar i; for (i 0; i 25; i i 1) begin : pe pe_unit #( .DATA_W(DATA_W) ) u_pe ( .clk(clk), .rst_n(rst_n), .din(window[i]), .w(w_rom[i]), .psum_in(psum_in[i]), .psum_out(psum_out[i]) ); end endgenerate逻辑说明window是5x5窗口的25个寄存器w_rom是当前输出通道对应的25个权重。每个PE内部是一个乘法器加一个累加寄存器psum_out psum_in din*w。第0个PE的psum_in接0最后一个PE的psum_out接输出寄存器。参数说明DATA_W是特征图位宽w_rom上电初始化时由外部加载可以用$readmemh从hex读取也可以用状态机从DDR搬进来。注意w_rom每个PE对应不同地址综合后占用BRAM或分布式RAM25路并行时要考虑读端口数量必要时复制权重存储。5.3 BRAM资源估算与搬运边界除了乘法器CNN硬件最大的资源消耗是存储。一行32像素x16bit是512bit一个BRAM18K可放36行。整张32x32特征图只需要一个BRAM但CONV2的输入有6通道各14x14要把通道数乘进去。Artix-7 35T约50块18Kb BRAM跑LeNet-5存特征图和权重完全够用。关键要区分“行缓冲”和“特征图存储”两类BRAM行缓冲要求每拍写一个读一个适合FIFO模式特征图存储要求随机读多个地址适合简单双端口模式。和“fpga图像处理”类项目一样CNN硬件更常碰到的瓶颈是DMA搬运而非片上计算。如果图像从DDR3读入每行32像素按16bit算只有64B搬运32行只需要2KB但对DDR的读效率来说突发长度太短反而浪费带宽。常见做法是让DMA连续读多行到片上异步FIFO再按行缓冲顺序消费。6. 用一张特征图验证CNN硬件而不是用准确率上板后最危险的信号是输出偶尔对、偶尔错。这时候不要跑10000张MNIST求准确率准确率是统计指标只告诉你系统坏了不告诉你坏在哪一层。硬件验证要做到逐层比对。6.1 先导出golden特征图在PC上用Python对同一权重做定点化推理把每层输出导出成heximport numpy as np w np.load(conv1_w.npy) wq (w * 128).astype(np.int8) with open(w_conv1.hex, w) as f: for v in wq.flatten(): f.write(%02x\n % (v 0xFF))逻辑说明导出的是8位有符号权重的十六进制文本Verilog的$readmemh可以直接读。特征图和输出同理CONV1输出需要在参考模型里做ReLU、做完后截断成16bit和RTL的位宽严格一致。6.2 RTL与golden逐拍比对testbench里用$readmemh加载权重和输入每出一个像素就与golden对比initial begin $readmemh(w_conv1.hex, w_mem); $readmemh(img.hex, img_mem); repeat (1024) begin (posedge clk); if (conv_valid) begin if (conv_out ! golden[conv_idx]) $display(MISMATCH at %0d: got %h exp %h, conv_idx, conv_out, golden[conv_idx]); else $display(MATCH at %0d: %h, conv_idx, conv_out); end end end技巧说明把每个MATCH都打印出来看起来啰嗦但能区分“全错”和“个别错”。全错通常是位宽截断点选错个别错通常是累加顺序或行缓冲边界条件。6.3 三个容易反复踩的坑latch、复位、截断第一个是latchVerilog里组合逻辑的if如果没有配套else综合器会产生latch。CNN数据通路的控制信号一旦被latch捕获行为就像流水线卡了半拍排查起来非常隐蔽。第二个是复位FPGA上电后DSP48乘积寄存器、BRAM内容都是未知的状态机必须同步复位不能只依赖异步复位。第三个是截断位置乘法结果的高位和低位往往被一并截掉建议在testbench里把截断前的结果也拉出来比对能快速定位是饱和逻辑写错还是截断位置选错。把这套逐层比对脚本固化下来每次改位宽、改并行度后跑一遍比盯着波形图事半功倍。本文还有配套的精品资源点击获取
返回列表