1. 异构算力时代的核心矛盾:为什么模型与硬件需要“共同进化”
搞AI系统的人这两年应该都有一个明显感受:模型结构迭代的速度,已经远远超过了硬件适配的速度。今天刚把一个Transformer变体在A100上调到吞吐上限,明天团队换了个新的推理卡,或者模型侧把注意力机制换成了线性注意力,之前那套调优参数基本就废了。这不是个别现象,而是异构AI加速器(Heterogeneous AI Accelerators)普及之后,整个行业都要面对的常态。
所谓异构AI加速器,指的是一个系统里同时存在多种计算单元——比如通用GPU、专用NPU、FPGA、甚至同一厂商不同代际的芯片混布。这种架构的好处是能针对不同算子类型选择最合适的硬件,但代价是:模型部署不再是一个“编译一次、到处运行”的问题,而变成了一个“模型结构、算子实现、硬件特性三者互相牵制”的联合优化问题。
Meta Infer这个方向要解决的核心问题,就是Automatic Model-Hardware Co-Adaptation——让模型和硬件在部署阶段自动互相适配,而不是靠工程师手工去试。传统做法是模型固定,然后针对硬件做算子替换和调度优化;或者硬件固定,去改模型结构。但这两条路都是单向的,遇到异构环境就容易顾此失彼。Co-Adaptation的思路是:把模型的计算图表示和硬件的资源约束放在同一个搜索空间里,让系统自动找到一个双方都能接受的折中点。
这篇文章我会从系统设计的角度,把这类方案的整体思路、关键模块、实操中会遇到的坑,以及我实际调优时总结出来的一些经验,完整地拆一遍。适合正在做推理引擎、模型部署、或者异构调度相关工作的同学参考,也适合对AI系统优化感兴趣、想了解底层逻辑的读者。
2. 整体设计思路:把“适配”从人工变成自动搜索
2.1 为什么单向优化在异构场景下会失效
先讲清楚一个前提:为什么不能继续用“模型固定、只调硬件”或者“硬件固定、只改模型”的老办法。
假设你有一个包含多头注意力、LayerNorm、GeLU、以及若干逐元素加法的模型。在纯GPU环境下,这些算子都能映射到cuBLAS或者自定义kernel上,调度器只需要考虑显存和SM占用率。但到了异构环境,比如一部分算子跑在NPU上、一部分跑在GPU上,问题就来了:NPU对矩阵乘的吞吐很高,但对LayerNorm这种带归约的操作支持很差;GPU反过来,通用性强但峰值算力可能不如NPU。如果你固定模型结构,只做算子分配,那LayerNorm放在哪边都会拖慢整体流水线。这时候如果允许模型侧做一点等价变换——比如把LayerNorm融合进前一个矩阵乘的偏置项,或者把GeLU近似成更硬件友好的分段线性函数——整体性能可能提升一大截。
这就是Co-Adaptation的价值:模型侧和硬件侧同时有调整空间,系统自动搜索一个联合最优解。
2.2 搜索空间怎么定义
整个方案的核心是一个联合搜索空间,我把它拆成三个维度:
- 模型变换维度:包括算子融合、算子替换(比如用ReLU近似GeLU)、精度调整(FP16/INT8/混合精度)、以及计算图重写(比如把串行的两个小矩阵乘合并成一个大矩阵乘)。
- 硬件映射维度:每个算子分配到哪个加速器、用哪种数据布局(NCHW/NHWC/自定义tiling)、以及流水线并行度。
- 调度策略维度:算子之间的执行顺序、是否重叠计算与通信、以及内存复用策略。
这三个维度不是独立的。比如你把某个算子从FP16降到INT8,那它在NPU上的吞吐可能翻倍,但精度损失可能导致你需要调整后续算子的输入范围,这又反过来影响模型变换维度的选择。所以搜索必须是联合的,不能分阶段贪心。
2.3 代价模型与搜索算法选型
联合搜索空间很大,不可能暴力枚举。实际系统里通常用一个**代价模型(Cost Model)**来快速评估每个候选方案,然后用启发式搜索或者强化学习来找最优解。
代价模型一般包含两部分:一是硬件性能预测,比如根据算子的计算量、内存访问量、以及目标硬件的峰值算力和带宽,估算执行时间;二是精度损失预测,比如用一个小校准集跑一下,看输出和原始模型的差异。这两部分加权求和,就是搜索的目标函数。
搜索算法方面,我试过几种:遗传算法在搜索空间离散且维度不高时效果不错;贝叶斯优化适合连续参数(比如tiling大小);强化学习适合序列决策(比如算子分配顺序)。实际落地时,很多团队会先用遗传算法快速筛一遍,再用局部搜索精细调优。这里没有银弹,关键是根据你的搜索空间特点选。
注意:代价模型的准确度直接决定搜索质量。如果代价模型预测的执行时间和实际差30%以上,搜出来的方案基本不可用。所以校准这一步不能省。
3. 核心模块拆解与实操要点
3.1 模型侧:计算图重写与算子等价变换
模型侧的可调空间比很多人想象的大。除了常见的算子融合,还有几类变换在异构场景下特别有用。
第一类是归约算子外提。比如LayerNorm里的均值和方差计算,如果目标硬件对归约支持差,可以把归约操作提到前一个矩阵乘的累加阶段,利用矩阵乘本身的累加器顺便算出来。这样LayerNorm就退化成一个逐元素乘加,硬件友好度大幅提升。
第二类是激活函数近似。GeLU在NPU上通常没有原生指令,需要拆成多条指令模拟。如果模型对精度不敏感,可以用ReLU或者HardSwish近似,性能提升明显。我实测过一个BERT变体,把GeLU换成HardSwish后,NPU上的推理延迟降了18%,精度只掉了0.3个百分点。
第三类是矩阵乘形状重排。异构硬件对矩阵乘的维度有偏好,比如某些NPU要求M是16的倍数。如果原始模型里有个M=13的矩阵乘,可以把它和相邻的逐元素操作合并,或者padding到16,虽然多了点计算量,但避免了硬件降速。
实操时,这些变换不是随便用的,需要满足等价性或者近似误差可控。建议在计算图层面维护一个变换规则库,每条规则标注适用条件和精度影响,搜索时按需启用。
3.2 硬件侧:算子分配与数据布局选择
硬件侧的核心决策是:每个算子放到哪个加速器上执行。这看起来简单,但实际要考虑的因素很多。
首先是数据依赖。如果算子A的输出是算子B的输入,而A在NPU、B在GPU,那中间结果需要跨设备传输。传输开销可能比计算本身还大。所以分配时要尽量让有依赖关系的算子落在同一个设备上,或者至少让传输量最小。
其次是负载均衡。异构环境里各设备的算力不同,如果全把重算子丢给NPU,NPU会成为瓶颈,GPU闲着。理想情况是按算力比例分配,但还要考虑算子类型匹配度。
数据布局方面,不同硬件对内存排布有不同偏好。GPU上NHWC通常比NCHW快,因为卷积可以更好地利用Tensor Core;但某些NPU只支持NCHW。如果模型里既有卷积又有矩阵乘,可能需要在中途做布局转换。转换本身有开销,所以要权衡:是统一用一种布局,还是在关键节点转换。
我一般会做一个布局转换代价表,记录每对布局之间的转换开销,然后让搜索算法决定在哪里转换。
3.3 调度侧:流水线并行与内存复用
调度侧的目标是让多个算子的执行重叠起来,隐藏内存访问和通信延迟。
一个常用技巧是双缓冲:当NPU在算当前tile时,GPU同时准备下一个tile的数据。这样计算和传输重叠,整体吞吐能提升不少。但双缓冲需要额外内存,如果模型很大,可能放不下。
另一个技巧是算子重排。比如把两个无依赖的算子调换顺序,让它们能并行执行。这需要计算图分析,找出所有可并行的算子对。
内存复用方面,异构环境里各设备有独立内存,不能像单设备那样随便复用。需要做一个全局内存规划,决定哪些中间结果可以释放、哪些需要保留。我见过一个案例,因为没做好内存复用,NPU内存爆了,只能把部分算子回退到GPU,性能反而更差。
实操心得:调度策略不要一开始就搞太复杂。先用简单的贪心调度跑通,再逐步加入双缓冲和重排。否则出了问题很难定位是调度逻辑的bug还是代价模型不准。
4. 完整实操流程:从模型导入到部署验证
4.1 环境准备与工具链搭建
假设你手头有一个训练好的PyTorch模型,目标环境是“一块GPU + 一块NPU”的异构平台。第一步是把模型导出成中间表示(IR),比如ONNX或者自定义的计算图格式。
导出时要注意:有些PyTorch算子(比如自定义的注意力实现)可能没有对应的ONNX算子,需要先做算子替换或者注册自定义算子。我一般会先用torch.onnx.export跑一遍,看哪些算子报错,然后逐个处理。
工具链方面,你需要:
- 一个计算图解析器,能把IR读进来并构建依赖关系。
- 一个硬件抽象层,封装各加速器的执行接口和性能计数器。
- 一个搜索框架,实现代价模型和搜索算法。
这些不一定都要自己写,很多推理框架(如TVM、TensorRT、OpenVINO)已经提供了部分能力。但异构场景下,现成框架往往不够灵活,可能需要自己扩展。
4.2 代价模型校准:用实测数据拟合
代价模型不准,后面全白搭。校准的方法是:选一批代表性算子(矩阵乘、卷积、归约、逐元素),在每个目标硬件上跑不同大小的输入,记录实际执行时间。然后用这些数据拟合一个性能模型。
拟合时要注意:执行时间通常和计算量、内存访问量、以及并行度有关。一个简单的线性模型可能不够,建议用分段线性或者多项式回归。如果硬件有性能计数器,可以直接读SM占用率、内存带宽利用率等指标,拟合会更准。
精度损失预测相对简单:用一个小校准集(比如100条样本)跑原始模型和变换后的模型,算输出差异。如果差异超过阈值,就拒绝这个变换。
4.3 搜索执行与结果筛选
搜索时,我一般分两阶段:先粗搜,再精搜。
粗搜阶段用遗传算法,种群大小设50,迭代20代。每个个体是一个完整的方案(模型变换+硬件映射+调度策略)。适应度函数就是代价模型的输出。粗搜能快速排除明显差的区域。
精搜阶段用局部搜索,在粗搜最优解附近做小步调整,比如改一个算子的分配、调一下tiling大小。这一步迭代次数不用多,10次左右就能收敛。
搜索完成后,会得到一批候选方案。不要直接选代价最低的,还要看方案的鲁棒性。比如某个方案在代价模型上得分很高,但依赖一个很激进的精度近似,实际部署可能不稳定。我一般会选代价在前10%且变换幅度最小的方案。
4.4 部署验证与回退机制
搜出来的方案要实际部署验证。验证分两步:先跑正确性,再跑性能。
正确性验证:用测试集跑一遍,看输出和原始模型的差异是否在可接受范围内。如果差异大,可能是某个变换的近似误差累积了,需要回退那个变换。
性能验证:用真实负载跑,看延迟和吞吐是否达到预期。如果没达到,可能是代价模型预测不准,或者实际硬件状态和校准时不同(比如温度降频)。这时候需要重新校准或者调整方案。
回退机制很重要:如果某个变换在实际部署时出问题,系统应该能自动回退到上一个稳定版本。我一般会在方案里标注每个变换的风险等级,高风险变换默认关闭,需要手动开启。
5. 常见问题与排查技巧实录
5.1 代价模型预测偏差大怎么办
这是最常见的问题。排查思路:
- 先检查校准数据是否覆盖了实际负载的算子类型和大小。如果实际负载里有校准集没见过的算子,预测肯定不准。
- 检查硬件是否处于稳定状态。如果校准时GPU温度低、部署时温度高,性能会差很多。
- 检查代价模型是否考虑了内存带宽瓶颈。有些算子计算量不大,但内存访问密集,如果模型只按计算量估算,会严重低估执行时间。
解决方法:增加校准数据多样性,加入温度补偿因子,以及在代价模型里显式建模内存带宽。
5.2 算子分配导致跨设备传输过多
如果搜索出来的方案里,算子频繁在GPU和NPU之间切换,传输开销会吃掉所有收益。排查时看传输次数和传输量,如果传输时间占比超过20%,就需要调整。
解决方法:在搜索空间里加入惩罚项,对跨设备传输加一个额外代价。或者限制每个设备的算子数量,强制让相关算子聚在一起。
5.3 精度损失超出预期
有时候单个变换的精度损失很小,但多个变换叠加后误差累积,导致最终输出不可用。排查时逐个关闭变换,看是哪个导致的。
解决方法:在搜索时加入精度约束,比如要求最终精度损失不超过1%。如果多个变换冲突,优先保留性能收益大、精度损失小的。
5.4 实际部署性能不如搜索预期
可能原因:硬件实际状态和校准时不同、驱动版本不一致、或者搜索时用的batch size和实际不同。
排查时先确认这些外部因素,如果都没问题,再检查方案本身。有时候是某个算子的tiling大小在实际负载下不合适,需要微调。
| 问题现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 代价模型预测偏差大 | 校准数据不足、硬件状态变化 | 对比校准和实际执行时间 | 增加校准数据、加入补偿因子 |
| 跨设备传输过多 | 算子分配不合理 | 统计传输次数和传输量 | 加入传输惩罚项、限制设备切换 |
| 精度损失超预期 | 多个变换误差累积 | 逐个关闭变换测试 | 加入精度约束、优先保留低损失变换 |
| 部署性能不如预期 | 硬件状态、驱动、batch size差异 | 确认外部因素后检查方案 | 微调tiling、重新校准 |
避坑技巧:搜索时不要只优化单一指标。我见过团队只追求最低延迟,结果搜出来的方案精度掉了一大截,根本没法上线。建议把延迟、精度、内存占用做成一个加权目标,权重根据业务需求定。
6. 这套方案还能怎么扩展
Co-Adaptation的思路不仅适用于推理,训练场景同样有类似问题。比如混合精度训练里,哪些层用FP16、哪些用FP32,也可以让系统自动搜。再比如分布式训练里的通信策略,也可以和模型并行策略联合优化。
另一个扩展方向是在线自适应。现在大部分方案是离线搜索一次,部署后就不变了。但如果实际负载是动态的(比如请求量波动、不同请求的模型分支不同),离线方案可能不是最优。可以做一个轻量级的在线调整模块,根据实时性能反馈微调算子分配。
我个人在实际操作中的体会是:这套东西的难点不在算法,而在工程细节。代价模型的校准、搜索空间的裁剪、以及回退机制的设计,每一个都需要反复打磨。建议先从一个小规模场景跑通,比如只有两个算子、两种硬件的简单模型,验证整个流程没问题后再扩展到复杂场景。另外,搜索出来的方案一定要做A/B测试,不要直接全量上线,否则出了问题影响面太大。