从逻辑门到深层神经网络:用FPGA实现手写数字识别

封面

1 前言

        什么是神经网络(Neural Network)?简单来说,人工神经网络(Artificial Neural Network, ANN)是一种模仿生物神经系统结构和功能的计算模型。它的基本计算单元是“神经元”,每个神经元接收来自上一层的输入,经过加权求和与非线性激活后得到输出。其核心计算公式可以表示为:

\[y = f(\sum_{i=1}^{n} w_i x_i + b)\]

        其中 $x_i$ 是输入特征,$w_i$ 是对应的权重(Weight),$b$ 是偏置(Bias),$f$ 是激活函数(Activation Function)。

        最基本神经网络由三类层构成:

        只要至少有一层“隐藏层”,通常就可以称为神经网络。

        深度神经网络(Deep Neural Network, DNN)就是层数很深的神经网络,本质一样,就是隐藏层更多。一般约定:

        深度不是“为了多而多”,而是带来表示能力的飞跃。以图像为例:

层级 学到的东西
浅层 边缘、直线
中层 角、纹理
深层 物体部件
最深层 整体语义(猫、人脸)

        深度越深,能力越强:

        常见深度神经网络家族:

        作为一名 IC/FPGA 工程师,我们习惯了用与非门、触发器和状态机来构建数字世界。当面对 AI 这个充满“概率”和“黑盒”色彩的领域时,最有效的方法就是从硬件底层重构神经网络。本文将拆解神经元的核心组件,探讨如何将数学上的参数转化为电路中的实存逻辑。

2 训练与推理

        在深入细节前,必须厘清“训练”与“推理”在网络结构上的异同。

3 神经元结构

        在硬件工程师眼里,神经元是一个级联的逻辑流水线。它由输入端$x_i$、权重存储$w_i$、乘加运算阵列、偏置接入点$b$和非线性转换器$ReLU$组成。

neuron_mac_unit

3.1 权重 (Weights)

3.2 计算单元 (Computing Unit)

3.3 偏置 (Bias)

3.4 激活函数 (Activation Function)

4 网络的结构

        神经元通过不同的连接拓扑,形成了处理数据的流水线。目前主流的有全连接和卷积两种模式。

4.1 全连接网络 (Fully Connected Network)

        全连接是神经网络中最直观的结构,即上一层的所有输出信号全部连接到下一层每一个神经元的输入端。

cnn_vs_fc

        这里做了一个全连接神经网络动画演示的页面,能够在一个3x3的网格上画线,画上的区域为1,其余为0,然后把这9个格子作为神经网络第一层,第二层做计算,第三层为输出层。通过动画一步一步演示三层全连接的神经网络是如何识别横竖斜线。

4.2 卷积网络 (Convolutional Network)

        卷积的物理本质是滑动窗口滤波器。它不再一次性看整张图,而是拿着一个“放大镜”(卷积核)在图上逐像素扫描 。

4.2.1 深度理解卷积:一个 1 维数组的特征提取实例

        为了理解卷积是如何“提取特征”的,我们假设有一段 1 维的图像信号,其中包含一个突发的白点(高亮度 255):

        结论:卷积后的结果在白点位置产生了极大的响应值 (510)。这就意味着,这个卷积核成功地在原始数据中“搜索”到了它想要的特征(中心高亮的模式)。

4.2.2 卷积层优缺点

4.2.3 池化层 (Pooling Layer)

        卷积层之后通常紧跟着池化层,它的作用是“降维”和“特征压缩”。最常用的是 最大池化 (Max Pooling)

4.3 输出逻辑:从概率到数字 (Argmax)

        经过多层卷积和全连接后,网络最后会输出10个数值,分别代表识别结果为数字0-9的“得分”。

argmax_tree

4.4 架构对比总结

特性 全连接网络 (FC) 卷积网络 (CNN)
连接方式 全局连接(网状) 局部连接(滑动窗口)
权重管理 独立权重,数量巨大 权值共享,数量极小
硬件瓶颈 存储带宽 (Memory Bound) 计算逻辑 (Logic Bound)
典型应用 简单传感器数据、最后分类层 图像识别、语音识别、视觉处理

