ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA上实现基础卷积加速:从行缓冲到流水线设计

FPGA上实现基础卷积加速:从行缓冲到流水线设计

1. 为什么要在FPGA上跑卷积

搞深度学习的人大多有个惯性思维:模型训练和推理默认跑在GPU上,算力不够就堆卡,显存不够就换大卡。但真到了落地环节,尤其是边缘侧、嵌入式设备、工业现场这些场景,GPU的功耗和成本就成了绕不过去的坎。一张入门级推理卡动辄几十瓦的功耗,放在需要长时间稳定运行的设备里,散热和供电都是麻烦事。这时候FPGA的优势就体现出来了——功耗低、延迟确定、接口灵活,而且可以针对特定网络结构做定制化流水线。

卷积操作是卷积神经网络里计算量最大的部分,也是整个网络里最适合做硬件加速的环节。一个标准的卷积层,计算量往往占到整个网络的百分之七八十以上。把这部分用FPGA做加速,收益最直接。但问题在于,卷积本身是个多层嵌套循环,输入特征图、权重、输出特征图之间的数据复用关系复杂,直接往FPGA上搬,很容易写成“能跑但跑不快”的版本。

我这次做的项目,目标很明确:从最基础的卷积操作入手,在FPGA上实现一个可综合、可仿真、结构清晰的卷积加速模块。不追求一步到位做成完整的推理引擎,而是先把单层卷积的硬件实现吃透,把数据流、时序、资源这三者的平衡关系摸清楚。适合谁看?如果你有基本的数字电路基础,写过Verilog或VHDL,对CNN的原理有概念,但没怎么碰过硬件加速,那这篇内容正好对路。如果你已经是老手,也可以看看我在流水线设计和BRAM复用上的取舍思路,说不定能碰出点新想法。

2. 整体方案设计与思路拆解

2.1 为什么选“基础卷积”作为切入点

很多人一上来就想做完整的CNN加速器,结果卡在第一个卷积层就动不了了。原因很简单:卷积层的参数组合太多——输入通道数、输出通道数、卷积核尺寸、步长、填充方式,每一种组合对应的硬件结构都不一样。如果一开始就想着做一个通用加速器,代码会写得极其臃肿,调试起来更是噩梦。

我的思路是先把最基础的情况做扎实:单输入通道、单输出通道、3x3卷积核、步长为1、无填充。这个配置虽然简单,但它包含了卷积操作的所有核心逻辑——乘累加、滑动窗口、数据复用。把这个版本跑通之后,再扩展到多通道、多卷积核,就是在这个骨架上做参数化扩展,而不是推倒重来。

提示:不要小看这个简化。很多实际网络的第一层卷积,输入通道数就是1(比如灰度图输入),或者输出通道数很少。把基础版本做稳,后面扩展才有底气。

2.2 数据流架构的选型考量

FPGA上实现卷积,数据流架构主要有三种思路:第一种是“行缓冲+滑动窗口”,数据按行流入,在片上维护一个窗口寄存器组,每个时钟周期算一个输出点;第二种是“全图缓存”,把整张特征图存进BRAM,然后按地址读取做计算;第三种是“脉动阵列”,用二维PE阵列做并行乘累加。

我选的是第一种,原因有三个。第一,行缓冲结构对BRAM的消耗最小,只需要缓存几行数据,而不是整张图。第二,滑动窗口的寄存器组天然匹配卷积的局部性,数据复用率高,不需要反复读BRAM。第三,这种结构的时序容易收敛,流水线级数可控,适合在中小规模FPGA上跑。

全图缓存的方式虽然控制逻辑简单,但BRAM消耗大,而且读取带宽容易成为瓶颈。脉动阵列性能上限高,但设计复杂度陡增,对新手不友好。行缓冲方案在资源、性能、复杂度三者之间取得了比较好的平衡。

2.3 定点数还是浮点数

