从逻辑门到深层神经网络:用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)就是层数很深的神经网络,本质一样,就是隐藏层更多。一般约定:

  • 1个隐藏层:浅层神经网络
  • 2层以上隐藏层:深度神经网络

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

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

        深度越深,能力越强:

  • 数学角度:多层非线性函数≈更复杂的函数族
  • 工程角度:可以把复杂问题拆为多步简单变换,每一层做“稍微抽象一点”的处理

        常见深度神经网络家族:

  • CNN(卷积神经网络)
    • 图像、视频
  • RNN/LSTM/GRU
    • 时间序列、语音
  • Transformer
    • NLP/多模态
  • MLP(多层全连接)
    • 最基础的DNN

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

2 训练与推理

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

  • 结构上的对称性:从宏观拓扑上看,训练和推理使用相同的网络框架(如层数、每层神经元数量等)。
  • 计算路径的区别:
    • 训练 (Training):是一个双向过程。除了前向计算,还需要一套极其复杂的“反向传播”路径来计算梯度(误差),并不断修正参数。
    • 推理 (Inference):是一个纯粹的单向前向过程。
  • 本文重点:FPGA 加速器的核心是实现高性能的推理引擎。因此,本文及后续实战将仅聚焦于推理(Forward Pass)部分,即如何让固定的参数在硬件上飞快运行。

3 神经元结构

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

neuron_mac_unit

3.1 权重 (Weights)

  • 数值来源:权重不是我们手动计算的,而是“训练”阶段产生的。它们是电脑通过海量数据迭代学到的最优系数文件
  • 连接细节:这是理解网络规模的关键——每一级神经元的每一个多比特输入,都拥有一个唯一且对应的权重值
  • 存储压力:在 FPGA 中,这些权重通常以定点数形式存放在 BRAM 或片外 Flash 中。
    • 注:训练产生的原始权重通常是 32 位浮点数(Float32),但为了硬件效率,我们会在后续步骤中通过“量化”将其转换为 8 位或 16 位定点数。

3.2 计算单元 (Computing Unit)

  • 计算公式:神经元的核心计算逻辑遵循公式:$y = \sum_{i=1}^{n} (x_i \cdot w_i) + b$。
  • FPGA 实现:
    • DSP Slices:利用 FPGA 内部专用的 DSP 资源(如 Xilinx 的 DSP48)来实现高效的乘法运算。
    • 加法树/累加器:将多个乘法结果通过逐级加法器或时间上的累加器合并。对于扇入(Fan-in)巨大的情况,通常采用分时复用技术来节省硬件面积。
  • 硬件权衡(并行与串行):
    • 高并行(追求速度):为每个乘法都分配一个硬件乘法器。优点是吞吐量极高,缺点是资源消耗巨大。
    • 时间复用(节省面积):用少量的计算单元,通过高频时钟循环处理所有输入。这是 FPGA 设计中最核心的权衡艺术。

3.3 偏置 (Bias)

  • 数值来源:与权重一样,偏置值也是由训练过程确定的。
  • 接入位置:偏置值被加在所有乘积之和($\sum_{i=1}^{n} (x_i \cdot w_i)$)之后,但在送入激活函数处理之前
  • 物理内涵:它相当于给计算结果增加了一个可调的“直流分量”,用于修正判定阈值。在硬件设计中,它通常作为加法累加器的初始值输入。

3.4 激活函数 (Activation Function)

  • 作用:引入非线性,决定神经元是否“兴奋”。
  • 硬件实现 (以 ReLU 为例):
    • ReLU 逻辑为 $f(x) = max(0, x)$。在硬件中,这本质上是一个基于符号位的截断逻辑
    • 实现细节:实时监控累加结果的最高位(MSB)。若 $MSB=1$(代表负数),通过一个 Mux 或与门强行将输出置 0;若 $MSB=0$,则原样透传。

4 网络的结构

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

4.1 全连接网络 (Fully Connected Network)

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

  • 优点:结构简单,逻辑直观,具备“全局视野”,能捕捉图片中跨度较大的特征关系。
  • 缺点:参数量爆炸。对于 28x28 的图像,若第一层有 512 个神经元,则需存储约 40 万个权重。这会产生极高的存储成本和带宽压力。
  • 使用场景:通常用于网络的最后几层,负责将提取出的抽象特征汇总,输出最终的分类决策。

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