5 算法训练和导出

        通过对神经元和网络结构的底层拆解,我们可以看到,神经网络在硬件实现上其实是一场数学公式与电路资源之间的博弈

        目前我们已经建立了“硬件直觉”,看清了推理引擎的骨架。但还有一个致命问题:FPGA 并不擅长处理电脑训练出来的那些浮点数(Float32)。这一章将进入AI硬件加速最关键的工程环节——模型量化(Quantization),探讨如何把浮点参数“塞进” 8 位定点逻辑中而不丢失精度。

        作为硬件工程师,我们并不需要从零研发最前沿的算法,但必须具备“模型工程化”的能力。在第二阶段,我们的目标是拿到一套能在 FPGA 上跑通的“参数包”。

5.1 软件与系统环境准备

        系统环境和安装步骤:

5.2 硬件友好型模型结构设计

        在FPGA上跑 AI,资源是第一考量。对于IC工程师,全连接层 (Linear Layer) 实际上就是一个大号的乘累加器 (MAC):$Y = X \cdot W^T + b$。

5.3 模型训练与数据集获取

        训练的过程就像是给电路寻找最优的偏置电压。核心逻辑如下:

        按照以下顺序在终端执行:

5.4 参数导出结果说明

5.5 权重分布观测 (Distribution Analysis)

        在进入下一阶段量化之前,我们需要对提取出的参数进行“体检”。

weight_distribution_histogram

5.6 存储格式预备

        导出的 .txt 浮点数文件是下一步“量化”的输入。在第三阶段,我们将通过量化脚本分析这些权重的分布范围,将其从 Float32 压缩为 Int8 或 Int16,以适配 FPGA 的 DSP48 单元。同时,根据不同的硬件流程,可能还需要准备:


6 量化

        上一章通过PyTorch成功训练出了一个MNIST全连接神经网络,并导出了模型的权重参数。打开导出的.txt文件会看到满屏的0.0234,-1.109这样的32位浮点数 (float32)。在 CPU 和 GPU 架构中,计算IEEE 754 标准的浮点数是非常基础且高效的操作。但在 FPGA 的底层数字逻辑中,如果要用纯逻辑单元(LUTs)强行拼凑出一个浮点乘累加器,不仅会消耗极其庞大的逻辑资源,更会严重拖慢系统的时序,让整个流水线变得异常臃肿。

        因此,将浮点数转换为定点数(Fixed-point)或纯整数(Integer),是AI算法真正落地到FPGA 硬件的必经之路。 这一步被称为模型量化(Quantization)。今天,我们将聚焦于第三阶段的核心:如何将这些参数转化为 FPGA 能够高效吞吐的数据,以及我们为什么在 PYNQ-Z2 开发板上做出了坚定的位宽选择。

6.1 定点数表示与Q格式

        在FPGA内部,我们最强大的算术计算资源是DSP Slice(如 Xilinx 的 DSP48E1)。它们是处理定点数和整数乘加运算的核心单元。

        为了在整数计算器上表示小数,工程上通常使用 Q 格式 (Q-Format)。比如 Q4.12 格式,表示总共 16 位宽,其中:

转换原理相对直观: 将浮点数乘以 $2^{12} (4096)$,然后四舍五入取整。

q4_12_format

        虽然操作看似仅为乘法与截断,但其中涉及关键的精度权衡。强行截断必然引入误差,若小数位宽过窄(例如仅 4 位),累积误差将导致 MNIST 识别准确率大幅下降。