这个问题几乎每个做FPGA加速的人都会纠结。浮点运算在FPGA上要么用硬核DSP(很多低端芯片没有),要么用软核实现,资源消耗大、时序差。定点数则天然适合FPGA的DSP切片,一个DSP48切片就能完成一次乘累加,效率极高。

我选的是16位定点数,8位整数部分加8位小数部分。为什么是16位而不是8位?8位定点在卷积累加过程中很容易溢出,尤其是3x3卷积,9个乘积相加,中间结果的范围会扩大不少。16位留出了足够的余量,同时DSP48对16位乘法的支持也很好。小数部分留8位,精度对于基础卷积来说够用,量化误差在可接受范围内。

注意:定点数的位宽选择不是拍脑袋决定的。你需要先估算输入数据的动态范围,再算卷积核权重的范围,然后推导乘累加结果的最大可能值,最后留出至少一倍的余量。这个计算过程我在后面会详细展开。

3. 核心细节解析与实操要点

3.1 行缓冲与滑动窗口的硬件映射

行缓冲的核心思想是:图像数据按行串行输入,片上维护一个深度为“卷积核宽度×图像宽度”的移位寄存器组。每来一个像素,整个寄存器组移位一次,窗口内的数据就更新一次。对于3x3卷积核,需要缓存3行数据,每行缓存当前窗口所需的3个像素。

具体实现上,我用三个行缓冲器,每个行缓冲器的深度等于图像宽度。数据流入时,第一行数据先填满第一个行缓冲器;当第二行数据到来时,第一行数据开始移入第二个行缓冲器,同时第二行数据填入第一个行缓冲器;第三行数据到来时,三个行缓冲器同时工作,滑动窗口就可以从三个行缓冲器的对应位置取出9个像素值。

这里有个细节容易踩坑:行缓冲器的读写地址控制。如果地址逻辑写错了,窗口数据就会错位,卷积结果完全不对。我的做法是用一个模图像宽度的计数器,每个时钟周期递增,到达图像宽度减一时归零。三个行缓冲器共用这个计数器,但读地址分别偏移0、1、2个位置,这样就能同时取出三行中对应的三个像素。

3.2 乘累加单元的设计与流水线划分

3x3卷积需要9次乘法和8次加法。如果在一个时钟周期内完成所有运算,组合逻辑路径太长,时序肯定跑不上去。我的做法是插入流水线,把乘累加分成三级:第一级做9个乘法,第二级做部分加法(比如先加前3个、再加中间3个、再加后3个),第三级做最终累加并输出。

这样划分之后,每个时钟周期只需要完成一次乘法加一次加法,关键路径大大缩短。代价是引入了2个时钟周期的延迟,但对于卷积这种数据流连续的操作来说,延迟不影响吞吐率——流水线填满之后,每个时钟周期仍然可以输出一个卷积结果。

DSP48切片的使用也有讲究。Xilinx的DSP48E1支持预加器,可以把两个数先相加再送入乘法器。利用这个特性,我可以把卷积核的某些系数预先组合,减少DSP的使用数量。不过对于基础版本,我暂时没有做这个优化,保持结构清晰更重要。

3.3 权重存储与读取策略

3x3卷积核只有9个权重,数量很少,直接用寄存器存储就行,不需要BRAM。但权重的加载方式需要考虑:是在综合时写死,还是运行时通过接口配置?我选的是后者,用一个简单的SPI接口或者并行加载接口,在卷积开始前把9个权重写入寄存器组。这样同一个硬件模块可以支持不同的卷积核,灵活性更好。

权重的定点数量化也需要留意。训练好的浮点权重需要乘以2的8次方(因为小数部分8位),然后取整。取整时用四舍五入而不是直接截断,能减少量化误差。我实测下来,四舍五入比截断的最终精度能高出两三个百分点,代价几乎可以忽略。

实操心得:权重加载完成后,最好加一个“加载完成”标志位,卷积模块检测到这个标志位后才开始接收图像数据。否则可能出现权重还没写完,卷积就已经开始计算的情况,结果全是错的。