4.2 卷积网络 (Convolutional Network)

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

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

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

  • 输入信号 (X):[0, 0, 255, 0, 0]
  • 卷积核 (K):[-1, 2, -1](这是一个经典的边缘/特征检测算子) 我们开始“滑动”计算:
    1. 窗口对准 [0, 0, 255]: 计算:$(-1 \times 0) + (2 \times 0) + (-1 \times 255) = \mathbf{-255}$
    2. 窗口右移,对准 [0, 255, 0]: 计算:$(-1 \times 0) + (2 \times 255) + (-1 \times 0) = \mathbf{510}$
    3. 窗口再右移,对准 [255, 0, 0]: 计算:$(-1 \times 255) + (2 \times 0) + (-1 \times 0) = \mathbf{-255}$

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

4.2.2 卷积层优缺点

  • 优点:
    • 权值共享:全图复用同一个nxn卷积核,参数量从几十万骤减至个位数,对FPGA的BRAM极其友好。
    • 局部感应:非常擅长提取线条、纹理等局部几何特征。
  • 缺点:硬件实现复杂。由于像素是流式输入的,为了凑齐 3x3 窗口进行卷积,我们需要设计行缓存 (Line Buffer) 来存储前两行数据,并配合精密的时序控制逻辑来管理滑动窗口,这比简单的全连接层要消耗更多的控制逻辑。
  • 使用场景:图像识别、视频处理的前端。它像是一个高效的过滤器,把原始像素“脱水”成高级特征。

4.2.3 池化层 (Pooling Layer)

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

  • 硬件实现:在硬件眼里,池化比卷积简单得多。它不需要乘法,只需要一个比较器
  • 逻辑逻辑:例如在一个 2x2 的窗口里,找出 4 个像素值中最大的那个输出。这在电路中就是一个简单的状态机加几个比较逻辑,能显著减少后续层级的计算压力。

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

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

  • Argmax 单元:在训练阶段,我们需要用 Softmax 函数将得分转为概率以计算损失。但在推理阶段,我们只关心谁的得分最高。因此,在硬件实现中,我们直接舍弃高成本的 Softmax 指数运算,代之以一个简单的 比较器树 (Comparator Tree),找出这 10 个数值中最大的一项,其对应的索引号(Index)即为识别出的数字。

4.4 架构对比总结

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

5 算法训练和导出

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

  • 计算:是 MAC 单元的流水线深度。
  • 存储:是权重在 BRAM 中的布局。
  • 非线性:是符号位的截断与比较器的组合。

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

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

5.1 软件与系统环境准备

        系统环境和安装步骤:

  • 系统:OpenSUSE Leap 15.5。
  • 硬件:
    • CPU:Westmere E56xx/L56xx/X56xx 系列(多核处理器)。
    • 内存:8GB RAM。
  • 安装步骤与指令:
    • 检查Python状态:
       python3 --version  # 确保 Python 版本在 3.8 以上
      
    • 安装PyTorch(CPU版本):
       # 安装 PyTorch 和 Torchvision,指定 CPU 版本的下载源以节省空间
       pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
      
    • 安装数据处理工具:
       # 安装用于矩阵处理和参数导出的 Numpy
       pip install numpy
      

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

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

  • 层级配置:784 (Input, 8-bit Uint) -> 128 (Hidden1) -> 64 (Hidden2) -> 10 (Output)。
  • PyTorch代码实现结构:
    import torch.nn as nn
    
    class SimpleMLP(nn.Module):
        def __init__(self):
            super(SimpleMLP, self).__init__()
            # 定义三层全连接层:映射关系直接对应硬件中的 MAC 阵列
            self.fc1 = nn.Linear(28*28, 128) # 权重矩阵: [128, 784]
            self.fc2 = nn.Linear(128, 64)    # 权重矩阵: [64, 128]
            self.fc3 = nn.Linear(64, 10)     # 权重矩阵: [10, 64]
            self.relu = nn.ReLU()            # 激活函数:硬件实现仅需一个比较器 max(0, x)
    
        def forward(self, x):
            # 数据流路径:描述了像素数据如何在硬件流水线中流动
            x = x.view(-1, 28*28)          # Flatten: 展平。本质上是把二维 BRAM 寻址转为一维流式数据
            x = self.relu(self.fc1(x))
            x = self.relu(self.fc2(x))
            x = self.fc3(x)
            return x
    

5.3 模型训练与数据集获取

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

  • 载入数据:自动从官网下载MNIST数据集并进行归一化(0-255映射到 0-1)。该数据集的原始官方发布地址是:http://yann.lecun.com/exdb/mnist/。
  • 定义损耗(Loss):使用CrossEntropyLoss,衡量预测值偏离真实值的距离。
  • 训练循环:
    • Forward:数据输入模型,计算输出。
    • Backward:计算误差对权重的偏导数(梯度)。
    • Update:优化器(Adam)根据梯度微调权重。

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

  • 启动训练
    python3 mnist_train.py
    

    训练过程中会实时打印 Loss,最终输出 Test Accuracy: 97.63%

  • 生成参数镜像
    python3 export_weights.py
    

    此操作会加载训练好的 mnist_model.pth,并将二进制权重转换为文本格式。

