1. 为什么我要自己造一块AI加速器
2023年的时候,我在一个边缘计算项目里被一块推理卡坑得很惨。模型本身只有几兆大小,算力需求也不算夸张,但市面上能买到的通用方案要么功耗高得离谱,要么延迟抖动大得没法做实时控制。那段时间我反复在想一个问题:为什么不能针对自己的模型结构,做一块专用的加速器?
这个念头一旦冒出来就压不住了。后来花了大概四个月,从指令集定义、矩阵运算单元设计、存储层次规划,到用FPGA做原型验证,再到跑通一个简化版的卷积网络,算是把整条链路走了一遍。这篇文章就是把这四个月里踩过的坑、做过的取舍、以及那些教科书上不会写的细节,完整地摊开来讲。
先说清楚定位:这不是一篇教你流片做芯片的文章,那需要千万级预算和完整的EDA工具链。这里讲的是一条从零开始、以FPGA为载体的AI加速器设计路径,核心是理解加速器的架构逻辑,用可综合的硬件描述语言把矩阵计算单元、数据搬运通路、控制状态机搭出来,最终能在真实硬件上跑通推理。
适合谁看?如果你写过Python训练模型,但对硬件设计只有模糊概念,这篇文章会帮你建立从算法到电路的映射思维。如果你已经做过FPGA开发,但没接触过AI加速器,这里面的数据复用策略和脉动阵列设计思路会很有参考价值。如果你只是好奇手机里的NPU到底在干什么,看完你会有个具象的认知。
整个设计围绕一个核心矛盾展开:矩阵乘法需要极高的数据吞吐,但存储器的带宽和容量永远是瓶颈。AI加速器的本质,就是在算力、带宽、面积、功耗这四个维度上找平衡点。后面所有的架构决策,都是这个矛盾的展开。
2. 先想清楚算什么东西:从矩阵乘法到硬件映射
2.1 神经网络推理的数学本质
不管网络多复杂,推理阶段的核心计算就是矩阵乘加。一个全连接层是Y = WX + b,一个卷积层展开后也是矩阵乘法。区别只在于数据怎么排布、有没有权重共享。
拿一个最简单的卷积层举例:输入特征图是H×W×C,卷积核是K×K×C×N,输出是H'×W'×N。如果把它看成矩阵运算,可以把每个输出像素位置对应的输入窗口拉成一个向量,卷积核拉成矩阵,就变成了输出矩阵 = 权重矩阵 × 输入矩阵。
这个视角转换非常关键。因为一旦看成矩阵乘法,硬件设计就有了统一的优化目标:让乘加运算单元尽可能不停地工作,同时让数据搬运的代价尽可能小。
我当时的做法是先统计目标模型的算子分布。跑了一个轻量级的图像分类网络,发现卷积层占了总计算量的92%以上,全连接层不到8%。这意味着加速器的设计重心必须放在卷积上,全连接层可以复用同一套矩阵运算单元,不需要单独优化。
2.2 为什么不用CPU或GPU而要自己设计
这个问题我被问过很多次。CPU的问题在于它的架构是为通用性设计的,大量的面积花在了分支预测、乱序执行、多级缓存一致性上,真正做乘加运算的ALU占比很小。跑一个矩阵乘法,CPU大部分时间在搬数据和控制流程,算力利用率可能只有个位数百分比。
GPU好很多,它有大量的流处理器可以做并行乘加。但GPU的功耗和面积对于边缘设备来说还是太大,而且它的编程模型是SIMT,需要把矩阵运算拆成成千上万个线程,调度开销和寄存器压力都不小。更关键的是,GPU的存储层次是固定的,你没法针对特定模型的数据复用模式做定制。
NPU的思路就是把这些通用性全部砍掉,只保留矩阵运算需要的最小控制逻辑,把面积和功耗全部堆到乘加阵列和片上存储上。我实测下来,同样一个卷积层,在FPGA上做的专用加速器比同期的嵌入式GPU方案,能效比大概高出三到五倍。当然这个数字跟具体实现和工艺有关,但方向是明确的。
2.3 确定加速器的能力边界
在动手写第一行代码之前,必须先把加速器的规格定死。我给自己列了一张表:
| 设计维度 | 我的选择 | 理由 |
|---|---|---|
| 支持算子 | 卷积、全连接、池化、ReLU | 覆盖目标网络全部算子 |
| 数据精度 | INT8量化 | 精度损失可接受,面积和功耗大幅降低 |
| 峰值算力 | 约0.5 TOPS | FPGA资源限制下的合理目标 |
| 片上存储 | 输入缓存+权重缓存+输出缓存 | 减少对片外存储的访问 |
| 接口 | AXI4 | 方便与处理器和DMA对接 |
| 时钟频率 | 100MHz | FPGA时序收敛的稳妥选择 |
这张表看起来简单,但每一项背后都有取舍。比如为什么选INT8而不是FP16?因为FP16的乘法器面积大概是INT8的四倍,而目标模型的量化精度损失在1%以内,完全能接受。为什么峰值算力定0.5 TOPS?因为我用的FPGA有大约2000个DSP切片,每个切片一个时钟周期做一个乘加,100MHz下理论峰值就是0.4 TOPS左右,定0.5是留了点余量。
这里有个经验:规格不要定得太满。我第一次设计时想把所有算子都支持,结果控制逻辑复杂到状态机根本写不下去。后来砍掉了转置卷积和空洞卷积,只保留最核心的几种,反而顺利跑通了。
3. 矩阵运算单元:加速器的心脏怎么搭
3.1 从单个乘加器到脉动阵列
最朴素的矩阵乘法硬件实现,就是一堆乘加器排成一行,每个时钟周期取一行权重和一组输入,算完累加。但这样有个致命问题:权重和输入数据需要反复从存储器读取,带宽根本扛不住。
脉动阵列的思路是让数据在计算单元之间流动,而不是每次都从存储器取。想象一排工人站在传送带旁边,每个工人手里拿着一个权重,输入数据像流水一样从左边传进来,每经过一个工人就做一次乘加,结果往右边传。这样输入数据只需要从左边进一次,权重只需要预先加载一次,数据复用率极高。
我设计的阵列是8×8的,也就是64个乘加单元。每个单元包含一个INT8乘法器、一个32位累加器、以及若干寄存器用于暂存数据和权重。阵列的输入是8个激活值和8个权重,输出是8个部分和。
为什么选8×8?因为FPGA的DSP切片数量有限,64个乘加单元已经占用了大部分资源。而且8×8的阵列对应的数据位宽和缓存大小比较匹配,再大就会导致缓存溢出,反而降低效率。
3.2 数据流设计:权重固定还是输出固定
脉动阵列有三种经典数据流:权重固定、输出固定、行固定。我选的是权重固定,原因是卷积层的权重可以预先加载并保持不变,而输入数据不断变化。权重固定意味着权重只需要在换层时重新加载,推理过程中不需要反复搬运权重,节省了大量带宽。
具体实现上,每个乘加单元有一个权重寄存器,在层初始化阶段通过配置通路把权重写进去。推理阶段,输入数据从阵列左侧流入,每个周期向右移动一格。部分和从阵列底部流出,进入累加器。
这里有个细节:部分和的位宽。INT8乘INT8的结果是16位,但累加多个乘积后位宽会增长。我用了32位累加器,理论上可以累加65536个乘积而不溢出。对于目标网络的卷积核大小,这个位宽绰绰有余。
3.3 乘加单元的电路细节
单个乘加单元的结构其实不复杂,但有几个地方容易出错。
乘法器我用的是Booth编码的INT8乘法器,综合后大约占用一个DSP切片。累加器是一个32位加法器,带一个寄存器。关键路径在乘法器和加法器之间,需要插入流水线寄存器来保证时序收敛。
我一开始没加流水线,综合后时序报告显示最高频率只能跑到60MHz左右。后来在乘法器和累加器之间插了一级寄存器,频率直接上到120MHz。代价是延迟增加了一个周期,但对于推理任务来说,吞吐量比延迟重要得多。
还有一个坑是符号扩展。INT8是有符号数,乘法结果需要正确地进行符号扩展后再累加。我第一版忘了处理负数,导致推理结果完全错误。后来在乘法器输出后加了一个符号扩展逻辑,问题解决。
// 乘加单元的核心逻辑(简化版) module mac_unit( input clk, input rst_n, input signed [7:0] activation, input signed [7:0] weight, input signed [31:0] partial_sum_in, output signed [31:0] partial_sum_out ); reg signed [15:0] product; reg signed [31:0] accum; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin product <= 16'b0; accum <= 32'b0; end else begin product <= activation * weight; accum <= partial_sum_in + {{16{product[15]}}, product}; end end assign partial_sum_out = accum; endmodule这段代码里{{16{product[15]}}, product}就是符号扩展,把16位乘积扩展到32位。少了这个,负数乘法就会出错。
4. 存储层次:比算力更关键的瓶颈
4.1 为什么存储设计决定加速器上限
有个数据很能说明问题:在典型的矩阵乘法中,每做一次乘加运算,需要读取两个操作数。如果操作数都从片外DRAM读取,那么算力再高也没用,因为DRAM带宽根本喂不饱计算单元。
我算过一笔账:8×8阵列在100MHz下,每秒需要8×8×100M = 6.4G次乘加。每次乘加需要两个INT8操作数,就是12.8GB/s的数据吞吐。而一块普通的DDR3带宽大概在8GB/s左右,根本不够。所以必须靠片上缓存来复用数据,把片外访问降到最低。
这就是存储层次设计的核心目标:让数据在片上多待一会儿,被尽可能多的计算单元复用。
4.2 输入缓存、权重缓存与输出缓存的分工
我设计了三块片上缓存:
- 输入缓存:存放当前层的输入特征图。大小是
16×16×32字节,对应一个较大的输入窗口。输入数据从片外一次性搬进来,然后在阵列计算过程中反复读取。 - 权重缓存:存放当前层的权重。大小是
8×8×32字节,对应阵列一次能处理的权重块。权重在层初始化时加载,推理过程中只读。 - 输出缓存:存放部分和和最终输出。大小是
16×16×32字节。部分和在阵列计算过程中累加,最终结果写回片外。
三块缓存都用双端口BRAM实现,一个端口给计算阵列读,一个端口给DMA写。这样数据搬运和计算可以并行,不会互相阻塞。
缓存大小的选择有个经验公式:输入缓存至少要能放下阵列一次计算所需的全部输入数据,否则阵列会频繁等待数据。我选的16×16×32对应的是阵列处理一个16×16输出块所需的输入窗口,刚好匹配。
4.3 数据复用策略:卷积窗口的滑动技巧
卷积计算有个天然的数据复用机会:相邻的输出像素共享大部分输入数据。比如一个3×3卷积,输出像素(i,j)和(i,j+1)的输入窗口有6个元素是重叠的。
我的做法是在输入缓存里维护一个滑动窗口。当阵列计算完一个输出块后,窗口向右滑动,只需要从片外加载新进入窗口的那一列数据,而不是重新加载整个窗口。这样片外访问量能降低到原来的三分之一左右。
权重那边的复用更简单:同一层的权重对所有输出位置都是一样的,所以权重缓存只需要在层切换时更新一次。这也是权重固定数据流的优势所在。
实测数据:用了滑动窗口复用后,片外带宽需求从12.8GB/s降到了4.2GB/s左右,普通的DDR3就能满足。这个优化对整体能效比的贡献,比单纯增加计算单元还要大。
5. 控制逻辑与指令调度:让数据流听话
5.1 状态机设计:从加载到写回的全流程
加速器的控制核心是一个有限状态机,管理整个推理流程。我把它分成五个状态:
- IDLE:等待启动信号。
- LOAD_WEIGHT:从片外加载当前层的权重到权重缓存。
- LOAD_INPUT:从片外加载输入数据到输入缓存。
- COMPUTE:启动脉动阵列,进行矩阵乘加,部分和写入输出缓存。
- WRITE_BACK:把输出缓存的结果写回片外,然后跳转到下一层或回到IDLE。
状态之间的跳转条件需要仔细设计。比如COMPUTE状态什么时候结束?我的做法是维护一个计数器,记录已经完成的输出块数量,当计数器达到当前层的总块数时,跳转到WRITE_BACK。
这里有个容易忽略的点:层与层之间的依赖。下一层的输入是上一层的输出,所以必须等WRITE_BACK完成后才能开始下一层的LOAD_INPUT。我在状态机里加了一个层计数器,确保顺序执行。
5.2 指令集设计:用微码描述每一层
为了让加速器能灵活支持不同的网络结构,我设计了一套简单的微指令。每条指令描述一个层的配置:
| 字段 | 位宽 | 含义 |
|---|---|---|
| opcode | 4 | 算子类型(卷积/全连接/池化) |
| input_addr | 16 | 输入数据在片外的起始地址 |
| weight_addr | 16 | 权重在片外的起始地址 |
| output_addr | 16 | 输出数据在片外的起始地址 |
| input_size | 16 | 输入特征图尺寸 |
| kernel_size | 8 | 卷积核尺寸 |
| stride | 4 | 步长 |
| channel_in | 8 | 输入通道数 |
| channel_out | 8 | 输出通道数 |
这些微指令在推理开始前由主控处理器写入加速器的指令缓存。加速器逐条读取指令,配置状态机,执行对应的层。这样换一个网络只需要重新生成微指令序列,硬件不需要改动。
微码的好处是灵活,坏处是需要额外的指令缓存和译码逻辑。我权衡后觉得值得,因为如果每换一个网络都要重新综合硬件,那开发效率太低了。
5.3 流水线冲突与数据冒险的处理
脉动阵列是深度流水线结构,数据从输入到输出要经过多个周期。如果控制逻辑没有处理好,就会出现数据冒险。
我遇到的一个典型问题是:当阵列还在计算上一个输出块时,输入缓存已经被新数据覆盖了。原因是LOAD_INPUT和COMPUTE两个状态没有正确同步。后来我加了一个双缓冲机制:输入缓存分成两块,一块给当前计算用,一块给下一块数据加载用。当计算完成时,切换缓冲区的角色。
权重缓存也有类似问题,但因为权重只在层切换时加载,冲突概率低很多。我的做法是在LOAD_WEIGHT状态时暂停阵列,等权重加载完成后再启动COMPUTE。
这些同步逻辑写起来很琐碎,但少一个就会导致结果错误。我的建议是画一张完整的时序图,把每个状态下的数据流向都标清楚,然后再写代码。
6. 用FPGA把设计跑起来:从仿真到上板
6.1 仿真验证:先让行为模型跑通
在写可综合代码之前,我先用Python写了一个行为级的模拟器。这个模拟器不关心时序,只验证算法逻辑:给定输入和权重,计算输出,和PyTorch的结果对比。
这一步非常关键。因为硬件调试的成本远高于软件,如果算法逻辑本身有问题,上板后根本无从查起。我的模拟器大概200行Python,实现了卷积、池化、全连接的前向计算,以及INT8量化。
量化是个容易出问题的地方。我用的对称量化,把浮点权重和激活值映射到[-127, 127]的整数范围。缩放因子根据每层的最大值动态计算。模拟器里跑出来的精度损失在0.8%左右,可以接受。
模拟器跑通后,我用Verilog写了一个对应的行为级模型,用相同的测试向量验证。两个模型输出一致后,才开始写可综合的RTL代码。
6.2 综合与实现:时序收敛的实战技巧
综合的时候遇到了几个典型问题。
第一个是资源超限。8×8阵列加上三块缓存,BRAM和DSP的使用率都超过了80%。我不得不把输入缓存从16×16×32缩小到12×12×32,牺牲了一点数据复用率,换来了资源余量。
第二个是时序违例。关键路径在乘法器和累加器之间,组合逻辑延迟太大。我插了两级流水线寄存器,把路径切成三段,频率从60MHz提到了110MHz。代价是阵列的填充延迟增加了两个周期,但吞吐量提升明显。
第三个是布线拥塞。脉动阵列的互连非常密集,布局布线工具经常报拥塞。我的解决办法是给阵列加区域约束,让它集中在一个区域,减少长距离布线。另外把权重加载通路和激活值通路分开布局,避免互相干扰。
综合报告里我重点关注三个指标:LUT使用率、DSP使用率、BRAM使用率。目标是都控制在85%以内,留出余量给后续修改。最终实现下来,LUT用了72%,DSP用了81%,BRAM用了78%,算是比较健康的水平。
6.3 上板实测:性能与功耗的平衡
上板测试用的是Xilinx的Zynq开发板,PS端跑Linux,负责加载数据和启动加速器,PL端就是我的加速器设计。
实测性能:在100MHz时钟下,跑一个约50M MAC的卷积网络,耗时约12ms,等效算力约0.42 TOPS。功耗方面,PL端动态功耗约1.8W,加上PS端总共约3.5W。能效比大约是0.12 TOPS/W。
这个数字跟专用ASIC比差很远,但考虑到FPGA的工艺和架构限制,已经达到了我的预期。更重要的是,整个设计流程走通了,从算法到硬件到实测,每个环节都摸清楚了。
一个实测发现:输入缓存的命中率对性能影响极大。当输入特征图尺寸不是缓存大小的整数倍时,滑动窗口会频繁失效,导致片外访问量激增。后来我在数据排布时做了padding,让每层输入都对齐到缓存边界,性能提升了约20%。
7. 踩过的坑与设计迭代中的教训
7.1 量化精度损失比预想的大
第一版设计我用了INT4量化,想着面积能再小一半。结果跑出来的分类准确率掉了15个百分点,完全不可用。后来分析发现,INT4对权重分布的表示能力太弱,很多小权重直接被量化成零。
改回INT8后精度恢复。但我也学到了一件事:量化位宽不是越小越好,要看权重的实际分布。如果权重集中在零附近,低位宽会丢失大量信息。后来我加了一个逐通道的缩放因子,让每个输出通道有自己的量化参数,精度又提升了0.3个百分点。
7.2 状态机的死锁问题
调试阶段遇到过一次死锁:加速器卡在COMPUTE状态出不来。用逻辑分析仪抓信号发现,输出块计数器的值比预期少了一个,导致状态机永远等不到完成条件。
根因是边界处理。当输出特征图的尺寸不能被阵列大小整除时,最后一块的输出块尺寸会小于阵列大小。我的计数器逻辑没有处理这种情况,导致计数错误。修复方法是在微指令里增加一个"有效输出块数"字段,状态机根据这个字段判断是否完成,而不是根据理论计算值。
这个坑让我意识到:硬件设计里所有边界条件都必须显式处理,不能像软件那样靠运行时判断。因为硬件没有异常处理机制,一个边界错误就会导致整个系统挂死。
7.3 带宽估算与实际差距
设计初期我估算片外带宽需求是4GB/s,选了DDR3。但实测发现峰值带宽需求到了6GB/s,DDR3偶尔会成为瓶颈。
原因是我的估算没有考虑DMA的效率损失。实际DMA传输有握手开销、仲裁延迟、刷新周期,有效带宽大概只有理论值的70%。后来我把输入缓存加大,提高了数据复用率,才把峰值带宽压回4GB/s以内。
教训是:带宽估算要留至少50%的余量,因为实际系统中的开销远比理论模型复杂。
8. 如果重新来过:架构优化的几个方向
8.1 从8×8到16×16:阵列扩展的代价
如果FPGA资源允许,把阵列从8×8扩展到16×16,峰值算力能翻四倍。但代价是互连复杂度呈平方增长,布线拥塞会非常严重。而且缓存大小也要相应增加,否则数据供应跟不上。
我的建议是不要盲目扩大阵列。先算清楚缓存带宽能不能支撑,再决定阵列大小。一个经验法则是:阵列的输入带宽需求不能超过缓存带宽的80%,否则阵列会经常饿死。
8.2 加入稀疏化支持:跳过零权重
目标网络量化后有很多零权重,如果能跳过这些零,算力利用率能提升不少。实现方法是在权重缓存里加一个有效位掩码,阵列遇到零权重时跳过乘加。
但这样会增加控制逻辑的复杂度,而且稀疏模式不规律时,跳过逻辑本身的开销可能抵消收益。我试过一版,在稀疏度50%的情况下,性能只提升了15%左右,不太划算。如果稀疏度能到80%以上,可能值得做。
8.3 多核架构:用多个小阵列替代一个大阵列
另一个思路是用四个4×4的小阵列替代一个8×8的大阵列,每个小阵列独立处理不同的输出通道。这样互连更简单,布线更容易,而且可以灵活分配任务。
缺点是权重需要复制多份,缓存面积增加。而且多个阵列之间的同步需要额外逻辑。我目前还在评估这个方案,初步仿真显示在通道数较多的层上,多核架构的效率更高。
9. 一些实操层面的建议
如果你也想动手做一块自己的AI加速器,我有几个具体的建议。
先从仿真开始,不要急着上板。用Python或C++写一个行为模型,把算法逻辑验证清楚。硬件调试的时间成本是软件仿真的十倍以上,前期多花时间在仿真上,后期能省大量调试时间。
规格要砍到最小可用。不要想着支持所有算子,先支持一个卷积和一个全连接,跑通一个最简单的网络。我第一版想支持七种算子,结果三个月都没跑通。后来砍到两种,两周就上板了。
存储设计比计算设计更重要。花在缓存大小、数据复用、带宽估算上的时间,应该比花在乘加单元上的时间多。计算单元是规则的,存储访问模式是不规则的,后者才是难点。
时序收敛要提前考虑。写RTL的时候就要想好哪里插流水线,不要等综合报违例了再改。关键路径通常在乘法器、加法器、多路选择器这些组合逻辑上,提前规划好流水线级数。
留足资源余量。FPGA资源用到90%以上时,布局布线会变得极其困难,时序也很难收敛。控制在80%左右比较稳妥,给后续优化留空间。
最后说一个心态上的体会:设计加速器是个系统工程,涉及算法、架构、电路、工具链多个层面。不要指望一次成功,迭代是常态。我前后改了五版才达到可用的状态,每一版都解决了上一版暴露的问题。这个过程本身,比最终的性能数字更有价值。