
最近在pynq-z2上做HLS开发把一套矩阵运算的加速逻辑从纯软件版本改成了FPGA硬件加速器。整个过程走下来最大的感受是网上关于PYNQ的教程不少但大多数停留在点亮LED或者跑个官方例程的程度真正能带着你从C代码一路走到硬件加速器落地、并且把中间那些坑都讲清楚的文章其实很难找。这篇就把我这次开发的完整链路拆开揉碎从环境版本匹配、可综合C代码的写法到Block Design接线、Overlay调用再到综合报告怎么读、优化怎么做一次说清楚。这篇内容适合两类人一是刚入手PYNQ-Z2、想用HLS做点正经事但不知道从哪下手的开发者二是已经能跑通Vivado流程、但总觉得HLS生成的IP不够快不知道怎么优化的工程师。如果你是纯写Verilog的老手也可以看看HLS这套流程在什么场景下值得用、什么场景下最好别碰。1. 为什么是PYNQ-Z2 HLS的组合拳1.1 PYNQ-Z2到底适合谁PYNQ-Z2这块板子核心是Zynq-7020也就是一块带有双核ARM Cortex-A9处理器的FPGA。PS侧跑Linux系统PL侧承载自定义逻辑。PYNQ项目的思路很直接把FPGA的配置和使用门槛降到Python层面通过Overlay机制加载比特流用Python操作硬件IP。这个定位注定了它的受众不是要做超高性能ASIC验证的团队而是三类人最多做算法加速原型验证的、做嵌入式教学和科研的、以及想用FPGA但又不想被Verilog劝退的软件工程师。如果你属于这三种之一PYNQ-Z2加上HLS的设计流程可能是目前综合效率最高的一条路。我自己更看中的一点是PYNQ-Z2上有完整的Linux环境可以直接跑Python脚本、Jupyter Notebook日志调试不要太方便。相比传统FPGA开发先烧bit再抓逻辑分析仪的模式PYNQ让软硬件协同调试真正变成了日常操作。1.2 HLS真正解决的是哪个层面的问题HLS全称High-Level Synthesis高层次综合本质上是把C/C/SystemC描述的行为编译成寄存器传输级RTL电路。很多人第一次接触HLS会有一个误解觉得HLS就是把C代码丢给工具工具自动给你一个硬件实际上远没这么简单。我个人的理解是HLS解决的是算法实现效率的问题而不是把C变成电路的问题。你用C写算法HLS负责把算法映射到并行硬件上但你得告诉工具怎么并行、数据怎么存储、接口长什么样。换句话说C代码只是起点编译出来的东西好不好很大程度上取决于你怎么加pragma、怎么设计数据流。拿我这次做的矩阵乘法来说纯软件版本的三重循环在ARM上跑就是老老实实一条条乘加指令执行。但同样的循环在HLS里可以流水线化让乘法器和加法器重叠工作可以数组拆分把数据分布到多个BRAM里并行读取。这两者的吞吐差距可能就是几十倍。而你在C代码里要做的只是告诉编译器这条循环请流水化。1.3 这条链路的适用边界保持清醒很重要。PYNQ-Z2 HLS不是万能的它有很清晰的适用边界。经过这次开发我认为以下几种情况用这个组合是划算的算法逻辑复杂但数据流相对规则的任务比如图像卷积、矩阵运算、FFT、滤波需要频繁迭代算法参数的场景HLS改C代码重新综合比改Verilog快一个数量级你需要快速看到硬件加速后大概能提升多少的场景HLS适合做性能预估反过来如果设计目标是极致时序性能、芯片面积最小化或者要精细控制每一个寄存器的行为那还是老老实实写RTL。HLS生成的电路在资源利用率和最高频率上跟手写RTL比还是有差距的这个不能回避。2. 开发环境搭建版本匹配是第一道坎2.1 PYNQ镜像和Vivado版本的对应关系先说结论PYNQ的板卡镜像和Vivado/Vitis的版本必须严格匹配这是我在这次开发中踩的第一个大坑也很有代表性。PYNQ-Z2官方发布过多个版本的镜像。v2.5对应Vivado 2018.2v2.6和v2.7对应Vivado 2018.3v3.0.1对应Vivado 2020.2。如果你用Vivado 2019.1去编译一个工程然后想加载到PYNQ v2.6的板卡上大概率会出现overlay加载失败或者加载了但IP的寄存器行为完全不对的情况。原因其实不难理解。PYNQ框架通过hwh文件来解析硬件IP的信息hwh本质上是从Vivado工程导出的硬件元数据。PYNQ镜像里内置的FPGA管理驱动、devicetree、Python包都是针对特定Vivado版本生成的。版本对不上轻则属性解析不出来重则地址映射都乱掉。这次开发我用了PYNQ v2.6镜像配合Vivado HLS 2018.3这是官方验证过的组合跑得很稳。如果你已经刷了其他版本的镜像先去官方文档把版本对应关系查清楚再开始建工程别一上来就动手。2.2 Vivado HLS / Vitis HLS的工程配置Vivado HLS 2018.3的工程创建有几个隐蔽的坑值得单独说。新建工程时要正确设置顶层函数Top Function这个函数名后面会成为IP的名字也直接影响PYNQ端调用时IP的属性名。建议起一个有辨识度的名字比如matrix_multiply不要用test这种不然后面自己都分不清。解决方案Solution的时钟周期设置默认是10ns对应100MHz。PYNQ-Z2的PL侧时钟一般建议跑100MHz-150MHz所以我直接沿用了默认值。这里要提醒的是如果你代码里的组合逻辑延迟太大布局布线后时序收敛不了板子上就会出现随机性错误而Vivado HLS综合阶段是看不出这些问题的它只会告诉你估算的时序。选择芯片型号时PYNQ-Z2用的是xc7z020clg400-1型号选错的话后面的IP集成、引脚约束全都会出问题。2.3 source和板卡文件那些容易忽略的事Vivado HLS装好以后有一个非常容易忽略的步骤source一下Vivado的settings64脚本。这个脚本会设置一系列环境变量包括重要的VIVADO_HLS_ROOT_DIR。很多初学者直接在终端敲vivado_hls报command not found就是因为没有source。PYNQ-Z2的板级支持文件也要提前装好。虽然PYNQ-Z2用的是标准Zynq-7020综合和实现不需要开发板定义文件但如果你想在Vivado里直接用PYNQ-Z2的板级预设比如DDR、时钟约束就需要把board files放到Vivado的安装目录下。这个不强制但建议装能省不少事。还有一点PYNQ-Z2和PYNQ-Z1在Vivado里都是Zynq-7020两者引脚定义不完全一样。如果你从网上找到一份PYNQ-Z1的工程文件想改到PYNQ-Z2上必须核对xdc约束文件尤其是以太网、HDMI这些外设的引脚。PL逻辑只要不涉及板上外设基本可以直接复用。3. 写出能综合的矩阵乘法IP可综合C的边界感3.1 可综合风格和写软件C的区别写HLS的C代码跟写嵌入式软件C是两种完全不同的思维。很多软件工程师第一次写HLS的时候代码倒是很规范但综合出来的硬件资源用量巨大、频率还跑不上去原因多半是没理解HLS可综合风格的限制。HLS可综合的C代码有一条硬边界不能有动态内存分配。malloc、new、自由指针这种软件里常用的手段在HLS里要么不支持要么会综合出一个很慢的实现。数组和指针的用法也要限制在工具能静态分析的范围里。另一个常见的误区是循环的写法。软件C里循环边界可以靠运行时变量计算但在HLS里如果你想做流水线优化循环边界最好是编译期常量或者至少是工具能推断出固定上限的值。否则工具没法展开循环、没法做数据依赖分析你就失去了用pragma优化的可能。数据类型的选择也很关键。HLS默认的int是32位但实际上FPGA里的DSP硬核最高支持到18x25的乘法。你用一个int做乘法综合工具会在DSP外面拼一堆LUT来扩展位宽产生额外延迟。对于矩阵乘法这样的场景如果数据范围够用用ap_int定长整型或者ap_fixed定点数资源效率和时序表现都会有明显提升。3.2 矩阵乘法源代码与接口约束我这次做的矩阵乘法IP数据规模是32x32的int32矩阵。顶层函数设计成从外部DDR读入A和B两个矩阵计算结果写回C矩阵。接口上三个矩阵全部通过AXI Master接口访问DDR控制寄存器走AXI-Lite。核心代码结构如下#include hls_stream.h #include ap_int.h #define N 32 typedef int mat_t; void matrix_multiply(mat_t A[N][N], mat_t B[N][N], mat_t C[N][N]) { #pragma HLS INTERFACE m_axi portA offsetslave bundlegmem0 #pragma HLS INTERFACE m_axi portB offsetslave bundlegmem1 #pragma HLS INTERFACE m_axi portC offsetslave bundlegmem2 #pragma HLS INTERFACE s_axilite portreturn for (int i 0; i N; i) { for (int j 0; j N; j) { mat_t sum 0; for (int k 0; k N; k) { sum A[i][k] * B[k][j]; } C[i][j] sum; } } }这段代码如果直接交给HLS综合能跑但性能和你想的相差甚远。因为内层循环k是串行累加的工具默认不会做流水线优化32次乘加串行执行再加上数据读取的等待时间整体latency会非常难看。接口的pragma设置同样有讲究。m_axi接口指定了AXI Master总线访问DDR。这里我用了三个独立的bundle也就是三条独立的AXI通道分别读A、读B、写C。如果你把A和B放在同一个bundle里它们就共用一条AXI总线遇到同时要读A又要读B的情况只能排队带宽直接减半。不过也要注意三个bundle在Block Design里意味着要多分配物理接口资源PYNQ-Z2通过AXI SmartConnect把它接到S_AXI_HP端口上这种做法是安全的。3.3 C仿真与RTL协同仿真提前暴露问题HLS开发流程里C仿真C Simulation和CRTL协同仿真Cosimulation是两个被很多人跳过的环节我觉得是绝对不能省的。C仿真的价值在于验证算法逻辑本身是否正确。我在写矩阵乘法的时候就是先在本地用gcc编译一遍同样的算法函数和HLS的C仿真结果对一下确认逻辑没问题再往下走。Cosimulation则更有意思它会用综合出来的RTL模型跑一遍你的输入数据然后和C模拟的结果对比。这一步能暴露综合后电路行为与C模型不一致的问题比如时序上出现了竞争、接口握手异常等。很多仿真过了上板不行的bug其实在Cosimulation阶段就能看出来。Cosimulation的配置注意几个点RTL仿真语言选Verilog即可仿真时长要设足够默认值有时候会因为矩阵规模大而出现超时如果要验证AXI Master接口需要勾选自动生成AXI总线测试激励的选项。4. 从IP到比特流Block Design里的AXI连接细节4.1 导出IP与Vivado工程创建HLS综合通过后选择Export RTL把IP核导出。导出格式选Vivado IP Catalog也就是我们常说的.xci或者打包成.zip的IP。这一步完成后你会在Vivado的IP Catalog列表里找到这个IP前提是在工程设置里把IP仓库路径指向HLS导出的目录。新建Vivado工程时芯片型号一定要选对xc7z020clg400-1。工程建好后第一步创建一个Block Design。这个环节的难点在于你需要同时加入两个关键IPZYNQ7 Processing System和你的HLS IP。ZYNQ7 Processing System的配置里默认的M_AXI_GP0是打开的这是PS作为主设备访问PL的通道给HLS IP的控制寄存器用。为了PL侧的IP能高效访问DDR还要打开S_AXI_HP0口这是PL主设备访问DDR的高速通道。很多人在这里会漏掉S_AXI_HP0结果数据端口没地方接后面被迫改设计。4.2 连接AXI接口与地址分配Block Design里的连线理论上可以点击Run Connection Automation自动完成但我建议手动检查和调整因为自动连的并不总是你想要的。HLS IP的s_axilite控制接口需要接到Zynq的M_AXI_GP0上。由于M_AXI_GP0是32位AXI接口中间通常会自动插入一个AXI Interconnect或者SmartConnect来做协议转换和地址路由这个没问题。HLS IP的三个m_axi数据接口这里的关键是不要把它们接到M_AXI_GP0上而是要接到S_AXI_HP0口。M_AXI_GP0是PS发起访问用的主端口PL侧设备挂在它下面是被访问方而m_axi是PL发起的DDR访问本质上是PL作为总线的发起者所以必须走S_AXI_HP这条路径。连接完成后打开Address Editor给HLS IP的控制接口分配地址。一般默认会分配在0x43C0_0000附近。要注意的是如果你有多个AXI从设备地址空间不能冲突。PYNQ加载overlay时会根据hwh文件里的地址自动映射只要你分配对了Python端不需要手动填地址。数据端口走S_AXI_HP0不需要分配地址因为HP0端口本身就是PL访问DDR的通道地址空间直接映射到PS的DDR上不需要在PS的地址空间里再划一块。4.3 生成bit文件的坑约束缺失与全局复位Block Design连线完成后右键Create HDL Wrapper然后Generate Output Products。这里有一步非常关键如果PYNQ-Z2的板级文件没有安装生成的工程会自动用默认的引脚约束来跑综合和实现但PYNQ-Z2上有几个外设特别是外部DDR的引脚和时钟必须用正确的约束才能工作。我建议在Constraints下新建一个xdc文件至少把系统时钟和DDR相关约束加进去。生成比特流过程的另一个常见问题是全局复位信号。Zynq的PS通过FCLK_CLK0给PL提供时钟通过ext_reset_in信号送一个复位。在Block Design里加入Processor System Reset IP来生成同步复位置位这一步不要省。否则HLS IP在上电后可能处于未知状态寄存器读写都不工作。我在实际生成比特流时最常遇到的问题是时序不收敛。报错的路径通常在HLS IP的内部解决办法不是去改Vivado的布局布线配置而是回到HLS工程里降低优化目标或者优化关键路径。100MHz的时序收敛不了就把HLS的时钟周期改成10ns综合后查看最差负时序裕量再决定是调整代码还是降低频率。5. 在PYNQ上跑起来Overlay、DMA缓冲区与实测5.1 把bit和tcl封装成overlayVivado Generate Bitstream完成后会生成一个.bit文件和一个相关联的.hwh文件硬件描述文件。PYNQ框架的Overlay类就是通过解析这两个文件来加载比特流并识别IP的。具体操作是在Vivado工程目录中找到你的bit文件路径通常在工程名.runs/impl_1/目录下。同时在生成的Block Design目录下找到.hwh文件。把这两个文件拷到PYNQ-Z2上同一个目录注意文件名保持一致比如都叫mm32.bit和mm32.hwh。然后用Overlay类加载from pynq import Overlay ol Overlay(mm32.bit) mm ol.matrix_multiply_0这里ol.matrix_multiply_0的属性名来自Block Design里IP的instance名称。你在Block Design里把HLS IP命名为matrix_multiply_0那么Python端就对应matrix_multiply_0属性。如果你在Block Design里改过名字记得同步调整。5.2 Python端buffer分配和调用流程PYNQ提供了一个专门的缓冲区抽象pynq.allocate用于分配物理连续的内存区域。这一点非常重要PL侧通过AXI Master访问DDR时使用的是物理地址所以传给硬件IP的缓冲区必须物理连续。普通Python的numpy数组分配的是虚拟内存物理地址不一定连续直接传给硬件IP会导致总线访问出错。import pynq import numpy as np buf_a pynq.allocate((N, N), dtypenp.int32) buf_b pynq.allocate((N, N), dtypenp.int32) buf_c pynq.allocate((N, N), dtypenp.int32) buf_a[:] np.random.randint(0, 10, (N, N)).astype(np.int32) buf_b[:] np.random.randint(0, 10, (N, N)).astype(np.int32) buf_c[:] 0allocate返回的对象可以直接用numpy的切片语法读写底层自动帮你处理了物理地址的申请和映射。在调用硬件IP前先给buf_a和buf_b填上测试数据然后调用IPmm.call(buf_a, buf_b, buf_c)这个call方法是PYNQ根据hwh自动生成的本质上是把三个缓冲区的物理地址写入HLS IP的对应寄存器然后发起计算最后等待完成。如果你不想用call也可以手动操作写寄存器启动然后轮询状态寄存器等done信号。5.3 实测数据加速效果到底如何我先抛出结论32x32的int32矩阵乘法用HLS做出来的硬件加速器相比PS侧直接跑C代码加速效果并不明显甚至有时还会更慢。原因很简单32x32的规模太小了。总计算量约3.3万次乘加ARM Cortex-A9在-O2优化下用几条毫秒就能算完。而FPGA侧的流程包括CPU把输入矩阵从用户态拷贝到物理连续缓冲区通过AXI总线把数据写到PL侧的DDR访问路径PL从DDR读数据、计算、写回DDRCPU再等到done信号。一次完整的调用固定开销可能就有几百微秒到毫秒级对于一次微秒级计算来说通信开销占比太高了。这说明一个很现实的道理FPGA加速不是所有场景都有效。你想在PYNQ-Z2上做出明显更快的效果要么把矩阵规模放大到256x256以上要么把它放到连续流式处理的场景里让计算和数据搬运重叠起来掩盖通信延迟。这次开发中我后来把矩阵规模扩展到128x128并加了循环流水线优化后硬件版本才真正跑出相对PS的3-5倍加速。所以如果你刚入门用32x32只是为了跑通流程不用纠结加速比这是正常的。6. 综合报告里藏着加速密码从Latency到流水线优化6.1 关键指标Latency、II、资源占用HLS综合完以后打开综合报告Solution的syn/report目录下你会看到一组数据分别是Latency、Interval、资源利用情况。这几个指标是hardware designer的必修课。Latency表示从任务开始到完成需要多少个时钟周期。Interval或者叫Initiation IntervalII表示连续两次任务开始之间的最小间隔它决定了吞吐率。对于矩阵乘法这种一次性任务你主要看Latency但如果你的IP是流式处理的比如每来一帧图像处理一次II就比Latency更重要。资源利用情况里重点关注三个DSP48E、BRAM、LUT。DSP是乘法器的硬核BRAM是片上存储LUT是通用逻辑。如果DSP用的特别多说明你的乘法器很多可能是好事但如果LUT用量也暴增往往意味着很多DSP在做位宽扩展或者数据搬运的逻辑这就需要优化了。6.2 从能跑到跑得快pipeline与数组拆分我第一次把矩阵乘法IP综合完Latency差不多在十万周期级别100MHz下就是1毫秒这个性能说实话很难看。后来做了两次关键优化性能才上来。第一个优化是给内层循环加流水线pragmafor (int k 0; k N; k) { #pragma HLS PIPELINE II1 sum A[i][k] * B[k][j]; }加上这行后工具会尝试让这个循环内的多个迭代在硬件上重叠执行。理想情况下每个时钟周期都能启动一次新的乘法而不是等上一次乘法完成。这个改动完成后内层循环的延迟大幅下降。但这里有个前提数组A和B的读操作不能成为瓶颈。默认情况下A和B作为大数组是存在BRAM里的BRAM的读取端口有限同时要读A[i][k]和B[k][j]就可能冲突。解决办法是数组拆分#pragma HLS ARRAY_PARTITION variableA cyclic factor4 dim2 #pragma HLS ARRAY_PARTITION variableB cyclic factor4 dim2这就是把原来一个BRAM里的大数组拆成多个BRAM存储这样可以在同一个周期并行访问多个不同位置的数据。array partition的粒度需要根据你算法里同时访问几个数据来决定太粗并行度不够太细浪费BRAM资源。6.3 优化前后对比我把32x32矩阵乘法经过流水线和数组拆分优化后Latency从约10万周期降到约3万周期DSP资源从之前复用几十次变成了一次循环内大量并行乘法DSP利用率反而更健康了。优化后的综合报告里DSP用量还在220以内Zynq-7020刚好220个DSP48E1说明还有余量。总结成一个表格更直观优化阶段Latency(周期)100MHz下耗时DSP使用备注初始三重循环~100000约1ms2-4串行乘加DSP复用严重内层PIPELINE~40000约0.4ms4-8乘法重叠执行性能提升数组拆分PIPELINE~30000约0.3ms16-32并行访问数据瓶颈解除进一步UNROLL视情况视情况64资源允许时可继续展开在实际优化时不要盲目追求UNROLL。PYNQ-Z2上的DSP资源就220个如果你想做32x32矩阵乘法同时展开32路乘法每个乘法需要1个DSP来做18x25运算32路就要至少32个DSP再加上其他逻辑完全可行。但如果矩阵变成64x64一次展开64路就不一定放得下了这时候要权衡循环展开和DSP复用。7. 几个真实踩过的坑与最终建议7.1 浮点运算的资源陷阱我第一次写矩阵乘法时图省事用了float类型。综合完后看报告DSP用量飙到100多LUT和FF的用量也很大最关键是Latency反而比int版慢了不少。原因在于Vivado HLS对float乘法并不是直接用DSP硬核做浮点运算而是综合一块浮点乘法器逻辑里面要用DSP做尾数乘法再用大量LUT做指数对齐、规格化、舍入。浮点运算本身资源开销就大而且很难做高并发。如果你要处理的算法数据范围可控建议优先考虑ap_fixed定点数或者直接用int。我最开始为了演示方便用了int32后来试过ap_fixed16,6资源用量和时序都更好了。定点数唯一的麻烦是精度需要你自己评估不能无脑替换。7.2 地址对齐和burst传输问题PYNQ的allocate函数会按页对齐分配内存正常使用不会有地址对齐问题。但我遇到过一种情况当我在HLS IP里通过offset参数访问数组的某个子区域时如果偏移量没有对齐到32字节AXI总线上的burst传输会退化成单次传输带宽打对折。具体表现是硬件IP的latency报告很好看但实际上板速度很慢。排查方法是在HLS的波形仿真里看m_axi接口的burst_length如果你期望是32或者16结果经常是1那就要检查地址对齐。在PYNQ端规避这个问题的最好办法是不要把要处理的数据放在缓冲区的任意偏移位置尽量从头开始使用整个缓冲区。如果确实需要在同一块缓冲区里划分多个小数据区域要保证每个区域的起始地址都按64字节对齐。7.3 仿真能过、上板不行的典型原因如果你遇到仿真没问题上板就瞎跑的情况按下面的顺序排查大概率能找到问题。首先检查时钟。PYNQ-Z2上PL时钟默认是多少如果你Block Design里设置的时钟频率和实际不一致所有时序行为都会乱。在Block Design里确认FCLK_CLK0的频率配置然后到PYNQ端用OL的clock信息核对。其次检查复位。HLS IP在上电后如果没有正确的复位序列寄存器的初值可能不对。确认Processor System Reset IP连对了并且外部复位信号极性正确。最后检查DDR访问路径。仿真环境里m_axi接口通常是被模拟成一个简单的内存模型带宽延迟都理想化。上板后真实DDR有延迟、有竞争如果BLOCK Design里HP接口的时钟域没有处理好PL访问DDR会一直等待表现就是IP卡死、没有任何错误信息。在我整个开发过程里上板最耗时间的反而是一次地址映射错误我把m_axi接口接到了M_AXI_GP0上结果PL侧发起的DDR读取一直没有响应IP在综合出来的电路上跑得像死机。把m_axi改接到S_AXI_HP0之后问题迎刃而解。这个接线细节网上很多教程都没有强调我建议所有刚开始做PYNQ HLS的人都在Block Design阶段就检查清楚。最后再分享一个小技巧调试阶段尽量在PYNQ端加一个简单的自检函数比如让硬件IP先做一个单位矩阵和随机矩阵的乘法然后用numpy做一次同样的运算对比结果。这一步能快速确认硬件逻辑和寄存器、缓冲区、DDR访问这条链路是否整体正常。这次开发能比较顺利地从软件逻辑转成硬件逻辑这套对比验证流程帮了大忙。