纯Verilog实现脉动阵列:FPGA低延迟车牌识别加速器设计
车牌识别在停车场、高速收费、园区门禁这些场景里要求早就不是“能识别”就行而是“够快、够稳、够便宜”。GPU方案性能强但几十毫秒的推理延迟、几十瓦的功耗、复杂得能劝退一堆嵌入式工程师的驱动栈放在前端设备上并不总是最优解。这个项目走了一条更硬核也更可控的路用纯Verilog实现脉动卷积阵列Systolic Array加速器在FPGA上完成车牌检测和识别整条链路追求超低延迟并且同一份RTL代码同时跑在Xilinx和紫光同创两家的FPGA上。写这篇东西的初衷很简单。网上讲脉动阵列的文章不少但绝大多数停留在概念图和矩阵乘公式上真正告诉你“数据流怎么排、PE阵列怎么挂、换国产FPGA要改哪些文件”的几乎没有。这篇我会按一个真实工程的路子走一遍为什么选脉动阵列、PE和行缓冲怎么设计、车牌识别网络怎么拆进硬件、Xilinx和紫光同创双平台部署时要隔离哪些差异、调试中踩过哪些坑。适合有基础Verilog积累、想自己从零搭CNN加速器的FPGA工程师也适合准备做视频图像处理项目、正在纠结方案选型的朋友。1. 项目定位为什么一个“纯Verilog”能打1.1 车牌识别的延迟瓶颈到底在哪一套典型的前端车牌识别系统链路是这样的摄像头采集图像图像经过接口送到处理器处理器跑预处理、车牌定位、字符识别最后把结果通过串口或网络发出去。如果用CPU方案图像往往要先经过一段USB或者网线在主机侧经过OpenCV、深度学习框架、系统调度这一层层开销端到端延迟很容易到50ms以上。GPU方案虽然推理快但功耗和散热在户外卡口、道闸设备里非常尴尬而且冷启动、驱动版本、掉卡恢复这些问题在无人值守场景里都是隐患。FPGA的价值在于把“采集-预处理-检测-识别-输出”全部放在同一颗芯片上完成。图像数据从传感器进来之后不需要出芯片中间结果通过片上BRAM和行缓冲流动不依赖DDR搬移延迟自然就压下来了。我在这个项目里的目标是从帧同步信号有效到识别结果输出端到端延迟控制在几毫秒量级为后续接道闸控制或收费系统留出足够多的响应余量。1.2 为什么选脉动阵列而不是通用卷积器做CNN加速器第一反应往往是搭一组并行的乘累加MAC单元每个单元独立算卷积的一部分。这种“广播式”MAC阵列结构简单但有个隐藏问题每个计算周期输入数据要同时广播给大量PE片上存储和寄存器堆的带宽压力会非常大。一旦网络层数变多、通道数变大带宽立刻成为瓶颈大量PE闲着等数据加速比上不去。脉动阵列的思路刚好反过来。每个PE只和相邻的PE通信输入数据像流水一样“流”过整个阵列权重和激活值在流动过程中被反复复用。它把对存储带宽的要求转换成了对数据流组织的设计要求。对于车牌识别这种小网络脉动阵列的优势体现得特别充分网络层数少、单层特征图尺寸不大阵列一旦填满几乎每个时钟周期都能吐出一个有效结果没有广播风暴也没有中间结果反复存取DDR的浪费。1.3 为什么坚持纯Verilog、同时兼容两条工具链能用HLS的地方我坚决不用能用IP核的地方我尽量自己写。这不是“代码洁癖”是这个场景的现实约束。第一超低延迟目标要求每条关键路径的时序都可控纯RTL综合出来的逻辑工程师能精确到拍数HLS生成的逻辑有时候一个循环展开方向的差异流水延迟就差出几十拍。第二项目交付时经常面临“Xilinx的板子做量产、紫光同创的板子做国产化备选”的情况纯Verilog的可移植性远好于Vivado HLS工程或者深度绑定Vitis的流程。紫光同创的PDS工具链对高版本Vivado/HLS生态支持有限但对标准Verilog RTL是完全兼容的。当然完全不碰厂商IP也不现实。时钟、BRAM、DSP这些底层资源两家的原语和IP配置不一样。我的做法是在RTL里加一层薄薄的平台适配层shim顶层逻辑只调用自己定义的接口不直接例化厂商原语实际用Xilinx还是紫光同创只改这一层适配文件。这套结构之后在第四章展开讲它是跨平台部署的核心。2. 核心设计脉动阵列的数据流与关键参数2.1 PE单元与权重静止数据流先看最小的计算单元。脉动阵列里的每个PE核心是一个带权重寄存器的乘累加器。我用的数据流是权重静止Weight Stationary权重在计算开始前从外部逐列写入PE内部的寄存器计算过程中权重不动激活值沿着水平方向一个接一个流进PE阵列部分和在垂直方向向下累加。等一行数据流完阵列底部输出的就是完整的输出特征图结果。module pe #(parameter W 8, parameter ACCW 32) ( input wire clk, input wire rst_n, input wire signed [W-1:0] act_in, // 来自左方PE的激活值 input wire signed [W-1:0] weight, // 权重加载端口 input wire signed [ACCW-1:0] psum_in, // 来自上方PE的部分和 input wire w_en, // 权重装载使能 output reg signed [W-1:0] act_out, // 向右方PE传递激活值 output reg signed [ACCW-1:0] psum_out // 向下方PE传递部分和 ); reg signed [W-1:0] w_reg; wire signed [ACCW-1:0] prod $signed(act_in) * $signed(w_reg); always (posedge clk or negedge rst_n) begin if (!rst_n) begin act_out d0; psum_out d0; end else begin act_out act_in; if (w_en) w_reg weight; psum_out psum_in prod; // 关键路径加法链 end end endmodule这段代码看起来简单实际上藏着整个设计最关键的时序问题psum_out psum_in prod是一条沿着阵列垂直方向逐级向下传递的加法链。阵列规模小时无所谓一旦做到16x16甚至32x32这条链上的组合逻辑延迟会直接决定系统能不能跑到目标频率。我的做法是中间插入流水寄存器每级PE输出的部分和额外打一拍代价是流水延迟增加几拍换来的却是频率可以稳定跑上200MHz甚至更高。对延迟敏感的场景多这几拍完全可接受。2.2 卷积到脉动阵列的映射理论上的脉动阵列做的是矩阵乘法但CNN卷积是三维运算不能直接往里灌。最正统的映射思路是先做im2col把输入特征图展开成矩阵但im2col需要额外开辟buffer非常费BRAM。我采用的是一种针对3x3卷积核做了裁剪的方案行缓冲line buffer缓存输入图像的连续三行按3x3滑动窗口取出当位置上的9个像素再把这9个值按固定顺序送入PE阵列的对应权重列。映射关系是这样的。PE阵列按“输出通道”分组每个输出通道的3x3x输入通道个权重占用一条PE列组。以第一层卷积为例输入是灰度图单通道3x3卷积核只有9个权重那一列9个PE就能装下整个kernel。计算时特征图的行数据从左向右流过这9个PE每个PE负责kernel的一个位置部分和在垂直方向累加底部输出结果。多输出通道时阵列宽度方向扩展每增加一个输出通道就增加一列PE组。这种映射方式不是最高效的通用矩阵乘法方案但它省掉了im2col的地址生成和buffer管理数据流非常干净硬件开销小非常适合车牌识别这种小网络、定长输入的场景。卷积层的padding、stride处理也都在行缓冲控制器里解决不额外占用计算单元。2.3 位宽、量化与饱和策略车牌识别对精度要求没到自动驾驶那种苛刻程度int8量化完全够用。输入图像本身是8bit灰度数据卷积权重我直接用8bit有符号数累加器做到32bit防止中间溢出。真正需要注意的不是位宽而是每层输出怎么截断。CNN的激活值范围逐层差异很大第一层卷积的输出范围可能是[-200, 300]到了后面几层可能就变成[-2000, 5000]了。如果两层之间都用同一个固定scale去截断精度损失会迅速累积。我的做法是逐层量化。离线阶段用一批车牌图像跑一遍浮点模型统计每一层输出的动态范围把每层的scale因子算好固化成一个查找表。FPGA侧在每个卷积层输出后根据当前层的scale做一次移位加饱和。scale尽量取2的幂这样乘法就变成了移位操作省掉的DSP资源可以留给更大的PE阵列。实践经验是相比统一scale逐层scale能稳定提升车牌字符识别的准确率大约1到2个百分点代价只是每层多一个移位器非常划算。2.4 端到端流水线与延迟估算整个加速器不是一次计算完整个网络而是按层复用一个16x16的脉动阵列。调度器是一个有限状态机按网络的拓扑顺序逐层触发每层开始前先通过权重加载端口把当前层的卷积核写入PE寄存器然后打开输入数据流水。算一笔账。我采用的识别网络输入是32x96的车牌灰度图结构是三层卷积加一层全连接conv13x3x1x8、池化、conv23x3x8x16、池化、conv33x3x16x32、全连接输出7个字符位各34类的分数。整个网络的乘累加总量大约2000万次16x16阵列共256个MAC单元理论上只要大约8万周期。跑200MHz时纯计算时间约0.4毫秒加上层间切换、权重装载、行缓冲排空这些开销整个识别前向过程可以控制在1毫秒以内。检测环节用Sobel边缘加形态学投影处理和卷积部分是并行流水的因此我实测整链路延迟稳定在3毫秒左右完全达到“超低延迟”的立项目标。3. 车牌检测与识别的工程实现3.1 图像预处理链路预处理模块直接挂在传感器输出后端采用AXI-Stream流式结构。首先做RGB转灰度用加权平均公式三个系数固定成8bit避免使用浮点。然后是ROI区域裁剪和缩放这一步很关键因为车牌在画面中的位置和大小随安装环境变化前端设备通常会把检测区域限定在一个预先标定的框内只对这个区域做后续处理能省掉大量无效计算。灰度转换和ROI裁剪都是逐像素流式操作不需要缓存整帧图像。缩放模块稍微麻烦一点我用了最朴素的最近邻虽然画质一般但胜在实现简单、时序稳定且实际测试中车牌字符这种高对比度内容对最近邻缩放并不敏感。预处理输出直接接到脉动阵列的行缓冲入口中间没有DDR参与数据延迟只有几十个时钟周期这是整个系统低延迟的第一个来源。3.2 检测与识别网络的硬件调度检测部分我没有上大目标检测网络。车牌场景有非常强的先验车牌色彩信息明确、边缘梯度方向集中、字符排列规则。所以检测链路用Sobel算子算梯度幅值再做水平投影和垂直投影结合连通域分析定位车牌区域。这套传统CV流程在FPGA上实现量小、延迟低实测在各种光照条件下对标准车牌的召回率足够稳定。真正用上脉动阵列的重头戏是识别部分定位到车牌区域后从原图中裁出字符区域图片缩放归一化后送入轻量CNN由16x16脉动阵列完成全部卷积计算。硬件调度器用了一个主状态机加若干计数器实现。每个卷积层运行前状态机先进入LOAD阶段此时PE阵列的权重使能信号逐列拉高将当前层的权重从BRAM写入PE寄存器。写入完成后进入RUN阶段行缓冲开始向阵列灌数据同时用计数器跟踪当前输出的行号和列号。最后一层计算完成后状态机进入DRAIN阶段等待流水线内的最后几个部分和从阵列底部排空。这个“LOAD-RUN-DRAIN”三段式结构写起来不复杂但调试时对时序的要求很高第五章我会专门讲这里踩过的坑。3.3 识别输出与后处理识别网络的最后一层是类似全连接的结构输出7个字符位各自在34个字符类别上的分数向量。硬件后处理模块对这7个分数向量做argmax得到每个字符位的类别编号和置信度加上一个车牌整体的置信度阈值判断最后打包成自定义的UART帧协议发送出去。整个过程没有一个CPU参与纯RTL完成。这里有个值得说的小细节最后一层的7x34分数矩阵我本来想直接做成一个二维寄存器阵列但综合后发现这部分的FF和查找表消耗比想象中大。后来改成复用脉动阵列本身来做矩阵乘把权重预先放到PE里输入分数向量从侧边流入结果从底部出来阵列利用率几乎满载。这样既省了额外硬件也让整个设计对“脉动阵列”这个核心的依赖更彻底。对于想让加速器跑多种模型的朋友这种复用一个阵列把卷积和全连接都算完的思路是降低资源占用非常有效的做法。4. Xilinx与紫光同创双平台部署实测4.1 两套工具链的基本差异Xilinx侧用的是Vivado从综合、实现到时序分析一体化IP核生态成熟Clocking Wizard、Block Memory Generator、AXI-Stream接口这些都很好用。紫光同创侧用的是官方PDS工具界面和操作逻辑跟Vivado有相似之处但IP配置方式、约束文件格式、部分原语名称都不一样。如果一个人只熟悉Vivado第一次接触PDS最直观的感受可能是“怎么什么都得手动配”以及“综合报告和布局布线的信息密度差一截”。建筑材料本身也有差异。Xilinx 7系列的BRAM是36Kb块可拆成两个18Kb使用紫光同创的BRAM块更接近18Kb粒度配置时要注意容量换算。DSP单元方面Xilinx有带预加器的DSP48E1紫光同创的DSP单元也支持乘加操作但流水级数和端口时序不完全一样。这些差异在RTL中直接例化IP时会产生大量不一致因此接口隔离不是“可选项”而是双平台部署的前提。4.2 可移植RTL的编写规范跨平台设计不是把代码拷过去就能跑通的需要从一开始就控制RTL的写法。我的工程目录里顶层是sys_top.v里面只做模块例化和数据流连接不直接例化任何厂商原语。所有厂商相关的逻辑都收在platform_xilinx.v和platform_unisoc.v两个文件中对外暴露完全一致的接口比如clk_gen生成时钟、rst_sync做异步复位同步释放、ram_wrapper封装BRAM读写延迟、dsp_wrapper封装乘加单元。顶层通过一个宏定义选择例化哪个适配文件其余逻辑代码完全共用。module platform_top #(parameter CLK_FREQ 200_000_000) ( input wire ext_clk, input wire ext_rst_n, output wire sys_clk, output wire sys_rst_n, ... ); ifdef FPGA_XILINX platform_xilinx u_platform (...); elsif FPGA_UNISOC platform_unisoc u_platform (...); else $error(No platform selected); endif endmodule这里要特别提醒BRAM读延迟是跨平台最容易出问题的地方。Xilinx的BRAM输出可以选择加寄存器读延迟可配置为1拍或2拍紫光同创的BRAM IP配置界面类似但默认延迟和Vivado不完全一致。如果RTL假定BRAM读延迟固定为某种拍数换平台后所有数据都会错位。我的做法是把BRAM封装成固定2拍读延迟的接口平台差异在wrapper内部打拍补齐上层逻辑只面对一个统一接口。4.3 资源与功耗实测对比我分别在Xilinx Artix-7系列和紫光同创Logos系列上做了实测。两个平台都跑200MHz整板功耗包括DDR和接口部分大约在1.5瓦左右纯FPGA核心逻辑功耗不到1瓦。这个功耗水平在嵌入式前端设备里基本不需要主动散热比大多数嵌入式GPU方案低了不只一个量级。资源类型Xilinx Artix-7紫光同创 Logos说明LUT约18k约21kLUT结构差异导致统计口径不同FF约16k约17k基本持平BRAM32块18Kb粒度28块18Kb粒度BRAM块容量换算后略有区别DSP单元64个64个脉动阵列的乘累加全部映射到DSP频率200MHz稳定200MHz稳定均留有5%以上的裕量核心功耗约0.8W约0.9W实测值两边的结果对比下来同一份RTL在资源消耗和行为表现上基本一致。唯一需要留意的是紫光同创PDS的时序报告口径与Vivado有细微差异WNS数值不能直接对比绝对值建议以“能否满足你设定的目标时钟约束”为准而不是纠结两个工具的report数字哪个更好看。4.4 时序收敛和物理约束脉动阵列的频率瓶颈基本就在PE阵列内部。乘法器输出到加法树再到累加器这条路径是每一个微架构工程师都绕不开的关键路径。我的优化思路是三级处理第一乘累加放进DSP单元内部完成DSP本身有内建的流水寄存器乘法后直接累加基本不额外消耗LUT第二部分和在PE间传递时插入流水寄存器让每条加法链都断成小段第三对阵列做物理区域约束在Vivado里用Pblock把16x16个PE限定在同一片SLR区域内避免布线绕远。紫光同创PDS里同样支持布局约束只是命令和脚本形式不同思路一样。还有一个小经验行缓冲控制器里的计数器逻辑虽然不复杂但综合后经常成为隐藏的时序瓶颈因为很多计数器都是几十bit宽而且使能信号要跨多个时钟周期。我习惯把计数器拆成高位和低位两个部分低位使能频繁、位宽小高位通过低位的进位触发这样能明显改善建立时间裕量。5. 踩坑记录与排查方法5.1 权重装载时序第一帧全零的坑第一次上板测试识别结果永远是“无车牌”排查了很久最后抓内部信号发现卷积输出的特征图全是0。问题出在权重装载信号和数据到达信号没有对齐LOAD阶段还没有结束RUN阶段的数据就已经开始流入阵列PE寄存器里还是全0算出来的结果自然是0。解决办法是严格按LOAD-RUN-DRAIN三段状态机执行。LOAD阶段用计数器清零并开始装载权重装载完最后一个权重后额外等两个周期确保所有PE的权重寄存器稳定写入再拉高数据有效信号进入RUN阶段。每个时钟周期都去检查状态机状态和计数器的边界条件尤其是最后一个PE的权重写入完成标志。这类问题在仿真里不一定能发现因为仿真的激励信号往往是理想对齐的而板级现场真正的数据流动会告诉你什么叫“差一拍都不行”。5.2 部分和溢出的逐层截断另一个精度相关的问题是累加器溢出。32bit累加器本身不容易溢出但每层卷积结束之后输出值必须缩放到下一层期望的8bit输入范围。如果直接丢低8位或者用固定的右移位数中间几层卷积的激活值动态范围一变精度立刻跳水。后来我改成逐层饱和截断。每层卷积的输出先乘以当前层的scale因子用移位实现然后做饱和到8bit有符号范围。scale因子的确定必须用真实车牌样本统计不能想当然。纯白车牌字符和深色车牌的激活值分布差异很大用单一测试图去定scale很容易在另一些光照条件下掉点。建议至少取几百张不同光照、不同倾斜角的车牌图像做统计让动态范围的覆盖更稳。5.3 跨平台结果不一致的两个原因最常被怀疑但实际最该排查的是BRAM读延迟差异。在Xilinx上跑正常的数据移植到紫光同创后特征图边缘偶尔出现错位的竖线。基本可以断定是BRAM读数据和消费数据的时序错拍。我统一把BRAM封装成固定延迟接口后这个问题彻底消失。另外一个隐蔽的坑是DSP单元流水级数不同。同一个乘法加累加操作在Xilinx的DSP48E1里可能是2级流水在紫光同创的DSP单元里可能是1级。如果RTL对DSP流水的延迟做了硬编码假设换平台后关键路径和数据对齐都会出问题。所以我平时写代码就要求自己把DSP相关逻辑统一封装在dsp_wrapper里延迟差异在wrapper内部补齐上层逻辑永远只看到固定延迟的接口。5.4 资源不足时的降档方案工程后期经常收到需求“能不能把阵列缩小一点给其他功能腾资源”脉动阵列的面积和PE数量基本成正比所以我把PE阵列的规模做成了参数化配置。顶层通过parameter定义列数和行数综合时只需改参数就能生成8x8、16x16等不同规格。实测8x8阵列在200MHz下识别网络的纯计算时间大概是1.6毫秒整链路延迟大约5毫秒对于很多道闸场景依然够用但资源和功耗几乎省了一半。如果资源还是不够还有第二个方案把两个输出通道合并推理。原本阵列一列对应一个输出通道改成两列共享一组权重虽然会让部分和的累加逻辑复杂一点但能进一步减少PE数量。代价是控制逻辑更繁琐、时序收敛需要多花时间。我个人的建议是不要为了省资源把控制逻辑改得太复杂优先考虑直接缩小阵列规模把复杂度留给数据流而不是留给状态机。在调试这个系统的过程中我最大的体会是脉动阵列的RTL写起来并不难难的是对数据流的精确掌控。每一个valid信号的拉高和拉低、每一拍数据在阵列里的位置都必须像流水线里的机械臂一样精确咬合。我用随机生成的车牌图片做了上万次对比测试仿真输出和板级输出逐像素完全一致这种确定性是CPU和GPU方案永远给不了你的安心感。同样的加速核换一个输入网络再花一两天调调调度器也能跑人脸检测或者工业缺陷识别。但不管怎么扩展核心始终是那句话先把数据流和时序搞清楚FPGA才真正快得起来。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →