1. 从"算力焦虑"说起:为什么第八篇还要聊软硬件协同
做AI芯片这行的人,最近几年都有一个共同的体感:单看纸面参数,芯片的峰值算力年年翻倍,但真正把模型部署上去,能跑出来的有效吞吐往往只有标称值的三四成。这个落差不是某一颗芯片的问题,而是整个"软硬件设计"链条上每一环都在漏。我把它叫做"算力焦虑"——不是算力不够,而是算力用不出来。
这个系列写到第八篇,前面七篇陆陆续续聊过架构选型、数据流、量化、编译栈这些话题。到了这一篇,我想把视角收一收,专门讲一个最容易被忽视、但决定成败的东西:软硬件的协同设计边界。也就是说,哪些事情应该交给硬件去做,哪些事情必须留给软件去兜,这条线画在哪里,直接决定了你这颗芯片是"能用"还是"好用"。
如果你正在做AI加速器的架构定义、在写算子库、在搭编译工具链,或者只是想把一个模型高效地跑到某块板子上,这篇内容都值得你花时间看完。我不会给你一堆空洞的方法论,而是把我在实际项目里踩过的坑、算过的账、做过的取舍,一条条摊开来讲。核心关键词就三个:软硬件协同、数据流设计、算子映射。这三个词贯穿全文,也是我认为AI芯片设计里最值钱的三块硬骨头。
先说一个反直觉的结论:AI芯片的性能瓶颈,八成不在计算单元本身,而在数据搬运和软硬件接口的错配上。你堆再多的MAC阵列,如果数据喂不进去、结果搬不出来、算子映射对不齐,那这些计算单元就是在空转烧电。所以这一篇的主线,是围绕"数据怎么流、算子怎么落、软硬件怎么分工"来展开的。
2. 数据流设计:AI芯片真正的性能命门
2.1 为什么说"搬运"比"计算"更贵
先算一笔账,这笔账我在很多次架构评审里都拿出来讲过。假设你有一颗芯片,片上SRAM带宽是每周期读取256字节,主频1GHz,那么理论片上带宽就是256GB/s。听起来不小。但如果你要跑一个典型的卷积层,权重加激活的数据量动辄几MB到几十MB,而片上SRAM可能只有几MB。这意味着数据必须反复在片外DRAM和片上缓存之间倒腾。
片外DRAM的带宽通常只有几十GB/s,延迟还是片上访问的几十倍甚至上百倍。于是问题就来了:计算单元一个周期能算几百次乘加,但喂给它一个操作数可能要等几十个周期。这就是所谓的"内存墙"。我见过太多项目,架构图上画着漂亮的PE阵列,理论算力标到几百TOPS,结果实测跑ResNet只有个位数TOPS的有效性能,根子全在这里。
所以数据流设计的第一个原则:尽量让数据在片上多待一会儿,让每一次搬运都物尽其用。这就引出了数据复用这个概念。卷积运算里,同一个权重会被很多个输出像素复用,同一份激活也会被多个卷积核复用。好的数据流设计,就是把这些复用机会榨干。
2.2 三种主流数据流的取舍逻辑
业内常提的数据流大致分三类:权重固定(Weight Stationary)、输出固定(Output Stationary)、行固定(Row Stationary)。名字听着抽象,我用生活化的方式解释一下。
权重固定,好比你把菜谱贴在灶台边上,厨师(计算单元)反复看同一份菜谱做不同的菜。它的好处是权重只加载一次,适合权重复用度高的场景,比如大卷积核。缺点是激活要不停换,激活带宽压力大。
输出固定,好比厨师守着一口锅,把需要的食材一样样往里加。它适合输出复用度高的场景,比如全连接层。缺点是权重和激活都得频繁搬。
行固定,是前两者的折中,试图同时复用权重和激活,在卷积场景下往往能拿到更好的综合能效。但它的控制逻辑复杂,对编译器和调度器的要求高得多。
我个人的经验是:没有绝对最优的数据流,只有和你的目标模型、目标工艺最匹配的数据流。如果你主打的是Transformer类模型,注意力机制里的矩阵乘对权重和激活的复用模式和CNN完全不同,照搬CNN那套数据流往往事倍功半。这时候你可能需要重新设计片上缓存的层次和搬运策略。
2.3 双缓冲与流水线:把等待藏起来
数据流设计里有一个几乎必用的技巧:双缓冲(Double Buffering)。原理很简单,准备两块缓冲区,一块在算的时候,另一块在搬。等算完了,两块角色互换。这样计算和搬运就能重叠起来,把搬运的延迟藏到计算背后。
但这里有个坑,很多人第一次做会踩:双缓冲的深度不是越多越好。缓冲区开太多,片上SRAM被占满,留给权重和中间结果的空间就没了,反而逼着你去访问片外。我一般建议从两级缓冲起步,根据实测的搬运延迟和计算时间比例再决定要不要加到三级。判断标准很直接:如果搬运时间已经小于计算时间,再加缓冲就是浪费面积。
还有一个细节是流水线的粒度。粗粒度流水(比如按整个层来切)控制简单但灵活性差,细粒度流水(按tile切)灵活但调度开销大。实测下来,按tile切、tile大小取到能填满计算阵列的1.5到2倍,是个比较稳的甜点区。
3. 算子映射:编译器与硬件的"翻译"博弈
3.1 算子映射到底在解决什么问题
硬件只会做它被设计出来的那几件事,比如矩阵乘、卷积、激活。而一个真实模型里有几百种算子,还有各种奇怪的shape和边界情况。算子映射要做的,就是把模型里的算子"翻译"成硬件能执行的指令序列,同时尽量让硬件跑满。
这个翻译过程,本质上是编译器的工作。但AI芯片的特殊之处在于,硬件往往有很强的专用性——比如某个数据排布格式、某个特殊的累加器结构。编译器如果不懂这些硬件细节,生成的代码就会很低效。这就是为什么AI芯片的编译器不能是通用的,必须和硬件深度绑定。
我见过一个典型的失败案例:某团队用了一个通用编译器框架,硬件设计得也不错,但编译器不知道硬件的片上缓存分bank、不知道bank冲突的规避规则,结果生成的代码在访问缓存时频繁冲突,性能直接砍半。后来他们重写了调度部分,把bank信息喂给编译器,性能才回来。这个教训很贵。
3.2 算子融合:省的不只是时间
算子融合是算子映射里性价比最高的一招。把相邻的几个算子合并成一个kernel执行,中间结果不落回片外,直接在片上传递。比如Conv + BN + ReLU这个经典组合,如果不融合,你要写三次片外、读三次片外;融合之后,中间结果全程在片上,片外访问直接省掉一大半。
但融合不是无脑合。有几个约束你得盯着:
- 数据依赖:只有真正前后依赖、中间结果不被别处引用的算子才能融合。
- 片上容量:融合后的中间结果得放得下,放不下就得切分,切分又可能引入额外开销。
- 并行度:融合后的大kernel可能并行度不够,填不满计算阵列,反而降低利用率。
我的经验是,融合的收益在访存密集型的算子上最明显,在纯计算密集的算子上收益有限。所以做融合策略时,优先盯那些"读多写多但计算少"的算子,比如各种element-wise操作、归一化层。
3.3 动态shape:编译器最头疼的对手
真实业务里,输入shape经常是动态的——今天batch是8,明天是16,序列长度还变来变去。这对算子映射是巨大的挑战,因为很多硬件的高效执行路径是"编译期确定"的,shape一变,之前优化好的调度就废了。
应对动态shape,业内大致两条路。一条是运行时编译,shape变了就现场重新编译,灵活但编译开销大,延迟敏感的场景受不了。另一条是多版本预编译,把常见的几档shape提前编译好,运行时按需选。后者更实用,但你要能预测出业务里shape的分布。
我倾向于混合策略:对高频shape预编译,对低频shape走运行时编译兜底。同时硬件层面留一些"shape无关"的通用执行路径,虽然效率低一点,但保证任何shape都能跑起来,不至于直接报错。
4. 软硬件分工:哪些交给硬件,哪些留给软件
4.1 划分边界的三个判断维度
软硬件分工是AI芯片设计里最考验判断力的地方。划错了,要么硬件做了一堆用不上的功能浪费面积,要么软件被迫用低效的方式模拟硬件该做的事。
我一般用三个维度来判断:
第一,频率。高频执行的操作,值得做成硬件。低频的、一次性的,交给软件。比如激活函数里ReLU几乎每个模型都用、每个像素都过,做成硬件天经地义;但某个冷门激活函数,做成软件查表就够了。
第二,灵活性需求。如果这个操作未来可能变、算法还在演进,就别焊死在硬件里。比如注意力机制的各种变体层出不穷,硬件最好只提供基础的矩阵乘和softmax原语,具体怎么组合交给软件。
第三,能效敏感度。对能效极度敏感的操作,硬件实现通常比软件快几个数量级。比如量化里的定点乘加,硬件一个周期搞定,软件模拟要几十个周期。
把这三个维度画成一个决策矩阵,大部分操作该放哪边就清楚了。
4.2 指令集设计:给软件留够"抓手"
硬件给软件留的接口,核心就是指令集。AI芯片的指令集设计和CPU很不一样,它往往需要一些"粗粒度"的指令,比如"执行一个tile的矩阵乘"这种,而不是一条条细碎的load/store。
但粗粒度指令有个问题:软件对硬件的控制力变弱了。如果硬件内部调度不透明,软件就没法针对性地优化。所以我的建议是,指令集要粗中有细——高层用粗粒度指令表达意图,同时暴露一些细粒度的控制位,让编译器在需要时能干预硬件行为,比如指定缓存策略、指定数据排布。
这里有个实操心得:指令集的文档一定要和编译器团队一起写。我见过硬件团队自己定义了一套指令,文档写得漂亮,但编译器团队拿到手发现根本没法生成高效代码,因为指令的语义和编译器的中间表示对不上。这种返工非常痛苦。
4.3 运行时系统:别让调度成为瓶颈
硬件和编译器都搞定了,还有一层容易被忽略:运行时系统。它负责在芯片上调度任务、管理内存、处理多模型并发。这一层做不好,前面的优化全白搭。
我踩过的一个坑是内存管理。运行时如果频繁做动态内存分配和释放,碎片化会很严重,跑久了性能就往下掉。后来我们改成预分配内存池,按模型的生命周期管理,稳定性立刻上来了。这个经验很朴素,但真的管用。
另一个坑是任务调度粒度。调度太细,开销大;调度太粗,负载不均。实测下来,按"一个算子的一个tile"作为调度单元,配合一个轻量的任务队列,是个比较平衡的选择。
5. 实测中的那些"意外":从纸面到硅片的落差
5.1 仿真跑得欢,上板就拉胯
几乎每个AI芯片项目都会经历这个阶段:仿真环境里性能曲线漂亮得不行,一上FPGA或者流片回来,性能直接打骨折。原因通常有几个。
一是仿真模型过于理想。仿真里假设内存访问零延迟、带宽无限,真实硬件里这些假设全不成立。二是时钟和功耗的墙。仿真跑在理想频率下,真实芯片受限于功耗和散热,频率上不去。三是外设和接口的瓶颈。仿真里往往忽略了DDR控制器、总线仲裁这些环节的开销。
我的应对办法是,尽早做带时序的仿真,尽早把真实的内存模型接进来。哪怕仿真慢一点,也比上板后才发现问题强。另外,FPGA原型验证这一步千万别省,它能暴露大量仿真发现不了的问题。
5.2 精度与性能的跷跷板
量化是AI芯片提性能的利器,但精度和性能之间的平衡非常微妙。INT8通常能带来数倍的性能提升,但有些模型对量化极其敏感,掉点严重。
我的经验是,量化要分层做,不要一刀切。对精度敏感的层(比如第一层、最后一层、某些归一化层)保留高精度,对中间的大量卷积层大胆用低精度。同时配合量化感知训练,让模型在训练阶段就适应低精度,比训练后直接量化效果好得多。
还有一个细节:硬件对量化的支持要留有余地。比如你设计的是INT8乘加,但最好也支持INT4甚至二值化的路径,因为算法演进很快,说不定明年主流就变了。
5.3 多模型并发下的资源争抢
真实业务里,芯片上往往不是跑一个模型,而是多个模型并发。这时候计算单元、片上缓存、片外带宽都成了争抢对象。如果调度不当,会出现"一个模型把资源占满,其他模型饿死"的情况。
解决思路是给资源做配额和优先级。计算单元按时间片轮转,缓存按模型划分区域,带宽按优先级分配。听起来像操作系统那套,确实就是那个思路。但AI芯片的实时性要求更高,调度决策要做得更快,所以硬件层面最好有一些辅助机制,比如硬件队列、硬件信号量,别全压在软件调度器上。
6. 给正在做AI芯片的你:几条掏心窝的建议
6.1 先想清楚目标模型,再定架构
我见过太多团队,先埋头把硬件架构设计得很"通用",结果发现目标模型根本用不上那些通用性,反而在关键算子上效率低下。架构设计一定要从目标模型出发。你主打CNN还是Transformer,主打训练还是推理,主打云端还是端侧,这些问题的答案会彻底改变你的架构选择。
具体做法是,先拿几个代表性模型做profiling,看清楚算力、带宽、存储的真实需求分布,再据此定架构参数。别拍脑袋。
6.2 编译器团队要早介入,别等硬件定型
硬件和编译器是共生关系。硬件定型后再让编译器团队接手,往往已经晚了,因为很多硬件设计决策会直接影响编译器的优化空间。理想状态是,架构定义阶段编译器团队就参与进来,一起评估"这个设计编译器能不能生成高效代码"。
6.3 留足验证和调优的时间
芯片项目的时间表里,验证和调优往往被压缩得最狠,但这两块恰恰是最耗时的。我的建议是,验证和调优的时间至少占总周期的三分之一。流片一次成本极高,宁可前期多花时间验证,也别指望流片后靠软件补。
6.4 建立自己的性能模型
不要依赖厂商给的峰值算力数字,那玩意儿参考价值有限。建立自己的性能模型,把计算、访存、调度开销都算进去,用它来指导架构决策和软件优化。这个模型一开始可能不准,但随着你积累的实测数据越来越多,它会越来越靠谱,成为团队最宝贵的资产。
说到底,AI芯片的软硬件设计是一门"平衡"的手艺。硬件给你下限,软件决定上限,而协同设计决定了你能走多高。这个系列写到第八篇,我越来越觉得,真正拉开团队差距的,不是谁堆的算力多,而是谁把软硬件这条缝补得更严实。希望这些从实战里抠出来的经验,能帮你在自己的项目里少走点弯路。