1. AI芯片软硬件协同设计的整体思路拆解
1.1 为什么AI芯片不能只谈硬件
很多人第一次接触AI芯片,脑子里想的都是算力、TOPS、制程这些硬指标,觉得只要把MAC阵列堆得足够大、把频率拉得足够高,性能自然就上去了。我刚开始也是这么想的,直到真正上手做了一版推理加速器的原型,才发现事情远没有这么简单。
AI芯片和传统通用CPU最大的区别在于,它的价值几乎完全由“跑什么模型、跑多快、多省电”来定义。而这三件事,没有一件是纯硬件能单独决定的。同一个矩阵乘法单元,配上不同的数据复用策略、不同的片上存储层次、不同的指令调度方式,实测性能能差出三到五倍。这就是为什么业内一直强调软硬件协同设计——硬件提供算力的上限,软件决定你能逼近这个上限到什么程度。
打个比方,硬件像是修了一条多车道高速,软件则是交通调度系统。路修得再宽,如果匝道设计不合理、信号灯配时糟糕,照样堵成一锅粥。AI芯片里的数据搬运就是那个“匝道”,而编译器调度就是“信号灯”。
1.2 软硬件协同的核心矛盾在哪里
协同设计听起来很美好,但实操中最大的痛点是:硬件团队和软件团队往往在不同的时间线上工作。硬件流片一次动辄几个月甚至更久,而模型结构可能几周就迭代一轮。这就导致一个尴尬局面——硬件做出来的时候,它优化的那个模型可能已经过时了。
所以成熟的团队会采取“硬件留冗余、软件做适配”的策略。硬件层面预留足够的可编程性,比如可配置的片上缓存大小、可切换的数据流模式;软件层面则通过编译器把新模型映射到已有硬件上。这个思路的关键在于找到“通用性”和“效率”的平衡点——太通用就变成CPU了,太专用又没法适应新模型。
我个人的经验是,对于边缘推理场景,把常见的算子(卷积、全连接、池化、激活)做成硬加速单元,同时保留一个可编程的向量处理单元来处理长尾算子,这个组合的性价比最高。云端训练场景则另当别论,对灵活性的要求高得多。
1.3 从模型到芯片的完整链路
一条完整的软硬件协同链路大致是这样的:模型训练完成后,经过图优化、算子融合、量化压缩,然后由编译器做算子到硬件的映射和调度,生成指令流或配置信息,最后由运行时系统加载到芯片上执行。
这个链路里每一步都可能成为瓶颈。图优化没做好,冗余算子会浪费带宽;量化策略不当,精度掉得厉害;调度不合理,计算单元大量空转。我见过不少项目,硬件指标很漂亮,但端到端跑下来还不如一个优化良好的GPU方案,问题就出在链路的某一环没打通。
2. 核心硬件模块的设计细节与实操要点
2.1 计算阵列:MAC单元的排布与数据流选择
计算阵列是AI芯片的心脏。主流的做法是二维MAC阵列,但阵列怎么排、数据怎么流,里面的门道很多。常见的数据流有四种:权重固定、输出固定、行固定和No Local Reuse。
权重固定数据流适合卷积层,权重加载一次可以复用很多次,能有效降低权重带宽压力。输出固定则适合全连接层,输出结果在阵列里累积,减少部分和的搬运。实际芯片里往往是混合策略,不同层用不同数据流。
阵列尺寸的选择也有讲究。假设你要支持3x3卷积、输入通道256、输出通道256,如果阵列是128x128,那一个输出通道需要分两个周期算完。阵列太小,调度开销大;阵列太大,利用率上不去,因为边缘层的通道数往往没那么多。我的经验是,阵列边长取常见通道数的公约数附近比较划算,比如64或128,这样大部分层都能整除,减少padding浪费。
注意:阵列尺寸不是越大越好。我实测过256x256的阵列跑MobileNet系列,利用率经常掉到30%以下,因为深度可分离卷积的通道数太少了,大阵列根本喂不饱。
2.2 片上存储层次:为什么SRAM比Cache更受青睐
AI芯片的片上存储设计和CPU很不一样。CPU依赖多级Cache加复杂的替换策略,但AI芯片更倾向于用软件管理的SRAM Scratchpad。原因很简单:AI计算的访存模式相对确定,编译器可以在编译期就知道数据什么时候用、用多久,与其让硬件猜,不如让软件直接管。
典型的层次是:寄存器文件 -> 近端SRAM(存权重和部分和) -> 远端SRAM(存输入特征图) -> DRAM。近端SRAM的带宽要做得非常高,因为它是计算单元的直接数据来源。远端SRAM容量大一些,用来做数据缓冲和复用。
容量怎么定?一个粗略的估算方法是:近端SRAM至少要能放下一个计算阵列一次迭代所需的权重和输入块。比如128x128阵列,权重是128x128x2字节(假设INT16),那就是32KB,输入块类似,所以近端SRAM至少64KB起步。远端SRAM则要能放下至少两到三层的特征图,方便做层间融合。
2.3 数据搬运:DMA设计中的坑
DMA看起来简单,但它是AI芯片里最容易出问题的地方之一。因为AI计算的数据搬运量极大,DMA的效率直接决定了整体性能。
第一个坑是地址对齐。很多DMA引擎要求传输起始地址按一定字节对齐,如果特征图的起始地址没对齐,要么报错要么性能暴跌。解决办法是在内存分配时就做好对齐,编译器在分配张量时预留padding。
第二个坑是描述符开销。如果每个小传输都要写一个描述符,描述符本身的开销可能比数据还大。好的做法是支持多维传输描述符,一次描述一个张量块,硬件自动展开。
第三个坑是和计算的同步。DMA搬完了计算单元还不知道,或者计算单元算完了DMA还没准备好下一块,都会造成空转。通常用双缓冲或多缓冲来解决,但缓冲深度要算好,太浅了盖不住延迟,太深了浪费SRAM。
2.4 量化与精度:INT8不是万能药
量化是AI芯片绕不开的话题。INT8能把带宽和算力需求降到FP32的四分之一,但精度损失是实打实的。我踩过的坑是:有些层对量化特别敏感,比如第一层卷积和最后的分类层,强行INT8会导致精度掉好几个点。
比较稳妥的做法是混合精度。大部分层用INT8,敏感层保留INT16或FP16。硬件上要支持这种混合精度,计算单元得能处理不同位宽的操作数。实现上可以做成可配置的,比如一个MAC单元支持INT8xINT8和INT16xINT16两种模式,通过配置寄存器切换。
另一个经验是量化感知训练。在训练阶段就模拟量化误差,让模型自己去适应,比训练完再量化效果好得多。这一步虽然属于软件范畴,但硬件设计时要预留对应的支持,比如对称量化和非对称量化的零点处理。
3. 软件栈与编译器的实操实现
3.1 图优化:算子融合的收益与代价
编译器前端的第一件事是图优化。最常见的操作是算子融合,把Conv+BN+ReLU合成一个算子。这样做的好处是减少中间结果的写回和读取,省带宽又省时间。
但融合不是无脑合。我遇到过把BN融合进Conv之后,权重范围变大,量化误差也跟着变大。所以融合之后要重新做量化校准,不能直接用融合前的参数。
另一个常见优化是常量折叠,把编译期就能算出来的子图提前算好。比如一些固定的预处理操作,完全可以在编译期完成,不用放到运行时。
图优化还要考虑硬件约束。比如某个算子硬件不支持,就得拆成多个支持的算子,或者回退到可编程单元。这个决策要在编译期做好,运行时再判断就来不及了。
3.2 算子映射:从计算图到硬件指令
算子映射是编译器的核心。简单说,就是把一个卷积算子拆成硬件能执行的指令序列。这里面涉及几个关键决策:数据怎么分块、循环怎么排、片上存储怎么分配。
以卷积为例,假设输入是HxWxC,卷积核是KxKxM。硬件阵列是NxN。映射时要决定:先沿哪个维度分块?块多大?循环顺序是什么?
我的经验是,优先让M维度(输出通道)对齐阵列尺寸,因为权重在M维度上是独立的,容易并行。如果M不够,再考虑沿H或W分块。循环顺序上,把复用最多的数据放在最内层循环,减少重复加载。
映射完之后要生成指令。指令的粒度很关键,太细了指令开销大,太粗了调度不灵活。通常的做法是定义几个宏指令,比如“加载权重块”“执行矩阵乘”“写回结果”,硬件内部再展开成微操作。
3.3 内存分配:张量生命周期管理
内存分配直接影响片上SRAM的利用率。核心思路是分析每个张量的生命周期,让生命周期不重叠的张量共享同一块内存。
实现上可以用一个简单的贪心算法:按张量首次使用时间排序,依次分配,如果当前没有空闲块就复用已经死亡的张量占用的块。更复杂的可以用线性扫描或者整数规划,但贪心在大多数情况下够用了。
要注意的是,有些张量虽然生命周期结束了,但它的数据还有用(比如被后续算子引用),这种不能复用。编译器要维护一个引用计数,计数归零才能回收。
提示:内存分配时留一点余量。我吃过亏,算得刚刚好,结果运行时因为对齐或者临时缓冲的需求,SRAM不够用了,只能回退到DRAM,性能直接腰斩。
3.4 运行时调度:双缓冲与流水线
运行时调度的目标是把计算和搬运重叠起来,让DMA和计算单元同时干活。经典的做法是双缓冲:计算当前块的同时,DMA预取下一块。
双缓冲的关键是缓冲深度的计算。假设计算一块需要T_compute,搬运一块需要T_dma,那缓冲深度至少要能盖住max(T_compute, T_dma)的时间。如果T_dma大于T_compute,说明搬运是瓶颈,要增加缓冲深度或者优化DMA。
流水线的粒度也要考虑。太细了同步开销大,太粗了缓冲需求大。一般以一层或者一个算子块为单位比较合适。
4. 常见问题与排查技巧实录
4.1 性能不达预期怎么排查
性能问题是最常见的。我的排查顺序是:先看计算单元利用率,再看带宽利用率,最后看同步开销。
计算单元利用率低,通常是数据供给不上。检查片上存储的带宽是否够,DMA是否成为瓶颈,数据复用是否充分。带宽利用率低,可能是访问模式不好,比如跨步访问、随机访问。同步开销大,往往是流水线没排好,或者缓冲深度不够。
一个实用的技巧是加性能计数器。在硬件里埋一些计数器,统计计算周期、等待周期、DMA传输量等。这些数据比仿真波形直观得多,能快速定位瓶颈。
4.2 精度掉点怎么定位
精度问题比性能问题更难查,因为它往往不是单一原因造成的。我的做法是逐层对比:把硬件跑出来的每层输出和软件参考实现对比,看哪一层开始出现明显偏差。
常见原因有几个:量化误差累积、算子融合引入的数值问题、累加器位宽不够导致溢出。累加器位宽是个容易被忽略的点,INT8乘法的结果累加,如果累加器只有16位,几百次累加就溢出了。通常累加器要32位才保险。
另一个坑是激活函数的近似实现。硬件里为了省面积,可能会用分段线性近似代替精确的指数或tanh,如果近似精度不够,会影响最终结果。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 计算单元利用率低 | 数据供给不足 | 查DMA带宽和SRAM带宽 | 增加缓冲、优化复用 |
| 精度掉点 | 量化误差或累加溢出 | 逐层对比参考实现 | 混合精度、加宽累加器 |
| 运行时报内存不足 | 内存分配过紧 | 查张量生命周期 | 留余量、优化复用 |
| DMA传输慢 | 地址不对齐或描述符开销大 | 查传输日志 | 对齐分配、多维描述符 |
| 流水线停顿 | 缓冲深度不够 | 查同步计数器 | 加深缓冲、调整粒度 |
4.4 几个容易忽略的实操细节
第一个是时钟域 crossing。AI芯片里往往有多个时钟域,计算单元一个、DMA一个、外部接口一个。跨时钟域的信号要处理好同步,否则会出现亚稳态,表现为偶发的数据错误,极难排查。
第二个是复位顺序。上电复位时,如果DMA先于计算单元复位,可能会往还没准备好的SRAM里写数据。复位顺序要严格定义,通常是从外到内,先复位接口,再复位DMA,最后复位计算核心。
第三个是温度对时序的影响。芯片跑久了温度升高,时序可能变差。如果时序余量留得不够,高温下会出现偶发错误。做原型验证时要在高温环境下多跑一段时间。
5. 从原型到产品的工程化考量
5.1 验证策略:仿真、FPGA原型与流片
AI芯片的验证工作量巨大,光靠仿真跑不完所有case。我的策略是分层验证:模块级用仿真,系统级用FPGA原型,流片前再做一轮门级仿真。
FPGA原型特别重要,因为它能跑真实的模型和数据集,暴露的问题比仿真多得多。但FPGA的频率通常比最终芯片低很多,所以性能数据只能做参考,不能直接外推。
验证时要覆盖边界情况:最小的张量、最大的张量、非对齐的地址、极端的数据分布。我见过一个bug只在输入全零的时候出现,仿真没覆盖到,流片后才被发现。
5.2 功耗优化:动态电压频率调节
功耗是AI芯片的硬指标,尤其是边缘场景。动态电压频率调节是常用手段,根据当前负载调整工作点。计算密集时升频升压,等待数据时降频降压。
但调频调压有延迟,切换太频繁反而浪费。通常的做法是设置几个档位,根据负载的滑动平均值来切换,避免频繁抖动。
另一个手段是时钟门控。计算单元空闲时把时钟关掉,能省不少动态功耗。但门控的粒度要合适,太细了控制逻辑开销大,太粗了省不了多少。
5.3 可扩展性设计:从单核到多核
单核性能总有上限,多核是必然趋势。但多核不是简单复制,核间通信和任务划分是关键。
常见的架构是多个计算核共享一块全局SRAM,通过片上网络互联。任务划分可以按层分,也可以按通道分。按层分实现简单,但并行度受限于层的依赖关系。按通道分并行度高,但需要处理跨核的归约。
片上网络的带宽要算好。如果核间通信量太大,网络会成为瓶颈。我的经验是,尽量让每个核处理独立的数据块,减少核间依赖。
5.4 工具链的易用性
最后说一个容易被技术团队忽略的点:工具链的易用性。芯片做得再好,如果编译器难用、调试工具匮乏,客户是不买账的。
好的工具链应该让用户能方便地导入模型、查看映射结果、分析性能瓶颈。日志要清晰,报错要明确。我见过一些芯片,性能其实不错,但工具链太烂,用户宁愿用慢一点的方案。
做工具链时多和实际用户聊,看他们怎么用、卡在哪里。很多改进点不是技术难题,只是没想到而已。
6. 一些个人体会
做AI芯片这几年,最大的感受是:硬件决定下限,软件决定上限。一颗芯片流片出来,它的峰值算力就固定了,但实际能跑出多少,全看软件栈的功力。我见过峰值算力一般的芯片,因为编译器做得好,端到端性能反而超过峰值更高的竞品。
另一个体会是,不要过早优化。先把链路跑通,再找瓶颈。很多团队一上来就抠细节,结果整体架构没搭好,局部优化再多也白搭。
还有就是多动手。看论文和实际做芯片是两回事,很多坑只有自己踩过才知道。哪怕是用FPGA搭个简易原型,也比纯纸上谈兵强得多。
最后分享一个小技巧:做架构设计时,先写一个行为级的性能模型,用Python或者C++都行,把计算、存储、带宽都建模进去。这个模型跑起来很快,能快速评估不同架构方案的优劣,比直接写RTL再仿真高效得多。等架构定下来了,再往下做细节实现。