☰
PYNQ开发板实战:二值神经网络(BNN)部署与MNIST手写数字识别
2026/10/26 21:20:53 网站建设 项目流程

简介:这份资源面向希望在嵌入式平台上实践深度学习硬件加速的开发者与学习者,围绕在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把浮点压到 ±1,backward用直通估计绕过符号函数导数为零的问题。(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, dtype=torch.int32)]) words = flat.reshape(-1, 32) # 按位拼成一个 32 位整数 packed = (words * (2 ** torch.arange(32))).sum(dim=1) 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 0x43C00000

0x43C00000这个基地址要和 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,), dtype=np.uint8) out_buf = allocate(shape=(10,), dtype=np.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, eps=1e-5): # 把 BN 折叠进卷积的偏置,得到每通道阈值 std = torch.sqrt(bn_var + eps) threshold = bn_mean - bn_bias * std / bn_scale return threshold

threshold就是硬件里比较用的常数。符号方向要特别注意:有的实现是acc >= threshold输出 1,有的是>,差一个等号在边界样本上会有差异。导出时把阈值也按层打包,硬件按层号索引。

5. 避坑与排查:板上跑 BNN 最容易翻车的几处

5.1 现象:第一次推理正确,第二次开始全错

原因几乎总是缓存一致性。allocate出来的缓冲区在 CPU 侧有缓存,DMA 写完后 CPU 读到的是旧值,或者 CPU 写完没 flush,DMA 读到旧数据。解决方法是每次传输前后成对调用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, layer=1) # 从 BRAM 或 DMA 读回 diff = np.count_nonzero(ref != hw) print(f'layer1 mismatch: {diff}/{ref.size}') assert diff == 0, '位序或阈值不一致,先查打包顺序'

从那以后我每次改硬件位宽或打包顺序,都强制先跑一遍逐层比对再上整网,省下来的调试时间远比写脚本多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询