干AI芯片这行的人应该都认同一个观点:会用硬件的人不如懂软硬件协同设计的人走得远。单独看RTL,或者单独写算子库,都只是摸到一半的门道;真正让一颗芯片跑出接近理论算力的,是软硬件设计两边反复对齐的那些细节。这是系列的第3篇,主题还是“AI芯片的软硬件设计”,但我不打算重复基础概念,而是把重点放在偏实操的部分——从算子如何落到硬件、编译器和指令集怎么互相妥协、到跑性能时那些令人头疼的问题怎么排查。适合谁看呢?刚进AI芯片行业、正在做加速器IP、或者想从算法转硬件方向的朋友,看完应该能少走不少弯路。
1. 先明白一件事:AI芯片的软硬件从来不是两条线
1.1 为什么软硬件不能各干各的
传统芯片开发的节奏一般是先定spec,再写RTL,流片回来之后才配套SDK和工具链。这个顺序放到通用CPU上问题不大,因为指令集是稳定的,应用生态早就在指令集之上构建好了。但AI芯片不一样,算法模型迭代太快,今天还在用卷积为主的网络,明天可能就变成了注意力机制为主的结构。如果硬件先拍板,软件再适配,最常见的结局是:算子库为了迁就硬件缺陷写出大量怪异的拼接逻辑,编译器迟迟上不了性能,最后整个项目陷入“硬件催软件、软件怪硬件”的死循环。
我见过一个项目,硬件团队提前把MAC阵列设计成了固定8x8的分块,理由是这样乘法器布线最整齐。结果算法团队后来换了一个大卷积核的检测模型,编译器为了把7x7卷积映射上去,不得不在输入通道维度上做了几十次重复搬运,访存流量翻了三倍,最终吞吐只有理论峰值的四分之一。如果一开始就让算子开发人员参与到阵列拓扑的讨论里,大概率会选一个同时支持2D卷积切分和矩阵乘累加的结构。软硬件协同设计的第一条原则就是:不要让任何一方在后期为前期拍板的决策买单。
1.2 抽象层级:从模型到晶体管之间隔了几层
为了讨论方便,得先把层级理清楚。一个训练好的模型通常以计算图的形式导出,常见的是ONNX这种中间格式。计算图里是算子节点,比如Conv、Gemm、Relu、Add。紧接着编译器会把计算图做优化和拆分,把算子变成一系列指令:矩阵乘指令、向量指令、数据搬运指令、同步指令。指令再往下走,就是微架构层面的执行单元,比如MAC阵列、向量ALU、DMA、片上存储。最后才落到门级电路。
每一层之间都有映射关系。你问一个软件工程师,他会告诉你模型到算子这层是算法的自然表达;你问一个硬件工程师,他会告诉你指令到微架构这层是电路的自然表达。但真正麻烦的其实是中间那层——“算子到指令”。这一层需要同时理解算法语义、数据布局、存储容量和并行粒度,也是软硬件协同设计的主战场。我自己的体会是,如果项目组里有人能把这一层讲清楚,比单纯引入多少工具链都管用。
1.3 先看数据流,再谈算力指标
很多人评估一颗AI芯片最先看TOPS,但这其实是个充满误导的数字。TOPS只说明MAC阵列的理论峰值,掩盖了“数据喂不喂得饱”的问题。一个更贴近真相的问法是:这个芯片上一条数据从DDR搬到片上,能被复用几次?
以3x3卷积为例,同一个输入像素会被多个输出位置的乘加运算复用,复用次数和卷积窗口、通道数是正相关的。如果硬件采用weight-stationary数据流,权重固定驻留在片上,输入激活不断流入,适合输入通道数较大、权重复用率高的场景。反之,output-stationary数据流把部分输出累加留在寄存器中,适合输出通道数较多的层。没有绝对优劣,只看模型结构里哪种模式占比更高。
所以我建议所有做软硬件设计的人,第一件事不是急着写RTL,而是先把目标模型拆一遍,统计每一层的计算量、访存量、参数量,算出算术强度(arithmetic intensity),也就是每字节访存对应多少次乘加。这个数字直接决定了硬件需要多大的片上buffer、多少带宽、什么样的数据流结构。后面第4章会给一个具体例子,现在先记住这个分析思路。
2. 硬件侧必须拍板的三个关键点:MAC阵列、存储层次、数据流
2.1 MAC阵列规模怎么定
MAC阵列的规模不是拍脑袋拍出来的,它源自一个很朴素的等式:峰值算力 = MAC数量 × 主频 × 2。那个2代表一次乘加等于一次乘法和一次累加,行业里习惯把MAC算成两个操作。
比如目标是1.5 TOPS的INT8算力,打算跑400MHz主频,那么需要的MAC数量是:1.5 × 10^12 / (400 × 10^6 × 2) = 1875,取整后做2048个MAC。2048个MAC再排成物理阵列,常见选择是64行32列,或者16行128列。行数通常对应输出通道的方向,列数对应输入通道或像素方向。阵列形状会直接影响编译器切片的便利性,比如64行对应一次可以处理64个输出通道,配合向量ALU处理16通道一组的数据,调度起来很顺。
阵列大了会怎样?如果片上buffer送不进足够数据,阵列只能空转。阵列小了会怎样?需要更高主频去凑算力,功耗直线上升。所以阵列规模必须在带宽和功耗之间找一个平衡点。这里有个实操心法:先用一个粗略的DDR带宽估算,4字节/周期,配合主频换算可用带宽,再看MAC阵列运行一个tile大概需要多少周期,能不能把数据按时喂饱。如果喂不饱,要么减小阵列,要么加大buffer,没有第三个选项。
2.2 存储层次与片上buffer容量怎么算
AI芯片的存储层次一般是:寄存器文件 -> L1 buffer -> global buffer -> DDR。靠近MAC阵列的存储必须带宽高、容量小;远离的带宽低、容量大。以2048个MAC为例,如果每个周期要从buffer里读两个8位操作数加一个累加回写,那么数据带宽至少是2048 × 3字节/周期。想让缓冲区和计算单元同频跑,需要把buffer拆成多个bank,避免同一周期冲突。
buffer容量怎么规划?核心依据是tiling方案。拿一个典型卷积层举例:输入为32×32×64,权重为3×3×64×64,输出为32×32×64。如果一次性全放进片上,激活加权重大约是2.1MB,还能接受。但如果是大一点的层,比如128×128×128,激活加权重会超过16MB,必须切片处理。
切片大小用约束式来算:选定的输出块,需要对应的输入切片、权重切片和累加buffer,三者之和要小于L1容量,同时尽量让数据复用率最高。我常用的切法是先把输出通道分成若干块,再把空间尺寸(H, W)分成小块。空间块越小,输入复用越少,DMA搬运会变多;空间块越大,buffer占用越大。两者是要平衡的。实际项目里,建议把tile大小做成编译器可配置的,甚至允许每个layer单独指定,不要全局写死,因为不同层的通道数和空间分辨率差异很大。
2.3 数据流的选择会影响编译器一辈子
数据流术语听起来很高端,说白了就是数据以谁为中心待在原地。Weight-stationary是权重留在片上反复用,适合卷积;output-stationary是部分和留在片上不断累加,适合全连接和输出通道多的层;input-stationary是输入激活留在片上,适合深度可分离卷积这类通道独立的操作。
我建议主流数据流做成可切换的,硬件上通过控制累加器的清零和保持逻辑实现,编译器根据算子类型选择。这里最容易踩的坑是:为了省几个寄存器,把数据流固定成单一模式,短期看RTL简单了,长期看算子库和编译器要写大量特殊处理逻辑。硬件上多出来的一点面积,往往能省掉软件上几个月的工程量。
2.4 低精度量化不只是软件的事
量化虽然看起来是软件层的技术,但硬件如果不配合,精度和性能都很难看。以INT8为例,硬件要有饱和截断逻辑,累加器宽度要足够,避免中间累加溢出。很多人只关注乘法器位宽,忽略了累加器位宽。INT8乘INT8的结果是16位,累加器至少要32位,否则连续累加几百次后高位数截断会产生严重误差。
经验值是:卷积层累加器32位起步,深度可分离卷积的累加器可以放宽到48位,因为这类操作累加次数往往更多。另一个容易忽略的是per-channel量化。逐张量量化对模型精度影响大,硬件如果只支持per-tensor的scale,量化效果会很受限。但per-channel量化会增加硬件复杂度,例如每个输出通道的scale不同,需要在累加之后按通道做乘法和位移操作。建议硬件从设计之初就预留per-channel的scale查表能力,哪怕第一版面积紧,也可以先在向量单元里用查表近似实现。
3. 编译器后端才是最费劲的环节:图优化、tiling与调度
3.1 图优化与算子融合,先砍掉无谓的访存
编译器拿到计算图之后,第一步不是急着选指令,而是做图优化。图优化里收益最大的一项是算子融合。一个典型的Conv + BatchNorm + ReLU结构,如果每个算子都独立执行,中间张量要写回DDR再读回来。一次224×224×128的中间张量,读写流量就是几十MB,放在边缘芯片上是无法接受的。
融合的做法是把BatchNorm的scale和shift吸收到卷积权重里,再把ReLU的阈值逻辑下沉到MAC阵列的输出级。这样中间结果留在片上寄存器或buffer里,不回DDR。我见过最夸张的项目,只是做了Conv+BN+ReLU融合,端到端性能就提升了近三倍,不是靠提升主频,而是靠减少访存。ResNet里的Add融合也是同理,Add的操作数之一可以留在片上,等主分支计算完再合并输出。融合做得好不好,直接决定了AI芯片软硬件协同的成功率。
3.2 Tiling策略与数据排布的艺术
做AI芯片编译器的人,一半时间都在跟tiling打交道。Tiling就是决定“一次算多大一块”。块太大,数据放不进buffer;块太小,DMA搬运太频繁,算力来不及发挥。我习惯把tiling拆成两个维度:输出通道块和空间块。输出通道块决定了权重切片的生命周期,空间块决定了输入激活的复用次数。
数据排布也不能忽视。NCHW排布对DMA连续搬运很不友好,通道维度的跨步会造成大量非连续访问。硬件如果支持NHWC或NC1HWxC2这样的排布,DMA可以连续地把C2维度的数据搬进buffer,编译器也方便做向量化。选哪种排布取决于硬件是否做了通道维度的向量化设计。我记得有个项目,软件团队强烈要求改为NC1HWxC2排布后,DMA效率从60%提升到了90%以上,代码逻辑还简化了。排布选择的底层原因是内存访问模式要跟MAC阵列的数据输入宽度对齐,而不一定跟算法习惯对齐。
3.3 指令调度:让DMA、计算、同步真正重叠
指令调度的核心是让DMA搬运和MAC计算同时进行。最经典的做法是double buffering,也叫乒乓缓冲。一块buffer用于当前计算,另一块buffer用于预取下一批数据。计算完当前块后,两块buffer角色互换。
那到底需要几层缓冲?需要看计算时间和搬运时间的相对大小。假设一个tile的计算时间是T_c,从DDR搬到buffer的时间是T_l。如果T_l明显小于T_c,两层buffer就够了;如果两者接近,可以用三层甚至四层buffer做更强的流水重叠。反过来,如果T_l远大于T_c,说明搬运是瓶颈,靠多buffer也救不回来,这时候应该增大tile,让每次搬运的数据量变大、搬运次数变少。
判断哪个环节是瓶颈,有一个简单办法:把所有DMA指令先注释掉,只留下计算指令,看模拟器里MAC利用率的极限;再把DMA打开,对比利用率下降幅度。如果下降超过20%,优先优化搬运;如果下降很小,瓶颈在计算或同步。这个方法虽然土,但比盲目调参靠谱得多。
3.4 动态shape怎么办
训练框架里很常见的动态batch、动态分辨率,到芯片编译器这里就是灾难。静态shape可以提前做好全部tiling和调度,编译器能把指令排得滴水不漏。动态shape则意味着内存分配、数据排布、tiling计划都得运行时重算,计算复杂度上了一个台阶。
实际产品里常用“限制语义+范围编译”的折中方案:不支持任意shape,但支持在预设范围内变化的分辨率,例如宽高必须是16的倍数,且从预设的几个档位中选择。编译器针对每个档位预生成一份编译产物,运行时按实际shape切换镜像。真正的任意动态shape,业界普遍采用JIT编译器方案,但这会牺牲启动时间和内存碎片,还要配套解释器和代码缓存,工作量大得惊人。如果项目不是非做不可,我个人建议先做范围限制,把JIT放到第二版。软硬件协同设计最忌讳的就是在第一个版本上追求完美,导致永远无法落地。
4. 一次完整的软硬件协同落地:从ONNX到板级跑通的实操记录
4.1 模拟项目X开始时只有一句话要求
假设我们要做一颗边缘AI加速芯片的原型验证,跑一个轻量级目标检测模型。需求是:INT8精度,实时30fps,整版功耗控制在1W以内。算法团队给了一个ONNX模型,结构是标准的主干加检测头,算下来每帧大约2.5 GMACs,即2.5 × 10^9次乘加。
先做顶层预算。帧率30fps换算到MAC计算量:2.5 × 30 = 75 GMACs/s,按TOPS口径就是150 GOPS INT8。硬件方案定2048个MAC,主频400MHz,峰值算力2048 × 400 × 10^6 × 2 = 1.64 TOPS。单看算力,利用率只需要15%就能满足需求,听起来很轻松,但真实瓶颈从来不是算力,而是访存。
4.2 访存预算与片上buffer参数计算
接下来算访存量。模型权重量大约是8MB,激活中间量最大的一层是6MB。如果每帧都要把权重全部从DDR读一遍,那么DDR读权重流量就是8MB × 30 = 240MB/s。中间激活如果频繁在DDR和片上之间交换,流量会冲到500MB/s以上。边缘场景常用LPDDR4,理论峰值12.8GB/s,但真实可用带宽往往只有理论值的一半,要留足余量。
算完访存,再回到片上buffer设计。模型最大的卷积层是128通道输入、128通道输出、空间尺寸64×64。如果按输出通道16、空间块16×16来切片,激活切片是16×16×128 = 32KB,权重切片是3×3×128×16 = 18KB,再加上累加buffer,总共约60KB。这个容量对于一颗边缘芯片来说很合理。所以L1 buffer容量定为128KB,留下充足的余量来适配其他层。
4.3 编译映射与调度结果回放
硬件配置定了,接下来就是编译器把每一层都映射上去。以主干第一个卷积层为例,输入是224×224×3,但第一个卷积的通道数很小,直接整层计算,不需要tiling。真正的挑战在中间的128通道层。编译器会生成这样的循环结构:
for oc_block in range(0, 128, 16): for spatial_block in range(0, 64, 16): # 并行发起DMA load_weight(oc_block, all_ic) # 搬运权重切片 load_activation(spatial_block, all_ic) # 搬运激活切片 wait_dma_done() # MAC阵列计算 compute_tile(oc_block, spatial_block) # 触发结果写回 store_output(oc_block, spatial_block)第一版调度跑下来,MAC利用率只有38%。性能分析显示,DMA等待占了很大比例:每算完一个tile,都要等下一个tile的激活搬完才开始,指令流水在同步点出现了长气泡。改法是把计算循环拆成两段:先发起下一个tile的DMA搬运,再执行当前tile的MAC计算,形成计算搬运重叠。这么一改,MAC利用率提到了72%,帧率也达到了31fps,超过需求一点点。
4.4 精度校准与量化方案的落地
性能达标后,精度校验又出了幺蛾子。硬件模型对INT8算子做了位精确模拟,精度对比只掉了0.4%,看起来可以接受。但跑板级测试时发现个别输出通道的误差明显偏大。定位后发现是per-channel量化缺失,前3个输出通道的权重分布和其余通道差异很大,per-tensor量化把高位细节截没了。
当时是在最后一层换成了per-channel量化,硬件用向量ALU查表完成scale缩放,精度恢复了。这个案例也说明了一个规律:量化方案不能一开始就按“最省硬件”去定,必须拿真实模型跑一遍分布统计,再决定哪些层需要per-channel。软硬件协同的意义恰好在这里,硬件留了per-channel scale的支持,软件在后期才能有回旋余地。
5. 常见问题与排查技巧:性能、精度、稳定性一个都不能少
5.1 性能只有理论峰值的一小半,先查什么
我见过太多项目一到性能调优就陷入玄学。其实性能问题的排查路径很有规律,按优先级应该是:先查DMA带宽是否有溢出,再查buffer bank冲突,最后查同步指令是否过多。DMA带宽如果要查,直接在cycle approximate模型里看DMA空闲周期比例,如果DMA模块一直忙碌而MAC阵列大量空闲,说明在等数据,优先级最高。
bank冲突的典型特征是:计算时间没变化,但DMA读取时间异常增大。因为两个bank同时访问同一个数据bank时,访问会被串行化。第一版硬件如果没做多层bank交叉,编译器就要通过修改数据排布来避让。同步指令过多的问题更容易定位,看指令trace里有大量SYNC等待点。解决办法是加大流水深度,或者把同步粒度从“每个tile同步”改成“每两个tile同步一次”。
5.2 精度不达标:列表找偏差,别猜
精度回归是最折磨人的。我的排查清单通常是:先确认硬件仿真用的量化参数和训练后校准用的参数完全一致;再看累加器位宽够不够;接着看激活输出的截断用的饱和还是环绕;然后看BatchNorm融合后的数值边界,特别是推理时的均值和方差是否更新正确。
如果这些都没问题,还有一招很实用:导出关键中间张量,在仿真器里逐层对比浮点参考模型和定点硬件模型的输出差值,找出第一个误差放大的层。往往定位到那一层后会发现,某个conv的输出通道由于没有做scale对齐,在16位中间表示上就丢了两三个bit。精度问题一定不要靠肉眼猜,每一轮对比都要有量化数据支撑。
5.3 编译失败和运行时挂起怎么办
运行时挂起最常见的原因是同步点缺失。多核共享同一个buffer时,一个核还没写完,另一个核已经开始读,数据被撕成了两半。这种问题的典型故障是结果周期性错乱,不是完全随机。
排查思路是把多核任务一个个关掉,先单核跑全流程,确认通过后再逐个加核。加核后如果挂了,就用仿真器dump同步指令附近的执行时序,看两个核的完成信号是否存在错位。还有一种隐蔽的挂起是DMA地址越界,数据排布做了tiling后,最后一个tile的尺寸不是对齐粒度导致的。解法是在编译器生成DMA描述符时做地址边界检查,硬件侧则增加保护寄存器,访问越界直接触发中断,而不是静默吞掉。
5.4 常用排查速查表
| 症状 | 先查这个方向 | 常见解法 |
|---|---|---|
| MAC利用率低,DMA很忙 | 搬运带宽不足或tile偏小 | 增大tile、开启双层DMA流水 |
| 计算时间正常但总周期变长 | 同步指令过多,流水气泡大 | 降低同步频率、加深buffer流水 |
| 线上误差比仿真大 | 板级量化参数不一致 | 统一校准流程、固化scale表 |
| 某个通道偏差明显 | 缺少per-channel量化 | 对离群通道单独scale |
| 多核运行结果偶发错乱 | 同步缺失或buffer互踩 | 补SYNC、加锁或分bank隔离 |
| DMA搬运速率异常 | bank访问冲突 | 调整数据排布或增加bank数量 |
6. 写在最后:一点真正值钱的流程经验
最后分享一个我踩过多次坑后的体会。软硬件协同设计听起来像是一个技术问题,但其实是个流程问题。每个项目最值钱的资产不是某一段RTL,也不是某一段编译优化pass,而是那张“模型到算子到数据流到指令到微架构”的全栈对照表。每次改动任何一个环节,都要回归这张表来看影响面,而不是头痛医头。
另一个小技巧是:在硬件里预留几个简单的调试寄存器,最好能记录MAC空闲周期、DMA等待周期、同步等待周期。编译器也预留一个可以dump指令调度的开关。这两个看起来不起眼的设计,会在调试阶段省掉你一半以上的时间。没有这些计数器,所有性能问题都像在黑暗里摸象。如果让我只给一条建议,那就是先把cycle approximate模型做得足够准,再开始写RTL。模型不准,后面所有软硬件联调都是在给错误的地基打补丁。