尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于PYNQ-Z2与Vivado HLS的YOLOv2 FPGA硬件加速实战

基于PYNQ-Z2与Vivado HLS的YOLOv2 FPGA硬件加速实战 1. 为什么要在PYNQ-Z2上折腾YOLOv2硬件加速第一次把YOLOv2跑在PYNQ-Z2的ARM核上时单帧推理耗时接近4秒这个数字对任何实时检测场景都是灾难。PYNQ-Z2搭载的是Zynq-7020双核Cortex-A9主频650MHzPL端有约53K逻辑单元和220个DSP48E1切片。纯软件跑卷积神经网络算力瓶颈卡得死死的。但FPGA的可编程逻辑恰好适合做卷积这种高度并行的乘累加运算把计算密集的部分卸载到PL端ARM只负责调度和数据搬运这才是这套板子该有的用法。YOLOv2本身是个比较适合入门硬件加速的检测网络。它没有YOLOv3那么多分支和残差结构主干是Darknet-19卷积层规整3x3和1x1交替没有复杂的跨层连接。网络结构规整意味着硬件流水线好设计不会出现某个特殊层把整个数据流打断的情况。而且YOLOv2的输入尺寸固定为416x416不像YOLOv3支持多尺度输入这给硬件资源分配带来了确定性。选择Vivado HLS而不是手写Verilog核心原因是开发效率。一个3x3卷积的硬件实现手写RTL要考虑行缓存、窗口滑动、边界处理、流水线握手写出来加验证至少两周。用HLS写C代码加几个pragma就能控制流水线和数组分割综合出来的时序通常也能满足150MHz到200MHz的目标频率。对于个人项目或者小团队快速验证HLS的投入产出比明显更高。Jupyter Notebook在这套流程里扮演的是最后一公里的角色。PYNQ框架把Overlay加载、DMA传输、寄存器读写都封装成了Python API在Notebook里可以边写边调实时看到推理结果。相比传统的编译-烧录-串口打印循环Notebook的交互式调试效率高出一个量级。你可以先单独测DMA能不能正常搬数据再测单个卷积层输出对不对最后串起整个网络每一步都有可视化的中间结果。这套方案适合谁有基本数字电路概念、会写C/C、想入门FPGA加速的开发者。如果你完全没接触过HLS建议先花两天过一遍Vivado HLS的官方教程理解接口综合和流水线的基本概念。如果你已经会手写RTL那HLS对你来说只是换个工具表达同样的硬件结构上手会很快。2. 开发环境搭建与工程骨架2.1 工具链版本选择与安装顺序Vivado的版本选择是个容易被忽视但影响很大的决策。PYNQ-Z2官方镜像v2.7对应的是Vivado 2019.1v3.0对应2022.1。我建议用2019.1原因是这个版本的HLS对C11支持稳定而且网上能找到的PYNQ-Z2参考工程大多基于这个版本。如果你用2022.1HLS的某些pragma语法有变化老教程里的代码可能综合报错。安装顺序必须是先装Vivado再装PYNQ镜像最后配Jupyter环境。Vivado安装时勾选Vivado HLS和SDK2019.1还叫SDK2020之后合并成Vitis。安装路径不要有中文和空格这是老生常谈但每年都有人踩。PYNQ-Z2的镜像从官方渠道下载后烧到SD卡插板子上电通过网线连到同一路由器浏览器访问http://pynq:9090就能进Jupyter。默认密码是xilinx。Jupyter环境本身不需要额外安装PYNQ镜像里已经预装了。但你需要确认几个Python包numpy用于数据处理PIL用于图像读取matplotlib用于可视化。在Notebook里执行import numpy如果报错用pip install补装。注意PYNQ的Python是3.6版本有些新包不兼容装的时候看下版本要求。2.2 工程目录结构设计一个清晰的目录结构能省掉后面大量的找文件时间。我的习惯是这样组织yolov2_accel/ ├── hls/ # HLS工程 │ ├── src/ # C源码 │ │ ├── conv2d.cpp │ │ ├── maxpool.cpp │ │ └── yolov2_top.cpp │ ├── tb/ # testbench │ └── build/ # 综合输出 ├── vivado/ # 系统集成工程 │ ├── bd/ # Block Design │ └── constraints/ # 约束文件 ├── pynq/ # 板端Python │ ├── overlay/ # .bit和.hwh文件 │ └── notebooks/ # Jupyter笔记本 └── weights/ # 权重文件HLS工程和Vivado工程分开因为HLS综合一次要十几分钟到半小时Vivado实现又要半小时以上。分开之后改HLS代码只需要重新综合HLS导出IP后Vivado里更新一下就行不用从头跑整个流程。2.3 PYNQ-Z2的硬件资源盘点在动手写代码之前先算清楚手上有多少资源。Zynq-7020的PL端资源如下资源类型数量YOLOv2加速器占用预估LUT53200约35000FF106400约40000DSP48E1220约180BRAM (36Kb)140约100时钟资源4个MMCM用1个DSP是卷积计算的硬通货每个DSP一个时钟周期能做一个18x18的乘累加。YOLOv2的卷积层如果按输出通道并行展开需要的DSP数量等于并行度乘以卷积核面积。比如3x3卷积并行度设为8就需要8x972个DSP。220个DSP意味着你最多同时展开两个这样的卷积层或者一个层用更高的并行度。这个账要提前算不然综合出来资源超了只能回头降并行度重新综合。BRAM主要用来做行缓存和权重缓存。3x3卷积需要缓存两行输入数据416宽度的图像每行416个像素如果数据位宽16bit两行就是416x2x21664字节一个36Kb的BRAM能存下。权重缓存方面YOLOv2最大的卷积层权重数量是3x3x256x512约118万个参数16bit量化后是2.3MB远超片上BRAM容量。所以权重必须放在DDR里通过DMA按需搬运或者用AXI Stream的方式流式加载。3. HLS卷积层设计从C代码到硬件流水线3.1 卷积的C参考实现与优化方向先用最朴素的C写一个卷积函数作为功能验证的基准void conv2d_ref( float input[CH_IN][H][W], float weight[CH_OUT][CH_IN][3][3], float bias[CH_OUT], float output[CH_OUT][H][W]) { for (int co 0; co CH_OUT; co) { for (int h 0; h H; h) { for (int w 0; w W; w) { float sum bias[co]; for (int ci 0; ci CH_IN; ci) { for (int kh 0; kh 3; kh) { for (int kw 0; kw 3; kw) { sum input[ci][hkh][wkw] * weight[co][ci][kh][kw]; } } } output[co][h][w] sum; } } } }这段代码有六层嵌套循环直接综合会生成一个状态机每个时钟周期只做一次乘累加完成一个输出像素需要CH_IN x 9个周期。对于CH_IN256的层一个像素就要2304个周期416x416的输出需要4亿个周期按150MHz算要2.6秒比ARM还慢。优化的核心思路是并行化。最内层的乘累加循环可以完全展开用DSP阵列并行计算。具体来说把ci、kh、kw三个循环展开每个时钟周期同时计算所有输入通道的9个乘累加。这样需要的DSP数量是CH_IN x 9对于CH_IN256就是2304个DSP远超220个。所以必须做部分展开比如展开因子设为8每个周期算8个乘累加DSP用量降到72个。3.2 关键pragma的使用与参数计算HLS的pragma是控制硬件结构的旋钮用对了事半功倍。卷积层最关键的几个pragmavoid conv2d_accel( hls::streamdata_t in_stream, hls::streamdata_t out_stream, data_t weight[CH_OUT][CH_IN][3][3]) { #pragma HLS INTERFACE axis portin_stream #pragma HLS INTERFACE axis portout_stream #pragma HLS INTERFACE m_axi portweight depth... data_t line_buf[2][W]; #pragma HLS ARRAY_PARTITION variableline_buf complete dim1 data_t win_buf[3][3]; #pragma HLS ARRAY_PARTITION variablewin_buf complete dim0 for (int h 0; h H; h) { for (int w 0; w W; w) { #pragma HLS PIPELINE II1 // 滑动窗口填充 // 乘累加计算 } } }ARRAY_PARTITION complete把数组拆成独立的寄存器这样每个元素可以同时被访问。PIPELINE II1告诉HLS每个时钟周期启动一次循环迭代这是达到高吞吐的关键。但II1不是随便写的要满足两个条件一是循环体内没有跨迭代的数据依赖二是资源够用。滑动窗口的填充逻辑如果写得不好会产生依赖导致II降不下来。权重数组的m_axi接口深度要算准。YOLOv2最大层的权重是3x3x256x51216bit量化后是2359296字节。深度参数写这个字节数除以位宽如果位宽是16bit深度就是1179648。这个值写小了会导致HLS综合时权重访问越界写大了浪费地址空间。3.3 行缓存与滑动窗口的硬件实现细节行缓存是卷积硬件设计的经典问题。3x3卷积需要同时访问三行数据但输入是逐像素流式进来的所以必须缓存前两行。标准做法是用两个BRAM做行缓存每个BRAM存一行像素。当第三行数据到来时三个行缓存同时输出对应位置的像素组成3x3窗口。这里有个容易忽略的细节行缓存的读写地址管理。如果每来一个像素就更新一次地址地址逻辑会变得复杂。更优雅的做法是用移位寄存器的方式每个时钟周期把新像素移入旧像素移出。但416个像素的移位寄存器会消耗大量FF所以实际实现是BRAM加地址指针指针每W个周期回绕一次。边界处理是另一个坑。图像边缘的像素没有完整的3x3邻域通常的做法是补零。在流式处理中补零意味着在每行开始前和结束后插入零像素。这需要在行缓存的控制逻辑里加状态判断当行计数器小于1或大于H-2时输出零。这个判断如果放在流水线里会增加逻辑级数可能拉低时序。我的做法是把边界处理单独做成一个状态在流水线外完成虽然多花几个周期但保证了主流水线的II1。3.4 量化策略float到int16的精度损失控制PYNQ-Z2的DSP做浮点运算效率很低一个浮点乘累加要消耗多个DSP和大量逻辑。所以必须量化到定点。YOLOv2的权重和激活值范围差异很大直接线性量化到int16会损失精度。我采用的是分层量化每一层的权重单独统计最大值然后缩放到int16范围。激活值用int8因为激活值经过ReLU后范围相对可控。量化带来的精度损失需要验证。我的做法是在Python里用numpy模拟量化过程对比float推理和int16推理的输出差异。如果某一层的输出差异超过5%就调整这一层的量化位宽或者缩放因子。实测下来YOLOv2在int16权重加int8激活的配置下mAP下降在2%以内对于硬件加速来说这个代价可以接受。反量化在输出层做。最后一层卷积输出后乘以缩放因子还原到float再送去做NMS。NMS在ARM上跑因为它的计算量不大但逻辑复杂用Python实现比硬件实现更灵活。4. Vivado系统集成Block Design与DMA配置4.1 从HLS导出IP到Block DesignHLS综合完成后导出IP的步骤是Solution菜单里选Export RTL格式选Vivado IP输出目录指向Vivado工程的IP仓库。然后在Vivado里打开IP Catalog刷新一下就能看到新导出的IP。这里有个坑如果HLS工程里改了接口或者参数重新导出IP后Vivado里已经例化的IP不会自动更新需要手动升级。右键IP选Upgrade IP或者删掉重新例化。Block Design里需要例化的模块包括Zynq Processing System、HLS卷积IP、AXI DMA、AXI Interconnect、以及可能的AXI Stream Data FIFO。Zynq PS要配置好DDR控制器和时钟PL端时钟设到150MHz。DMA配置成Simple模式还是Scatter-Gather模式取决于数据量。YOLOv2一帧416x416x3的输入是519168字节加上中间层的数据用Simple模式每次传输要重新配置开销大。Scatter-Gather模式可以预先建好描述符链DMA自动连续搬运适合这种大批量传输。4.2 DMA带宽与数据搬运的瓶颈分析AXI DMA的理论带宽是每个时钟周期传输位宽的数据。如果位宽是64bit150MHz时钟理论带宽是1.2GB/s。但实际带宽受DDR控制器和AXI Interconnect的影响实测下来大概在600MB/s到800MB/s之间。YOLOv2一帧推理需要搬运的数据量输入519KB每层输出和下一层输入之间的搬运加上权重搬运。总数据量大概在50MB到80MB之间。按700MB/s算光数据搬运就要70ms到110ms。这个时间可能比计算本身还长所以数据复用很重要。能放在片上BRAM的中间结果就不要搬回DDR层与层之间用AXI Stream直连只有跨IP边界时才走DMA。DMA的中断配置也要注意。PYNQ的Python API里DMA传输完成会触发中断Python端用wait()等待。如果中断没配好wait()会一直阻塞。检查BD里DMA的interrupt端口是否连到了Zynq PS的IRQ以及PYNQ的overlay驱动是否正确注册了中断处理函数。4.3 时序收敛与资源占用的权衡150MHz的目标频率在Zynq-7020上不算激进但如果流水线设计得不好时序还是会挂。常见的时序问题出在DSP的级联路径上。如果乘累加的结果要跨多个DSP级联组合逻辑延迟会累积。解决办法是在DSP之间插入寄存器代价是多一个时钟周期的延迟但时序能改善。资源占用方面综合报告里要看几个关键数字LUT利用率、DSP利用率、BRAM利用率。如果LUT超过80%布线会变得困难时序容易挂。DSP超过90%也一样。我的经验是留20%的余量给后续调试和修改留空间。如果资源实在不够降低并行度是最直接的办法但吞吐会成比例下降。功耗也是要考虑的。Zynq-7020在满载运行时功耗大概2W到3WPYNQ-Z2的散热片能扛住但如果环境温度高还是要加个小风扇。Vivado的Power Report可以估算功耗综合后看一下如果超过3.5W就要注意散热。5. Jupyter Notebook端的驱动与调试5.1 Overlay加载与寄存器映射PYNQ的Overlay机制把bitstream和硬件信息打包成一个对象。加载Overlay的代码很简单from pynq import Overlay ol Overlay(/home/xilinx/yolov2_accel.bit) dma ol.axi_dma_0 conv_ip ol.conv2d_accel_0但这里有个细节.hwh文件必须和.bit文件同名同目录否则Overlay加载会报错找不到IP信息。.hwh文件是Vivado导出硬件时生成的包含了IP的寄存器映射和中断信息。如果只拷了.bit没拷.hwhPython端就不知道IP的寄存器地址读写会失败。寄存器读写用ip.register_map属性。比如要配置卷积层的输入通道数可以这样conv_ip.register_map.CH_IN 256 conv_ip.register_map.CH_OUT 512但要注意HLS IP的寄存器映射是自动生成的寄存器名称和C函数里的参数名对应。如果C函数里参数叫ch_in寄存器名就是ch_in。大小写敏感写错了会报AttributeError。5.2 DMA传输的Python封装与性能测量DMA传输的Python封装要处理几个事情分配连续内存、启动传输、等待完成、读取结果。PYNQ提供了allocate函数来分配连续物理内存from pynq import allocate import numpy as np in_buf allocate(shape(519168,), dtypenp.uint8) out_buf allocate(shape(CH_OUT*H*W,), dtypenp.int16) # 填充输入数据 in_buf[:] input_data.flatten() # 启动DMA传输 dma.sendchannel.transfer(in_buf) dma.recvchannel.transfer(out_buf) dma.sendchannel.wait() dma.recvchannel.wait()性能测量用time模块但要注意Python的计时精度。time.time()的精度在毫秒级对于几十毫秒的推理够用。如果要更精确用time.perf_counter()。测量时要跑多次取平均第一次运行有缓存预热的影响数据会偏大。一个常见的坑是DMA传输的数据量必须是4字节对齐的。如果输入数据是519168字节这个数能被4整除没问题。但如果某一层的输出是奇数长度就要补零到4的倍数。补零的位置和数量要在Python端和硬件端约定好不然数据会错位。5.3 中间结果可视化与逐层验证逐层验证是调试硬件加速器的核心方法。不要一上来就跑整个网络先单独测一个卷积层。在Notebook里构造一个小的输入矩阵比如8x8x3权重用已知值手动算一遍期望输出然后跑硬件对比结果。可视化用matplotlib。把输入图像、中间层特征图、最终检测框都画出来。特征图的可视化要注意int16的数据范围可能很大直接imshow会一片白或一片黑。要先归一化到0-255或者用np.clip限制范围。如果某一层输出不对排查顺序是先确认输入数据是否正确写入DMA缓冲区再确认权重是否正确加载然后检查HLS IP的寄存器配置是否和预期一致最后看时序是否满足。我遇到过一次输出全零的情况查了半天发现是HLS IP的时钟没接对IP在跑但时钟是悬空的寄存器写不进去。6. 踩坑实录那些让我熬夜的报错6.1 Jupyter Notebook打不开与DLL加载失败ImportError: DLL load failed while importing rpds这个报错在Windows上跑Jupyter时经常遇到。原因是rpds-py这个包的C扩展和当前Python版本不匹配。解决办法是卸载重装pip uninstall rpds-py然后pip install rpds-py。如果还不行装一个纯Python的替代版本或者升级pip到最新版再装。Jupyter Notebook打不开还有可能是端口被占用。默认端口是8888如果被其他程序占了Jupyter会尝试8889、8890。但有时候它不提示直接卡住。用jupyter notebook --port9999指定一个不常用的端口能绕过这个问题。在PYNQ板子上Jupyter是作为服务运行的。如果浏览器访问http://pynq:9090没反应先ping一下pynq看网络通不通。不通的话检查网线、路由器、板子的IP配置。通的话可能是Jupyter服务挂了SSH上去用sudo systemctl restart jupyter重启。6.2 单元格执行代码没有任何反应Notebook里执行单元格没反应最常见的原因是内核挂了。看右上角的内核状态如果是Dead或者Disconnected需要重启内核。但重启内核会丢失所有变量所以重要数据要先存到文件。另一个原因是代码里有死循环或者阻塞操作。比如DMA的wait()如果中断没来会一直等。这时候Notebook界面看起来是卡住的但实际上内核还在跑。解决办法是在wait()之前加超时或者用dma.sendchannel.running属性轮询状态。还有一种情况是单元格前面的[*]一直不变成数字说明代码在执行但没结束。如果是大循环等就是了。如果是死循环点工具栏的停止按钮或者重启内核。6.3 代码自动补齐失效与nvim集成问题Jupyter的代码自动补齐依赖jedi库。如果补齐不工作先确认jedi装了没pip show jedi。没装就pip install jedi。装了但还是不工作可能是版本太老升级一下。在nvim里用Jupyter需要装jupyter-vim或者vim-jupyter插件。配置的时候要注意nvim的Python路径要和Jupyter的Python路径一致不然连不上内核。我试过用conda环境结果nvim用的是系统PythonJupyter在conda环境里死活连不上。后来统一用系统Python问题解决。6.4 生成Markdown目录语法的正确姿势Jupyter里生成Markdown目录最稳的方法是手动写锚点。Markdown的标题会自动生成锚点规则是转小写、空格换连字符、去掉特殊字符。比如## 1. 项目概述的锚点是#1-项目概述。在Notebook开头写一个目录列表- [1. 项目概述](#1-项目概述) - [2. 环境搭建](#2-环境搭建)但中文锚点在有些Markdown渲染器里支持不好。更保险的做法是用HTML锚点a idsection1/a然后链接写[1. 项目概述](#section1)。这样在任何渲染器里都能跳转。7. 性能实测与优化空间7.1 端到端推理耗时拆解实测数据输入416x416x3的图像端到端推理耗时约85ms。拆解下来DMA传输占35msHLS计算占40msARM端后处理NMS和画框占10ms。DMA传输是大头因为中间层的数据反复在DDR和PL之间搬运。对比纯ARM推理的4秒加速比约47倍。这个数字看起来不错但离实时30fps33ms还有距离。优化方向主要是减少DMA传输次数把能融合的层在PL端直接串起来中间结果不落DDR。7.2 层融合与数据复用的优化思路YOLOv2里有几个连续的1x1卷积和3x3卷积这些层可以融合成一个IP中间结果用AXI Stream直连不经过DMA。融合之后DMA传输次数从原来的几十次降到几次传输时间能压缩到10ms以内。数据复用方面输入图像在多个卷积层里被重复读取。如果能把输入图像缓存在片上BRAM里后续层直接从BRAM读能省掉重复的DMA传输。但416x416x3的图像是519KBBRAM总共才4.9Mb约600KB放不下。折中方案是分块处理把图像切成条带每条带缓存在BRAM里处理完再换下一条带。7.3 从YOLOv2到更复杂网络的扩展性这套框架搭好之后扩展到YOLOv3或者更复杂的网络主要改动在HLS端。YOLOv3有残差连接和上采样残差连接需要把两路数据相加在硬件上就是一个加法器加一个FIFO做对齐。上采样是最近邻插值实现起来比卷积简单。但YOLOv3的层数更多参数更大Zynq-7020的资源可能不够。如果要做YOLOv3建议换Zynq UltraScale的板子比如ZCU104DSP数量是Zynq-7020的十几倍BRAM也大得多。不过那又是另一套开发流程了Vitis和PYNQ的版本兼容性要重新折腾一遍。8. 一些个人体会这套YOLOv2加速器从开始到跑通前后花了大概三周。其中HLS调试占了一半时间Vivado系统集成占了三分之一剩下的是Python端调试。最大的感受是HLS虽然降低了硬件开发的门槛但并不意味着不需要理解硬件。pragma写错了综合出来的电路可能完全不是你想要的结构而HLS的报告不会直接告诉你这里设计错了只会告诉你时序不满足或者资源超了。另一个体会是调试硬件加速器要有耐心。软件调试可以打log、单步跟踪硬件调试只能靠仿真和ILA抓信号。Vivado的ILA是个好东西但配置起来麻烦而且会占用BRAM资源。我的做法是先在HLS的C仿真里把功能调对再上板子跑板子上只调时序和接口问题。这样能省掉大量在ILA上抓波形的时间。最后说下权重加载。YOLOv2的权重文件是Darknet格式需要转成HLS能读的格式。我写了个Python脚本读Darknet的weights文件按层解析转成numpy数组再存成二进制文件。HLS端用fopen和fread读二进制文件。这个转换脚本要小心字节序Darknet是小端Zynq也是小端所以直接读就行。但如果用其他工具链可能要处理字节序转换。
返回列表