
1. 为什么要在PYNQ-Z2上折腾YOLOv2硬件加速1.1 这个项目到底在做什么把YOLOv2这个目标检测网络塞进PYNQ-Z2这块小板子上用Vivado HLS把卷积层写成可综合的C代码再通过Jupyter Notebook在板子上直接调用加速器跑推理——这就是整个项目的全貌。听起来像是三个不相干的东西硬凑在一起但实际上这是一条非常典型的边缘AI部署链路算法模型在PC上训练好用高层次综合工具把计算密集的部分翻译成硬件逻辑最后在嵌入式平台上验证加速效果。PYNQ-Z2的核心是Zynq-7020芯片里面有一块FPGA可编程逻辑PL和一个双核ARM Cortex-A9处理器PS。这种异构架构的好处在于你可以把卷积、池化这类规整的并行计算丢给PL做流水线加速而把图像预处理、后处理、结果展示这些控制逻辑交给PS上的Python来处理。Jupyter Notebook跑在PS的Linux系统上通过PYNQ提供的Overlay机制加载硬件加速器用Python调用底层驱动整个开发体验比传统的嵌入式开发要顺畅得多。这个项目适合谁如果你已经学过数字电路基础写过一点C/C对深度学习有基本概念但没怎么碰过FPGA开发那这个项目就是为你准备的。它不需要你从Verilog开始手写卷积模块Vivado HLS会帮你把C代码综合成RTL你只需要关注算法层面的优化和接口设计。1.2 为什么选YOLOv2而不是YOLOv3或v4YOLOv2在这个场景下是个很务实的选择。它的网络结构比YOLOv3简单不少没有FPN那种多尺度特征融合的复杂连接卷积层数量也少得多。YOLOv2的骨干网络是Darknet-19一共19个卷积层加5个最大池化层最后接一个1x1卷积做检测头。整个网络的计算量大约在30亿次乘加运算GMACs左右对于Zynq-7020这种级别的FPGA来说虽然不能做到实时高帧率但跑个几帧每秒的推理是完全可行的。相比之下YOLOv3的骨干网络是Darknet-53计算量翻了好几倍而且多尺度检测头需要额外的特征金字塔结构在FPGA上实现起来复杂度陡增。YOLOv2的另一个好处是它的输入分辨率可以灵活调整从416x416到608x608都可以你可以根据FPGA的资源余量来权衡精度和速度。对于学习硬件加速来说YOLOv2的复杂度刚好卡在“有挑战但不至于劝退”的区间。1.3 整体技术路线图整个项目可以拆成四个阶段。第一阶段是在PC上完成YOLOv2模型的训练和量化得到权重文件和量化参数。第二阶段是用Vivado HLS编写卷积层、池化层、LeakyReLU激活函数的硬件实现通过C仿真和综合验证功能正确性。第三阶段是在Vivado中搭建Block Design把HLS生成的IP核、DMA控制器、Zynq PS等模块连接起来生成比特流文件。第四阶段是在PYNQ-Z2上加载Overlay用Jupyter Notebook编写Python驱动代码完成图像输入、推理执行和结果可视化。这四个阶段里HLS开发是最耗时的部分因为你需要反复调整pragma指令来优化流水线和资源占用。Vivado的Block Design搭建相对标准化但DMA的配置和地址映射容易出错。PYNQ端的Python代码反而最简单因为PYNQ框架已经把底层驱动封装得很好了。注意整个项目对Vivado版本有要求建议使用Vivado 2018.2或2019.1这两个版本对PYNQ-Z2的支持最稳定。太新的版本可能会遇到板级文件不兼容的问题。2. 开发环境搭建与工具链配置2.1 Vivado和HLS的安装要点Vivado的安装是个体力活完整安装包大概20GB左右加上HLS组件会更大。安装的时候有几个坑需要提前避开。首先安装路径不要有中文和空格否则HLS综合的时候会报一些莫名其妙的错误。其次在组件选择界面除了默认的Zynq-7000系列支持一定要勾选Vivado HLS和SDK或者Vitis取决于你的版本。如果你只装了Vivado没装HLS后面就没法把C代码综合成IP核。安装完成后建议先跑一下Vivado的许可证配置。PYNQ-Z2用的是Zynq-7020器件这个器件在Vivado WebPACK许可证的覆盖范围内不需要额外的付费许可证。但如果你用的是其他型号的Zynq板子可能需要检查一下许可证是否支持。2.2 PYNQ-Z2的板级文件与镜像烧录PYNQ-Z2的板级文件需要从TULTulip Creative的官网下载包括板卡定义文件和PYNQ镜像。镜像烧录到SD卡之后把卡插到板子上通过网线连接到电脑上电启动。首次启动大概需要一两分钟系统起来之后在浏览器里输入板子的IP地址默认是192.168.2.99或者通过串口查看实际IP就能看到Jupyter Notebook的登录界面。默认的用户名和密码都是xilinx。登录进去之后你会看到一个文件浏览器界面里面有一些示例Notebook。建议先跑一下官方的入门示例确认板子和网络连接都正常。提示如果你在浏览器里打不开Jupyter Notebook先检查网线是否插在板子的以太网口上不是电脑的然后确认电脑的IP地址是否和板子在同一个网段。板子默认的IP是192.168.2.99电脑需要设置成192.168.2.x。2.3 Jupyter Notebook的使用技巧PYNQ上的Jupyter Notebook和你在PC上用的没什么本质区别但因为跑在ARM处理器上性能有限所以有些操作习惯需要调整。比如不要一次性执行太多单元格尤其是涉及大量数据加载的单元格容易把内存吃满。另外Notebook的自动补全功能在PYNQ上默认是关闭的你可以通过安装jupyter-contrib-nbextensions来开启但会稍微增加系统负担。如果你习惯用vim或者nvim编辑代码PYNQ上也支持通过终端启动Jupyter Notebook然后用nvim编辑.py文件再在Notebook里import。不过对于这个项目来说直接在Notebook里写Python代码是最方便的因为你可以边写边看输出调试效率高。注意在PYNQ上运行Jupyter Notebook时如果遇到“单元格执行代码没有任何反应”的情况大概率是内核挂了。这时候需要重启内核Kernel - Restart或者检查一下是不是内存不足导致进程被kill了。3. YOLOv2网络结构的硬件友好化改造3.1 Darknet-19的层结构拆解YOLOv2的骨干网络Darknet-19由19个卷积层和5个最大池化层组成。卷积层全部是3x3或1x1的卷积核步长为1或2。池化层全部是2x2的最大池化步长为2。这种设计对硬件非常友好因为3x3卷积可以用行缓存line buffer实现滑动窗口不需要额外的内存访问。具体来说Darknet-19的结构是这样的首先是几个3x3卷积加2x2池化的组合通道数从32逐步增加到1024。中间穿插了几个1x1卷积用来压缩通道数减少计算量。最后是一个全局平均池化层和一个1x1卷积作为分类头。对于检测任务YOLOv2去掉了最后的分类头换成了一个1x1卷积输出检测框的坐标和类别概率。在硬件实现时我们需要把每一层的参数权重、偏置、量化系数提取出来按照HLS能识别的格式存储。通常的做法是把权重保存为二进制文件或者C数组在HLS代码里通过指针读取。3.2 量化策略从浮点到定点FPGA上做浮点运算的代价很高所以必须把YOLOv2的权重和激活值量化成定点数。常用的方案是8位定点量化也就是把浮点数映射到-128到127的整数范围。量化的关键是确定每一层的缩放因子scale factor使得量化后的数值分布尽可能接近原始浮点分布。具体操作上可以用TensorFlow或者PyTorch的量化工具来做训练后量化post-training quantization。以PyTorch为例你可以用torch.quantization模块对模型进行动态量化或者静态量化。静态量化需要提供校准数据集让工具统计每一层激活值的分布范围然后计算最优的缩放因子和零点zero point。量化后的模型精度会有一定下降但对于YOLOv2这种目标检测任务8位量化通常只会让mAP下降1到2个百分点完全可以接受。如果你对精度要求更高可以尝试混合量化对敏感层保留16位精度。提示量化的时候要注意LeakyReLU的负半轴斜率是0.1不是0。如果量化时把负半轴截断成0模型的检测效果会明显变差。3.3 卷积层的HLS实现思路在HLS里实现卷积层核心是设计好数据流和计算流水线。一个典型的3x3卷积层的HLS实现包含以下几个部分行缓存line buffer用于缓存输入特征图的三行数据滑动窗口sliding window从行缓存中提取3x3的像素块乘加树MAC tree完成9个乘加运算累加器accumulator把多个输入通道的结果累加起来。HLS的pragma指令在这里非常关键。比如用#pragma HLS PIPELINE II1可以让循环体每个时钟周期启动一次新的迭代实现流水线并行。用#pragma HLS ARRAY_PARTITION可以把权重数组拆分成多个小块让多个乘法器同时访问不同的权重提高并行度。但并行度不是越高越好因为FPGA的DSP资源是有限的。Zynq-7020有220个DSP48E1切片每个切片可以做一个18x18的乘法。如果你把并行度设得太高综合工具会报资源不足的错误。所以需要在并行度和资源占用之间找平衡点。4. Vivado HLS工程实战从C到IP核4.1 创建HLS工程与源文件组织打开Vivado HLS新建工程选择器件xc7z020clg400-1这是PYNQ-Z2上的Zynq-7020型号。添加源文件的时候建议把卷积层、池化层、激活函数分别写成独立的函数放在不同的.cpp文件里头文件里声明函数接口。这样做的目的是方便单独综合和验证每一层也方便后续在Block Design里把不同的层组合成流水线。顶层函数的设计很关键。通常我们会把整个YOLOv2网络写成一个顶层函数输入是图像数据指针输出是检测结果指针。但这样综合出来的IP核会很大时序可能跑不到100MHz。另一种做法是把网络拆成几个大块比如骨干网络一个IP核检测头一个IP核中间用DMA或者BRAM传递数据。这样每个IP核的规模可控时序更容易收敛。4.2 卷积层的C代码与pragma优化下面是一个简化版的3x3卷积层HLS代码示例void conv_3x3( data_t input[MAX_H][MAX_W][MAX_C], data_t weight[OUT_C][3][3][IN_C], data_t bias[OUT_C], data_t output[MAX_H][MAX_W][OUT_C], int H, int W, int IN_C, int OUT_C ) { #pragma HLS INTERFACE m_axi portinput offsetslave bundlegmem #pragma HLS INTERFACE m_axi portweight offsetslave bundlegmem #pragma HLS INTERFACE m_axi portoutput offsetslave bundlegmem #pragma HLS INTERFACE s_axilite portreturn bundlecontrol for (int oh 0; oh H; oh) { for (int ow 0; ow W; ow) { for (int oc 0; oc OUT_C; oc) { #pragma HLS PIPELINE II1 data_t acc bias[oc]; for (int kh 0; kh 3; kh) { for (int kw 0; kw 3; kw) { for (int ic 0; ic IN_C; ic) { #pragma HLS UNROLL factor4 acc input[ohkh][owkw][ic] * weight[oc][kh][kw][ic]; } } } output[oh][ow][oc] leaky_relu(acc); } } } }这段代码里#pragma HLS PIPELINE II1让输出通道的循环实现流水线每个时钟周期处理一个输出通道。#pragma HLS UNROLL factor4让输入通道的循环部分展开一次做4个乘加。实际项目中你需要根据DSP资源和时序报告来调整这些参数。注意HLS综合的时候如果报“无法满足时序约束”的错误先检查一下时钟周期设置。默认是10ns100MHz如果时序不满足可以放宽到15ns或者20ns看看是否通过。如果还是不行就需要降低并行度或者优化数据访问模式。4.3 C仿真与综合验证写完代码之后第一步是跑C仿真C Simulation用测试数据验证功能正确性。测试数据可以用Python生成把输入特征图和权重保存成文本文件在C仿真里读进来计算输出再和Python的参考结果对比。如果误差在可接受范围内比如量化误差导致的1到2个LSB的偏差就说明功能没问题。C仿真通过之后跑C综合C Synthesis生成RTL代码和资源报告。资源报告里会列出用了多少DSP、多少BRAM、多少FF和LUT。你需要关注的是DSP和BRAM的占用率如果超过80%后面布局布线可能会很困难。时序报告里会给出预估的时钟频率如果达不到100MHz需要回头调整pragma。综合通过之后导出IP核Export RTL选择Vivado IP格式。导出的IP核会包含一个.zip文件里面是IP的定义文件和RTL代码。这个IP核就可以在Vivado的Block Design里直接调用了。5. Vivado Block Design搭建与比特流生成5.1 系统架构设计Block Design的架构决定了整个加速器的数据通路。一个典型的方案是Zynq PS通过AXI HP接口连接DMA控制器DMA把图像数据从DDR搬运到加速器的输入BRAM加速器计算完成后DMA再把结果搬回DDR。PS通过AXI Lite接口配置加速器的寄存器比如图像尺寸、层参数等。具体来说你需要添加以下IP核Zynq Processing System配置好DDR和时钟、AXI DMA、AXI Interconnect、Processor System Reset、以及你从HLS导出的卷积加速器IP。如果加速器有多个层可以把它们串联起来用AXI Stream接口传递数据这样DMA只需要在输入端和输出端各接一次。5.2 DMA配置与地址映射AXI DMA的配置有几个关键点。首先确保DMA的缓冲区长度寄存器Buffer Length Register位宽足够大能覆盖一帧图像的数据量。比如416x416x3的图像数据量大约是520KB缓冲区长度寄存器至少需要20位。其次DMA的读通道和写通道要分别连接到加速器的输入和输出接口中间可能需要AXI Stream Data Width Converter来匹配位宽。地址映射方面PS端的DDR地址空间需要和DMA的地址对齐。通常我们会用PYNQ的allocate函数在DDR里分配一块连续内存然后把物理地址传给DMA。在Vivado的Address Editor里需要确认加速器的寄存器地址和DMA的寄存器地址没有冲突。提示如果DMA传输的时候卡住不动先检查一下DMA的复位信号是否正常。AXI DMA有时候需要软复位才能正常工作可以在Python代码里通过写寄存器来触发复位。5.3 综合、实现与比特流生成Block Design搭建完成后先跑一下Validate Design确保没有连接错误。然后生成顶层Wrapper跑综合Synthesis和实现Implementation。实现阶段最耗时可能需要几十分钟到几个小时取决于设计的复杂度和电脑的性能。实现完成后检查时序报告。如果时序不满足WNS为负需要回头优化。常见的优化手段包括降低时钟频率、减少并行度、优化数据访问模式、插入流水线寄存器等。时序满足之后生成比特流文件.bit和硬件描述文件.hwh。这两个文件后面要在PYNQ上加载。6. PYNQ端Python驱动与Jupyter Notebook实战6.1 Overlay加载与硬件验证把生成的.bit和.hwh文件上传到PYNQ的Jupyter Notebook文件系统里然后新建一个Notebook用以下代码加载Overlayfrom pynq import Overlay import numpy as np overlay Overlay(/home/xilinx/yolov2_accel.bit) overlay.download() dma overlay.axi_dma_0 conv_accel overlay.conv_3x3_0加载成功后你可以通过overlay.ip_dict查看所有IP核的寄存器映射。如果加载失败检查一下.bit和.hwh文件的文件名是否一致除了扩展名以及文件路径是否正确。6.2 图像数据预处理与DMA传输YOLOv2的输入是416x416的RGB图像需要先缩放到这个尺寸然后归一化到0到1之间再量化成8位整数。在Python里可以用PIL或者OpenCV来做缩放用numpy来做归一化和量化。from PIL import Image img Image.open(test.jpg).resize((416, 416)) img_array np.array(img, dtypenp.float32) / 255.0 img_quant (img_array * 255).astype(np.uint8)然后通过DMA把量化后的图像数据传到加速器的输入缓冲区from pynq import allocate in_buffer allocate(shape(416, 416, 3), dtypenp.uint8) out_buffer allocate(shape(13, 13, 425), dtypenp.uint8) in_buffer[:] img_quant dma.sendchannel.transfer(in_buffer) dma.recvchannel.transfer(out_buffer) dma.sendchannel.wait() dma.recvchannel.wait()注意DMA传输的时候输入缓冲区和输出缓冲区的大小必须和加速器的接口定义一致。如果加速器的输入是416x416x3你传了一个224x224x3的数组DMA会报错或者传输不完整。6.3 后处理与检测结果可视化YOLOv2的输出是一个13x13x425的张量其中425 5个锚框 × (4个坐标 1个置信度 80个类别概率)。后处理需要做几件事把坐标从相对值转换成绝对值用非极大值抑制NMS去掉重叠的检测框然后在原图上画出检测结果。def yolo_postprocess(output, anchors, img_size416, conf_thresh0.5, nms_thresh0.4): boxes [] for i in range(13): for j in range(13): for a in range(5): data output[i, j, a*85:(a1)*85] conf data[4] if conf conf_thresh: continue cx (j data[0]) / 13 * img_size cy (i data[1]) / 13 * img_size w np.exp(data[2]) * anchors[a*2] / 416 * img_size h np.exp(data[3]) * anchors[a*21] / 416 * img_size classes data[5:] class_id np.argmax(classes) score conf * classes[class_id] boxes.append([cx, cy, w, h, score, class_id]) return nms(boxes, nms_thresh)NMS的实现可以用numpy手写也可以调用OpenCV的cv2.dnn.NMSBoxes。后处理在ARM上跑速度可能比较慢如果帧率要求高可以考虑把NMS也放到FPGA上做但那是另一个话题了。7. 性能调优与常见问题排查7.1 加速比分析与瓶颈定位跑通整个流程之后你需要评估加速效果。在PC上跑同样的YOLOv2模型记录推理时间然后在PYNQ上跑对比两者的差异。通常来说FPGA加速器在卷积层上的加速比可以达到5到10倍但整个流程的加速比会被数据搬运和后处理拖累。瓶颈通常出现在三个地方DMA传输带宽、加速器的并行度、后处理的计算量。DMA传输带宽受限于AXI HP接口的位宽和时钟频率Zynq-7020的HP接口是64位宽100MHz时钟理论带宽是800MB/s。如果一帧图像是520KB传输时间大约是0.65ms这个开销在总时间里的占比取决于加速器的计算时间。如果加速器的计算时间远大于DMA传输时间那瓶颈就在计算上需要提高并行度或者优化数据复用。如果DMA传输时间占比很高可以考虑用双缓冲double buffering来重叠传输和计算。7.2 常见问题速查表问题现象可能原因解决方法Jupyter Notebook打不开网络配置错误或板子未启动检查网线连接和IP地址通过串口查看启动日志单元格执行代码没有任何反应内核崩溃或内存不足重启内核减少单次加载的数据量DMA传输卡住DMA未复位或地址不对齐在Python里触发DMA软复位检查缓冲区地址HLS综合报时序错误时钟频率过高或并行度过大降低时钟频率或减少UNROLL因子检测结果全是乱框量化参数错误或权重加载错误检查量化缩放因子和权重文件格式运行Jupyter Notebook出现ImportError: DLL load failed依赖库版本不兼容重新安装对应版本的numpy和pynq库7.3 实操心得与避坑技巧第一个坑是HLS的接口协议选择。如果你用m_axi接口HLS会生成一个AXI主设备需要额外的AXI Interconnect来连接DMA。如果你用axis接口数据流是单向的更适合流水线处理。我建议在卷积层之间用axis接口在输入输出端用m_axi接口。第二个坑是量化参数的存储。如果你把量化参数存在Python端每次推理都要传给加速器会增加通信开销。更好的做法是把量化参数固化在HLS代码里作为常量数组这样加速器内部就能完成反量化输出直接是浮点结果。第三个坑是Jupyter Notebook的内存管理。PYNQ的ARM处理器只有512MB内存如果你在Notebook里加载了多张大图很容易把内存吃满。建议用del及时释放不用的数组或者用allocate的free方法释放DMA缓冲区。提示如果你在运行Jupyter Notebook时遇到“无法运行”的情况先检查一下是不是有多个Notebook同时占用了DMA资源。DMA是独占资源同一时间只能有一个Notebook使用。8. 后续扩展与个人体会这个项目跑通之后你可以从几个方向继续深挖。一是把YOLOv2的检测头也做成硬件加速器这样整个网络都在FPGA上跑不需要ARM参与计算。二是尝试INT4量化进一步降低资源占用提高并行度。三是把视频输入接进来用HDMI或者MIPI接口做实时检测。我个人在实际操作中的体会是HLS的pragma调优是最耗时间的环节但也是最值得投入的。有时候一个UNROLL因子的调整就能让性能翻倍。另外不要一开始就追求全网络加速先把一个卷积层跑通验证数据通路和量化方案再逐步扩展。这样出问题的时候排查范围小定位快。最后分享一个小技巧在HLS代码里加一些#pragma HLS LATENCY的约束可以让综合工具报告每一层的延迟方便你定位性能瓶颈。这个功能在调试阶段非常有用比看综合报告里的数字直观多了。