ARTICLE DETAIL

资讯详情

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

FPGA上部署脉冲神经网络SNN:MNIST手写数字识别的完整实践

FPGA上部署脉冲神经网络SNN:MNIST手写数字识别的完整实践 1. 项目背景与整体思路拆解1.1 为什么是SNN能耗与事件驱动的双重诱惑做这个项目之前我一直在折腾传统ANN在FPGA上的部署CNN也好、MLP也好说白了就是把权重做成定点数然后一层层卷、一层层乘加本质上是把模型“压扁”成一张巨大的计算图。后来接触到了SNN也就是脉冲神经网络感觉一下子打开了另一个方向。SNN的核心思路和传统神经网络完全不一样神经元之间传递的不是连续的浮点数激活值而是离散的脉冲事件也就是0和1组成的脉冲序列信息编码在脉冲的发放频率或者发放时机上。这种方式在生物学上更接近大脑神经元的工作机制同时也天然适合硬件实现——因为脉冲就是二进制信号你不需要做浮点乘法很多地方甚至只需要累加器和一个比较器就够了。我在这套项目里选择SNN还有一个非常现实的理由就是它的能耗模型非常适合FPGA这种资源有限但并行度极高的平台。传统CNN在FPGA上做加速瓶颈在乘法器资源和片上存储带宽而SNN在足够稀疏的脉冲驱动下很多神经元在大部分时间根本没有输入事件计算单元可以处于“空闲”状态这正好能发挥FPGA按需供电、按需计算的特性。实测下来同样完成MNIST手写数字识别SNN部署在FPGA上的动态功耗比同规模CNN部署低了一个数量级早期原型甚至只需要几十毫瓦就能跑起来。当然SNN也不是免费的午餐它最大的问题在于训练。因为神经元的发放函数是阶跃不连续的梯度无法正常回传传统的反向传播算法不能直接用在SNN上。这个坑我后面会详细讲。1.2 为什么是MNIST验证算法与硬件协同的黄金测试集MNIST是手写数字识别领域的入门数据集60000张28×28像素的训练图片加上10000张测试图片内容就是0到9的灰度手写数字。别看它小这个数据集在过去将近三十年里见证了机器学习从统计方法一路走到深度学习巅峰的全过程。选择MNIST作为SNN的FPGA部署任务不是因为它简单而是因为它足够标准化、足够小适合快速验证算法到硬件的整个链路是否畅通。我用这个数据集踩过的关键坑是下载问题。现在用PyTorch的torchvision.datasets.MNIST接口直接下载经常会遇到404报错这是因为MNIST官方服务器不再维护原来的下载链接失效了。我在新环境上第一次跑这个项目就卡在这一步后面给出了避开这个问题的具体方案这里先卖个关子。MNIST对FPGA设计来说还有一个很大的好处就是单张图片数据量小。28×28的灰度图输入每个像素用8bit表示总共也就784字节。这个小尺寸意味着我们可以把整张图片缓存在片上不需要复杂的DDR读写调度这对初次上手FPGA加速SNN的人来说是非常友好的起步点。等模型架构和硬件流水线在MNIST上跑通了再切换到CIFAR-10、ImageNet这些大尺寸数据你只需要扩展存储带宽和计算阵列规模基本架构不需要大改。1.3 为什么是FPGA可重构并行架构与SNN的天然契合度FPGA的英文全称是Field Programmable Gate Array现场可编程逻辑门阵列。它的本质是一大片可以随意配置的逻辑单元和布线资源你可以把电路“画”进去实现任何你想要的数字逻辑。和CPU相比FPGA没有取指译码执行的流程它不是串行执行指令而是直接为你的算法生成一套专用硬件电路所以天然具备流水线和并行处理能力。SNN在FPGA上的落地优势体现在几个方面。第一是脉冲的二进制性质SNN中的突触输入要么是脉冲要么不是这样突触权重的乘法可以转化为条件累加——当有脉冲时执行累加没有脉冲时直接跳过不需要额外的乘法器。第二是神经元的时间动态性LIF神经元模型Leaky Integrate-and-Fire泄露积分发放模型的关键是膜电位在时间上积分、泄露和比较这些操作在FPGA上只需要加法器、移位寄存器和寄存器就能实现不涉及复杂的浮点运算。第三是空间并行性每一层神经元的计算彼此独立FPGA可以同时实例化数十甚至上百个神经元处理单元在MNIST这种规模下单FPGA甚至可以不使用循环复用策略直接把一整层神经元全部展开实现真正的并行推理。对比GPUGPU虽然也能做SNN仿真但GPU本质上是为高吞吐的矩阵乘法设计的仍然需要把脉冲事件转换成密集矩阵运算能耗远高于专用的脉冲驱动硬件。而FPGA可以在事件驱动模式下让大部分计算资源处于待机状态只处理实际到达的脉冲这种动态功耗管理是GPU很难做到的。2. SNN核心原理与MNIST数据编码细节2.1 LIF神经元模型从微分方程到数字电路的映射SNN中最常用的神经元模型是LIF模型它的数学表述非常简单。膜电位V随时间演化当有输入脉冲到达时膜电位升高在没有输入时膜电位按指数规律衰减也就是“泄露”当膜电位超过一个固定阈值时神经元发放一个输出脉冲然后膜电位被重置到静息电位或者低于阈值的某个值。用微分方程写出来大概是这个形式τ * dV/dt -(V - V_rest) R * I(t)其中τ是膜时间常数V_rest是静息电位I(t)是输入电流。在数字硬件上我们不能直接处理连续微分方程需要做离散化。最简单的欧拉法离散化结果是这样V(t1) V(t) - leak * (V(t) - V_rest) input如果你的输入是二值脉冲乘加可以简化为有脉冲就加权重没脉冲就跳过。leak系数可以写成2的负幂次方形式用移位操作代替乘法这样整个神经元硬件就完全不需要乘法器了。这个离散化的过程是SNN硬件实现的关键也是最容易出错的地方后面会有一节专门讲参数标定。我第一版实现用的是16位定点数来表示膜电位leak系数取了1/16阈值设在63静息电位是0。这些数值看着随意但每一个都是经过波形仿真的反复验证才定下来的。如果leak取得太大膜电位衰减太快神经元很难累积分发如果阈值太高神经元几乎不发放输出层会出现空白如果阈值太低输出层会过度发放数字之间区分不开。2.2 MNIST图片的脉冲编码几种方案与取舍MNIST图片是灰度像素取值范围是0到255SNN的输入是脉冲序列所以我们必须先完成一张灰度图到脉冲序列的编码转换。这个环节做得好不好直接影响后续推理精度我测试下来不同的编码方式同等硬件资源下精度差异能有5个百分点以上。第一种是速率编码也是最经典的方式。对每个像素值在固定时间窗口内按比例生成脉冲比如像素值128窗口长度100就生成大约50个随机分布的脉冲。这种方式简单直观但是缺点非常明显你需要足够长的时间窗口才能精确表达灰度值推理延迟大且随机性会引入额外噪声。我最初用这种方式跑完一个数字需要200个仿真周期精度在96%左右但时间开销确实有点大。第二种是泊松编码本质上是速率编码的随机化版本每个时间步按概率发放脉冲。这种方法在软件仿真中很常用但硬件实现需要高质量的伪随机数发生器会吃掉不少LUT资源。实测下来在硬件上性价比不高。第三种是时间编码每个神经元最多发放一个脉冲灰度值的大小映射为发放时刻的早晚。这种方式对时间窗口长度要求低但硬件实现需要对每个时间步做比较判断逻辑复杂度略高。我最终在这个项目里采用的是简化的速率编码把输入像素值归一化到0到15的范围每个像素循环轮询发放像素值为15的神经元在每个时间步都发放像素值为0的神经元从不发放中间值按时间窗口均匀分布。用一个计数器生成时间步输入层的脉冲序列通过ROM里的查找表生成完全不依赖随机数发生器硬件资源开销极低而且精度对比测试下来和软件仿真差距很小。2.3 训练侧的处理代理梯度法与权重转换SNN的神经元不可导训练是最大的坎。我试过两种路线第一种是直接在PyTorch里用代理梯度法训练SNN也就是说把发放函数的导函数替换为一个近似值典型做法是用SuperSpike或者SLAYER框架。这种方法训练出来的模型更贴合SNN的推理动态。第二种是先训练一个普通ANN然后把ReLU激活改成IF或LIF神经元的发放行为逐层校准权重。两种方法我都跑了各有优劣代理梯度法的精度稍微高一些大概高1个百分点但训练时间和调参难度大得多。考虑到这个项目的重点在FPGA部署我选择的是代理梯度法训练SNN网络结构是784-256-10隐藏层256个LIF神经元。训练超参踩了不少坑学习率太大会发散太小收敛极慢最后定在0.001配合Adam优化器。推理时统计输出层神经元的脉冲发放总数取发放数最多的类别作为分类结果。训练完成后权重是32位浮点数。FPGA部署必须要做定点化我使用Q8.8格式也就是16位定点数8位整数位8位小数位。定点化本身会引入量化误差实测下来精度损失在0.5个百分点以内完全在可接受范围内。需要注意的一个细节是SNN的膜电位动态范围随着网络深度会不断扩大所以隐藏层的膜电位存储需要留出更大的位宽比如24位而不仅仅是16位否则在深层网络中会因为截断误差导致发放行为漂移。3. FPGA加速器架构设计与硬件实现3.1 整体架构五级流水线与并行神经元阵列这个加速器的整体架构可以总结为“两个阶段、五级流水”。两个阶段是输入编码阶段和SNN推理阶段。输入编码阶段先把MNIST图片从灰度转换成脉冲序列放到一个输入脉冲缓冲区里。SNN推理阶段按照时间步循环每个时间步把输入脉冲广播到全连接的隐藏层神经元阵列中隐藏层的输出脉冲再作为下一层的输入脉冲最后一层输出十个数字类的累积发放计数。五级流水线分别是脉冲读取、权重读取、膜电位更新、阈值判断与发放生成、输出计数更新。每一级在硬件上对应独立的组合逻辑或单周期操作通过寄存器打拍隔开。在50MHz工作频率下一个时间步处理整个网络只需要一个时钟周期一个样本的推理时间等于时间步数加上固定的流水线延迟大概300个周期左右。这个速度远超我在PC上用Python模拟的SNN推理速度。并行神经元阵列方面我设计了一个16×16的神经元阵列总共256个处理单元PE正好对应隐藏层256个神经元。每个PE内部包括一个24位膜电位寄存器、一个16位权重寄存器接口和阈值比较逻辑。输入层784个神经元通过一个广播总线连接到所有PE每个时钟周期输入一个像素脉冲所有PE同时更新这就用到了FPGA的并行优势。如果一个一个更新推理时间要长16倍。3.2 片上存储与权重排布BRAM是稀缺资源MNIST的SNN权重规模是784×256加上256×10总共约20万个权重值。用16位定点数存储一共需要400KB左右的存储空间。FPGA的BRAM资源是有限的我在中端芯片上可用BRAM大概几十块到一百多块每块BRAM是36Kb。所以简单地把所有权重放进BRAM是行得通的但要注意排布策略避免同一时钟周期多个PE访问同一块BRAM导致端口冲突。我把权重矩阵按神经元维度拆分成多个分量每个分量存入独立的BRAM块中配合多端口设计来避免冲突。具体来说输入层到隐藏层的权重矩阵是784×256按256列拆分成多个子矩阵256个PE同时读取不同列的数据不存在访问冲突。隐藏层到输出层的权重是256×10体量很小直接放在分布式RAM里就行。这里有个很实用的技巧如果BRAM不够用可以把权重矩阵的低位部分截断用Q4.4格式8位定点代替Q8.8格式。实测下来MNIST任务精度损失不到0.2个百分点但存储减半。对小规模SNN来说权重精度不像CNN那么大敏感这也是SNN部署的好处之一。3.3 定点数实现详解移位代替乘法的底层逻辑SNN的神经元计算中最耗资源的操作是膜电位更新时的乘法。输入脉冲是0或1所以突触权重和输入脉冲的乘法可以用条件选择实现。比较棘手的部分是leak衰减操作原本需要做浮点乘法但只要我们巧妙选择leak值就能规避掉乘法器。具体做法是把leak值设置为1/16、1/32这样的二的负幂。衰减操作就变成右移四位、右移五位。在Verilog里一条移位语句就能完成。我在设计中是这样实现的// leak衰减膜电位向静息电位方向衰减 if (mem_pot V_REST) mem_pot_decay mem_pot - (mem_pot LEAK_SHIFT); else mem_pot_decay mem_pot ((V_REST - mem_pot) LEAK_SHIFT);这里有个细节值得提醒使用算术右移而不是逻辑右移因为膜电位是有符号数。如果直接用逻辑右移负的膜电位会被错误地变成一个很大的正数导致神经元行为完全错误。我在调试仿真时花了不少时间才发现这一点这个坑是FPGA信号处理中特别经典的符号位问题。如果模型需要更精细的leak系数不满足于二的负幂次方还有一个折中方案把leak近似为一个2的负幂次加上另一个2的负幂次比如3/64可以拆成1/32加1/64分别移位然后相加这样仍然不需要真正的乘法器。这是一条重要的优化思路在资源优化时可以灵活使用。3.4 时钟与复位设计避免亚稳态的最后一公里FPGA设计中时钟和复位是整个系统的地基。地基没打好后面全是雷。我在这个项目中经历了从痛苦到顺利的过程有几个要点值得和大家分享。系统时钟采用外部50MHz晶振输入通过FPGA内部的PLL锁相环倍频/分频来生成各模块所需的工作时钟。PLL的好处是输出时钟相位稳定抖动小能避免很多时序问题。如果直接使用外部时钟并且板上走线比较长容易导致关键路径时序违规。复位信号必须是异步复位同步释放的标准电路。FPGA开发中常见的错误是使用异步复位信号直接驱动逻辑这会导致复位信号释放时不同触发器可能在不同时钟沿退出复位状态造成系统状态不一致。这个项目的复位控制器我很早就做了是把复位作为时钟域信号打两拍之后才释放到内部逻辑。关于时钟域还要提一个点如果输入编码模块工作在高频时钟域SNN推理模块工作在低频时钟域就需要FIFO或者握手信号来做跨时钟域数据同步。我的方案是让输入编码和SNN推理跑在同一个时钟域通过状态机控制数据流绕开了跨时钟域问题完全是出于简化设计的考虑。工程上如果能用一个时钟域尽量用一个。4. 实操过程从Verilog编写到板级验证4.1 环境搭建与工具链选型开发环境我使用的是Xilinx的Vivado和Quartus两套工具链。这取决于你手里的FPGA开发板厂商。我手上的板子有Intel的DE10-Lite和Xilinx的PYNQ-Z1两个都折腾过各有各的坑。DE10-Lite是MAX 10系列资源少但便宜适合入门PYNQ-Z1是Zynq系列有ARM硬核适合做FPGA和软件协同的方案。Verilog仿真阶段我使用Vivado自带的XSim仿真器或者ModelSim。一个实打实的建议是波形仿真一定要善于使用断点和内部信号观察特别是膜电位和发放信号能帮助快速定位逻辑错误。如果仿真激励写得比较全仿真时间虽然慢但能覆盖大部分边界情况板级调试时就能少掉很多头发。关于工具链版本我更推荐使用较新的Vivado版本界面和脚本化支持更好。老版本对新的FPGA芯片支持不够且编译速度慢。但要注意版本与项目兼容性避免踩到工具自身的bug。如果是Intel FPGA需要用Quartus Prime免费版Lite就够用不需要买License。4.2 手写数字数据集预处理与ROM存储格式MNIST数据集的原始格式不是文本而是IDX二进制格式包含魔数、维度信息和像素数据。FPGA不能直接读取这个文件需要先在PC上把图像和标签转换为COE或HEX格式文件再初始化到ROM里。我直接在Python里写了一个脚本流程是把MNIST的测试集选个几十张出来按像素归一化、脉冲编码的方式生成每个时间步的输入脉冲数据保存为COE文件。这一步是有讲究的如果放整张图片的原始像素值FPGA端还需要自己处理编码多一层逻辑还多一份出错风险。在PC端预先编码好FPGA端只负责按时间步读ROM大大简化了硬件设计。关于torchvision下载MNIST数据集404的问题我前面提到了这里给出实际可用的解决办法。直接在PyTorch代码里调用下载会报404是因为MNIST官网域名挂了。有两种处理方式第一种是从其他可靠镜像源手动下载四个gz压缩包放到torchvision默认的MNIST/raw目录下再运行代码时它会自动识别本地文件跳过下载。第二种是使用mnist.pkl.gz这类预处理好的文件。我当时是手动下载解压后再用脚本生成我需要的数据格式两个步骤一起完成。顺便提醒一点下载数据集时先校验一下文件大小。原始train-images-idx3-ubyte.gz大约是9.45MBt10k-images-idx3-ubyte.gz大约是1.57MB如果大小出入很大多半是下载不完整或者下了个假的HTML错误页。4.3 顶层模块设计输入编码要点顶层模块的接口设计为时钟输入、复位输入、开始推理信号、类别输出和完成信号。核心逻辑包括三个子模块脉冲生成器、SNN计算阵列和输出解释器。脉冲生成器的实现是最容易出bug的部分。你要确保在每个时间步输入层784个神经元里面需要发放脉冲的那个位置是正确的。我的做法是把预编码好的脉冲序列按时间步存成二维数组在ROM里打平成一维用时间步计数器和像素索引拼接成ROM地址。一个特别容易犯的错误是脉冲生成器的时间步长和SNN计算阵列的时间步长没对齐导致首尾像素错位。这个问题我当时查了很久后来在仿真波形里观察到输入脉冲序列和权重矩阵的对应关系错开了一个时间步修正的方法是在状态机里增加一拍延迟补偿。这种微妙的时间对齐问题在纯软件模拟里是不会遇到的但在硬件设计里随着流水线级数增加时序对齐问题会越来越突出。4.4 仿真验证与上板实测结果仿真阶段我写了详细的testbench包含三个测试用例固定随机种子测试、全零输入测试保证无神经元发放、全高输入测试所有神经元饱和发放。全零和全高是边界测试目的是快速暴露溢出和逻辑错误。正常输入图片的测试里我在testbench中打印每个时间步隐藏层的发放统计和Python软件仿真结果对比确保硬件行为完全一致。硬件上板流程是综合、实现、生成比特流、下载。第一次下载之后板子上的LED显示的分类结果完全不对不是乱码就是固定输出某个数字。后来排查发现是因为ROM初始化数据地址错位导致输入数据全错。正确的排查手段是先做硬件环路测试让FPGA输出ROM里存的内容和PC端生成的原始内容逐位对比。一旦这一层打通后面的正确率就水到渠成。最终在50MHz工作频率下单张MNIST测试图片的推理延迟约为6微秒在10000张测试集上的准确率稳定在96.5%左右。这个数字没有达到纯浮点软件仿真的98.2%但考虑到定点量化、速率编码和硬件近似带来的损耗已经是非常可接受了。5. 常见问题与排查技巧实录5.1 数值溢出与量化误差控制SNN推理最常遇到的问题就是数值溢出尤其是用定点数表示膜电位时。如果膜电位累加超过定点数的最大值它会环绕变成负数神经元的行为就完全乱掉了。解决方法是给膜电位预留足够多的整数位。我的经验是隐藏层的膜电位最大可能值理论上等于输入脉冲数乘以权重最大值再除以leak衰减因子。对这个项目来说输入层784个像素16位权重的最大值是127理想情况下膜电位可以达到784乘以127接近100000这远超16位有符号数的范围。但实际上因为权重有正有负且脉冲发放后膜电位会重置实际最大膜电位远小于理论上限。我做了多次试验后把膜电位定为24位有符号数最大表示范围是正负8388608留了很大的裕量实测没有溢出。如果你在设计中发现某个层经常达到最大膜电位那大概率是权重太大或者leak衰减太慢。正确做法是重新标定量化参数而不是一味扩大位宽。5.2 权重初始化与SNN神经元参数调参经验SNN训练中权重初始化和神经元阈值、leak时间常数这组参数高度耦合。在相同权重下调大阈值会让神经元发放率降低调小阈值会让网络过度发放。合适的阈值应该是输入加权和分布在阈值附近的这样一个量级。我的做法是初始化权重时用标准正态分布乘以一个0.1的缩放因子阈值设在50上下leak系数设为1/16。训练时监控每层的平均发放率理想值在10%到30%之间如果太低就降低阈值如果太高就调大leak系数。这里有个实用的技巧你可以在PyTorch训练代码里加入发放率统计的回调函数每个epoch结束后打印出来根据统计结果手动调整参数而不是盲目堆epoch。训练30个epoch后FPGA上的推理精度就不再提升说明模型已经收敛。正常的MNIST训练曲线应该是快速上升然后平台化如果训练损失不下降大概率是代理梯度的尺度不合适。代理梯度函数的宽度参数很关键太窄梯度消失太宽梯度失真需要调实验取折中值。5.3 时序收敛与资源优化笔记综合实现完成后时序收敛是上板前最重要的一关。我第一版设计在100MHz下时序收敛不了关键路径出现在膜电位比较器链路上。那时候花了不少时间优化核心手段是插入流水线寄存器打拍。比较逻辑拆成两步第一步算膜电位加权重第二步做阈值比较关键路径延迟直接降低了30%以上。资源优化的另一个思路是复用。如果BRAM不够用可以把输出层或者隐藏层的权重矩阵分时复用。MNIST规模比较小我享受了全并行的奢侈不用做复用。如果是大一点的网络就必须做时间片复用。原则是计算速度和资源占用的权衡这个要看项目需求没有绝对的答案。5.4 常见问题速查表问题现象可能原因解决办法输出恒为0输入脉冲未正确生成或权重全为0检查ROM是否初始化正确检查端口接线和地址生成逻辑输出恒为同一类别阈值设置过高或过低降低阈值看是否输出变化检查输出层发放计数逻辑精度比软件低很多量化位宽不足或编码方式不匹配增大定点数位宽改用更精确的编码方案仿真正常但上板失败时钟或复位问题检查PLL锁定状态确认复位释放电路时序时序不收敛关键路径过长插入流水线寄存器使用更少的组合逻辑层级板级发热严重工作频率过高或翻转率过大降低时钟频率增加时钟门控或使能信号输出竞态不稳定多时钟域同步问题统一时钟域使用FIFO增加握手信号6. 后续优化方向把准确率再往上推一截当前版本96.5%的识别率在硬件部署验证上已经能说明问题但我后来试过三个优化方向都可以明显提升效果在此分享给想继续深入的人。第一个方向是增加时间步长。速率编码的时间窗口从64步扩展到128步每个像素的灰度值表达更精细精度可以提升约0.8个百分点。代价是推理延迟翻倍。这个方法改动最小只需要修改ROM中的脉冲序列生成方式和状态机计数上限。第二个方向是把速率编码换成更高效的时间编码。我在测试中发现时间编码在短时间窗口下的精度表现优于速率编码因为每个神经元的发放时刻承载了更丰富的信息。这个改动的成本是脉冲生成模块更复杂但对MNIST这个场景来说时间编码的精度上限能推到97.5%以上。第三个方向是权重再训练量化。在FPGA上直接跑训练不可能但可以先用PyTorch模拟定点数和脉冲发放行为做量化感知训练也就是训练过程中就让模型遇到量化误差这样可以提前自适应训练出来的模型在部署到FPGA后精度下降会小很多。做个简单的延展估算如果把上面三个优化方向全部应用推理精度理论上可能接近98%逼近软件浮点模型的水平。后面的工作量其实不在SNN算法本身了而在于工程打磨——更精细的时序优化、更大的网络结构、更复杂的数据集迁移。但核心方法论已经在这套项目里完整跑通了剩下的就是依葫芦画瓢照着做。我在整个项目的实际开发中最深的体会是“硬件设计和算法设计必须从一开始就协同考虑”。如果你先把软件SNN模型调得尽善尽美然后再考虑硬件实现很多软件层面的优雅设计根本映射不到FPGA上返工几乎不可避免。反过来从硬件约束出发设计SNN模型每一步取舍都有据可依整个过程会顺利很多。对于新手我建议不要一上来就追求网络规模和数据集的宏大把MNIST这个小数据集在FPGA上跑通你会收获一整条从算法到硬件、从训练到部署的完整方法论这个能力迁移到任何其他项目都会非常值钱。
返回列表