ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI芯片软硬件协同设计实战:从计算阵列到编译器优化

AI芯片软硬件协同设计实战:从计算阵列到编译器优化

AI 芯片的软硬件设计这件事,我前后参与了几个完整项目,从架构定义一路跟到软件工具链落地,最大的体会就是:芯片流片回来只是开始,配套软件能不能把硬件“伺候好”,才决定这颗芯片是样片还是产品。市面上讲AI芯片架构的文章很多,但真正聊“硬件和软件边界在哪里”的很少。这篇文章想讲的,就是把一个 AI 芯片项目推进到中后段时,那些绕不开的软硬件协同设计问题,包括硬件侧的计算阵列、存储层次、互联方案,软件侧的编译器、量化、运行时,以及两边对不上的时候怎么定位、怎么解决。内容大致对应这类项目里“第十个迭代节点”前后团队要啃的硬骨头,适合在做的工程师、系统软件开发者,也适合想入行异构计算的高年级学生。

我不会在这儿堆一堆架构名词然后戛然而止,而是尽量把每次决策背后的原因说清楚,比如为什么这里选脉动阵列不选SIMD,为什么编译器要管内存排布而不是只生成指令。这些细节平时散落在会议纪要和调试记录里,很少被整理成文,今天一并写出来。

1. 先捋思路:AI芯片的软硬件设计到底在设计什么

1.1 芯片的成功标准不是“流片回片”,而是“能快乐地跑起模型”

很多项目在立项阶段就搞反了一件事:把流片当作终点。实际上,一颗AI芯片流片回来,如果配套编译器不能把常见模型高效映射上去,如果操作系统的设备驱动和运行时调度器不稳定,这芯片就是一块昂贵的砖头。我见过不止一个项目,芯片功耗和面积都控制得不错,结果卡在软件栈交付上,最后项目评估非常被动。

所以现在业内普遍强调 hardware/software co-design,也就是软硬件协同设计。这不是一句口号,而是具体的工作分工:硬件架构在定义寄存器、存储结构、算力阵列的时候,就要把软件的写法考虑进去;软件在写编译器、算子库的时候,也要反过来给硬件提需求。

举个例子,硬件团队决定把片上SRAM容量从 4MB 扩到 8MB,听起来只是面积和功耗增加,但软件层面的影响是:编译器能否把更多中间张量留在片上,避免频繁把数据搬回片外DRAM。如果容量翻倍能把某几个关键模型的片上命中率从 60% 提到 90%,那这个面积代价就非常值。这个测算必须在架构定义早期就做,等RTL冻结了再改存储架构,代价是按人月算的。

软硬件协同设计落实到工程上,要明确三件事:第一,硬件寄存器接口和软件内存视图要提前约定;第二,硬件提供的抽象原语(比如DMA描述符格式、同步机制)必须能被编译器简洁地表达;第三,性能和精度的回归基准不能只有两三个模型,至少要有覆盖图像分类、检测、分割、自然语言处理的一整套基准集。

1.2 一个 AI 芯片项目的典型推进节点

讲到“第十个迭代节点”,阶段经历的节奏其实是类似的:

  • 第 1-2 轮:明确目标场景,确定算力指标和功耗budget,选择制程。
  • 第 3-4 轮:架构探索,用性能仿真器和已有模型基准做数据流模拟,权衡计算阵列形态和存储层次。
  • 第 5-6 轮:RTL实现与验证,同时软件团队基于虚拟原型开发编译器和运行时。
  • 第 7-8 轮:FPGA验证和emulation平台联调,把编译生成的代码跑在硬件模型上,开始真正的软硬件对账。
  • 第 9-10 轮:性能优化、精度对齐、软件工具链打磨,为流片做最终准备。

“第十个节点”基本对应的是“性能压测寄存器级调优 + 软件语义正确性收敛”的阶段。这个阶段最累,因为问题不再是单个模块能不能跑,而是整条链路从模型输入到硬件计算结果,在各种边界情况下能不能保持一致、性能和精度指标是否达标。