4. 实操过程与核心环节实现

4.1 定点数量化参数的完整计算过程

假设输入图像像素值范围是0到255,用8位无符号整数表示。卷积核权重是训练好的浮点数,范围大约在-1到1之间。我需要把两者都转成16位定点数。

输入像素转定点:8位整数部分直接放在高8位,低8位补零。这样0到255就映射到0到255×256的范围。

权重转定点:浮点权重乘以256(2的8次方),然后四舍五入取整。比如权重0.5变成128,权重-0.25变成-64。这样权重范围大约在-256到256之间,用16位有符号数表示绰绰有余。

乘累加结果的范围估算:单个乘积最大是255×256×256=16,711,680,9个乘积相加最大约150,405,120。这个值需要多少位?2的27次方是134,217,728,2的28次方是268,435,456。所以至少需要28位才能保证不溢出。我在代码里用32位累加器,留出足够余量。

最终输出需要右移8位(因为权重放大了256倍),然后做饱和截断到8位。饱和截断的意思是:如果结果大于255就取255,小于0就取0。这一步很关键,不做饱和处理的话,输出会回绕,图像上会出现莫名其妙的亮斑或暗斑。

4.2 Verilog核心模块的代码结构

整个设计分为三个模块:行缓冲模块、乘累加模块、顶层控制模块。行缓冲模块负责缓存三行数据并输出滑动窗口;乘累加模块接收9个像素值和9个权重,做流水线乘累加;顶层控制模块负责状态机控制、权重加载、输出有效信号生成。

行缓冲模块的核心是一个双端口BRAM的推断。我用的是简单双端口模式,一个端口写,一个端口读。写地址就是输入像素计数器,读地址是写地址减去图像宽度(读上一行)和减去两倍图像宽度(读上上行)。这样不需要额外的地址生成逻辑,BRAM的读写时序也容易满足。

乘累加模块里,9个乘法器并行例化,每个乘法器后面跟一个寄存器。第二级加法用三个加法器,分别计算前三、中三、后三的和。第三级用一个加法器把三个部分和加起来。整个流水线深度为3,吞吐率为每时钟周期一个输出。

顶层控制模块的状态机有四个状态:IDLE、LOAD_WEIGHT、CONV、DONE。IDLE等待启动信号;LOAD_WEIGHT接收权重;CONV接收图像数据并输出卷积结果;DONE在图像数据接收完毕后产生一个完成脉冲。

4.3 仿真验证与结果对比

仿真平台用的是业内常用的HDL仿真器。测试激励是一张8x8的灰度图,卷积核是一个3x3的均值模糊核(所有元素都是1/9)。预期输出是每个像素取3x3邻域的平均值。

仿真波形显示,行缓冲模块在第三个像素输入后开始输出有效窗口数据,乘累加模块在窗口数据有效后3个时钟周期输出第一个卷积结果。整个8x8图像处理完毕共用了约70个时钟周期,其中包括流水线填充的延迟。

把仿真输出的定点数转回浮点数,和Python用NumPy做的参考卷积结果对比,最大误差在0.02以内。这个误差主要来自权重的定点量化,对于基础卷积来说完全可以接受。如果后续要做多层网络,这个误差会累积,那时候可能需要考虑更高精度的定点格式或者混合精度方案。

注意:仿真通过不代表上板就能跑。上板之前一定要做时序约束,尤其是时钟频率的约束。我一开始没加约束,综合出来的电路跑在50MHz都不稳定,加了约束之后轻松跑到100MHz。

5. 常见问题与排查技巧实录

5.1 卷积结果整体偏移或错位

这是最常见的问题,十有八九是行缓冲的地址逻辑写错了。排查方法很简单:把输入图像设成一张只有中心一个白点的图,看输出图像的白点位置对不对。如果白点偏移了,就检查行缓冲的读地址偏移量。对于3x3卷积,读地址应该是写地址减去图像宽度再减1、减图像宽度、减图像宽度加1,分别对应窗口的左上、中上、右上。

