1. 为什么我想从零设计一款AI加速器
1.1 一个朴素但真实的动机
三年前我在做一个边缘侧视觉检测项目,模型不大,参数量不到两百万,但推理延迟始终卡在40毫秒下不来。我试过换更快的CPU、把模型量化到INT8、甚至把后处理全部搬到GPU上,效果都不理想。问题不在于算力不够,而在于数据在存储器与计算单元之间来回搬运的能耗和延迟,远远超过了计算本身。那段时间我翻了很多资料,看到一句话印象很深:矩阵乘法本身不贵,贵的是把矩阵搬来搬去。这句话直接把我推向了AI加速器设计这个方向。
所谓AI加速器,说白了就是专门为神经网络里那些重复度极高的运算(主要是矩阵乘加)定制的硬件。它不像CPU那样什么都能干但什么都不算特别快,也不像GPU那样靠海量线程堆吞吐,而是把神经网络的计算模式吃透之后,用最省的方式把矩阵运算做掉。NPU、TPU、各种端侧推理芯片,本质上都是这个思路的不同实现。
这篇文章不是学术论文,也不是芯片设计教材的读书笔记。我想做的是把“从零设计一款AI加速器”这件事,按照一个从业者真实的思考路径拆开:先想清楚要解决什么问题,再决定架构长什么样,然后落到具体的矩阵运算单元、存储层次、数据流调度,最后聊聊验证和踩过的坑。如果你是对硬件加速感兴趣的软件工程师,或者是刚入门的数字IC设计者,又或者只是好奇NPU里面到底在干什么,这篇内容应该都能给你一些可以直接参考的东西。
1.2 先明确边界:我要设计的到底是什么
“AI加速器”这个词太宽了。从数据中心里几百瓦的推理卡,到手机SoC里几平方毫米的NPU,再到单片机旁边挂的一颗几毛钱的协处理器,都叫AI加速器。如果不先把边界画清楚,后面的架构讨论就是空中楼阁。
我给自己定的目标是:面向边缘侧推理的定点矩阵加速器。具体约束如下:
- 目标场景:卷积神经网络和轻量级Transformer的推理,不做训练
- 数据精度:INT8为主,支持INT16累加,不碰浮点
- 算力目标:在200MHz时钟下做到1TOPS左右的等效算力
- 存储约束:片上SRAM不超过256KB,不接外部DRAM
- 接口:AXI4-Lite做控制,AXI4-Stream做数据输入输出
为什么这么定?因为边缘侧最缺的不是峰值算力,而是能效比和面积效率。你在一块智能摄像头的主控旁边塞一颗几百毫瓦的加速器,如果还要外挂DDR,那整体功耗和成本就失控了。把数据留在片上、把精度压到INT8、把矩阵运算做成流水线,这才是边缘加速器该有的样子。
提示:很多初学者一上来就想做“通用AI加速器”,既支持卷积又支持全连接还支持各种激活函数,结果架构越做越复杂,最后连仿真都跑不通。先把一个核心运算做扎实,比什么都重要。
2. 架构设计:从矩阵乘法出发倒推硬件结构
2.1 为什么矩阵乘法是核心中的核心
神经网络推理,拆到最底层,绝大部分计算量都落在矩阵乘法上。卷积可以展开成矩阵乘(im2col),全连接本身就是矩阵乘,Transformer里的注意力机制核心也是Q、K、V三个矩阵的乘加。所以只要把矩阵乘法加速做好,就覆盖了90%以上的计算需求。
一个典型的矩阵乘是C = A × B,其中A是M×K,B是K×N,C是M×N。在CPU上,这件事靠循环嵌套加SIMD指令来做;在GPU上,靠成千上万个线程并行;而在AI加速器上,我们要做的是设计一个二维的乘加阵列,让A的行和B的列在硬件里直接相遇,一个周期内完成一批乘加。
这里有个关键概念叫数据复用。矩阵乘里每个A的元素会被复用N次,每个B的元素会被复用M次。如果每次乘加都从存储器里重新读数据,那带宽根本扛不住。所以加速器的核心设计目标就是:让数据在计算单元附近尽可能久地停留,减少对存储器的访问次数。
2.2 脉动阵列:一个经典但不过时的选择
说到矩阵加速,绕不开脉动阵列(Systolic Array)。我第一次看到这个概念的时候觉得挺玄乎,后来想明白了,它本质上就是一个让数据像心跳一样有节奏地在计算单元之间流动的结构。
具体来说,假设我有一个8×8的乘加单元(PE)阵列。A矩阵的元素从左侧流入,B矩阵的元素从上方流入,每个PE负责一个乘加操作,结果向下或向右传递。数据在阵列里“脉动”前进,每个周期都有新的数据进来,也有算完的数据出去。这样做的好处是:
- 每个PE只需要和相邻的PE通信,布线短,频率容易做高
- 数据复用率极高,A的每个元素进入阵列后会被整行PE用到
- 控制逻辑简单,不需要复杂的调度器
当然,脉动阵列也不是没有缺点。它的灵活性较差,适合规则的矩阵运算,遇到稀疏矩阵或者不规则计算就效率骤降。但对于边缘推理这种以规则卷积为主的场景,它依然是最优解之一。
我最终选择的是一种可配置的脉动阵列:PE阵列大小固定为16×16,但支持三种工作模式——标准矩阵乘模式、深度卷积模式、以及向量乘加模式。这样既能覆盖常规卷积,也能处理MobileNet里的深度可分离卷积。
2.3 存储层次:比计算更值得花心思的地方
前面说了,矩阵乘本身不贵,贵的是搬数据。所以在设计加速器的时候,我在存储层次上花的精力比计算单元还多。
整个存储层次从外到内分为四级:
| 层级 | 容量 | 带宽 | 用途 |
|---|---|---|---|
| 外部接口 | - | AXI4-Stream | 输入特征图和权重 |
| 全局SRAM | 128KB | 256bit/cycle | 缓存输入、权重、输出 |
| 行缓冲 | 4KB | 512bit/cycle | 为PE阵列提供对齐数据 |
| 寄存器文件 | 每个PE 32B | 本地 | PE内部暂存 |
为什么这么分?因为PE阵列每个周期要吃掉16×16=256个乘加操作,对应需要16个A元素和16个B元素。如果直接从全局SRAM读,带宽根本不够。所以我在PE阵列旁边放了行缓冲,提前把数据准备好,PE阵列只管算,不管等。
这里有个经验:行缓冲的深度要至少能装下两行数据,这样才能做双缓冲,一边算当前行一边预取下一行。我一开始只做了一行缓冲,结果PE阵列有30%的时间在等数据,算力利用率直接掉到70%以下。后来加了双缓冲,利用率才回到95%以上。
2.4 数据流调度:让每个周期都不浪费
数据流调度是加速器设计里最像“艺术”的部分。同样的硬件,调度策略不同,实际性能可能差一倍。
我采用的是输出 stationary 加权重 stationary 的混合策略。具体来说:
- 权重在计算开始前一次性加载到PE阵列的本地寄存器里,整个推理过程中不变
- 输入特征图按行流入,每行计算完后输出部分和
- 部分和在PE阵列内部累加,直到所有K维度处理完才写出
这样做的好处是权重只需要读一次,输入特征图也只读一次,输出部分和只在最后写一次。整个过程中,全局SRAM的访问次数被压到了最低。
注意:输出 stationary 策略对片上存储的要求比较高,因为部分和要一直留在PE阵列里。如果K维度太大,部分和寄存器会不够用。我的做法是把K维度分块,每块算完先写到全局SRAM,最后再累加。虽然多了一次读写,但总比寄存器溢出强。
3. 核心模块实现:从PE到控制器的完整拆解
3.1 乘加单元(PE)的设计细节
PE是整个加速器里最基础的单元,它的设计直接决定了加速器的面积、功耗和频率。
一个PE的核心就是一个乘法器加一个累加器。但要做得好,有几个细节必须注意:
第一,乘法器的位宽选择。INT8乘INT8得到INT16,这是最自然的做法。但如果你要做INT8乘INT8加INT16累加,那累加器就要至少24位,防止多次累加后溢出。我一开始用了16位累加器,结果在跑ResNet的一个中间层时直接溢出了,输出全是错的。后来改成24位,问题解决。
第二,流水线级数。乘法器本身有延迟,如果PE里不加流水线,时钟频率上不去。我在PE里插了一级流水线,把乘法和累加分到两个周期做。代价是控制逻辑稍微复杂一点,但频率从100MHz提到了200MHz,算力直接翻倍。
第三,清零和使能逻辑。PE需要支持三种操作:清零累加器、累加当前乘积、输出结果。这些控制信号要做得干净,不能有毛刺。我用了同步复位加时钟使能的方案,实测下来很稳。
下面是一个PE的简化Verilog代码,你可以直接参考:
module pe #(parameter DW = 8, parameter AW = 24) ( input wire clk, input wire rst_n, input wire en, input wire clr, input wire signed [DW-1:0] a, input wire signed [DW-1:0] b, output reg signed [AW-1:0] out ); wire signed [2*DW-1:0] prod; assign prod = a * b; always @(posedge clk or negedge rst_n) begin if (!rst_n) out <= 0; else if (en) begin if (clr) out <= {{(AW-2*DW){prod[2*DW-1]}}, prod}; else out <= out + {{(AW-2*DW){prod[2*DW-1]}}, prod}; end end endmodule这段代码里有个细节:符号扩展。因为prod是16位有符号数,累加器是24位,所以要把prod的符号位扩展到24位再相加。如果忘了这一步,负数乘法就会出错。
3.2 行缓冲与数据对齐
行缓冲的作用是把全局SRAM里的数据整理成PE阵列需要的格式。听起来简单,做起来坑很多。
最大的坑是数据对齐。卷积运算里,输入特征图的一个3×3窗口要对应9个权重,但这9个数据在SRAM里不是连续存放的。如果每次都要重新计算地址,那控制逻辑会非常复杂。我的做法是在行缓冲里做一个滑动窗口:每次新进来一行数据,窗口就往下滑一行,同时把最旧的一行挤出去。这样PE阵列永远看到的是一个对齐好的3×3窗口,不需要关心地址计算。
另一个坑是边界处理。卷积在特征图边缘需要补零,如果补零逻辑放在PE阵列里做,会浪费计算周期。我把它放在行缓冲的写入端:当写入地址超出特征图范围时,直接写零。这样PE阵列完全感知不到边界的存在,计算效率不受影响。
3.3 控制器与状态机
控制器是整个加速器的“大脑”,它要协调数据加载、计算、输出三个阶段的时序。
我设计了一个五状态的状态机:
- IDLE:等待启动信号
- LOAD_WEIGHT:从全局SRAM加载权重到PE阵列
- LOAD_INPUT:加载输入特征图到行缓冲
- COMPUTE:PE阵列计算,输出部分和
- WRITE_OUT:把结果写回全局SRAM
状态之间的切换条件要仔细设计。比如从LOAD_INPUT到COMPUTE,必须等行缓冲填满至少两行才能开始,否则PE阵列会饿死。从COMPUTE到WRITE_OUT,必须等所有部分和累加完毕。
实操心得:状态机的调试建议先用仿真跑一遍完整的推理流程,把每个状态的持续时间打印出来。我当时发现COMPUTE状态占了总时间的85%,但其中有20%是在等行缓冲。后来调整了LOAD_INPUT的预取策略,把这20%压到了5%以下。
3.4 量化与反量化模块
INT8推理离不开量化。模型训练时是浮点,部署时要转成定点,这中间有一个缩放因子(scale)和零点(zero point)。
我的加速器里集成了一个简单的量化模块,支持每层独立的scale和zero point。具体做法是:
- 权重在加载时就已经量化好了,直接存INT8
- 输入特征图在写入行缓冲前做量化,浮点转INT8
- 输出部分和在写回SRAM后做反量化,INT24转浮点
这里有个经验:scale和zero point最好用2的幂次,这样量化就是简单的移位操作,不需要乘法器。虽然精度会损失一点,但在边缘场景下完全够用,而且省下来的面积和功耗非常可观。
4. 验证与调试:那些只有踩过才知道的坑
4.1 功能验证:从单元测试到系统级仿真
加速器设计最怕的就是流片回来发现算错了。所以在RTL阶段就要把验证做扎实。
我的验证策略分三层:
第一层是PE级单元测试。用随机生成的A和B矩阵,在PE阵列上跑一遍,和Python的numpy结果对比。这一步主要验证乘加逻辑和累加器位宽。
第二层是模块级测试。把行缓冲、控制器、PE阵列连起来,跑一个完整的卷积层。输入用真实的特征图数据,输出和PyTorch的CPU结果对比。这一步主要验证数据流和状态机。
第三层是系统级仿真。把整个加速器挂到一个小型的SoC模型上,跑一个完整的MobileNet推理。这一步主要验证接口和中断逻辑。
三层验证跑下来,基本能覆盖95%以上的功能bug。剩下的5%往往是时序相关的,需要上板调试。
4.2 时序收敛:那些让人头疼的路径
200MHz的目标频率在28nm工艺下不算高,但也不是随便就能达到的。我遇到的最大问题是PE阵列的布线延迟。
16×16的PE阵列,每个PE都要和上下左右的PE通信,布线资源非常紧张。如果综合工具把PE摆得太散,关键路径就会出现在PE之间的连线上。我的解决办法是:
- 用手动布局约束把PE阵列约束成一个紧凑的矩形
- 在PE之间的连线上插入寄存器,把长路径打断
- 对时钟树做平衡,确保所有PE的时钟偏斜在可接受范围内
这些手段用下来,时序余量从-0.3ns变成了+0.15ns,算是勉强收敛。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 输出全零 | PE阵列使能信号没拉高 | 检查控制器的en信号 | 修正状态机切换条件 |
| 输出溢出 | 累加器位宽不够 | 打印中间部分和 | 增加累加器位宽 |
| 算力利用率低 | 行缓冲带宽不足 | 统计PE等待周期 | 加双缓冲或加宽SRAM位宽 |
| 时序不收敛 | PE间布线太长 | 看时序报告的关键路径 | 手动布局加插入寄存器 |
| 量化后精度掉太多 | scale粒度太粗 | 逐层对比浮点和定点输出 | 改成每通道独立scale |
4.4 上板调试:从仿真到现实的鸿沟
仿真跑通不代表上板能跑。我第一次上板的时候,加速器直接不工作,读出来的全是0xDEADBEEF。排查了两天才发现是AXI4-Stream的握手信号有问题:仿真里ready和valid的时序是理想的,但实际硬件里ready信号有延迟,导致数据丢失。
后来我养成了一个习惯:所有跨时钟域的信号都加同步器,所有握手信号都做超时保护。虽然多了一点面积,但稳定性提升了一个数量级。
提示:上板调试建议先用一个最简单的测试用例,比如4×4的矩阵乘,确认数据通路是通的。然后再逐步加大规模,不要一上来就跑完整模型。
5. 性能评估与优化方向
5.1 实测性能数据
在200MHz时钟下,16×16的PE阵列理论峰值算力是:
16 × 16 × 2 × 200M = 102.4 GOPS实测下来,跑一个标准的3×3卷积层,算力利用率在92%左右,等效算力约94 GOPS。跑深度可分离卷积时利用率会掉到75%左右,因为深度卷积的复用率低,PE阵列有一部分在空转。
功耗方面,在28nm工艺下,整个加速器的动态功耗约180mW,静态功耗约15mW。能效比大约是0.5 TOPS/W,在边缘场景下算是中规中矩。
5.2 下一步可以怎么优化
如果要把这个设计再往前推一步,我觉得有几个方向值得尝试:
第一,支持稀疏计算。很多模型剪枝后权重里有大量零,如果PE阵列能跳过零权重,算力可以再提升30%以上。做法是在PE里加一个零检测逻辑,遇到零直接跳过乘加。
第二,增加多核扩展。单核16×16的算力有限,如果做成4核,每个核负责不同的输出通道,算力可以线性扩展。代价是核间通信和任务调度的复杂度上升。
第三,优化数据流支持Transformer。现在的架构对卷积很友好,但对注意力机制里的矩阵转置和softmax支持不够。如果要做端侧大模型,这部分必须补上。
我个人在实际操作中的体会是,AI加速器设计最难的不是某个模块的实现,而是在面积、功耗、性能、灵活性之间找到平衡点。你不可能什么都想要,必须根据目标场景做取舍。边缘侧就老老实实做INT8、做小阵列、做高复用;数据中心才需要考虑浮点、大阵列、高带宽。想清楚这一点,后面的设计决策就会顺畅很多。
最后再分享一个小技巧:如果你也在做类似的加速器设计,建议先把Python的行为级模型写出来,用numpy把整个推理流程跑通,再开始写RTL。这样你在调试RTL的时候,永远有一个“标准答案”可以对比,效率会高很多。我一开始跳过这一步直接写Verilog,结果在调试累加器溢出的时候花了整整一周,后来补了Python模型,半天就定位到了问题。