先搞清一个问题:为什么需要图引擎?

在说 GE 之前,先聊清楚一件事:为什么 PyTorch 代码在 GPU 上跑得好好的,到了昇腾 NPU 上就需要一个图引擎来管?

PyTorch 默认是动态图(Eager Execution),也就是一行代码一行代码执行。你写 output = model(input),Python 解释器就一行行地调用对应的算子,每一步的结果立刻算出来,立刻用掉。灵活、调试方便,但每次执行都要重新走一遍 Python 解释器 → 算子调用的链路。

动态图有个好处是灵活,坏处是慢。每次调用算子,Python 和 CUDA/CANN 之间都要跨越一次语言边界(Python → C++ → 底层 API),这个切换本身有开销。更关键的是,GPU/NPU 的底层执行是高度并行的,但动态图每次只能看到一条指令,没法做全局优化。

静态图就不一样了。你把整个模型的计算流程先打包成一张图(Graph),图里每个节点是一个算子,每条边是数据依赖关系。图打好之后,编译器可以站在全局视角做优化:哪些算子可以合并、哪些计算可以重排、哪段数据不需要落显存直接复用……

GE 干的就是把动态图转成静态图,然后做全局优化这件事。

GE 在整个 CANN 架构里的位置

在说 GE 具体干什么之前,先把它放在 CANN 五层架构里看一下位置:

你的 PyTorch 代码(动态图)
        ↓
   CANN 第一层:AscendCL(对外窗口)
        ↓
   CANN 第三层:GE(图引擎)← 这里
        ↓
   CANN 第四层:Runtime(运行时)
        ↓
   昇腾 NPU 硬件

GE 夹在 AscendCL 和 Runtime 中间。它的上游是各种框架(PyTorch、MindSpore、ONNX),下游是 Runtime。模型代码先经过 GE 做编译和优化,再交给 Runtime 去真正驱动硬件执行。

这个设计思路跟 NVIDIA 的 TensorRT、TensorFlow 的 XLA 是一样的——都是在框架和硬件之间插一层编译优化层。但 GE 的实现更复杂一些,因为它要适配的硬件(昇腾达芬奇架构)和场景(训练+推理+算子开发)都更多。

GE 具体干的五件事

GE 做的事情很多,但核心可以归纳成五件。按顺序说:

第一件事:图构建(Graph Building)

你的 PyTorch 代码到了 GE 这里,第一步是把它转成 GE 能理解的数据结构——一张计算图。

这一步有两种模式:

  • 追踪模式(Trace):让 PyTorch 真正跑一遍 forward,把中间的所有算子调用记录下来,生成静态的计算图。这种方式简单直接,但只记录了实际执行过的路径,分支和动态 shape 处理不一定完整。
  • 解析模式(Parse):直接解析模型文件(比如 ONNX),根据文件格式规范重建计算图。这种方式拿到的是完整的模型结构定义,但有时候 ONNX 里某些算子的语义和 PyTorch 里不一样,需要额外处理。

图构建完之后,GE 手里拿到的是一张有向无环图(DAG),节点是算子,边是数据流向。

第二件事:算子融合(Operator Fusion)

这是 GE 最核心、也最能提升性能的一步。

什么叫算子融合?就是把两个或多个相邻的算子合并成一个算子执行,减少中间结果的读写。

举个例子,你的 PyTorch 模型里有一个卷积层 + BatchNorm 层 + ReLU 激活:

import torch
import torch_npu