6.2 Q格式位宽的选择

        在深度学习的工业界,INT8量化几乎是标配。直觉上,8位数据比16位更窄,省寄存器、省布线,但是首选16-bit定点数量化是因为硬件数据宽度确实变小了,但算法补偿和数据通路控制的代价却呈指数级上升。

        16-bit有65536个离散的台阶,可以从容地进行“乘常数截断”,精度几乎零损失。而8-bit只有256个台阶,强行把复杂的权重分布塞进去会产生巨大的量化噪声。为了挽救精度,算法端被迫引入了非对称量化、动态Scale、Zero-point,甚至必须进行量化感知训练 (QAT)。这大大增加了算法端的设计复杂度。

        在16-bit固定小数点下(如Q4.12),乘累加 (MAC) 就是极简的$Sum = Sum + (X * W)$。

        但在标准的INT8仿射量化下,真实的数学值是 $Scale \times (Value_{int8} - ZeroPoint)$。展开后,硬件里的一个 MAC 操作变成了包含交叉乘法项和偏置补偿的复杂逻辑:

\[Sum = \sum (X_q \cdot W_q) - Z_w \sum X_q - Z_x \sum W_q + N \cdot Z_x \cdot Z_w\]

        原本一个乘法器的事,现在硬件里凭空多出了一堆补偿逻辑。

        INT8算完累加后会变成一个32位的中间值。为了送到下一层,你必须除以一个浮点数的Scale重新映射回INT8。这需要在RTL里设计复杂的动态移位和乘法来模拟浮点除法,数据流控制器设计难度极高。

        综上所述,16-bit定点是算法和硬件友好的“甜点区”,它极简的控制逻辑能让你把主要精力放在时序和流水线架构上。

6.3 FPGA芯片选择

        没啥好选的,我手头只有PYNQ-Z2开发板,搭载的是Zynq-7000系列的XC7Z020SoC。分析这颗芯片的资源,16-bit就很合适:

6.4 Python量化脚本

        理论分析完毕,下面是编写的Python脚本。它将上一章导出的浮点数.txt转换为16-bit Q4.12 格式的.hex文件,这些文件将会在下一阶段直接被Vivado 中的$readmemh指令读取并烧入BRAM。

import numpy as np
import os

