做FPGA加速的人,谁没在卷积运算上折腾过几个来回。前阵子完成了一个比较有意思的项目:用纯Verilog写了一套脉动卷积阵列加速器,同时部署到Xilinx和紫光同创两家FPGA上,专门跑车牌检测和识别,目标只有一个——把端到端延迟压到极低。整套系统从RTL设计、仿真验证到双平台移植我都走了一遍,这里把思路、关键决策、时序优化和踩过的坑一起整理出来,给准备在FPGA上做轻量级AI推理的朋友做个参考。
这套方案本质上解决的是一个很具体的问题:车牌识别的应用场景,比如停车场出入口、高速收费闸口、园区门禁,往往对延迟极其敏感。摄像头拍到车牌到道闸抬起,通常要求百毫秒甚至更低,而传统做法是FPGA只做抓拍,把图片传给上位机用GPU或CPU跑深度学习模型,再回传结果。这一来一回,网络传输、系统调度、模型推理叠加在一起,延迟很容易超标,尤其在多路并发时。
把检测和识别整个搬到FPGA上之后,链路变成了Sensor进FPGA,结果直接输出结构化信息(车牌号、置信度、框坐标),省掉了中间所有跟主机打交道的环节。配合脉动阵列这种高并行度的计算结构,整车的识别延迟能做到几毫秒量级,这是我做这个项目最大的初衷。
1. 项目整体设计与思路拆解
1.1 为什么选脉动卷积阵列而不是其他加速结构
FPGA上做卷积加速,方案其实不少。最简单的就是展开乘累加(MAC)并行计算,每一层卷积按输出通道展开成多个并行乘法器;进阶一点的有Winograd、FFT变换;再往上就是脉动阵列,本质是把MAC单元排成二维阵列,数据在阵列中规律性地“流动”,每个PE(Processing Element)只和相邻PE通信,最大程度减少布线资源消耗,同时获得极高吞吐量。
选择脉动阵列,最关键的原因是它在计算密度和布线复杂度之间取得了很好的平衡。全展开方案,比如一次并行算16路卷积,每个输出通道都要把输入特征图广播给所有乘法器,布线扇出非常大,时序很容易崩,FPGA资源也扛不住大规模网络。脉动阵列则不同,数据像流水线一样从一个PE传到下一个PE,每个PE只需要跟左右邻居打交道,布线长度可控,工作频率可以拉得比较高。
拿车牌识别场景来说,输入一般是灰度图,模型结构比较轻量,卷积层通道数不会太大。我最终选择的是8x8的脉动阵列,每个PE做INT8乘累加的方案。8x8算下来同时有64个MAC在做计算,配合流水线设计,单周期就能完成64次乘加。这个规模对车牌检测这种轻量网络来说,功耗和资源都能接受。
1.2 为什么坚持纯Verilog而不是HLS或Chisel
这两年HLS(高综合)确实越来越成熟,Chisel在国外开源社区也很流行,但我这个项目从头到尾用的都是纯Verilog。原因有几个。
第一是可控性。脉动阵列的时序、数据流节奏、PE间的握手关系,这些都需要非常精细的控制。HLS综合出来的电路虽然功能没问题,但在时序、资源利用率上很难做到手工RTL那种极致。纯Verilog写出来的代码,每一个触发器的位置、每一级流水线的深度,我自己心里都有数。
第二是跨平台的可移植性。项目一开始目标就是Xilinx和紫光同创双平台部署。纯Verilog的IP核在这两家的EDA工具链里基本都能直接编译,只要注意避免使用厂商特定的原语,移植成本非常低。HLS则不同,Xilinx的Vitis HLS生成的代码绑定了大量Xilinx特有的接口和宏定义,想挪到紫光同创的PDS上,很可能要大量重写。
第三是调试直观性。用Verilog写的RTL,在仿真阶段直接看波形就能定位问题。比如某个PE的valid信号没对齐,一眼就能从波形图上看出来。HLS产生的RTL虽然也有Vivado HLS生成的波形,但可读性差很多,出了问题很难排查。
1.3 车牌检测识别场景对算法和硬件的特殊约束
车牌识别和通用目标检测有个很大的不同:它的任务相对固定,类别很少(国内车牌就是蓝牌、绿牌、黄牌等几种),字符集有限(省份简称加数字字母),所以网络可以做得非常轻。这意味着不需要很深的ResNet或YOLO这种大模型,一个精简的CNN或者甚至传统卷积加全连接就能解决。
但车牌识别也有它的麻烦。首先是车牌在画面中的大小和角度不固定,需要检测模块有尺度适应性;其次是车牌字符宽度窄,笔画间空隙小,分割和识别阶段对特征的分辨率要求较高;再就是实际场景中光照变化剧烈,夜间、逆光、反光都会影响效果。
这些约束反映到硬件上,就是算法不能太重,但要有足够的灵活性来应对输入尺寸变化。脉动阵列处理卷积的方式刚好符合这种需求——卷积核大小可以灵活配置,输入特征图的尺寸只要在阵列可承载的范围内,就能按行流式灌入数据,不需要重新综合。我最终采用的检测策略,是先用一个小规模的二值化车牌候选区域提取(简单传统算法),再用轻量CNN做字符分类,把大计算量的部分控制在一个很小的区域内。
2. 核心细节解析与实操要点
2.1 脉动卷积阵列的微架构拆解
脉动阵列的经典结构,从教科书上看就是一行行的PE,数据从左往右流,权重从上往下流,部分和从下往上累加。但真正落到RTL,有很多细节值得抠。
每个PE内部的核心计算单元是一个乘法器加一个累加器。我用的数据位宽,输入特征图是8bit,权重是8bit,乘法结果是16bit,累加器做到32bit,这样多个9x9的卷积核叠加,数值不会溢出。PE内部的状态很简单:当前周期收到的输入数据、权重、部分和,以及一个valid信号。valid为高时执行乘累加,为低时保持原值不变化。
阵列的边界需要额外的控制逻辑。输入特征图数据从左侧进入第一列PE,但并不是每个PE每周期都有数据。比如遇到卷积窗口滑动到边缘时,数据流需要暂停一拍。这个控制用一组简单的移位寄存器实现——数据在阵列中流动时,如果某一行当前窗口内的像素不满KxK,就把valid信号拉低。这个部分是我调试过程中花时间比较久的地方,因为脉动阵列的valid信号不像普通流水线那么直观,需要精确对齐。
核心代码的结构是这样:
module systolic_pe #( parameter DATA_WIDTH = 8, parameter ACC_WIDTH = 32 )( input wire clk, input wire rst_n, input wire valid, input wire [DATA_WIDTH-1:0] data_in, input wire [DATA_WIDTH-1:0] weight_in, input wire [ACC_WIDTH-1:0] acc_in, output reg [DATA_WIDTH-1:0] data_out, output reg [DATA_WIDTH-1:0] weight_out, output reg [ACC_WIDTH-1:0] acc_out ); wire signed [ACC_WIDTH-1:0] mul_result; wire signed [ACC_WIDTH-1:0] acc_temp; assign mul_result = $signed(weight_in) * $signed(data_in); assign acc_temp = acc_in + mul_result; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin data_out <= 0; weight_out <= 0; acc_out <= 0; end else if (valid) begin data_out <= data_in; weight_out <= weight_in; acc_out <= acc_temp; end else begin data_out <= data_in; // 数据仍然流动,但不做乘累加 weight_out <= weight_in; acc_out <= acc_in; // 保持原值 end end endmodule这个PE代码的要点在于,当valid信号为低时,数据流和权重流仍然在传递,只是累加器不更新。这一点很重要,因为脉动阵列中数据流动是全局同步的,如果某一行数据不往后传,后面PE就永远收不到数据了。所以valid控制的只是累加逻辑,数据通路始终保持连通。
2.2 数据流映射与权重加载策略
脉动阵列的经典数据流有三种:权重固定(Weight Stationary)、输入固定(Input Stationary)、输出固定(Output Stationary)。对于车牌识别这种小网络,我选择的是权重固定方式。
原因很实际:车牌识别模型可能包含多个卷积层,每层的权重是固定的模型参数。权重固定意味着权重可以预先把整个层的数据加载到阵列的PE寄存器里,然后输入特征图以流的方式灌入,卷积结果在阵列底部直接流出来。这样数据搬移量最小,输入只需要从外部存储器读一遍,权重只加载一次。
权重加载的具体做法:每个PE除了有数据通路外,还有一条权重写入通路。系统先通过一组配置寄存器把当前卷积层的所有权重按阵列尺寸分行写入到对应列的PE中。比如第一行的PE存第一组卷积核的权重,第二行存第二组,依此类推。相比数据通路,这个权重写入通路不追求高带宽,通过一个串行配置口(类似SPI的状态机)逐行写入即可。
输入特征图的映射上,我做了一个比较关键的设计:把特征图按行分块,每次送入阵列的是连续的行数据。比如输入是32x32的特征图,卷积核是3x3,那我就把32行数据分四次,每次送8行(因为阵列宽度是8)。每次送入时,数据在阵列中从第一列向第八列流动,每个PE内部缓存最近三行三列的数据,从而完成卷积窗口计算。这个思路参考了经典的脉动阵列卷积实现,核心是让输入数据在阵列中“滑”而不是重复广播。
2.3 车牌检测识别网络轻量化设计
既然要走脉动阵列,网络结构就要为硬件服务。我做了一个两阶段的方案:第一阶段是车牌粗定位,第二阶段是字符识别。
第一阶段使用的是一种类似边缘检测加投影分析的方法,没有可学习的参数,完全用硬件实现。流程是对输入灰度图做Sobel边缘检测,然后做水平方向和垂直方向的投影,找出车牌区域的上下左右边界。这个阶段用Verilog做起来非常顺手,因为本质上就是对像素做线性的滤波和累加,不需要MAC阵列。实测下来,这个阶段在100MHz时钟下,对720P图像的处理只要几百微秒。
第二阶段是核心的字符识别。我设计了一个非常轻量的CNN结构:输入是归一化到16x48的字符灰度图,经过两层卷积加一层全连接,输出是34个字符类别(各省简称+数字+字母)。第一层卷积用8个3x3的卷积核,第二层用16个3x3的卷积核。这个网络规模,8x8的脉动阵列跑起来非常从容,一层卷积的计算量大约就是上万次MAC,在200MHz下,一次前向推理的时间在几百微秒量级。
把整个网络的量化做到INT8是个重要环节。模型本身很小,我用通用工具做了校准后量化,把浮点权重转成INT8。这一步影响很大,如果用FP32推理,阵列面积至少大四倍,时序也会难做很多。INT8量化之后,权重的存储只需要几十KB,刚好可以全部放在FPGA片上BRAM里。
3. 实操过程与核心环节实现
3.1 RTL设计与仿真环境搭建
项目最开始当然是搭仿真环境。我用的是Verilog标准的testbench,没有依赖特定厂商的仿真工具,这样在Vivado XSim和紫光同创PDS集成的仿真器之间可以无缝切换。
仿真测试的核心是验证脉动阵列的计算正确性。我先用C语言写了一个参考模型,输入输出都和RTL保持一致,然后在testbench里读入同一组测试数据,比对输出。比对方式很简单:RTL算完把结果写到文本文件,C模型也把结果写到文本文件,然后用脚本比对,逐行对比。
这里有个很关键的仿真技巧:测试数据不能只用全零、全一这种简单模式。我吃了亏,一开始测试全部用简单的递增数据,导致卷积计算里引入了累加,但累加器溢出问题在仿真里完全没暴露出来。后面我改成用大量的随机数据,并且在其中插入一些极端值(比如接近INT8最大值的数),才把输出限幅逻辑里一个Bug给抓了出来。
仿真的一个重要点是数据时序验证。脉动阵列的输入需要由testbench模拟真实的DMA时序,我专门写了一个总线功能模型,模拟DDR读取特征图数据的延迟和突发行为。通过这个模型,可以提前发现真实系统中可能出现的data backpressure问题,而不是等到上板之后才去处理。
3.2 Xilinx FPGA部署流程
这是项目的第一站。我用的芯片是Xilinx的Artix-7系列,具体型号是XC7A75T,资源开销大概如下:
| 资源类型 | 使用量 | 总量 | 利用率 |
|---|---|---|---|
| LUT | 19842 | 47200 | 42% |
| FF | 21357 | 94400 | 22% |
| BRAM | 76 | 105 | 72% |
| DSP48 | 64 | 180 | 35% |
DSP48正好用了64个,对应8x8阵列里的64个PE。这个阵列在Artix-7这个中端芯片上跑得比较轻松,时序目标设定为200MHz,布局布线后最差路径还有一点余量。
部署流程上,我写了模块级的约束文件,给脉动阵列的时钟单独分了一组,并且把阵列的输入输出寄存器都手工指定了位置,让数据流动路径尽量规则化。脉动阵列这种设计,布局布线如果完全交给工具随机排,很容易出现关键路径绕过半个芯片的情况。我在Vivado里使用了Pblock,把64个PE圈在一个矩形区域内,这样同一行PE之间的距离基本固定,时序更容易收敛。
在接口设计上,输入图像数据是走AXI4-Stream协议进来的,我这里做了一个简单的Stream转阵列格式的适配模块。适配模块的核心任务是把连续的像素流重新组织成行列格式,并且根据当前卷积窗口位置产生valid信号。这个模块本身逻辑不复杂,但状态机写起来需要非常细心,因为像素流的节奏完全是等长且流水式的,状态机里一旦出现气泡,后面的数据全乱了。
3.3 紫光同创FPGA移植要点
紫光同创这个平台,接触过的人都知道,工具链和生态跟Xilinx比起来确实有差距,但对于纯Verilog的设计来说,移植难度没有想象中大。
我用的紫光同创芯片型号是Logos-2系列,资源和Artix-7那款比较接近。移植的主要工作集中在四个方面。
第一是原语替换。之前代码里用到了Xilinx的BRAM原语和DSP48原语。这个部分在RTL设计时我提前做了隔离——写了一个统一的存储接口模块和乘法器接口模块,内部再根据厂商不同做条件编译。在紫光同创上用ifdef PGL分支调用了它自己的BRAM原语,其他地方保持不变。这个隔离设计让我在移植时只改了不到一百行代码。
第二是复位策略。Xilinx平台我习惯用异步复位,紫光同创的PDS工具对异步复位的处理没有Vivado那么成熟,布局布线后容易出现复位释放不同步的问题。移植时我改成了同步复位,相比原来,每个PE多一个同步触发器,面积开销很小但稳定性提升明显。
第三是时序约束。PDS支持SDC约束,大部分语法和Vivado的XDC兼容。但需要注意,PDS对时钟组的管理和Vivado有差异,我从Vivado直接搬过来的约束文件在PDS里跑,刚开始没有任何警告,但其实部分约束没生效。这个坑很难发现,后来我检查综合报告里的时序路径,发现时钟频率限制根本没应用上。解决办法是逐条核对PDS的约束报告,确认每一条约束都被工具承认了。
第四是上板调试。PDS的在线逻辑分析仪叫GAO,功能和Vivado的ILA类似,但抓取深度和触发条件设置要弱一些。调试时建议提前把重要信号(比如阵列的valid信号、状态机状态)都保留到顶层,方便在线观测。我开始时把信号埋得太深,GAO又不像ILA那样支持自动跨层级探测,导致反复改了两次综合设置才把信号抓出来。
3.4 超低延迟的系统级优化
部署完只是第一步,真正要把延迟压下来,还要做系统级的优化。我的目标是端到端延迟(从Sensor出帧到车牌结果输出)控制在5毫秒以内。
优化工作分成了三段。
图像输入侧,我用的是MIPI接口的摄像头模组,FPGA通过MIPI接收子层把RAW图像转成灰度图。这里有个关键优化:不做整帧缓存,而是做行缓冲。传统做法是一帧图采集完放进DDR,然后从DDR取出来处理,这一进一出至少多出两帧时间。而行缓冲方式,MIPI进来一行数据就往车牌检测模块送一行,检测模块实时做边缘检测和投影,等最后一行的投影累加完,车牌位置结果立刻就能输出。
检测和识别之间也有个优化:传统流程是先输出整张车牌区域的图像,再开始识别。我改成了边检测边识别——基于投影法确定的车牌RGB范围边界,在数据还停留在行缓冲里时就把车牌字符区域切割出来,直接送到识别模块。这样省掉了中间一个图像缓冲,少了一次DDR的访问。
关键的延迟数据,我做了一个实测记录。输入720P灰度图,MIPI采集一行是1280像素,在100MHz下约12.8微秒,整帧采集约7.4毫秒。检测阶段由于是流水式,在帧结束后50微秒内输出车牌区域。字符识别阶段,单字符前向推理时间约0.2毫秒,7个字符一共1.4毫秒。整条链路结束,正好在帧结束后的1.5毫秒内输出结果,端到端延迟大约是帧采集时间加1.5毫秒,合计9毫秒以内。如果把传感器帧率升到60fps,可以进一步压缩到约8毫秒。
4. 常见问题与排查技巧实录
4.1 脉动阵列valid时序错位问题
这个Bug在仿真阶段特别容易遇到。表现为卷积输出的结果位置不断偏移,跑同样的输入,前几次结果对,后几次结果就错位了。
排查思路是这样的:先定位是哪个PE的输出不对,用波形对比PE输出的valid时序,果然发现有一个PE的valid信号比同行的其他PE提前了一拍。原因是我在写数据流控制时,用一个计数器来生成每行的valid信号,但计数器复位时的初值在每个PE里没有统一,导致跨行valid信号错位。
解决方法是把所有PE的valid生成逻辑抽出来,统一由一个控制模块生成,再按列广播下去。PE内部不再自己独立判断窗口位置,只负责接收外部的valid信号。这样时序单一,问题就消失了。这个经验后续在移植到紫光同创时也变得很重要,因为如果PE各自产生valid,两个平台的复位时序差异会放大这种错位。
4.2 DDR读写冲突导致的偶发数据错误
在Xilinx平台上整板调试时,遇到的第一个大问题是偶发的数据错误。表现为识别结果偶尔变成乱码。
通过ILA排查,发现是DDR带宽不够导致的突发读数据FIFO下溢。脉冲阵列的输入需要持续的数据流,但DDR读数据时如果和写入数据通道发生冲突,总线仲裁会让读请求等待,导致FIFO里的数据被消费完,阵列拿到了空洞数据。
解决办法是增加了三级缓冲:MIPI进来的原始数据先写进一个乒乓FIFO,检测模块从FIFO读数据,检测结果写入另一个FIFO,识别模块从后一个FIFO读数据。同时在DDR总线上增加了带优先级的仲裁器,读请求的优先级设为高于写请求,确保识别过程中数据流不断。这个改动让偶发错误的概率从每100帧几次降到了完全消失。
4.3 跨平台移植后性能下降问题
移植到紫光同创后,时序目标一直是200MHz,但实际综合后只能跑到160MHz,延迟多了20%。检查综合报告发现,关键路径落在了PE内部的累加器上。紫光同创的FPGA没有类似Xilinx DSP48那样的硬核乘累加器,乘法器是用LUT搭建的,导致PE内部的组合逻辑链变长,频率上不去。
解决方法是把PE内部的时序重新划分。原本一个周期里完成乘法和累加两步操作,现在拆成两个周期,第一周期算乘法,第二周期和部分和做累加。虽然单次计算多了一拍流水线延迟,但主频从160MHz提升到了190MHz,整体吞吐量反而提升了。这个改动对脉动阵列来说很自然,阵列的流水线深度增加一级,对整体延迟影响微乎其微。
4.4 使用不同厂商BRAM位宽不匹配的坑
这个坑出现在移植时,原因是我在Xilinx平台用惯了18Kb BRAM,每个BRAM可以配置成512x36或1024x18等。紫光同创的BRAM容量和配置模式不一样,导致我原来看似“很合理”的存储配置,在PDS里死活综合不出来。
解决方式是先用小容量仿真存储模块替代真实的BRAM原语,验证逻辑正确性,然后单独做一个容量测试模块,分别测试两个平台的BRAM配置能力。测试完再改参数。这个经验是:在写跨平台设计时,存储这类比较吃平台特性的模块,提前做一个最小的资源适配层,后面会省很多事。
5. 影响范围与应用扩展
5.1 场景扩展:从车牌到更多目标检测
这套脉动阵列的设计,虽然最开始是给车牌识别用的,但它的核心能力是可以复用的。脉动阵列处理的是通用卷积,只要把权重重新加载,它能跑的远不止车牌这一种模型。
我后来把识别模块的输入从车牌字符改成了几类简单的工业零件缺陷分类,效果也不错,改动量主要在网络配置参数上。实际上,只要目标检测的网络结构足够轻量(比如YOLO-tiny、SSD-tiny这类),8x8脉动阵列配合行缓冲方案,都能以极低延迟跑起来。只是对更大的模型,阵列规模和片上缓存需要同步扩容,比如设计成16x16或给每列加一个权重切换机制。
5.2 架构可复用性与团队能力沉淀
这个项目带来的另一个价值,是沉淀了一套相对独立的“FPGA视频AI加速模板”。从MIPI输入适配、行缓冲处理、脉动阵列卷积,到DDR读写仲裁,再到Xilinx/紫光同创双平台移植的注意事项,都已经整理成了文档和可复用的RTL模块。后面再做类似项目,不需要从零开始,只需要把网络参数和输出解析模块替换掉就行。
对我们团队来说,这套方案还有一个隐性收益——彻底验证了在国产FPGA上做AI推理的可行性。之前很多人对紫光同创做AI计算有顾虑,担心性能差太远。实际测试下来,在资源相近的情况下,紫光同创做到190MHz,和Xilinx跑200MHz的差距非常小。对于车牌识别这类轻载AI应用来说,完全具备落地的能力。
5.3 延迟进一步优化的可能性
当前版本延迟的主要瓶颈其实已经不在计算侧,而在图像采集侧。MIPI传感器的帧率决定了数据进来的速度,要压缩延迟,可以直接改用更高帧率的Sensor,或者配置为ROI输出模式(只输出感兴趣区域),这样帧采集时间能大幅缩短。
计算侧也有优化空间。当前两个卷积层是串行执行的,第二层要等第一层全部算完才开始。如果BRAM资源足够,可以把两层卷积放到两个独立的脉动阵列上,让它们流水执行,识别时间从1.4毫秒潜在地降到0.7毫秒以内。不过这需要芯片资源进一步放宽,属于下一版本的规划了。
做完整个项目,我最大的体会是:FPGA做AI加速,难点往往不在AI本身,而在系统工程。脉动阵列的RTL写好只是第一步,真正花时间的是怎么让数据源源不断地喂进来,怎么让时序在跨平台时还能收敛,怎么让延迟控制到视频流级别的确定性。如果你也在做类似的事情,建议先从系统数据流画图开始,把一个像素从Sensor到最终结果的完整路径走一遍,再动手写RTL,这样会少走很多弯路。