class ConvBnRelu(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = torch.nn.Conv2d(64, 64, 3, padding=1)
        self.bn = torch.nn.BatchNorm2d(64)
        self.relu = torch.nn.ReLU()

    def forward(self, x):
        x = self.conv(x)    # 算子1:卷积
        x = self.bn(x)     # 算子2:归一化
        x = self.relu(x)   # 算子3:激活
        return x

model = ConvBnRelu().npu()
x = torch.randn(1, 64, 224, 224).npu()
output = model(x)

在动态图里,这是三次独立的算子调用:Conv → BN → ReLU。每次算完的结果都要先写回 HBM 显存,再读出来给下一个算子用。

HBM: [conv输出] → 写 → 读 → [bn输入] → 算 → 写 → 读 → [relu输入] → 算 → 写 → 读 → [最终输出]

三次 HBM 写 + 三次 HBM 读,加上中间 buffer 的分配和释放开销,显存带宽被吃掉了不少。

GE 在图优化阶段发现这三个算子可以融合成一个:Conv + BatchNorm 可以在数学上合并成一个等效的卷积(权重和偏置做一次线性变换),ReLU 也合并进去,最后只需要一次卷积操作就能完成三个算子的工作:

# 融合后,GE 把上面三行代码变成了这一行等效操作
output = fused_conv_bn_relu(x)  # 一次计算,结果只写一次 HBM

融合之后,HBM 读写从 6 次变成 2 次(输入 + 输出),计算密度大幅提升。在实际的 ResNet-50 模型里,光是 Conv+BN+ReLU 融合这一项,就能让端到端推理性能提升 20%~30%。

GE 内置了几十种融合规则,除了 Conv+BN+ReLU,还有:MatMul+Add(矩阵乘加融合)、Softmax+Reshape、LayerNorm+FusedAdd 等等。这个融合规则库随着 CANN 版本更新在不断扩充。

第三件事:内存优化(Memory Optimization)

图融合减少的是读写次数,但还有另一个瓶颈:显存占用。

深度学习模型中间会有大量临时张量(activation)。如果每个算子的中间结果都完整保存在 HBM 里,显存峰值会非常大。GE 有几个手段来处理这个问题:

内存复用(Memory Reuse):两张量生命周期不重叠的情况下,可以复用同一块显存区域。比如算子 A 的输出到了算子 B 手里之后,算子 A 的输出张量就可以释放了,它占的显存可以让给后续算子用。GE 会在图上分析每个张量的生命周期,制定一个显存分配方案,让峰值显存尽可能低。

重计算(Recomputation):有些算子的输入很小但输出很大(比如 Softmax)。如果把这个大输出存下来会占用大量显存,但重新算一遍反而更划算。GE 会自动判断哪些算子适合重计算,在 backward 时重新算出需要的中间值,而不是预先存好。

张量虚拟化(Tensor Virtualization):对于一些大模型的中间激活,可以通过 CPU 内存做 offload,在需要的时候再搬回来。GE 会在编译时分析数据流,决定哪些激活应该留在 NPU 显存里,哪些可以 offload 到 CPU 内存。

第四件事:数据排布优化(Layout Optimization)

昇腾达芬奇架构的矩阵计算单元(Cube Unit)对输入数据的排布方式有要求。不同的算子组合,最优的数据排布可能不一样。

GE 会分析数据流和算子类型,选择最优的 layout。比如某些场景下 NCHW(通道优先)排布更优,某些场景下 NHWC(通道后置)更优。GE 会在关键节点插入 layout 转换算子,同时尽量减少不必要的转换开销。

第五件事:执行调度(Execution Scheduling)

图优化完了,GE 还要决定这些算子的执行顺序。

听起来图已经有了拓扑顺序,执行顺序应该是确定的啊?但这里有个问题:GPU/NPU 上算子之间有依赖关系,但依赖关系不是简单的串行。有些算子在等数据,有些算子在计算,理想情况下应该让等待和计算并行起来。

GE 的执行调度器会把可以并行的算子分到不同的执行流里,让计算和访存尽量重叠。比如在读取下一批数据的同时,GPU/NPU 在计算上一批数据。这个过程叫双缓冲(Double Buffering)或者流水线调度,是高性能推理引擎的标配。

怎么让 GE 不偷懒

回到开头那个问题:为什么有人装了 CANN 但性能只有理论算力的三分之一?

大概率是图融合没生效。可能有几种原因:

原因一:动态 shape 导致 GE 无法融合

如果你的模型输入 shape 不是固定的(比如变长序列),GE 在做图优化时会比较保守。很多融合规则要求 shape 固定才能做,否则融合后 shape 对不上就麻烦了。

解法:尽量用固定 shape,或者在 ATC 转换时用 --input_shape 指定固定 shape:

atc --model=model.onnx \
    --framework=5 \
    --output=model_npu \
    --soc_version=Ascend910 \
    --input_shape="actual_input_1:1,3,224,224"   # 固定 shape 让 GE 充分优化

原因二:某些自定义算子没有注册到融合规则里

如果你的模型用了自定义算子(不是 CANN 标准算子库里的),GE 可能不知道怎么处理,直接跳过优化。

解法:检查你的模型里有没有自定义算子,或者看看是不是某些 PyTorch 的实现方式触发了不太好融合的算子组合。

原因三:CANN 版本太老

新版本的 CANN 会不断添加新的融合规则。如果你的 CANN 版本很老,很多优化 GE 还没学会。

解法:升 CANN 版本。新版本的 ge 仓库里会记录每个版本新增的融合规则。

怎么看到 GE 干了什么

调试 GE 的一个有用技巧是在 ATC 转换时打开详细日志:

atc --model=resnet50.onnx \
    --framework=5 \
    --output=resnet50_npu \
    --soc_version=Ascend910 \
    --input_shape="actual_input_1:1,3,224,224" \
    --log=info   # 打开详细日志,可以看到 GE 做了哪些优化

日志里会输出 GE 执行的每一步优化:融合了哪些算子、优化了哪些数据排布、内存分配策略是什么。仔细看一遍日志,你能对自己模型的性能瓶颈有更清晰的认识。

如果你是 PyTorch 动态图场景(不用 ATC 转离线模型),可以用 torch_npu 的 profiling 工具看 GE 的优化情况:

import torch
from torch_npu.contrib import profile

with profile(profile_dir="./ge_profile"):
    output = model(input)

生成的 profiling 报告里可以看到每个算子有没有被融合、算子间的数据排布是什么、显存峰值是多少。

总结

GE 图引擎是 CANN 里最核心的一层。它的核心工作是把你的动态图模型转成一张经过深度优化的静态图,然后交给 Runtime 去执行。

优化主要靠五件事:图构建 → 算子融合 → 内存优化 → 数据排布优化 → 执行调度。其中算子融合(把 Conv+BN+ReLU 合并成一次计算)是性能提升最显著的一步,也是最容易被忽略的一步。

很多人在昇腾 NPU 上跑不出理想性能,根本原因就是 GE 的图优化没生效——可能是 shape 不固定、可能是用了不好融合的算子组合、可能是 CANN 版本太老。学会看 GE 的日志,能帮你快速定位问题出在哪一层。

理解了 GE 在干什么,你再去看 cann-recipes-infer 里的推理优化技巧,就能知其然也知其所以然。昇腾 NPU 的性能上限不低,GE 的优化做好了,120% 的算力利用率不是梦。

本篇文章涉及的相关仓库:

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