def quantize_to_hex(float_val, bits=16, frac_bits=12):
    # 第一步:缩放 (Multiply by 2^frac_bits)
    scaled = float_val * (2**frac_bits)
    # 第二步:四舍五入取整
    quantized = int(round(scaled))
    
    # 第三步:饱和截断逻辑 (防溢出保护)
    max_val = (2**(bits-1)) - 1
    min_val = -(2**(bits-1))
    if quantized > max_val:
        quantized = max_val
    if quantized < min_val:
        quantized = min_val
    
    # 第四步:处理 2 的补码,并格式化为 Hex
    if quantized < 0:
        quantized = (1 << bits) + quantized
        
    fmt_str = '0' + str(bits//4) + 'x'
    return format(quantized, fmt_str)

"""
硬件工程提示 (Hardware Tip):
在 RTL 实现中,累加器位宽通常远大于输入位宽。将结果从 32 位累加器截断回 16 位 Q4.12 时,
必须实现“饱和逻辑”(Saturation Logic)。
简单的 Verilog 逻辑如下:
always @(*) begin
    if (acc[31] == 0 && |acc[30:15]) // 正向溢出
        data_out = 16'h7FFF;
    else if (acc[31] == 1 && !(&acc[30:15])) // 负向溢出
        data_out = 16'h8000;
    else
        data_out = acc[15:0];
end
"""

def process_file(input_path, output_path, bits=16, frac_bits=12):
    data = np.loadtxt(input_path)
    with open(output_path, 'w') as f:
        if data.ndim == 0: # 处理只有单个数值的情况(如 bias)
            f.write(quantize_to_hex(data, bits, frac_bits) + "\n")
        else:
            for val in data.flatten(): # 矩阵展平
                f.write(quantize_to_hex(val, bits, frac_bits) + "\n")
    print(f"Quantized {input_path} -> {output_path} (Hex)")

def main():
    param_dir = "params"
    hex_dir = "params_hex"
    if not os.path.exists(hex_dir):
        os.makedirs(hex_dir)
        
    files = [f for f in os.listdir(param_dir) if f.endswith('.txt')]
    
    # 使用 16-bit Q4.12 格式
    BITS = 16
    FRAC_BITS = 12
    
    # 计算量化所能表示的真实浮点数范围
    max_val_f = (2**(BITS-1) - 1) / (2**FRAC_BITS)
    min_val_f = -(2**(BITS-1)) / (2**FRAC_BITS)
    print(f"Starting Quantization: BITS={BITS}, FRAC_BITS={FRAC_BITS} (Range: [{min_val_f}, {max_val_f}])")
    
    for f in files:
        input_path = os.path.join(param_dir, f)
        output_path = os.path.join(hex_dir, f.replace('.txt', '.hex'))
        process_file(input_path, output_path, BITS, FRAC_BITS)
        
    print("\nQuantization complete. Hex files are in 'params_hex/'.")
    print("These files can be loaded in Verilog using $readmemh.")

if __name__ == "__main__":
    main()

运行输出:

Starting Quantization: BITS=16, FRAC_BITS=12 (Range: [-8.0, 7.999755859375])
Quantized params/fc1_weight.txt -> params_hex/fc1_weight.hex (Hex)
...
Quantization complete. Hex files are in 'params_hex/'.

        至此,数据侧的准备工作彻底收官。我们拿到了可以在FPGA内部快速流转的16位纯净版参数包(以2的补码十六进制表示)。例如导出的 02a4,代表十进制的 676,对应真实的浮点权重就是 676 / 4096 ≈ 0.165


7 硬件架构设计 (Hardware Architecture Design)

        本阶段是项目的核心,目标是实现一个单时钟域、全硬编码 (ROM-first) 的硬件加速器。我们将避开复杂的 AXI 总线,专注于神经网络在 FPGA 上的数学计算与时序控制。

7.1 核心架构规划 (Architecture Strategy)

7.1.1 极简数据流

7.1.2 硬件规格 (Specification)

根据 mnist_train.py 的模型定义,硬件需支持以下 3 层全连接网络:

7.2 硬件架构详细说明 (Hardware Architecture Description)

        以下是整个硬件加速器的工业级 RTL 架构图,清晰展示了顶层模块 mnist_top 内部的组件例化关系、数据流(Data Path)与控制流(Control Path)的每一个信号连线:

fpga_mnist_arch

7.2.1 顶层接口 (Top-level Interface)

系统仅包含一组同步时钟域,接口定义如下:

module mnist_top (
    input  wire        clk,      // 全局唯一时钟 (驱动所有逻辑)
    input  wire        rst_n,    // 全局同步复位 (低电平有效)
    input  wire        start,    // 启动脉冲 (触发一次完整的推理流程)
    output wire        done,     // 完成标志 (计算结束且 digit 输出有效)
    output wire [3:0]  digit     // 最终识别出的数字结果 (0-9)
);

7.2.2 核心模块逻辑定义

  1. 全局控制器 (Global FSM)
    • 职责:负责启动神经网络的生命周期管理。
    • 流程:检测到 start 信号后,依次驱动 Layer 1、Layer 2 和 Layer 3 的计算逻辑。
    • 状态切换:当一层计算完成后,负责切换寻址逻辑并更新 layer_id 信号。
  2. 地址计算器 (Address Calculator)
    • 职责:根据当前处理的神经元索引和输入索引,实时计算存储器的物理地址。
    • 逻辑:根据 layer_id 的不同,分别计算读取 Image ROMActivation RAM 的地址,以及读取 Weight ROM 中对应权重的偏移量。
  3. 存储单元 (BRAM Storage)
    • 权重与图片:所有 109,184 个权重和 784 个图片像素点均以 16-bit 定点数形式存储在初始化的 ROM (BRAM IP) 中。
    • 中间缓存:使用一个大小为 128 (第一层神经元数) 的 Ping-Pong Buffer。计算第 N 层时,从 Buffer A 读,写回 Buffer B;下一层则反之。
  4. 计算核心 (PE Core)
    • 职责:核心 MAC (乘累加) 单元。
    • 逻辑:从存储单元读取 dataweight,执行一次乘法并累加到 acc_reg
    • 后处理:在一个神经元的所有输入累加完毕后,依次执行 ReLU 激活和饱和截断,最后输出 16-bit 定点结果。

7.3 系统运转时序 (System Workflow & Timing)

        理解数据在时钟驱动下如何“流动”是 RTL 设计的关键。以下是系统从启动到结束的典型时序流程:

  1. 触发阶段 (Trigger)
    • 外部拉高 start 信号一个时钟周期。
    • 全局控制器检测到信号,状态从 IDLE 跳转至 LAYER1_RUN,同时将 layer_id 设为 0
  2. 数据读取流水线 (Data Fetch Pipeline)
    • 地址计算器立即输出 img_addr = 0weight_addr = 0
    • 由于 BRAM 存在读取延迟,第 2 或第 3 个时钟周期后,第一个像素数据和第一个权重数据到达 PE 核心
  3. 神经元累加循环 (Neuron Accumulation)
    • PE 核心在每个时钟周期执行一次 $w \cdot x$ 乘法并累加到 acc_reg
    • 对于 Layer 1,该过程持续 784 个时钟周期。
  4. 激活与回写 (Activation & Write-back)
    • 当第 784 个输入累加完毕,PE 核心输出经过 ReLU 和量化后的 16-bit 结果。
    • 地址计算器给出 act_addr = 0,将该结果写入 Activation RAM (Buffer A)
  5. 层级迭代 (Layer Iteration)
    • 重复上述过程 128 次,完成 Layer 1 所有神经元的计算。
    • 全局控制器layer_id 切换为 1
    • 地址计算器开始从 Activation RAM (Buffer A) 读取输入,并将 Layer 2 的计算结果写向 Activation RAM (Buffer B)
  6. 输出与完成 (Finish)
    • Layer 3 计算结束后,状态机进入 DONE 状态。
    • 拉高 done 信号,并将最终识别出的数字索引锁定在 digit 总线上。

7.4 任务详细拆解

7.4.1 第一步:数据准备与 BRAM 配置

7.4.2 第二步:计算核心 (PE: Processing Element) 设计

7.4.3 第三步:地址生成器与状态控制 (FSM)

7.4.4 第四步:仿真验证 (Simulation)

7.5 架构权衡:面积 vs. 速度 (Area vs. Speed Trade-off)

维度 单 PE 方案 (本计划采用) 多 PE 并行方案
DSP 消耗 1 个 N 个
逻辑复杂度 低 (一套地址线) 高 (需数据分发与分库存储)
存储接口 单口 BRAM 即可 需多口 BRAM 或多库并行
建议 调通首选 性能优化首选

7.6 行动指南 (Action Items)

  1. 导出第一张测试图的 .coe 数据。
  2. 在 Vivado 中例化第一个 BRAM 并用仿真读出数据。
  3. 编写 pe_unit.v 并完成单元仿真。
  4. 编写 mnist_controller.v 整合寻址与 FSM。
  5. 编写 mnist_top.v 完成系统集成。
  6. 编写 Testbench 并通过功能仿真验证结果。

8 RTL 实现 (RTL Implementation)

        在执行阶段,我们完成了以下核心模块的设计与实现。

8.1 模块设计详解 (Module Design Details)

8.1.1 计算核心:pe_unit.v

8.1.2 指挥中心:mnist_controller.v

8.1.3 顶层集成与仿真模型

8.2 验证结论 (Verification Results)

8.3 实施记录与交付物 (Implementation Record)

步骤 交付文件 主要内容
数据准备 coe/generate_coe.py 权重与图片的量化转换脚本
  coe/weights.coe, weights.hex 16-bit Q4.12 模型参数文件
  coe/image.coe, image.hex 测试样本数据
RTL 实现 rtl/pe_unit.v 乘累加计算核心
  rtl/mnist_controller.v 全局状态机与地址生成逻辑
  rtl/mnist_top.v 顶层模块集成
验证体系 tb/simulation_models.v BRAM 与 RAM 的行为级仿真模型
  tb/mnist_top_tb.v 系统级冒烟测试平台

[!IMPORTANT] 设计原则:始终保持单时钟域设计,禁止在加速器内部产生分频时钟。



Back to Archive
WeChat QR Code

Scan to connect