2. 硬件侧的核心决策:计算阵列、存储与互联

2.1 计算阵列怎么选:脉动阵列、SIMD还是“大小核”

AI芯片的核心计算资源,主流方案无非三类:脉动阵列(systolic array)、SIMD向量单元、以及“大算力阵列+小向量核”的混合结构。三者各有适用场景,不是越高端越好。

脉动阵列的做法是让数据在二维的PE(处理单元)阵列里像波浪一样流动,每个PE只管本地的乘累加,数据从相邻PE传来。它的最大优势是:数据复用性好,读片上的开销低,非常适合卷积这类计算密集、权重复用高的算子。缺点也明显:矩阵形状必须规整,遇到稀疏、动态形状的算子(比如某些NLP模型里的Gather操作)利用率会掉得很快。

SIMD向量单元则是每个周期处理一组向量数据,灵活性高,适合元素级操作和形状不规整的算子,但数据搬运和寄存器压力更大。混合结构是当前很多SoC型AI芯片的选择:主计算用脉动阵列扛卷积和矩阵乘,旁边配一组迷你向量核处理激活、归一化、池化和一些标量逻辑。

选型层面,我的建议是不要拍脑袋。拿目标模型集在性能模型上分别模拟三种方案的吞吐和利用率,再看面积和功耗预算匹配度。在第一版架构中,不妨保守一点:主阵列规模按最常跑的三五个模型定,别为了追峰值算力把阵列做大。峰值算力宣传好看,实际应用跑不出来是这个行业最常见的尴尬。

2.2 存储层次设计:片上SRAM与多级缓存的数据流约定

AI芯片的存储设计是最能体现软硬件协同水平的地方。片外DRAM带宽宝贵,一片高性能AI芯片如果在计算单元高负载时不断因为缺数据而stall,那算力再高也白搭。所以片上存储(通常是SRAM)负责缓存权重、输入和中间结果,容量规划要结合编译器实际能产生的数据复用模式来定。

存储层次一般分两级:一级是紧贴计算阵列的寄存器级buffer,做数据的暂存和重排;二级是容量较大的共享SRAM,像一个L2 cache但通常由软件显式管理而不是硬件自动缓存。显式管理听起来麻烦,但好处是编译器可以精确知道数据何时被搬到片上、何时可以释放。硬件自动cache管理听起来省心,但AI工作负载的访问模式高度可预测,硬件猜不准的反而不如软件算得准。

在寄存器接口设计上,最关键的一点是:给软件提供灵活的DMA描述符,支持多维度张量搬运、跨步访问、甚至简单的地址拼接能力。我第一次做芯片时,觉得DMA只要支持一维搬运就够了,结果软件团队为了把一个 [1,64,56,56] 的tensor拼成适合阵列的输入格式,写了一堆冗余搬运,带宽消耗翻倍。后来专门在DMA里加了2D搬运支持,软件代码简化了很多,带宽也下来了。

2.3 片上互联与SoC集成:别小看数据搬移

很多人关注计算阵列,我却更重视NoC(网络片上互联)和总线设计。AI芯片里最容易被低估的瓶颈就是数据搬移。计算峰值提上去了,数据还在片外或者从一个子系统搬到另一个子系统的路上,花的时间比计算还长。

片上互联设计有三个具体建议:

第一,区分高带宽流和低带宽控制流。计算阵列到SRAM之间的通路要尽量宽、尽量短,最好做成专用的高带宽直连;而CPU核、外设、调试接口这些低频率访问走通用总线,不要挤占高带宽通路。

第二,重视多核情况下的“广播”和“聚合”模式。数据并行切分到多个计算核时,每个核都需要读一份权重,或者处理完以后需要把部分和合起来。如果NoC不支持高效的广播/规约操作,软件就会被迫加上大量同步和中间拷贝,这个损耗在跑大模型时会被放大。

