CANN的图优化这两年一直是性能调优圈子里绕不开的话题,尤其是图引擎里的融合策略。我上个月帮同事排查一个ResNet推理性能问题时,发现一个很典型的状况:AI Core利用率看着不低,但整卡吞吐就是上不去。逐个算子拉profiling数据,时间全耗在中间张量落DDR、再读回来的路上,加上大量几十微秒级小kernel的启动开销,算力都被“碎片化”吃掉了。这篇内容就基于这次实战,把CANN图引擎里融合相关的东西拆开聊一聊:融合发生在哪一步、高级融合策略有哪些具体形态、收益模型怎么算、规则怎么落地,以及上线前容易踩的坑。这里的“图优化”是深度学习计算图优化,和概率图模型里的“因子图优化”不是一个概念,但两者都追求“结构重写”带来的全局收益,只是对象完全不同。
1. 一个性能问题的现场:从一次ResNet排查说起
1.1 现象还原:算得很快,但跑得不快
模型是ResNet-50,batch=128的推理任务,单卡跑。第一版结果出来,端到端时延比理论估算值高了将近一倍。我一开始怀疑是某个算子实现有性能缺陷,但逐算子看下来,绝大多数算子的单次执行时间都很短,AI Core的峰值算力利用率也不算差。这种“单个算子都正常,整体却不正常”的现象,通常意味着瓶颈不在算子内部,而在算子之间的衔接上。
msprof拉出来的数据印证了这一点:整张图被拆成了8000多个kernel,单算子平均执行时间只有几十微秒,而kernel launch、调度、同步这些非计算开销占了总时间的三成以上。更夸张的是中间张量读写,很多小算子之间传递的特征图都要经过DDR写入再读回,数据流量巨大,AI Core真正“闲着等数据”的时间远比我想象得多。
1.2 拆解瓶颈:kernel碎片化与中间张量驻留
在NPU上跑模型,和CPU/GPU的道理一样:你不可能让每个算子都单独拿一份完整的数据来算。数据从DDR搬到片上缓存要时间,算完写回DDR也要时间。如果每个算子都是“搬数据-计算-写回”三步走,那么特征图越大,读写的代价就越夸张。我算了一笔账:一个batch=128、通道数256、空间尺寸56x56的中间特征图,单张float32就是约400MB的数据量。这样一个张量从DDR读一遍再写一遍,按约1TB/s的带宽估算,消耗的时间就要0.8ms上下,而很多单算子的计算时间不过几十微秒——到底谁在拖后腿,一目了然。
这就是图优化存在的根本原因:不要每次都把中间结果交给DDR,尽量让数据在片上缓存里直接“接力”。而承接这件事的,正是CANN的图引擎(Graph Engine,GE)。从框架侧拿到计算图之后,GE会在图层面做一系列改写,把合适的算子组合起来,减少启动开销、消除中间张量的DDR往返。这个“组合”的动作,就是融合。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 图引擎的优化流水线:融合在哪个环节发生
2.1 从框架侧到硬件执行:GE到底干什么
要理解融合,得先把CANN的图编译链路看明白。推理场景下,PyTorch/TensorFlow模型一般先通过相应前端转换成成IR表达,再进入CANN侧。GE拿到这张计算图之后,并不会直接把它映射成硬件任务,而是先做几条流水线操作:
- 图结构的基础化简:常量折叠、死节点消除、冗余算子合并;
- 数据格式推导:结合NPU对数据排布的要求,给每个张量推导layout;
- 算子融合:把能融合的计算子图合并成新的、更粗粒度的融合算子;
- 内存分配与任务切分:为中间张量分配buffer,并将融合算子切分成AI Core可执行的task。
融合发生在“格式推导”和“任务生成”之间。这个位置很关键,意味着融合决策可以拿到每个算子的真实数据布局、shape、dtype等完整信息,而不只是框架侧的粗粒度描述。
2.2 融合的本质是图重写,不是算子叠加
很多人对融合的理解是“把两个算子的计算写进一个kernel”。这在实现上是对的,但在图引擎层面,融合更像是一次子图匹配和替换:定义好一个pattern,在图里找到匹配的子图,然后用一个融合算子节点替换它。替换之后,原算子节点消失,中间张量的生命周期也随之消失——数据不再需要落到DDR,这就是绝大多数性能收益的来源。
AOE(Ascend Optimization Engine)在这里起到辅助作用。它会在目标硬件上跑一些实际测量,基于反馈微调融合策略、算子切分参数或数据排布选择。需要注意,AOE不是替代GE规则,而是给规则提供更准确的代价依据。
3. 三类高级融合策略落地:纵向、横向与布局转换
3.1 纵向融合:把一条数据链上的算子“串”成流水
纵向融合是收益最直观、也是大家接触最多的一类,典型代表就是Conv+BN+ReLU。以推理场景为例,BN在推理时本质上是把均值、方差、scale、shift换算成一组线性变换参数。既然卷积输出要经过BN再经过ReLU,那这组线性变换可以直接“吸收”进卷积的weight和bias里,BN节点从图中消失,ReLU则作为卷积输出后的一个逐元素操作并入融合算子末尾。这样,原本三个算子的计算被合并成一个,中间两个特征图根本不用落DDR。
我实际测过一个典型层:输入特征图是上述400MB量级,不融合时BN需要读一次、写一次,ReLU又读一次、写一次,再加上Conv的输出写回,整条链路的数据搬运开销非常庞大。融合之后,Conv跑完的结果留在片上,BN和ReLU直接在片上处理,只把最终结果写回DDR。这一层省下来的时间,有时比那一个Conv本身还多。
纵向融合并不局限于Conv+BN+ReLU。Pooling、逐元素操作、某些归一化层,只要满足数值等价和硬件约束,都可能被纳入同一条融合链。融合链越长,中间张量被消除得越多,但kernel内部逻辑也越复杂,后面会讲到这种复杂度的代价。
3.2 横向融合:把多个“兄弟算子”合并成一个批次
横向融合处理的是同一层级上的多个算子,典型场景是残差结构或多分支结构。比如一个模块里有4个并行的卷积分支,每个分支后面各接一个BN和ReLU。如果不融合,这4条分支会被拆成12个小kernel,每个kernel都很小,AI Core数量一多,大量计算单元会空转。
横向融合的思路,是把这些结构相同、shape相同、互不依赖的算子合并成一个kernel,在kernel内部做batch式的循环处理。这样做的收益主要体现在两方面:一是kernel launch次数大幅减少,二是单个kernel内部的并行度得到提升,AI Core更容易被填满。
但横向融合不是无脑合。如果各分支的shape差异太大,强行合并会导致内部逻辑复杂、切分困难,反而得不偿失。所以图引擎里横向融合的pattern通常对shape一致性有严格约束。
3.3 布局转换与格式推导:融合掉看不见的Transpose
NPU对数据排布有自己的偏好,比如某些场景要求NCHW的数据在内部按更细粒度的维度拆分存储。框架侧传来的往往是NCHW或NHWC的通用排布,如果图引擎不做处理,每个算子在计算前都要插入一个Transpose或者Format转换算子,这个代价其实非常高。
图引擎的解法是“格式推导+融合”:在整张图上做布局分析,把某种格式需求尽量往前或往后传播,让转换尽量发生在图的边界,而不是每个算子的输入输出处。如果实在需要转换,就尝试把转换算子融合到相邻的Conv这类可以吸收权重重排的算子中。很多模型从框架迁移到CANN后性能不佳,“算子本身太慢”只是表象,真正的原因是图中插入了大量可见或不可见的格式转换节点。
判断一个模型是不是吃了布局转换的亏,方法很简单:在profiling数据里搜索Transpose/Format相关算子,看它们的累计耗时占比。如果很高,大概率是格式推导没有生效或者pattern没匹配上。
4. 融合收益模型:什么时候该融,什么时候别硬融
4.1 两个成本模型:计算密集与访存密集
想理解为什么不是所有算子堆一起都更快,得先明确融合到底省了什么。融合省下的主要是两类开销:一是中间张量在DDR上的写入和读回,二是kernel launch、同步这类非计算开销。它不会减少真正的计算量,甚至因为kernel内部复杂度增加,还可能多出一些地址计算和分支判断。
所以,不同计算特征的算子,融合收益差异很大:
| 算子类型 | 典型代表 | 主要开销 | 融合收益逻辑 |
|---|---|---|---|
| 访存密集型 | 逐元素操作、BN、ReLU、Cast | 数据搬运 | 融合后少读少写,收益最稳定,建议优先合 |
| 计算密集型 | Conv、MatMul、Pooling | 算子计算+搬运 | 主要看能否把中间结果驻留片上,需要结合shape判定 |
| 混合型 | Conv+BN+ReLU链 | 两者都是大头 | 收益最大,但规则约束也最多 |
工程上可以粗略估算:一次融合的收益 = (被消除的中间张量读写时间 + 省掉的kernel启动时间) - (融合后kernel内部资源冲突或串行化带来的额外开销)。当第一项明显大于第二项时,融合就是值得的;反之就需要代价模型来把关。
4.2 为什么有些融合看起来省算子,实测却不快
我遇到过不止一次“理论收益算得很好,实测反而变慢”的情况。归纳起来,主要有三个原因。
第一个是并行度下降。有些小算子本身可以在多个AI Core之间灵活切分,融合成一个复杂kernel后,如果切分策略没跟上,blocks数量可能变少,AI Core利用率反而下降。
第二个是片上缓存冲突。融合意味着中间结果要驻留在片上,如果多个中间张量的生命周期重叠,占了太多buffer空间,就会挤占后续计算所需的数据缓存,导致频繁换入换出,把“省下来的DDR读写”又还了回去。
第三个是计算阶段被串行化。没融合时,两个独立小kernel之间可能通过流水线重叠执行,一个kernel在算,另一个kernel的数据已经在搬运了。融合成一个kernel后,如果内部没有做好计算和搬运的overlap,反而会让这些阶段变成严格的串行。
所以,CANN图引擎里很多融合规则默认不激进,不是因为做不到,而是因为代价模型判断在当前shape和硬件组合下不值得。这也是为什么融合开关需要你能手动控制——为特定模型开启或关闭特定规则,往往比全量开启更有效。
5. 规则编写与模式匹配:以“图重写”视角做工程落地
5.1 从anchor出发描述子图模式
在CANN的图优化代码里,一条融合规则的开发通常分成三步:定义pattern、注册匹配器、实现子图替换。第一步最关键,因为pattern描述的是“长什么样的子图可以融”。
比较实用的做法是选一个anchor算子。anchor一般选计算量大、输出特征图也大的算子,比如Conv。然后沿着anchor的输入张量往上游匹配,沿着输出张量往下游匹配。匹配的粒度不只是算子类型,还包括dtype精度、shape、stride、padding、group、dilation等属性。任何一项不满足,这个窗口就失配,不能硬融。
我还碰到过一个坑:匹配时只看算子类型,没校验group和dilation,导致一个不满足等价条件的卷积组合同样被融合了,结果数值对不上。后来补了一条铁律:所有影响输出布局和权重形状的属性都必须参与匹配,任何属性缺失都必须保守失配。
5.2 匹配顺序与重叠窗口处理
图里一个节点很可能同时命中多个融合pattern。比如一个Conv输出既接BN+ReLU,又接一个Add分支。这时候匹配器会面临窗口重叠:同一节点既可能被纵向规则吃掉,也可能被横向规则吃掉。如果替换顺序处理不好,规则引擎内部的状态就会乱,轻则优化不生效,重则编译报错。
我自己的处理原则是:先纵向、后横向;同类型规则里,先匹配覆盖节点数更多的长链。这样可以保证长链优先获得融合机会,避免因为先做了横向合并,把纵向融合的pattern结构破坏掉。仓库里review规则合入时,这一条也是重点考察项。
5.3 融合规则合入仓库后的回归验证
一条新规则进入仓库,不能只靠单模型验证。我的习惯是准备三类测试:第一类是单元级pattern匹配测试,覆盖不同的shape、dtype、属性组合;第二类是典型模型性能回归,比如ResNet、EfficientNet这一批;第三类是数值一致性测试,对比融合前后的输出,设置合理的误差容忍度。
尤其要注意shape的边界情况,比如batch=1、空间尺寸极小、通道数不是对齐粒度。这类shape在真实模型里不常见,但一旦触发边界条件,最容易暴露规则里的隐式假设。我踩过最深的一次,就是C0维度和对齐策略的冲突,在常规size下完全没有问题,换成特殊shape后直接编译失败。
6. 实测定标:用Profiling数据决定融合开关
6.1 融合前后到底该看哪几个指标
我不建议直接把端到端时延作为唯一衡量指标,那样出了问题很难定位。更靠谱的做法是记录以下几个维度:
- kernel数量:融合越激进,kernel数量越少;
- 中间张量的DDR读写量:这个直接反映融合省掉了多少数据搬运;
- AI Core利用率:区分是“算不过来”还是“等数据”;
- 任务下发与调度耗时:看kernel启动开销占比有没有降下来;
- 端到端时延:最终结果指标。
msprof在这时候很好用,它能输出task级别的timeline,能看到每个kernel的开始时间、结束时间、耗时,以及数据搬运的统计信息。拿同一模型在融合开关开启和关闭两种情况下跑一遍,对比这些指标,就能快速找到“融合后反而变慢”的元凶。
6.2 一个实际的调优案例
还是回到开头的ResNet。我先用融合开关把图优化功能整体关掉,得到一个baseline;然后单独开启纵向融合规则,kernel数量从8000多降到4000多,时延下降约18%;接着开启横向融合规则,kernel数量又降了一截,但时延只降了约5%,说明这个模型里launch开销已经不再是主要矛盾。
最有意思的是batch不同,结论完全不同。小batch下,算子计算时间极短,kernel启动开销占比高,横向融合的收益非常明显;大batch下,AI Core更容易吃饱,中间张量的DDR带宽消耗成了矛盾,纵向融合的价值反而更大。
我后来把融合开关做成了一组可配置项,定义成类似这样的结构:
json复制{
"fusion_config": {
"conv_bn_relu": "on",
"horizontal_pattern": "on",
"layout_transpose_eliminate": "on"
}
}
这样就很容易在不对代码做二次编译的情况下,为一组模型批量试不同开关组合,找到每个模型自己的最优组合。对线上长期运行的模型,这个操作很值。
6.3 AOE自动调优和手工调优怎么配合
AOE能基于目标硬件的实际测量搜索较优的配置,但它的搜索空间受限于内置策略。对于一个特定模型,先手工分类排查,定位到是launch瓶颈还是带宽瓶颈,再有针对性地开/关若干条pass,然后用AOE在限定范围内做精细搜索,这样效率最高。我一般不会把AOE作为第一步,因为全空间搜索时间太长,而且结果的可解释性差。
7. 上线前的坑:边界、匹配冲突与浮点漂移
7.1 边界条件:padding、stride与layout的“等效”并不是必然
融合规则之所以难写,是因为“数学等价”和“硬件实现等价”之间存在很大的鸿沟。拿Conv+BN融合来说,数学上BN的吸收是对的,但如果卷积的group不是1,或者dilation大于1,weight的排布和shape都会变,BN参数对齐就要特殊处理,一旦没对齐,结果就错。
还有ConvTranspose。它虽然也带conv字眼,但梯度计算、数据排布逻辑跟普通Conv差别很大,把它当Conv做融合直接翻车。写规则时对这些“看起来像但实际不是”的算子要格外警惕。
7.2 多分支交汇处的匹配窗口冲突
同一个节点被多个pattern命中的问题,在复杂结构模型里非常常见。我见过一个线上问题:一个Add节点的两个输入分别来自两条被横向融合的分支,结果横向规则先执行,把两个上游节点合并成一个,导致后续纵向规则匹配时找不到原始图结构,整个pass失效。模型跑是能跑,性能却回到优化前。
解决这类问题,我建议在匹配器内部维护一个状态位,标记当前窗口是否已经被其他规则锁定;同时规则执行的顺序要尽量稳定,不要依赖哈希遍历顺序。仓库里最好有个“规则顺序白名单”,把高频冲突的组合提前列出来。
7.3 浮点结果的一致性:融合会改变数值累积顺序
融合后的算子,计算顺序跟原始图很可能不同,浮点结果自然会有微小差别。比如多个逐元素算子合并后,中间数据可能在片上以不同精度或截断方式处理;BN吸收进Conv后,计算过程中的舍入误差也可能不一样。大多数场景下这个误差在可接受范围内,但量化模型或对精度极度敏感的推理场景就需要特别小心。
我的建议是整个测试集跑完再评估,不要拿单条样本判断;融合前和融合后的输出对比,用相对误差而不是绝对误差;遇到精度敏感场景,先对比融合开关全开和全关的输出差异,确定误差来源到底是不是融合。
做融合优化做得越多,我越觉得核心原则就三句话:先看访存,再看launch,最后才抠并行度。绝大多数性能瓶颈都能归到这三类里。优化时不要贪多,每次只动一条规则,采集数据、对比收益、验证误差,确认无误后再开下一条。这套流程虽然慢一点,但结果稳定、可解释、容易回滚。现在社区里很多训练赛和性能挑战赛都拿图优化和融合策略做考点,说到底考察的就是这种“从数据出发、以规则落地、用指标验证”的完整闭环能力。
