FPGA上部署单层全连接神经网络:MATLAB定点量化与EGo1开发板实战

📅 发布时间:2026/10/11 14:11:08
FPGA上部署单层全连接神经网络:MATLAB定点量化与EGo1开发板实战
1. 为什么要在FPGA上跑一个只有一层的神经网络很多人第一次听到FPGA神经网络这几个字脑子里浮现的都是动辄几十层、参数量上亿的大模型。但真正上手做过硬件部署的人都知道把一个大网络塞进FPGA里光是权重存储和带宽就够喝一壶的。所以当我决定在EGo1这块入门级开发板上做手写数字识别时第一件事就是做减法——把网络砍到只剩一个全连接层。这个选择不是偷懒而是有明确的工程逻辑。MNIST数据集的输入是28×28的灰度图展开就是784维向量输出是10个类别。如果只用一个全连接层权重矩阵就是784×10加上10个偏置总共7850个参数。这个量级用定点数量化后完全可以塞进FPGA的片上BRAM里不需要外挂DDR也不需要复杂的访存调度。对于EGo1这种资源有限的板子来说这是能跑通、能验证、能讲清楚原理的最小可行方案。更重要的是单全连接层的结构足够透明。你能清楚地看到每一个乘加操作对应哪一根信号线、哪一个时钟周期这对于理解神经网络在硬件上到底是怎么算的这件事比跑通一个黑盒大模型有价值得多。我见过太多人拿着现成的HLS工程跑出结果却说不清数据在流水线里是怎么流动的。这个项目的意义就在于它逼着你把每一个细节都想明白。整个流程分三段MATLAB负责训练和定点量化FPGA负责推理加速EGo1开发板负责承载和展示。下面我会把这三段拆开讲清楚每一步为什么这么做、怎么做、以及我踩过的那些坑。2. MATLAB端的训练与定点量化从浮点到定点的关键一跃2.1 为什么不用现成的深度学习框架你可能会问训练一个单层全连接网络用PyTorch或者TensorFlow不是几行代码的事吗确实但问题出在量化环节。这个项目最终要部署到FPGA上而FPGA里没有浮点运算单元至少EGo1上没有硬核浮点所有计算必须是定点数。如果我用PyTorch训练完再导出权重还得额外写一套量化脚本而且很难保证量化后的精度损失在可控范围内。MATLAB的好处是它从训练到量化到生成硬件可用的系数文件可以在一套环境里完成。特别是它的Fixed-Point Designer工具箱能让你在训练阶段就模拟定点运算的效果提前看到量化误差对识别率的影响。我实测下来用MATLAB做这套流程从训练到生成.coe文件熟练的话半天就能跑通。具体做法是先用浮点训练一个基准模型确认识别率能到92%以上单层全连接在MNIST上的上限大概就在92%左右再高就过拟合了。然后固定权重把输入、权重、偏置、累加器、输出全部转成定点格式重新跑一遍推理看识别率掉了多少。如果掉得厉害就调整定点位宽直到精度损失在可接受范围内。2.2 定点位宽怎么选一次被精度坑惨的经历定点数的格式是Qm.nm是整数位n是小数位总位宽是mn1符号位。选位宽的核心原则是整数位要够大防止溢出小数位要够多保证精度。我第一次做的时候想当然地用了Q3.12觉得12位小数够用了。结果训练集上识别率92%量化后直接掉到78%。排查了半天才发现问题出在累加器上。784个乘加运算每个乘积的位宽是输入位宽加权重位宽累加之后位宽还要再涨。如果累加器位宽不够中间结果就会溢出导致高位被截断整个计算结果就废了。后来我把输入和权重都设成8位定点Q1.7范围-1到1乘积是16位累加器设成32位最后输出再截断回8位。这样识别率只掉了0.5个百分点稳定在91.5%左右。这个配置在资源和精度之间取得了很好的平衡。数据类型位宽格式范围说明输入像素8Q1.7[-1, 1)归一化后的灰度值权重8Q1.7[-1, 1)训练后量化偏置8Q1.7[-1, 1)训练后量化乘积16Q2.14—自动推导累加器32Q16.14—防止溢出输出8Q1.7[-1, 1)截断后送比较器注意累加器位宽一定要留足余量。784个乘积相加最坏情况下位宽会增加log2(784)≈10位所以16位乘积加10位就是26位取32位是稳妥的选择。2.3 权重导出与.coe文件生成量化完成后需要把权重和偏置导出成FPGA能读的格式。Xilinx的BRAM初始化通常用.coe文件格式很简单第一行定义进制比如radix10表示十进制第二行定义向量长度后面每行一个数。这里有个细节容易被忽略MATLAB里的权重矩阵是784×10但FPGA里BRAM的存储顺序需要和你的读取逻辑匹配。我建议按列优先存储也就是先存第1个输出神经元对应的784个权重再存第2个以此类推。这样在FPGA里做矩阵向量乘法时地址生成逻辑最简单一个计数器就能搞定。导出脚本大概长这样% 假设W是784x10的定点权重矩阵b是10x1的偏置 W_int int8(W * 128); % Q1.7乘以2^7 b_int int8(b * 128); % 写入权重coe文件 fid fopen(weights.coe, w); fprintf(fid, memory_initialization_radix10;\n); fprintf(fid, memory_initialization_vector\n); for col 1:10 for row 1:784 fprintf(fid, %d,\n, W_int(row, col)); end end fclose(fid);偏置文件类似只有10个数。测试图像也需要导出成.coe每张图784个像素值按Q1.7量化后写入。3. FPGA端的硬件架构把矩阵乘法拆成时序逻辑3.1 整体数据流设计FPGA端的核心任务就一件事算y Wx b然后找y里最大的那个元素对应的索引。听起来简单但784×10的矩阵乘法在硬件上怎么展开有很多种做法。最直接的做法是并行展开784个乘法器同时工作一个时钟周期就能算完一个输出神经元。但EGo1上的DSP资源有限784个乘法器根本不现实。所以必须做时分复用用少量的乘法器分多个周期完成计算。我采用的方案是用10个乘法器每个乘法器负责一个输出神经元。每个时钟周期从BRAM里读出一个输入像素和10个对应的权重每个输出神经元一个10个乘法器并行计算结果累加到各自的累加器里。784个周期后10个累加器里就是最终的y值。这个方案资源占用少时序也容易收敛。具体的数据流是这样的状态机处于IDLE状态等待开始信号。收到开始信号后地址计数器清零累加器清零。每个时钟周期地址计数器加1从图像BRAM读出第i个像素从权重BRAM读出第i组10个权重。10个乘法器同时计算像素×权重结果加到对应的累加器。重复步骤3-4直到计数器达到783。计数器满后加上偏置进入比较状态。比较10个累加器的值输出最大值的索引。回到IDLE等待下一次开始信号。3.2 BRAM的配置与读取时序EGo1上的BRAM是双端口还是单端口取决于你用的IP核配置。我建议用简单双端口BRAM一个端口写初始化时用一个端口读推理时用。读取延迟通常设为1个时钟周期也就是说地址给出后下一个周期数据才有效。这个延迟必须在状态机里考虑进去否则会读错数据。权重BRAM的位宽是80位10个8位权重拼在一起深度是784。图像BRAM的位宽是8位深度是784。这样每个周期读一个地址就能同时拿到1个像素和10个权重正好喂给10个乘法器。这里有个坑BRAM的初始化文件格式和.coe略有不同。Vivado的BRAM IP核支持.coe但如果你用Verilog的$readmemh就需要把数据转成十六进制。我建议直接用IP核的.coe加载功能省得转换。3.3 乘法器与累加器的位宽处理8位×8位的乘法结果是16位。10个乘法器的结果分别加到10个32位累加器上。这里要注意符号扩展如果乘积是负数加到累加器时需要把高16位补1而不是补0。Verilog里用$signed()或者手动做符号扩展都可以但千万别忘了。累加完成后加上偏置。偏置也是8位定点加到32位累加器上时同样要做符号扩展。加完偏置后取累加器的高8位作为最终输出相当于右移14位因为累加器是Q16.14格式。这个截断操作会引入少量误差但实测对识别率影响很小。比较逻辑很简单用一个大小的比较树10个输入两两比较5个周期就能找出最大值。或者用组合逻辑直接比较一个周期出结果。我为了时序余量用了两级流水线比较两个周期出结果。4. EGo1开发板上的落地细节从比特流到实际识别4.1 开发环境与工程配置EGo1用的是Xilinx Artix-7系列FPGA具体型号是XC7A35T。这个芯片有约20800个逻辑单元、50个DSP切片、1800Kb的BRAM。我们的设计大概用掉10个DSP乘法器、不到1000个LUT、几个BRAM块。资源占用率很低时序很容易收敛到100MHz以上。Vivado工程配置时有几个地方需要注意时钟约束EGo1板载晶振是100MHz但我们可以用MMCM分频或倍频。我直接用100MHz时序余量很大。引脚约束输入开关、按键、LED、数码管都要绑定到正确的引脚。EGo1的引脚定义在板卡手册里有照着写XDC文件就行。BRAM初始化在IP核配置里加载.coe文件综合时会自动初始化BRAM内容。4.2 输入图像的预处理与存储MNIST的图片是28×28灰度图像素值0-255。在MATLAB里归一化到[-1,1]后量化成Q1.7格式。但EGo1上没有摄像头输入怎么把测试图像送进去我的做法是把几张测试图像的像素数据预先写入BRAM用拨码开关选择当前测试哪一张。比如拨码开关设为0时地址0-783对应第一张图设为1时地址784-1567对应第二张图。这样不需要外部存储也不需要动态加载简单可靠。如果你想让项目更有交互性可以加一个UART接口从PC串口发送图像数据。但这会增加复杂度建议先把固定图像跑通再考虑扩展。4.3 识别结果的显示与验证识别结果通过数码管显示。EGo1上有4个数码管我用最低位显示识别出的数字0-9其他位显示一些调试信息比如当前测试图像的索引。验证流程是这样的用MATLAB生成10张测试图像的.coe文件每张对应一个数字。把.coe加载到BRAM综合实现生成比特流。下载到EGo1拨码开关选择测试图像。按复位键观察数码管显示的数字是否和预期一致。如果不对用ILA集成逻辑分析仪抓取内部信号看是哪个环节出了问题。我实测下来10张测试图全部识别正确。但换成另外10张没见过的图有1张识别错了。这说明单层全连接网络的泛化能力确实有限92%的识别率意味着每100张图里大概有8张会错。这是模型本身的局限不是硬件的问题。5. 调试过程中踩过的坑与排查思路5.1 识别率骤降从92%到10%的惊魂时刻第一次跑通硬件后数码管显示的数字一直是0不管换哪张测试图都是0。我一开始以为是BRAM没初始化检查了.coe文件加载没问题。又怀疑是乘法器符号位搞错了查了波形乘积是对的。最后用ILA抓了累加器的值发现10个累加器的值几乎一样而且都很小。问题出在权重BRAM的读取地址上。我在MATLAB里是按列优先存储权重但FPGA里的地址生成逻辑写成了行优先。结果读出来的权重和像素完全不对应算出来的y值自然是一团糟。把地址逻辑改成列优先后识别率立刻恢复到91%。这个坑的教训是MATLAB和FPGA的数据布局一定要提前对齐最好画个图确认一下。别想当然地觉得应该差不多差一个索引结果就全错了。5.2 时序违例一个容易被忽略的跨时钟域问题设计里有两个时钟域一个是100MHz的主时钟一个是数码管的扫描时钟通常几百Hz。识别结果从主时钟域传到扫描时钟域时如果没有做同步处理数码管显示会闪烁或者显示错误的值。我一开始没注意这个问题数码管显示的数字偶尔会跳变。后来加了一个两级触发器同步器问题就解决了。虽然识别结果变化很慢按一次键才更新一次理论上不加同步器也能工作但为了稳定性和可复现性还是加上比较稳妥。5.3 资源占用的优化空间当前设计用了10个DSP。如果你想进一步省资源可以只用1个DSP分10个周期算完10个输出神经元。但这样推理时间会从784个周期变成7840个周期对于100MHz时钟来说也就是78.4微秒仍然很快。所以资源紧张的话完全可以串行化。另一个优化点是权重存储。784×10×8位62720位约62Kb。EGo1有1800Kb BRAM完全放得下。但如果你想把网络做大一点比如加一个隐藏层BRAM就会紧张。这时候可以考虑用权重压缩或者稀疏化但那是另一个话题了。6. 这个项目还能怎么扩展单全连接层跑通之后你可以沿着几个方向继续深入。第一个方向是加隐藏层。比如加一个64个神经元的隐藏层网络变成784-64-10。识别率能提升到95%以上但参数量变成784×6464×1050816个是原来的6倍多。BRAM够用但乘法器需要时分复用设计复杂度会上升不少。第二个方向是换激活函数。单层全连接没有激活函数本质上就是个线性分类器。加隐藏层后必须加ReLU或Sigmoid这时候硬件上就要实现激活函数的定点近似。ReLU最简单就是一个比较器Sigmoid复杂一些可以用分段线性近似或者查找表。第三个方向是动态输入。加一个摄像头模块实时采集手写数字做预处理后送进网络。这个方向最接近实际应用但涉及图像采集、二值化、居中、缩放等一系列预处理步骤工作量不小。第四个方向是量化优化。当前用的是8位定点可以尝试4位甚至2位量化看看识别率能保持多少。低位宽意味着更少的BRAM和更小的乘法器对资源受限的场景很有吸引力。不管选哪个方向核心思路都是一样的先在MATLAB里验证算法再在FPGA上实现硬件最后在开发板上验证。这个流程走顺了做更复杂的网络也只是时间问题。我个人在实际操作中的体会是FPGA神经网络部署最难的不是写Verilog而是把浮点算法正确地映射到定点硬件上。定点位宽的选择、数据布局的对齐、时序的收敛每一个环节都可能出问题。但只要把单层全连接这个最小系统跑通后面的事情就都是在这个框架上做加法了。