最近在调一块FPGA开发板上的深度学习推理加速器,写到一半实在忍不住想把踩过的坑记下来。这个小项目代号叫Deep-Learning Processor,一直处于“更新中”的状态——设计、验证、回炉、再验证,差不多是家常便饭。目前已经跑通了几个关键算子,距离最初设定的目标指标还有一段距离,但整个架构的骨架算是立住了。
这篇文章会把这套处理器当前的设计思路、指令集与微架构取舍、算子映射的细节、软件栈的配套方案,以及最近踩过的一些实坑都整理出来。既是给自己做阶段性的总结,也希望能给准备接触深度学习处理器设计的朋友一条相对完整的参考路径:从为什么要自己动手,到怎么把卷积变成指令,再到怎么把模型真正跑起来。
1. 为什么自研深度学习处理器:从一次不愉快的部署说起
1.1 通用处理器跑CNN的瓶颈到底在哪
事情起源于一个很朴素的需求:想在一块功耗有限的嵌入式设备上跑一个中轻量级的卷积神经网络,做实时图像分类。一开始图省事,直接用了某个通用处理器上的推理库,结果一张 224×224 的输入图,单次推理延迟大概在 180 毫秒左右,而且处理器满载、内存带宽吃紧,稍微再跑一路视频流就直接掉帧。
这个结果其实不意外。CPU 擅长的是复杂控制流和标量运算,但CNN的核心计算几乎都是规则的乘累加(MAC),数据搬运量大、并行度高,通用处理器为了兼容各种任务,在寄存器数量、指令调度、缓存层次上都做了太多“折中”。对纯卷积而言,很多周期其实花在了指令发射、地址计算和数据搬移上,而不是真正在做乘法。
我当时把这些开销粗算了一笔:一个 3×3 卷积在 CPU 上展开后,访存指令和算术指令的比例经常能接近 1:1,甚至更难看。也就是说,每做一次乘累加,差不多要额外消耗一个周期去取数和算地址。换个专用架构,把这些固定开销摊到大量的并行 MAC 单元上,延迟是可以压缩一个数量级的。
1.2 为什么选FPGA做原型而不是直接流片
明确了要往专用加速方向走,接下来就是平台选择问题。当时排除了两个选项:
- ASIC 流片:性能上限最高,但周期和成本完全不是个人或小团队能承受的,一次失败的改版代价太高,不适合验证架构;
- GPU:虽然算力强,但功耗、体积和价格都不符合嵌入式场景,而且在很小的模型上延迟反而受限于调度开销;
FPGA 成了最合理的中间选项。它的逻辑资源可以按照自定义数据通路来布线,接近 ASIC 的自由度,又能随时改代码重新综合,迭代周期控制在小时级。现在的很多中高端 FPGA 开发板还自带高速存储接口和多通道收发器,足够跑一个不大不小的推理加速原型。
选 FPGA 还有一个隐形好处:RTL 设计一旦在真实硬件上验证通过,后续想把它改造成 ASIC,绝大部分微架构代码是可以直接沿用的,只是替换掉底层单元库和时钟方案而已。所以这个“原型先行”的路线,后续退路也是清晰的。
1.3 项目定位与目标指标
为了避免项目变成“什么都想做、什么都做不成”的烂尾工程,我在最开始就给这个深度学习处理器定了几条硬指标:
- 目标场景:面向边缘推理,模型以 CNN 为主,支持常见分类网络结构;
- 量化精度:以 INT8 定点为主,INT16 作为兜底选项;
- 峰值算力:目标 200 GMAC/s 左右,对应一块中端 FPGA 上铺大约 1024 个 MAC 单元、跑到 200MHz 主频;
- 存储规划:片上 SRAM 总量 4MB 左右,尽量让中间特征图留在片内;
- 配套软件:必须有一个能自动把模型计算图映射成指令的编译器,纯手写汇编不现实。
这几条指标不算激进,但每一项背后都带着具体的约束。比如 4MB 片上存储,是参考了几个轻量级模型中间特征图大小之后拍出来的;200 GMAC/s 的目标算力,也是根据“中端 FPGA 的逻辑规模和可达到的时钟频率”反复估算出来的。先把边界定住,后面做架构设计时才不会没完没了地扩需求。
2. 指令集与微架构:最花时间的一层
2.1 指令集设计:为什么用自定义VLIW而不是RISC-V向量扩展
指令集是整个处理器的灵魂,也是整个项目里我推翻次数最多的部分。最开始考虑过直接用 RISC-V 的向量扩展(V 扩展)作为基础,这样能蹭到一大堆现成工具链,但后来发现这条路对专用加速器来说未必划算。
V 扩展的设计偏向通用向量计算,有完整的元素长度编码、mask 控制、异常处理等机制,这些都会增加译码和流水线的复杂度。而一个 CNN 加速核真正需要的指令其实非常有限:加载数据、做矩阵乘累加、做激活、做池化、搬数据。为了两个主要算子去支持一套通用向量 ISA,有点用牛刀杀鸡。
最终选择了自定义的 VLIW(超长指令字)架构。每条指令被打包成 128 位,内部同时编码多个微操作:MAC 阵列的启动、DMA 搬运的触发、激活函数的类型、池化窗口参数等。这样做的好处是硬件译码非常简单,指令里的每个字段直接对应某个功能单元的配置寄存器,省去了复杂的乱序调度逻辑。
代价也很明显:编译器必须负责所有并行调度。也就是说,能不能把 MAC 运算和下一次 DMA 搬运重叠起来,全看编译器有没有提前分析出数据依赖并排好指令顺序。这正是软件栈部分需要重点投入的原因,后面会专门讲。
2.2 顶层微架构:数据通路如何组织
当前版本的处理核采用了比较经典的“标量控制 + 向量数据通路”两级结构。我把顶层拆成四个主要模块:
| 模块 | 职责 | 关键参数 |
|---|---|---|
| 标量控制核 | 指令译码、循环控制、执行流程管理 | 支持简单分支与循环指令 |
| MAC 阵列 | 完成矩阵乘、卷积的核心计算 | 16×64 阵列,共1024个MAC单元 |
| 向量功能单元 | 激活、池化、元素级乘加 | 支持ReLU/ReLU6、最大/平均池化 |
| 存储与搬运 | 片上SRAM管理、片外DMA传输 | 总存储4MB,分权重/输入/输出三块 |
MAC 阵列设计成 16 行 × 64 列的二维结构,每一行共享同一组输入特征值,每一列共享同一个权重值。这种组织方式的好处是数据复用非常自然:一个周期内读入 64 个输入值和 16 个权重值,就能一口气完成 1024 次乘累加,而访存开销只有 80 个数。这样的数据复用比例,是通用处理器很难做到的。
每个 MAC 单元内部包含一个乘法器和一个 32 位累加器。累加器位宽比 INT8 输入高出不少,是为了在卷积核尺寸较大时避免中间结果溢出,后面再做定点化截断。INT8 乘法出来的 16 位结果会不断地累加,最多可以安全累加 65536 次而不溢出,这对常见卷积层已经非常充裕。
2.3 存储层次:片上SRAM、DMA与乒乓缓冲
存储是整个设计里最需要精打细算的部分。4MB 片上 SRAM 我分成三块:一块放权重(Weight SRAM),一块放输入特征图(Input SRAM),一块放输出特征图(Output SRAM)。权重和输入都支持 DMA 预取,输出在计算完成后由 DMA 搬回片外 DRAM 或下一层用作输入。
这中间有个很关键的技巧叫乒乓缓冲(Ping-Pong Buffer)。计算单元在读取 Input SRAM 里第 0 块数据计算时,DMA 可以同时在往第 1 块里搬下一份数据。等上一块算完,计算单元切到第 1 块,DMA 又回来给第 0 块装新的数据。这样计算和访存完全重叠,MAC 阵列永远不会干等数据。
乒乓缓冲的实现并不复杂,本质上是给输入/输出缓冲区各自多加一套地址切换逻辑,再加上用 double-buffer 标志寄存器来通知当前该读哪块。我第一次实现时忽略了地址回绕的时序,结果在切换到第 1 块时读到了旧数据,折腾了两天才定位到问题。后来养成了一个习惯:所有缓冲区切换逻辑的先写仿真用例,不上板。
2.4 控制逻辑:给标量核做减法
最初设想过用一个完整的微处理器来做控制,后来发现没必要。现在控制核的指令集非常精简,只有数据搬运、循环控制、跳转、等待同步、配置 MAC 阵列这几类。它更像一个“调度器”而不是“处理器”,主要任务就是按顺序把 VLIW 指令派发下去,并在 DMA、MAC 阵列、向量功能单元之间做同步。
控制逻辑的复杂度被刻意压低了,带来的直接收益是时序收敛容易很多。FPGA 上最怕的就是一条很长的控制逻辑链造成组合延迟超标,进而拖累整个设计的主频。保持每个模块间的握手信号干净、减少跨时钟域路径,是在中后期调时序时能留出余量的关键。
3. 算子映射与数据流:把计算变成数据搬移的艺术
3.1 GEMM路线与直接卷积的取舍
拿到一个要跑的 CNN 模型,第一件事就是决定卷积层怎么落到 MAC 阵列上。业界常见的做法有两种:im2col + GEMM 路线,以及直接卷积路线。
im2col 的思路是把每个卷积窗口拉成一行向量,整个卷积转换成大矩阵乘法,这样能复用大量成熟的 GEMM 优化技巧。问题在于 im2col 会把输入特征图的数据复制几遍,导致内存膨胀。比如一个 3×3 卷积,Im2col 后的数据量是原特征图的 9 倍,对 4MB 片上存储来说,这个膨胀率可能直接让中间特征图无法全部放进去。
直接卷积则是保持原始的嵌套循环结构,让 MAC 阵列直接按照卷积窗口去取数。这样做避免了数据复制,但控制逻辑更复杂,因为每个输出点的累加值来自多个输入窗口,需要额外处理好累加器的生命周期。
我最后的设计是“折中路线”:小卷积核(3×3、1×1)走直接卷积,大卷积核(5×5以上)通过拆分窗口的方式映射到矩阵乘路径。结果这套双路径方案确实更实用,虽然代码量增加了,内存占用和计算效率都更平衡。
3.2 卷积循环分块与数据复用
以最常见的 3×3 卷积为例,我大致算过一个典型层的开销:输入是 224×224×3,64 个 3×3 卷积核,步长 1,那么总的乘累加次数大约是:
输出特征图尺寸 × 输出通道 × 输入通道 × 核尺寸 = 224×224×64×3×3×3 ≈ 8660 万次 MAC
参数总量是 64×3×3×3 ≈ 1700 个权重,INT8 下只占不到 2KB,权重这块完全不是瓶颈。但中间特征图 224×224×64 在 INT8 下大约是 3.2MB,再加上上一层输入和下一层输出,已经接近甚至超出片上 SRAM 的容量。
所以必须在时间维度上做分块(Tiling):一次只算输出特征图的某几个通道的整张空间图,把所有中间结果留在片内,算完一块再算下一块。我选择的是“按输出通道分块”,每块处理 16 个输出通道,这样中间缓存是 224×224×16 ≈ 800KB,控制在 SRAM 可承受范围内。
3.3 数据流选择:输入固定、权重固定还是输出固定
MAC 阵列在一个周期内同时读 64 个输入和 16 个权重,这本身决定了数据流只能是某种固定模式。我最初按“权重固定”方式组织:先把一层权重的若干行加载进阵列,然后不断换输入数据。这种方式适合逐层串行推理,权重复用率很高。
后来又尝试了“输入固定”的方式:把某一个小块输入长期驻留在阵列内部,多个输出通道同时累加。这种方式输出通道间的并行度更高,但对累加器数量要求也更大,最后在 FPGA 资源限制下没能完整落地,只用在 1×1 卷积上。实际经验是:数据流选择不能只看理论复用率,还要结合片上存储带宽来权衡。
3.4 INT8量化与数值处理
模型从 FP32 转成 INT8,是所有推理加速器绕不开的一步。原理不复杂:对权重和激活分别统计数值范围,然后做一个线性缩放,把浮点数映射到 [-128, 127] 区间。推理时用整数乘累加算出结果,最后再乘回缩放因子得到近似浮点结果。
实际调参时发现几个容易被忽略的细节:
- 激活值范围需要在有代表性的输入数据上统计,而不是随便拿几张图就定。范围定小了会截断尾部信息,定大了又会浪费 INT8 的动态范围;
- 残差连接层相加后数值范围会变大,必须在每层入口处重新做截断,否则累计误差会在深层网络里慢慢放大;
- 卷积计算用 INT8,但偏置(bias)要单独用 INT32 累加,最后才做截断,否则小数值的偏置很容易在累加过程中被丢光。
3.5 激活、池化与全连接:复用同一个向量单元
除了卷积,网络里还有 ReLU、最大池化、全连接这些算子。我的做法是全部归到向量功能单元里处理。向量单元本质上是一组支持比较和简单算术的并行处理器,输入是一整行中间数据,配置好操作模式后一个周期出结果。
这样设计的好处是数据流统一:卷积算完的输出直接留在 Output SRAM,向量单元从那里读进来做激活/池化,产出结果再写回 Input SRAM,作为下一层卷积的输入。整个流程里的数据搬移路径很清晰,也方便编译器做整体规划——它只需要记住每个缓冲区的当前状态,就能决定下一层的数据从哪来、往哪去。
4. 软件栈:编译器才是硬件的灵魂
4.1 模型接入:从计算图到自定义IR
硬件做得再好,如果每次部署新模型都要手写指令序列,这个加速器就完全没有实用价值。所以软件栈的第一层必须解决“模型自动进来、指令自动出去”的问题。我用了一条相对标准的链路:先把训练框架导出的模型(比如开放格式的计算图)解析出来,转成自己定义的一个中间表示(IR)。
这个 IR 不复杂,就是一组算子的有序列表,每个算子记录自己的类型、输入输出张量、形状、步长、padding 等参数。它相当于整个编译器的“通用底盘”,后面所有优化和代码生成都基于这个 IR 操作。模型解析层做好之后有个很大的好处:不管用户从哪个训练框架导出模型,只要转成同一份 IR,后面的流程就完全一致了。
4.2 后端代码生成:循环调度与指令打包
IR 拿到手之后,编译器要干几件事:
- 根据目标硬件参数(MAC 阵列尺寸、SRAM 容量)对每个算子做分块规划;
- 对循环进行重排,决定先算输出通道、还是先算空间位置,以最大化数据复用;
- 将循环体里的小块计算转换成一条条 VLIW 指令,同时安排 DMA 预取和乒乓切换。
其中第三点最考验工程能力。因为 VLIW 把并行调度的责任全推给了编译器,编译器必须精确知道每一条指令里 MAC 阵列要算哪块数据、DMA 要搬哪块数据、向量单元又在对哪个缓冲区做处理,并且保证它们之间没有数据依赖冲突。
我在这部分花了很大精力写了一个简化的依赖分析器。它的原理类似经典编译原理里的数据流分析,只是数据流对象从寄存器变量变成了“缓冲区片段的访问记录”。一旦发现某条 DMA 指令会覆盖当前 MAC 阵列正在读取的数据,就必须在指令流里插入同步点或者是调整指令顺序。这个分析器跑过之后,硬件上实际出现的数据竞争大大减少。
4.3 运行时与驱动:寄存器、描述符与性能计数
编译器的产出是一段二进制指令流,最终还是要被某种运行时环境装载到硬件上执行。驱动层的设计我参考了现代 GPU 的“命令描述符”思路:建立一块主机端可见的共享内存区域,里面放好指令流地址、输入输出缓冲地址、任务长度等参数,然后通知加速器开始执行。加速器完成后通过中断或轮询状态寄存器来汇报。
对嵌入式的调试来说,硬件性能计数器非常关键。我在硬件里加了一组计数器:MAC 阵列忙周期数、DMA 传输周期数、等待同步的空转周期数。编译器每次跑完一个模型,驱动把这些计数读出来回传到主机,我就能算出真实的计算效率,而不是只看理论峰值。这套计数器在后期的算子调优中帮了大忙。
4.4 仿真器先行:编译器的“试错沙盘”
编译器在真实硬件上调试成本太高,所以我又写了一个周期级的寄存器传输级仿真器。这个仿真器不追求速度,只追求行为一致:每条指令的每个微操作在哪个周期生效、DMA 什么时候返回到达信号、MAC 阵列哪一步能拿到数据,全都按 RTL 的逻辑模拟一遍。
有了这个沙盘,编译器的很多 bug 可以在不碰硬件的情况下暴露出来。比如调度器漏加同步点、分块尺寸超过 SRAM 容量,这些问题在仿真器里几秒钟就能跑出来,而上板调试至少要十几分钟一轮。先把软件栈在仿真器里调稳,再拿去碰真实硬件,是一天能完成多少轮迭代的分水岭。
5. 当前进度与踩坑记录:更新的部分
5.1 已经跑通的算子和性能数据
先给一下当前版本的实测结果。测试网络是一个小型分类网络,主体由 8 个 3×3 卷积层、3 个池化层和 1 个全连接层组成,输入 224×224×3。在 FPGA 原型板上实际跑通后,关键数据如下:
| 算子 | 时钟频率 | 计算效率 | 备注 |
|---|---|---|---|
| 3×3 卷积(64通道) | 200 MHz | 约 145 GMAC/s | 受限于分块切换时的缓冲区同步开销 |
| 3×3 卷积(128通道) | 200 MHz | 约 162 GMAC/s | 输出通道分块更大,MAC利用率提升 |
| 1×1 卷积 | 200 MHz | 约 188 GMAC/s | 无空间窗口数据复用压力,效率最高 |
| 最大池化 2×2 | 200 MHz | 带宽饱和 | 计算量很小,主要受限于访存速度 |
| 全连接层 | 200 MHz | 约 130 GMAC/s | 小批量时受限于DMA搬运启动开销 |
整体跑一个推理的时间约 23 毫秒,和我最初在 CPU 上实现的 180 毫秒相比,延迟缩小了大约 8 倍,但距离最初“实时视频流”的目标还有一定距离。瓶颈主要集中在 3×3 卷积的 MAC 阵列利用率没有跑满,原因不是数据通路不够宽,而是分块之间的同步开销太大。
5.2 三个值得记录的坑
第一个坑是 DMA 与计算单元之间的数据竞争。表现是推理结果偶发性的出现几个像素的偏差,而且不是每次都能复现。查了很久发现是双缓冲切换到第 0 块时,DMA 预取还没真正完成,计算单元就已经开始读第 0 块。解决方式是在缓冲区状态寄存器里增加一个“有效位”,只有 DMA 写完对应块后才会置位,计算单元在每个块开始前必须检查这个位。
第二个坑是 INT8 量化在残差连接上的误差放大。起初我偷懒对整张网络只做一次量化范围统计,结果带残差连接的结构越深偏差越大,最后分类准确率比 FP32 低了接近两个百分点。后来改为逐层统计激活范围,并专门在残差相加点两侧加了额外的截断逻辑,准确率才回到可以接受的水平。
第三个坑是 FPGA 时序收敛。当我把 MAC 阵列从 512 个单元扩到 1024 个时,布线后的最大时钟频率从 210MHz 掉到了 165MHz,算力反而没有提升多少。定位下来是一条跨模块的握手信号路径太长,最终通过把同步信号拆成两级流水寄存器解决,代价是多一个时钟周期的控制延迟,但主频成功回到了 200MHz 以上。
5.3 下一步更新计划
现在这个项目依然是“更新中”的状态,后面要动的地方也列出来了:
- 给 3×3 卷积设计更细粒度的分块策略,减少缓冲区切换的频率,目标是把计算效率从 145 GMAC/s 提到 180 GMAC/s 附近;
- 在编译器后端加入算子融合,把“卷积+激活+池化”这一段在指令流层面合并成更紧凑的序列,省掉中间特征图的一次写读往返;
- 探索稀疏化跳过当输入数据为零时的乘加运算,初步估计常见模型里 30% 以上的激活值是零,跳过这些周期能带来可观的加速比;
- 把这套架构的 RTL 移植到更新工艺的 FPGA 上,验证主频继续往上提的空间,为后续可能的 ASIC 化留好数据点。
电源和散热也重新排过一轮。原来整板功耗峰值有些偏高,后来把 MAC 阵列附近的时钟门控做了更精细的拆分:按照行分组,只有当某一行真实参与计算时才给该行时钟域供电。这一改动降了大约 15% 的功耗,代价是布线上多了几组时钟使能,换来的是可以稳定跑更长时间的温度测试。
我自己在实际调试中体会最深的一点是:自研深度学习处理器的难度从来不在某个单独模块,而在硬件、编译器、量化策略三件事必须同一时间推进,任何一个环节滞后,另外两个都会卡住。硬件改一个握手信号,编译器要跟着改调度规则;量化精度不达标,就要回过去查数据通路是不是少了一级累加器位宽。这种交叉影响在文档里看不出来,只有真正改过一轮的人才知道全套链路有多绕。
如果你也想动手做类似的东西,我的建议是先把目标模型定死,然后用软件跑出每层的特征图和参数规模,再回头设计存储和计算阵列,千万不要先拍一个很大的 MAC 阵列然后指望编译器能填满它。最后再分享一个一直沿用的土办法:每完成一个版本,先把仿真器和硬件上的性能计数器对比一遍,两个数字对不上的时候,八成是某个模块在偷跑或者在空转,这种问题越早暴露越省时间。