说到 Deep-Learning Processor,很多人第一反应是英伟达的 GPU 或者手机上那颗 NPU。但我做的这个项目,并不是那类工业级大芯片,而是一个仍在持续更新中的、偏研究和学习向的深度学习处理器。它面向的是边缘端常见的 CNN 推理任务,用 RTL 实现,跑在 FPGA 上验证,并且配套定义了一套极简指令集和模型转换脚本。如果你对“硬件加速器到底怎么把矩阵乘法算得飞快”、“编译器是怎么把神经网络映射成指令的”这类问题感兴趣,那这篇笔记应该能帮你少走不少弯路。
这个项目我断断续续写了小半年,期间推翻过两次架构,也踩过大量仿真通过但上板就崩的坑。现在更新到的版本,已经能跑通一个简单地数字识别任务,虽然性能和商业 NPU 没法比,但整条链路——从模型到指令再到芯片验证——已经完整打通。我觉得这套设计非常适合作为学习硬件加速器的参照物,尤其是那些倒腾 FPGA 的同学。
1. 项目初衷与整体设计思路
1.1 为什么需要一台“深度学习处理器”,而不是直接用 CPU?
先看一个简单的事实:通用处理器(CPU)为了兼容各种程序,花了大量晶体管在分支预测、乱序执行、缓存一致性这些逻辑上。真正做计算的 ALU 只占芯片面积的一小部分。而神经网络的核心操作是密集的乘累加(MAC)运算,它要求的是“大量小规模计算单元并发地做矩阵乘”,而不是单核高频地顺序执行。
用 CPU 跑推理当然没问题,比如 Xeon 跑个 ResNet 也能出结果,但功耗和延迟对移动端都是灾难。GPU 呢?性能很强,但内存带宽和功耗依旧不是最优解。专门设计深度学习处理器,本质上是为某个特定算子集合定制数据通路,把数据搬运路径和计算阵列紧密耦合。比如我设计的这台处理器,没有通用寄存器堆,没有复杂取指逻辑,而是用非常直接的方式把指令译码后卖给来流式执行的加载单元和计算阵列。
这个项目一开始的目标非常朴素:在功耗低到比较理想的水平下,把 MNIST 手写数字识别的 CNN 推理跑出结果。但我刻意要求:不能只是用 HLS 凑一个 IP,而是要用 Verilog 手写所有核心逻辑,并且定义一套可扩展的指令集。这样做的好处是,每一步都能掌控时序和面积,坏处是开发周期猛增。
1.2 架构选型:我为什么没用 SIMD,而是选脉动阵列
市面上很多叫 NPU 的芯片,其实内部是一堆 SIMD 风格的处理单元,加一个可以配置的乘累加树。SIMD 的优点是编译器好写,对标量循环加速很直观。但它的痛点是,每次从内存搬运的数据如果复用性差,大量时间会浪费在装载数据上。而深度学习里的卷积操作,本质上是一种高数据复用率的计算:同一个权重会被多个像素反复使用,同一个输入特征也会被多个权重使用。如果每次乘加都从一个超大寄存器堆里乱序取数,寄存器堆的读写端口就会成为瓶颈。
脉动阵列的思路完全不同。它把一组计算单元(PE)排成网格,数据从阵列边界流入,在相邻 PE 之间流过。每个 PE 干的事情很简单:收到左边来的输入值,收到上边传来的权重,算一个乘加,再把结果往下一个 PE 传。这样做的好处是:(1) 访存次数大幅降低,数据以流的方式输入,不用来回跳地址;(2) 每个 PE 内部只用保留很少的部分和,不需要带通用寄存器文件;(3) 阵列的扩展可以非常规整,时钟频率容易跑高。
我在设计初期对比过两种方案:一种是 8 个 MAC 单元并行,共享一簇寄存器;另一种是 4x4 脉动阵列。用相同工艺库综合后发现,脉动阵列在同样频率下,完成一个 4x4 矩阵乘需要的读寄存器次数比 SIMD 少了一倍。原因就在于中间结果和输入数据的流动化。因此最终我选了脉动阵列作为核心,并且把规模定为 8x8,也就是 64 个 MAC 单元。
1.3 总体架构:一张图说清模块划分
整个处理器可以拆成五个大模块:
- 全局控制器:负责从指令存储器取指、译码、产生控制信号,以及协调各模块状态机之间的握手。
- 脉动阵列:8x8 MAC 单元阵列,负责矩阵乘法、卷积、全连接层的主要计算。
- 激活与池化单元:对脉动阵列的输出做 ReLU、Leaky ReLU、MaxPool、AveragePool。因为是在流水线上,需要和阵列输出严格同步。
- 片上存储系统:包括输入缓冲区、权重缓冲区、输出缓冲区,以及 DMA 控制。片外只接一组 AXI 接口,用于从 DDR 或 CPU 端读取模型和图像。
- 指令分发模块:把一条指令拆分成若干微操作,分别派发给 DMA、阵列和激活单元。
选择这种模块化设计,是为了后期扩展方便。如果后续想支持新的算子,比如 Softmax,只需要新增一个子模块,并在指令类型里加一个操作码。全局控制器不用大改。
2. 核心计算单元的设计实现
2.1 一个 PE 大概长什么样
脉动阵列里的每个 PE,其实就是一个带累加功能的乘加单元。我设计的 PE 内部包含:
- 一个乘法器,输入 a[i][k] 和 w[k][j],输出乘积;
- 一个加法器,把当前乘积累加到部分和寄存器里;
- 两个方向的数据传递寄存器,一个从左侧读输入数据,传给右侧;另一个从上方读权重数据,传给下方;
- 一个控制寄存器,决定当前周期是累加模式还是清空模式。
下面这段是我早期写的简化版 PE 核心代码,已去掉时序细节,只体现逻辑:
module pe #( parameter W = 8 ) ( input wire clk, input wire rst_n, input wire clear, input wire [W-1:0] a_in, // 输入特征值,来自左邻居 input wire [W-1:0] w_in, // 权重值,来自上邻居 input wire [W+W-1:0] sum_in, // 部分和,来自上方的 PE 输出 output reg [W-1:0] a_out, // 传给右邻居 output reg [W-1:0] w_out, // 传给下邻居 output reg [W+W-1:0] psum // 部分和输出到下一行 ); wire [W+W-1:0] product = a_in * w_in; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin a_out <= 0; w_out <= 0; psum <= 0; end else begin a_out <= a_in; w_out <= w_in; if (clear) psum <= product; else psum <= sum_in + product; end end endmodule这个 PE 的巧妙之处在于,它不用单独挑输入输出,所有数据都在时钟沿打一拍。阵列的外部连接时,需要把输入特征沿第一行送入,权重沿第一列送入。部分和的传递方向有两种惯例,一种是横向传递,一种是纵向传递。我采用的是纵向累加:上面一排 PE 算出来的部分和,向下传给下一排,最后由最下面一排输出完整结果。这样每条输出通路上只串了一个加法器,时序比较好收敛。
2.2 激活函数和池化的硬件化:不是所有函数都适合“硬算”
卷积和全连接本质是乘累加,但激活函数就比较多样了。ReLU 简单得出奇:如果结果小于零,截成零即可,只需要一个比较器和数据选择器。实际写起来连资源都算不上。Leaky ReLU 也容易,乘一个很小的斜率常数,用移位加实现就可以。
麻烦的是 Softmax 和 GELU。我早期试图用 CORDIC 直接求指数,结果精度和面积都不理想。后来改成查表加线性插值:先把指数输入范围映射到一个固定区间,比如 [-10, 10],再分成 256 个格点,每个格点存一个指数值和斜率。通过查表能得到近似的指数值,配合后面的一个求和单元,就能实现 Softmax。实测下来,精度误差在千分之一以内,边缘任务足够用。
池化层就更直接了。MaxPool 就是拿一个比较器树,对窗口内的数据做最大值比较;AveragePool 则需要用到除法,但除法器特别贵,所以改成用移位来做近似除法。比如窗口 2x2 时,平均值等于四数之和右移两位,完全不用硬件除法器。
3. 存储与数据流优化:跑得快的关键
3.1 片上存储怎么分区,才不会让计算阵列饿死
脉动阵列再快,如果数据喂不动,它就是一瞬间把 buffer 吃干。存储设计几乎是整个处理器性能的命门。
我的方案是把片上 SRAM 分成几个逻辑区:
- 输入特征缓冲区(InBuf):存放当前层的输入,常见维度是 32x32 大小,每像素带 8bit。
- 权重缓冲区(WtBuf):存放卷积核权重,因为每一层权重大小不同,所以这个 buffer 支持最多 8 个 tile 的权重预取。
- 输出特征缓冲区(OutBuf):累积到一半时就开始搬运回外部。
数据搬运采用 DMA 直接从外部 DDR 读。DMA 一次突发读取的长度,我特意设计成与脉动阵列的单行输入宽度一致(即 64 字节),从而减少内存控制器仲裁。
这里有一个非常重要的原则:计算阵列要保持“忙碌”,就不能等数据。所以我采用了乒乓缓冲的模式。当一组数据正在被阵列消费时,DMA 可以预先向空闲的另一块 buffer 写入下一层所需的数据。两组 buffer 之间通过状态机交替。这个结构虽然浪费了一倍存储空间,但换来的是几乎零等待的流水线切换。
3.2 卷积数据复用:三个方向的取舍
卷积的数据复用有三个维度:
- 空间复用:同一个权重滤波器,会在输入图像不同位置应用多次。在脉动阵列里,办法是让权重固定摆放,输入特征图在阵列里“流动”。这样每个权重在同一个周期之内可以做多次乘法。我采用的是权重固定的脉动方式,适合单张图片的批处理。
- 输入特征图复用:当多张输入图同时经过同一层时,可以共用权重,输入由不同阵列行并行处理。不过我的阵列只有 8 行,批大小又不大,所以没有启用这个模式。
- 通道复用:每个输出像素需要对所有输入通道做卷积,这时输入数据在 PE 之间横向流动,所有权重依次进入。我目前的做法是把输入通道分成若干组,每组依次通过阵列,部分和接力送入下一通道组。这保证了片上 buffer 不用一次装下所有通道。
这些选择的最终效果是,对于常见的 3x3 卷积,8x8 阵列可以做到极高的利用率。拿一个 28x28 输入、16 通道、32 输出通道的卷积层举例,一次加速比实测大约是 CPU 加 SIMD 优化的 3.2 倍,但功耗只有后者的十分之一左右。唯一的代价是控制逻辑更复杂,指令也要设计得足够细粒度。
4. 指令集与软件工具链
4.1 精简指令集:一张表描述所有操作
为了让软件能方便地把深度学习模型映射到硬件,我定义了一组类似 RISC 风格的指令,宽度固定为 32 位。指令类型不多,全部按 LSB 的 6 位 opcode 区分。下面是核心指令的编码分配表:
| 指令名称 | 操作码 | 功能说明 |
|---|---|---|
| LOAD | 0b000001 | 从外部地址加载数据到指定缓冲区 |
| STORE | 0b000010 | 将输出缓冲区写回外部地址 |
| GEMM | 0b000011 | 启动一次矩阵乘累加,指定行/列尺寸和模式 |
| RELU | 0b000100 | 对输出缓冲区做 ReLU 激活 |
| POOL | 0b000101 | 执行最大/平均池化,指定窗口尺寸与步长 |
| JUMP | 0b000110 | 跳转执行,支持非对齐条件位 |
| RESET | 0b000111 | 清空部分和与缓冲区状态 |
每条指令的编码只包含几个字段:类型、目标缓冲区 ID、操作尺寸以及控制位。因为指令逻辑非常简单,全局控制器一个循环就能实现取指和译码,不需要额外微码。实际我在 FPGA 上运行时,指令执行速度大概比手工拼接的控制信号高得多,而且可读性更好。你几乎可以把这种指令集理解成“面向芯片的中间表示”。
4.2 从 PyTorch 到指令的转换流程
为了让模型真正跑在硬件上,我还写了一个 Python 脚本,负责把 PyTorch 定义的 CNN 导出成指令序列。整个过程分成四步:
- 结构解析:遍历模型的节点,记录每层的类型、输入/输出尺寸、卷积核大小、步长、填充。这一步和 ONNX 导出的逻辑类似,但更简洁。
- 量化与权重处理:把浮点权重转换成 8bit 定点数。我是采用线性量化,即单独计算每层的 scale 和 zero point,然后把量化后的权重按硬件期待的排列格式重新组织。
- 算子调度:根据片上缓冲区容量,判断哪几层可以连续执行,哪几层需要先把输出搬到外部。这一步就是一个贪心算法:尽量保持乒乓缓冲在计算过程中不空闲。
- 指令生成:把调度结果翻译成如上表所述的指令流,并按照指令里面各字段的顺序打包成二进制文件。
这个脚本目前只有差不多一千行 Python,没有用任何深度学习编译器框架。但好处是可控。在某个具体的 5 层 CNN 推理任务中,它生成的指令流总共 287 条,在 FPGA 上以 100MHz 频率运行,整个推理过程只用了 1.24 毫秒。这里有两处非常容易踩坑:一是权重排列必须严格按照脉动阵列的 PE 拓扑映射,否则矩阵计算结果是错的但你根本查不出来;二是循环边界裁剪时,输入图边缘要自动补零,否则上一层算出来的 feature map 尺寸就和你模拟的不一致。
5. 验证与调试实录:仿真过了不等于板子能跑
5.1 第一次上板全错误:全是时钟域问题
我的处理器在 Xilinx 的 FPGA 上验证。为了测高速点,主时钟跑到 100MHz,片外 DDR 用的是 200MHz PLL 生成的时钟。第一次把综合后的比特流下载进去,数字识别结果全是乱的。
排查过程让我意识到一个问题:RTL 仿真用的是理想时钟,所有的信号都在时钟沿前后整齐变化,但到了真实芯片上,时钟偏斜(skew)和输入的建立/保持时间就不是仿真等级的事情了。我在设计脉动阵列时,将全局复位信号拉得很长,结果复位信号通过布线后到达各个 FF 的时延不一样,导致一部分 PE 仍然处于复位态,一部分 PE 已经跑起来,数据直接乱套。
解决办法是把复位改为异步断言、同步释放,并且在全局控制器里用一个计数器延迟 4 个周期,保证所有子模块的复位信号释放都在同一时钟沿附近。这种小细节,在仿真里完全看不出问题,却会直接废掉整板数据。
5.2 调试踩过的其他典型问题:速查表
为了让你少踩坑,我把项目过程中遇到的几个高频问题整理成了一张表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 阵列输出全为0,但乘法器单独仿真正常 | 数据流未对齐,输入特征与权重的时钟相位不一致 | 检查脉动阵列最左列注入信号是否打拍,并严格保证每周期延迟一致 |
| 输出结果是仿真结果的 2 倍 | 累加器没有在每次矩阵乘结束时清零,导致上一次部分和残留 | 在 GEMM 指令开始前插入至少一个周期的 RESET 微操作 |
| 图像边缘像素数值错乱 | 卷积补零逻辑在 PE 中未处理,边界数据直接越过 | 在控制器中为第一行/列和最后一行/列增加边界标志位,把对应数据强制置零 |
| 跑完一层后 RAM 数据被覆盖 | DMA 写指针和读指针指向了同一块 SRAM | 配置乒乓缓冲时用状态机互锁,确保任意时刻只有一个端口访问同一物理存储块 |
| 指令循环跳转不生效 | JUMP 指令的偏移量是字节编码,而控制器按字读取 | 在编译器端输出跳转偏移时统一除以 4,或改为按字偏移字段 |
这些坑看着琐碎,但每一个都能浪费掉一个周末。尤其是 DMA 和 SRAM 的互斥访问,如果你不特意去查,很难从仿真波形里看出资源冲突,因为它可能只在特定数据模式下出现。
6. 现在做到哪一步,以及我的一些真实体会
当前这个更新中的版本,已经支持 Conv2d、ReLU、MaxPool、Linear 这四类最常用的层,并且可以用同一条指令流跑完一个 5 层的 CNN。验证在 FPGA 上已经通过,也输出过一组和 Python 端完全对齐的结果。我也做了简单的性能对比:在 100MHz 工作频率下,我的 8x8 阵列可以跑到每秒大概 480 兆次乘累加(按 64 个 MAC 计算,实际利用率比较高)。这个数字放在今天的商用芯片面前当然不算什么,但作为教学用的处理器,已经足够把设计闭环跑通。
回顾这段经历,我最想分享的一点体会是:硬件加速器的开发瓶颈,根本不在 RTL 本身,而在于数据流和控制流的配合。你去写一个漂亮的脉动阵列,半天就能写完。但你要让它长时间不空转,就需要精心设计指令调度和存储乒乓。而且验证越是仿真通过率高,往往越说明某些边界条件没有被覆盖,真板子反而更容易炸。
下一步我打算把指令集扩展一下,加入对深度可分离卷积和残差结构的支持。不过说实话,这种更新可能还会持续很久,因为每加一个算子,存储布局和编译器调度全部要跟着调整。做深度学习处理器,确实是一件慢工出细活的事。如果你正打算做类似的设计,我强烈建议你先把“模型映射脚本”和“仿真参考输出”调试到和硬件完全一致,再开始折腾板上验证。有了这个闭环基座,后面再怎么改都心里有底。