前言
GE 是昇腾 CANN 软件栈里最容易被误解的组件。很多人把它当成编译器,也有人以为它是执行引擎,这两种说法都不准确。GE 的全称是 Graph Engine,它做的事情可以用一句话概括:把上层框架描述的计算图变成昇腾 NPU 上能够高效执行的指令序列。这句话听起来不复杂,但实际涉及图解析、算子融合、内存规划、并行调度等一整套流程。ge 这个仓库在昇腾开源社区里的定位,恰好卡在编译层和执行层之间——向上对接 PyTorch、MindSpore 等框架的适配器,向下对接 Runtime 和底层驱动。
理解 GE 的意义在于,当你的模型在昇腾 NPU 上跑出问题的时候——显存爆了、性能不达预期、同模型不同 CANN 版本结果不一致——你能不能从 GE 的角度去定位根因。如果你只用过 AscendCL 的推理接口,那 GE 对你来说是个黑盒;但如果你想深入理解昇腾的编译与执行机制,GE 是绕不过去的。
这篇文章从 GE 的整体架构出发,拆解构图、编译、调度、离线模型生成这几个核心阶段,讲清楚数据是怎么流转的,以及 GE 在设计上做了哪些取舍。
计算图是什么,为什么需要 GE 来处理
在讲 GE 之前,先搞清楚"计算图"这个概念。你用 PyTorch 写一个模型,比如两个线性层夹一个 ReLU:
import torch import torch.nn as nn class SimpleModel(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(1024, 2048) self.relu = nn.ReLU() self.fc2 = nn.Linear(2048, 10) def forward(self, x): x = self.fc1(x) x = self.relu(x) x = self.fc2(x) return xWHY:这里用 PyTorch 的 nn.Module 定义网络结构,而不是用底层算子拼装,是因为 Module 封装了参数初始化和前向传播逻辑,减少手写错误。但 PyTorch 的动态图机制意味着每次 forward 都重新建图,这个开销在训练时可以接受,部署推理时就成了浪费。
PyTorch 的动态图在每次前向传播时实时构建计算流程,调试方便但执行效率有限。昇腾 NPU 需要的是静态图——提前把整个计算流程确定下来,然后一次性优化和调度。GE 的工作就是完成从动态图到静态图的转换,以及后续的优化和编译。
你可能会问:为什么不直接让 NPU 支持 PyTorch 的动态图?答案是硬件执行效率和灵活性之间的矛盾。静态图可以在编译期做全局优化(算子融合、内存复用、流水线并行),这些优化在动态图模式下根本无法实现,因为每一步都不知道下一步要做什么。动态图让开发者灵活,静态图让硬件高效,GE 站在两者之间做翻译和优化。
GE 的整体架构:四个核心阶段
GE 的工作流程可以分成四个阶段:构图、编译优化、调度、离线模型生成。这四个阶段不是孤立的,前后之间有数据依赖,也有反馈回路。
构图阶段,GE 从上层框架拿到计算图的原型。这个阶段的输入来源有两个:一是通过 TorchAir 等 PyTorch 适配器导出的 TorchScript 图,二是通过 MindSpore 的 ANF 图直接传入。GE 把这些不同格式的图统一转换成自己的内部表示——ComputeGraph。ComputeGraph 里的每个节点叫 Node,对应一个算子;每条边叫 Edge,对应数据流向。构图阶段还会做基本的合法性校验:输入输出张量是否定义完整、有没有孤立的节点、是否存在环形依赖。
编译优化阶段,这是 GE 最核心的部分。GE 内部实现了大量的 Pass(图优化遍历),每个 Pass 对计算图做一类特定的变换。Pass 的执行顺序有讲究——有些 Pass 依赖前置 Pass 的结果,比如常量折叠必须在算子融合之前完成,否则融合的输入里可能包含可以提前算出来的常量。主要的优化手段包括:
常量折叠:如果某个算子的所有输入都是编译期已知的常量,直接把计算结果写死到图里,省掉运行时的计算开销。比如你的模型里有一个 Reshape 操作,目标形状是固定的 [1, 3, 224, 224],那这个 Reshape 就可以在编译期完成,不需要在每次推理时重新计算。
算子融合:把多个小算子合并成一个大算子。最典型的场景是 Conv2D + BatchNorm + ReLU 三合一。Conv2D 输出的中间张量如果按原始图的方式需要写回全局内存再被 BatchNorm 读走,这来来回回的内存读写开销很大。融合成一个算子后,中间结果可以直接在片上缓存(L1/L2)里流转,省掉大量的全局内存访问。
内存复用:分析算子之间的生命周期关系,如果两个张量不会同时存活,就让它们共享同一块内存。比如 ResBlock 里前半段和后半段的中间激活值,在时序上不会同时存在,可以复用同一块 buffer。
调度阶段,编译优化后的图还需要考虑如何在硬件上执行。昇腾 NPU 内部有多个 AI Core,调度器需要决定哪些算子可以并行执行、哪些必须串行等待、数据在 AI Core 之间怎么搬移。多卡场景下还有 HCCL 集合通信的调度问题——AllReduce 操作的时机如果插入不当,会造成某些 NPU 闲置等待。调度策略会根据具体的硬件型号动态调整:Ascend 910 和 Ascend 310 的核数、带宽、内存容量差异很大,同一张图在不同设备上的调度方案也不同。
离线模型生成阶段,GE 把优化和调度后的结果序列化成一个 .om 文件(离线模型)。这个文件包含了完整的指令流、内存分配表和参数配置。有了 .om 文件,你就可以脱离 Python 环境,直接用 AscendCL 的接口加载运行。这对嵌入式和边缘部署场景非常关键——Ascend 310 上跑推理,不可能装一整套 Python + PyTorch 环境。
算子融合的细节:GE 做了哪些融合,为什么能提升性能
算子融合是 GE 编译优化里最值得展开讲的部分。不是所有算子都能融合,融合的前提是数据局部性和算子间的执行时序关系。
GE 支持的融合规则分为两类:模式匹配融合和代价模型融合。
模式匹配融合是最直观的。GE 维护了一张融合规则表,当计算图中出现符合规则的算子组合时,自动替换为融合后的算子。比如:
| 原始算子组合 | 融合后算子 | 融合收益 |
|---|---|---|
| Conv2D + BiasAdd | Conv2D(含bias) | 减少一次内存读写 |
| Conv2D + BatchNorm + ReLU | ConvBNReLu | 中间结果不写回全局内存 |
| MatMul + Add | MatMul(含bias) | 合并两次计算为一次 |
| ReduceMean + Reshape | SqueezeReduce | 避免冗余形状变换 |
代价模型融合更复杂一些。GE 会评估融合前后整个子图的执行时间,如果融合后的总时间更短才执行融合。有些情况下融合反而变慢——比如融合后的算子计算密度太高,导致某个 AI Core 成为瓶颈,而未融合时多个小算子可以分散到不同 Core 上并行。代价模型融合的判断依据包括算子的计算量、内存访问量、数据依赖关系和硬件资源状况。
下面这段代码展示了如何在 GE 的配置中开启和自定义融合规则:
import ge # WHY: 默认情况下 GE 会开启所有内置融合规则 # 但某些场景下特定融合规则可能导致精度问题(比如混合精度训练时 BN 融合) # 所以需要提供手动控制融合策略的接口 graph = ge.Graph() options = ge.InitializeOptions() options.fusion_switch_file = "./fusion_switch.cfg" # WHY: 通过配置文件精细控制每个融合规则的开关 # 比如只关闭 ConvBNReLu 融合,保留其他融合 # fusion_switch.cfg 内容示例: # { # "Switch": { # "GraphFusion": { # "ConvBNReLu": "off", # WHY: 精度校验发现 ConvBNReLu 融合在 fp16 下有精度损失 # "Conv2DBackpropInput": "on" # }, # "OpFusion": { # "All": "on" # } # } # }WHY:为什么不默认全部开启融合?因为融合不是零风险的。算子融合改变了计算的执行顺序和精度路径——fp16 累加的顺序不同,结果就可能不同。在精度敏感的场景下(比如金融风控模型的推理),你需要能够关闭特定融合规则来保证结果一致性。
内存管理:GE 怎么规划显存
昇腾 NPU 的显存是稀缺资源,尤其是 Ascend 310 这种边缘设备只有 8GB 显存。GE 的内存管理直接决定你的模型能不能跑起来。
GE 的内存规划分三步:分析张量生命周期、建立冲突图、分配内存偏移。
张量生命周期分析是基础。GE 会遍历整张计算图,记录每个张量从产生到消费的时间段。一个张量的生命周期从它被某个算子写出那一刻开始,到最后一个消费它的算子读取完毕那一刻结束。
建立冲突图的逻辑是:如果两个张量的生命周期有重叠,它们就不能共享内存,在冲突图里连一条边。反之,如果生命周期没有交集,它们可以复用同一块 buffer。
内存偏移分配就是经典的图着色问题——把冲突图里的节点涂上不同颜色,每种颜色代表一块独立的内存区域,相邻节点(有冲突的张量)必须不同色。GE 用的是贪心近似算法,时间复杂度可控,虽然不一定得到最优解但工程上够用。
# WHY: GE 内存复用的效果可以通过 dump 图的内存信息来观察 # 如果发现内存占用异常,需要检查是否有不必要的张量生命周期延长 import ge graph = ge.Graph() # ... 构建计算图 ... # dump 内存分配信息 mem_info = graph.GetMemoryInfo() for tensor_name, info in mem_info.items(): print(f"{tensor_name}: offset={info.offset}, size={info.size}, " f"lifetime=[{info.life_start}, {info.life_end}]") # WHY: 如果 life_end 远大于最后一个消费算子的执行时间 # 说明张量生命周期被不必要地延长了,可能是某个 Pass 引入了冗余依赖WHY:这里没有用torch.cuda.max_memory_allocated()这种 PyTorch 接口来观察内存,因为 PyTorch 的内存统计只在 CUDA 设备上准确。昇腾 NPU 的内存管理由 GE 和 Runtime 协同完成,必须用 GE 自身的接口才能看到真实的分配情况。
GE 与 Runtime 的协作关系
GE 生成的离线模型不是直接"跑"在 NPU 上的,中间还有一层 Runtime。理解 GE 和 Runtime 的分工对排查性能问题很重要。
GE 负责"编译期"的所有决策:算子选择、融合策略、内存布局、执行顺序。这些决策在模型编译完成后就固定了,运行时不会再改变。
Runtime 负责"执行期"的任务管理:把 GE 生成的指令流下发到 NPU、管理 Host 和 Device 之间的数据搬运、处理硬件中断和异常。
这种编译期和运行期的分离有一个好处:编译一次,多次运行。你的 .om 文件编译好之后,每次推理只需要调用aclmdlExecute就行,不需要重新编译。代价是灵活性降低——如果你想动态修改模型结构(比如条件分支),GE 的静态图模式就不太好处理。
GE 对动态 shape 的支持在近几个 CANN 版本里有明显改进。早期的 GE 要求所有张量的形状在编译期完全确定,这意味着你用固定 batch size 编译的模型,换个 batch size 就得重新编译。现在的 GE 支持动态 shape 和动态 batch,原理是编译时生成一个"超集"图,运行时根据实际输入形状选择对应的子图执行。当然,这个超集图的编译时间会比固定 shape 长,而且内存占用也会更大。
import acl # WHY: 动态 batch 推理需要在模型编译时指定支持的 batch 范围 # 如果编译时没有开启动态 batch,运行时传入不同 batch 的输入会直接报错 # 初始化 ACL acl.init() context = acl.rt.create_context(0) # 加载离线模型 model_id = acl.mdl.load_from_file("model.om") model_desc = acl.mdl.create_desc() acl.mdl.get_desc(model_desc, model_id) # WHY: 动态 batch 场景下需要在执行前设置当前 batch size # 这一步告诉 GE 运行时应该选择哪个编译好的子图 dataset_input = acl.mdl.create_dataset() # ... 准备输入数据 ... acl.mdl.execute(model_id, dataset_input)WHY:上面这段代码用的是 AscendCL 的 C 接口封装(Python 绑定),而不是 PyTorch 的接口。因为如果你想直接控制 GE 的编译输出和 Runtime 的执行细节,必须走 AscendCL 这条路。PyTorch 接口封装了太多底层细节,出了问题你没法定位是 GE 编译的问题还是 Runtime 调度的问题。
效率对比:GE 优化前后的性能差异
| 优化项 | 未优化状态 | GE 优化后 | 提升来源 |
|---|---|---|---|
| Conv2D + BN + ReLU 三算子 | 3次全局内存读写 | 1次全局内存读写,中间数据走片上缓存 | 减少内存搬运 |
| 常量折叠 | 运行时重复计算固定值 | 编译期预计算,运行时零开销 | 消除冗余计算 |
| 内存复用 | 每个张量独占显存 | 生命周期不重叠的张量共享 buffer | 显存占用大幅降低 |
| 多流并行 | 串行执行无依赖算子 | 无依赖算子并行下发到不同 AI Core | 提高硬件利用率 |
需要强调的是,表格里的"大幅降低""显著提升"这些描述是定性判断,具体数值取决于模型结构和硬件型号。不同场景下 GE 的优化效果差异很大——计算密集型模型(如大语言模型的推理)本身内存搬运占比不高,融合收益有限;而轻量级模型(如 MobileNet)的瓶颈往往在内存带宽上,算子融合的收益非常明显。
TorchAir:PyTorch 模型接入 GE 的桥梁
前面讲了 GE 的工作原理,但作为 PyTorch 用户你大概率不会直接调用 GE 的接口。TorchAir 是昇腾提供的 PyTorch 扩展,它的作用是把 PyTorch 的动态图导出为 GE 可接受的静态图格式。
TorchAir 的核心接口是torch_air.export,它内部做的事情分三步:
第一步,用torch.jit.trace把 PyTorch 模型追踪成 TorchScript 图。这一步要求模型的 forward 方法不能有 Python 层面的控制流(if/else、for 循环),否则 trace 会丢失分支信息。
第二步,把 TorchScript 图转换成 GE 的 ComputeGraph 表示。这个转换过程会处理 PyTorch 算子和 GE 算子之间的映射关系——大部分算子是一一映射的,少数算子需要拆分或组合。比如 PyTorch 的nn.LayerNorm在 GE 端会被拆成 ReduceMean + Sub + Mul + Add 四个底层算子。
第三步,调用 GE 的编译接口,对 ComputeGraph 执行完整的优化和编译流程,最终输出 .om 文件。
import torch import torch_npu import torch_air # WHY: torch_air.export 的内部流程是 trace → 转GE图 → 编译 # 如果 trace 阶段失败,说明模型有动态控制流,需要改写为静态实现 model = SimpleModel() model.eval() dummy_input = torch.randn(1, 1024) # 导出为 GE 离线模型 torch_air.export( model, (dummy_input,), output_path="simple_model.om", dynamic_batch=True # WHY: 开启动态 batch 支持 # 编译时间会增加到 2-3 倍,但运行时可以接受任意 batch size ) # WHY: dynamic_batch=True 会让 GE 为每个可能的 batch 生成对应的子图 # 如果你的应用场景只有固定 batch,不要开这个选项,编译出来的模型更小更快WHY:这里用torch_air.export而不是torch.jit.save+acl.mdl.load,是因为 TorchAir 封装了 TorchScript 到 GE 图的转换逻辑,避免了手动处理算子映射的麻烦。如果你直接用torch.jit.save导出 TorchScript 模型然后手动加载到 GE,需要自己处理几十种算子的映射关系,工作量巨大且容易出错。
常见问题与排查思路
GE 相关的问题通常表现为三类:编译失败、推理结果异常、性能不达预期。
编译失败最常见的原因是算子不支持。GE 的算子库虽然在持续扩展,但不可能覆盖所有 PyTorch 算子。遇到不支持的算子,编译日志会报OpType xxx not supported。解决方案有两个:一是用等价的支持算子替换(比如用torch.nn.functional.hardswish替代自定义的 swish 实现);二是用 Ascend C 开发自定义算子并注册到 GE。
推理结果异常通常跟算子融合有关。fp16 模式下的融合可能改变累加顺序,导致结果跟 fp32 基线不一致。排查方法是逐个关闭融合规则,找到导致精度偏差的那个融合。前面提到的fusion_switch_file配置就是干这个的。
性能不达预期需要看 GE 的编译日志和 Runtime 的性能统计。编译日志里会记录每个 Pass 的执行情况和融合结果。Runtime 的acl.prof模块可以采集算子级别的执行时间。如果发现某个算子执行时间异常长,可能是调度不合理或者内存访问模式不友好,需要针对性地调整 GE 的编译选项。
还有一个容易被忽略的排查方向:GE 的编译缓存。CANN 默认会缓存编译结果到磁盘,当你修改了模型结构但缓存没有正确失效时,可能会加载到旧的编译结果,导致表现不符合预期。清除缓存的方法是删除~/.ascend/cache目录下的编译产物,然后重新编译。如果问题消失了,说明是缓存导致的不一致。
从 GE 的设计看昇腾软件栈的取舍
GE 选择静态图编译模式是一个明确的取舍——用灵活性换性能。这个选择跟昇腾 NPU 的硬件特性直接相关:昇腾 NPU 的 AI Core 采用达芬奇架构,擅长执行确定性的密集计算,不擅长处理运行时的动态分支。静态图编译让 GE 有机会做全局优化,把计算密度压到最高,充分发挥硬件能力。
代价是动态 shape 和控制流的处理比较笨拙。虽然近几个 CANN 版本在动态 shape 支持上做了大量改进,但跟 PyTorch 原生动态图比还是有差距。这也是为什么昇腾的训练场景推荐用 MindSpore(天然静态图),推理场景用 TorchAir 导出(先训练再编译)。
GE 的 Pass 机制与图分区策略
前面多次提到 Pass,这里展开讲讲 GE 的 Pass 机制是怎么运作的,以及跟 Pass 密切相关的图分区策略。Pass 是 GE 编译优化的基本执行单元,每个 Pass 实现一类特定的图变换逻辑。GE 的编译流程本质上就是一组 Pass 按顺序依次对 ComputeGraph 做变换。
Pass 分为两类:GraphFusion 和 OpFusion。GraphFusion 操作的是计算图的结构——合并节点、删除冗余边、替换子图。OpFusion 操作的是单个算子内部的执行逻辑——比如把两个 kernel 合并为一个,减少 launch 开销。
仓库地址:https://atomgit.com/cann/ge