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.n,m是整数位,n是小数位,总位宽是m+n+1(符号位)。选位宽的核心原则是:整数位要够大,防止溢出;小数位要够多,保证精度。
我第一次做的时候,想当然地用了Q3.12,觉得12位小数够用了。结果训练集上识别率92%,量化后直接掉到78%。排查了半天才发现,问题出在累加器上。784个乘加运算,每个乘积的位宽是输入位宽加权重位宽,累加之后位宽还要再涨。如果累加器位宽不够,中间结果就会溢出,导致高位被截断,整个计算结果就废了。
后来我把输入和权重都设成8位定点(Q1.7,范围-1到1),乘积是16位,累加器设成32位,最后输出再截断回8位。这样识别率只掉了0.5个百分点,稳定在91.5%左右。这个配置在资源和精度之间取得了很好的平衡。
| 数据类型 | 位宽 | 格式 | 范围 | 说明 |
|---|---|---|---|---|
| 输入像素 | 8 | Q1.7 | [-1, 1) | 归一化后的灰度值 |
| 权重 | 8 | Q1.7 | [-1, 1) | 训练后量化 |
| 偏置 | 8 | Q1.7 | [-1, 1) | 训练后量化 |
| 乘积 | 16 | Q2.14 | — | 自动推导 |
| 累加器 | 32 | Q16.14 | — | 防止溢出 |
| 输出 | 8 | Q1.7 | [-1, 1) | 截断后送比较器 |
注意:累加器位宽一定要留足余量。784个乘积相加,最坏情况下位宽会增加log2(784)≈10位,所以16位乘积加10位就是26位,取32位是稳妥的选择。
2.3 权重导出与.coe文件生成
量化完成后,需要把权重和偏置导出成FPGA能读的格式。Xilinx的BRAM初始化通常用.coe文件,格式很简单:第一行定义进制(比如radix=10表示十进制),第二行定义向量长度,后面每行一个数。
这里有个细节容易被忽略: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_radix=10;\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×64+64×10=50816个,是原来的6倍多。BRAM够用,但乘法器需要时分复用,设计复杂度会上升不少。
第二个方向是换激活函数。单层全连接没有激活函数,本质上就是个线性分类器。加隐藏层后必须加ReLU或Sigmoid,这时候硬件上就要实现激活函数的定点近似。ReLU最简单,就是一个比较器;Sigmoid复杂一些,可以用分段线性近似或者查找表。
第三个方向是动态输入。加一个摄像头模块,实时采集手写数字,做预处理后送进网络。这个方向最接近实际应用,但涉及图像采集、二值化、居中、缩放等一系列预处理步骤,工作量不小。
第四个方向是量化优化。当前用的是8位定点,可以尝试4位甚至2位量化,看看识别率能保持多少。低位宽意味着更少的BRAM和更小的乘法器,对资源受限的场景很有吸引力。
不管选哪个方向,核心思路都是一样的:先在MATLAB里验证算法,再在FPGA上实现硬件,最后在开发板上验证。这个流程走顺了,做更复杂的网络也只是时间问题。
我个人在实际操作中的体会是,FPGA神经网络部署最难的不是写Verilog,而是把浮点算法正确地映射到定点硬件上。定点位宽的选择、数据布局的对齐、时序的收敛,每一个环节都可能出问题。但只要把单层全连接这个最小系统跑通,后面的事情就都是在这个框架上做加法了。