第三,给软件留足够的同步原语。硬件至少提供栅栏(barrier)、事件(event)、中断或轮询寄存器几种同步手段。编译器生成异步流水线时,最怕的就是同步代价太高。如果同步一次要几十个周期,小算子的异步化就完全没有收益。这个数字在架构阶段就要测出来。

3. 软件栈:编译器、量化与运行时

3.1 编译器:把算子映射到硬件,而不只是“翻译一下”

AI芯片的编译器核心工作是把计算图里的算子映射到计算阵列上,并且尽可能压榨硬件的利用率。以卷积为例,编译器要决定循环展开的顺序:哪些维度在空间上展开成PE阵列的并行度,哪些维度在时间上循环复用,权重和输入分别以什么方式搬运到片上。

这个决策直接与硬件的物理结构绑定。具体说,编译器要知道三件事:PE阵列的行列维度、片上buffer容量、DMA的搬运能力。如果编译器对这三件事一无所知,只能生成通用代码,那硬件的利用率大概率在 10%-30% 之间徘徊。

图优化层面,最常见也最有效的两个pass是算子融合和布局转换。算子融合把卷积后接的ReLU、Add等操作尽量合进一次计算流程,减少中间张量落片外的机会;布局转换则统一不同算子间的张量内存格式,比如把默认的NCHW排布转成更适合计算阵列的NHWC或tiled排布。这些优化看似平平无奇,但对最终性能的影响往往比指令级优化更显著。

3.2 量化不是简单截断:精度和性能的平衡

AI芯片普遍靠低精度数值格式来换取吞吐提升,最常见的是INT8和FP16,更激进的有INT4、INT2。但低精度不是“把fp32模型的权重截一下”就完事,这里面精度敏感层、混合精度、量化感知训练三件事必须配合起来做。

量化工作流里踩过最深的坑是“per-tensor vs per-channel”。刚开始图省事,整个tensor共享一组scale和zero_point,结果某几个通道的数值范围差异很大,小通道直接被噪点淹没,模型精度掉得离谱。改成per-channel之后,图像分类模型的精度损失从 3% 降到了 0.8% 以内。代价是硬件和编译器都要支持按通道读取scale,好在绝大多数现代AI芯片都支持了这个特性。

另外,务必建立一个精度回归自动化框架。每次代码改动后,把十几个代表性模型端到端跑一遍,对比fp32的基线输出,统计最大误差和平均误差。这个框架要足够快,最好在半天内能出全套报告。没有它,精度问题会藏在代码深处,等流片前才暴雷。

3.3 运行时与内存生命周期的管理

运行时(runtime)承担任务调度、内存分配、DMA管理和同步。它和操作系统驱动不同,runtime更接近“用户态库”,负责把编译产物变成一次真正能在硬件上执行的作业。

内存生命周期管理是软件栈里最容易失控的部分。AI推理时分了几大块内存:输入缓冲、权重缓冲、中间张量区、输出缓冲。中间张量的生命周期很短,如果不复用,会出现内存耗尽或者频繁申请释放的问题。好的做法是编译器在编译期就做内存规划,给每个中间张量分配固定偏移,运行时只维护一个大内存池,按静态规划好的布局使用。

任务调度也要考虑多硬件队列。如果硬件只提供一个执行队列,软件侧没法同时跑多个自媒体算子或者做流水线重叠(比如DMA搬运和计算重叠)。所以硬件设计早期要预留多队列或异步指令通道。这个需求如果不提给硬件团队,后面软件做流水线就是空中楼阁。

4. 软硬件协同设计的关键流程与实操细节

4.1 从基准集出发的架构探索

架构探索阶段,很多人喜欢拿几个“标杆模型”手动排playground脚本,但这样做很容易漏掉实际部署时的边界条件。我更倾向于准备一套分层的基准集:

  • L1:单元算子级,比如单个卷积、单个矩阵乘,用来测峰值性能。
  • L2:端到端模型级,5 到 8 个主流模型,覆盖CNN、轻量级网络、Transformer结构。
  • L3:系统级场景,比如连续多次推理、多路并发推理,用来测稳定性和调度能力。

