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$组成。
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](这是一个经典的边缘/特征检测算子) 我们开始“滑动”计算:- 窗口对准
[0, 0, 255]: 计算:$(-1 \times 0) + (2 \times 0) + (-1 \times 255) = \mathbf{-255}$ - 窗口右移,对准
[0, 255, 0]: 计算:$(-1 \times 0) + (2 \times 255) + (-1 \times 0) = \mathbf{510}$ - 窗口再右移,对准
[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
- 检查Python状态:
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(多层感知机)时,我们有两种极端策略:
- 全并行:为每个神经元都分配独立的乘法器。优点:极快;缺点:FPGA 资源瞬间爆掉。
- 全串行:只有一个乘法器,循环计算所有连接。优点:省资源;缺点:太慢。
我们的选择:折中策略。 按层进行计算,每一层内部可以并行若干个神经元。由于 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 的推理结果一致。
- 仿真步骤:
- 准备一张图片的 hex 数据(例如
digit_5.hex)。 - 编写 Testbench,模拟时钟并循环输入像素。
- 通过 Vivado 的
Tcl Console运行仿真:run all。
- 准备一张图片的 hex 数据(例如
- 综合与布线:
在 Vivado 中点击
Run Synthesis,并重点查看 Utilization Report。确保 DSP48 和 BRAM 的占用率在合理范围内。
[!IMPORTANT] 关键细节:由于我们使用的是 8 位定点化,累加过程中会产生位宽扩展(16位、24位甚至32位)。在输出到下一层之前,必须进行舍位与饱和处理,否则数据会因为溢出而完全失效。
总结:第四阶段是将“数学公式”变成“时序电路”的过程。理解了 时钟驱动下的数据流动,你就真正掌握了 FPGA 神经网络加速器的秘诀。