5.4 参数导出结果说明

  • 存储路径:ai/params/
  • 文件清单:
    • fc1_weight.txt, fc1_bias.txt:第一层的权重与偏置。
    • fc2_weight.txt, fc2_bias.txt:第二层的权重与偏置。
    • fc3_weight.txt, fc3_bias.txt:第三层的权重与偏置。
  • 数据排列:每个文件中的数值按行存放,对应矩阵展开后的序列,方便 Verilog 通过 $readmemh$readmemb 读取。

5.5 存储格式预备

        导出的 .txt 浮点数文件是下一步“量化”的输入。在第三阶段,我们将通过量化脚本分析这些权重的分布范围,将其从 Float32 压缩为 Int8 或 Int16,以适配 FPGA 的 DSP48 单元。


6 FPGA 硬件实现 (RTL Implementation)

        到了这一步,我们正式从“炼丹炉”进入了“加工厂”。硬件实现的本质就是用有限的面积(Logic Cells/DSPs)去换取最大的速度

6.1 设计理念:层级复用与流水线

        在实现 MLP(多层感知机)时,我们有两种极端策略:

  1. 全并行:为每个神经元都分配独立的乘法器。优点:极快;缺点:FPGA 资源瞬间爆掉。
  2. 全串行:只有一个乘法器,循环计算所有连接。优点:省资源;缺点:太慢。

我们的选择折中策略。 按层进行计算,每一层内部可以并行若干个神经元。由于 MNIST 模型较小,我们可以设计一个通用的 MAC 引擎 (Multiplier-Accumulator),通过状态机(FSM)切换不同的权重地址,实现对 784 -> 128 -> 64 -> 10 结构的顺序处理。

6.2 核心模块:神经元 MAC 引擎

        在 Verilog 中,一个神经元的核心逻辑就是一个“乘累加”单元。

// 核心逻辑片段:累加器
always @(posedge clk or posedge rst) begin
    if (rst) begin
        accumulator <= 32'd0;
    end else if (state == CALC) begin
        // 定点数乘法:8-bit * 8-bit = 16-bit
        // 累加:需要更宽的位宽防止溢出
        accumulator <= accumulator + ($signed(weight) * $signed(pixel_in));
    end else if (state == BIAS) begin
        // 最后加上偏置项
        accumulator <= accumulator + ($signed(bias) << 7); // 注意对齐定点数的小数位
    end
end

6.3 存储管理:BRAM 的加载

        我们在第三阶段生成的 .hex 文件终于派上用场了。利用 Verilog 的 $readmemh 指令,我们可以非常优雅地初始化 FPGA 内部的 BRAM (Block RAM)

  • 具体命令
    reg [7:0] weight_rom [0:999]; // 定义 8 位宽的存储区
        
    initial begin
        $readmemh("fc1_weight.hex", weight_rom); // 仿真和综合阶段都会自动加载
    end
    

6.4 激活函数与输出判决

  • ReLU 实现:在硬件中,ReLU 就是一个简单的“符号位截断”。如果累加结果最高位为 1(负数),则输出 0;否则保持原值。
    assign relu_out = (accumulator[31] == 1'b1) ? 8'd0 : accumulator[14:7]; // 截取小数位并舍入
    
  • Argmax 树:最后 10 个数字进入一个比较器树,通过两两比较,最终输出得分最高的索引值。

6.5 仿真与 Vivado 流程

        在正式烧录前,Testbench 是必不可少的。我们需要在仿真环境中模拟像素流的输入,观察输出信号是否与 PyTorch 的推理结果一致。

  • 仿真步骤
    1. 准备一张图片的 hex 数据(例如 digit_5.hex)。
    2. 编写 Testbench,模拟时钟并循环输入像素。
    3. 通过 Vivado 的 Tcl Console 运行仿真:run all
  • 综合与布线: 在 Vivado 中点击 Run Synthesis,并重点查看 Utilization Report。确保 DSP48 和 BRAM 的占用率在合理范围内。

[!IMPORTANT] 关键细节:由于我们使用的是 8 位定点化,累加过程中会产生位宽扩展(16位、24位甚至32位)。在输出到下一层之前,必须进行舍位与饱和处理,否则数据会因为溢出而完全失效。


总结:第四阶段是将“数学公式”变成“时序电路”的过程。理解了 时钟驱动下的数据流动,你就真正掌握了 FPGA 神经网络加速器的秘诀。


Back to Archive