每次架构调整(比如改变PE行数、调整SRAM分区方案)都必须在这套基准集上跑完整轮对比。不能只看单个算子的提升,因为某种改动可能提升了卷积乘加效率,却导致编译器无法做张量布局转换,反而整条模型性能下降。

这个阶段仿真器精度很重要。性能仿真器至少要能做周期级估算,允许配置ALU吞吐、存储带宽、访问延迟这些参数。内部模型误差控制在 15% 以内就基本可信,但如果误差超过 30%,先别急着优化架构,回去校准仿真器。

4.2 Emulation和FPGA验证:提前暴露软件问题

在RTL真正可跑之前,软件团队往往拿着一个“模拟器”在开发,但模拟器对时序、带宽的模拟通常很粗略。等RTL ready后,FPGA原型和emulation平台成了软硬件集中联调的战场。这个阶段最常发现的问题不是功能错误,而是协议理解不一致。

举例,硬件团队定义某个状态寄存器表示“任务完成”,但没写清楚这个状态是“最后一次写请求已接收”还是“所有数据已在片上且结果可读”。软件团队按后者理解,结果拿到的部分数据还是脏的。刚开始觉得是软件时序问题,最后排查到协议文档的语义歧义上。自此,我在每个新项目启动时都会要求硬件团队把寄存器icon尽量描述成“可测试的断言”,由软件和验证团队共同评审一遍寄存器手册,再冻结接口。

FPGA验证还有一个作用:跑真实模型,收集实际DMA占用、buffer冲突事件,反哺编译器优化。很多在理想模型上看起来合理的调度策略,在真实硬件上会因为竞争和同步开销而失效。这时需要通过运行时统计接口(performance counter)找到热点,再回到编译器里调整。

4.3 代码生成、算子库与手工优化如何分工

编译器自动代码生成和手工算子库不是非此即彼的。我的经验是:核心高频算子(卷积、矩阵乘、某些常见融合模式)用手工算子库,保证性能下限;其余非常规频繁算子走编译器自动生成,保证覆盖度。编译器慢慢变聪明之后,再逐步把手写算子的规则内化为templates或者转译规则。

这里有个很重要的工程原则:算子库和编译器要共用同一套“硬件描述文件”,比如PE阵列大小、buffer容量、DMA能力,避免两边各维护一份配置然后悄悄不一致。我见过因为硬件描述文件分裂,手工算子库以为SRAM是8MB、编译器以为4MB,导致性能完全对不上且难以定位。统一配置文件之后,这类问题少了很多。

另外,要预留好profiling机制。芯片上至少要有足够的性能计数器,统计阵列利用率、DMA带宽占用、buffer miss次数、同步等待周期。没有这些数据,所谓“性能优化”就是瞎猜。这些计数器属于硬件和软件之间的“共同语言”,必须在设计阶段就规划好。

5. 常见问题与排查经验

5.1 编译生成的代码结果不对,先软还是先硬?

联调阶段最常见的恐怖故事是:编译出的算子计算结果偶尔不对,看起来像硬件bug,也像编译器bug。定位这类问题,我的流程是:

  • 第一步,把计算图切成单算子,逐算子跑,确定问题算子是哪个。
  • 第二步,查算子内算法逻辑:是不是溢出了?是不是量化参数用错?是不是地址越界读到了脏数据?这些属于编译器和算子库的锅,好查。
  • 第三步,能排除软件之后,再用硬件自测指令(直接写寄存器、喂已知输入、检查输出buffer)验证硬件逻辑。
  • 第四步,交叉验证:同一个算子用不同布局、不同数据块跑,看错误是否有重现性。特定数据形状才出错,大概率跟对齐、边界条件相关;随机出错,大概率是同步或竞争问题。

