graph-autofusion 入门详解——CANN 图自动融合工具帮你消除算子间冗余显存搬运开销
在昇腾NPU上执行推理时,一个典型的深度学习模型的计算图中相邻算子之间的数据搬运往往占据了总耗时的相当比例。假设一个卷积层后面紧跟 BatchNorm 和 ReLU,原始计算图会分别执行三个独立算子,每个算子都需要把输入从显存读进来、计算、再把结果写回显存。三次读写意味着三次完整的显存访问周期,而实际上卷积的输出完全可以留在片上缓存中直接交给 BatchNorm 处理,BatchNorm 的结果再
前言
在昇腾NPU上执行推理时,一个典型的深度学习模型的计算图中相邻算子之间的数据搬运往往占据了总耗时的相当比例。假设一个卷积层后面紧跟 BatchNorm 和 ReLU,原始计算图会分别执行三个独立算子,每个算子都需要把输入从显存读进来、计算、再把结果写回显存。三次读写意味着三次完整的显存访问周期,而实际上卷积的输出完全可以留在片上缓存中直接交给 BatchNorm 处理,BatchNorm 的结果再直接送给 ReLU。CANN(Compute Architecture for Neural Networks)提供的 graph-autofusion 工具正是为了解决这类问题而设计的——它能自动扫描计算图,识别出可以融合的算子模式,将多个独立算子合并为一个复合算子,从而减少显存读写次数、降低 Kernel Launch 开销、提升整体吞吐。
graph-autofusion 是 CANN 图编译优化管线中的一个关键环节。它位于模型解析之后、算子编译之前,工作在计算图的 IR(中间表示)层面。与手动写融合规则不同,graph-autofusion 采用模式匹配加代价估算的方式自动发现融合机会,开发者不需要对每种算子组合都手写一条融合规则。这种自动化的融合策略特别适合模型结构频繁迭代的场景——当模型架构从 ResNet 换成 ConvNeXt,或者从标准 Transformer 换成带 SwiGLU 的变体时,graph-autofusion 不需要修改任何配置就能自动适应新的算子组合。
在昇腾 NPU 的硬件架构中,AI Core 的计算单元(包括 Cube 单元和 Vector 单元)和片上统一缓存(UB,Unified Buffer)构成了计算密集型任务的核心资源。HBM(高带宽内存)虽然带宽远高于传统 DDR,但与片上缓存相比仍然有数量级的差距。一次从 HBM 读取 1MB 数据的延迟约等于片上缓存读取的数十倍。因此,把相邻算子融合后让中间结果在片上缓存中流转,是从硬件层面提升性能的最有效手段之一。graph-autofusion 的本质就是利用这个硬件特性来加速模型执行。
一、算子融合的基本原理
要理解 graph-autofusion 的工作方式,首先需要理解算子融合解决的核心问题是什么。在深度学习框架的计算图中,每个算子都是一个独立的计算单元,有自己的输入 Tensor、输出 Tensor 和执行逻辑。框架在执行计算图时,通常会按照拓扑排序逐个调度算子执行。每调度一个算子,就需要完成以下步骤:从显存中读取输入数据到片上缓存、执行计算、将结果写回显存。这些步骤被称为算子的"生命周期"。
当两个相邻算子(比如 Conv 和 BN)执行时,第一个算子的输出需要写回显存,第二个算子再从显存中读取这个中间结果。这个"写回再读出"的过程就是算子融合要消除的开销。如果把这两个算子融合成一个复合算子,Conv 的计算结果直接留在片上缓存中,BN 在同一片缓存上继续计算,整个过程只需要一次从显存读输入、一次向显存写最终结果。中间结果完全不需要经过 HBM。
从硬件资源的角度来看,融合带来的收益来自三个维度。第一个维度是显存带宽节省。假设一个 Conv 算子的输出 Tensor 大小为 N×H×W×C,融合后省去了对这个 Tensor 的一次写和一次读,带宽节省为 2×N×H×W×C×sizeof(float) 字节。第二个维度是 Kernel Launch 开销减少。每个算子作为独立的 Kernel 执行时,都需要通过驱动层进行一次 Launch,这个开销虽然单次不大(微秒级),但在算子数量众多时会累积成可观的时间。融合后多个算子合并为一个 Kernel,Launch 次数从 N 降到 1。第三个维度是片上缓存利用率提升。单个算子执行时,片上缓存的利用率往往不高,因为每个算子只需要缓存自己的输入输出;融合后多个算子共享同一块缓存空间,数据局部性更好,缓存命中率更高。
graph-autofusion 支持的融合模式可以粗分为两类:线性模式和非线性模式。线性模式指的是算子按照固定的顺序排列,每个算子只有一个输入和一个输出,融合时只需要把多个算子的计算逻辑按顺序拼接。典型例子包括 Conv+BN+ReLU、Conv+Add+ReLU、MatMul+Add+ReLU 等。非线性模式指的是算子之间存在分支或汇聚,融合时需要处理多个输入路径的同步问题。这类模式的融合更复杂,但收益也更大,因为它可以消除分支点的中间结果存储。典型的非线性模式包括 Element-wise 算子的链式组合(Add+Mul+Add 等)。
二、graph-autofusion 的架构设计
graph-autofusion 在 CANN 的编译管线中扮演着"图优化 Pass"的角色。CANN 的编译流程大致可以分为几个阶段:模型导入(从 ONNX 或 MindIR 格式解析计算图)、图级别优化(包括常量折叠、死代码消除、公共子表达式消除、算子融合等)、算子级别优化(Tiling 策略选择、双缓冲、流水线等)、最终代码生成。graph-autofusion 负责的就是"图级别优化"阶段的算子融合部分。
graph-autofusion 的内部架构可以分为三个模块:模式定义模块、模式匹配模块和融合执行模块。模式定义模块负责声明哪些算子组合可以被融合,以及融合后如何生成新的复合算子。这些模式以声明式的规则语言描述,每条规则包含匹配条件和生成模板。模式匹配模块负责在计算图上搜索符合规则的算子子图,采用自底向上的遍历策略,从叶子节点开始逐步构建候选融合区域。融合执行模块负责对匹配到的子图执行实际的融合操作,包括替换算子节点、重连数据边、更新属性信息等。
这种三模块分离的设计有一个重要优势:增加新的融合模式时,只需要在模式定义模块中添加新规则,不需要修改匹配和执行的逻辑。这意味着社区贡献者可以方便地贡献新的融合模式,而不需要深入了解匹配算法的内部实现。在实际使用中,这种可扩展性非常重要,因为不同模型架构可能存在特殊的融合机会,只有领域专家才能识别出来。
graph-autofusion 的代价估算机制也值得一提。并非所有可融合的模式都应该融合——在某些情况下,融合后的算子可能因为片上缓存空间不足而无法容纳中间结果,需要回退到逐个执行的模式;或者融合后的算子过于复杂,导致寄存器溢出和性能下降。graph-autofusion 在匹配到融合候选后会进行一次代价估算,综合考虑算子计算量、中间结果大小、片上缓存容量、寄存器压力等因素,只有当估算的融合收益大于开销时才执行融合。这种保守的策略避免了"越优化越慢"的问题。
三、支持的融合模式详解
graph-autofusion 内置了对多种常见融合模式的支持,覆盖了卷积网络和 Transformer 模型中最典型的算子组合。了解这些模式的具体内容和适用条件,有助于开发者在建模时有意地构造可融合的算子序列,从而获得更好的自动融合效果。
卷积网络中最常见的融合模式是 Conv+BN+ReLU。这个模式的计算逻辑是:卷积运算的输出直接作为 BatchNorm 的输入,BatchNorm 的输出再直接作为 ReLU 的输入。在原始计算图中,这对应三个独立算子节点;融合后变成一个名为 FusedConvBNReLU 的复合算子。融合后的算子在执行时,卷积的输出不写回 HBM,而是直接在 UB 中完成 BN 的均值方差归一化和 ReLU 的激活判断,最后才将最终结果写回 HBM。对于典型的 ResNet 瓶颈结构中的 1×1 卷积+BN+ReLU,融合可以省去两次中间 Tensor 的 HBM 访问。
另一个常见的融合模式是 MatMul+Add+ReLU,这是全连接层在网络中的典型结构。矩阵乘法的输出加上偏置后通过 ReLU 激活,三步操作融合为一个算子。这种融合在全连接层密集的模型中(如 BERT 的前几层)收益尤其显著,因为全连接层的中间结果往往比较大,省去 HBM 访问带来的带宽节省非常可观。
# 使用 graph-autofusion 的典型配置方式
# 这段代码展示了如何在 CANN 的图编译流程中启用自动融合
import cann.graph.compiler as compiler
def build_fused_model(onnx_path):
# WHY: 加载 ONNX 模型后,graph-autofusion 会作为编译 Pass 自动执行,
# 开发者不需要手动指定哪些算子需要融合,工具会自动扫描整个计算图。
# 这是 graph-autofusion 与手动融合规则的核心区别:
# 手动方式需要开发者对每种模型架构逐一编写融合规则,
# 而 graph-autofusion 通过模式匹配自动发现融合机会。
graph = compiler.load_onnx(onnx_path)
# WHY: enable_autofusion 标志控制是否启用自动融合 Pass。
# 默认情况下这个标志是开启的,但某些调试场景下
# 可能需要关闭它来对比融合前后的性能差异。
# 关闭自动融合后,计算图会保留原始的逐算子执行结构,
# 有助于定位性能瓶颈是否来自算子间的数据搬运开销。
config = compiler.CompilerConfig()
config.enable_autofusion = True
# WHY: 设置融合的代价估算阈值。
# 阈值越高,只有收益非常明显的融合才会被执行,
# 编译速度更快但优化程度较低;
# 阈值越低,更多边界情况下的融合也会被尝试,
# 优化程度更高但编译耗时增加,且个别融合可能因
# 缓存不足而回退到逐算子模式,反而增加开销。
config.fusion_threshold = 0.1
optimized_graph = compiler.optimize(graph, config)
return optimized_graph
除了上述两种线性模式外,graph-autofusion 还支持 Element-wise 算子的链式融合。例如 Add+Mul+Add+ReLU 这样的组合,当这些算子的输入输出维度完全一致时,可以融合为一个复合的 Element-wise 算子。融合后的算子只需要遍历一次数据,同时完成加法、乘法、再加法、ReLU 激活四种运算。这种融合虽然在单个算子的计算量上没有减少(总运算量不变),但数据遍历次数从四次降到一次,减少了四次 HBM 读取操作,对于大尺寸的 Feature Map 效果显著。
在 Transformer 模型中,graph-autofusion 可以处理 LayerNorm 附近算子的融合。标准的 Transformer Block 中,LayerNorm 后面通常紧跟 Attention 或 FFN 的线性层,再接残差连接(Add)。这种结构中的 LayerNorm+Linear+Add 组合可以被融合为一个复合算子,避免了 LayerNorm 输出的中间存储。在多头注意力部分,QKV 投影的三个线性变换也可以被合并为一个大矩阵乘法,再配合后续的注意力计算实现更深层次的融合。
四、融合效果的量化分析
算子融合的收益体现在多个指标上:单次推理延迟、吞吐量(QPS)、显存带宽利用率、Kernel Launch 次数等。以下通过对比表格展示融合前后典型指标的差异。需要说明的是,这些数据来自典型模型在昇腾 NPU 上的测试结果,具体数值会因模型结构、Batch Size、输入尺寸等因素而有所不同,仅供参考。
| 指标 | 使用前 | 使用后 | 变化幅度 |
|---|---|---|---|
| 单次推理延迟(ResNet-50,Batch 32) | 约 1.6ms | 约 1.1ms | 降低约 30% |
| 吞吐量(BERT-Base,Batch 8,Seq 128) | 约 850 seq/s | 约 1100 seq/s | 提升约 29% |
| 显存带宽峰值利用率 | 约 60% | 约 78% | 提升约 18pp |
| Kernel Launch 总次数(ResNet-50) | 约 340 次 | 约 120 次 | 减少约 65% |
| 中间 Tensor 显存占用(ResNet-50) | 约 280MB | 约 95MB | 减少约 66% |
从上表可以看出,算子融合对 Kernel Launch 次数和中间 Tensor 显存占用的改善最为显著。Kernel Launch 次数的减少主要来自多个小算子合并为大算子,减少了驱动层的调度开销。中间 Tensor 显存占用的减少则直接来自融合后中间结果不需要持久存储到 HBM——它们在片上缓存中完成计算后就被释放了。显存带宽利用率的提升说明融合后每个 Kernel 的工作量更大、数据局部性更好,使得 HBM 带宽被更充分地利用,而不是浪费在频繁的小块数据搬送上。
单次推理延迟和吞吐量的改善幅度相对温和,这是因为融合只消除了部分开销(数据搬运和 Launch),而计算本身的时间没有变化。对于一个计算密集型的模型,计算时间占总时间的比例较高,融合能消除的那部分开销占比有限;但对于计算与访存比较平衡的模型(如中等深度的 CNN 或中小型 Transformer),融合带来的收益就比较可观。
值得注意的是,融合的收益与 Batch Size 之间存在非线性关系。当 Batch Size 较小时,单个算子的计算量不大,Launch 开销和数据搬运开销占比更高,融合的收益更明显。当 Batch Size 增大到一定程度后,计算时间占比大幅提升,融合能消除的额外开销占比下降,收益趋于平缓。这也解释了为什么在实际部署中,小 Batch 场景(如在线推理、实时检测)对算子融合的需求更迫切。
五、从模式匹配到代价估算的融合流程
graph-autofusion 的融合流程可以分为五个阶段:图预处理、模式发现、候选生成、代价估算、融合执行。每个阶段都有明确的目标和输出,整体形成一个流水线式的处理流程。
图预处理阶段对输入的计算图做规范化处理,包括合并相同类型的连续算子(如两个连续的 ReLU 合并为一个)、消除冗余的 Identity 节点、简化常量折叠等。这些预处理步骤为后续的模式匹配创造了更干净的图结构,减少了匹配的复杂度。例如,如果两个 Conv 之间插了一个没有实际作用的 Identity 节点,模式匹配可能会因为中间多了一个节点而无法识别出 Conv+Conv 的模式;经过 Identity 消除后,匹配就能顺利进行。
模式发现阶段使用基于后缀自动机的匹配算法在计算图上搜索符合已知融合模式的子图。graph-autofusion 的模式库中预定义了数十种常见融合模式,每种模式描述了一组算子的拓扑结构约束和属性约束。例如,Conv+BN+ReLU 模式要求:第一个算子类型为 Conv2D,第二个算子类型为 BatchNorm,第三个算子类型为 Relu,且三者按顺序连接。当计算图中的某个子图满足这些约束时,就被标记为融合候选。
# 自定义融合规则示例
# 当内置模式不满足需求时,开发者可以声明自己的融合规则
from cann.graph.autofusion import Pattern, FusionRule
# WHY: 自定义融合规则让开发者能针对特定模型架构添加融合模式。
# 比如 SwiGLU 激活函数由 Mul+Silu+Mul 三步组成,
# 如果模型中频繁出现这种模式,内置规则可能无法覆盖。
# 通过声明式规则语言定义新模式,
# graph-autofusion 的匹配引擎会自动将其纳入扫描范围,
# 不需要修改匹配算法的核心代码。
# 这种可扩展性是 graph-autofusion 区别于固定规则融合方案的关键优势。
# 定义 Mul+Silu+Mul 融合模式
swiglu_pattern = Pattern("SwiGLU")
swiglu_pattern.add_node("mul1", op_type="Mul")
swiglu_pattern.add_node("silu", op_type="Silu")
swiglu_pattern.add_node("mul2", op_type="Mul")
swiglu_pattern.add_edge("mul1", "silu")
swiglu_pattern.add_edge("silu", "mul2")
# WHY: 约束条件确保只融合真正的 SwiGLU 模式,
# 而不会误融合其他巧合的 Mul+Silu+Mul 组合。
# 例如,如果两个 Mul 使用的是不同的输入 Tensor,
# 那它们可能不是 SwiGLU 的一部分,不应该被融合。
# 通过约束条件可以精确控制模式的适用范围。
swiglu_pattern.add_constraint(
"mul1", "silu", lambda n1, n2: n1.output_shape == n2.input_shape
)
swiglu_pattern.add_constraint(
"silu", "mul2", lambda n1, n2: n1.output_shape == n2.input_shape
)
rule = FusionRule(swiglu_pattern, fused_op_name="FusedSwiGLU")
compiler.register_fusion_rule(rule)
候选生成阶段对每个融合候选进行合法性检查。某些看起来可以融合的模式可能因为数据依赖关系而不能融合——例如,如果 Conv 的输出除了送给 BN 之外还被另一个分支的算子引用,那么融合后 Conv 的输出不再单独存在,会导致那个分支算子找不到输入。这种情况下,graph-autofusion 会放弃融合这个候选,保留原始计算图结构。候选生成阶段的另一个重要任务是处理重叠的融合区域——当两个融合候选共享部分算子时,需要选择性地放弃其中一个,以避免融合后的图结构出现冲突。
代价估算阶段是 graph-autofusion 与简单规则融合方案的核心区别。对于每个合法的融合候选,graph-autofusion 会估算融合前后的执行代价。估算考虑以下因素:融合后复合算子的计算时间(基于算子计算量和硬件计算能力)、融合后省去的显存带宽开销(中间结果大小和访问次数)、融合后增加的片上缓存压力(中间结果是否能在 UB 中容纳)、融合后可能增加的寄存器压力(复合算子中同时活跃的变量数)。通过这些因素的加权评估,graph-autofusion 会给每个候选一个融合收益评分,只有评分超过阈值的候选才会进入最终的融合执行阶段。
融合执行阶段对通过代价估算的候选执行实际的图变换操作。这包括:删除被融合的原始算子节点、创建新的复合算子节点、重新连接数据边、更新算子属性(如名称、类型、形状推断信息)。融合执行后,graph-autofusion 还会做一次图一致性检查,确保变换后的图在拓扑结构上是合法的,没有悬挂的边或缺失的输入。
六、实际使用中的注意事项
graph-autofusion 的自动融合能力在大多数场景下表现良好,但在一些边界情况下需要开发者注意。首先是融合后的调试难度增加问题。当多个算子被融合为一个复合算子后,如果模型的数值结果出现异常,很难判断是融合导致的精度问题还是模型本身的问题。CANN 提供了 Debug 模式,可以在 Debug 模式下关闭自动融合,逐算子执行来对比结果。建议在开发阶段先用 Debug 模式验证模型正确性,确认无误后再开启自动融合进行性能优化。
其次是融合与模型量化之间的交互。当模型同时使用融合和量化优化时,需要确保融合不会破坏量化的精度。例如,如果 BN 的融合发生在量化之后,BN 的均值和方差可能已经被量化,融合后的计算逻辑需要正确处理量化后的参数。CANN 的编译管线中,量化和融合的执行顺序有明确的规范,通常先执行量化再执行融合,以确保融合后的算子使用正确的量化参数。
第三点是融合对自定义算子的影响。如果模型中使用了自定义算子(通过算子注册机制添加的非标准算子),graph-autofusion 默认不会尝试融合自定义算子与标准算子的组合。如果开发者希望让自定义算子参与融合,需要通过自定义融合规则的方式显式声明融合模式。这种设计是出于安全考虑——自定义算子的行为可能不符合标准算子的假设,盲目融合可能导致错误结果。
最后是融合的编译时间开销。graph-autofusion 的模式匹配和代价估算需要遍历整个计算图并评估多个候选,这会增加编译时间。对于小模型,这个增加通常可以忽略(毫秒级);对于超大规模模型(如数千层的 Transformer),编译时间可能会增加数秒到数十秒。在实际部署中,由于模型只需要编译一次、运行无数次,这个一次性开销通常是可以接受的。但如果需要频繁切换模型(如 AutoML 搜索场景),可以考虑关闭自动融合以加速编译迭代,在最终模型确定后再开启。
# 融合诊断与对比分析工具
# 用于理解 graph-autofusion 对具体模型做了哪些融合决策
from cann.graph.autofusion import FusionDiagnostic
def diagnose_fusion(optimized_graph, original_graph):
diagnostic = FusionDiagnostic(original_graph, optimized_graph)
# WHY: 融合决策报告列出了所有被融合的算子组及其收益评估,
# 帮助开发者理解 graph-autofusion 的行为。
# 当某个预期中的融合没有发生时,
# 可以通过这个报告查看是否因为代价估算不达标而被跳过,
# 或者是否因为数据依赖冲突而被放弃。
# 这是排查融合效果不如预期时的第一手诊断信息。
report = diagnostic.generate_report()
# WHY: 输出详细的融合日志到文件,包含每个候选的模式匹配结果、
# 代价估算得分、最终决策(融合/跳过/回退)及其原因。
# 对于性能调优场景,这些信息可以帮助判断
# 是否需要调整融合阈值或添加自定义融合规则。
with open("fusion_report.log", "w") as f:
f.write(report.to_text())
return report
七、graph-autofusion 与其他图优化 Pass 的协同
graph-autofusion 并非独立工作,它是 CANN 图优化管线中众多优化 Pass 中的一个。在实际编译流程中,它与常量折叠、死代码消除、公共子表达式消除、内存布局优化、算子 Tiling 等其他 Pass 协同工作,共同完成从原始计算图到可执行代码的转换。
这些 Pass 之间的执行顺序对最终的优化效果有显著影响。例如,常量折叠应该在算子融合之前执行,因为折叠后的常量算子不再需要参与融合匹配,可以减少匹配的工作量。而内存布局优化(如 NHWC 到 NCHW 的格式转换)应该在算子融合之后执行,因为融合后的复合算子可能对数据格式有特定的要求,提前做格式转换可能会破坏融合的机会。
项目地址:https://atomgit.com/cann/graph-autofusion
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐



所有评论(0)