ARTICLE DETAIL

资讯详情

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

FPGA车牌识别加速:纯Verilog脉动阵列实现毫秒级延迟

FPGA车牌识别加速:纯Verilog脉动阵列实现毫秒级延迟 做车牌检测识别最怕的不是算法不够花哨而是延迟压不住。停车场闸机前那几秒车停稳、摄像头抓拍、识别、抬杆整个过程但凡多犹豫一下后面就是一串喇叭声。这个项目里我用纯Verilog搭了一套脉动卷积阵列加速器没有调现成的IP核也没用HLS硬是在Xilinx和紫光同创两家的FPGA上把车牌检测和识别的完整流程跑通了端到端延迟做到几十毫秒级别。整套东西的思路、RTL实现细节、跨平台移植的坑和性能优化过程我在这篇文章里完整摊开给准备在FPGA上做AI加速的同行当个参考。1. 车牌识别场景的算力账单为什么非得用专用加速器1.1 从抓拍到抬杆延迟预算怎么分配停车场入口的完整动作链大概是这样的车辆驶入、地感线圈触发、摄像头抓拍、图像传给处理单元、检测车牌位置、识别车牌字符、查库比对、抬杆放行。传统方案里图像处理一般放在工控机或者服务器上摄像头到主机之间哪怕走的是千兆网一帧720p图像也有几毫秒的传输和拷贝开销。要是再赶上主机负载高、进程调度抖动识别延迟从100ms飙到几百毫秒很正常。FPGA的优势在于数据不需要上CPU、不需要经过DDR反复搬运摄像头进来的数据直接在片上流转。但FPGA也有自己的问题它本质上是一个搭积木的硬件平台用错了架构资源利用率低、时序收敛困难最后可能还不如一颗跑着OpenCV的ARM芯片。所以这个项目的第一个设计决策很明确把整个识别流程全部放在FPGA内部不借助额外处理器。我的延迟预算是这样拆的图像采集和预处理5ms以内检测网络30ms以内车牌区域裁剪和识别网络10ms以内结果输出1ms以内总共控制在50ms左右。这个预算对标的是司机基本感觉不到等待的体验同时给系统的后续扩展留出余地。1.2 通用处理器为什么跑不动两个硬瓶颈很多人一开始会问车牌识别而已用ARM加OpenCV不是也能做吗能但要看计算量。车牌检测如果用一个轻量级卷积网络输入320x240灰度图总共大概1亿多次乘加运算。听起来不多但在一颗300MHz的ARM处理器上即使编译器做了很好的优化每个乘加平均也要几个甚至十几个周期再加上内存访问、循环控制的开销跑完整个网络得上百毫秒。这里面的第一个瓶颈是冯诺依曼墙数据和指令都要从内存取卷积运算的访存密度又特别高缓存命中率稍有波动性能就掉一截。第二个瓶颈是并行度不足ARM核心再多也就四个八个而卷积运算本身是高度并行的——不同输出通道之间、不同空间位置之间互不依赖这种并行度需要大量计算单元同时发力才能榨干。FPGA恰好在这两点上都是强项数据在片上流动不走通用内存总线逻辑阵列可以同时铺开几十上百个乘加单元。但FPGA也不是随手写写就能发挥威力的真正把性能做上去需要一个规整的、数据复用率高的计算架构。脉动卷积阵列就是为这种场景准备的。1.3 脉动卷积阵列的直觉理解脉动阵列这个概念听起来高深其实拿流水线来类比就很好懂。想象一条流水线每个工位只干一件事——把从上游传来的半成品加上自己手里的一份材料再传给下游。工位之间不需要来回取料每个人都专注在自己的岗位上原料从头流到尾成品就从另一端源源不断出来。对应到卷积计算每个处理单元就是一个工位它内部寄存着一个权值输入数据从阵列的一端流入经过每个PE时做一次乘加运算部分积累加结果在阵列中流动。整个过程数据局部性极好每个输入像素会被多个滤波器反复使用但这种反复使用全部发生在片内不需要反复从内存读。同时脉动阵列结构非常规整全部由重复的PE单元组成非常适合用Verilog这种RTL语言描述逻辑清晰、便于规模扩展。2. 整体架构检测识别两级流水共用一块加速阵列2.1 一个阵列跑两个网络复用而不是堆硬件车牌识别全流程实际包含两个神经网络前面是目标检测网络负责从整幅图像里找到车牌的位置框后面是识别网络负责把裁剪出来的车牌图像映射成字符序列。如果给每个网络各配一套加速器资源翻倍不说逻辑复杂度也上来了。我的做法是设计一个统一的脉动阵列计算核心通过一个指令调度器在运行时切换网络参数。检测网络跑完得到车牌框坐标后同一块阵列再加载识别网络的权重继续跑识别推理。这么做的好处是FPGA资源利用率高在Xilinx和紫光同创的中等规模器件上都能放下同时调度逻辑也不复杂——本质就是一个状态机当前网络执行完毕后切换权重加载指针和输入数据源。整个系统数据流是这样的摄像头并行输入先经过灰度化和缩放预处理写入双帧缓冲检测阶段从帧缓冲A读图像输出车牌框坐标根据坐标从帧缓冲B中截取车牌区域作为识别网络的输入识别结果经过解码后通过UART或以太网输出。帧缓冲设计成双缓冲是为了实现乒乓操作——摄像头正在写入bank A的时候加速器可以读取bank B的数据互不干扰。2.2 数据通路设计绕开DDR的片上方案设计初期我认真考虑过要不要加一颗DDR3做帧缓存毕竟很多FPGA图像处理方案都离不开DDR。但仔细一算发现车牌识别的输入分辨率根本不需要做到1080p640x480的灰度图足够用了一帧才307KB。而常见FPGA的片上BRAM资源有几百KB到几MB完全能够容纳两到三帧灰度图。于是整个数据通路完全绕开DDR图像数据从摄像头进来后直接写入片上BRAM。这个决策对延迟的贡献极大省去了DDR读写带宽竞争也省去了DDR控制器的复杂逻辑更重要的是端到端延迟变得极其可控——每个模块之间的握手信号是确定的不会出现DDR仲裁导致的随机延迟。行缓冲Line Buffer也是数据通路里的关键模块。卷积运算需要用到一个3x3的滑动窗口而图像数据是按行连续输入的。行缓冲的作用就是缓存两行完整数据配合当前行数据拼出三行数据后再取3x3窗口。这个模块用移位寄存器或者小容量RAM实现逻辑不复杂但很容易写错后面我会专门讲踩坑经验。2.3 量化方案INT8够不够用神经网络权重默认是FP32浮点格式但FPGA做浮点运算资源开销巨大。车牌识别这个场景对量化非常友好因为车牌字符的边缘、纹理特征是强结构信息不像自然图像那样对噪声和高频细节那么敏感。我采用的是常见的INT8对称量化方案先在校准集上统计每一层激活值的绝对值最大值算出缩放系数scale再把浮点权重和激活值映射到[-127, 127]区间。卷积计算过程中乘加累加器保持32位精度避免中间结果溢出每层输出经过ReLU和量化后再回到8位。这样做的精度损失在车牌数据集上实测不到0.5%完全在可用范围内。这里有一个容易被忽略的细节累加器尽管是32位卷积层的累加次数是有限的。如果中间结果超过INT32范围就需要提前截断或者重新安排量化策略。我用了一个宏定义来配置累加器位宽初期设成40位多留裕量综合发现问题后再裁剪确保不会出现溢出导致精度崩掉的情况。3. 纯Verilog实现脉动阵列核心模块逐个拆3.1 PE处理单元怎么写一段可以被综合器吃透的RTL脉动阵列的基本单元就是PE麻雀虽小五脏俱全。单个PE内部需要做三件事锁存自己的权值、接收输入数据并传下去、将输入与权值相乘并累加部分和。这里我贴一段核心代码这是整个项目里所有PE的骨架module pe #( parameter DW 8, // 数据位宽 parameter AW 32 // 累加器位宽 )( input logic clk, input logic rst_n, input logic w_load, // 权值加载使能 input logic [DW-1:0] w_in, // 从上游输入的权值 input logic [DW-1:0] act_in, // 从上游输入的激活值 output logic [DW-1:0] act_out, input logic [AW-1:0] psum_in, // 上游部分和 output logic [AW-1:0] psum_out ); logic [DW-1:0] w_reg; logic [AW-1:0] psum_reg; always_ff (posedge clk or negedge rst_n) begin if (!rst_n) begin w_reg 0; psum_reg 0; end else begin if (w_load) w_reg w_in; act_out act_in; // 激活值打拍向下游传递 psum_reg psum_in $signed(w_reg) * $signed(act_in); end end assign psum_out psum_reg; endmodule这段代码有几个关键点。第一权值寄存器只有在w_load信号有效时才更新计算阶段权值保持不动这就是典型的权值固定数据流。第二乘法结果直接加到部分和上累加器宽度要比乘数宽度宽得多防止溢出。第三act_out在时钟沿后输出等于当前拍的act_in相当于数据在阵列中每经过一个PE就延迟一个时钟周期实现自然的数据流水传递。我建议所有乘法都写成$signed()显式有符号乘法避免综合器把INT8当作无符号数处理否则负权重会算出完全错误的结果。3.2 数据流控制权重加载、输入流动、结果排空搭建好单个PE后难点转移到整个阵列的数据流控制。一个16x16的脉动阵列在实际跑卷积时不能简单地把图像怼进去就完事它需要三个明确的阶段LOAD_WEIGHT、STREAM_IN和DRAIN。LOAD_WEIGHT阶段把当前卷积层所有滤波器的权值卸载到PE阵列内部寄存器。这一阶段常被新手忽视但权值加载是耗时大户。假设一个卷积层有64个输出通道、32个输入通道、3x3卷积核总权重数就是64x32x918432个。如果每个周期加载一个PE加载完这些权重需要18432个时钟周期。在150MHz时钟下就是123微秒听着不长但网络有几十层累积起来就非常可观了。STREAM_IN阶段才真正开始做卷积计算。输入特征图从行缓冲生成滑动窗口后窗口数据按照脉动阵列的节奏送入第一行PE部分和在阵列中向下累加。这个阶段必须严格保证数据到达时间和PE计算节拍一致否则序列完全错位输出结果就是错的。DRAIN阶段则是把阵列里还没流出的部分和全部排空。我最初的设计没有显式处理排空导致最后一行卷积结果永远算不完后来在状态机里加了计数器在输入结束后多等PE阵列深度个时钟周期问题才解决。3.3 配套模块行缓冲、ReLU、池化和量化脉动阵列只是发动机周边配套模块决定它能不能正常运转。行缓冲模块负责从图像帧缓存里读数据拼出3x3窗口ReLU激活在数字电路里其实就是判断符号位负数清零一个二选一多路选择器就搞定最大池化做一个两两比较的树形结构量化模块做饱和截断或者四舍五入。这些模块单独拎出来都很简单难的是它们的时序要和脉动阵列完全对齐。我是用计数器统一管理整个流水线的节拍图像行计数器、窗口内偏移计数器、输入通道计数器、输出通道计数器每个计数器的溢出条件都预先在仿真里验证过。如果你也打算自己写强烈建议先把这些计数器的时序图画出来再写代码不要直接上手Verilog否则大概率会在仿真阶段被各种边界条件折磨。4. Xilinx与紫光同创双平台落地纯Verilog的隐藏优势4.1 两个平台的差异从EDA工具到底层原语这个项目的目标平台从一开始就是两个Xilinx的7系列/Zynq系列以及紫光同创的FPGA。很多FPGA工程师会习惯性地大量使用厂商IP核比如Xilinx的FIFO IP、Block Memory Generator、MMCM时钟IP。这种做法在单一平台开发时效率很高但要做跨平台移植就非常痛苦——Vivado生成的IP核在紫光同创的Pango Design SuitePDS里完全无法使用只能重新生成对应平台的IP而两边的接口参数、复位策略、时钟约束都不完全兼容。项目强调纯Verilog的价值就在这里所有逻辑模块全部用行为级RTL编写不依赖任何厂商特定的IP原语。FIFO自己写RAM自己写时钟模块封装成独立的wrapper内部才根据平台例化不同的时钟原语。这样做的代价是前期的开发量更大但换来的是代码在两个平台之间可以无缝综合省去了移植时大量的返工。实际操作中两边的差异主要体现在这几个方面Xilinx用Vivado和XDC约束紫光同创用PDS和PDC约束Xilinx的DSP原语是DSP48E1/E2紫光同创有自己的DSP单元结构块RAM的容量和排列也不同但这些都是物理资源层面的差异逻辑代码本身不受影响。我只需要把平台相关的部分收敛到几个顶层模块里其余80%的RTL代码两边完全一致。4.2 移植实操与资源、时序评估具体移植时我的做法是建两个工程共享同一套RTL源文件只有平台适配层不同。平台适配层包含三部分时钟生成、复位同步、调试接口。时钟生成在Xilinx下例化MMCM在紫光同创下例化它自己的PLL封装成统一的clk_gen接口复位逻辑统一用同步复位加异步断言的方式避免两家工具的复位树综合差异调试接口在Xilinx上走ILA在紫光同创上用PDS的逻辑分析仪。资源占用方面拿我最终使用的8x16128个PE阵列举例每个PE对应一个乘加单元映射到两个平台都需要约128个DSP单元。Xilinx Zynq XC7Z020有220个DSP资源占用58%紫光同创同等规模逻辑的器件也基本够用。BRAM主要用于帧缓冲和权重存储我分配了约1.5Mbit的BRAM资源。整体LUT占用在40%左右时序在150MHz约束下两个平台都能收敛。关于工作频率我一开始在Xilinx上跑到200MHz很轻松但同样的代码放到紫光同创器件上200MHz就时序违例了。后来分析发现主要是因为两家的DSP和BRAM布局布线特性不同最终把目标时钟统一调整到150MHz两个平台都能稳定收敛。这种事只有双平台同时做才会碰到如果只盯着一家器件可能根本不会注意到综合器对代码结构的偏好差异。5. 超低延迟优化实录把延迟一毫秒一毫秒抠出来5.1 端到端延迟分解瓶颈往往不在乘法器我花了很长时间优化延迟最后发现一个反直觉的结论脉动阵列本身的乘加计算只占端到端延迟的一小半真正吃时间的反而是穿插在计算周围的各种等待。我把自己实测的一帧数据做了个延迟分解大概是这样图像采集和灰度/缩放预处理5ms检测网络推理约18ms车牌区域裁剪和预处理3ms识别网络推理约6ms结果编码和输出1ms内总耗时约32ms。其中的检测网络推理18ms包含了每层的权值加载、计算、排空、以及层间切换的流水线气泡。如果只算纯计算时间128个PE在150MHz下的峰值算力是19.2GMAC/s而我这个检测网络大约1.5亿次MAC理论计算只要8ms左右。多出来的10ms基本都消耗在权值加载和流水线等待上。这个差距说明了一个道理对于中小规模的加速器来说性能瓶颈不是算力不足而是数据供给速率和调度效率。如果你也在调类似系统建议先做延迟分解别一上来就堆PE数量。5.2 我踩过的三个性能坑第一个坑是权值加载时间被严重低估。最初的实现里每个卷积层之间串行执行当前层计算完、把结果写回缓存、然后才开始加载下一层的权重。每一层平均增加120微秒左右的加载时间。解决办法是给权重存储做双缓冲当前层正在计算时下一层权重已经在另一个缓冲区里加载完毕层切换时直接切换数据源几乎零开销。这个优化让检测网络的推理时间从27ms降到了18ms。第二个坑是批次归一化BN层在推理时的融合。最初网络里每一层卷积后面都跟了单独的BN计算模块用BRAM存均值和方差参数算完还要再做一次乘法缩放。后来我把BN参数融合到卷积层本身的权重和偏置里在量化阶段就把融合做完FPGA上完全不需要单独的BN模块省掉的不仅是计算时间还有BN参数占用的存储和搬运开销。第三个坑是边界处理。车牌图像经过多次卷积后特征图尺寸会缩小而卷积本身需要padding才能保证边缘像素参与计算。我之前图省事直接在行缓冲里把边界值固定为0结果输入图像边缘的车牌边框特征计算错误检测率掉了一截。最后还是在输入特征图进阵列之前由行缓冲控制器根据坐标判断是否处于边界边界处补零、否则透传数据问题才算解决。6. 调试经验与常见问题速查6.1 仿真验证三板斧从单元到系统逐层推进脉动阵列这类高度规则的结构调试起来有一个好处一旦PE本身验证正确整个阵列的行为就基本可控。我的验证策略分三步走。第一步是单元验证单独仿真PE模块喂几组已知的权值和输入值断言计算结果的正确性这一步能同时验证有符号乘法和累加逻辑。第二步是小规模阵列验证用4x4的阵列搭配已知权重验证数据流方向和流水节拍是否正确。第三步才是整机验证把阵列、行缓冲、量化模块、网络调度器全部连起来用仿真图像跑完整网络与软件模型逐层对比输出。所有验证模块都用SystemVerilog断言和testbench完成纯Verilog项目的好处是仿真不依赖任何厂商工具ModelSim、Vivado Simulator、开源的iverilog都能跑非常灵活。6.2 上板调试把看不见的状态拉出来上板调试和仿真完全是两回事很多在仿真里永远不会出现的问题在上板后都会暴露出来。我遇到过的几个典型问题整理成了一张速查表方便大家排查现象可能原因排查方法输出全是0权值加载失败或使能信号没拉起来检查权值加载状态机抓LOAD_WEIGHT阶段的完成信号图像有重影或错位行缓冲写地址和读地址错拍打印行缓冲读写指针和理想时间轴对比检测框整体偏移卷积padding逻辑有误边缘特征计算错误核对边界判断条件仿真中强制检查边缘像素路径偶发时序错误跨时钟域信号没做同步检查所有跨时钟域握手信号补两级寄存器同步精度和软件模型偏差大累加器溢出或量化scale不准逐层对比中间特征图定位最大偏差层上板调试时逻辑分析仪是必备工具。我的经验是把层完成标志、帧结束标志、量化输出有效这几个关键信号全部拉出来用状态机配合计数器定位问题出现的具体帧和具体层这样可以把随机性问题缩小到具体环节效率远高于瞎猜。6.3 一个关于跨平台的独门建议最后分享一个我觉得特别重要的经验跨平台项目千万别等一个平台全部调通了再移植到另一个平台。我的做法是代码开发阶段就保持两个平台的综合脚本同步更新每次大改动后两个平台各跑一次综合和时序分析。这样做的好处是能尽早发现只在某个平台能综合的代码风格问题。比如某些for循环在Vivado里能正确展开在PDS里可能因为综合策略不同导致逻辑膨胀这类问题越早发现返工成本越低。写在最后的体会这套系统做完之后我最大的感受反而是轻舟已过万重山。脉动阵列、纯Verilog、双平台部署每一项单独拿出来都不算新鲜但把它们组合在一起解决一个具体的实时场景中间还是有不少朴素的工程智慧。如果回到项目初期我会更早地做延迟分解更早地做权重双缓冲更早地正视两个平台之间的工具链差异。每个环节单独看都是小问题攒在一起就可能让整个项目延期。这套方案后续还可以扩展的方向很多比如把阵列规模加大、加入多核并行、扩展到其他实时检测任务。但就车牌检测识别这个场景来说一块中等规模的FPGA、一套纯RTL实现的脉动阵列已经能交出足够让人满意的超低延迟答卷了。
返回列表