ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于FPGA脉动阵列的超低延迟车牌识别加速器设计与实现

基于FPGA脉动阵列的超低延迟车牌识别加速器设计与实现 手头这个车牌检测识别项目断断续续做了大半年最后的上板结果还算符合预期纯Verilog搭的脉动卷积阵列加速器跑在Xilinx和紫光同创两个平台的FPGA上端到端从摄像头帧同步信号到串口吐出识别结果延迟压缩到了毫秒级。说实话做到一半的时候我曾经怀疑过这个架构选型——把卷积计算全部换成脉动阵列自己写数据调度、自己写量化、自己移植双平台每一步都有坑。但跑通之后再看这套方案在成本和功耗的约束下确实是把“超低延迟”这四个字落到实处的最稳妥路子。这篇博客就把这个项目的完整链路拆开为什么车牌识别适合脉动阵列、Verilog里阵列结构和数据流怎么设计、模型怎么裁剪和量化、Xilinx和紫光同创双平台移植又有哪些工程差异。适合正在做FPGA深度学习加速、车牌识别一体机或者准备用国产FPGA替换进口方案的工程师参考。1. 车牌场景下脉动阵列的价值算力密集但数据复用高1.1 车牌识别模型的算力画像与实时性瓶颈车牌识别本质上是一个轻量级视觉任务但它的工程约束很特殊在门禁、停车场、高速收费这些场景里运动车辆通过抓拍点的速度不慢系统需要在一两帧内完成“检测车牌位置识别字符”两步。后端服务器的方案延迟不可控尤其联网和排队带来的抖动根本没法掐表算。嵌入端用ARM跑量化后的轻量网络单帧推理也要两三帧以上车一旦开快了就漏识。从算力上看如果采用一个裁剪后的检测网络加一个识别分支假设输入分辨率320x240主干通道数取4/8/16这种小通道设计整网MAC数大概在几千万到一亿上下。这个量级的计算CPU吃力GPU的功耗又超了。FPGA的优势在于能把卷积的计算结构完全定制化做到“为这块模型专门搭一块电路”。但反过来不少FPGA工程师也踩过坑把卷积核写成一组嵌套for循环然后期待综合工具把它优化成高效电路。结果往往是资源利用率低、时序压不下去、数据搬运效率差。真正的问题在于卷积计算的结构化特征——权重复用、滑动窗口重叠——没有在硬件上被显式利用。这就是脉动阵列入场的原因。1.2 为什么是脉动阵列而不是“一堆乘法器”脉动阵列的核心思想是让数据沿着PE阵列有节奏地流动每个PE只做一次乘加然后把结果传递给下一个PE。对比传统的“一个乘法器接一个累加器”方案它的优势有两个。数据局部性同一个输入特征值被多个权重复用数据从BRAM或寄存器读到阵列里以后在片内多次“流经”相关PE不用反复搬回存储器。带宽友好每个周期只需要往阵列边缘喂少量数据阵列内部就靠寄存器链把数据在多个PE之间传递。这对FPGA的BRAM带宽和布线资源非常友好。对3x3卷积来说一个像素点被用来生成多个输出位置的部分和数据复用率天然很高。脉动阵列能把这种复用变成REG2REG的连乘流水综合工具也更容易收敛时序。选型阶段我也对比过HLS方案。Vivado HLS写起来快但生成的IP核在双平台移植时依赖库不一样而且调度结果一旦不理想想手动优化很痛苦。纯Verilog虽然开发周期长一点但每个寄存器的位置、每条信号的时序都在自己手里换平台时只要逻辑写规范基本能做到“核心代码一行不改”。2. 阵列微架构与调度PE、行缓冲与地址生成器的Verilog实现2.1 PE单元设计8bit乘加与32bit累加先看最核心的PEProcessing Element设计。我最终采用的是Weight-Stationary数据流权重驻留在PE内不动输入激活从左侧进入部分和从上方进来、累加后从下方出去。这样每个周期PE只做一件事读入新的激活、乘以驻留权重、加上累积的部分和。module pe #( parameter DATA_W 8, parameter ACC_W 32 )( input wire clk, input wire rst_n, input wire load_w, // 权重加载使能 input wire [DATA_W-1:0] weight_in, // 权重写入端口 input wire [DATA_W-1:0] act_in, // 激活输入来自左侧PE input wire [ACC_W-1:0] psum_in, // 部分和输入来自上方PE output reg [DATA_W-1:0] act_out, // 激活输出给右侧PE output reg [ACC_W-1:0] psum_out // 部分和输出给下方PE ); reg [DATA_W-1:0] wreg; wire [ACC_W-1:0] mul_result; wire [ACC_W-1:0] mul_extend; always (posedge clk or negedge rst_n) begin if (!rst_n) wreg 8d0; else if (load_w) wreg weight_in; end assign mul_result $signed(act_in) * $signed(wreg); always (posedge clk or negedge rst_n) begin if (!rst_n) begin act_out 8d0; psum_out 32d0; end else begin act_out act_in; // 激活数据延迟一拍向右传递 psum_out psum_in mul_result; // 部分和累加后向下传递 end end endmodule这段代码看起来简单但两个细节值得注意。第一权重寄存器是单独使能加载的因为脉动阵列要求“先把权重全部驻留到位再开始流入激活数据”第二psum保持32bit累加为什么不是16bit因为累加过程中一旦截断量化误差会逐层累积。我调试阶段为省资源把累加器压到16bit结果识别率掉了快十个百分点后来老老实实改回32bit。2.2 阵列规模与数据流的时间关系我用的阵列规模是16x16也就是16行16列共256个PE。16行对应一个3x3卷积窗口展开后的一段输入数据批量16列对应一批输出通道。具体到某个卷积层时这个映射关系需要灵活适配通道数通道数不足16就用部分PE闲置也不会影响其他层的调度。数据流调度分为两个阶段权重加载阶段和计算阶段。权重加载阶段每拍往阵列写入一个8bit权重按行列编址顺序写入对应PE256个权重需要256拍实际批量加载时可以压缩但设计初期先保证正确。计算阶段行缓冲生成每个卷积窗口对应的窗口向量把向量拆成16个一组沿阵列左边界逐行注入每个PE从左侧拿到激活、与驻留权重相乘再把部分和往下方递。时间关系上第1拍注入第1行激活第1列的PE开始算第2拍第1行激活传到第2列同时第2行激活进第1列依此类推整个阵列像水波一样推进。这种结构的好处是数据通路在空间和时间上都是规则的综合器很容易把关键路径压在“激活计算psum累加”这一段上双平台时序收敛都比较顺利。权重加载的顺序要和行缓冲送数据的顺序严格对应。我在设计文档里画过一张时序表第0到15拍加载第0行权重第16到31拍加载第1行权重这样计算阶段从第256拍开始行缓冲刚好在同一个时钟沿送出第一组窗口数据。这个“权重加载完成”和“数据流入开始”的握手我单独用了一个计数器信号来控制而不是依赖复位释放后的固定周期因为双平台复位释放时机有微小差异靠固定周期数等是最容易出bug的做法。2.3 行缓冲与地址生成器最容易出边界bug的地方脉动阵列本身只负责算喂什么数据需要行缓冲和地址生成器配合。行缓冲我用了三组FIFO对应3x3卷积核的三行窗口数据。输入像素按行写入行缓冲地址生成器根据步长和padding判断哪些位置需要输出有效窗口数据、哪些位置补零。地址生成器本质上是一个状态机维护当前输出像素的行列坐标坐标决定了读哪三行、哪三列以及是否补零补零数据用一个逻辑开关插入数据流宽度和有效数据一致。这个逻辑在Verilog里看起来琐碎但实际上是整个设计最容易出bug的地方。我在Xilinx平台上把数据流对齐后一度漏掉了右侧padding导致识别率在车牌右边缘字符上明显恶化。排查了很久才发现是地址生成器在“窗口跨行”那个节拍上少等了一个周期右侧的补零数据被吞掉了。后来我在testbench里专门加了一种“边界模式”——只用1xN的图片测试卷积输出比对每个输出像素的位置所有边界case一次暴露。3. 模型裁剪与INT8量化让网络在片上“塞得下、跑得快”3.1 检测和识别两段式怎么在硬件上合成一个网络车牌识别通常要做两件事定位车牌区域识别字符序列。如果硬件里分成两段先检测后识别那就要求中间结果先保存再处理延迟和存储开销都上去了。我在项目中选择了共享backbone的策略一个浅层特征提取网络后面分两个头——一个负责定位车牌框一个负责从对应区域特征里识别字符。在FPGA上只实现一份前向计算检测头输出和识别头输出在同一帧流水里完成省掉了一次特征图存储的往返。共享backbone会带来定位精度和识别精度的权衡但车牌是强结构目标特征不复杂浅层backbone完全够用。我用的模型backbone基本是3x3卷积2x2池化堆叠层数不超过八层权重数量控制在几十万以内。这个规模才谈得上整网放入片上存储如果模型再大后面说的“完全不经过DDR”的方案就做不到了。3.2 INT8量化与校准为什么图像输入要“先减128”模型在PyTorch里训练完成、浮点精度验证没问题之后进入FPGA前必须先做量化。最常用的方案是INT8权重和激活都用signed 8bit表示累加器保持32bit。直接转换精度损失明显需要做校准——收集几百张典型车牌图片跑一遍浮点网络统计每一层激活分布的KL散度找到最佳的INT8截断阈值把浮点值映射到[-128,127]。一个容易忽略的预处理细节摄像头输出的像素是0~255的unsigned值INT8有符号表示装不下。第一版我直接强转结果大晴天照片高光过曝全被截断到127车牌反光区域直接识别失败。正确做法是输入前先把像素减128把数据范围搬到[-128,127]再送入第一层卷积。这个偏移量在量化校准阶段要和浮点模型对齐不能前后端各减各的。BN层也必须在推理阶段融合进卷积。量化前先把BN的gamma、beta、mean、var折算进卷积权重和偏置再整体做INT8校准否则BN层在硬件里单独实现会浪费流水节拍和存储。融合公式就是标准推理转换W W * gamma / sqrt(var eps)b (b - mean) / sqrt(var eps) * gamma beta然后把浮点W和b一起做INT8量化。这一步如果分开做INT8的误差会被BN放大尤其当var很小的时候。3.3 激活与池化的硬件化ReLU层硬件实现非常简单但在脉动阵列里要注意位置。我在每个PE的psum输出后面没有放ReLU而是在一列PE计算完一个输出像素的完整累加后放在该列底部的处理单元里做。这样既不打断阵列的乘法流水又保证了ReLU只作用在最终结果上不会在部分和上被错误截断。池化层直接并入下一层的行缓冲2x2池化意味着行缓冲需要多缓存一行数据多耗一点BRAM但省了一个单独的池化模块。max-pooling我用了简单的两拍比较器数据流不断流这个优化在最终时序上贡献了大概5%的周期节省聊胜于无。4. 数据流流水线的延迟拆解首帧延迟与稳态帧率如何算出来4.1 尽量减少DDR往返很多FPGA视觉方案的瓶颈不在计算而在数据往返。图像从摄像头进来先写满DDR再让加速器从DDR里一帧一帧地读最后结果又写回DDR。这个流程对于“超低延迟”是完全不可接受的。我在架构上做了两个决定一是传感器数据不经过DDR采集模块按行写入行缓冲直接作为第一层卷积的输入流二是中间层特征图全部放在片内BRAM用ping-pong方式在层间传递。这个做法有个前提——模型要足够小。我估算过如果中间特征图峰值在几百KB以内BRAM完全扛得住。反过来如果模型大到特征图放不下就必须引入DDR延迟预算就得重新算。所以先把模型裁剪到什么程度、特征图峰值多大、片上BRAM够不够这些在项目一开始就要算清楚而不是等代码写完再去塞。4.2 层间Ping-pong调度让多帧图像在流水线里叠起来层与层之间的握手我设计得很简单每一层做完一个窗口的输出把结果写入下一个层的输入行缓冲同时向上游发送“我有空位”的反压信号。这样卷积层的计算节奏由上游数据到来驱动每一层之间没有“等整帧算完”的停顿形成一个真正的流水线。稳态下多帧图像其实同时处于网络的不同层——第N帧还在第一层卷积第N1帧的像素已经在采集模块排队了。这种帧间流水是“超低延迟”和“高吞吐”同时成立的窍门。如果不做帧间流水每一帧从输入到输出都要走完整个网络的串行延迟帧率会低得难看。4.3 延迟估算公式写汇报材料时别只报一个数一帧图像经过一个卷积层的理想延迟可以用下面这个式子近似Layer_latency ≈ 总MAC数 / (阵列总PE数 × 时钟频率)例如输入320x240、3x3卷积、通道数16到16总MAC约为320x240x16x16x9≈1.77亿。用16x16阵列256个PE、150MHz时钟计算单层理想延迟约2.9ms。这只是单层卷积整网八层不流水累计就要20ms以上但层间流水让稳态帧率基本由最慢的那一层决定首帧延迟则由各层延迟之和决定。我随手整理了一个延迟预算示意表方便不同需求的人参考输入分辨率通道设计整网MAC规模单帧首延迟粗估适用场景160x1204/8/16约2000万1-2ms高速通道单目标320x2404/8/16约8000万4-6ms停车场/门禁常规640x3604/8/16约2.8亿15-20ms多目标/复杂背景做项目汇报时至少要给两个数首帧延迟和稳态帧率。首帧延迟是“车辆进入画面到结果出来的时间”稳态帧率是“连续识别时每秒能处理多少帧”。两者衡量的是流水线的不同侧面千万别混着说。5. 从Vivado到PDSXilinx与紫光同创双平台移植实录5.1 平台适配层RTL核心代码一行不改的前提代码层面最麻烦的不是RTL逻辑本身而是平台相关的原语和IP核。Xilinx这边我用Vivado时钟IP直接用Clocking Wizard紫光同创用Pango Design SuitePDSPLL IP的名字、端口的有效电平跟Xilinx都不完全一样。BRAM也一样Xilinx的Block Memory Generator生成的是native接口PDS里是另一套IP名称直接替换会报端口不匹配。我采取的做法是在RTL顶层做了一层“平台适配层”把PLL、BRAM、复位同步器这类平台相关模块全部封装成统一接口逻辑部分不直接依赖具体原语。顶层适配层大约占整个工程代码量的10%不到但换平台时只需替换这10%阵列核心代码一行不用动。这个抽象层的设计成本在当时看起来有点“过度”但真正开始做紫光移植时省下了大把调试时间。5.2 PDS综合器和Vivado的三个差异紫光PDS综合器对Verilog-2001语法支持得不错但我在使用中还是踩了几个具体坑。第一个坑是综合属性不通用。Xilinx里流行的(* ram_style block *)这类综合属性PDS不是完全识别。我一开始指望PDS自动把大数组推断成BRAM结果发现一部分存到了分布式RAM里资源翻倍还拖慢时序后来只能全部改成手工例化RAM原语。第二个坑是DSP推断能力。同样的乘法器RTLVivado能很好地推断到DSP48E1PDS却可能综合成LUT乘法器时序和资源都吃亏。解决方法是把乘法器单独抽成一个模块里面按平台条件例化DSP原语。双平台要保持一致的算术精度位宽对齐一定要仔细。我建议乘法器接口统一用signed wire不要混用signed/unsigned否则PDS综合结果和仿真器行为对不上。第三个坑是时序报告的习惯差异。PDS的时序报告方式与Vivado有差异刚开始找关键路径找得头疼。后来我干脆放弃实时读报告先把RTL里关键数据通路的组合逻辑控制在两级以内再用工具收敛效果反而更好。在Xilinx上我习惯把关键路径设在阵列的psum传递链上PDS里同样如此但时钟频率从150MHz掉到了130MHz左右原因是PDS布线拥塞度比Vivado高稍微保守一点能减少迭代次数。5.3 license问题和图像输入方案经常有人问“Xilinx的DP 1.4 IP要钱吗”确实Xilinx的部分显示相关IP需要licenseMIPI CSI-2也有限制。我做车牌识别系统时没有纠结MIPI直接选了一颗支持DVP并口输出的摄像头输出YUV422格式FPGA端只用一个简单的采集模块把并口数据转成像素流。DVP接口在低分辨率场景下带宽完全够用还避开了IP授权和引脚约束的麻烦整体采集逻辑干净利落。这里也提醒一句如果项目里确实要用MIPIXilinx平台可以走第三方或者自己写物理层的串行解串逻辑但开发和验证成本都不低。车牌识别这类产品如果不需要4K分辨率DVP或者BT.1120并行接口是性价比非常高的选择而且双平台通用性极好几乎不需要改采集逻辑。6. 上板验证、实测数据与后续扩展思路6.1 上板前的联调逻辑从最小系统到真实摄像头上板前的系统联调我按照“最小系统—数据流—算法精度”三步走。先只跑一个层用板上自检模式生成已知图像源验证阵列输出再跑整网用几张固定测试图比对仿真结果最后接真实摄像头看实际识别效果。阵列对外只暴露三个握手信号输入有效、输出有效、忙。上层调度只关心这三个信号不关心内部256个PE在做什么。上板后我先用ILA抓这三个信号看它们在各层的时序是否和testbench里一致。这一步能排除掉90%的时钟和复位问题。6.2 两套平台的实测数据下面是我手头记录的对比逻辑设计完全一致差异来自时钟频率和平台资源平台器件主频逻辑资源占用DSP占用单帧识别延迟XilinxArtix-7 XC7A100T150MHzLUT约40%约60/805ms量级紫光同创对标PH1A系列130MHzLUT约45%约60个6ms量级这个数据说明在精心裁剪后纯FPGA方案完全能压进10ms以内的超低延迟区间紫光平台因为综合工具保守一点时序收敛频率略低但交付指标仍然达标。功耗方面板级测量大概在几瓦到十几瓦区间对比GPU方案优势明显。6.3 复现时要留意的三件事仿真验证一定用真实图片数据不要只跑随机激励。我在验证阶段写了一个脚本把Python侧转出的二进制图片文件直接灌进testbench逐层比对INT8定点输出和PyTorch参考输出。误差控制在1个LSB以内才敢上板。随机激励只能测数据通路完整性测不出量化截断和边界padding的问题。权重更新要考虑方便性。权重不要写死在寄存器常量里做成上电后从SPI Flash或UART加载的形式。不然每改一次模型都要重新综合工程双平台各来一遍时间成本太高。多目标场景。项目最初是单车牌识别实际现场经常会出现两辆车同时进入画面。建议在检测头设计时保留多目标输出的能力哪怕初期只取最大置信度框也为后续扩展留了余地。整个项目跑完我自己最大的收获不是写了多少行Verilog而是把“数据流设计”放在寄存器设计和RTL编码之前的习惯养成了。先画清楚数据怎么进、怎么流、怎么出再动手写代码最后无论是换平台还是调时序都从容得多。车牌识别这个场景刚好把脉动阵列、量化、流水线这些硬核技术都串到了一起一次打磨下来后面再碰到同样的低延迟视觉任务就有完整的套路可抄了。
RELATED READING

延伸阅读

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