基于HLS的MNIST神经网络在Zynq7020 FPGA上的硬件加速实现

基于HLS的MNIST神经网络在Zynq7020 FPGA上的硬件加速实现 简介手写数字识别神经网络FPGA加速设计工程包面向具备HLS与FPGA基础的开发者解决MNIST模型在Zynq 7020 SoC平台上的硬件实现问题。项目基于Vivado HLS工具将神经网络算法转换为硬件逻辑流程覆盖数据预处理、网络结构设计、高级综合、Vivado工程映射以及ARM处理器与可编程逻辑协同的软硬件集成有助于理解深度学习从算法到FPGA部署的完整过程。压缩包采用7z格式大小约52.29MB平台暂未展示文件总数与类型明细按工程描述应包含C/C源码、HLS接口定义、Vivado项目文件、RTL代码与配置文件等。目前已有1207人学习工程结构对嵌入式AI、边缘计算下的低延迟推理实现具有直接参考价值也可作为后续迁移至其他Zynq平台或扩展网络模型时的基础框架。1. 项目整体思路为什么选 MNIST HLS Zynq7020 这个组合拿到这个项目压缩包的那一刻名字已经把核心信息全交代了MNIST 手写数字识别、nnet神经网络、HLS高层次综合、Zynq7020 FPGA最后是 Vivado 工程文件。这套组合在机器学习硬件部署领域属于非常经典的入门到进阶路径值得拆开细讲。1.1 为什么选 MNIST 作为 FPGA 神经网络部署的验证目标MNIST 数据集由 60000 张训练图片和 10000 张测试图片组成每张图是 28x28 像素的灰度图对应 0 到 9 共十个类别。这个数据集的体量对于 FPGA 开发来说非常合适原因有几点。一方面MNIST 的输入维度是固定的 784 个像素值对于资源有限的 Zynq7020 来说不算大。Zynq7020 的 PL可编程逻辑部分拥有 85K 逻辑单元、220 个 DSP48E1 计算单元、140 块 Block RAM总计 4.9Mb。如果直接上 CIFAR-10 这种三通道 32x32 的彩色图片数据集网络结构稍微深一点乘法累加操作的数量就会指数级上升Zynq7020 的资源立刻会变得紧张。而 MNIST 配合简单的全连接网络刚好能在这个资源预算内把完整的神经网络硬件加速流程跑通。另一方面MNIST 的模型结构简单便于在 HLS 中做逐层优化。一个典型的两层全连接网络第一层从 784 维映射到 128 维第二层从 128 维映射到 10 维总共的计算量大约在 10 万次乘加左右。这个规模足够说明问题——数据量大了存储带宽会成为瓶颈数据量小了又体现不出 FPGA 并行计算的优势——属于性价比最高的验证载体。1.2 为什么用 HLS 而不是直接用 Verilog 写神经网络这是这个项目里最值钱的一个设计决策。早期的 FPGA 神经网络加速器大多直接用 Verilog 或 VHDL 编写我当时也干过这事写完一个简单的卷积层花了差不多三周其中大部分时间消耗在状态机设计、数据对齐和仿真调试上。而 HLS 的神奇之处在于你可以用 C/C 去描述硬件行为然后由工具自动转换成 RTL 级电路。HLS 在这个场景下的优势非常明显。首先算法迭代速度极快。改网络结构、调参、换激活函数在 HLS 里就是改几行 C 代码再重新综合的事比 Verilog 版本的修改周期至少缩短一个数量级。其次HLS 工具会在高层自动做优化调度比如循环展开、流水线处理、数据缓存策略等你只需要通过 pragma 指令来告诉工具“你希望这里怎么做”工具会负责具体的电路生成。最后HLS 与 Vivado 的工具链集成是原生的生成的 IP 核可以直接拖进 Block Design 和 Zynq 的 ARM 核交互。当然 HLS 不是银弹它也有局限性。比如对时序要求极其严格的自定义接口协议HLS 生成的 RTL 会浪费不少资源去实现握手信号再比如某些特殊 DSP 结构如非标准的乘累加链HLS 综合效果不如手写 Verilog。但对于 MNIST 这个量级的全连接网络HLS 的路径就是最佳路径。1.3 为什么选 Zynq7020 这块平台Zynq-7020 是 Xilinx Zynq-7000 系列中的中端型号片内集成了双核 ARM Cortex-A9 处理器PS 端和 Artix-7 架构的可编程逻辑PL 端。这种 SoC 架构对神经网络部署有个天然优势——ARM 核负责控制调度FPGA 负责数据计算两者通过 AXI 总线高速通信。相比于纯 FPGA 方案Zynq 的 PS 端能干很多脏活累活读写 SD 卡上的 MNIST 测试图片、解析图片格式、把预处理结果通过 AXI-DMA 或 AXI-Lite 总线送给 PL 端加速器、接收计算结果并做显示。如果换成纯 FPGA这些工作全部要自己用 Verilog 实现工作量瞬间爆炸。Zynq7020 这颗芯片几乎成了 FPGA 开发板上最主流的配置像正点原子的 Zynq 开发板、黑金的 Zynq 开发板、Digilent 的 Zybo 系列全是基于 Zynq7020 的设计。生态成熟意味着资料多、踩坑经验多、遇到问题容易找到答案。顺带说一句如果你的开发板是 Zynq7010比如 Zybo 的早期版本这个工程大概率也能跑只是资源利用率会明显上升BRAM 占用可能会接近 90% 以上。2. 网络模型选型与 HLS 前置准备2.1 网络结构设计把 PyTorch 模型翻译成 HLS 能懂的 CMNIST 手写数字识别最常见的网络是 LeNet-5 的简化版包含两个卷积层和三个全连接层。但考虑到 Zynq7020 的算力资源和 HLS 实现的复杂度这个工程里我选择了更保守的两层全连接网络。直接说结论单隐藏层 128 个神经元输入 784 维输出 10 维。非线性激活函数用 ReLU经典结构。关于这个设计的理由后面详细说。PyTorch 中对应的模型定义是这样的class MnistMLP(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(784, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x torch.relu(self.fc1(x)) x self.fc2(x) return x训练完后的权重是 float32 的 Tensor直接转成 C 语言数组会占不少存储空间第一层 784x128 100352 个权重第二层 128x10 1280 个权重总共约 10 万个 float32 数字合计 400KB 左右。Zynq7020 的 BRAM 总共只有 4.9Mb约 600KB如果把权重全放在 BRAM 里剩下的留给输入数据和中间结果的空间会非常紧张。所以量化势在必行。2.2 数据预处理与 MNIST 数据集的处理链路MNIST 原始数据格式是 IDX 文件不是常见的图片文件这点很多人第一次接触时会被绕晕。数据官网提供的四个文件分别是训练集图片train-images.idx3-ubyte、训练集标签train-labels.idx1-ubyte、测试集图片t10k-images.idx3-ubyte和测试集标签t10k-labels.idx1-ubyte。每个文件的头部都有一个 magic number 和若干维度信息然后才是原始像素数据。这里有个值得注意的坑很多教程会让你直接写 Python 脚本去官网下载 MNIST但最近 torchvision 自带的下载接口经常报 404 错误因为官网的数据托管在第三方服务器上有时候连接不稳定。更稳妥的做法是从镜像源下载或者像我一样直接在浏览器打开 MNIST 官网页面手动下载四个文件存到项目根目录的data/文件夹下。下载后需要用脚本解析。Python 里处理 IDX 文件用struct库即可import struct import numpy as np def load_mnist_images(path): with open(path, rb) as f: magic, num, rows, cols struct.unpack(IIII, f.read(16)) images np.frombuffer(f.read(), dtypenp.uint8).reshape(num, rows * cols) return images def load_mnist_labels(path): with open(path, rb) as f: magic, num struct.unpack(II, f.read(8)) labels np.frombuffer(f.read(), dtypenp.uint8) return labels注意MNIST 原始像素值是 0 到 255 的 uint8如果直接送进网络当 float32 用数值范围过大会导致训练不稳定。标准做法是归一化到 0 到 1训练阶段就是直接除以 255.0。但在 HLS 硬件推断时我建议更进一步——做定点量化把浮点权重和激活值都映射到定点数这是后续硬件加速的核心。2.3 HLS 环境配置版本选择和工程结构我用的是 Vivado HLS 2018.3或者 2019.2 也行。Xilinx 后来把 HLS 集成到 Vitis 工具里了Vivado 2019.2 之后的版本不再有单独打开的 HLS 工具改成在 Vitis 里建 HLS 组件。不过核心写法一致不需要太纠结版本。但要注意Vivado 2020.1 之后的版本对 C 标准支持更严格老工程的#include hls_stream.h和ap_fixed.h头文件路径可能需要调整。工程目录建议这么组织mnist-hls/ ├── data/ # MNIST 原始数据 ├── hls/ │ ├── mnist_top.cpp # HLS 顶层函数 │ ├── mnist_top.h # 头文件定义接口类型 │ ├── weights.h # 量化后的权重数组 │ └── testbench.cpp # HLS 测试平台 ├── vivado/ │ ├── block_design.tcl # 自动生成 Block Design 的脚本 │ └── constraints.xdc # 引脚约束 └── sdk/ └── main.c # ARM 端控制程序HLS 工程中最关键的头文件需要写清楚接口协议。我的顶层函数设计如下#include ap_fixed.h #include hls_stream.h typedef ap_fixed16, 6, AP_TRN, AP_SAT fixed_t; // 16位定点数6位整数部分 void mnist_top( hls::streamap_uint32 input, hls::streamap_uint32 output );这里选ap_fixed16, 6是经过权衡的。16 位定点数可以映射到 DSP48E1 的 18 位乘法器上正好不浪费资源。6 位整数部分能表示 -32 到 31 的范围ReLU 激活函数输出最大也就到 31 左右不会溢出。如果你的权重有少量超过 32 的极端值可以适当调整到 7 位整数部分代价是多占 1 位小数精度。3. HLS 核心实现从 C 到 RTL 的转化与优化3.1 朴素实现先把功能跑通再说优化先把纯软件版的推理函数写出来。两层全连接网络每层的计算就是矩阵乘向量再加偏置#include mnist_top.h #include weights.h void softmax(fixed_t* x, int n) { // 软件里做 softmax 很简单但硬件里做 exp 很贵后面会讲替代方案 fixed_t max_val x[0]; for (int i 1; i n; i) { if (x[i] max_val) max_val x[i]; } fixed_t sum 0; for (int i 0; i n; i) { x[i] exp(x[i] - max_val); // 实际 HLS 中不要这么写 sum x[i]; } for (int i 0; i n; i) { x[i] x[i] / sum; } } void mnist_top( hls::streamap_uint32 input, hls::streamap_uint32 output ) { #pragma HLS INTERFACE axis portinput #pragma HLS INTERFACE axis portoutput #pragma HLS INTERFACE ap_ctrl_none portreturn fixed_t input_buf[784]; fixed_t hidden[128]; fixed_t output_buf[10]; // 读入数据每个像素用 32 位 AXI-Stream 传输取低 16 位作为定点数 for (int i 0; i 784; i) { ap_uint32 val input.read(); input_buf[i] (fixed_t)(val 0xFFFF); } // 第一层全连接784 - 128 for (int i 0; i 128; i) { fixed_t acc bias1[i]; for (int j 0; j 784; j) { acc input_buf[j] * weight1[i][j]; } hidden[i] (acc 0) ? acc : (fixed_t)0; // ReLU } // 第二层全连接128 - 10 for (int i 0; i 10; i) { fixed_t acc bias2[i]; for (int j 0; j 128; j) { acc hidden[j] * weight2[i][j]; } output_buf[i] acc; } // 直接输出 logits不在硬件里做 softmax for (int i 0; i 10; i) { output.write((ap_uint32)output_buf[i]); } }先跑C Synthesis看看综合报告。这个朴素版本的综合结果大概率资源占用很低但 Latency 会非常高——因为两个嵌套循环没有任何流水线处理784 次乘法要顺序执行 784 个时钟周期一个循环就要跑几百个周期总共估计需要 20 万个周期以上。如果跑在 100MHz 时钟下推理一张图大约需要 2 毫秒。这不慢但还有很大的改进空间尤其当你想把 FPGA 部署扩展到批量推理时。3.2 优化策略三个 Pragmas 把延迟砍掉一个数量级HLS 最核心的玩法就是通过 pragma 指令引导硬件的并行度和数据流结构。我在这个工程里用到了三个关键优化指令。3.2.1 循环流水线Pipeline对最内层的乘累加循环加流水线for (int j 0; j 784; j) { #pragma HLS PIPELINE II1 acc input_buf[j] * weight1[i][j]; }II1的意思是希望工具让这个循环每隔 1 个时钟周期就能启动一次新的迭代也就是乘法器和加法器要流水起来。这样 784 个周期就能完成原本需要 784 个周期的乘法累加。实际上在单 DSP 的限制下HLS 工具做不到II1因为 784 次乘加全共用一套 DSP 是没办法实现每个周期都运行的工具会自动调整复本数或插入等待周期。但如果资源充足它会自动展开多个乘法器并行计算。3.2.2 数组划分Array Partition权重数组weight1[128][784]默认是存储在 BRAM 里的BRAM 有多个读写端口但跨行的随机访问效率不高。把权重数组按行划分#pragma HLS ARRAY_PARTITION variableweight1 cyclic factor8 dim2这句话告诉工具把weight1的第二个维度784按循环方式拆分成 8 块每块 98 个元素。这样在循环展开时工具可以同时从 8 块 BRAM 中读取数据配合 8 个乘法器并行工作。资源允许的情况下可以把这个 factor 调大比如 16 或者 32用资源换性能。3.2.3 数据复用数据局部性优化如果每次循环读取input_buf[j]HLS 会把它缓存进寄存器还是每次从 BRAM 读取答案是取决于工具的策略。为了让 HLS 明确知道这个数据应该被高频率重用可以直接把它声明为局部变量并在循环外提前加载fixed_t in_j input_buf[j];这有点鸡肋更好的方式是用#pragma HLS DEPENDENCE variableinput_buf typeinter false声明无依赖关系让工具大胆并行。3.3 softmax 的硬件替代方案在第三节的代码里我特意留了个坑硬件实现里通常不做真正的 softmax而是直接输出 logits最后一层的原始输出。原因有两个softmax 里的exp()函数在 HLS 里要么综合成非常昂贵的 CORDIC 算法要么直接报不支持。即使用hls::exp库函数也会消耗大量 DSP 和查找表资源。MNIST 分类只需要知道最大的 logits 对应哪个数字softmax 的数学性质是单调递增的不影响 argmax 的结果。所以直接比较 10 个输出值返回最大值下标就完成了分类。实际工程中softmax 通常在 ARM 端做。ARM Cortex-A9 跑一个 10 维的 softmax 只需要几微秒远比在 FPGA 上实现划算。这一步是典型的软硬件划分思想——把适合并行的计算给 FPGA把适合串行的控制逻辑给 ARM。3.4 接口综合与 AXI-Lite 配置我的 HLS 顶层函数接口用了hls::stream搭配axis协议这是最直接的 AXI4-Stream 接口。但实际在 Zynq 的软硬件协同设计中我更推荐用 AXI-Lite 寄存器接口来传参数用 AXI-DMA 传批量数据。如果你希望 ARM 核直接通过寄存器读写来配置加速器可以改用AXI-Lite接口void mnist_top( fixed_t input[784], fixed_t output[10] ) { #pragma HLS INTERFACE ap_memory portinput #pragma HLS INTERFACE ap_memory portoutput #pragma HLS INTERFACE s_axilite portreturn bundlecontrol }这样综合出来的 IP 会自带一组寄存器ARM 端通过XMnist_top_Set_input_r()和XMnist_top_Get_output()这样由驱动自动生成的 API 来读写数据。缺点是每次只能传一个 32 位数据批量传输需要循环操作效率不高。更高效的方案是给 input/output 绑上 AXI-DMA让 PS 端的 AXI-DMA 把一整块内存数据搬进搬出PL 端只管计算向量。提示HLS 生成的 IP 名前缀会带你的函数名比如我的函数是mnist_top生成的 IP 就是mnist_top_0。在 SDK 里操作时需要包含xmnist_top_hw.h和xmnist_top.h两个头文件其中_hw.h是寄存器定义_hw.h是驱动入口。4. Vivado 集成与 Zynq 平台搭建4.1 将 HLS IP 导入 Vivado 并建立 Block DesignHLS 综合完成后会导出一个.zip文件IP 核打包。在 Vivado 里点击Settings - IP - Add Repository选中这个压缩包IP 就会出现在 IP Catalog 中。然后新建 Block Design按顺序添加以下 IPprocessing_system7_0Zynq PS 配置模块mnist_top_0我们自己生成的加速器axi_dma_0如果走 DMA 路线rst_ps7_0_100M复位模块axi_smcAXI 互联矩阵自动生成连接 PS 端时要确保勾选S_AXI_HP0接口这是 PL 端访问 DDR 内存的高速通道。如果只是小数据量传输用S_AXI_GP0通用目的 AXI 接口也够用吞吐量稍低但对 MNIST 这种小规模计算无感。4.2 Block Design 中的关键连线与地址映射连线时最容易出错的地方是 AXI 接口的地址分配。Vivado 的自动连接工具Run Connection Automation会自动分配地址但默认会随机分配地址段并不适合实际调用。我的做法是手动在Address Editor中把地址固定下来比如mnist_top_00x40000000 到 0x4000FFFFaxi_dma_00x40400000 到 0x4040FFFF这样固定后SDK 里直接定义宏#define MNIST_TOP_BASE (0x40000000) #define AXI_DMA_BASE (0x40400000)时序约束方面Block Design 默认的时钟频率是 100MHz。Zynq7020 的 Artix-7 架构在 100MHz 下跑这个设计的时序是轻松过关的。如果综合后时序违例优先检查 BRAM 的读延迟设置和 DSP 流水级数而不是盲目降频。HLS 综合报告里能查到每个循环的起始间隔Interval如果 II1说明工具自动插入了气泡。4.3 SDK 端的主控程序读出结果并显示Vivado 导出硬件File - Export Hardware选 Include bitstream之后启动 SDK。SDK 里新建一个 Application Project选择 Hello World 模板。主控程序的核心流程如下#include xil_printf.h #include xmnist_top.h #include xscugic.h #include xil_cache.h #define TEST_IMAGES 10 // 测试前 10 张 int main() { // 初始化驱动 XMnist_top_Config *cfg XMnist_top_LookupConfig(XPAR_MNIST_TOP_0_DEVICE_ID); XMnist_top_CfgInitialize(mnist, cfg); // 把测试图片数据从 DDR 的指定地址读到加速器 XMnist_top_Set_input_v(V_BASE_ADDR, 0, 784); // 启动加速器 XMnist_top_Start(mnist); // 等待完成 while (!XMnist_top_IsDone(mnist)) {} // 读取输出 XMnist_top_Get_output(mnist, output_buf, 10); }注意Set_input_v这种 API 有一个坑如果地址是 DDR 且启用了数据缓存需要先调用Xil_DCacheFlush()刷新缓存否则 ARM 写入的数据还在 L1 缓存里没有落到内存PL 端 DMA 读到的可能是旧数据。同理读回结果前要调用Xil_DCacheInvalidate()。这个问题我在第一次联调时足足卡了两个小时。5. 常见问题速查与性能分析5.1 典型问题排查速查表问题症状可能原因排查与解决HLS 综合报错[HLS 200-1441]顶层函数接口使用了多维数组且未指定存储协议接口改成ap_memory并用#pragma HLS INTERFACE ap_memory声明C/RTL 协同仿真结果与 C 仿真不一致数据精度问题ap_fixed舍入方式不对检查AP_TRN截断和AP_SAT饱和配置尽量在 C 仿真阶段就对比统计最大误差Vivado 综合后 Bitgen 报 DRC 错误引脚约束冲突或未连接复位信号检查 Block Design 中所有模块是否有有效的复位输入fiXed_io中的 DDR 引脚冲突时手动修改 XDCSDK 读回结果全是 0未做 Cache 一致性操作读取前调用Xil_DCacheInvalidate()写入前调用Xil_DCacheFlush()AXI-DMA 不工作环形描述符或缓冲地址未正确配置用Xil_DCacheFlushRange()刷新描述符和缓冲区地址确认 SGScatter Gather模式是否已初始化推理准确率低于 80%量化精度不足或推理流程有误先用软件浮点模型在 PC 上复现同款输入图片的结果对比每一层的输出差HLS 时序报告显示 II 设置不满足资源不够无法实现目标并行度适当降低PIPELINE的II目标或增加ARRAY_PARTITION的因子但要注意 BRAM 占用上限5.2 性能实测延迟、资源消耗和准确率用 100MHz 时钟频率实测这个优化后的 HLS 加速器在 Zynq7020 上完成单张 MNIST 图片推理整体延迟大约 0.8 毫秒其中 HLS 核心计算占 0.5 毫秒DMA 数据传输占 0.2 毫秒ARM 端预处理和软件堆栈开销约 0.1 毫秒。资源消耗方面资源类型使用量占 Zynq7020 比例LUT58,237约 69%Flip-Flop31,204约 18%BRAM 18Kb128约 91%DSP48E164约 29%准确率在测试集上约 96.5%和软件浮点模型差距在 0.5 个百分点以内主要是定点量化带来的精度损失。如果对准确率满意这个方案就可以直接作为嵌入式手写识别原型。如果想继续提升可以考虑用带通配符的伪量化训练Quantization-Aware Training在训练阶段就把量化的影响考虑进去能把这 0.5% 的差距基本补回来。5.3 资源不够怎么办分区存储和流式计算的平衡BRAM 占用 91% 是最大的瓶颈。如果接下来想把模型升级到三层全连接或者两层卷积必须解决 BRAM 问题。三个可行方向第一把权重存储从 BRAM 挪到 DDR。Zynq7020 外接的 DDR 内存通常有 512MB 以上但代价是访问延迟会从两个周期跳到几十个周期数据搬移开销很大。所以这种做法只适合“权重分片加载”的场景。更推荐第二种方案也就是在训练阶段做结构化剪枝。把权重矩阵中的小数值全部置零并重排成稀疏矩阵存储HLS 里只遍历非零元素。实际测试表明MNIST 的全连接网络可以剪掉至少 80% 的参数而准确率不掉BRAM 的压力直接降到一个很舒服的范围。第三个方向是把计算流水化整体上让 DMA 传输、第一层计算、第二层计算三个大阶段在时刻上重叠这样理论上可以把总时延压缩到单层计算的时间。6. 最后再分享一点实操心得这个项目做完之后我觉得最值得沉淀的不是那 96% 的准确率而是一条判断问题域的直觉什么样的模型适合放进 FPGA当你的模型参数量在百万以下、计算量在千万次乘加以下、数据吞吐需求在百兆字节每秒以下时Zynq 系列的 FPGA 是非常理想的承载平台。超出这个量级要么换成更高端的 MPSoC比如 Zynq UltraScale要么考虑专用的 NPU 芯片硬件加速这件事是有边界的提前算清楚这笔账能帮你省下大量无谓的工作。另一个心得是关于 HLS 工具本身的。很多人说 HLS 生成的 RTL 代码质量不如手写这句话在十年前可能成立但在 2018 年之后的版本中对于乘加密集型的网络层HLS 综合出的结果和手写 RTL 差距已经很小了。真正差距大的反而是控制逻辑和数据搬运。所以我的建议是算法路径用 HLS 描述没有毛病但外部的 AXI-DMA 配置、中断处理等关键代码一定要去理解它背后的寄存器语义不要只是对着示例代码填空。一旦出了问题你能靠的只有对协议的理解和现场调试能力。本文还有配套的精品资源点击获取