做完这个项目两三个月了,我一直在琢磨要不要把全过程写下来。原因很简单:市面上能同时跑通Xilinx和紫光同创的纯Verilog卷积加速方案,几乎找不到可参考的细节,大部分人都被HLS或者厂商自带IP绑住了。这次我们用纯Verilog手写了一套脉动卷积阵列加速器,用在车牌检测和识别场景上,把端到端的推理延迟压到了几十微秒这个量级。整个过程踩了不少坑,也积累了一些实打实的经验,这篇文章就把架构设计、部署差异、调试实录一次讲清楚,适合正在做FPGA视觉加速、想了解脉动阵列、或者有国产FPGA迁移需求的朋友当参考。
1. 项目全貌与核心设计思路
1.1 为什么坚持纯Verilog而不是HLS
说实话,最开始我们也认真评估过Vivado HLS、Vitis AI,甚至想过直接调Xilinx的DPU IP。但深入比了几轮后,还是决定全部手写RTL,用纯Verilog实现。
最主要的原因是低延迟场景下,HLS的高度抽象反而成了包袱。HLS的PIPELINE指令虽然能帮你把循环流水化,但它对硬件结构的映射是黑盒,综合后实际生成了多少级流水、数据在哪个周期对齐、资源被调度到哪个Bank,这些信息都不够透明。一旦要针对具体时序去做微调,HLS的修改-综合-验证周期远不如直接改RTL来得痛快。脉动阵列这种有严格数据流动方向的结构,手写RTL可以直接把每个数据在哪个时钟周期到哪个PE映射到代码里,综合后的关键路径完全可预期。
另一个原因是资源控制粒度。车牌识别网络不大,但卷积层的通道数、特征图尺寸都要精确匹配FPGA上的DSP和BRAM。用HLS生成出来的逻辑通常偏大,尤其在做小尺寸定点量化时,HLS对移位、截断的处理往往不如手写灵活。代码规模方面,整个项目RTL大概9000行,加上验证脚本不到两万行。对于有清晰架构设计的团队来说,这个量级完全可维护。
当然这不是说HLS一无是处,如果是大规模算法快速原型验证,HLS效率确实高。但在“超低延迟”这个约束下,纯Verilog能给到更细的控制粒度,这是它不可替代的理由。
1.2 为什么一定要上脉动阵列
传统CPU/DSP做卷积的路径是:先从内存把输入数据和权重搬到ALU旁边,算完再搬回去。数据搬运的延迟和功耗占了很大比重。脉动阵列的思路完全反过来,它在每个PE里都放上寄存器、乘法器和加法器,输入数据一旦进入阵列,就像水在管道里流动一样,按照固定节奏从上游PE流向下游PE,每个PE在数据经过时顺手完成一次乘累加。
这种结构的好处有两层。第一层是数据复用,相邻PE之间共享输入激活和权重,外部存储器的访问次数被大幅削减,这对小容量FPGA的BRAM带宽非常有意义。第二层是吞吐率,阵列里所有PE在同一个时钟沿工作,宏观上可以在一个周期之内完成几十甚至上百次乘加运算。我们用32个PE的阵列跑车牌识别网络,150MHz时钟下等效算力约4.8 GOPs,处理一张几十像素的小图,计算延迟就是几十微秒量级。
这里顺便回应一个问题:为什么不用乘法器树?乘法器树适合做单次向量的乘加,比如全连接层的一次推理。但卷积是三维滑动窗口的重复计算,脉动阵列可以把整个卷积循环的中间结果留在片内流动,不像乘法器树那样每次运算都要把中间结果搬进搬出。在视觉任务上,脉动阵列才是更贴合计算形态的硬件结构。
1.3 车牌检测识别场景的硬件需求拆解
做车牌识别,第一反应可能是一股脑上大模型。但真实场景有个特点:目标高度固定,无非是蓝底白字、黄底黑字、绿底白字,字符排列规范,类别数量有限。这种先验决定了系统不需要对整帧高清图像做复杂检测,用“颜色粗定位+轻量CNN验证识别”的级联方案更合理。
我们把整条链路拆成几个关键模块:
- 输入采集:720p/1080p视频流通过MIPI或并行接口进FPGA,先做灰度化和降采样,得到适合硬件处理的小尺寸灰度图;
- 车牌粗定位:利用车牌底色在YUV颜色空间内的分布特征,通过像素级阈值判定和连通域聚集,快速找出候选框;
- 候选区域精修:对候选框做裁剪、缩放、归一化,送到CNN验证网络排除误检;
- 字符识别:对验证通过的车牌区域做定长切分,逐字符分类,最终输出超低延迟的可读结果。
真正的卷积加速器只负责后面两个阶段,它们处理的数据量小、网络结构浅,计算时间非常短。粗定位虽然处理的是全图,但用的只是比较器和行缓存,逻辑简单,延迟几乎可以忽略。这套任务拆解才是“超低延迟”的根本来源,单纯堆算力反而是最笨的办法。
2. 脉动卷积阵列的硬件架构逐层拆解
2.1 单个PE的计算内核:乘累加怎么在一个周期内完成
先从最底层的PE说起。PE是脉动阵列的基本单元,我习惯把它理解成一个“带寄存器的乘累加站”。每个PE有四个方向的输入输出:输入激活、权重输入、部分和输入,以及对应的三个输出,再加上时钟和复位。
module systolic_pe #( parameter DATA_WIDTH = 8, parameter ACC_WIDTH = 24 )( input wire clk, input wire rst_n, input wire [(DATA_WIDTH-1):0] act_in, // 经过本PE的输入激活 input wire [(DATA_WIDTH-1):0] w_in, // 经过本PE的权重 input wire [(ACC_WIDTH-1):0] psum_in, // 上游传进来的部分和 output reg [(DATA_WIDTH-1):0] act_out, // 传递给下游PE的激活 output reg [(DATA_WIDTH-1):0] w_out, // 传递给下游PE的权重 output reg [(ACC_WIDTH-1):0] psum_out // 累加后的部分和 ); wire signed [(ACC_WIDTH-1):0] act_ext; wire signed [(ACC_WIDTH-1):0] w_ext; wire signed [(ACC_WIDTH-1):0] mul_out; wire signed [(ACC_WIDTH-1):0] acc_out; assign act_ext = {{(ACC_WIDTH-DATA_WIDTH){act_in[DATA_WIDTH-1]}}, act_in}; assign w_ext = {{(ACC_WIDTH-DATA_WIDTH){w_in[DATA_WIDTH-1]}}, w_in}; assign mul_out = act_ext * w_ext; assign acc_out = psum_in + mul_out; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin act_out <= {(DATA_WIDTH){1'b0}}; w_out <= {(DATA_WIDTH){1'b0}}; psum_out <= {(ACC_WIDTH){1'b0}}; end else begin act_out <= act_in; w_out <= w_in; psum_out <= acc_out; end end endmodule这个PE的要点是乘法结果和上游部分和在同一个组合逻辑内完成相加,然后在时钟沿统一打拍输出。这样做的好处是所有PE之间的路径都被寄存器隔开,组合逻辑只存在于单个PE内部,不会出现一个信号穿透几十个PE的长路径,这是后续时序收敛的基础。
乘法器的位宽扩展要注意符号问题。输入数据默认是8bit有符号定点数,乘法结果扩展成24bit是为了给累加器留余量。车牌识别网络几乎全程使用ReLU激活,所以部分和理论上都是非负数,但中间层的偏置可能导致负值,统一按有符号处理最稳妥,否则会出现“算出来全是对的、输出全变成0”这种让人头疼的问题。
2.2 一维脉动阵列的数据调度:权重预载、输入巡游、部分和累积
有了PE之后,第二层问题就是怎么把它们组织起来。我们最终用的是32个PE组成的一维阵列,而不是更吸引眼球的二维阵列。二维阵列适合大矩阵乘法,但车牌识别网络的卷积层通道数不多,用二维阵列会造成大量PE空洞,利用率反而低。一维阵列加上时延控制,在小型网络上能做到更紧凑的调度。
一维脉动阵列跑3x3卷积的基本流程分三步。第一步,把卷积核的9个权重预载到对应的PE里,做好静态配置。第二步,输入图像像素按照从左到右、从上到下的顺序,一个接一个进入阵列。第三步,每个PE把当前输入像素和预载权重相乘,再加上从上游传来的部分和结果,形成新的部分和继续往下游传。
这样说比较抽象,用一个8x8灰度图、1个3x3卷积核的例子来描述。当第一个输出像素需要计算时,硬件同时需要(0,0)到(2,2)共9个像素。如果输入数据是逐行扫描的,那么我们需要等第0、1、2行分别进入行缓冲后,才能凑齐第一窗口。这也正是行缓冲在下一节存在的意义。数据准备好后,9个像素在时间上错开进入阵列,每一拍进入一个像素,和对应位置的权重相乘。从第4拍开始,部分和就能依次从阵列尾部输出。也就是说,阵列尾部拿到第一个完整结果只需要几个周期,这就是“数据流动”带来的低延迟特性。
权重预载方面,要考虑多输出通道的情况。车牌识别网络卷积层输出通道通常是16、32,我们的做法是把多个输出通道的权重按组存入BRAM,然后通过一个权重调度状态机,在空闲间隙预载下一组权重,实现通道间的无缝切换,避免等待周期。
2.3 行缓冲与滑动窗口:图像卷积的数据喂给方式
脉动阵列解决了“数据在阵列内部流动”的问题,但还有一个前置问题:怎么把二维图像转换成适配阵列的输入序列。直接去外部RAM随机读取9个像素是不可能的,那会让访存变成性能瓶颈。标准解法就是行缓冲,也叫Line Buffer。
行缓冲的原理可以类比成三行抽屉。图像按行流式进入,第一行先填满第一抽屉,第二行填满第二抽屉,第三行边进边和前面两行组合,产生第一行输出的3x3窗口数据。每来一个新像素,窗口向右滑动一格,第三抽屉的新像素进来,第一抽屉的对应像素被丢弃。这样每个输入像素周期都能产生一个卷积窗口,不需要任何额外等待。
在Verilog实现上,行缓冲一般用BRAM做,三个行缓冲各存放一行图像数据,配合一组移位寄存器实现3x3窗口的组织。注意行缓冲的写地址和读地址必须圆滑递增,到了行尾要生成行同步信号,让窗口清空,避免换行时把上一行末尾的像素串到下一行开头。
// 行缓冲模块:输入流式像素,输出3x3窗口 module line_buffer #( parameter WIDTH = 8, parameter H_SIZE = 128 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] din, input wire valid, output wire [WIDTH-1:0] win_r0_c0, win_r0_c1, win_r0_c2, output wire [WIDTH-1:0] win_r1_c0, win_r1_c1, win_r1_c2, output wire [WIDTH-1:0] win_r2_c0, win_r2_c1, win_r2_c2 ); // 三行移位窗口寄存器 // 每拍valid有效时,将新像素挤入窗口右端,窗口整体左移 // 行切换时由行结束信号控制底层数据从BRAM行缓存装载 endmodule滑动窗口这件事本身和“滑动窗口滤波Verilog”是同一个套路,无非滤波器的系数变成了卷积核的权重。理解了行缓冲之后,再来做均值滤波、高斯滤波、Sobel边缘检测,代码框架几乎不用改,换一组系数和窗口大小就行。
2.4 阵列尺寸与DSP资源的平衡
阵列尺寸不是越大越好。我们最初在仿真里尝试过64个PE的方案,发现性能提升不到20%,但DSP消耗翻倍,BRAM作为权重缓存也吃紧,完全得不偿失。后来用“最小满足算力法”来确定阵列规模。
具体做法是:先把车牌识别网络每层的乘加次数统计出来,除以系统帧率目标,得到需要的有效MAC/s。再除以工作时钟频率,得到并行度要求。比如网络单帧总乘加次数约1.2M MACs,目标帧率25fps,那么有效算力需要30M MAC/s。在150MHz时钟下,每个PE每秒能做150M MACs,理论上1个PE满负荷就够。但考虑行缓冲带来的空转、权重切换产生的气泡、多通道并行的需要,实际上取32个PE比较稳妥,既能展开多输出通道,又能在小图内一条流水线吃完。
资源占用方面,以我们用的Xilinx Zynq-7020为例,32个PE消耗约32个DSP48E1,行缓冲和权重缓存消耗约86块36Kb的BRAM,LUT消耗约三万。在紫光同创Logos系列中端型号上,DSP数量需要裁剪到24个左右,我们把部分卷积层改成分时复用方案,在一个PE里通过状态机完成两次乘加,牺牲少量时间换取资源可控。
注意:阵列规模一定要在写RTL之前确定,不要等代码写完了再改。脉动阵列的调度逻辑和PE数量强耦合,临时增减PE,状态机和地址生成器都要跟着大改,这个教训我们试过一次就不再犯了。
3. 车牌检测识别整体流水线设计与实现
3.1 从像素到结果:整条流水线的划分
整个系统从外部看是一条完整流水线:视频像素流进来,过几个模块,最终从输出接口吐出车牌号码。但内部是分阶段并行处理的,我在设计时把流水线切成六段。
第一段是图像采集与预处理。MIPI或并行接口进来的Bayer/YUV数据先做灰度化,再用双线性降采样把720p降到640x360以内。灰度化用公式0.299R+0.587G+0.114B,为了避免浮点,用整数移位近似。第二段是颜色粗检测,在YUV空间里对蓝色、黄色像素做阈值比较,生成二值图。第三段是连通域分析,对二值图做快速游程编码,把相邻的目标点连成候选块。第四段是候选框精修,对候选块坐标做归一化,送进第一个CNN网络(验证网络),排除非车牌的干扰目标。第五段是车牌识别网络,对验证通过的车牌区域做字符切分和分类。第六段是结果输出,把7位字符的类别编码通过并口或FMC接口发给MCU显示。
这样拆的好处是,前三级处理是在像素流中完成的,不需要等整帧结束,数据自然的流水下去。真正停下来等一整帧的只有粗检测模块里的连通域分析,但它的计算只是像素比较和地址合并,几百个周期就出结果。
3.2 检测网络设计:小分辨率单尺度回归
检测网络我们做得比较轻,核心目的是“精修候选框”,而不是从头找车牌。既然颜色粗检测已经把区域缩小到几个候选块,验证网络的输入就是一个小尺寸灰度图,比如24x8,网络只有两层卷积加一层全连接,输出4个坐标回归值和1个置信度。
训练这个网络比想象中简单,我们用PyTorch把车牌图像按照不同光照、模糊程度做了数据增强,正样本是真实车牌,负样本是路面、护栏、车身等颜色接近的干扰块。训练到验证集精度97%左右就够用了,因为后面的识别网络还会对坐标做进一步约束。训练完成后,把权重导出成8bit有符号整数,每组权重用$readmemh读入FPGA的BRAM初始化文件,或者通过串口在启动时加载。
这里有个容易被忽视的点:训练时的输入分辨率要和硬件一致。很多人训练时用64x64的图,硬件端却把候选框缩放到24x8,精度下降不明显,但坐标回归的分布完全不一样了。我们后来把训练时的预处理脚本和硬件缩放参数统一,效果明显回升。
3.3 识别网络设计与字符切分策略
车牌字符识别部分,我们经历了两次迭代。第一版用投影法做字符切分,思路是统计每一列的像素密度,找出字符之间的空白间隔来分割。代码写起来简单,但实际场景里车牌有螺丝钉、锈迹、光线不均,投影曲线的谷底经常被噪声填平,切分错误率很高。
后来放弃投影法,改成固定等宽切分。理由很朴素:中国车牌有标准物理尺寸,字符间距固定,只要定位到车牌左右边界,按固定宽度切成7段即可。车牌定位由检测网络输出,精度足够。每段10x20灰度图送入一个小CNN做40类分类(省份汉字31类+英文字母24类,部分字母数字合并),输出字符类别编号。
识别网络的结构是:输入10x20灰度,第一层3x3x16卷积加2x2池化,第二层3x3x32卷积加全局平均池化,最后接40路全连接。整个网络每帧只需要对7个字符分别推理,总乘加量很小。在实际的FPGA实现中,7个字符的分类可以并行调度,同时喂给7组PE,让字符识别的尾部延迟压缩到“最慢那组分类结果”的时间,而不是7倍时间。
3.4 超低延迟的量化拆解
“超低延迟”四个字说出来容易,落到数据上要能让人信服。我们最终用ILA在板卡上实测了一组数据,延迟定义是“候选框自动锁定后,从最后一个输入像素进入识别网络真面目到7位字符结果出现在输出寄存器”的时间。
| 处理阶段 | 周期数 | 150MHz下对应时间 |
|---|---|---|
| 候选框数据搬入行缓冲 | 512 | 3.4 us |
| 验证网络三层推理 | 620 | 4.1 us |
| 车牌区域裁剪与归一化 | 256 | 1.7 us |
| 识别网络30层计算 | 2300 | 15.3 us |
| 结果与置信度输出 | 8 | 0.05 us |
| 合计 | 3696 | 24.6 us |
这个24.6us是从候选框锁到结果输出的尾部延迟,在实际应用里已经接近“所见即所得”。如果按整帧从进入到识别完成的端到端来算,720p视频大约需要6.8ms,这是因为得等整帧像素扫描完才能做全图粗检测,但任务级的延迟和计算级延迟在这里是两回事。做硬件加速时一定要分清这两个指标,否则很容易对自己的系统产生错误预期。
4. 跨平台移植实战:Xilinx与紫光同创FPGA部署
4.1 资源与时钟约束的初始评估
跨平台部署的第一件事不是写代码,而是拿着器件手册评估资源和时序。Xilinx这边我们用的Zynq-7020,资源足够,重点考虑时钟结构。脉动阵列跑多少频率,决定了PE之间的流水深度。我们一开始在Vivado里综合到250MHz,时序差一点没过,后来把关键路径的乘法器做了额外打拍,稳定收敛在200MHz。但紫光同创的目标器件综合结果不太乐观,关键路径能跑到160MHz左右,再往上FF路径就会变红。
我的建议是,如果同时部署多个平台,RTL设计阶段就按性能较低的平台来定约束。我们把全系统统一约束到150MHz,这样两边的时序报告都非常健康,而且还能留出余量给布局布线的波动。不要追求某个平台跑到极限,跨平台项目的一致性比极限性能更重要。
资源上也要按资源少的器件做预算。Xilinx器件上我们用了32个DSP,在紫光同创的中端型号上DSP数量不够,需要用LUT乘法器替代部分PE。我们写了一个可综合的乘法器选择宏:
`ifdef USE_LUT_MUL // 使用移位加法实现8bit乘法 // 代价是组合逻辑变大,但可以节省DSP资源 `else // 直接例化厂商DSP原语或使用乘号 `endif这样同一份RTL可以通过编译命令切换乘法实现方式,不需要改算法层的调度代码。
4.2 Xilinx工程搭建与关键原语使用
Xilinx这边用Vivado 2020.2,开发环境在Linux上启动会遇到glibc库依赖问题,但这个在官方论坛有现成解决方案,装对应版本的库文件就能跑起来。真正花时间的是时钟原语和配置Flash的适配。
脉动阵列需要多路时钟,像素时钟、阵列时钟、MCU接口时钟。我们统一用Xilinx的MMCM原语生成,关键例化代码如下:
MMCME2_ADV #( .CLKIN1_PERIOD(20.0), // 输入50MHz .CLKFBOUT_MULT_F(8.0), // VCO 400MHz .CLKOUT0_DIVIDE_F(4.0), // 输出100MHz .CLKOUT1_DIVIDE(8) // 输出50MHz ) mmcm_inst ( .CLKOUT0(clk_array), .CLKOUT1(clk_pixel), . );配置Flash用的是华邦W25Q128,在Vivado里不需要额外写驱动,只要在Generate Bitstream后选择Memory Configuration,把SPI总线设为x4模式,时钟频率降到25MHz左右,下载一次bit流,后续就能开机自动加载。这里有个经验:SPI时钟不是越高越好,W25Q128在四线模式下虽然规格书标称能跑104MHz,但FPGA配置逻辑的时序余量通常不稳定,用50MHz以下最省心。
4.3 紫光同创PDS平台移植过程与差异对比
紫光同创的PDS开发环境界面和Vivado非常像,熟悉Vivado的工程师几乎零成本上手。但实际踩坑主要在细节上。
最大的差异是原语命名。Xilinx的MMCME2_ADV、BUFG、IBUFDS在紫光同创里分别对应不同的原语名称,用的时候不能直接把Xilinx代码拿过来综合。我们的处理方式是写一个平台无关的时钟包装模块,把所有厂商原语封装在内部,上层逻辑看到的接口只有clk_sys、clk_pixel和rst_n。这样核心RTL完全不用改。
另一个差异是SystemVerilog支持度。PDS较老版本对always_ff、logic关键字可能只支持一部分,报错信息又不够直观,折腾起来很费时间。我们的经验是跨平台项目统一用Verilog-2001子集,规避掉所有SV语法糖,宁可多写几行也不让综合器挑毛病。
紫光同创的bit流固化和Xilinx流程也略有不同。PDS软件内置了配置存储器的生成向导,支持SPI NOR Flash,我们同样用的W25Q128,配置模式和Xilinx基本一致,只是下载工具换成了PDS自带的Programmer。整体来说,从Xilinx迁到紫光同创大概花了三周,其中一半时间在改原语封装和调整资源分配,另一半时间在等布局布线结果和修时序。
4.4 与STM32H743的FMC控制通路
在整机系统里,FPGA不是独立的计算盒子,它需要把结果传给MCU做上层业务。我们选了STM32H743作为主控,用FMC总线完成通信,这也是一个被问得很多的方向。
FMC可以把外部并行存储器接口映射到ARM内存地址空间,MCU访问FPGA内部寄存器就像读写SRAM一样简单。FPGA侧只需要实现一个异步从设备接口,以地址锁存、读写控制、数据总线为核心。握手时序可以参考标准SRAM接口,但在FPGA内部要注意异步信号的同步问题,读数据时做好采样窗口对齐。MCU端通过类似下面的代码完成一次读操作:
uint16_t result = *(volatile uint16_t *)0x60000000;我们已经把FMC片选地址映射到Bank1的起始地址,FPGA内部通过地址译码把不同偏移量映射到不同寄存器。比如偏移0x00是控制寄存器,0x02是状态寄存器,0x04到0x10是7个字符的识别结果。这种方案接口简单、吞吐率足够,实测单次读写耗时在几十纳秒内,对结果上报这种低频操作绰绰有余。
5. 验证、调试与问题排查实录
5.1 用Testbench和仿真把好第一道关
脉动阵列这种强时序结构,直接上板调试会很痛苦,所以仿真工作一定要做扎实。我们用的是QuestaSim加手写testbench,配合Python生成测试向量。
仿真的第一个层次是单PE。给固定的输入激活和权重,检查乘法累加结果是否正确,符号位有没有丢失。这个层级最容易发现位宽问题。第二个层次是单层网络仿真,用随机权重和随机输入跑一遍,和Python参考模型做逐周期比对。第三个层次才是整机流水,喂一幅真实的720p车牌图像,连续跑完粗检测、验证、识别,输出应该正好对上车牌号码。
testbench里面有几个小技巧。第一个是用任务封装图像读取,$readmemh把十六进制像素文件读入内存,然后按行按列送入DUT。第二个是加断言检查部分和输出,一旦与参考模型不一致,立刻打印当前层号、行号、列号和期望值实际值。第三个是保存仿真波形文件的信号子集,只导出关键信号,避免波形文件动辄几个GB。
initial begin // 读图像文件 $readmemh("input_image.hex", img_mem); // 送入DUT for (row = 0; row < H_SIZE; row = row + 1) for (col = 0; col < W_SIZE; col = col + 1) begin @(posedge clk); din = img_mem[row * W_SIZE + col]; valid = 1'b1; end end5.2 上板调试中遇到的典型问题
仿真过了不代表上板就稳,这里记录几个我们实际踩过的坑。
第一个是数据溢出问题。8bit输入乘8bit权重,结果最大约65025,24bit累加器理论不会溢出。但ReLU之后的特征图我们为了省字节又重新截断成8bit,这时候直接把高位砍掉会导致严重的信息丢失。后来改成先饱和截断再存储,也就是大于127的都记作127,小于-128的都记作-128,精度损失小了很多。
第二个是行缓冲的换行毛刺。在行尾切换缓冲行时,窗口寄存器里残留了上一行末尾的像素,导致输出出现一条垂直的噪声线。排查了很久才发现是行结束信号没有同步到窗口平移逻辑,后来在行结束信号上做了两级寄存同步,问题消失。
第三个是跨时钟域问题。像素时钟和阵列时钟不同源,行缓冲的读写跨域时如果只有一个异步FIFO,偶尔会出现像素错位。我们在行缓冲两端各加了一个小深度的异步FIFO,再配合像素有效信号做握手,基本解决了偶发错位。
第四个是关于复位。原本用的是全局异步复位,但PE数组里几百个寄存器复位释放时间略有不一致,导致状态机偶发进入非法状态。统一改成同步复位模块后,复位树归一化,这个问题再没有复现。
5.3 跨平台性能差异与优化手段
同一份RTL在Xilinx和紫光同创上的综合结果有非常明显的差距,这也符合预期,毕竟工艺和架构不同。Xilinx跑150MHz非常轻松,关键路径余量有3ns左右。紫光同创相同约束下余量只有0.3ns,布局布线稍有波动就可能变负。
针对第二平台的时序优化,我们做了三件事。第一是给乘法器输出打一拍,虽然增加了一级流水延迟,但对吞吐率几乎无影响,关键路径因此缩短了约1ns。第二是把部分和累加器从32bit改成24bit,减少加法器进位链长度。第三是控制每根权重的扇出数量,权重缓存输出的信号同时喂给16个PE,扇出过大影响布局,复制成多份后时序明显改善。
经验:跨平台项目千万不要等Xilinx跑通了才开始考虑第二个平台,应该在RTL编码阶段就制定好“平台无关核心+厂商相关包装”的分层策略。时钟、复位、I/O这些跨平台必改的逻辑全部收进wrapper,核心算法层只使用标准Verilog,这样迁移成本能降低一半以上。
5.4 从项目复盘中沉淀的通用经验
做一个项目如果只留下一堆代码,价值少了一半。把过程中的判断和方法沉淀下来,才算真正完成闭环。
第一,低延迟的关键是任务级流水,不是单纯提高时钟。我们系统里的峰值算力并不高,但通过把颜色粗检测、候选框精修、字符识别三级流水组织起来,让每一步都在数据到达的瞬间处理完,尾部延迟才能控制在几十微秒。
第二,纯Verilog并不排斥厂商原语。设计里时钟管理、I/O接口、BRAM初始化这些部分,用厂商原语是最省事也最可靠的做法。关键是划清边界,不让厂商相关代码渗透到算法核心。
第三,AI辅助设计工具值得用,但要用在合适的位置。我们用Claude Code辅助生成过不少testbench骨架、寄存器配置模块和注释补全,节省了挺多时间。但脉动阵列的调度、行缓冲的状态机、跨域同步这些核心逻辑,AI生成的内容还是要逐行审查,因为它很容易写出功能上正确但时序上完全收敛不了的代码。
第四,建议从行缓冲和单PE开始学习脉动阵列。脉动阵列本身不是玄学,它就是把数据在寄存器之间流动这件事做到了极致。只要把单个PE的时序吃透,理解数据流的方向,再扩展到32个PE甚至二维阵列,都是水到渠成的事。
这套加速器目前还在持续迭代。我们计划下一步扩展成多路车牌并发检测,同时接入DDR3控制器,把更大分辨率图像直接纳入计算范围。届时再把这部分经验和踩坑记录整理出来分享。