踩过最多的坑是“边界数据 + 汇编生成器的对齐假设”。比如编译器假设输入通道数必须是SIMD宽度整数倍,遇到特殊形状就做填充,但填充值没写对,计算结果就在边界处发生污染。这种问题往往单算子功能测试过了,端到端才爆发,非常隐蔽。最终解法是给编译器生成代码添加“预先断言”,发现形状不满足假设直接报错,而不是默默生成错误代码。

5.2 性能不达标:带宽受限、并行度不足还是同步开销?

性能不达标的原因可以归成三类:

  • 带宽受限:片上能放下的数据太少,大量时间花在DMA搬运上。对策是修改内存切分策略,增加数据复用;或者硬件上扩buffer。软件侧经过存储在编译器里对循环进行分块(tiling),让同一块数据在片上尽量被多次计算使用。
  • 并行度不足:主流算子在阵列上的利用率不高,原因可能是卷积输出通道只有16,而PE阵列行数是32,等于一半PE闲置。可以在编译器里做输出通道的“空间打包”,把多个不完整的卷积batch拼起来算,但也取决于硬件是否支持多个逻辑任务共享一块阵列。
  • 同步开销过大:每次任务切换/数据搬入都要花大量周期同步。对策是设计异步流水:DMA搬运第N+1块数据的同时,计算第N块。这需要运行时调度和硬件多队列配合,后面再遇到类似问题先查硬件支持几个outstanding请求。

实测最好用的调优次序是:先解决带宽受限(数据放不下),再解决并行度不足(利用率低),最后才抠同步延时。不要反向优化。

5.3 工具链debug的几条实用建议

调试AI芯片软件栈和调试普通应用很不一样,出问题后往往没法单步执行硬件,只能靠log、寄存器和数据dump。下面几条是反复用到的:

第一,给编译器加“可编译导出模式”:每次生成调度计划时,顺手输出一份人类可读的解释信息,比如哪个循环被分块成多少、哪个张量被分配到SRAM的哪个偏移。有人觉得这是额外负担,但它能在排查性能问题时帮你省一个下午。

第二,用“最小复现”思路保留用例。发现一个模型性能异常时,不要直接在完整模型上调,一次次剪枝,直到剪出一个几十行的算子序列能复现问题。这个序列就是队友和后续测试集的宝贵用例。

第三,重视回归测试的护城河作用。每次修改编译器或算子库,跑一遍预定义的用例集。用例集不需要完全覆盖所有模型,但必须覆盖:不同形状卷积、不同量化精度、不同batch大小、以及几种典型融合模式。出现回归时,先用二分法定位是哪次改动引入的。

表:常见错误来源与定位优先级

错误表现优先怀疑对象常用定位方法
特定形状下结果错误对齐/填充逻辑、边界处理对比不同shape输入,检查地址偏移计算
随机偶发错误同步/竞争、DMA完成事件丢失增加同步点,缩小复现范围
全模型精度退化量化参数、数据范围缩放单算子级精度对比,查scale/zero_point
性能差但功能正确数据布局、存储分配性能计数器分析,检查使用率和带宽占用
多核结果不一致核间任务切分、共享内存写冲突单核跑通后逐步增加核数,确认冲突位置

写到这里,想起早期项目里的一段经历:我们花了整整两周查一个偶发的死锁,最后定位到同步寄存器在极端时序下被软件轮询读到了旧值。硬件团队觉得软件应该更强壮,软件团队觉得硬件应该保证一致性。后来两边各让一步,硬件在寄存器更新后加了一个屏障周期,软件在轮询时主动重复读两次,从此再没见过那个死锁。这类问题的本质不是谁错了,而是软硬件之间缺少统一的时序契约。

如果看完这篇对你有什么实际帮助,我个人的建议只有两条:一是在项目早期就把软硬件的“接口语义”当成最高优先级的交付物,白纸黑字写清楚,这不是文档工程,这是工程师的地基;二是无论硬件性能多亮眼,永远先做一个能自动跑精度回归和性能回归的最小工具链,再开始优化其他的部分。有了这个工具链,后面每一步改动都是有底气的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进