尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

PYNQ开发板实战:二值神经网络(BNN)部署与MNIST手写数字识别

PYNQ开发板实战:二值神经网络(BNN)部署与MNIST手写数字识别 简介这份资源面向希望在嵌入式平台上实践深度学习硬件加速的开发者与学习者围绕在PYNQ基于Zynq FPGA的Python嵌入式平台上部署二值神经网络BNN完成数字识别展开并对比FPGA加速前后的识别速度差异帮助读者理解BNN如何通过将权重与激活值二值化来降低计算与存储开销以及FPGA并行定制逻辑在卷积、矩阵运算中的加速价值。压缩包共1843个文件约82.12MB以1681个bin权重文件为主辅以so动态库、py脚本、tcl与cpp源码、hwh硬件描述、bit比特流、ipynb实验笔记及md说明文档覆盖模型定义、训练、硬件部署与加速逻辑的完整链路。已有2094人学习下载。读者可据此复现BNN在PYNQ上的实现流程理解软硬件协同设计方法并借助源码与笔记排查部署问题对嵌入式AI加速研究具有参考价值。1. 一块开发板跑通二值神经网络从数字识别看 PYNQ 上的 BNN 落地手里有一块 PYNQ-Z2想让它自己认出手写数字又不满足于只调一个现成的 Python 脚本——这大概是很多人第一次接触 FPGA 加速深度学习时的真实起点。这个项目做的事很具体在 PYNQ 开发板上部署一个二值神经网络BNN完成 MNIST 手写数字识别把卷积和全连接里的乘加运算压成 XNOR 加 popcount让 PL 端真正承担推理负载。它适合两类人一类是想把神经网络从纯软件搬到可编程逻辑上、但不知道从哪下手的开发者另一类是已经会写 Verilog、想看看一个完整推理系统怎么和 Python 侧对接的硬件方向同学。读完你会清楚整个数据通路怎么搭、权重怎么二值化、板子上怎么跑起来以及哪些地方最容易翻车。2. 系统架构拆解PS 与 PL 之间到底怎么分工2.1 为什么 BNN 特别适合往 FPGA 上放普通卷积网络里一次 3×3 卷积的每个输出点要做 9 次乘加权重是 8 位或 16 位浮点乘法器资源吃得厉害。二值神经网络把权重和激活都约束到 1/-1 两个值乘法就退化成同或XNOR累加退化成数 1 的个数popcount。这个变换不是学术上的花活它直接决定了硬件能不能塞得下一个 8 位乘法器在 FPGA 上要占 DSP 资源而 XNOR 和 popcount 全是查找表和进位链就能干的活。我一般会这样向别人解释收益假设一层有 128 个 3×3×64 的卷积核二值化之后每个乘加变成 1 位逻辑运算理论上可以把运算密度提高一个数量级同时权重存储从每参数 1 字节压到 1 位片上 BRAM 能装下整个模型。代价是精度会掉所以训练阶段必须用带符号函数的直通估计STE来更新浮点影子权重推理阶段才真正二值化。这个项目里训练在 PyTorch 侧完成导出的是打包好的二值权重板子上只负责前向。2.2 PS 侧与 PL 侧的责任边界PYNQ 的架构是 ARM 处理器PS加可编程逻辑PL两者通过 AXI 总线通信。这个项目里分工很清晰PS 侧跑 Python负责读入 MNIST 图片、做预处理、把数据写进 PL 能访问的缓冲区、启动加速器、读回结果并打印。PL 侧是综合好的 BNN 加速器 IP内部包含输入缓冲、若干二值卷积层、池化、全连接层和输出比较逻辑。两者之间用 AXI-Lite 传控制寄存器启动、复位、层配置用 AXI-Stream 或 AXI Master 搬特征图数据。这种划分的好处是 Python 侧改动成本极低换一张图只是改数组坏处是数据搬运容易成为瓶颈后面避坑章节会专门讲。2.3 目录结构长什么样一个典型的工程目录大致是这样组织的不同人命名会有差异但层次基本一致bnn_pynq/ ├── hw/ # 硬件工程 │ ├── src/ │ │ ├── bnn_top.v # 顶层例化各层 │ │ ├── conv_layer.v # 二值卷积 popcount │ │ ├── pool_layer.v # 最大池化 │ │ └── fc_layer.v # 全连接 │ ├── constraints/ │ │ └── pynq_z2.xdc # 引脚与时序约束 │ └── tcl/ │ └── build_bd.tcl # 生成 Block Design 的脚本 ├── sw/ │ ├── train_bnn.py # PyTorch 训练与导出 │ ├── export_weights.py # 权重打包成 .mem/.npy │ └── inference.py # 板上推理脚本 ├── overlay/ │ └── bnn.bit # 综合后的比特流 └── data/ └── mnist/ # 测试图片hw/是 Vivado 侧sw/是 Python 侧overlay/放最终产物。这个分层很关键硬件改动只碰hw/算法改动只碰sw/两边通过权重文件和寄存器约定解耦。2.4 加速器的数据通路从一次推理的完整链路看数据是这样流动的Python 把 28×28 的灰度图归一化后写进一块连续内存通过 DMA 送进 PL 的输入 FIFO第一层二值卷积把输入和权重做 XNOR-popcount得到整数部分和再和阈值比较输出 1 位特征图经过池化降采样后进入下一层最后全连接层输出 10 个分数取最大值对应的下标作为识别结果写回寄存器供 Python 读取。这里有个容易忽略的点二值卷积的“乘加”结果是一个整数需要和偏置比较后才重新变成 1 位。也就是说每层之间都存在一次“整数域到二值域”的再量化这个阈值通常来自训练时的批归一化参数折叠。项目里把这组阈值也打包进权重文件硬件按层索引读取。3. 从零跑起来环境、比特流与板上推理3.1 环境准备与版本对齐PYNQ 的镜像版本和 Vivado 版本必须匹配这是血泪经验。常见组合是 PYNQ 2.7 配 Vivado 2020.2或者 PYNQ 3.x 配 Vivado 2022.x。版本错位最典型的症状是Overlay加载时报 bitstream 不兼容或者 AXI 寄存器地址对不上。板子侧需要确认的几件事# 在 PYNQ 的 Jupyter 终端里执行 cat /etc/pynq_version # 确认镜像版本 ls /home/xilinx/jupyter_notebooks # 确认工作目录 python3 -c import pynq; print(pynq.__version__)如果要用 PyTorch 训练建议在 PC 上做不要在板子上训。板子的 ARM 核跑训练会非常慢而且 PYNQ 镜像里不一定装了对应版本的 torch。训练和推理分离是这类项目的标准做法。3.2 训练侧二值化与权重导出训练脚本的核心是把普通卷积替换成二值卷积并用 STE 处理不可导的符号函数。下面这段是训练循环里最关键的几行import torch import torch.nn as nn class Binarize(torch.autograd.Function): staticmethod def forward(ctx, x): # 前向大于等于 0 取 1否则取 -1 ctx.save_for_backward(x) return x.sign().clamp(-1, 1) staticmethod def backward(ctx, grad_output): # 反向直通估计梯度原样传回 x, ctx.saved_tensors # 只在 |x| 1 的区间内传梯度避免饱和区噪声 return grad_output * (x.abs() 1).float() class BinaryConv(nn.Conv2d): def forward(self, x): w Binarize.apply(self.weight) x Binarize.apply(x) return nn.functional.conv2d(x, w, self.bias, self.stride, self.padding)Binarize.forward把浮点压到 ±1backward用直通估计绕过符号函数导数为零的问题。(x.abs() 1)这个掩码是我习惯加的能让训练后期更稳。训练完成后导出脚本要做两件事把权重按位打包成 32 位字一个 word 装 32 个权重把每层的 BN 参数折叠成比较阈值。def pack_binary_weights(weight): # weight: [out_c, in_c, kh, kw]取值 ±1 flat (weight.sign().flatten() 0).to(torch.int32) pad (-flat.numel()) % 32 if pad: flat torch.cat([flat, torch.zeros(pad, dtypetorch.int32)]) words flat.reshape(-1, 32) # 按位拼成一个 32 位整数 packed (words * (2 ** torch.arange(32))).sum(dim1) return packed.to(torch.int64).numpy()打包顺序必须和硬件里 popcount 的位序严格一致否则结果会整体错乱但又不报错这是最阴的一类 bug。导出时建议同时存一份浮点参考输出板上跑完拿来做逐层比对。3.3 硬件侧生成比特流并加载 Overlay硬件工程用 Vivado 打开Block Design 里通常包含 Zynq PS、AXI Interconnect、DMA 和自定义的 BNN IP。综合前要检查两件事时钟约束是否覆盖了加速器的工作频率以及 AXI 地址分配是否和 Python 侧一致。# build_bd.tcl 片段给 BNN IP 分配地址段 assign_bd_address -target_address_space /processing_system7_0/Data \ [get_bd_addr_segs {bnn_accel_0/S_AXI/reg0}] \ -range 64K -offset 0x43C000000x43C00000这个基地址要和 Python 里allocate(0x43C00000, 0x10000)对上。生成比特流后拷到板子的overlay/目录Python 侧这样加载from pynq import Overlay, allocate import numpy as np ol Overlay(/home/xilinx/bnn_pynq/overlay/bnn.bit) dma ol.axi_dma_0 accel ol.bnn_accel_0 # 分配物理连续内存大小按最大特征图算 in_buf allocate(shape(784,), dtypenp.uint8) out_buf allocate(shape(10,), dtypenp.int32)allocate拿到的是物理连续内存DMA 才能直接访问。普通 numpy 数组不行这一点新手经常踩。3.4 一次完整推理的调用链把上面几块拼起来推理脚本的主干是这样def infer(img): # img: 28x28归一化到 0/1 后二值化成 0/1 位 bits (img.reshape(-1) 0.5).astype(np.uint8) in_buf[:] bits in_buf.flush() # 确保数据写回内存 dma.sendchannel.transfer(in_buf) dma.recvchannel.transfer(out_buf) accel.write(0x00, 1) # 启动寄存器 dma.sendchannel.wait() dma.recvchannel.wait() out_buf.invalidate() # 丢弃 CPU 缓存读新数据 return int(np.argmax(out_buf))flush()和invalidate()是缓存一致性操作少了它们会读到旧数据表现为“第一次对、第二次错”这种玄学现象。accel.write(0x00, 1)的寄存器偏移取决于 IP 的寄存器映射改 IP 时要同步改这里。4. 核心模块拆解卷积、池化与全连接的硬件实现4.1 二值卷积的 XNOR-popcount 内核卷积层是整个加速器的重头。核心思路是把输入特征图和权重都表示成位向量按位做 XNOR 后数 1 的个数得到“一致位数”再换算成有符号的乘加和。// 一个输出通道、一个窗口的简化计算 // in_vec: 当前窗口的输入位w_vec: 对应权重位 wire [31:0] xnor_out ~(in_vec ^ w_vec); wire [5:0] ones popcount32(xnor_out); // 一致位数 ones 对应 (2*ones - 32) 的乘加和 wire signed [7:0] acc (ones 1) - 32;popcount32一般用加法树实现综合工具也能识别成进位链。(ones 1) - 32这个换算是关键32 位里如果有ones位相同那么 1 的贡献是ones-1 的贡献是32 - ones净和就是2*ones - 32。这个公式错了整个网络的输出会整体偏移但每一层看起来都“有输出”很难定位。实际工程里会把多个输出通道并行展开用多个 popcount 单元同时算再送进累加器。并行度取决于 BRAM 带宽和 LUT 预算我一般先按 8 或 16 通道并行起步综合后看时序再调。4.2 池化层的位运算实现二值网络里的最大池化退化成“或”运算窗口里只要有一个 1输出就是 1。这比浮点池化省太多资源。// 2x2 最大池化输入为 1 位特征图 always (posedge clk) begin if (valid_in) begin out_bit in_a | in_b | in_c | in_d; end end四个输入位或起来就是窗口最大值。注意这里的“最大”是在二值域定义的和浮点域的最大池化语义一致因为 1 大于 -1。如果训练时用的是平均池化这里就不能这么写得改成计数比较导出权重时也要相应调整。4.3 全连接层与输出比较最后一层全连接把特征图压成 10 个分数。二值全连接同样是 XNOR-popcount但输出是整数不需要再二值化直接送进比较器找最大值。// 10 路分数找最大值输出索引 reg [3:0] max_idx; reg signed [15:0] max_val; integer i; always (posedge clk) begin max_val scores[0]; max_idx 0; for (i 1; i 10; i i 1) begin if (scores[i] max_val) begin max_val scores[i]; max_idx i; end end end这个比较器是组合逻辑展开的10 路不算多时序压力不大。如果类别数上百就得改成流水线比较。输出索引写回 AXI 寄存器Python 侧读out_buf或直接读寄存器都行。4.4 层间阈值与再量化每层卷积输出整数和后要和阈值比较才变回 1 位。阈值来自 BN 折叠def fold_bn(conv_w, bn_scale, bn_bias, bn_mean, bn_var, eps1e-5): # 把 BN 折叠进卷积的偏置得到每通道阈值 std torch.sqrt(bn_var eps) threshold bn_mean - bn_bias * std / bn_scale return thresholdthreshold就是硬件里比较用的常数。符号方向要特别注意有的实现是acc threshold输出 1有的是差一个等号在边界样本上会有差异。导出时把阈值也按层打包硬件按层号索引。5. 避坑与排查板上跑 BNN 最容易翻车的几处5.1 现象第一次推理正确第二次开始全错原因几乎总是缓存一致性。allocate出来的缓冲区在 CPU 侧有缓存DMA 写完后 CPU 读到的是旧值或者 CPU 写完没 flushDMA 读到旧数据。解决方法是每次传输前后成对调用flush()和invalidate()不要只在初始化时调一次。5.2 现象输出全是同一个类别先查权重打包的位序。硬件 popcount 假设 bit0 是最先的输入如果 Python 打包时用了相反的顺序卷积结果会整体错乱但仍有输出argmax 往往退化成常数。排查办法是拿一张全 0 输入跑一遍看中间层输出是否符合预期再拿一张已知标签的图逐层比对浮点参考。另一个常见原因是阈值符号反了导致每层输出全 1 或全 0。5.3 现象Overlay 加载报 bitstream 不兼容PYNQ 镜像版本和 Vivado 版本不匹配或者比特流不是为这块板子的器件型号综合的。确认pynq.__version__和 Vivado 版本对应关系重新用匹配版本综合。还有一种情况是.hwh文件没跟着.bit一起拷过去导致 IP 信息缺失加载时找不到寄存器映射。5.4 现象时序不收敛综合报 setup violation加速器工作频率定太高或者并行度开太大导致组合逻辑路径过长。先把时钟降到 100MHz 试再逐步往上加popcount 的加法树可以插一级流水。另外检查 AXI 互联是否成了关键路径必要时给 DMA 和加速器之间加寄存器切片。5.5 现象识别率明显低于软件仿真先确认测试集预处理是否一致训练时归一化方式、二值化阈值、输入位序都要和板上完全对齐。再查池化类型是否匹配训练用平均池化而硬件实现成最大池化精度会掉得很明显。最后看是否漏了某一层的阈值再量化少一层比较会让后续所有层的输入分布偏移。6. 二次开发换数据集、加层与精度回补的实操思路想把 MNIST 换成别的任务改动集中在三处。第一处是输入尺寸28×28 换成 32×32 或更大时输入缓冲和第一层窗口要同步改in_buf的 shape 和 DMA 传输长度都要跟着调。第二处是层配置加一层卷积意味着硬件里多例化一个conv_layer同时 Python 侧权重文件多一段层索引寄存器多一个值。我习惯把层数和每层参数写进一个 JSON硬件综合时生成对应的寄存器映射软件侧读同一份 JSON避免两边手改不同步。精度回补是二值网络绕不开的话题。纯二值模型在复杂数据集上掉点明显常见做法是首尾层保留全精度第一层卷积输入是原始像素用 8 位最后一层全连接输出用整数或浮点。这样硬件里只多一小块乘法器精度能回来不少。另一个技巧是训练时用多比特激活、二值权重W1A2 之类硬件上激活的 popcount 改成加权求和资源增加有限。验证方法上我强烈建议在 Python 侧写一个逐层参考实现把每层输出存成 npy板上跑同一张图时把中间结果也读回来比对。第一次对齐往往要花半天但后面改任何一层都能快速定位。下面这个比对脚本片段可以直接抄ref np.load(layer1_ref.npy) hw read_layer_output(accel, layer1) # 从 BRAM 或 DMA 读回 diff np.count_nonzero(ref ! hw) print(flayer1 mismatch: {diff}/{ref.size}) assert diff 0, 位序或阈值不一致先查打包顺序从那以后我每次改硬件位宽或打包顺序都强制先跑一遍逐层比对再上整网省下来的调试时间远比写脚本多。希望帮到你。本文还有配套的精品资源点击获取
返回列表