另一个可能的原因是图像宽度参数没有正确传递。行缓冲的深度必须等于图像宽度,如果参数写错了,整个窗口都会错位。我建议把图像宽度做成模块参数,在例化时显式传递,不要用宏定义或者全局常量。

5.2 时序不收敛,综合报建立时间违例

时序问题通常来自两个地方:乘法器的组合逻辑路径太长,或者BRAM的读延迟没有处理好。对于乘法器,确保每个乘法后面都跟了寄存器,不要让两个乘法器的输出直接相加。对于BRAM,如果用的是标准模式(读延迟2个时钟周期),需要在读地址和读数据之间插入对应的流水线寄存器,否则数据会对不上。

如果时序还是紧张,可以尝试降低时钟频率,或者把乘累加流水线再拆细一级。我试过把第三级加法再拆成两级,时序余量从0.2ns提升到1.5ns,代价是延迟增加一个时钟周期,但吞吐率不变。

5.3 资源利用率过高,BRAM不够用

行缓冲方案本身对BRAM的需求不大,但如果图像宽度很大(比如1920),三个行缓冲器就需要3×1920×16位的存储空间,大约11.5KB。对于小规模FPGA来说,这个数字可能有点紧张。

优化方法有两个:一是用分布式RAM代替BRAM,把行缓冲器映射到LUTRAM里,节省BRAM资源;二是降低数据位宽,如果输入是8位,行缓冲器就用8位存储,不要用16位。我实测下来,8位存储对最终精度的影响很小,但BRAM消耗直接减半。

5.4 常见问题速查表

问题现象可能原因排查方法解决措施
输出全零权重未加载或加载错误检查权重加载状态机确保LOAD_WEIGHT状态正确执行
输出整体偏移行缓冲读地址错误用单点图像测试修正读地址偏移量
输出有周期性噪声累加器溢出检查累加器位宽增加累加器位宽或做饱和处理
时序不收敛组合逻辑路径过长查看时序报告插入流水线寄存器
BRAM不够行缓冲深度过大查看资源报告改用分布式RAM或降低位宽

实操心得:调试卷积模块时,一定要先用小尺寸图像(比如8x8)做仿真,波形容易看,问题容易定位。等小图跑通了再上大图,否则波形太长,根本看不出问题在哪。

6. 从基础版本到多通道扩展的演进思路

基础版本跑通之后,下一步自然是扩展到多输入通道和多输出通道。多输入通道的处理方式是:每个输入通道独立做卷积,然后把所有通道的结果累加起来。硬件上可以复用同一个乘累加模块,分时处理不同通道,也可以例化多个乘累加模块做并行。分时处理节省资源但吞吐率低,并行处理吞吐率高但资源消耗大,具体选哪种要看你的FPGA规模和性能需求。

多输出通道则是每个输出通道有一套独立的权重,共享同一个输入窗口。硬件上可以并行例化多个乘累加模块,每个模块用自己的权重,同时计算多个输出通道。这样输入窗口只需要读取一次,数据复用率更高。

再往上走,还可以加入池化层、激活函数的硬件实现,最终做成一个完整的卷积层加速器。但那是后话了,基础卷积这一关过不了,后面都是空中楼阁。

我个人在实际操作中的体会是:FPGA做卷积加速,最难的不是写代码,而是想清楚数据怎么流动。把数据流图画出来,把每个时钟周期数据在哪、算什么、存哪里都标清楚,代码自然就写出来了。反过来,如果数据流没想明白就动手写Verilog,大概率会陷入反复改代码、反复仿真的泥潭。另外,定点数的位宽选择要留足余量,宁可多用几位,也不要为了省资源而冒溢出的风险。溢出导致的错误在仿真里可能看不出来,上板之后才暴露,那时候排查成本就高了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进