1. 从一颗芯片的诞生说起:为什么软硬件必须一起设计
很多人第一次听到“AI芯片软硬件协同设计”这个词,脑子里浮现的画面大概是两拨人各干各的——一拨画电路图,一拨写代码,最后拼在一起能跑就行。我刚开始接触这个领域的时候也是这么想的,直到亲眼看到一个算力标称很高的加速卡,在实际模型上跑出来的吞吐量只有理论值的不到三成,才真正理解这件事的复杂度。
AI芯片和通用CPU最大的区别在于,它从设计的第一天起就是为特定类型的计算任务服务的。通用CPU要兼顾各种指令、各种场景,所以它的架构必须足够“宽容”;而AI芯片面对的是高度规律化的矩阵乘法、卷积、注意力计算,这种规律性既是机会也是陷阱——你可以为它定制最极致的数据通路,但一旦软件调度没跟上,这些定制化的硬件资源就会大量闲置。
这就是软硬件协同设计的核心命题:硬件提供能力,软件释放能力,两者之间的匹配程度决定了最终能拿到多少有效算力。这个系列写到第五篇,我打算把之前零散聊过的点串起来,从架构选型、数据流设计、编译栈、算子映射到实际部署中的调优,完整走一遍。如果你是从第一篇跟过来的,这篇可以当作一个阶段性的总结;如果是新读者,也不影响理解,我会把关键背景补全。
适合读这篇的人大概有三类:一是正在做AI加速器架构设计的工程师,想了解软件侧到底关心什么;二是做模型部署和推理优化的同学,想搞清楚硬件底层发生了什么;三是对这个方向感兴趣的学生或转行者,想建立一个从应用到硅片的完整认知框架。不管你是哪一类,我的目标都是让你读完能带走一些可以直接用的思路,而不是一堆名词解释。
2. 架构选型:为什么偏偏是这几种方案
2.1 从计算模式反推硬件架构
要理解AI芯片的架构选择,得先回到计算本身。深度学习模型里占比最大的运算是什么?是矩阵乘法。一个全连接层是矩阵乘向量,一个卷积层可以展开成矩阵乘矩阵,一个注意力头本质上也是几次矩阵乘。这些运算的共同特点是:乘加操作密集、数据复用率高、对精度有一定容忍度。
基于这个观察,硬件架构的设计空间就围绕几个核心问题展开:乘加单元怎么排列、数据怎么搬运、结果怎么暂存。目前主流的方案大致可以分成三类。
第一类是脉动阵列,代表思路是让数据像水流一样穿过计算单元阵列,每个单元完成一次乘加后把部分和传给下一个单元。这种结构的优势是数据复用率极高,权重可以在阵列中驻留,不需要反复从内存读取。缺点是灵活性受限,遇到非规则的计算模式效率会下降。
第二类是SIMD/SIMT架构,也就是单指令多数据或单指令多线程的思路,给大量并行计算单元配上统一的控制逻辑。这种方案灵活性好,能适应多种算子,但控制开销和寄存器压力相对较大。
第三类是可重构数据流架构,计算单元之间的连接可以根据不同的算子动态配置。这种方案试图在效率和灵活性之间找平衡,但编译和调度的复杂度最高。
我个人的经验是,选哪种架构不是拍脑袋决定的,而是要从你的目标工作负载反推。如果你主要跑的是标准卷积网络,脉动阵列的能效比通常最优;如果你需要支持大量不同结构的模型,包括一些动态形状的算子,那SIMT或者可重构方案可能更合适。这里没有银弹,只有取舍。
2.2 存储层次:被低估的胜负手
很多人讨论AI芯片时把注意力全放在算力上,动辄几百TOPS的数字确实吸引眼球。但实际部署过模型的人都知道,瓶颈往往不在算力,而在带宽。
我做过一个粗略的估算:一个典型的Transformer推理任务,每做一次乘加运算,大约需要从外部内存读取2到4字节的数据(权重和激活值)。如果你的芯片算力是256TOPS,那需要的带宽就是256乘以2到4,也就是512到1024GB/s。这个数字已经接近甚至超过了很多高端内存的带宽上限。
所以存储层次的设计才是真正决定实际性能的关键。一个合理的存储体系通常包含三级:寄存器文件、片上缓存(SRAM)、外部内存(DRAM)。寄存器文件离计算单元最近,延迟最低但容量最小;片上缓存容量大一些,可以暂存权重和中间结果;外部内存容量最大但带宽和延迟都是瓶颈。
设计时的核心策略是尽量让数据在离计算单元近的地方多停留。具体做法包括:权重预加载到片上缓存、中间结果在寄存器文件中复用、通过分块(tiling)技术把大矩阵拆成能放进片上缓存的小块。这些策略听起来简单,但实际做起来需要硬件和软件紧密配合——硬件要提供足够的片上存储和灵活的访问模式,软件要能自动完成分块和调度。
一个常见的误区是认为片上缓存越大越好。实际上,SRAM面积在芯片总面积中占比很高,盲目增大缓存会挤占计算单元的面积,反而降低总算力。关键是找到适合目标工作负载的平衡点。
2.3 精度选择:不是越高越好
AI芯片支持的数值精度也是架构设计中的重要决策。常见的精度格式包括FP32、FP16、BF16、INT8、INT4等。精度越高,单个数值占用的位宽越大,计算单元的面积和功耗也越大。
这里有一个关键认知:深度学习模型对精度的容忍度远高于传统科学计算。大量实验表明,把FP32降到FP16甚至INT8,模型精度损失通常在一个百分点以内,但算力和能效可以提升两到四倍。这就是为什么现在很多AI芯片把INT8作为主力精度,同时保留FP16用于对精度敏感的部分。
但精度选择不是一刀切的。我踩过的一个坑是:把整个模型统一量化到INT8,结果某些层的输出出现了明显的精度崩塌。后来分析发现,这些层的激活值分布范围特别大,INT8的256个量化级别不够用。解决办法是采用混合精度策略——大部分层用INT8,少数敏感层保留FP16。这个策略现在已经被主流推理框架支持,但需要硬件同时支持多种精度的计算单元。
3. 数据流设计:让每一块数据都物尽其用
3.1 数据复用是能效的核心来源
在AI芯片中,数据搬运消耗的能量远大于计算本身。有研究数据表明,从DRAM读取一次数据的能耗大约是一次乘加运算的200倍,从片上缓存读取大约是6倍,从寄存器读取则几乎可以忽略。这意味着,提升能效的核心手段就是最大化数据复用——让每一块从内存取出来的数据,在送回内存之前尽可能多地参与计算。
数据复用有三个维度:输入复用、权重复用、输出复用。输入复用是指同一个输入数据与多个不同的权重做运算;权重复用是指同一个权重与多个不同的输入做运算;输出复用是指多个计算结果累加到同一个输出上。
不同的数据流策略在这三个维度上的侧重不同。以卷积计算为例,常见的策略包括:
- 权重固定:把权重固定在计算单元中,让不同的输入数据流过。适合权重数量小、输入数据量大的场景。
- 输出固定:把输出结果固定在累加器中,让不同的输入和权重组合流入。适合输出通道数较多的场景。
- 行固定:在权重固定的基础上,进一步固定一行输入数据,减少输入数据的重复读取。
实际设计中,往往需要根据目标算子的形状和大小,动态选择或组合这些策略。这就引出了下一个话题:编译器如何自动完成这个选择。
3.2 分块策略与片上存储管理
分块是数据流设计中最基础也最重要的技术。核心思想很简单:把大矩阵拆成小块,让每一块都能放进片上缓存,计算完一块再加载下一块。但具体怎么拆、拆多大、按什么顺序计算,这里面有大量可以优化的空间。
假设你的片上缓存有256KB,要计算一个1024x1024的矩阵乘法。如果每个元素是2字节(FP16),那完整的两个输入矩阵加一个输出矩阵需要6MB,远远超过缓存容量。你需要把它们分成若干小块,每块的大小要满足:输入块A、输入块B和输出块C的总大小不超过256KB。
一个常见的分块方案是:把输出矩阵分成16x16的小块,每个小块对应输入矩阵的一行(16个元素)和另一矩阵的一列(16个元素)。这样每次只需要加载32个输入元素和256个输出元素,总共不到1KB。但这样做的问题是输入数据的复用率太低——每个输入元素只参与了一次计算就被丢弃了。
更好的方案是增大分块尺寸,让输入数据在缓存中停留更久。比如把输出分成64x64的块,对应的输入是64个元素和64个元素,输出是4096个元素。这样输入数据的复用次数从1次提升到了64次。当然,分块越大,需要的缓存也越大,需要在复用率和缓存容量之间找平衡。
实操中有一个经验公式:最优分块大小大约等于片上缓存容量的平方根。比如256KB缓存,分块边长大约在256到512之间。当然这只是一个起点,具体还要根据算子类型和数据精度调整。
3.3 双缓冲与流水线隐藏
即使做了分块,数据加载仍然需要时间。如果计算单元在等待数据加载时闲置,那实际利用率就会大打折扣。解决办法是双缓冲:准备两块缓存区域,一块用于当前计算,另一块同时预加载下一块数据。当当前计算完成时,下一块数据已经就绪,计算单元可以无缝切换到下一块。
这个技术听起来简单,但实现时需要注意几个细节。首先是缓存区域的划分要合理,两块缓冲区不能互相干扰。其次是加载和计算的同步机制要设计好,通常用硬件信号量或屏障指令来实现。最后是加载的粒度要匹配计算的时间——如果加载太快,缓冲区会空等;如果加载太慢,计算单元会 stall。
更进一步的优化是多级流水线。把整个计算过程拆成加载、计算、写回三个阶段,每个阶段由不同的硬件单元负责,三个阶段可以同时进行。这样理论上计算单元的利用率可以接近100%。但流水线的级数越多,控制逻辑越复杂,而且流水线填充和排空的开销也越大。实际设计中通常根据算子的特点选择两到三级流水。
4. 编译栈:连接模型与硬件的桥梁
4.1 从计算图到硬件指令的完整链路
一个训练好的模型要跑在AI芯片上,中间需要经过一系列转换。这个转换过程由编译栈完成,大致可以分为以下几个阶段:
图优化阶段:对模型的计算图进行等价变换,包括算子融合、常量折叠、死代码消除等。算子融合是最有价值的优化之一,比如把卷积、批归一化和激活函数融合成一个算子,可以减少中间结果的读写次数。
算子选择阶段:把计算图中的每个算子映射到硬件支持的具体实现。同一个算子可能有多种实现方式,比如卷积可以用直接卷积、im2col加矩阵乘、Winograd算法等。编译器需要根据输入尺寸、通道数、精度等因素选择最优实现。
调度与分块阶段:为每个算子决定数据分块大小、计算顺序、缓存分配等。这个阶段直接决定了数据复用率和硬件利用率。
代码生成阶段:把调度方案翻译成硬件能执行的指令序列,包括计算指令、数据搬运指令、同步指令等。
这个链路的每一环都会影响最终性能。我见过不少案例,模型本身没问题,硬件算力也够,但因为编译器的某个优化没做到位,实际性能只有理论值的一半。
4.2 算子融合的收益与代价
算子融合是编译优化中收益最直接的手段之一。以常见的“卷积+批归一化+ReLU”组合为例,如果不融合,需要把卷积的输出写到内存,再读出来做批归一化,再写回去,再读出来做ReLU。三次读写,每次都是全量数据。融合之后,卷积的输出直接留在寄存器或片上缓存中,批归一化和ReLU紧接着完成,只需要一次写回。
收益是显而易见的,但融合也有代价。融合后的算子对片上存储的需求更高,因为需要同时容纳多个算子的中间结果。如果缓存不够,融合反而会导致频繁的溢出和重加载,性能不升反降。
所以编译器需要做一个权衡:融合哪些算子、融合多少个、在什么粒度上融合。这个决策通常基于一个代价模型,估算不同融合方案下的内存访问量和计算量,选择总代价最小的方案。代价模型的准确性直接决定了优化效果,这也是各家编译器拉开差距的地方。
4.3 自动调优:让编译器自己找最优解
手工为每个算子指定分块大小和调度策略是不现实的,因为算子的形状和参数千变万化。所以现代AI编译器普遍采用自动调优的方式:定义一个搜索空间(包含各种分块大小、循环顺序、缓存策略的组合),然后通过搜索算法找到最优配置。
搜索算法有几种常见选择。穷举搜索最准确但耗时最长,只适合搜索空间很小的情况。启发式搜索基于规则快速给出一个不错的解,但可能错过最优解。机器学习引导的搜索用模型预测不同配置的性能,逐步缩小搜索范围,在准确性和效率之间取得平衡。
实际使用中,自动调优通常分为两个阶段:离线调优和在线调优。离线调优在部署前完成,为常见的算子形状找到最优配置并缓存下来。在线调优在运行时进行,针对实际遇到的形状做微调。离线调优的覆盖面决定了基础性能,在线调优的响应速度决定了应对新形状的能力。
自动调优的一个常见坑是搜索空间设计不合理。如果搜索空间太小,可能找不到好解;如果太大,调优时间会不可接受。我的经验是,先根据硬件参数(缓存大小、计算单元数量、带宽)确定搜索空间的边界,再在这个范围内做精细搜索。
5. 算子映射与手写优化:什么时候该自己动手
5.1 编译器搞不定的那些情况
虽然编译器的能力越来越强,但总有一些情况是它搞不定的。比如:
- 动态形状算子:某些模型的输入长度是可变的,编译器在编译时无法确定最优分块策略。
- 稀疏计算:稀疏矩阵乘法的非零元素分布不规则,编译器的通用优化效果有限。
- 自定义算子:研究者提出的新算子可能还没有被编译器支持。
- 极端性能要求:某些场景下需要压榨出最后一点性能,编译器的通用策略不够极致。
遇到这些情况,就需要手写优化。手写优化通常从两个层面入手:算子层面和指令层面。
算子层面的优化包括:根据具体形状手工设计分块策略、利用算子的数学特性减少计算量(比如Winograd算法把卷积的乘法次数减少到原来的三分之一)、针对稀疏模式设计专用的数据压缩和索引方案。
指令层面的优化包括:手工编排指令顺序以隐藏延迟、使用特殊的硬件指令(比如矩阵乘加指令、数据重排指令)、精细控制缓存和寄存器的分配。
5.2 一个手写优化的实际案例
假设我们要优化一个注意力机制中的softmax算子。这个算子的计算过程是:对输入向量求最大值、减去最大值、求指数、求和、归一化。每一步都依赖前一步的结果,看起来很难并行化。
但仔细分析会发现,求最大值和求指数可以合并成一次遍历,求和和归一化也可以合并。而且,如果输入向量很长,可以分成若干段并行处理,最后再合并各段的结果。这就是所谓的在线softmax算法。
手写优化的关键步骤是:
- 分析算子的数据依赖关系,找出可以并行和可以合并的部分。
- 根据硬件特性设计数据布局,比如把输入向量按计算单元的宽度对齐。
- 编写核心计算循环,注意寄存器分配和指令调度。
- 用实际数据验证正确性,再用性能分析工具找出瓶颈。
- 迭代优化,直到达到目标性能。
这个过程中最容易出错的地方是边界处理。当输入长度不是计算单元宽度的整数倍时,需要特殊处理尾部元素。如果处理不当,会出现结果错误或者性能骤降。
5.3 手写优化与编译器优化的配合
手写优化和编译器优化不是对立的,而是互补的。一个常见的做法是:用编译器完成大部分算子的优化,只对性能瓶颈算子进行手写优化。手写优化的算子可以作为自定义算子注册到编译器中,和其他算子一起参与图优化和调度。
另一种做法是:用手写优化探索出好的调度策略,然后把这些策略抽象成编译器可以理解的规则或模板,让编译器自动应用到类似的算子上。这样既保留了手写优化的效果,又保持了编译器的通用性。
我个人倾向于第二种做法,因为它能把一次性的优化经验固化下来,惠及后续的模型和算子。当然,这需要编译器和手写优化工程师之间有良好的协作机制。
6. 实际部署中的调优与排查
6.1 性能分析:找到真正的瓶颈
部署一个模型到AI芯片上,第一步是跑通,第二步是跑快。跑快的前提是知道慢在哪里。性能分析的工具和方法有很多,但核心思路是一致的:测量每个阶段的耗时,找到占比最大的那个。
一个典型的分析流程是:
- 端到端耗时:先测量整个模型的推理时间,建立一个基线。
- 逐层耗时:测量每个算子的耗时,找出最耗时的几个算子。
- 算子内部拆解:对最耗时的算子,进一步拆解为数据加载、计算、写回等阶段,看时间花在哪里。
- 硬件计数器:利用硬件提供的性能计数器,查看计算单元利用率、缓存命中率、内存带宽利用率等指标。
根据分析结果,可以判断瓶颈类型:
| 瓶颈类型 | 典型表现 | 优化方向 |
|---|---|---|
| 计算瓶颈 | 计算单元利用率高,内存带宽利用率低 | 减少计算量(如算子融合、低精度计算) |
| 带宽瓶颈 | 内存带宽利用率高,计算单元闲置 | 提高数据复用率、优化分块策略 |
| 延迟瓶颈 | 流水线 stall 多,同步开销大 | 优化双缓冲、减少同步点 |
| 控制瓶颈 | 控制指令占比高 | 减少循环开销、使用更大的计算块 |
6.2 常见问题与排查技巧
在实际部署中,我遇到过各种各样的问题。这里整理几个典型的,附上排查思路。
问题一:性能远低于理论值。这是最常见的问题。排查时先确认理论值的计算方式是否合理——有些厂商标称的算力是稀疏算力或者特定精度下的算力,实际使用中达不到。然后检查是否有明显的瓶颈,比如某个算子耗时异常。最后检查编译器的优化选项是否开启,有时候默认配置并不是最优的。
问题二:精度不达标。如果模型在CPU上精度正常,在AI芯片上精度下降,通常是量化导致的。排查方法是逐层对比量化前后的输出,找到精度损失最大的层。对于这些层,可以尝试保留更高精度,或者调整量化参数。
问题三:动态形状导致性能波动。如果模型支持动态输入,不同输入长度下的性能可能差异很大。排查方法是测量不同长度下的耗时,找到性能骤降的临界点。通常是因为某个长度下分块策略不再适用,导致缓存溢出或计算单元利用率下降。
问题四:多模型并发时性能下降。多个模型共享硬件资源时,可能出现资源竞争。排查方法是分别测量单模型和并发场景下的性能,看是否有明显的相互干扰。优化方向包括调整模型的优先级、错开计算密集阶段、合理分配缓存。
一个实用的排查技巧是:把模型的每个算子单独拿出来跑,测量其实际性能,然后和理论性能对比。这样能快速定位到问题算子,避免在无关的地方浪费时间。
6.3 调优的优先级与取舍
调优不是无止境的,需要根据实际需求确定优先级。我的经验是按照以下顺序推进:
首先保证正确性,任何优化都不能以牺牲正确性为代价。其次保证稳定性,性能可以不是最优,但不能忽快忽慢。然后追求性价比,用最小的改动获得最大的性能提升。最后才是极致性能,这通常需要大量的手写优化和反复调试。
在时间有限的情况下,优先做那些收益大、风险低的优化。比如开启编译器的自动调优、调整分块大小、启用低精度计算,这些通常能带来明显的提升,而且不太容易引入错误。手写算子和指令级优化虽然收益可能更大,但风险和耗时也更高,适合在项目后期有充足时间时进行。
7. 一些个人体会
写到这里,这个系列已经涵盖了AI芯片软硬件设计的主要方面。回顾这几篇的内容,我最大的体会是:软硬件协同设计不是一个阶段性的工作,而是一个贯穿始终的理念。
硬件设计者在定义架构时,就要考虑软件如何调度、编译器如何映射;软件工程师在优化模型时,也要理解硬件的限制和特性。两边不能各干各的,更不能互相甩锅。我见过太多项目,硬件指标很漂亮,软件跑不起来;或者软件优化得很好,硬件不支持。这些都是协同没做好的结果。
另一个体会是,没有一劳永逸的方案。模型在变,硬件在变,编译器在变,今天的最优解明天可能就不是了。所以与其追求一个完美的设计,不如建立一个能够快速迭代和适应的框架。这个框架包括:可配置的硬件架构、可扩展的编译栈、可复用的优化经验。
最后分享一个实用的小技巧:在做任何优化之前,先建立一个可靠的性能基线。这个基线包括端到端耗时、逐层耗时、硬件利用率等指标。有了基线,你才能判断优化是否有效,才能对比不同方案的好坏。我见过不少人跳过这一步,直接开始调优,结果改了半天不知道有没有效果,最后只能凭感觉。这是非常低效的做法。
这个系列到这里就告一段落了,但AI芯片的软硬件设计还有很多值得深入的方向,比如存算一体、光计算、类脑架构等。如果后续有机会,我会继续分享这些新方向上的探索。